fix: throttle cli credential sync
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user