feat: support token auth profiles

This commit is contained in:
Peter Steinberger
2026-01-09 07:51:47 +01:00
parent eced473e05
commit 37cbcc97d3
16 changed files with 388 additions and 113 deletions

View File

@@ -19,7 +19,7 @@ export type AuthProfileHealthStatus =
export type AuthProfileHealth = { export type AuthProfileHealth = {
profileId: string; profileId: string;
provider: string; provider: string;
type: "oauth" | "api_key"; type: "oauth" | "token" | "api_key";
status: AuthProfileHealthStatus; status: AuthProfileHealthStatus;
expiresAt?: number; expiresAt?: number;
remainingMs?: 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( const { status, remainingMs } = resolveOAuthStatus(
credential.expires, credential.expires,
now, now,
@@ -192,16 +225,18 @@ export function buildAuthHealthSummary(params: {
} }
const oauthProfiles = provider.profiles.filter((p) => p.type === "oauth"); const oauthProfiles = provider.profiles.filter((p) => p.type === "oauth");
const tokenProfiles = provider.profiles.filter((p) => p.type === "token");
const apiKeyProfiles = provider.profiles.filter( const apiKeyProfiles = provider.profiles.filter(
(p) => p.type === "api_key", (p) => p.type === "api_key",
); );
if (oauthProfiles.length === 0) { const expirable = [...oauthProfiles, ...tokenProfiles];
if (expirable.length === 0) {
provider.status = apiKeyProfiles.length > 0 ? "static" : "missing"; provider.status = apiKeyProfiles.length > 0 ? "static" : "missing";
continue; continue;
} }
const expiryCandidates = oauthProfiles const expiryCandidates = expirable
.map((p) => p.expiresAt) .map((p) => p.expiresAt)
.filter((v): v is number => typeof v === "number" && Number.isFinite(v)); .filter((v): v is number => typeof v === "number" && Number.isFinite(v));
if (expiryCandidates.length > 0) { if (expiryCandidates.length > 0) {
@@ -209,7 +244,7 @@ export function buildAuthHealthSummary(params: {
provider.remainingMs = provider.expiresAt - now; 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")) { if (statuses.includes("expired") || statuses.includes("missing")) {
provider.status = "expired"; provider.status = "expired";
} else if (statuses.includes("expiring")) { } else if (statuses.includes("expiring")) {

View File

@@ -428,7 +428,7 @@ describe("external CLI credential sync", () => {
); );
expect(store.profiles[CLAUDE_CLI_PROFILE_ID]).toBeDefined(); expect(store.profiles[CLAUDE_CLI_PROFILE_ID]).toBeDefined();
expect( 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"); ).toBe("fresh-access-token");
expect( expect(
(store.profiles[CLAUDE_CLI_PROFILE_ID] as { expires: number }).expires, (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( const agentDir = fs.mkdtempSync(
path.join(os.tmpdir(), "clawdbot-cli-no-downgrade-"), path.join(os.tmpdir(), "clawdbot-cli-no-downgrade-"),
); );
@@ -567,10 +567,9 @@ describe("external CLI credential sync", () => {
version: 1, version: 1,
profiles: { profiles: {
[CLAUDE_CLI_PROFILE_ID]: { [CLAUDE_CLI_PROFILE_ID]: {
type: "oauth", type: "token",
provider: "anthropic", provider: "anthropic",
access: "store-access", token: "store-access",
refresh: "store-refresh",
expires: Date.now() + 60 * 60 * 1000, expires: Date.now() + 60 * 60 * 1000,
}, },
}, },
@@ -579,7 +578,7 @@ describe("external CLI credential sync", () => {
const store = ensureAuthProfileStore(agentDir); const store = ensureAuthProfileStore(agentDir);
expect( expect(
(store.profiles[CLAUDE_CLI_PROFILE_ID] as { access: string }).access, (store.profiles[CLAUDE_CLI_PROFILE_ID] as { token: string }).token,
).toBe("store-access"); ).toBe("store-access");
} finally { } finally {
restoreHomeEnv(originalHome); restoreHomeEnv(originalHome);

View File

@@ -48,13 +48,29 @@ export type ApiKeyCredential = {
email?: string; 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 & { export type OAuthCredential = OAuthCredentials & {
type: "oauth"; type: "oauth";
provider: OAuthProvider; provider: OAuthProvider;
email?: string; email?: string;
}; };
export type AuthProfileCredential = ApiKeyCredential | OAuthCredential; export type AuthProfileCredential =
| ApiKeyCredential
| TokenCredential
| OAuthCredential;
/** Per-profile usage statistics for round-robin and cooldown tracking */ /** Per-profile usage statistics for round-robin and cooldown tracking */
export type ProfileUsageStats = { export type ProfileUsageStats = {
@@ -220,7 +236,13 @@ function coerceLegacyStore(raw: unknown): LegacyAuthStore | null {
for (const [key, value] of Object.entries(record)) { for (const [key, value] of Object.entries(record)) {
if (!value || typeof value !== "object") continue; if (!value || typeof value !== "object") continue;
const typed = value as Partial<AuthProfileCredential>; const typed = value as Partial<AuthProfileCredential>;
if (typed.type !== "api_key" && typed.type !== "oauth") continue; if (
typed.type !== "api_key" &&
typed.type !== "oauth" &&
typed.type !== "token"
) {
continue;
}
entries[key] = { entries[key] = {
...typed, ...typed,
provider: typed.provider ?? (key as OAuthProvider), provider: typed.provider ?? (key as OAuthProvider),
@@ -238,7 +260,13 @@ function coerceAuthStore(raw: unknown): AuthProfileStore | null {
for (const [key, value] of Object.entries(profiles)) { for (const [key, value] of Object.entries(profiles)) {
if (!value || typeof value !== "object") continue; if (!value || typeof value !== "object") continue;
const typed = value as Partial<AuthProfileCredential>; const typed = value as Partial<AuthProfileCredential>;
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; if (!typed.provider) continue;
normalized[key] = typed as AuthProfileCredential; normalized[key] = typed as AuthProfileCredential;
} }
@@ -285,7 +313,7 @@ function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean {
*/ */
function readClaudeCliCredentials(options?: { function readClaudeCliCredentials(options?: {
allowKeychainPrompt?: boolean; allowKeychainPrompt?: boolean;
}): OAuthCredential | null { }): TokenCredential | null {
if (process.platform === "darwin" && options?.allowKeychainPrompt !== false) { if (process.platform === "darwin" && options?.allowKeychainPrompt !== false) {
const keychainCreds = readClaudeCliKeychainCredentials(); const keychainCreds = readClaudeCliKeychainCredentials();
if (keychainCreds) { if (keychainCreds) {
@@ -306,18 +334,15 @@ function readClaudeCliCredentials(options?: {
if (!claudeOauth || typeof claudeOauth !== "object") return null; if (!claudeOauth || typeof claudeOauth !== "object") return null;
const accessToken = claudeOauth.accessToken; const accessToken = claudeOauth.accessToken;
const refreshToken = claudeOauth.refreshToken;
const expiresAt = claudeOauth.expiresAt; const expiresAt = claudeOauth.expiresAt;
if (typeof accessToken !== "string" || !accessToken) return null; if (typeof accessToken !== "string" || !accessToken) return null;
if (typeof refreshToken !== "string" || !refreshToken) return null;
if (typeof expiresAt !== "number" || expiresAt <= 0) return null; if (typeof expiresAt !== "number" || expiresAt <= 0) return null;
return { return {
type: "oauth", type: "token",
provider: "anthropic", provider: "anthropic",
access: accessToken, token: accessToken,
refresh: refreshToken,
expires: expiresAt, expires: expiresAt,
}; };
} }
@@ -326,7 +351,7 @@ function readClaudeCliCredentials(options?: {
* Read Claude Code credentials from macOS keychain. * Read Claude Code credentials from macOS keychain.
* Uses the `security` CLI to access keychain without native dependencies. * Uses the `security` CLI to access keychain without native dependencies.
*/ */
function readClaudeCliKeychainCredentials(): OAuthCredential | null { function readClaudeCliKeychainCredentials(): TokenCredential | null {
try { try {
const result = execSync( const result = execSync(
'security find-generic-password -s "Claude Code-credentials" -w', 'security find-generic-password -s "Claude Code-credentials" -w',
@@ -338,18 +363,15 @@ function readClaudeCliKeychainCredentials(): OAuthCredential | null {
if (!claudeOauth || typeof claudeOauth !== "object") return null; if (!claudeOauth || typeof claudeOauth !== "object") return null;
const accessToken = claudeOauth.accessToken; const accessToken = claudeOauth.accessToken;
const refreshToken = claudeOauth.refreshToken;
const expiresAt = claudeOauth.expiresAt; const expiresAt = claudeOauth.expiresAt;
if (typeof accessToken !== "string" || !accessToken) return null; if (typeof accessToken !== "string" || !accessToken) return null;
if (typeof refreshToken !== "string" || !refreshToken) return null;
if (typeof expiresAt !== "number" || expiresAt <= 0) return null; if (typeof expiresAt !== "number" || expiresAt <= 0) return null;
return { return {
type: "oauth", type: "token",
provider: "anthropic", provider: "anthropic",
access: accessToken, token: accessToken,
refresh: refreshToken,
expires: expiresAt, expires: expiresAt,
}; };
} catch { } 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. * 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 * This allows clawdbot to use the same credentials as these tools without requiring
@@ -434,25 +470,28 @@ function syncExternalCliCredentials(
const claudeCreds = readClaudeCliCredentials(options); const claudeCreds = readClaudeCliCredentials(options);
if (claudeCreds) { if (claudeCreds) {
const existing = store.profiles[CLAUDE_CLI_PROFILE_ID]; 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 // Update if: no existing profile, existing is not oauth, or CLI has newer/valid token
const shouldUpdate = const shouldUpdate =
!existingOAuth || !existingToken ||
existingOAuth.provider !== "anthropic" || existingToken.provider !== "anthropic" ||
existingOAuth.expires <= now || (existingToken.expires ?? 0) <= now ||
(claudeCreds.expires > now && ((claudeCreds.expires ?? 0) > now &&
claudeCreds.expires > existingOAuth.expires); (claudeCreds.expires ?? 0) > (existingToken.expires ?? 0));
if ( if (
shouldUpdate && shouldUpdate &&
!shallowEqualOAuthCredentials(existingOAuth, claudeCreds) !shallowEqualTokenCredentials(existingToken, claudeCreds)
) { ) {
store.profiles[CLAUDE_CLI_PROFILE_ID] = claudeCreds; store.profiles[CLAUDE_CLI_PROFILE_ID] = claudeCreds;
mutated = true; mutated = true;
log.info("synced anthropic credentials from claude cli", { log.info("synced anthropic credentials from claude cli", {
profileId: CLAUDE_CLI_PROFILE_ID, 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, key: cred.key,
...(cred.email ? { email: cred.email } : {}), ...(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 { } else {
store.profiles[profileId] = { store.profiles[profileId] = {
type: "oauth", type: "oauth",
@@ -570,6 +619,16 @@ export function ensureAuthProfileStore(
key: cred.key, key: cred.key,
...(cred.email ? { email: cred.email } : {}), ...(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 { } else {
store.profiles[profileId] = { store.profiles[profileId] = {
type: "oauth", type: "oauth",
@@ -882,16 +941,17 @@ function orderProfilesByMode(
// Then by lastUsed (oldest first = round-robin within type) // Then by lastUsed (oldest first = round-robin within type)
const scored = available.map((profileId) => { const scored = available.map((profileId) => {
const type = store.profiles[profileId]?.type; 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; const lastUsed = store.usageStats?.[profileId]?.lastUsed ?? 0;
return { profileId, typeScore, lastUsed }; 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). // Secondary sort: lastUsed (oldest first for round-robin within type).
const sorted = scored const sorted = scored
.sort((a, b) => { .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; if (a.typeScore !== b.typeScore) return a.typeScore - b.typeScore;
// Then by lastUsed (oldest first) // Then by lastUsed (oldest first)
return a.lastUsed - b.lastUsed; return a.lastUsed - b.lastUsed;
@@ -921,11 +981,27 @@ export async function resolveApiKeyForProfile(params: {
if (!cred) return null; if (!cred) return null;
const profileConfig = cfg?.auth?.profiles?.[profileId]; const profileConfig = cfg?.auth?.profiles?.[profileId];
if (profileConfig && profileConfig.provider !== cred.provider) return null; 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") { if (cred.type === "api_key") {
return { apiKey: cred.key, provider: cred.provider, email: cred.email }; 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) { if (Date.now() < cred.expires) {
return { return {
apiKey: buildOAuthApiKey(cred.provider, cred), apiKey: buildOAuthApiKey(cred.provider, cred),

View File

@@ -100,7 +100,7 @@ export async function resolveApiKeyForProvider(params: {
} }
export type EnvApiKeyResult = { apiKey: string; source: string }; 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 { export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
const applied = new Set(getShellEnvAppliedKeys()); const applied = new Set(getShellEnvAppliedKeys());
@@ -158,10 +158,14 @@ export function resolveModelAuthMode(
const modes = new Set( const modes = new Set(
profiles profiles
.map((id) => authStore.profiles[id]?.type) .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("oauth")) return "oauth";
if (modes.has("token")) return "token";
if (modes.has("api_key")) return "api-key"; if (modes.has("api_key")) return "api-key";
} }

View File

@@ -236,6 +236,10 @@ function resolveModelAuthLabel(
if (profile.type === "oauth") { if (profile.type === "oauth") {
return `oauth${label ? ` (${label})` : ""}`; return `oauth${label ? ` (${label})` : ""}`;
} }
if (profile.type === "token") {
const snippet = formatApiKeySnippet(profile.token);
return `token ${snippet}${label ? ` (${label})` : ""}`;
}
const snippet = formatApiKeySnippet(profile.key); const snippet = formatApiKeySnippet(profile.key);
return `api-key ${snippet}${label ? ` (${label})` : ""}`; return `api-key ${snippet}${label ? ` (${label})` : ""}`;
} }

View File

@@ -88,13 +88,18 @@ const resolveAuthLabel = async (
!profile || !profile ||
(configProfile?.provider && (configProfile?.provider &&
configProfile.provider !== profile.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`; return `${profileId}=missing`;
} }
if (profile.type === "api_key") { if (profile.type === "api_key") {
return `${profileId}=${maskApiKey(profile.key)}`; return `${profileId}=${maskApiKey(profile.key)}`;
} }
if (profile.type === "token") {
return `${profileId}=token:${maskApiKey(profile.token)}`;
}
const display = resolveAuthProfileDisplayLabel({ const display = resolveAuthProfileDisplayLabel({
cfg, cfg,
store, store,

View File

@@ -61,7 +61,7 @@ export function buildAuthChoiceOptions(params: {
} }
const claudeCli = params.store.profiles[CLAUDE_CLI_PROFILE_ID]; const claudeCli = params.store.profiles[CLAUDE_CLI_PROFILE_ID];
if (claudeCli?.type === "oauth") { if (claudeCli?.type === "oauth" || claudeCli?.type === "token") {
options.push({ options.push({
value: "claude-cli", value: "claude-cli",
label: "Anthropic OAuth (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({ options.push({
value: "openai-codex", 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: "gemini-api-key", label: "Google Gemini API key" });
options.push({ value: "apiKey", label: "Anthropic 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)" }); options.push({ value: "minimax", label: "Minimax M2.1 (LM Studio)" });
if (params.includeSkip) { if (params.includeSkip) {
options.push({ value: "skip", label: "Skip for now" }); options.push({ value: "skip", label: "Skip for now" });

View File

@@ -1,5 +1,4 @@
import { import {
loginAnthropic,
loginOpenAICodex, loginOpenAICodex,
type OAuthCredentials, type OAuthCredentials,
type OAuthProvider, type OAuthProvider,
@@ -10,6 +9,7 @@ import {
CODEX_CLI_PROFILE_ID, CODEX_CLI_PROFILE_ID,
ensureAuthProfileStore, ensureAuthProfileStore,
listProfilesForProvider, listProfilesForProvider,
upsertAuthProfile,
} from "../agents/auth-profiles.js"; } from "../agents/auth-profiles.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import { import {
@@ -17,7 +17,11 @@ import {
resolveEnvApiKey, resolveEnvApiKey,
} from "../agents/model-auth.js"; } from "../agents/model-auth.js";
import { loadModelCatalog } from "../agents/model-catalog.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 { ClawdbotConfig } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js"; import type { WizardPrompter } from "../wizard/prompts.js";
@@ -134,44 +138,62 @@ export async function applyAuthChoice(params: {
if (params.authChoice === "oauth") { if (params.authChoice === "oauth") {
await params.prompter.note( 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; if (!process.stdin.isTTY) {
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));
await params.prompter.note( await params.prompter.note(
"Trouble with OAuth? See https://docs.clawd.bot/start/faq", "`claude setup-token` requires an interactive TTY.",
"OAuth help", "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") { } else if (params.authChoice === "claude-cli") {
const store = ensureAuthProfileStore(params.agentDir, { const store = ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: false, allowKeychainPrompt: false,
@@ -202,18 +224,108 @@ export async function applyAuthChoice(params: {
}); });
if (!storeWithKeychain.profiles[CLAUDE_CLI_PROFILE_ID]) { if (!storeWithKeychain.profiles[CLAUDE_CLI_PROFILE_ID]) {
await params.prompter.note( if (process.stdin.isTTY) {
process.platform === "darwin" const runNow = await params.prompter.confirm({
? 'No Claude CLI credentials found in Keychain ("Claude Code-credentials") or ~/.claude/.credentials.json.' message: "Run `claude setup-token` now?",
: "No Claude CLI credentials found at ~/.claude/.credentials.json.", initialValue: true,
"Claude CLI OAuth", });
); if (runNow) {
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)}`,
"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, { nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: CLAUDE_CLI_PROFILE_ID, profileId: CLAUDE_CLI_PROFILE_ID,
provider: "anthropic", 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") { } else if (params.authChoice === "openai-codex") {
const isRemote = isRemoteEnvironment(); const isRemote = isRemoteEnvironment();

View File

@@ -86,7 +86,7 @@ export async function noteAuthProfileHealth(params: {
const findIssues = () => const findIssues = () =>
summary.profiles.filter( summary.profiles.filter(
(profile) => (profile) =>
profile.type === "oauth" && (profile.type === "oauth" || profile.type === "token") &&
(profile.status === "expired" || (profile.status === "expired" ||
profile.status === "expiring" || profile.status === "expiring" ||
profile.status === "missing"), profile.status === "missing"),
@@ -96,13 +96,15 @@ export async function noteAuthProfileHealth(params: {
if (issues.length === 0) return; if (issues.length === 0) return;
const shouldRefresh = await params.prompter.confirmRepair({ 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, initialValue: true,
}); });
if (shouldRefresh) { if (shouldRefresh) {
const refreshTargets = issues.filter((issue) => const refreshTargets = issues.filter(
["expired", "expiring", "missing"].includes(issue.status), (issue) =>
issue.type === "oauth" &&
["expired", "expiring", "missing"].includes(issue.status),
); );
const errors: string[] = []; const errors: string[] = [];
for (const profile of refreshTargets) { for (const profile of refreshTargets) {

View File

@@ -159,6 +159,7 @@ type ProviderAuthOverview = {
profiles: { profiles: {
count: number; count: number;
oauth: number; oauth: number;
token: number;
apiKey: number; apiKey: number;
labels: string[]; labels: string[];
}; };
@@ -180,6 +181,9 @@ function resolveProviderAuthOverview(params: {
if (profile.type === "api_key") { if (profile.type === "api_key") {
return `${profileId}=${maskApiKey(profile.key)}`; return `${profileId}=${maskApiKey(profile.key)}`;
} }
if (profile.type === "token") {
return `${profileId}=token:${maskApiKey(profile.token)}`;
}
const display = resolveAuthProfileDisplayLabel({ cfg, store, profileId }); const display = resolveAuthProfileDisplayLabel({ cfg, store, profileId });
const suffix = const suffix =
display === profileId display === profileId
@@ -192,6 +196,9 @@ function resolveProviderAuthOverview(params: {
const oauthCount = profiles.filter( const oauthCount = profiles.filter(
(id) => store.profiles[id]?.type === "oauth", (id) => store.profiles[id]?.type === "oauth",
).length; ).length;
const tokenCount = profiles.filter(
(id) => store.profiles[id]?.type === "token",
).length;
const apiKeyCount = profiles.filter( const apiKeyCount = profiles.filter(
(id) => store.profiles[id]?.type === "api_key", (id) => store.profiles[id]?.type === "api_key",
).length; ).length;
@@ -227,6 +234,7 @@ function resolveProviderAuthOverview(params: {
profiles: { profiles: {
count: profiles.length, count: profiles.length,
oauth: oauthCount, oauth: oauthCount,
token: tokenCount,
apiKey: apiKeyCount, apiKey: apiKeyCount,
labels, labels,
}, },
@@ -739,11 +747,16 @@ export async function modelsStatusCommand(
const providersWithOauth = providerAuth const providersWithOauth = providerAuth
.filter( .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) => { .map((entry) => {
const count = 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})`; return `${entry.provider} (${count})`;
}); });
@@ -754,7 +767,7 @@ export async function modelsStatusCommand(
providers, providers,
}); });
const oauthProfiles = authHealth.profiles.filter( const oauthProfiles = authHealth.profiles.filter(
(profile) => profile.type === "oauth", (profile) => profile.type === "oauth" || profile.type === "token",
); );
const checkStatus = (() => { const checkStatus = (() => {
@@ -926,7 +939,7 @@ export async function modelsStatusCommand(
); );
runtime.log( runtime.log(
`${label( `${label(
`Providers w/ OAuth (${providersWithOauth.length || 0})`, `Providers w/ OAuth/tokens (${providersWithOauth.length || 0})`,
)}${colorize(rich, theme.muted, ":")} ${colorize( )}${colorize(rich, theme.muted, ":")} ${colorize(
rich, rich,
providersWithOauth.length ? theme.info : theme.muted, providersWithOauth.length ? theme.info : theme.muted,
@@ -953,7 +966,7 @@ export async function modelsStatusCommand(
bits.push( bits.push(
formatKeyValue( formatKeyValue(
"profiles", "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, rich,
), ),
); );
@@ -1003,7 +1016,7 @@ export async function modelsStatusCommand(
} }
runtime.log(""); runtime.log("");
runtime.log(colorize(rich, theme.heading, "OAuth status")); runtime.log(colorize(rich, theme.heading, "OAuth/token status"));
if (oauthProfiles.length === 0) { if (oauthProfiles.length === 0) {
runtime.log(colorize(rich, theme.muted, "- none")); runtime.log(colorize(rich, theme.muted, "- none"));
return; return;
@@ -1011,6 +1024,7 @@ export async function modelsStatusCommand(
const formatStatus = (status: string) => { const formatStatus = (status: string) => {
if (status === "ok") return colorize(rich, theme.success, "ok"); 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 === "expiring") return colorize(rich, theme.warn, "expiring");
if (status === "missing") return colorize(rich, theme.warn, "unknown"); if (status === "missing") return colorize(rich, theme.warn, "unknown");
return colorize(rich, theme.error, "expired"); return colorize(rich, theme.error, "expired");
@@ -1020,9 +1034,12 @@ export async function modelsStatusCommand(
const labelText = profile.label || profile.profileId; const labelText = profile.label || profile.profileId;
const label = colorize(rich, theme.accent, labelText); const label = colorize(rich, theme.accent, labelText);
const status = formatStatus(profile.status); const status = formatStatus(profile.status);
const expiry = profile.expiresAt const expiry =
? ` expires in ${formatRemainingShort(profile.remainingMs)}` profile.status === "static"
: " expires unknown"; ? ""
: profile.expiresAt
? ` expires in ${formatRemainingShort(profile.remainingMs)}`
: " expires unknown";
const source = const source =
profile.source !== "store" profile.source !== "store"
? colorize(rich, theme.muted, ` (${profile.source})`) ? colorize(rich, theme.muted, ` (${profile.source})`)

View File

@@ -52,7 +52,7 @@ describe("writeOAuthCredentials", () => {
expires: Date.now() + 60_000, expires: Date.now() + 60_000,
} satisfies OAuthCredentials; } satisfies OAuthCredentials;
await writeOAuthCredentials("anthropic", creds); await writeOAuthCredentials("openai-codex", creds);
// Now writes to the multi-agent path: agents/main/agent // Now writes to the multi-agent path: agents/main/agent
const authProfilePath = path.join( const authProfilePath = path.join(
@@ -66,7 +66,7 @@ describe("writeOAuthCredentials", () => {
const parsed = JSON.parse(raw) as { const parsed = JSON.parse(raw) as {
profiles?: Record<string, OAuthCredentials & { type?: string }>; profiles?: Record<string, OAuthCredentials & { type?: string }>;
}; };
expect(parsed.profiles?.["anthropic:default"]).toMatchObject({ expect(parsed.profiles?.["openai-codex:default"]).toMatchObject({
refresh: "refresh-token", refresh: "refresh-token",
access: "access-token", access: "access-token",
type: "oauth", type: "oauth",

View File

@@ -51,7 +51,7 @@ export function applyAuthProfileConfig(
params: { params: {
profileId: string; profileId: string;
provider: string; provider: string;
mode: "api_key" | "oauth"; mode: "api_key" | "oauth" | "token";
email?: string; email?: string;
preferProfileFirst?: boolean; preferProfileFirst?: boolean;
}, },

View File

@@ -151,7 +151,7 @@ export async function runNonInteractiveOnboarding(
nextConfig = applyAuthProfileConfig(nextConfig, { nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: CLAUDE_CLI_PROFILE_ID, profileId: CLAUDE_CLI_PROFILE_ID,
provider: "anthropic", provider: "anthropic",
mode: "oauth", mode: "token",
}); });
} else if (authChoice === "codex-cli") { } else if (authChoice === "codex-cli") {
const store = ensureAuthProfileStore(); const store = ensureAuthProfileStore();
@@ -169,17 +169,18 @@ export async function runNonInteractiveOnboarding(
} else if (authChoice === "minimax") { } else if (authChoice === "minimax") {
nextConfig = applyMinimaxConfig(nextConfig); nextConfig = applyMinimaxConfig(nextConfig);
} else if ( } else if (
authChoice === "token" ||
authChoice === "oauth" || authChoice === "oauth" ||
authChoice === "openai-codex" || authChoice === "openai-codex" ||
authChoice === "antigravity" authChoice === "antigravity"
) { ) {
runtime.error( const label =
`${ authChoice === "antigravity"
authChoice === "oauth" || authChoice === "openai-codex" ? "Antigravity"
? "OAuth" : authChoice === "token"
: "Antigravity" ? "Token"
} requires interactive mode.`, : "OAuth";
); runtime.error(`${label} requires interactive mode.`);
runtime.exit(1); runtime.exit(1);
return; return;
} }

View File

@@ -5,6 +5,7 @@ export type OnboardMode = "local" | "remote";
export type AuthChoice = export type AuthChoice =
| "oauth" | "oauth"
| "claude-cli" | "claude-cli"
| "token"
| "openai-codex" | "openai-codex"
| "codex-cli" | "codex-cli"
| "antigravity" | "antigravity"

View File

@@ -985,7 +985,13 @@ export type ModelsConfig = {
export type AuthProfileConfig = { export type AuthProfileConfig = {
provider: string; 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; email?: string;
}; };

View File

@@ -895,7 +895,11 @@ export const ClawdbotSchema = z.object({
z.string(), z.string(),
z.object({ z.object({
provider: z.string(), 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(), email: z.string().optional(),
}), }),
) )