feat: support token auth profiles
This commit is contained in:
@@ -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" });
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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})`)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export type OnboardMode = "local" | "remote";
|
||||
export type AuthChoice =
|
||||
| "oauth"
|
||||
| "claude-cli"
|
||||
| "token"
|
||||
| "openai-codex"
|
||||
| "codex-cli"
|
||||
| "antigravity"
|
||||
|
||||
Reference in New Issue
Block a user