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.
|
- 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”).
|
- 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: 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.
|
- 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: 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)
|
- 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 { resolveUserPath } from "../utils.js";
|
||||||
import { resolveClawdbotAgentDir } from "./agent-paths.js";
|
import { resolveClawdbotAgentDir } from "./agent-paths.js";
|
||||||
import {
|
import {
|
||||||
readClaudeCliCredentials,
|
readClaudeCliCredentialsCached,
|
||||||
readCodexCliCredentials,
|
readCodexCliCredentialsCached,
|
||||||
writeClaudeCliCredentials,
|
writeClaudeCliCredentials,
|
||||||
} from "./cli-credentials.js";
|
} from "./cli-credentials.js";
|
||||||
import { normalizeProviderId } from "./model-selection.js";
|
import { normalizeProviderId } from "./model-selection.js";
|
||||||
@@ -40,6 +40,9 @@ const AUTH_STORE_LOCK_OPTIONS = {
|
|||||||
stale: 30_000,
|
stale: 30_000,
|
||||||
} as const;
|
} 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");
|
const log = createSubsystemLogger("agents/auth-profiles");
|
||||||
|
|
||||||
export type ApiKeyCredential = {
|
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.
|
* 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
|
* This allows clawdbot to use the same credentials as these tools without requiring
|
||||||
@@ -378,7 +394,18 @@ function syncExternalCliCredentials(
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
// Sync from Claude CLI (supports both OAuth and Token credentials)
|
// 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) {
|
if (claudeCreds) {
|
||||||
const existing = store.profiles[CLAUDE_CLI_PROFILE_ID];
|
const existing = store.profiles[CLAUDE_CLI_PROFILE_ID];
|
||||||
const claudeCredsExpires = claudeCreds.expires ?? 0;
|
const claudeCredsExpires = claudeCreds.expires ?? 0;
|
||||||
@@ -438,7 +465,14 @@ function syncExternalCliCredentials(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sync from Codex CLI
|
// 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) {
|
if (codexCreds) {
|
||||||
const existing = store.profiles[CODEX_CLI_PROFILE_ID];
|
const existing = store.profiles[CODEX_CLI_PROFILE_ID];
|
||||||
const existingOAuth = existing?.type === "oauth" ? existing : undefined;
|
const existingOAuth = existing?.type === "oauth" ? existing : undefined;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
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());
|
const execSyncMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
@@ -11,7 +11,13 @@ vi.mock("node:child_process", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe("cli credentials", () => {
|
describe("cli credentials", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.resetModules();
|
||||||
execSyncMock.mockReset();
|
execSyncMock.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -109,4 +115,69 @@ describe("cli credentials", () => {
|
|||||||
expect(updated.claudeAiOauth?.refreshToken).toBe("new-refresh");
|
expect(updated.claudeAiOauth?.refreshToken).toBe("new-refresh");
|
||||||
expect(updated.claudeAiOauth?.expiresAt).toBeTypeOf("number");
|
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_SERVICE = "Claude Code-credentials";
|
||||||
const CLAUDE_CLI_KEYCHAIN_ACCOUNT = "Claude Code";
|
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 =
|
export type ClaudeCliCredential =
|
||||||
| {
|
| {
|
||||||
type: "oauth";
|
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(
|
export function writeClaudeCliKeychainCredentials(
|
||||||
newCredentials: OAuthCredentials,
|
newCredentials: OAuthCredentials,
|
||||||
): boolean {
|
): boolean {
|
||||||
@@ -280,3 +313,24 @@ export function readCodexCliCredentials(): CodexCliCredential | null {
|
|||||||
expires,
|
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(
|
const { ensureAuthProfileStore } = await import(
|
||||||
"../../agents/auth-profiles.js"
|
"../../agents/auth-profiles.js"
|
||||||
);
|
);
|
||||||
const store = ensureAuthProfileStore();
|
const store = ensureAuthProfileStore(undefined, {
|
||||||
|
allowKeychainPrompt: false,
|
||||||
|
});
|
||||||
const profile = store.profiles[sessionEntry.authProfileOverride];
|
const profile = store.profiles[sessionEntry.authProfileOverride];
|
||||||
if (!profile || profile.provider !== provider) {
|
if (!profile || profile.provider !== provider) {
|
||||||
delete sessionEntry.authProfileOverride;
|
delete sessionEntry.authProfileOverride;
|
||||||
|
|||||||
Reference in New Issue
Block a user