From a54706a063a12baa039646f9bef7120ff7c4672c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 10 Jan 2026 17:44:03 +0100 Subject: [PATCH] fix: throttle cli credential sync --- CHANGELOG.md | 1 + src/agents/auth-profiles.ts | 42 ++++++++++++-- src/agents/cli-credentials.test.ts | 73 ++++++++++++++++++++++++- src/agents/cli-credentials.ts | 54 ++++++++++++++++++ src/auto-reply/reply/model-selection.ts | 4 +- 5 files changed, 168 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bfec10a30..8c0f45586 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Auto-reply: prefer `RawBody` for command/directive parsing (WhatsApp + Discord) and prevent fallback runs from clobbering concurrent session updates. (#643) — thanks @mcinteerj. - Agents/OpenAI: fix Responses tool-only → follow-up turn handling (avoid standalone `reasoning` items that trigger 400 “required following item”). - Auth: update Claude Code keychain credentials in-place during refresh sync; share JSON file helpers; add CLI fallback coverage. +- Auth: throttle external CLI credential syncs (Claude/Codex), reduce Keychain reads, and skip sync when cached credentials are still fresh. - Onboarding/Gateway: persist non-interactive gateway token auth in config; add WS wizard + gateway tool-calling regression coverage. - Gateway/Control UI: make `chat.send` non-blocking, wire Stop to `chat.abort`, and treat `/stop` as an out-of-band abort. (#653) - Gateway/Control UI: allow `chat.abort` without `runId` (abort active runs), suppress post-abort chat streaming, and prune stuck chat runs. (#653) diff --git a/src/agents/auth-profiles.ts b/src/agents/auth-profiles.ts index 076a7b2da..4016cde2f 100644 --- a/src/agents/auth-profiles.ts +++ b/src/agents/auth-profiles.ts @@ -16,8 +16,8 @@ import { createSubsystemLogger } from "../logging.js"; import { resolveUserPath } from "../utils.js"; import { resolveClawdbotAgentDir } from "./agent-paths.js"; import { - readClaudeCliCredentials, - readCodexCliCredentials, + readClaudeCliCredentialsCached, + readCodexCliCredentialsCached, writeClaudeCliCredentials, } from "./cli-credentials.js"; import { normalizeProviderId } from "./model-selection.js"; @@ -40,6 +40,9 @@ const AUTH_STORE_LOCK_OPTIONS = { stale: 30_000, } as const; +const EXTERNAL_CLI_SYNC_TTL_MS = 15 * 60 * 1000; +const EXTERNAL_CLI_NEAR_EXPIRY_MS = 10 * 60 * 1000; + const log = createSubsystemLogger("agents/auth-profiles"); export type ApiKeyCredential = { @@ -363,6 +366,19 @@ function shallowEqualTokenCredentials( ); } +function isExternalProfileFresh( + cred: AuthProfileCredential | undefined, + now: number, +): boolean { + if (!cred) return false; + if (cred.type !== "oauth" && cred.type !== "token") return false; + if (cred.provider !== "anthropic" && cred.provider !== "openai-codex") { + return false; + } + if (typeof cred.expires !== "number") return true; + return cred.expires > now + EXTERNAL_CLI_NEAR_EXPIRY_MS; +} + /** * Sync OAuth credentials from external CLI tools (Claude CLI, Codex CLI) into the store. * This allows clawdbot to use the same credentials as these tools without requiring @@ -378,7 +394,18 @@ function syncExternalCliCredentials( const now = Date.now(); // Sync from Claude CLI (supports both OAuth and Token credentials) - const claudeCreds = readClaudeCliCredentials(options); + const existingClaude = store.profiles[CLAUDE_CLI_PROFILE_ID]; + const shouldSyncClaude = + !existingClaude || + existingClaude.provider !== "anthropic" || + existingClaude.type === "token" || + !isExternalProfileFresh(existingClaude, now); + const claudeCreds = shouldSyncClaude + ? readClaudeCliCredentialsCached({ + allowKeychainPrompt: options?.allowKeychainPrompt, + ttlMs: EXTERNAL_CLI_SYNC_TTL_MS, + }) + : null; if (claudeCreds) { const existing = store.profiles[CLAUDE_CLI_PROFILE_ID]; const claudeCredsExpires = claudeCreds.expires ?? 0; @@ -438,7 +465,14 @@ function syncExternalCliCredentials( } // Sync from Codex CLI - const codexCreds = readCodexCliCredentials(); + const existingCodex = store.profiles[CODEX_CLI_PROFILE_ID]; + const shouldSyncCodex = + !existingCodex || + existingCodex.provider !== ("openai-codex" as OAuthProvider) || + !isExternalProfileFresh(existingCodex, now); + const codexCreds = shouldSyncCodex + ? readCodexCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }) + : null; if (codexCreds) { const existing = store.profiles[CODEX_CLI_PROFILE_ID]; const existingOAuth = existing?.type === "oauth" ? existing : undefined; diff --git a/src/agents/cli-credentials.test.ts b/src/agents/cli-credentials.test.ts index 4cbed14ac..ff6f6c4dd 100644 --- a/src/agents/cli-credentials.test.ts +++ b/src/agents/cli-credentials.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const execSyncMock = vi.hoisted(() => vi.fn()); @@ -11,7 +11,13 @@ vi.mock("node:child_process", () => ({ })); describe("cli credentials", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + vi.resetModules(); execSyncMock.mockReset(); }); @@ -109,4 +115,69 @@ describe("cli credentials", () => { expect(updated.claudeAiOauth?.refreshToken).toBe("new-refresh"); expect(updated.claudeAiOauth?.expiresAt).toBeTypeOf("number"); }); + + it("caches Claude 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 { readClaudeCliCredentialsCached } = await import( + "./cli-credentials.js" + ); + + const first = readClaudeCliCredentialsCached({ + allowKeychainPrompt: true, + ttlMs: 15 * 60 * 1000, + }); + const second = readClaudeCliCredentialsCached({ + allowKeychainPrompt: false, + ttlMs: 15 * 60 * 1000, + }); + + expect(first).toBeTruthy(); + expect(second).toEqual(first); + expect(execSyncMock).toHaveBeenCalledTimes(1); + }); + + it("refreshes Claude 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 { readClaudeCliCredentialsCached } = await import( + "./cli-credentials.js" + ); + + const first = readClaudeCliCredentialsCached({ + allowKeychainPrompt: true, + ttlMs: 15 * 60 * 1000, + }); + + vi.advanceTimersByTime(15 * 60 * 1000 + 1); + + const second = readClaudeCliCredentialsCached({ + allowKeychainPrompt: true, + ttlMs: 15 * 60 * 1000, + }); + + expect(first).toBeTruthy(); + expect(second).toBeTruthy(); + expect(execSyncMock).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/agents/cli-credentials.ts b/src/agents/cli-credentials.ts index 147ffe906..8ce0f8758 100644 --- a/src/agents/cli-credentials.ts +++ b/src/agents/cli-credentials.ts @@ -16,6 +16,15 @@ const CODEX_CLI_AUTH_RELATIVE_PATH = ".codex/auth.json"; const CLAUDE_CLI_KEYCHAIN_SERVICE = "Claude Code-credentials"; const CLAUDE_CLI_KEYCHAIN_ACCOUNT = "Claude Code"; +type CachedValue = { + value: T | null; + readAt: number; + cacheKey: string; +}; + +let claudeCliCache: CachedValue | null = null; +let codexCliCache: CachedValue | null = null; + export type ClaudeCliCredential = | { type: "oauth"; @@ -146,6 +155,30 @@ export function readClaudeCliCredentials(options?: { }; } +export function readClaudeCliCredentialsCached(options?: { + allowKeychainPrompt?: boolean; + ttlMs?: number; +}): ClaudeCliCredential | null { + const ttlMs = options?.ttlMs ?? 0; + const now = Date.now(); + const cacheKey = resolveClaudeCliCredentialsPath(); + if ( + ttlMs > 0 && + claudeCliCache && + claudeCliCache.cacheKey === cacheKey && + now - claudeCliCache.readAt < ttlMs + ) { + return claudeCliCache.value; + } + const value = readClaudeCliCredentials({ + allowKeychainPrompt: options?.allowKeychainPrompt, + }); + if (ttlMs > 0) { + claudeCliCache = { value, readAt: now, cacheKey }; + } + return value; +} + export function writeClaudeCliKeychainCredentials( newCredentials: OAuthCredentials, ): boolean { @@ -280,3 +313,24 @@ export function readCodexCliCredentials(): CodexCliCredential | null { expires, }; } + +export function readCodexCliCredentialsCached(options?: { + ttlMs?: number; +}): CodexCliCredential | null { + const ttlMs = options?.ttlMs ?? 0; + const now = Date.now(); + const cacheKey = resolveCodexCliAuthPath(); + if ( + ttlMs > 0 && + codexCliCache && + codexCliCache.cacheKey === cacheKey && + now - codexCliCache.readAt < ttlMs + ) { + return codexCliCache.value; + } + const value = readCodexCliCredentials(); + if (ttlMs > 0) { + codexCliCache = { value, readAt: now, cacheKey }; + } + return value; +} diff --git a/src/auto-reply/reply/model-selection.ts b/src/auto-reply/reply/model-selection.ts index 37b290309..d0733e273 100644 --- a/src/auto-reply/reply/model-selection.ts +++ b/src/auto-reply/reply/model-selection.ts @@ -124,7 +124,9 @@ export async function createModelSelectionState(params: { const { ensureAuthProfileStore } = await import( "../../agents/auth-profiles.js" ); - const store = ensureAuthProfileStore(); + const store = ensureAuthProfileStore(undefined, { + allowKeychainPrompt: false, + }); const profile = store.profiles[sessionEntry.authProfileOverride]; if (!profile || profile.provider !== provider) { delete sessionEntry.authProfileOverride;