fix: harden cli credential sync
This commit is contained in:
@@ -4,6 +4,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.
|
||||
- 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.
|
||||
- Branding: normalize user-facing “ClawdBot”/“CLAWDBOT” → “Clawdbot” (CLI, status, docs).
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
@@ -15,16 +14,17 @@ import type { AuthProfileConfig } from "../config/types.js";
|
||||
import { createSubsystemLogger } from "../logging.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { resolveClawdbotAgentDir } from "./agent-paths.js";
|
||||
import {
|
||||
readClaudeCliCredentials,
|
||||
readCodexCliCredentials,
|
||||
writeClaudeCliCredentials,
|
||||
} from "./cli-credentials.js";
|
||||
import { normalizeProviderId } from "./model-selection.js";
|
||||
|
||||
const AUTH_STORE_VERSION = 1;
|
||||
const AUTH_PROFILE_FILENAME = "auth-profiles.json";
|
||||
const LEGACY_AUTH_FILENAME = "auth.json";
|
||||
|
||||
// External CLI credential file locations
|
||||
const CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH = ".claude/.credentials.json";
|
||||
const CODEX_CLI_AUTH_RELATIVE_PATH = ".codex/auth.json";
|
||||
|
||||
export const CLAUDE_CLI_PROFILE_ID = "anthropic:claude-cli";
|
||||
export const CODEX_CLI_PROFILE_ID = "openai-codex:codex-cli";
|
||||
|
||||
@@ -136,133 +136,6 @@ function saveJsonFile(pathname: string, data: unknown) {
|
||||
fs.chmodSync(pathname, 0o600);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write refreshed OAuth credentials back to Claude CLI's credential storage.
|
||||
* This ensures Claude Code continues to work after ClawdBot refreshes the token.
|
||||
*
|
||||
* On macOS: Updates keychain entry "Claude Code-credentials" (primary storage).
|
||||
* On Linux/Windows: Updates ~/.claude/.credentials.json file.
|
||||
*
|
||||
* Only writes if Claude CLI credentials exist (Claude Code is installed).
|
||||
*/
|
||||
function writeClaudeCliCredentials(newCredentials: OAuthCredentials): boolean {
|
||||
// On macOS, Claude Code uses keychain as primary storage
|
||||
if (process.platform === "darwin") {
|
||||
return writeClaudeCliKeychainCredentials(newCredentials);
|
||||
}
|
||||
|
||||
// On Linux/Windows, use file storage
|
||||
return writeClaudeCliFileCredentials(newCredentials);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write credentials to macOS keychain.
|
||||
*/
|
||||
function writeClaudeCliKeychainCredentials(
|
||||
newCredentials: OAuthCredentials,
|
||||
): boolean {
|
||||
try {
|
||||
// First read existing keychain entry to preserve other fields
|
||||
const existingResult = execSync(
|
||||
'security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null',
|
||||
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
|
||||
);
|
||||
|
||||
const existingData = JSON.parse(existingResult.trim());
|
||||
const existingOauth = existingData?.claudeAiOauth;
|
||||
if (!existingOauth || typeof existingOauth !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update with new tokens while preserving other fields
|
||||
existingData.claudeAiOauth = {
|
||||
...existingOauth,
|
||||
accessToken: newCredentials.access,
|
||||
refreshToken: newCredentials.refresh,
|
||||
expiresAt: newCredentials.expires,
|
||||
};
|
||||
|
||||
const newValue = JSON.stringify(existingData);
|
||||
|
||||
// Delete old entry and add new one (keychain doesn't support update)
|
||||
try {
|
||||
execSync(
|
||||
'security delete-generic-password -s "Claude Code-credentials"',
|
||||
{
|
||||
encoding: "utf8",
|
||||
timeout: 5000,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
// Entry might not exist, continue
|
||||
}
|
||||
|
||||
execSync(
|
||||
`security add-generic-password -s "Claude Code-credentials" -a "Claude Code" -w '${newValue.replace(/'/g, "'\"'\"'")}'`,
|
||||
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
|
||||
);
|
||||
|
||||
log.info("wrote refreshed credentials to claude cli keychain", {
|
||||
expires: new Date(newCredentials.expires).toISOString(),
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
log.warn("failed to write credentials to claude cli keychain", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
// Fall back to file storage on macOS
|
||||
return writeClaudeCliFileCredentials(newCredentials);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write credentials to file storage (~/.claude/.credentials.json).
|
||||
*/
|
||||
function writeClaudeCliFileCredentials(
|
||||
newCredentials: OAuthCredentials,
|
||||
): boolean {
|
||||
const credPath = path.join(
|
||||
resolveUserPath("~"),
|
||||
CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH,
|
||||
);
|
||||
|
||||
// Only update if Claude CLI credentials file exists
|
||||
if (!fs.existsSync(credPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = loadJsonFile(credPath);
|
||||
if (!raw || typeof raw !== "object") return false;
|
||||
|
||||
const data = raw as Record<string, unknown>;
|
||||
const existingOauth = data.claudeAiOauth as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (!existingOauth || typeof existingOauth !== "object") return false;
|
||||
|
||||
// Update with new tokens while preserving other fields (scopes, subscriptionType, etc.)
|
||||
data.claudeAiOauth = {
|
||||
...existingOauth,
|
||||
accessToken: newCredentials.access,
|
||||
refreshToken: newCredentials.refresh,
|
||||
expiresAt: newCredentials.expires,
|
||||
};
|
||||
|
||||
saveJsonFile(credPath, data);
|
||||
log.info("wrote refreshed credentials to claude cli file", {
|
||||
expires: new Date(newCredentials.expires).toISOString(),
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
log.warn("failed to write credentials to claude cli file", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureAuthStoreFile(pathname: string) {
|
||||
if (fs.existsSync(pathname)) return;
|
||||
const payload: AuthProfileStore = {
|
||||
@@ -476,159 +349,6 @@ function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean {
|
||||
return mutated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read Anthropic OAuth credentials from Claude CLI's keychain entry (macOS)
|
||||
* or credential file (Linux/Windows).
|
||||
*
|
||||
* On macOS, Claude Code stores credentials in keychain "Claude Code-credentials".
|
||||
* On Linux/Windows, it uses ~/.claude/.credentials.json
|
||||
*
|
||||
* Returns OAuthCredential when refreshToken is available (enables auto-refresh),
|
||||
* or TokenCredential as fallback for backward compatibility.
|
||||
*/
|
||||
function readClaudeCliCredentials(options?: {
|
||||
allowKeychainPrompt?: boolean;
|
||||
}): OAuthCredential | TokenCredential | null {
|
||||
if (process.platform === "darwin" && options?.allowKeychainPrompt !== false) {
|
||||
const keychainCreds = readClaudeCliKeychainCredentials();
|
||||
if (keychainCreds) {
|
||||
log.info("read anthropic credentials from claude cli keychain", {
|
||||
type: keychainCreds.type,
|
||||
});
|
||||
return keychainCreds;
|
||||
}
|
||||
}
|
||||
|
||||
const credPath = path.join(
|
||||
resolveUserPath("~"),
|
||||
CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH,
|
||||
);
|
||||
const raw = loadJsonFile(credPath);
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
|
||||
const data = raw as Record<string, unknown>;
|
||||
const claudeOauth = data.claudeAiOauth as Record<string, unknown> | undefined;
|
||||
if (!claudeOauth || typeof claudeOauth !== "object") return null;
|
||||
|
||||
const accessToken = claudeOauth.accessToken;
|
||||
const refreshToken = claudeOauth.refreshToken;
|
||||
const expiresAt = claudeOauth.expiresAt;
|
||||
|
||||
if (typeof accessToken !== "string" || !accessToken) return null;
|
||||
if (typeof expiresAt !== "number" || expiresAt <= 0) return null;
|
||||
|
||||
// Return OAuthCredential when refreshToken is available (enables auto-refresh)
|
||||
if (typeof refreshToken === "string" && refreshToken) {
|
||||
return {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: accessToken,
|
||||
refresh: refreshToken,
|
||||
expires: expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback to TokenCredential for backward compatibility (no auto-refresh)
|
||||
return {
|
||||
type: "token",
|
||||
provider: "anthropic",
|
||||
token: accessToken,
|
||||
expires: expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read Claude Code credentials from macOS keychain.
|
||||
* Uses the `security` CLI to access keychain without native dependencies.
|
||||
*
|
||||
* Returns OAuthCredential when refreshToken is available (enables auto-refresh),
|
||||
* or TokenCredential as fallback for backward compatibility.
|
||||
*/
|
||||
function readClaudeCliKeychainCredentials():
|
||||
| OAuthCredential
|
||||
| TokenCredential
|
||||
| null {
|
||||
try {
|
||||
const result = execSync(
|
||||
'security find-generic-password -s "Claude Code-credentials" -w',
|
||||
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
|
||||
);
|
||||
|
||||
const data = JSON.parse(result.trim());
|
||||
const claudeOauth = data?.claudeAiOauth;
|
||||
if (!claudeOauth || typeof claudeOauth !== "object") return null;
|
||||
|
||||
const accessToken = claudeOauth.accessToken;
|
||||
const refreshToken = claudeOauth.refreshToken;
|
||||
const expiresAt = claudeOauth.expiresAt;
|
||||
|
||||
if (typeof accessToken !== "string" || !accessToken) return null;
|
||||
if (typeof expiresAt !== "number" || expiresAt <= 0) return null;
|
||||
|
||||
// Return OAuthCredential when refreshToken is available (enables auto-refresh)
|
||||
if (typeof refreshToken === "string" && refreshToken) {
|
||||
return {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: accessToken,
|
||||
refresh: refreshToken,
|
||||
expires: expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback to TokenCredential for backward compatibility (no auto-refresh)
|
||||
return {
|
||||
type: "token",
|
||||
provider: "anthropic",
|
||||
token: accessToken,
|
||||
expires: expiresAt,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read OpenAI Codex OAuth credentials from Codex CLI's auth file.
|
||||
* Codex CLI stores credentials at ~/.codex/auth.json
|
||||
*/
|
||||
function readCodexCliCredentials(): OAuthCredential | null {
|
||||
const authPath = path.join(
|
||||
resolveUserPath("~"),
|
||||
CODEX_CLI_AUTH_RELATIVE_PATH,
|
||||
);
|
||||
const raw = loadJsonFile(authPath);
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
|
||||
const data = raw as Record<string, unknown>;
|
||||
const tokens = data.tokens as Record<string, unknown> | undefined;
|
||||
if (!tokens || typeof tokens !== "object") return null;
|
||||
|
||||
const accessToken = tokens.access_token;
|
||||
const refreshToken = tokens.refresh_token;
|
||||
|
||||
if (typeof accessToken !== "string" || !accessToken) return null;
|
||||
if (typeof refreshToken !== "string" || !refreshToken) return null;
|
||||
|
||||
// Codex CLI doesn't store expiry, estimate 1 hour from file mtime or now
|
||||
let expires: number;
|
||||
try {
|
||||
const stat = fs.statSync(authPath);
|
||||
// Assume token is valid for ~1 hour from when the file was last modified
|
||||
expires = stat.mtimeMs + 60 * 60 * 1000;
|
||||
} catch {
|
||||
expires = Date.now() + 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "oauth",
|
||||
provider: "openai-codex" as unknown as OAuthProvider,
|
||||
access: accessToken,
|
||||
refresh: refreshToken,
|
||||
expires,
|
||||
};
|
||||
}
|
||||
|
||||
function shallowEqualOAuthCredentials(
|
||||
a: OAuthCredential | undefined,
|
||||
b: OAuthCredential,
|
||||
|
||||
54
src/agents/cli-credentials.test.ts
Normal file
54
src/agents/cli-credentials.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const execSyncMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
execSync: execSyncMock,
|
||||
}));
|
||||
|
||||
describe("cli credentials", () => {
|
||||
afterEach(() => {
|
||||
execSyncMock.mockReset();
|
||||
});
|
||||
|
||||
it("updates the Claude Code keychain item in place", async () => {
|
||||
const commands: string[] = [];
|
||||
|
||||
execSyncMock.mockImplementation((command: unknown) => {
|
||||
const cmd = String(command);
|
||||
commands.push(cmd);
|
||||
|
||||
if (cmd.includes("find-generic-password")) {
|
||||
return JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: "old-access",
|
||||
refreshToken: "old-refresh",
|
||||
expiresAt: Date.now() + 60_000,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return "";
|
||||
});
|
||||
|
||||
const { writeClaudeCliKeychainCredentials } = await import(
|
||||
"./cli-credentials.js"
|
||||
);
|
||||
|
||||
const ok = writeClaudeCliKeychainCredentials({
|
||||
access: "new-access",
|
||||
refresh: "new-refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(
|
||||
commands.some((cmd) => cmd.includes("delete-generic-password")),
|
||||
).toBe(false);
|
||||
|
||||
const updateCommand = commands.find((cmd) =>
|
||||
cmd.includes("add-generic-password"),
|
||||
);
|
||||
expect(updateCommand).toContain("-U");
|
||||
});
|
||||
});
|
||||
277
src/agents/cli-credentials.ts
Normal file
277
src/agents/cli-credentials.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import type { OAuthCredentials, OAuthProvider } from "@mariozechner/pi-ai";
|
||||
|
||||
import { createSubsystemLogger } from "../logging.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
|
||||
const log = createSubsystemLogger("agents/auth-profiles");
|
||||
|
||||
const CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH = ".claude/.credentials.json";
|
||||
const CODEX_CLI_AUTH_RELATIVE_PATH = ".codex/auth.json";
|
||||
|
||||
const CLAUDE_CLI_KEYCHAIN_SERVICE = "Claude Code-credentials";
|
||||
const CLAUDE_CLI_KEYCHAIN_ACCOUNT = "Claude Code";
|
||||
|
||||
export type ClaudeCliCredential =
|
||||
| {
|
||||
type: "oauth";
|
||||
provider: "anthropic";
|
||||
access: string;
|
||||
refresh: string;
|
||||
expires: number;
|
||||
}
|
||||
| {
|
||||
type: "token";
|
||||
provider: "anthropic";
|
||||
token: string;
|
||||
expires: number;
|
||||
};
|
||||
|
||||
export type CodexCliCredential = {
|
||||
type: "oauth";
|
||||
provider: OAuthProvider;
|
||||
access: string;
|
||||
refresh: string;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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 readClaudeCliKeychainCredentials(): ClaudeCliCredential | null {
|
||||
try {
|
||||
const result = execSync(
|
||||
`security find-generic-password -s "${CLAUDE_CLI_KEYCHAIN_SERVICE}" -w`,
|
||||
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
|
||||
);
|
||||
|
||||
const data = JSON.parse(result.trim());
|
||||
const claudeOauth = data?.claudeAiOauth;
|
||||
if (!claudeOauth || typeof claudeOauth !== "object") return null;
|
||||
|
||||
const accessToken = claudeOauth.accessToken;
|
||||
const refreshToken = claudeOauth.refreshToken;
|
||||
const expiresAt = claudeOauth.expiresAt;
|
||||
|
||||
if (typeof accessToken !== "string" || !accessToken) return null;
|
||||
if (typeof expiresAt !== "number" || expiresAt <= 0) return null;
|
||||
|
||||
if (typeof refreshToken === "string" && refreshToken) {
|
||||
return {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: accessToken,
|
||||
refresh: refreshToken,
|
||||
expires: expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "token",
|
||||
provider: "anthropic",
|
||||
token: accessToken,
|
||||
expires: expiresAt,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function readClaudeCliCredentials(options?: {
|
||||
allowKeychainPrompt?: boolean;
|
||||
}): ClaudeCliCredential | null {
|
||||
if (process.platform === "darwin" && options?.allowKeychainPrompt !== false) {
|
||||
const keychainCreds = readClaudeCliKeychainCredentials();
|
||||
if (keychainCreds) {
|
||||
log.info("read anthropic credentials from claude cli keychain", {
|
||||
type: keychainCreds.type,
|
||||
});
|
||||
return keychainCreds;
|
||||
}
|
||||
}
|
||||
|
||||
const credPath = path.join(
|
||||
resolveUserPath("~"),
|
||||
CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH,
|
||||
);
|
||||
const raw = loadJsonFile(credPath);
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
|
||||
const data = raw as Record<string, unknown>;
|
||||
const claudeOauth = data.claudeAiOauth as Record<string, unknown> | undefined;
|
||||
if (!claudeOauth || typeof claudeOauth !== "object") return null;
|
||||
|
||||
const accessToken = claudeOauth.accessToken;
|
||||
const refreshToken = claudeOauth.refreshToken;
|
||||
const expiresAt = claudeOauth.expiresAt;
|
||||
|
||||
if (typeof accessToken !== "string" || !accessToken) return null;
|
||||
if (typeof expiresAt !== "number" || expiresAt <= 0) return null;
|
||||
|
||||
if (typeof refreshToken === "string" && refreshToken) {
|
||||
return {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: accessToken,
|
||||
refresh: refreshToken,
|
||||
expires: expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "token",
|
||||
provider: "anthropic",
|
||||
token: accessToken,
|
||||
expires: expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function writeClaudeCliKeychainCredentials(
|
||||
newCredentials: OAuthCredentials,
|
||||
): boolean {
|
||||
try {
|
||||
const existingResult = execSync(
|
||||
`security find-generic-password -s "${CLAUDE_CLI_KEYCHAIN_SERVICE}" -w 2>/dev/null`,
|
||||
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
|
||||
);
|
||||
|
||||
const existingData = JSON.parse(existingResult.trim());
|
||||
const existingOauth = existingData?.claudeAiOauth;
|
||||
if (!existingOauth || typeof existingOauth !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
existingData.claudeAiOauth = {
|
||||
...existingOauth,
|
||||
accessToken: newCredentials.access,
|
||||
refreshToken: newCredentials.refresh,
|
||||
expiresAt: newCredentials.expires,
|
||||
};
|
||||
|
||||
const newValue = JSON.stringify(existingData);
|
||||
|
||||
execSync(
|
||||
`security add-generic-password -U -s "${CLAUDE_CLI_KEYCHAIN_SERVICE}" -a "${CLAUDE_CLI_KEYCHAIN_ACCOUNT}" -w '${newValue.replace(/'/g, "'\"'\"'")}'`,
|
||||
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
|
||||
);
|
||||
|
||||
log.info("wrote refreshed credentials to claude cli keychain", {
|
||||
expires: new Date(newCredentials.expires).toISOString(),
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
log.warn("failed to write credentials to claude cli keychain", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeClaudeCliFileCredentials(
|
||||
newCredentials: OAuthCredentials,
|
||||
): boolean {
|
||||
const credPath = path.join(
|
||||
resolveUserPath("~"),
|
||||
CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH,
|
||||
);
|
||||
|
||||
if (!fs.existsSync(credPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = loadJsonFile(credPath);
|
||||
if (!raw || typeof raw !== "object") return false;
|
||||
|
||||
const data = raw as Record<string, unknown>;
|
||||
const existingOauth = data.claudeAiOauth as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (!existingOauth || typeof existingOauth !== "object") return false;
|
||||
|
||||
data.claudeAiOauth = {
|
||||
...existingOauth,
|
||||
accessToken: newCredentials.access,
|
||||
refreshToken: newCredentials.refresh,
|
||||
expiresAt: newCredentials.expires,
|
||||
};
|
||||
|
||||
saveJsonFile(credPath, data);
|
||||
log.info("wrote refreshed credentials to claude cli file", {
|
||||
expires: new Date(newCredentials.expires).toISOString(),
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
log.warn("failed to write credentials to claude cli file", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeClaudeCliCredentials(
|
||||
newCredentials: OAuthCredentials,
|
||||
): boolean {
|
||||
if (process.platform === "darwin") {
|
||||
const didWriteKeychain = writeClaudeCliKeychainCredentials(newCredentials);
|
||||
if (didWriteKeychain) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return writeClaudeCliFileCredentials(newCredentials);
|
||||
}
|
||||
|
||||
export function readCodexCliCredentials(): CodexCliCredential | null {
|
||||
const authPath = path.join(
|
||||
resolveUserPath("~"),
|
||||
CODEX_CLI_AUTH_RELATIVE_PATH,
|
||||
);
|
||||
const raw = loadJsonFile(authPath);
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
|
||||
const data = raw as Record<string, unknown>;
|
||||
const tokens = data.tokens as Record<string, unknown> | undefined;
|
||||
if (!tokens || typeof tokens !== "object") return null;
|
||||
|
||||
const accessToken = tokens.access_token;
|
||||
const refreshToken = tokens.refresh_token;
|
||||
|
||||
if (typeof accessToken !== "string" || !accessToken) return null;
|
||||
if (typeof refreshToken !== "string" || !refreshToken) return null;
|
||||
|
||||
let expires: number;
|
||||
try {
|
||||
const stat = fs.statSync(authPath);
|
||||
expires = stat.mtimeMs + 60 * 60 * 1000;
|
||||
} catch {
|
||||
expires = Date.now() + 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "oauth",
|
||||
provider: "openai-codex" as OAuthProvider,
|
||||
access: accessToken,
|
||||
refresh: refreshToken,
|
||||
expires,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user