diff --git a/src/agents/auth-health.ts b/src/agents/auth-health.ts index 51e969b94..8455b4727 100644 --- a/src/agents/auth-health.ts +++ b/src/agents/auth-health.ts @@ -19,7 +19,7 @@ export type AuthProfileHealthStatus = export type AuthProfileHealth = { profileId: string; provider: string; - type: "oauth" | "api_key"; + type: "oauth" | "token" | "api_key"; status: AuthProfileHealthStatus; expiresAt?: number; remainingMs?: number; @@ -109,6 +109,39 @@ function buildProfileHealth(params: { }; } + if (credential.type === "token") { + const expiresAt = + typeof credential.expires === "number" && + Number.isFinite(credential.expires) + ? credential.expires + : undefined; + if (!expiresAt || expiresAt <= 0) { + return { + profileId, + provider: credential.provider, + type: "token", + status: "static", + source, + label, + }; + } + const { status, remainingMs } = resolveOAuthStatus( + expiresAt, + now, + warnAfterMs, + ); + return { + profileId, + provider: credential.provider, + type: "token", + status, + expiresAt, + remainingMs, + source, + label, + }; + } + const { status, remainingMs } = resolveOAuthStatus( credential.expires, now, @@ -192,16 +225,18 @@ export function buildAuthHealthSummary(params: { } const oauthProfiles = provider.profiles.filter((p) => p.type === "oauth"); + const tokenProfiles = provider.profiles.filter((p) => p.type === "token"); const apiKeyProfiles = provider.profiles.filter( (p) => p.type === "api_key", ); - if (oauthProfiles.length === 0) { + const expirable = [...oauthProfiles, ...tokenProfiles]; + if (expirable.length === 0) { provider.status = apiKeyProfiles.length > 0 ? "static" : "missing"; continue; } - const expiryCandidates = oauthProfiles + const expiryCandidates = expirable .map((p) => p.expiresAt) .filter((v): v is number => typeof v === "number" && Number.isFinite(v)); if (expiryCandidates.length > 0) { @@ -209,7 +244,7 @@ export function buildAuthHealthSummary(params: { provider.remainingMs = provider.expiresAt - now; } - const statuses = oauthProfiles.map((p) => p.status); + const statuses = expirable.map((p) => p.status); if (statuses.includes("expired") || statuses.includes("missing")) { provider.status = "expired"; } else if (statuses.includes("expiring")) { diff --git a/src/agents/auth-profiles.test.ts b/src/agents/auth-profiles.test.ts index 1e75668e1..0c582e7bc 100644 --- a/src/agents/auth-profiles.test.ts +++ b/src/agents/auth-profiles.test.ts @@ -428,7 +428,7 @@ describe("external CLI credential sync", () => { ); expect(store.profiles[CLAUDE_CLI_PROFILE_ID]).toBeDefined(); expect( - (store.profiles[CLAUDE_CLI_PROFILE_ID] as { access: string }).access, + (store.profiles[CLAUDE_CLI_PROFILE_ID] as { token: string }).token, ).toBe("fresh-access-token"); expect( (store.profiles[CLAUDE_CLI_PROFILE_ID] as { expires: number }).expires, @@ -537,7 +537,7 @@ describe("external CLI credential sync", () => { } }); - it("does not overwrite fresher store OAuth with older Claude CLI credentials", () => { + it("does not overwrite fresher store token with older Claude CLI credentials", () => { const agentDir = fs.mkdtempSync( path.join(os.tmpdir(), "clawdbot-cli-no-downgrade-"), ); @@ -567,10 +567,9 @@ describe("external CLI credential sync", () => { version: 1, profiles: { [CLAUDE_CLI_PROFILE_ID]: { - type: "oauth", + type: "token", provider: "anthropic", - access: "store-access", - refresh: "store-refresh", + token: "store-access", expires: Date.now() + 60 * 60 * 1000, }, }, @@ -579,7 +578,7 @@ describe("external CLI credential sync", () => { const store = ensureAuthProfileStore(agentDir); expect( - (store.profiles[CLAUDE_CLI_PROFILE_ID] as { access: string }).access, + (store.profiles[CLAUDE_CLI_PROFILE_ID] as { token: string }).token, ).toBe("store-access"); } finally { restoreHomeEnv(originalHome); diff --git a/src/agents/auth-profiles.ts b/src/agents/auth-profiles.ts index c0348c0e7..780d476d8 100644 --- a/src/agents/auth-profiles.ts +++ b/src/agents/auth-profiles.ts @@ -48,13 +48,29 @@ export type ApiKeyCredential = { email?: string; }; +export type TokenCredential = { + /** + * Static bearer-style token (often OAuth access token / PAT). + * Not refreshable by clawdbot (unlike `type: "oauth"`). + */ + type: "token"; + provider: string; + token: string; + /** Optional expiry timestamp (ms since epoch). */ + expires?: number; + email?: string; +}; + export type OAuthCredential = OAuthCredentials & { type: "oauth"; provider: OAuthProvider; email?: string; }; -export type AuthProfileCredential = ApiKeyCredential | OAuthCredential; +export type AuthProfileCredential = + | ApiKeyCredential + | TokenCredential + | OAuthCredential; /** Per-profile usage statistics for round-robin and cooldown tracking */ export type ProfileUsageStats = { @@ -220,7 +236,13 @@ function coerceLegacyStore(raw: unknown): LegacyAuthStore | null { for (const [key, value] of Object.entries(record)) { if (!value || typeof value !== "object") continue; const typed = value as Partial; - if (typed.type !== "api_key" && typed.type !== "oauth") continue; + if ( + typed.type !== "api_key" && + typed.type !== "oauth" && + typed.type !== "token" + ) { + continue; + } entries[key] = { ...typed, provider: typed.provider ?? (key as OAuthProvider), @@ -238,7 +260,13 @@ function coerceAuthStore(raw: unknown): AuthProfileStore | null { for (const [key, value] of Object.entries(profiles)) { if (!value || typeof value !== "object") continue; const typed = value as Partial; - if (typed.type !== "api_key" && typed.type !== "oauth") continue; + if ( + typed.type !== "api_key" && + typed.type !== "oauth" && + typed.type !== "token" + ) { + continue; + } if (!typed.provider) continue; normalized[key] = typed as AuthProfileCredential; } @@ -285,7 +313,7 @@ function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean { */ function readClaudeCliCredentials(options?: { allowKeychainPrompt?: boolean; -}): OAuthCredential | null { +}): TokenCredential | null { if (process.platform === "darwin" && options?.allowKeychainPrompt !== false) { const keychainCreds = readClaudeCliKeychainCredentials(); if (keychainCreds) { @@ -306,18 +334,15 @@ function readClaudeCliCredentials(options?: { 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 refreshToken !== "string" || !refreshToken) return null; if (typeof expiresAt !== "number" || expiresAt <= 0) return null; return { - type: "oauth", + type: "token", provider: "anthropic", - access: accessToken, - refresh: refreshToken, + token: accessToken, expires: expiresAt, }; } @@ -326,7 +351,7 @@ function readClaudeCliCredentials(options?: { * Read Claude Code credentials from macOS keychain. * Uses the `security` CLI to access keychain without native dependencies. */ -function readClaudeCliKeychainCredentials(): OAuthCredential | null { +function readClaudeCliKeychainCredentials(): TokenCredential | null { try { const result = execSync( 'security find-generic-password -s "Claude Code-credentials" -w', @@ -338,18 +363,15 @@ function readClaudeCliKeychainCredentials(): OAuthCredential | null { 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 refreshToken !== "string" || !refreshToken) return null; if (typeof expiresAt !== "number" || expiresAt <= 0) return null; return { - type: "oauth", + type: "token", provider: "anthropic", - access: accessToken, - refresh: refreshToken, + token: accessToken, expires: expiresAt, }; } catch { @@ -416,6 +438,20 @@ function shallowEqualOAuthCredentials( ); } +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 + ); +} + /** * Sync OAuth credentials from external CLI tools (Claude CLI, Codex CLI) into the store. * This allows clawdbot to use the same credentials as these tools without requiring @@ -434,25 +470,28 @@ function syncExternalCliCredentials( const claudeCreds = readClaudeCliCredentials(options); if (claudeCreds) { const existing = store.profiles[CLAUDE_CLI_PROFILE_ID]; - const existingOAuth = existing?.type === "oauth" ? existing : undefined; + const existingToken = existing?.type === "token" ? existing : undefined; // Update if: no existing profile, existing is not oauth, or CLI has newer/valid token const shouldUpdate = - !existingOAuth || - existingOAuth.provider !== "anthropic" || - existingOAuth.expires <= now || - (claudeCreds.expires > now && - claudeCreds.expires > existingOAuth.expires); + !existingToken || + existingToken.provider !== "anthropic" || + (existingToken.expires ?? 0) <= now || + ((claudeCreds.expires ?? 0) > now && + (claudeCreds.expires ?? 0) > (existingToken.expires ?? 0)); if ( shouldUpdate && - !shallowEqualOAuthCredentials(existingOAuth, claudeCreds) + !shallowEqualTokenCredentials(existingToken, claudeCreds) ) { store.profiles[CLAUDE_CLI_PROFILE_ID] = claudeCreds; mutated = true; log.info("synced anthropic credentials from claude cli", { profileId: CLAUDE_CLI_PROFILE_ID, - expires: new Date(claudeCreds.expires).toISOString(), + expires: + typeof claudeCreds.expires === "number" + ? new Date(claudeCreds.expires).toISOString() + : "unknown", }); } } @@ -515,6 +554,16 @@ export function loadAuthProfileStore(): AuthProfileStore { key: cred.key, ...(cred.email ? { email: cred.email } : {}), }; + } else if (cred.type === "token") { + store.profiles[profileId] = { + type: "token", + provider: cred.provider ?? (provider as OAuthProvider), + token: cred.token, + ...(typeof cred.expires === "number" + ? { expires: cred.expires } + : {}), + ...(cred.email ? { email: cred.email } : {}), + }; } else { store.profiles[profileId] = { type: "oauth", @@ -570,6 +619,16 @@ export function ensureAuthProfileStore( key: cred.key, ...(cred.email ? { email: cred.email } : {}), }; + } else if (cred.type === "token") { + store.profiles[profileId] = { + type: "token", + provider: cred.provider ?? (provider as OAuthProvider), + token: cred.token, + ...(typeof cred.expires === "number" + ? { expires: cred.expires } + : {}), + ...(cred.email ? { email: cred.email } : {}), + }; } else { store.profiles[profileId] = { type: "oauth", @@ -882,16 +941,17 @@ function orderProfilesByMode( // Then by lastUsed (oldest first = round-robin within type) const scored = available.map((profileId) => { const type = store.profiles[profileId]?.type; - const typeScore = type === "oauth" ? 0 : type === "api_key" ? 1 : 2; + const typeScore = + type === "oauth" ? 0 : type === "token" ? 1 : type === "api_key" ? 2 : 3; const lastUsed = store.usageStats?.[profileId]?.lastUsed ?? 0; return { profileId, typeScore, lastUsed }; }); - // Primary sort: type preference (oauth > api_key). + // Primary sort: type preference (oauth > token > api_key). // Secondary sort: lastUsed (oldest first for round-robin within type). const sorted = scored .sort((a, b) => { - // First by type (oauth > api_key) + // First by type (oauth > token > api_key) if (a.typeScore !== b.typeScore) return a.typeScore - b.typeScore; // Then by lastUsed (oldest first) return a.lastUsed - b.lastUsed; @@ -921,11 +981,27 @@ export async function resolveApiKeyForProfile(params: { if (!cred) return null; const profileConfig = cfg?.auth?.profiles?.[profileId]; if (profileConfig && profileConfig.provider !== cred.provider) return null; - if (profileConfig && profileConfig.mode !== cred.type) return null; + if (profileConfig && profileConfig.mode !== cred.type) { + // Compatibility: treat "oauth" config as compatible with stored token profiles. + if (!(profileConfig.mode === "oauth" && cred.type === "token")) return null; + } if (cred.type === "api_key") { return { apiKey: cred.key, provider: cred.provider, email: cred.email }; } + if (cred.type === "token") { + const token = cred.token?.trim(); + if (!token) return null; + if ( + typeof cred.expires === "number" && + Number.isFinite(cred.expires) && + cred.expires > 0 && + Date.now() >= cred.expires + ) { + return null; + } + return { apiKey: token, provider: cred.provider, email: cred.email }; + } if (Date.now() < cred.expires) { return { apiKey: buildOAuthApiKey(cred.provider, cred), diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 4390747ac..22ff3879b 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -100,7 +100,7 @@ export async function resolveApiKeyForProvider(params: { } export type EnvApiKeyResult = { apiKey: string; source: string }; -export type ModelAuthMode = "api-key" | "oauth" | "mixed" | "unknown"; +export type ModelAuthMode = "api-key" | "oauth" | "token" | "mixed" | "unknown"; export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null { const applied = new Set(getShellEnvAppliedKeys()); @@ -158,10 +158,14 @@ export function resolveModelAuthMode( const modes = new Set( profiles .map((id) => authStore.profiles[id]?.type) - .filter((mode): mode is "api_key" | "oauth" => Boolean(mode)), + .filter((mode): mode is "api_key" | "oauth" | "token" => Boolean(mode)), ); - if (modes.has("oauth") && modes.has("api_key")) return "mixed"; + const distinct = ["oauth", "token", "api_key"].filter((k) => + modes.has(k as "oauth" | "token" | "api_key"), + ); + if (distinct.length >= 2) return "mixed"; if (modes.has("oauth")) return "oauth"; + if (modes.has("token")) return "token"; if (modes.has("api_key")) return "api-key"; } diff --git a/src/auto-reply/reply/commands.ts b/src/auto-reply/reply/commands.ts index f955525bb..5102d1b78 100644 --- a/src/auto-reply/reply/commands.ts +++ b/src/auto-reply/reply/commands.ts @@ -236,6 +236,10 @@ function resolveModelAuthLabel( if (profile.type === "oauth") { return `oauth${label ? ` (${label})` : ""}`; } + if (profile.type === "token") { + const snippet = formatApiKeySnippet(profile.token); + return `token ${snippet}${label ? ` (${label})` : ""}`; + } const snippet = formatApiKeySnippet(profile.key); return `api-key ${snippet}${label ? ` (${label})` : ""}`; } diff --git a/src/auto-reply/reply/directive-handling.ts b/src/auto-reply/reply/directive-handling.ts index 44e3fe279..15a89b79e 100644 --- a/src/auto-reply/reply/directive-handling.ts +++ b/src/auto-reply/reply/directive-handling.ts @@ -88,13 +88,18 @@ const resolveAuthLabel = async ( !profile || (configProfile?.provider && configProfile.provider !== profile.provider) || - (configProfile?.mode && configProfile.mode !== profile.type) + (configProfile?.mode && + configProfile.mode !== profile.type && + !(configProfile.mode === "oauth" && profile.type === "token")) ) { return `${profileId}=missing`; } if (profile.type === "api_key") { return `${profileId}=${maskApiKey(profile.key)}`; } + if (profile.type === "token") { + return `${profileId}=token:${maskApiKey(profile.token)}`; + } const display = resolveAuthProfileDisplayLabel({ cfg, store, diff --git a/src/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts index 6c161ad51..a5bd2aa29 100644 --- a/src/commands/auth-choice-options.ts +++ b/src/commands/auth-choice-options.ts @@ -61,7 +61,7 @@ export function buildAuthChoiceOptions(params: { } const claudeCli = params.store.profiles[CLAUDE_CLI_PROFILE_ID]; - if (claudeCli?.type === "oauth") { + if (claudeCli?.type === "oauth" || claudeCli?.type === "token") { options.push({ value: "claude-cli", label: "Anthropic OAuth (Claude CLI)", @@ -75,7 +75,11 @@ export function buildAuthChoiceOptions(params: { }); } - options.push({ value: "oauth", label: "Anthropic OAuth (Claude Pro/Max)" }); + options.push({ + value: "oauth", + label: "Anthropic token (setup-token)", + hint: "Runs `claude setup-token`", + }); options.push({ value: "openai-codex", @@ -87,6 +91,11 @@ export function buildAuthChoiceOptions(params: { }); options.push({ value: "gemini-api-key", label: "Google Gemini API key" }); options.push({ value: "apiKey", label: "Anthropic API key" }); + options.push({ + value: "token", + label: "Paste token (advanced)", + hint: "Stores as a non-refreshable token profile", + }); options.push({ value: "minimax", label: "Minimax M2.1 (LM Studio)" }); if (params.includeSkip) { options.push({ value: "skip", label: "Skip for now" }); diff --git a/src/commands/auth-choice.ts b/src/commands/auth-choice.ts index 42eba1ad3..45bf5cf9a 100644 --- a/src/commands/auth-choice.ts +++ b/src/commands/auth-choice.ts @@ -1,5 +1,4 @@ import { - loginAnthropic, loginOpenAICodex, type OAuthCredentials, type OAuthProvider, @@ -10,6 +9,7 @@ import { CODEX_CLI_PROFILE_ID, ensureAuthProfileStore, listProfilesForProvider, + upsertAuthProfile, } from "../agents/auth-profiles.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import { @@ -17,7 +17,11 @@ import { resolveEnvApiKey, } from "../agents/model-auth.js"; import { loadModelCatalog } from "../agents/model-catalog.js"; -import { resolveConfiguredModelRef } from "../agents/model-selection.js"; +import { + normalizeProviderId, + resolveConfiguredModelRef, +} from "../agents/model-selection.js"; +import { parseDurationMs } from "../cli/parse-duration.js"; import type { ClawdbotConfig } from "../config/config.js"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; @@ -134,44 +138,62 @@ export async function applyAuthChoice(params: { if (params.authChoice === "oauth") { await params.prompter.note( - "Browser will open. Paste the code shown after login (code#state).", - "Anthropic OAuth", + [ + "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 token", ); - const spin = params.prompter.progress("Waiting for authorization…"); - let oauthCreds: OAuthCredentials | null = null; - try { - oauthCreds = await loginAnthropic( - async (url) => { - await openUrl(url); - params.runtime.log(`Open: ${url}`); - }, - async () => { - const code = await params.prompter.text({ - message: "Paste authorization code (code#state)", - validate: (value) => (value?.trim() ? undefined : "Required"), - }); - return String(code); - }, - ); - spin.stop("OAuth complete"); - if (oauthCreds) { - await writeOAuthCredentials("anthropic", oauthCreds, params.agentDir); - const profileId = `anthropic:${oauthCreds.email ?? "default"}`; - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId, - provider: "anthropic", - mode: "oauth", - email: oauthCreds.email ?? undefined, - }); - } - } catch (err) { - spin.stop("OAuth failed"); - params.runtime.error(String(err)); + + if (!process.stdin.isTTY) { await params.prompter.note( - "Trouble with OAuth? See https://docs.clawd.bot/start/faq", - "OAuth help", + "`claude setup-token` requires an interactive TTY.", + "Anthropic token", ); + return { config: nextConfig, agentModelOverride }; } + + const proceed = await params.prompter.confirm({ + message: "Run `claude setup-token` now?", + initialValue: true, + }); + if (!proceed) return { config: nextConfig, agentModelOverride }; + + 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 token", + ); + return { config: nextConfig, agentModelOverride }; + } + if (typeof res.status === "number" && res.status !== 0) { + await params.prompter.note( + `claude setup-token failed (exit ${res.status})`, + "Anthropic token", + ); + return { config: nextConfig, agentModelOverride }; + } + + const store = ensureAuthProfileStore(params.agentDir, { + allowKeychainPrompt: true, + }); + if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) { + await params.prompter.note( + `No Claude CLI credentials found after setup-token. Expected ${CLAUDE_CLI_PROFILE_ID}.`, + "Anthropic token", + ); + return { config: nextConfig, agentModelOverride }; + } + + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: CLAUDE_CLI_PROFILE_ID, + provider: "anthropic", + mode: "token", + }); } else if (params.authChoice === "claude-cli") { const store = ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false, @@ -202,18 +224,108 @@ export async function applyAuthChoice(params: { }); if (!storeWithKeychain.profiles[CLAUDE_CLI_PROFILE_ID]) { - await params.prompter.note( - process.platform === "darwin" - ? 'No Claude CLI credentials found in Keychain ("Claude Code-credentials") or ~/.claude/.credentials.json.' - : "No Claude CLI credentials found at ~/.claude/.credentials.json.", - "Claude CLI OAuth", - ); - return { config: nextConfig, agentModelOverride }; + 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 CLI credentials found in Keychain ("Claude Code-credentials") or ~/.claude/.credentials.json.' + : "No Claude CLI credentials found at ~/.claude/.credentials.json.", + "Claude CLI OAuth", + ); + return { config: nextConfig, agentModelOverride }; + } } nextConfig = applyAuthProfileConfig(nextConfig, { profileId: CLAUDE_CLI_PROFILE_ID, provider: "anthropic", - mode: "oauth", + mode: "token", + }); + } else if (params.authChoice === "token") { + const providerRaw = await params.prompter.text({ + message: "Token provider id (e.g. anthropic)", + validate: (value) => (value?.trim() ? undefined : "Required"), + }); + const provider = normalizeProviderId(String(providerRaw).trim()); + const defaultProfileId = `${provider}:manual`; + + const profileIdRaw = await params.prompter.text({ + message: "Auth profile id", + initialValue: defaultProfileId, + validate: (value) => (value?.trim() ? undefined : "Required"), + }); + const profileId = String(profileIdRaw).trim(); + + const tokenRaw = await params.prompter.text({ + message: `Paste token for ${provider}`, + validate: (value) => (value?.trim() ? undefined : "Required"), + }); + const token = String(tokenRaw).trim(); + + const wantsExpiry = await params.prompter.confirm({ + message: "Does this token expire?", + initialValue: false, + }); + const expiresInRaw = wantsExpiry + ? await params.prompter.text({ + message: "Expires in (duration)", + initialValue: "365d", + validate: (value) => { + try { + parseDurationMs(String(value ?? ""), { defaultUnit: "d" }); + return undefined; + } catch { + return "Invalid duration (e.g. 365d, 12h, 30m)"; + } + }, + }) + : ""; + + const expiresIn = String(expiresInRaw).trim(); + const expires = expiresIn + ? Date.now() + parseDurationMs(expiresIn, { defaultUnit: "d" }) + : undefined; + + upsertAuthProfile({ + profileId, + agentDir: params.agentDir, + credential: { + type: "token", + provider, + token, + ...(expires ? { expires } : {}), + }, + }); + + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId, + provider, + mode: "token", }); } else if (params.authChoice === "openai-codex") { const isRemote = isRemoteEnvironment(); diff --git a/src/commands/doctor-auth.ts b/src/commands/doctor-auth.ts index d02cb4974..13a158846 100644 --- a/src/commands/doctor-auth.ts +++ b/src/commands/doctor-auth.ts @@ -86,7 +86,7 @@ export async function noteAuthProfileHealth(params: { const findIssues = () => summary.profiles.filter( (profile) => - profile.type === "oauth" && + (profile.type === "oauth" || profile.type === "token") && (profile.status === "expired" || profile.status === "expiring" || profile.status === "missing"), @@ -96,13 +96,15 @@ export async function noteAuthProfileHealth(params: { if (issues.length === 0) return; const shouldRefresh = await params.prompter.confirmRepair({ - message: "Refresh expiring OAuth tokens now?", + message: "Refresh expiring OAuth tokens now? (static tokens need re-auth)", initialValue: true, }); if (shouldRefresh) { - const refreshTargets = issues.filter((issue) => - ["expired", "expiring", "missing"].includes(issue.status), + const refreshTargets = issues.filter( + (issue) => + issue.type === "oauth" && + ["expired", "expiring", "missing"].includes(issue.status), ); const errors: string[] = []; for (const profile of refreshTargets) { diff --git a/src/commands/models/list.ts b/src/commands/models/list.ts index 5a3e178a3..0ec63a3ab 100644 --- a/src/commands/models/list.ts +++ b/src/commands/models/list.ts @@ -159,6 +159,7 @@ type ProviderAuthOverview = { profiles: { count: number; oauth: number; + token: number; apiKey: number; labels: string[]; }; @@ -180,6 +181,9 @@ function resolveProviderAuthOverview(params: { if (profile.type === "api_key") { return `${profileId}=${maskApiKey(profile.key)}`; } + if (profile.type === "token") { + return `${profileId}=token:${maskApiKey(profile.token)}`; + } const display = resolveAuthProfileDisplayLabel({ cfg, store, profileId }); const suffix = display === profileId @@ -192,6 +196,9 @@ function resolveProviderAuthOverview(params: { const oauthCount = profiles.filter( (id) => store.profiles[id]?.type === "oauth", ).length; + const tokenCount = profiles.filter( + (id) => store.profiles[id]?.type === "token", + ).length; const apiKeyCount = profiles.filter( (id) => store.profiles[id]?.type === "api_key", ).length; @@ -227,6 +234,7 @@ function resolveProviderAuthOverview(params: { profiles: { count: profiles.length, oauth: oauthCount, + token: tokenCount, apiKey: apiKeyCount, labels, }, @@ -739,11 +747,16 @@ export async function modelsStatusCommand( const providersWithOauth = providerAuth .filter( - (entry) => entry.profiles.oauth > 0 || entry.env?.value === "OAuth (env)", + (entry) => + entry.profiles.oauth > 0 || + entry.profiles.token > 0 || + entry.env?.value === "OAuth (env)", ) .map((entry) => { const count = - entry.profiles.oauth || (entry.env?.value === "OAuth (env)" ? 1 : 0); + entry.profiles.oauth + + entry.profiles.token + + (entry.env?.value === "OAuth (env)" ? 1 : 0); return `${entry.provider} (${count})`; }); @@ -754,7 +767,7 @@ export async function modelsStatusCommand( providers, }); const oauthProfiles = authHealth.profiles.filter( - (profile) => profile.type === "oauth", + (profile) => profile.type === "oauth" || profile.type === "token", ); const checkStatus = (() => { @@ -926,7 +939,7 @@ export async function modelsStatusCommand( ); runtime.log( `${label( - `Providers w/ OAuth (${providersWithOauth.length || 0})`, + `Providers w/ OAuth/tokens (${providersWithOauth.length || 0})`, )}${colorize(rich, theme.muted, ":")} ${colorize( rich, providersWithOauth.length ? theme.info : theme.muted, @@ -953,7 +966,7 @@ export async function modelsStatusCommand( bits.push( formatKeyValue( "profiles", - `${entry.profiles.count} (oauth=${entry.profiles.oauth}, api_key=${entry.profiles.apiKey})`, + `${entry.profiles.count} (oauth=${entry.profiles.oauth}, token=${entry.profiles.token}, api_key=${entry.profiles.apiKey})`, rich, ), ); @@ -1003,7 +1016,7 @@ export async function modelsStatusCommand( } runtime.log(""); - runtime.log(colorize(rich, theme.heading, "OAuth status")); + runtime.log(colorize(rich, theme.heading, "OAuth/token status")); if (oauthProfiles.length === 0) { runtime.log(colorize(rich, theme.muted, "- none")); return; @@ -1011,6 +1024,7 @@ export async function modelsStatusCommand( const formatStatus = (status: string) => { if (status === "ok") return colorize(rich, theme.success, "ok"); + if (status === "static") return colorize(rich, theme.muted, "static"); if (status === "expiring") return colorize(rich, theme.warn, "expiring"); if (status === "missing") return colorize(rich, theme.warn, "unknown"); return colorize(rich, theme.error, "expired"); @@ -1020,9 +1034,12 @@ export async function modelsStatusCommand( const labelText = profile.label || profile.profileId; const label = colorize(rich, theme.accent, labelText); const status = formatStatus(profile.status); - const expiry = profile.expiresAt - ? ` expires in ${formatRemainingShort(profile.remainingMs)}` - : " expires unknown"; + const expiry = + profile.status === "static" + ? "" + : profile.expiresAt + ? ` expires in ${formatRemainingShort(profile.remainingMs)}` + : " expires unknown"; const source = profile.source !== "store" ? colorize(rich, theme.muted, ` (${profile.source})`) diff --git a/src/commands/onboard-auth.test.ts b/src/commands/onboard-auth.test.ts index 5def9a6ef..a970ca032 100644 --- a/src/commands/onboard-auth.test.ts +++ b/src/commands/onboard-auth.test.ts @@ -52,7 +52,7 @@ describe("writeOAuthCredentials", () => { expires: Date.now() + 60_000, } satisfies OAuthCredentials; - await writeOAuthCredentials("anthropic", creds); + await writeOAuthCredentials("openai-codex", creds); // Now writes to the multi-agent path: agents/main/agent const authProfilePath = path.join( @@ -66,7 +66,7 @@ describe("writeOAuthCredentials", () => { const parsed = JSON.parse(raw) as { profiles?: Record; }; - expect(parsed.profiles?.["anthropic:default"]).toMatchObject({ + expect(parsed.profiles?.["openai-codex:default"]).toMatchObject({ refresh: "refresh-token", access: "access-token", type: "oauth", diff --git a/src/commands/onboard-auth.ts b/src/commands/onboard-auth.ts index e1116a98d..76e976c22 100644 --- a/src/commands/onboard-auth.ts +++ b/src/commands/onboard-auth.ts @@ -51,7 +51,7 @@ export function applyAuthProfileConfig( params: { profileId: string; provider: string; - mode: "api_key" | "oauth"; + mode: "api_key" | "oauth" | "token"; email?: string; preferProfileFirst?: boolean; }, diff --git a/src/commands/onboard-non-interactive.ts b/src/commands/onboard-non-interactive.ts index 51bb6ee84..73c8fc888 100644 --- a/src/commands/onboard-non-interactive.ts +++ b/src/commands/onboard-non-interactive.ts @@ -151,7 +151,7 @@ export async function runNonInteractiveOnboarding( nextConfig = applyAuthProfileConfig(nextConfig, { profileId: CLAUDE_CLI_PROFILE_ID, provider: "anthropic", - mode: "oauth", + mode: "token", }); } else if (authChoice === "codex-cli") { const store = ensureAuthProfileStore(); @@ -169,17 +169,18 @@ export async function runNonInteractiveOnboarding( } else if (authChoice === "minimax") { nextConfig = applyMinimaxConfig(nextConfig); } else if ( + authChoice === "token" || authChoice === "oauth" || authChoice === "openai-codex" || authChoice === "antigravity" ) { - runtime.error( - `${ - authChoice === "oauth" || authChoice === "openai-codex" - ? "OAuth" - : "Antigravity" - } requires interactive mode.`, - ); + const label = + authChoice === "antigravity" + ? "Antigravity" + : authChoice === "token" + ? "Token" + : "OAuth"; + runtime.error(`${label} requires interactive mode.`); runtime.exit(1); return; } diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index 6ee3b5fc9..159cd11e6 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -5,6 +5,7 @@ export type OnboardMode = "local" | "remote"; export type AuthChoice = | "oauth" | "claude-cli" + | "token" | "openai-codex" | "codex-cli" | "antigravity" diff --git a/src/config/types.ts b/src/config/types.ts index a4c7bae17..b99312195 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -985,7 +985,13 @@ export type ModelsConfig = { export type AuthProfileConfig = { provider: string; - mode: "api_key" | "oauth"; + /** + * Credential type expected in auth-profiles.json for this profile id. + * - api_key: static provider API key + * - oauth: refreshable OAuth credentials (access+refresh+expires) + * - token: static bearer-style token (optionally expiring; no refresh) + */ + mode: "api_key" | "oauth" | "token"; email?: string; }; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 9045d7b3c..083d53a11 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -895,7 +895,11 @@ export const ClawdbotSchema = z.object({ z.string(), z.object({ provider: z.string(), - mode: z.union([z.literal("api_key"), z.literal("oauth")]), + mode: z.union([ + z.literal("api_key"), + z.literal("oauth"), + z.literal("token"), + ]), email: z.string().optional(), }), )