fix: harden cli credential sync

This commit is contained in:
Peter Steinberger
2026-01-10 16:37:49 +01:00
parent 81f9093c3c
commit 8978ac425e
5 changed files with 116 additions and 50 deletions

View File

@@ -7,7 +7,7 @@
### Fixes ### Fixes
- 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; 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. - 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: `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. - CLI/Pairing: accept positional provider for `pairing list|approve` (npm-run compatible); update docs/bot hints.

View File

@@ -11,6 +11,7 @@ import lockfile from "proper-lockfile";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { resolveOAuthPath } from "../config/paths.js"; import { resolveOAuthPath } from "../config/paths.js";
import type { AuthProfileConfig } from "../config/types.js"; import type { AuthProfileConfig } from "../config/types.js";
import { loadJsonFile, saveJsonFile } from "../infra/json-file.js";
import { createSubsystemLogger } from "../logging.js"; 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";
@@ -117,25 +118,6 @@ function resolveLegacyAuthStorePath(agentDir?: string): string {
return path.join(resolved, LEGACY_AUTH_FILENAME); 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) { function ensureAuthStoreFile(pathname: string) {
if (fs.existsSync(pathname)) return; if (fs.existsSync(pathname)) return;
const payload: AuthProfileStore = { const payload: AuthProfileStore = {

View File

@@ -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"; import { afterEach, describe, expect, it, vi } from "vitest";
const execSyncMock = vi.hoisted(() => vi.fn()); const execSyncMock = vi.hoisted(() => vi.fn());
@@ -51,4 +55,58 @@ describe("cli credentials", () => {
); );
expect(updateCommand).toContain("-U"); 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");
});
}); });

View File

@@ -4,6 +4,7 @@ import path from "node:path";
import type { OAuthCredentials, OAuthProvider } from "@mariozechner/pi-ai"; import type { OAuthCredentials, OAuthProvider } from "@mariozechner/pi-ai";
import { loadJsonFile, saveJsonFile } from "../infra/json-file.js";
import { createSubsystemLogger } from "../logging.js"; import { createSubsystemLogger } from "../logging.js";
import { resolveUserPath } from "../utils.js"; import { resolveUserPath } from "../utils.js";
@@ -38,23 +39,26 @@ export type CodexCliCredential = {
expires: number; expires: number;
}; };
function loadJsonFile(pathname: string): unknown { type ClaudeCliFileOptions = {
try { homeDir?: string;
if (!fs.existsSync(pathname)) return undefined; };
const raw = fs.readFileSync(pathname, "utf8");
return JSON.parse(raw) as unknown; type ClaudeCliWriteOptions = ClaudeCliFileOptions & {
} catch { platform?: NodeJS.Platform;
return undefined; 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) { function resolveCodexCliAuthPath() {
const dir = path.dirname(pathname); return path.join(resolveUserPath("~"), CODEX_CLI_AUTH_RELATIVE_PATH);
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 readClaudeCliKeychainCredentials(): ClaudeCliCredential | null { function readClaudeCliKeychainCredentials(): ClaudeCliCredential | null {
@@ -109,10 +113,7 @@ export function readClaudeCliCredentials(options?: {
} }
} }
const credPath = path.join( const credPath = resolveClaudeCliCredentialsPath();
resolveUserPath("~"),
CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH,
);
const raw = loadJsonFile(credPath); const raw = loadJsonFile(credPath);
if (!raw || typeof raw !== "object") return null; if (!raw || typeof raw !== "object") return null;
@@ -188,11 +189,9 @@ export function writeClaudeCliKeychainCredentials(
export function writeClaudeCliFileCredentials( export function writeClaudeCliFileCredentials(
newCredentials: OAuthCredentials, newCredentials: OAuthCredentials,
options?: ClaudeCliFileOptions,
): boolean { ): boolean {
const credPath = path.join( const credPath = resolveClaudeCliCredentialsPath(options?.homeDir);
resolveUserPath("~"),
CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH,
);
if (!fs.existsSync(credPath)) { if (!fs.existsSync(credPath)) {
return false; return false;
@@ -230,22 +229,28 @@ export function writeClaudeCliFileCredentials(
export function writeClaudeCliCredentials( export function writeClaudeCliCredentials(
newCredentials: OAuthCredentials, newCredentials: OAuthCredentials,
options?: ClaudeCliWriteOptions,
): boolean { ): boolean {
if (process.platform === "darwin") { const platform = options?.platform ?? process.platform;
const didWriteKeychain = writeClaudeCliKeychainCredentials(newCredentials); const writeKeychain =
options?.writeKeychain ?? writeClaudeCliKeychainCredentials;
const writeFile =
options?.writeFile ??
((credentials, fileOptions) =>
writeClaudeCliFileCredentials(credentials, fileOptions));
if (platform === "darwin") {
const didWriteKeychain = writeKeychain(newCredentials);
if (didWriteKeychain) { if (didWriteKeychain) {
return true; return true;
} }
} }
return writeClaudeCliFileCredentials(newCredentials); return writeFile(newCredentials, { homeDir: options?.homeDir });
} }
export function readCodexCliCredentials(): CodexCliCredential | null { export function readCodexCliCredentials(): CodexCliCredential | null {
const authPath = path.join( const authPath = resolveCodexCliAuthPath();
resolveUserPath("~"),
CODEX_CLI_AUTH_RELATIVE_PATH,
);
const raw = loadJsonFile(authPath); const raw = loadJsonFile(authPath);
if (!raw || typeof raw !== "object") return null; if (!raw || typeof raw !== "object") return null;

21
src/infra/json-file.ts Normal file
View File

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