refactor(auth)!: remove external CLI OAuth reuse

This commit is contained in:
Peter Steinberger
2026-01-26 19:04:29 +00:00
parent 39d219da59
commit 526303d9a2
21 changed files with 260 additions and 567 deletions

View File

@@ -2,12 +2,10 @@ import type { ClawdbotConfig } from "../config/config.js";
import {
type AuthProfileCredential,
type AuthProfileStore,
CLAUDE_CLI_PROFILE_ID,
CODEX_CLI_PROFILE_ID,
resolveAuthProfileDisplayLabel,
} from "./auth-profiles.js";
export type AuthProfileSource = "claude-cli" | "codex-cli" | "store";
export type AuthProfileSource = "store";
export type AuthProfileHealthStatus = "ok" | "expiring" | "expired" | "missing" | "static";
@@ -41,9 +39,7 @@ export type AuthHealthSummary = {
export const DEFAULT_OAUTH_WARN_MS = 24 * 60 * 60 * 1000;
export function resolveAuthProfileSource(profileId: string): AuthProfileSource {
if (profileId === CLAUDE_CLI_PROFILE_ID) return "claude-cli";
if (profileId === CODEX_CLI_PROFILE_ID) return "codex-cli";
export function resolveAuthProfileSource(_profileId: string): AuthProfileSource {
return "store";
}

View File

@@ -1,22 +1,11 @@
import { readQwenCliCredentialsCached } from "../cli-credentials.js";
import {
readClaudeCliCredentialsCached,
readCodexCliCredentialsCached,
readQwenCliCredentialsCached,
} from "../cli-credentials.js";
import {
CLAUDE_CLI_PROFILE_ID,
CODEX_CLI_PROFILE_ID,
EXTERNAL_CLI_NEAR_EXPIRY_MS,
EXTERNAL_CLI_SYNC_TTL_MS,
QWEN_CLI_PROFILE_ID,
log,
} from "./constants.js";
import type {
AuthProfileCredential,
AuthProfileStore,
OAuthCredential,
TokenCredential,
} from "./types.js";
import type { AuthProfileCredential, AuthProfileStore, OAuthCredential } from "./types.js";
function shallowEqualOAuthCredentials(a: OAuthCredential | undefined, b: OAuthCredential): boolean {
if (!a) return false;
@@ -33,25 +22,10 @@ function shallowEqualOAuthCredentials(a: OAuthCredential | undefined, b: OAuthCr
);
}
function shallowEqualTokenCredentials(a: TokenCredential | undefined, b: TokenCredential): boolean {
if (!a) return false;
if (a.type !== "token") return false;
return (
a.provider === b.provider &&
a.token === b.token &&
a.expires === b.expires &&
a.email === b.email
);
}
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" &&
cred.provider !== "qwen-portal"
) {
if (cred.provider !== "qwen-portal") {
return false;
}
if (typeof cred.expires !== "number") return true;
@@ -59,163 +33,14 @@ function isExternalProfileFresh(cred: AuthProfileCredential | undefined, now: nu
}
/**
* Find any existing openai-codex profile (other than codex-cli) that has the same
* access and refresh tokens. This prevents creating a duplicate codex-cli profile
* when the user has already set up a custom profile with the same credentials.
*/
export function findDuplicateCodexProfile(
store: AuthProfileStore,
creds: OAuthCredential,
): string | undefined {
for (const [profileId, profile] of Object.entries(store.profiles)) {
if (profileId === CODEX_CLI_PROFILE_ID) continue;
if (profile.type !== "oauth") continue;
if (profile.provider !== "openai-codex") continue;
if (profile.access === creds.access && profile.refresh === creds.refresh) {
return profileId;
}
}
return undefined;
}
/**
* Sync OAuth credentials from external CLI tools (Claude Code CLI, Codex CLI) into the store.
* This allows clawdbot to use the same credentials as these tools without requiring
* separate authentication, and keeps credentials in sync when CLI tools refresh tokens.
* Sync OAuth credentials from external CLI tools (Qwen Code CLI) into the store.
*
* Returns true if any credentials were updated.
*/
export function syncExternalCliCredentials(
store: AuthProfileStore,
options?: { allowKeychainPrompt?: boolean },
): boolean {
export function syncExternalCliCredentials(store: AuthProfileStore): boolean {
let mutated = false;
const now = Date.now();
// Sync from Claude Code CLI (supports both OAuth and Token credentials)
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) {
const existing = store.profiles[CLAUDE_CLI_PROFILE_ID];
const claudeCredsExpires = claudeCreds.expires ?? 0;
// Determine if we should update based on credential comparison
let shouldUpdate = false;
let isEqual = false;
if (claudeCreds.type === "oauth") {
const existingOAuth = existing?.type === "oauth" ? existing : undefined;
isEqual = shallowEqualOAuthCredentials(existingOAuth, claudeCreds);
// Update if: no existing profile, type changed to oauth, expired, or CLI has newer token
shouldUpdate =
!existingOAuth ||
existingOAuth.provider !== "anthropic" ||
existingOAuth.expires <= now ||
(claudeCredsExpires > now && claudeCredsExpires > existingOAuth.expires);
} else {
const existingToken = existing?.type === "token" ? existing : undefined;
isEqual = shallowEqualTokenCredentials(existingToken, claudeCreds);
// Update if: no existing profile, expired, or CLI has newer token
shouldUpdate =
!existingToken ||
existingToken.provider !== "anthropic" ||
(existingToken.expires ?? 0) <= now ||
(claudeCredsExpires > now && claudeCredsExpires > (existingToken.expires ?? 0));
}
// Also update if credential type changed (token -> oauth upgrade)
if (existing && existing.type !== claudeCreds.type) {
// Prefer oauth over token (enables auto-refresh)
if (claudeCreds.type === "oauth") {
shouldUpdate = true;
isEqual = false;
}
}
// Avoid downgrading from oauth to token-only credentials.
if (existing?.type === "oauth" && claudeCreds.type === "token") {
shouldUpdate = false;
}
if (shouldUpdate && !isEqual) {
store.profiles[CLAUDE_CLI_PROFILE_ID] = claudeCreds;
mutated = true;
log.info("synced anthropic credentials from claude cli", {
profileId: CLAUDE_CLI_PROFILE_ID,
type: claudeCreds.type,
expires:
typeof claudeCreds.expires === "number"
? new Date(claudeCreds.expires).toISOString()
: "unknown",
});
}
}
// Sync from Codex CLI
const existingCodex = store.profiles[CODEX_CLI_PROFILE_ID];
const existingCodexOAuth = existingCodex?.type === "oauth" ? existingCodex : undefined;
const duplicateExistingId = existingCodexOAuth
? findDuplicateCodexProfile(store, existingCodexOAuth)
: undefined;
if (duplicateExistingId) {
delete store.profiles[CODEX_CLI_PROFILE_ID];
mutated = true;
log.info("removed codex-cli profile: credentials already exist in another profile", {
existingProfileId: duplicateExistingId,
removedProfileId: CODEX_CLI_PROFILE_ID,
});
}
const shouldSyncCodex =
!existingCodex ||
existingCodex.provider !== "openai-codex" ||
!isExternalProfileFresh(existingCodex, now);
const codexCreds =
shouldSyncCodex || duplicateExistingId
? readCodexCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS })
: null;
if (codexCreds) {
const duplicateProfileId = findDuplicateCodexProfile(store, codexCreds);
if (duplicateProfileId) {
if (store.profiles[CODEX_CLI_PROFILE_ID]) {
delete store.profiles[CODEX_CLI_PROFILE_ID];
mutated = true;
log.info("removed codex-cli profile: credentials already exist in another profile", {
existingProfileId: duplicateProfileId,
removedProfileId: CODEX_CLI_PROFILE_ID,
});
}
} else {
const existing = store.profiles[CODEX_CLI_PROFILE_ID];
const existingOAuth = existing?.type === "oauth" ? existing : undefined;
// Codex creds don't carry expiry; use file mtime heuristic for freshness.
const shouldUpdate =
!existingOAuth ||
existingOAuth.provider !== "openai-codex" ||
existingOAuth.expires <= now ||
codexCreds.expires > existingOAuth.expires;
if (shouldUpdate && !shallowEqualOAuthCredentials(existingOAuth, codexCreds)) {
store.profiles[CODEX_CLI_PROFILE_ID] = codexCreds;
mutated = true;
log.info("synced openai-codex credentials from codex cli", {
profileId: CODEX_CLI_PROFILE_ID,
expires: new Date(codexCreds.expires).toISOString(),
});
}
}
}
// Sync from Qwen Code CLI
const existingQwen = store.profiles[QWEN_CLI_PROFILE_ID];
const shouldSyncQwen =

View File

@@ -4,8 +4,7 @@ import lockfile from "proper-lockfile";
import type { ClawdbotConfig } from "../../config/config.js";
import { refreshChutesTokens } from "../chutes-oauth.js";
import { refreshQwenPortalCredentials } from "../../providers/qwen-portal-oauth.js";
import { writeClaudeCliCredentials } from "../cli-credentials.js";
import { AUTH_STORE_LOCK_OPTIONS, CLAUDE_CLI_PROFILE_ID } from "./constants.js";
import { AUTH_STORE_LOCK_OPTIONS } from "./constants.js";
import { formatAuthDoctorHint } from "./doctor.js";
import { ensureAuthStoreFile, resolveAuthStorePath } from "./paths.js";
import { suggestOAuthProfileIdForLegacyDefault } from "./repair.js";
@@ -72,12 +71,6 @@ async function refreshOAuthTokenWithLock(params: {
};
saveAuthProfileStore(store, params.agentDir);
// Sync refreshed credentials back to Claude Code CLI if this is the claude-cli profile
// This ensures Claude Code continues to work after ClawdBot refreshes the token
if (params.profileId === CLAUDE_CLI_PROFILE_ID && cred.provider === "anthropic") {
writeClaudeCliCredentials(result.newCredentials);
}
return result;
} finally {
if (release) {

View File

@@ -3,13 +3,8 @@ import type { OAuthCredentials } from "@mariozechner/pi-ai";
import lockfile from "proper-lockfile";
import { resolveOAuthPath } from "../../config/paths.js";
import { loadJsonFile, saveJsonFile } from "../../infra/json-file.js";
import {
AUTH_STORE_LOCK_OPTIONS,
AUTH_STORE_VERSION,
CODEX_CLI_PROFILE_ID,
log,
} from "./constants.js";
import { findDuplicateCodexProfile, syncExternalCliCredentials } from "./external-cli-sync.js";
import { AUTH_STORE_LOCK_OPTIONS, AUTH_STORE_VERSION, log } from "./constants.js";
import { syncExternalCliCredentials } from "./external-cli-sync.js";
import { ensureAuthStoreFile, resolveAuthStorePath, resolveLegacyAuthStorePath } from "./paths.js";
import type { AuthProfileCredential, AuthProfileStore, ProfileUsageStats } from "./types.js";
@@ -229,14 +224,14 @@ export function loadAuthProfileStore(): AuthProfileStore {
function loadAuthProfileStoreForAgent(
agentDir?: string,
options?: { allowKeychainPrompt?: boolean },
_options?: { allowKeychainPrompt?: boolean },
): AuthProfileStore {
const authPath = resolveAuthStorePath(agentDir);
const raw = loadJsonFile(authPath);
const asStore = coerceAuthStore(raw);
if (asStore) {
// Sync from external CLI tools on every load
const synced = syncExternalCliCredentials(asStore, options);
const synced = syncExternalCliCredentials(asStore);
if (synced) {
saveJsonFile(authPath, asStore);
}
@@ -297,7 +292,7 @@ function loadAuthProfileStoreForAgent(
}
const mergedOAuth = mergeOAuthFileIntoStore(store);
const syncedCli = syncExternalCliCredentials(store, options);
const syncedCli = syncExternalCliCredentials(store);
const shouldWrite = legacy !== null || mergedOAuth || syncedCli;
if (shouldWrite) {
saveJsonFile(authPath, store);
@@ -337,15 +332,6 @@ export function ensureAuthProfileStore(
const mainStore = loadAuthProfileStoreForAgent(undefined, options);
const merged = mergeAuthProfileStores(mainStore, store);
// Keep per-agent view clean even if the main store has codex-cli.
const codexProfile = merged.profiles[CODEX_CLI_PROFILE_ID];
if (codexProfile?.type === "oauth") {
const duplicateId = findDuplicateCodexProfile(merged, codexProfile);
if (duplicateId) {
delete merged.profiles[CODEX_CLI_PROFILE_ID];
}
}
return merged;
}

View File

@@ -389,7 +389,7 @@ export function registerModelsCli(program: Command) {
.description("Set per-agent auth order override (locks rotation to this list)")
.requiredOption("--provider <name>", "Provider id (e.g. anthropic)")
.option("--agent <id>", "Agent id (default: configured default agent)")
.argument("<profileIds...>", "Auth profile ids (e.g. anthropic:claude-cli)")
.argument("<profileIds...>", "Auth profile ids (e.g. anthropic:default)")
.action(async (profileIds: string[], opts) => {
await runModelsCommand(async () => {
await modelsAuthOrderSetCommand(

View File

@@ -52,7 +52,7 @@ export function registerOnboardCommand(program: Command) {
.option("--mode <mode>", "Wizard mode: local|remote")
.option(
"--auth-choice <choice>",
"Auth: setup-token|claude-cli|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|kimi-code-api-key|synthetic-api-key|venice-api-key|codex-cli|gemini-api-key|zai-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip",
"Auth: setup-token|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|kimi-code-api-key|synthetic-api-key|venice-api-key|gemini-api-key|zai-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip",
)
.option(
"--token-provider <id>",

View File

@@ -258,7 +258,6 @@ export async function agentsAddCommand(
prompter,
store: authStore,
includeSkip: true,
includeClaudeCliIfMissing: true,
});
const authResult = await applyAuthChoice({

View File

@@ -1,6 +1,4 @@
import type { AuthProfileStore } from "../agents/auth-profiles.js";
import { CLAUDE_CLI_PROFILE_ID, CODEX_CLI_PROFILE_ID } from "../agents/auth-profiles.js";
import { colorize, isRich, theme } from "../terminal/theme.js";
import type { AuthChoice } from "./onboard-types.js";
export type AuthChoiceOption = {
@@ -41,13 +39,13 @@ const AUTH_CHOICE_GROUP_DEFS: {
value: "openai",
label: "OpenAI",
hint: "Codex OAuth + API key",
choices: ["codex-cli", "openai-codex", "openai-api-key"],
choices: ["openai-codex", "openai-api-key"],
},
{
value: "anthropic",
label: "Anthropic",
hint: "Claude Code CLI + API key",
choices: ["token", "claude-cli", "apiKey"],
hint: "setup-token + API key",
choices: ["token", "apiKey"],
},
{
value: "minimax",
@@ -117,65 +115,12 @@ const AUTH_CHOICE_GROUP_DEFS: {
},
];
function formatOAuthHint(expires?: number, opts?: { allowStale?: boolean }): string {
const rich = isRich();
if (!expires) {
return colorize(rich, theme.muted, "token unavailable");
}
const now = Date.now();
const remaining = expires - now;
if (remaining <= 0) {
if (opts?.allowStale) {
return colorize(rich, theme.warn, "token present · refresh on use");
}
return colorize(rich, theme.error, "token expired");
}
const minutes = Math.round(remaining / (60 * 1000));
const duration =
minutes >= 120
? `${Math.round(minutes / 60)}h`
: minutes >= 60
? "1h"
: `${Math.max(minutes, 1)}m`;
const label = `token ok · expires in ${duration}`;
if (minutes <= 10) {
return colorize(rich, theme.warn, label);
}
return colorize(rich, theme.success, label);
}
export function buildAuthChoiceOptions(params: {
store: AuthProfileStore;
includeSkip: boolean;
includeClaudeCliIfMissing?: boolean;
platform?: NodeJS.Platform;
}): AuthChoiceOption[] {
void params.store;
const options: AuthChoiceOption[] = [];
const platform = params.platform ?? process.platform;
const codexCli = params.store.profiles[CODEX_CLI_PROFILE_ID];
if (codexCli?.type === "oauth") {
options.push({
value: "codex-cli",
label: "OpenAI Codex OAuth (Codex CLI)",
hint: formatOAuthHint(codexCli.expires, { allowStale: true }),
});
}
const claudeCli = params.store.profiles[CLAUDE_CLI_PROFILE_ID];
if (claudeCli?.type === "oauth" || claudeCli?.type === "token") {
options.push({
value: "claude-cli",
label: "Anthropic token (Claude Code CLI)",
hint: `reuses existing Claude Code auth · ${formatOAuthHint(claudeCli.expires)}`,
});
} else if (params.includeClaudeCliIfMissing && platform === "darwin") {
options.push({
value: "claude-cli",
label: "Anthropic token (Claude Code CLI)",
hint: "reuses existing Claude Code auth · requires Keychain access",
});
}
options.push({
value: "token",
@@ -245,12 +190,7 @@ export function buildAuthChoiceOptions(params: {
return options;
}
export function buildAuthChoiceGroups(params: {
store: AuthProfileStore;
includeSkip: boolean;
includeClaudeCliIfMissing?: boolean;
platform?: NodeJS.Platform;
}): {
export function buildAuthChoiceGroups(params: { store: AuthProfileStore; includeSkip: boolean }): {
groups: AuthChoiceGroup[];
skipOption?: AuthChoiceOption;
} {

View File

@@ -9,8 +9,6 @@ export async function promptAuthChoiceGrouped(params: {
prompter: WizardPrompter;
store: AuthProfileStore;
includeSkip: boolean;
includeClaudeCliIfMissing?: boolean;
platform?: NodeJS.Platform;
}): Promise<AuthChoice> {
const { groups, skipOption } = buildAuthChoiceGroups(params);
const availableGroups = groups.filter((group) => group.options.length > 0);

View File

@@ -1,8 +1,4 @@
import {
CLAUDE_CLI_PROFILE_ID,
ensureAuthProfileStore,
upsertAuthProfile,
} from "../agents/auth-profiles.js";
import { upsertAuthProfile } from "../agents/auth-profiles.js";
import {
formatApiKeyPreview,
normalizeApiKeyInput,
@@ -15,153 +11,17 @@ import { applyAuthProfileConfig, setAnthropicApiKey } from "./onboard-auth.js";
export async function applyAuthChoiceAnthropic(
params: ApplyAuthChoiceParams,
): Promise<ApplyAuthChoiceResult | null> {
if (params.authChoice === "claude-cli") {
if (
params.authChoice === "setup-token" ||
params.authChoice === "oauth" ||
params.authChoice === "token"
) {
let nextConfig = params.config;
const store = ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: false,
});
const hasClaudeCli = Boolean(store.profiles[CLAUDE_CLI_PROFILE_ID]);
if (!hasClaudeCli && process.platform === "darwin") {
await params.prompter.note(
[
"macOS will show a Keychain prompt next.",
'Choose "Always Allow" so the launchd gateway can start without prompts.',
'If you choose "Allow" or "Deny", each restart will block on a Keychain alert.',
].join("\n"),
"Claude Code CLI Keychain",
);
const proceed = await params.prompter.confirm({
message: "Check Keychain for Claude Code CLI credentials now?",
initialValue: true,
});
if (!proceed) return { config: nextConfig };
}
const storeWithKeychain = hasClaudeCli
? store
: ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: true,
});
if (!storeWithKeychain.profiles[CLAUDE_CLI_PROFILE_ID]) {
if (process.stdin.isTTY) {
const runNow = await params.prompter.confirm({
message: "Run `claude setup-token` now?",
initialValue: true,
});
if (runNow) {
const res = await (async () => {
const { spawnSync } = await import("node:child_process");
return spawnSync("claude", ["setup-token"], { stdio: "inherit" });
})();
if (res.error) {
await params.prompter.note(
`Failed to run claude: ${String(res.error)}`,
"Claude setup-token",
);
}
}
} else {
await params.prompter.note(
"`claude setup-token` requires an interactive TTY.",
"Claude setup-token",
);
}
const refreshed = ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: true,
});
if (!refreshed.profiles[CLAUDE_CLI_PROFILE_ID]) {
await params.prompter.note(
process.platform === "darwin"
? 'No Claude Code CLI credentials found in Keychain ("Claude Code-credentials") or ~/.claude/.credentials.json.'
: "No Claude Code CLI credentials found at ~/.claude/.credentials.json.",
"Claude Code CLI OAuth",
);
return { config: nextConfig };
}
}
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: CLAUDE_CLI_PROFILE_ID,
provider: "anthropic",
mode: "oauth",
});
return { config: nextConfig };
}
if (params.authChoice === "setup-token" || params.authChoice === "oauth") {
let nextConfig = params.config;
await params.prompter.note(
[
"This will run `claude setup-token` to create a long-lived Anthropic token.",
"Requires an interactive TTY and a Claude Pro/Max subscription.",
].join("\n"),
"Anthropic setup-token",
);
if (!process.stdin.isTTY) {
await params.prompter.note(
"`claude setup-token` requires an interactive TTY.",
"Anthropic setup-token",
);
return { config: nextConfig };
}
const proceed = await params.prompter.confirm({
message: "Run `claude setup-token` now?",
initialValue: true,
});
if (!proceed) return { config: nextConfig };
const res = await (async () => {
const { spawnSync } = await import("node:child_process");
return spawnSync("claude", ["setup-token"], { stdio: "inherit" });
})();
if (res.error) {
await params.prompter.note(
`Failed to run claude: ${String(res.error)}`,
"Anthropic setup-token",
);
return { config: nextConfig };
}
if (typeof res.status === "number" && res.status !== 0) {
await params.prompter.note(
`claude setup-token failed (exit ${res.status})`,
"Anthropic setup-token",
);
return { config: nextConfig };
}
const store = ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: true,
});
if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) {
await params.prompter.note(
`No Claude Code CLI credentials found after setup-token. Expected ${CLAUDE_CLI_PROFILE_ID}.`,
"Anthropic setup-token",
);
return { config: nextConfig };
}
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: CLAUDE_CLI_PROFILE_ID,
provider: "anthropic",
mode: "oauth",
});
return { config: nextConfig };
}
if (params.authChoice === "token") {
let nextConfig = params.config;
const provider = (await params.prompter.select({
message: "Token provider",
options: [{ value: "anthropic", label: "Anthropic (only supported)" }],
})) as "anthropic";
await params.prompter.note(
["Run `claude setup-token` in your terminal.", "Then paste the generated token below."].join(
"\n",
),
"Anthropic token",
"Anthropic setup-token",
);
const tokenRaw = await params.prompter.text({
@@ -174,6 +34,7 @@ export async function applyAuthChoiceAnthropic(
message: "Token name (blank = default)",
placeholder: "default",
});
const provider = "anthropic";
const namedProfileId = buildTokenProfileId({
provider,
name: String(profileNameRaw ?? ""),

View File

@@ -1,5 +1,4 @@
import { loginOpenAICodex } from "@mariozechner/pi-ai";
import { CODEX_CLI_PROFILE_ID, ensureAuthProfileStore } from "../agents/auth-profiles.js";
import { resolveEnvApiKey } from "../agents/model-auth.js";
import { upsertSharedEnvVar } from "../infra/env-file.js";
import { isRemoteEnvironment } from "./oauth-env.js";
@@ -146,45 +145,5 @@ export async function applyAuthChoiceOpenAI(
return { config: nextConfig, agentModelOverride };
}
if (params.authChoice === "codex-cli") {
let nextConfig = params.config;
let agentModelOverride: string | undefined;
const noteAgentModel = async (model: string) => {
if (!params.agentId) return;
await params.prompter.note(
`Default model set to ${model} for agent "${params.agentId}".`,
"Model configured",
);
};
const store = ensureAuthProfileStore(params.agentDir);
if (!store.profiles[CODEX_CLI_PROFILE_ID]) {
await params.prompter.note(
"No Codex CLI credentials found at ~/.codex/auth.json.",
"Codex CLI OAuth",
);
return { config: nextConfig, agentModelOverride };
}
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: CODEX_CLI_PROFILE_ID,
provider: "openai-codex",
mode: "oauth",
});
if (params.setDefaultModel) {
const applied = applyOpenAICodexModelDefault(nextConfig);
nextConfig = applied.next;
if (applied.changed) {
await params.prompter.note(
`Default model set to ${OPENAI_CODEX_DEFAULT_MODEL}`,
"Model configured",
);
}
} else {
agentModelOverride = OPENAI_CODEX_DEFAULT_MODEL;
await noteAgentModel(OPENAI_CODEX_DEFAULT_MODEL);
}
return { config: nextConfig, agentModelOverride };
}
return null;
}

View File

@@ -1,8 +1,4 @@
import {
CLAUDE_CLI_PROFILE_ID,
CODEX_CLI_PROFILE_ID,
loadAuthProfileStore,
} from "../../agents/auth-profiles.js";
import { loadAuthProfileStore } from "../../agents/auth-profiles.js";
import { listChannelPlugins } from "../../channels/plugins/index.js";
import { buildChannelAccountSnapshot } from "../../channels/plugins/status.js";
import type { ChannelAccountSnapshot, ChannelPlugin } from "../../channels/plugins/types.js";
@@ -115,7 +111,7 @@ export async function channelsListCommand(
id: profileId,
provider: profile.provider,
type: profile.type,
isExternal: profileId === CLAUDE_CLI_PROFILE_ID || profileId === CODEX_CLI_PROFILE_ID,
isExternal: false,
}));
if (opts.json) {
const usage = includeUsage ? await loadProviderUsageSummary() : undefined;

View File

@@ -47,7 +47,6 @@ export async function promptAuthConfig(
allowKeychainPrompt: false,
}),
includeSkip: true,
includeClaudeCliIfMissing: true,
});
let next = cfg;
@@ -74,10 +73,7 @@ export async function promptAuthConfig(
}
const anthropicOAuth =
authChoice === "claude-cli" ||
authChoice === "setup-token" ||
authChoice === "token" ||
authChoice === "oauth";
authChoice === "setup-token" || authChoice === "token" || authChoice === "oauth";
const allowlistSelection = await promptModelAllowlist({
config: next,

View File

@@ -11,6 +11,7 @@ import {
resolveApiKeyForProfile,
resolveProfileUnusableUntilForDisplay,
} from "../agents/auth-profiles.js";
import { updateAuthProfileStoreWithLock } from "../agents/auth-profiles/store.js";
import type { ClawdbotConfig } from "../config/config.js";
import { note } from "../terminal/note.js";
import { formatCliCommand } from "../cli/command-format.js";
@@ -38,6 +39,148 @@ export async function maybeRepairAnthropicOAuthProfileId(
return repair.config;
}
function pruneAuthOrder(
order: Record<string, string[]> | undefined,
profileIds: Set<string>,
): { next: Record<string, string[]> | undefined; changed: boolean } {
if (!order) return { next: order, changed: false };
let changed = false;
const next: Record<string, string[]> = {};
for (const [provider, list] of Object.entries(order)) {
const filtered = list.filter((id) => !profileIds.has(id));
if (filtered.length !== list.length) changed = true;
if (filtered.length > 0) next[provider] = filtered;
}
return { next: Object.keys(next).length > 0 ? next : undefined, changed };
}
function pruneAuthProfiles(
cfg: ClawdbotConfig,
profileIds: Set<string>,
): { next: ClawdbotConfig; changed: boolean } {
const profiles = cfg.auth?.profiles;
const order = cfg.auth?.order;
const nextProfiles = profiles ? { ...profiles } : undefined;
let changed = false;
if (nextProfiles) {
for (const id of profileIds) {
if (id in nextProfiles) {
delete nextProfiles[id];
changed = true;
}
}
}
const prunedOrder = pruneAuthOrder(order, profileIds);
if (prunedOrder.changed) changed = true;
if (!changed) return { next: cfg, changed: false };
const nextAuth =
nextProfiles || prunedOrder.next
? {
...cfg.auth,
profiles: nextProfiles && Object.keys(nextProfiles).length > 0 ? nextProfiles : undefined,
order: prunedOrder.next,
}
: undefined;
return {
next: {
...cfg,
auth: nextAuth,
},
changed: true,
};
}
export async function maybeRemoveDeprecatedCliAuthProfiles(
cfg: ClawdbotConfig,
prompter: DoctorPrompter,
): Promise<ClawdbotConfig> {
const store = ensureAuthProfileStore(undefined, { allowKeychainPrompt: false });
const deprecated = new Set<string>();
if (store.profiles[CLAUDE_CLI_PROFILE_ID] || cfg.auth?.profiles?.[CLAUDE_CLI_PROFILE_ID]) {
deprecated.add(CLAUDE_CLI_PROFILE_ID);
}
if (store.profiles[CODEX_CLI_PROFILE_ID] || cfg.auth?.profiles?.[CODEX_CLI_PROFILE_ID]) {
deprecated.add(CODEX_CLI_PROFILE_ID);
}
if (deprecated.size === 0) return cfg;
const lines = ["Deprecated external CLI auth profiles detected (no longer supported):"];
if (deprecated.has(CLAUDE_CLI_PROFILE_ID)) {
lines.push(
`- ${CLAUDE_CLI_PROFILE_ID} (Anthropic): use setup-token → ${formatCliCommand("clawdbot models auth setup-token")}`,
);
}
if (deprecated.has(CODEX_CLI_PROFILE_ID)) {
lines.push(
`- ${CODEX_CLI_PROFILE_ID} (OpenAI Codex): use OAuth → ${formatCliCommand(
"clawdbot models auth login --provider openai-codex",
)}`,
);
}
note(lines.join("\n"), "Auth profiles");
const shouldRemove = await prompter.confirmRepair({
message: "Remove deprecated CLI auth profiles now?",
initialValue: true,
});
if (!shouldRemove) return cfg;
await updateAuthProfileStoreWithLock({
updater: (nextStore) => {
let mutated = false;
for (const id of deprecated) {
if (nextStore.profiles[id]) {
delete nextStore.profiles[id];
mutated = true;
}
if (nextStore.usageStats?.[id]) {
delete nextStore.usageStats[id];
mutated = true;
}
}
if (nextStore.order) {
for (const [provider, list] of Object.entries(nextStore.order)) {
const filtered = list.filter((id) => !deprecated.has(id));
if (filtered.length !== list.length) {
mutated = true;
if (filtered.length > 0) {
nextStore.order[provider] = filtered;
} else {
delete nextStore.order[provider];
}
}
}
}
if (nextStore.lastGood) {
for (const [provider, profileId] of Object.entries(nextStore.lastGood)) {
if (deprecated.has(profileId)) {
delete nextStore.lastGood[provider];
mutated = true;
}
}
}
return mutated;
},
});
const pruned = pruneAuthProfiles(cfg, deprecated);
if (pruned.changed) {
note(
Array.from(deprecated.values())
.map((id) => `- removed ${id} from config`)
.join("\n"),
"Doctor changes",
);
}
return pruned.next;
}
type AuthIssue = {
profileId: string;
provider: string;
@@ -47,10 +190,14 @@ type AuthIssue = {
function formatAuthIssueHint(issue: AuthIssue): string | null {
if (issue.provider === "anthropic" && issue.profileId === CLAUDE_CLI_PROFILE_ID) {
return "Run `claude setup-token` on the gateway host.";
return `Deprecated profile. Use ${formatCliCommand("clawdbot models auth setup-token")} or ${formatCliCommand(
"clawdbot configure",
)}.`;
}
if (issue.provider === "openai-codex" && issue.profileId === CODEX_CLI_PROFILE_ID) {
return `Run \`codex login\` (or \`${formatCliCommand("clawdbot configure")}\` → OpenAI Codex OAuth).`;
return `Deprecated profile. Use ${formatCliCommand(
"clawdbot models auth login --provider openai-codex",
)} or ${formatCliCommand("clawdbot configure")}.`;
}
return `Re-auth via \`${formatCliCommand("clawdbot configure")}\` or \`${formatCliCommand("clawdbot onboard")}\`.`;
}

View File

@@ -22,7 +22,11 @@ import { defaultRuntime } from "../runtime.js";
import { note } from "../terminal/note.js";
import { stylePromptTitle } from "../terminal/prompt-style.js";
import { shortenHomePath } from "../utils.js";
import { maybeRepairAnthropicOAuthProfileId, noteAuthProfileHealth } from "./doctor-auth.js";
import {
maybeRemoveDeprecatedCliAuthProfiles,
maybeRepairAnthropicOAuthProfileId,
noteAuthProfileHealth,
} from "./doctor-auth.js";
import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
import { maybeRepairGatewayDaemon } from "./doctor-gateway-daemon-flow.js";
import { checkGatewayHealth } from "./doctor-gateway-health.js";
@@ -104,6 +108,7 @@ export async function doctorCommand(
}
cfg = await maybeRepairAnthropicOAuthProfileId(cfg, prompter);
cfg = await maybeRemoveDeprecatedCliAuthProfiles(cfg, prompter);
await noteAuthProfileHealth({
cfg,
prompter,

View File

@@ -1,12 +1,6 @@
import { spawnSync } from "node:child_process";
import { confirm as clackConfirm, select as clackSelect, text as clackText } from "@clack/prompts";
import {
CLAUDE_CLI_PROFILE_ID,
ensureAuthProfileStore,
upsertAuthProfile,
} from "../../agents/auth-profiles.js";
import { upsertAuthProfile } from "../../agents/auth-profiles.js";
import { normalizeProviderId } from "../../agents/model-selection.js";
import {
resolveAgentDir,
@@ -33,6 +27,7 @@ import type {
ProviderPlugin,
} from "../../plugins/types.js";
import type { AuthProfileCredential } from "../../agents/auth-profiles/types.js";
import { validateAnthropicSetupToken } from "../auth-token.js";
const confirm = (params: Parameters<typeof clackConfirm>[0]) =>
clackConfirm({
@@ -73,9 +68,7 @@ export async function modelsAuthSetupTokenCommand(
) {
const provider = resolveTokenProvider(opts.provider ?? "anthropic");
if (provider !== "anthropic") {
throw new Error(
"Only --provider anthropic is supported for setup-token (uses `claude setup-token`).",
);
throw new Error("Only --provider anthropic is supported for setup-token.");
}
if (!process.stdin.isTTY) {
@@ -84,38 +77,38 @@ export async function modelsAuthSetupTokenCommand(
if (!opts.yes) {
const proceed = await confirm({
message: "Run `claude setup-token` now?",
message: "Have you run `claude setup-token` and copied the token?",
initialValue: true,
});
if (!proceed) return;
}
const res = spawnSync("claude", ["setup-token"], { stdio: "inherit" });
if (res.error) throw res.error;
if (typeof res.status === "number" && res.status !== 0) {
throw new Error(`claude setup-token failed (exit ${res.status})`);
}
const store = ensureAuthProfileStore(undefined, {
allowKeychainPrompt: true,
const tokenInput = await text({
message: "Paste Anthropic setup-token",
validate: (value) => validateAnthropicSetupToken(String(value ?? "")),
});
const token = String(tokenInput).trim();
const profileId = resolveDefaultTokenProfileId(provider);
upsertAuthProfile({
profileId,
credential: {
type: "token",
provider,
token,
},
});
const synced = store.profiles[CLAUDE_CLI_PROFILE_ID];
if (!synced) {
throw new Error(
`No Claude Code CLI credentials found after setup-token. Expected auth profile ${CLAUDE_CLI_PROFILE_ID}.`,
);
}
await updateConfig((cfg) =>
applyAuthProfileConfig(cfg, {
profileId: CLAUDE_CLI_PROFILE_ID,
provider: "anthropic",
mode: "oauth",
profileId,
provider,
mode: "token",
}),
);
logConfigUpdated(runtime);
runtime.log(`Auth profile: ${CLAUDE_CLI_PROFILE_ID} (anthropic/oauth)`);
runtime.log(`Auth profile: ${profileId} (${provider}/token)`);
}
export async function modelsAuthPasteTokenCommand(
@@ -189,7 +182,7 @@ export async function modelsAuthAddCommand(_opts: Record<string, never>, runtime
{
value: "setup-token",
label: "setup-token (claude)",
hint: "Runs `claude setup-token` (recommended)",
hint: "Paste a setup-token from `claude setup-token`",
},
]
: []),

View File

@@ -487,7 +487,7 @@ export async function modelsStatusCommand(
for (const provider of missingProvidersInUse) {
const hint =
provider === "anthropic"
? `Run \`claude setup-token\` or \`${formatCliCommand("clawdbot configure")}\`.`
? `Run \`claude setup-token\`, then \`${formatCliCommand("clawdbot models auth setup-token")}\` or \`${formatCliCommand("clawdbot configure")}\`.`
: `Run \`${formatCliCommand("clawdbot configure")}\` or set an API key env var.`;
runtime.log(`- ${theme.heading(provider)} ${hint}`);
}
@@ -558,9 +558,7 @@ export async function modelsStatusCommand(
: profile.expiresAt
? ` expires in ${formatRemainingShort(profile.remainingMs)}`
: " expires unknown";
const source =
profile.source !== "store" ? colorize(rich, theme.muted, ` (${profile.source})`) : "";
runtime.log(` - ${label} ${status}${expiry}${source}`);
runtime.log(` - ${label} ${status}${expiry}`);
}
}
}

View File

@@ -1,9 +1,4 @@
import {
CLAUDE_CLI_PROFILE_ID,
CODEX_CLI_PROFILE_ID,
ensureAuthProfileStore,
upsertAuthProfile,
} from "../../../agents/auth-profiles.js";
import { upsertAuthProfile } from "../../../agents/auth-profiles.js";
import { normalizeProviderId } from "../../../agents/model-selection.js";
import { parseDurationMs } from "../../../cli/parse-duration.js";
import type { ClawdbotConfig } from "../../../config/config.js";
@@ -36,7 +31,6 @@ import {
setZaiApiKey,
} from "../../onboard-auth.js";
import type { AuthChoice, OnboardOptions } from "../../onboard-types.js";
import { applyOpenAICodexModelDefault } from "../../openai-codex-model-default.js";
import { resolveNonInteractiveApiKey } from "../api-keys.js";
import { shortenHomePath } from "../../../utils.js";
@@ -50,6 +44,28 @@ export async function applyNonInteractiveAuthChoice(params: {
const { authChoice, opts, runtime, baseConfig } = params;
let nextConfig = params.nextConfig;
if (authChoice === "claude-cli" || authChoice === "codex-cli") {
runtime.error(
[
`Auth choice "${authChoice}" is deprecated.`,
'Use "--auth-choice token" (Anthropic setup-token) or "--auth-choice openai-codex".',
].join("\n"),
);
runtime.exit(1);
return null;
}
if (authChoice === "setup-token") {
runtime.error(
[
'Auth choice "setup-token" requires interactive mode.',
'Use "--auth-choice token" with --token and --token-provider anthropic.',
].join("\n"),
);
runtime.exit(1);
return null;
}
if (authChoice === "apiKey") {
const resolved = await resolveNonInteractiveApiKey({
provider: "anthropic",
@@ -318,41 +334,6 @@ export async function applyNonInteractiveAuthChoice(params: {
return applyMinimaxApiConfig(nextConfig, modelId);
}
if (authChoice === "claude-cli") {
const store = ensureAuthProfileStore(undefined, {
allowKeychainPrompt: false,
});
if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) {
runtime.error(
process.platform === "darwin"
? 'No Claude Code CLI credentials found. Run interactive onboarding to approve Keychain access for "Claude Code-credentials".'
: "No Claude Code CLI credentials found at ~/.claude/.credentials.json",
);
runtime.exit(1);
return null;
}
return applyAuthProfileConfig(nextConfig, {
profileId: CLAUDE_CLI_PROFILE_ID,
provider: "anthropic",
mode: "oauth",
});
}
if (authChoice === "codex-cli") {
const store = ensureAuthProfileStore();
if (!store.profiles[CODEX_CLI_PROFILE_ID]) {
runtime.error("No Codex CLI credentials found at ~/.codex/auth.json");
runtime.exit(1);
return null;
}
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: CODEX_CLI_PROFILE_ID,
provider: "openai-codex",
mode: "oauth",
});
return applyOpenAICodexModelDefault(nextConfig).next;
}
if (authChoice === "minimax") return applyMinimaxConfig(nextConfig);
if (authChoice === "opencode-zen") {

View File

@@ -12,9 +12,33 @@ import type { OnboardOptions } from "./onboard-types.js";
export async function onboardCommand(opts: OnboardOptions, runtime: RuntimeEnv = defaultRuntime) {
assertSupportedRuntime(runtime);
const authChoice = opts.authChoice === "oauth" ? ("setup-token" as const) : opts.authChoice;
const normalizedAuthChoice =
authChoice === "claude-cli"
? ("setup-token" as const)
: authChoice === "codex-cli"
? ("openai-codex" as const)
: authChoice;
if (opts.nonInteractive && (authChoice === "claude-cli" || authChoice === "codex-cli")) {
runtime.error(
[
`Auth choice "${authChoice}" is deprecated.`,
'Use "--auth-choice token" (Anthropic setup-token) or "--auth-choice openai-codex".',
].join("\n"),
);
runtime.exit(1);
return;
}
if (authChoice === "claude-cli") {
runtime.log('Auth choice "claude-cli" is deprecated; using setup-token flow instead.');
}
if (authChoice === "codex-cli") {
runtime.log('Auth choice "codex-cli" is deprecated; using OpenAI Codex OAuth instead.');
}
const flow = opts.flow === "manual" ? ("advanced" as const) : opts.flow;
const normalizedOpts =
authChoice === opts.authChoice && flow === opts.flow ? opts : { ...opts, authChoice, flow };
normalizedAuthChoice === opts.authChoice && flow === opts.flow
? opts
: { ...opts, authChoice: normalizedAuthChoice, flow };
if (normalizedOpts.nonInteractive && normalizedOpts.acceptRisk !== true) {
runtime.error(

View File

@@ -3,7 +3,6 @@ import os from "node:os";
import path from "node:path";
import {
CLAUDE_CLI_PROFILE_ID,
ensureAuthProfileStore,
listProfilesForProvider,
resolveApiKeyForProfile,
@@ -111,9 +110,7 @@ async function resolveOAuthToken(params: {
provider: params.provider,
});
// Claude Code CLI creds are the only Anthropic tokens that reliably include the
// `user:profile` scope required for the OAuth usage endpoint.
const candidates = params.provider === "anthropic" ? [CLAUDE_CLI_PROFILE_ID, ...order] : order;
const candidates = order;
const deduped: string[] = [];
for (const entry of candidates) {
if (!deduped.includes(entry)) deduped.push(entry);

View File

@@ -360,7 +360,6 @@ export async function runOnboardingWizard(
prompter,
store: authStore,
includeSkip: true,
includeClaudeCliIfMissing: true,
}));
const authResult = await applyAuthChoice({