fix(agents): reuse cached Claude keychain credentials

This commit is contained in:
Peter Steinberger
2026-04-28 20:35:58 +01:00
parent aec5efed8d
commit 1824ceba54
2 changed files with 76 additions and 4 deletions

View File

@@ -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;

View File

@@ -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,
}),