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

@@ -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" });

View File

@@ -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();

View File

@@ -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) {

View File

@@ -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})`)

View File

@@ -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<string, OAuthCredentials & { type?: string }>;
};
expect(parsed.profiles?.["anthropic:default"]).toMatchObject({
expect(parsed.profiles?.["openai-codex:default"]).toMatchObject({
refresh: "refresh-token",
access: "access-token",
type: "oauth",

View File

@@ -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;
},

View File

@@ -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;
}

View File

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