diff --git a/src/agents/cli-credentials.test.ts b/src/agents/cli-credentials.test.ts index 64437d1d6f2..bc1f00a4cda 100644 --- a/src/agents/cli-credentials.test.ts +++ b/src/agents/cli-credentials.test.ts @@ -205,7 +205,7 @@ describe("cli credentials", () => { it.each([ { name: "caches Claude Code CLI credentials within the TTL window", - allowKeychainPromptSecondRead: false, + allowKeychainPromptSecondRead: true, advanceMs: 0, expectedCalls: 1, expectSameObject: true, @@ -240,6 +240,62 @@ describe("cli credentials", () => { }, ); + it("does not let no-keychain Claude cache misses poison keychain reads", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-claude-cache-")); + vi.setSystemTime(new Date("2025-01-01T00:00:00Z")); + + const withoutKeychain = readClaudeCliCredentialsCached({ + allowKeychainPrompt: false, + ttlMs: CLI_CREDENTIALS_CACHE_TTL_MS, + platform: "darwin", + homeDir: tempDir, + execSync: execSyncMock, + }); + + expect(withoutKeychain).toBeNull(); + expect(execSyncMock).not.toHaveBeenCalled(); + + mockClaudeCliCredentialRead(); + const withKeychain = readClaudeCliCredentialsCached({ + allowKeychainPrompt: true, + ttlMs: CLI_CREDENTIALS_CACHE_TTL_MS, + platform: "darwin", + homeDir: tempDir, + execSync: execSyncMock, + }); + + expect(withKeychain).toMatchObject({ + type: "oauth", + provider: "anthropic", + refresh: "cached-refresh", + }); + expect(execSyncMock).toHaveBeenCalledTimes(1); + }); + + it("reuses cached Claude keychain credentials for no-prompt reads", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-claude-cache-")); + vi.setSystemTime(new Date("2025-01-01T00:00:00Z")); + mockClaudeCliCredentialRead(); + + const withKeychain = readClaudeCliCredentialsCached({ + allowKeychainPrompt: true, + ttlMs: CLI_CREDENTIALS_CACHE_TTL_MS, + platform: "darwin", + homeDir: tempDir, + execSync: execSyncMock, + }); + const withoutPrompt = readClaudeCliCredentialsCached({ + allowKeychainPrompt: false, + ttlMs: CLI_CREDENTIALS_CACHE_TTL_MS, + platform: "darwin", + homeDir: tempDir, + execSync: execSyncMock, + }); + + expect(withoutPrompt).toEqual(withKeychain); + expect(execSyncMock).toHaveBeenCalledTimes(1); + }); + it("reads Codex credentials from keychain when available", async () => { const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-codex-")); process.env.CODEX_HOME = tempHome; diff --git a/src/agents/cli-credentials.ts b/src/agents/cli-credentials.ts index cbe09c65286..fe6ba1c29b8 100644 --- a/src/agents/cli-credentials.ts +++ b/src/agents/cli-credentials.ts @@ -455,14 +455,30 @@ export function readClaudeCliCredentialsCached(options?: { homeDir?: string; execSync?: ExecSyncFn; }): ClaudeCliCredential | null { + const platform = options?.platform ?? process.platform; + const ttlMs = options?.ttlMs ?? 0; + const credentialsPath = resolveClaudeCliCredentialsPath(options?.homeDir); + const keychainCacheKey = `${credentialsPath}:keychain`; + if ( + ttlMs > 0 && + platform === "darwin" && + options?.allowKeychainPrompt === false && + claudeCliCache?.cacheKey === keychainCacheKey && + claudeCliCache.value && + Date.now() - claudeCliCache.readAt < ttlMs + ) { + return claudeCliCache.value; + } + const keychainIntent = + platform === "darwin" && options?.allowKeychainPrompt !== false ? "keychain" : "file"; return readCachedCliCredential({ - ttlMs: options?.ttlMs ?? 0, + ttlMs, cache: claudeCliCache, - cacheKey: resolveClaudeCliCredentialsPath(options?.homeDir), + cacheKey: `${credentialsPath}:${keychainIntent}`, read: () => readClaudeCliCredentials({ allowKeychainPrompt: options?.allowKeychainPrompt, - platform: options?.platform, + platform, homeDir: options?.homeDir, execSync: options?.execSync, }),