diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a3d4c946..f737ad3f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ ### Fixes - 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; extract CLI sync helpers + coverage. +- Auth: update Claude Code keychain credentials in-place during refresh sync; share JSON file helpers; add CLI fallback coverage. - Onboarding/Gateway: persist non-interactive gateway token auth in config; add WS wizard + gateway tool-calling regression coverage. - CLI: `clawdbot sessions` now includes `elev:*` + `usage:*` flags in the table output. - CLI/Pairing: accept positional provider for `pairing list|approve` (npm-run compatible); update docs/bot hints. diff --git a/src/agents/auth-profiles.ts b/src/agents/auth-profiles.ts index f752d302a..076a7b2da 100644 --- a/src/agents/auth-profiles.ts +++ b/src/agents/auth-profiles.ts @@ -11,6 +11,7 @@ import lockfile from "proper-lockfile"; import type { ClawdbotConfig } from "../config/config.js"; import { resolveOAuthPath } from "../config/paths.js"; import type { AuthProfileConfig } from "../config/types.js"; +import { loadJsonFile, saveJsonFile } from "../infra/json-file.js"; import { createSubsystemLogger } from "../logging.js"; import { resolveUserPath } from "../utils.js"; import { resolveClawdbotAgentDir } from "./agent-paths.js"; @@ -117,25 +118,6 @@ function resolveLegacyAuthStorePath(agentDir?: string): string { return path.join(resolved, LEGACY_AUTH_FILENAME); } -function loadJsonFile(pathname: string): unknown { - try { - if (!fs.existsSync(pathname)) return undefined; - const raw = fs.readFileSync(pathname, "utf8"); - return JSON.parse(raw) as unknown; - } catch { - return undefined; - } -} - -function saveJsonFile(pathname: string, data: unknown) { - const dir = path.dirname(pathname); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); - } - fs.writeFileSync(pathname, `${JSON.stringify(data, null, 2)}\n`, "utf8"); - fs.chmodSync(pathname, 0o600); -} - function ensureAuthStoreFile(pathname: string) { if (fs.existsSync(pathname)) return; const payload: AuthProfileStore = { diff --git a/src/agents/cli-credentials.test.ts b/src/agents/cli-credentials.test.ts index cde2eda7e..4cbed14ac 100644 --- a/src/agents/cli-credentials.test.ts +++ b/src/agents/cli-credentials.test.ts @@ -1,3 +1,7 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + import { afterEach, describe, expect, it, vi } from "vitest"; const execSyncMock = vi.hoisted(() => vi.fn()); @@ -51,4 +55,58 @@ describe("cli credentials", () => { ); expect(updateCommand).toContain("-U"); }); + + it("falls back to the file store when the keychain update fails", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-")); + const credPath = path.join(tempDir, ".claude", ".credentials.json"); + + fs.mkdirSync(path.dirname(credPath), { recursive: true, mode: 0o700 }); + fs.writeFileSync( + credPath, + `${JSON.stringify( + { + claudeAiOauth: { + accessToken: "old-access", + refreshToken: "old-refresh", + expiresAt: Date.now() + 60_000, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + + const writeKeychain = vi.fn(() => false); + + const { writeClaudeCliCredentials } = await import("./cli-credentials.js"); + + const ok = writeClaudeCliCredentials( + { + access: "new-access", + refresh: "new-refresh", + expires: Date.now() + 120_000, + }, + { + platform: "darwin", + homeDir: tempDir, + writeKeychain, + }, + ); + + expect(ok).toBe(true); + expect(writeKeychain).toHaveBeenCalledTimes(1); + + const updated = JSON.parse(fs.readFileSync(credPath, "utf8")) as { + claudeAiOauth?: { + accessToken?: string; + refreshToken?: string; + expiresAt?: number; + }; + }; + + expect(updated.claudeAiOauth?.accessToken).toBe("new-access"); + expect(updated.claudeAiOauth?.refreshToken).toBe("new-refresh"); + expect(updated.claudeAiOauth?.expiresAt).toBeTypeOf("number"); + }); }); diff --git a/src/agents/cli-credentials.ts b/src/agents/cli-credentials.ts index f045eb825..147ffe906 100644 --- a/src/agents/cli-credentials.ts +++ b/src/agents/cli-credentials.ts @@ -4,6 +4,7 @@ import path from "node:path"; import type { OAuthCredentials, OAuthProvider } from "@mariozechner/pi-ai"; +import { loadJsonFile, saveJsonFile } from "../infra/json-file.js"; import { createSubsystemLogger } from "../logging.js"; import { resolveUserPath } from "../utils.js"; @@ -38,23 +39,26 @@ export type CodexCliCredential = { expires: number; }; -function loadJsonFile(pathname: string): unknown { - try { - if (!fs.existsSync(pathname)) return undefined; - const raw = fs.readFileSync(pathname, "utf8"); - return JSON.parse(raw) as unknown; - } catch { - return undefined; - } +type ClaudeCliFileOptions = { + homeDir?: string; +}; + +type ClaudeCliWriteOptions = ClaudeCliFileOptions & { + platform?: NodeJS.Platform; + writeKeychain?: (credentials: OAuthCredentials) => boolean; + writeFile?: ( + credentials: OAuthCredentials, + options?: ClaudeCliFileOptions, + ) => boolean; +}; + +function resolveClaudeCliCredentialsPath(homeDir?: string) { + const baseDir = homeDir ?? resolveUserPath("~"); + return path.join(baseDir, CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH); } -function saveJsonFile(pathname: string, data: unknown) { - const dir = path.dirname(pathname); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); - } - fs.writeFileSync(pathname, `${JSON.stringify(data, null, 2)}\n`, "utf8"); - fs.chmodSync(pathname, 0o600); +function resolveCodexCliAuthPath() { + return path.join(resolveUserPath("~"), CODEX_CLI_AUTH_RELATIVE_PATH); } function readClaudeCliKeychainCredentials(): ClaudeCliCredential | null { @@ -109,10 +113,7 @@ export function readClaudeCliCredentials(options?: { } } - const credPath = path.join( - resolveUserPath("~"), - CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH, - ); + const credPath = resolveClaudeCliCredentialsPath(); const raw = loadJsonFile(credPath); if (!raw || typeof raw !== "object") return null; @@ -188,11 +189,9 @@ export function writeClaudeCliKeychainCredentials( export function writeClaudeCliFileCredentials( newCredentials: OAuthCredentials, + options?: ClaudeCliFileOptions, ): boolean { - const credPath = path.join( - resolveUserPath("~"), - CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH, - ); + const credPath = resolveClaudeCliCredentialsPath(options?.homeDir); if (!fs.existsSync(credPath)) { return false; @@ -230,22 +229,28 @@ export function writeClaudeCliFileCredentials( export function writeClaudeCliCredentials( newCredentials: OAuthCredentials, + options?: ClaudeCliWriteOptions, ): boolean { - if (process.platform === "darwin") { - const didWriteKeychain = writeClaudeCliKeychainCredentials(newCredentials); + const platform = options?.platform ?? process.platform; + const writeKeychain = + options?.writeKeychain ?? writeClaudeCliKeychainCredentials; + const writeFile = + options?.writeFile ?? + ((credentials, fileOptions) => + writeClaudeCliFileCredentials(credentials, fileOptions)); + + if (platform === "darwin") { + const didWriteKeychain = writeKeychain(newCredentials); if (didWriteKeychain) { return true; } } - return writeClaudeCliFileCredentials(newCredentials); + return writeFile(newCredentials, { homeDir: options?.homeDir }); } export function readCodexCliCredentials(): CodexCliCredential | null { - const authPath = path.join( - resolveUserPath("~"), - CODEX_CLI_AUTH_RELATIVE_PATH, - ); + const authPath = resolveCodexCliAuthPath(); const raw = loadJsonFile(authPath); if (!raw || typeof raw !== "object") return null; diff --git a/src/infra/json-file.ts b/src/infra/json-file.ts new file mode 100644 index 000000000..19c8169f7 --- /dev/null +++ b/src/infra/json-file.ts @@ -0,0 +1,21 @@ +import fs from "node:fs"; +import path from "node:path"; + +export function loadJsonFile(pathname: string): unknown { + try { + if (!fs.existsSync(pathname)) return undefined; + const raw = fs.readFileSync(pathname, "utf8"); + return JSON.parse(raw) as unknown; + } catch { + return undefined; + } +} + +export function saveJsonFile(pathname: string, data: unknown) { + const dir = path.dirname(pathname); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + fs.writeFileSync(pathname, `${JSON.stringify(data, null, 2)}\n`, "utf8"); + fs.chmodSync(pathname, 0o600); +}