fix: harden cli credential sync
This commit is contained in:
@@ -4,6 +4,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.
|
||||||
- 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.
|
||||||
- Branding: normalize user-facing “ClawdBot”/“CLAWDBOT” → “Clawdbot” (CLI, status, docs).
|
- 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 fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
@@ -15,16 +14,17 @@ import type { AuthProfileConfig } from "../config/types.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";
|
||||||
|
import {
|
||||||
|
readClaudeCliCredentials,
|
||||||
|
readCodexCliCredentials,
|
||||||
|
writeClaudeCliCredentials,
|
||||||
|
} from "./cli-credentials.js";
|
||||||
import { normalizeProviderId } from "./model-selection.js";
|
import { normalizeProviderId } from "./model-selection.js";
|
||||||
|
|
||||||
const AUTH_STORE_VERSION = 1;
|
const AUTH_STORE_VERSION = 1;
|
||||||
const AUTH_PROFILE_FILENAME = "auth-profiles.json";
|
const AUTH_PROFILE_FILENAME = "auth-profiles.json";
|
||||||
const LEGACY_AUTH_FILENAME = "auth.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 CLAUDE_CLI_PROFILE_ID = "anthropic:claude-cli";
|
||||||
export const CODEX_CLI_PROFILE_ID = "openai-codex:codex-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);
|
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) {
|
function ensureAuthStoreFile(pathname: string) {
|
||||||
if (fs.existsSync(pathname)) return;
|
if (fs.existsSync(pathname)) return;
|
||||||
const payload: AuthProfileStore = {
|
const payload: AuthProfileStore = {
|
||||||
@@ -476,159 +349,6 @@ function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean {
|
|||||||
return mutated;
|
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(
|
function shallowEqualOAuthCredentials(
|
||||||
a: OAuthCredential | undefined,
|
a: OAuthCredential | undefined,
|
||||||
b: OAuthCredential,
|
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