fix: throttle cli credential sync

This commit is contained in:
Peter Steinberger
2026-01-10 17:44:03 +01:00
parent 6cc8570369
commit a54706a063
5 changed files with 168 additions and 6 deletions

View File

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

View File

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

View File

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

View File

@@ -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<T> = {
value: T | null;
readAt: number;
cacheKey: string;
};
let claudeCliCache: CachedValue<ClaudeCliCredential> | null = null;
let codexCliCache: CachedValue<CodexCliCredential> | 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;
}

View File

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