Files
openclaw/src/commands/models/auth.ts
2026-03-07 18:51:17 +00:00

471 lines
14 KiB
TypeScript

import {
cancel,
confirm as clackConfirm,
isCancel,
select as clackSelect,
text as clackText,
} from "@clack/prompts";
import {
resolveAgentDir,
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
} from "../../agents/agent-scope.js";
import { upsertAuthProfile } from "../../agents/auth-profiles.js";
import type { AuthProfileCredential } from "../../agents/auth-profiles/types.js";
import { normalizeProviderId } from "../../agents/model-selection.js";
import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js";
import { formatCliCommand } from "../../cli/command-format.js";
import { parseDurationMs } from "../../cli/parse-duration.js";
import { logConfigUpdated } from "../../config/logging.js";
import { resolvePluginProviders } from "../../plugins/providers.js";
import type { ProviderAuthResult, ProviderPlugin } from "../../plugins/types.js";
import type { RuntimeEnv } from "../../runtime.js";
import { stylePromptHint, stylePromptMessage } from "../../terminal/prompt-style.js";
import { createClackPrompter } from "../../wizard/clack-prompter.js";
import { validateAnthropicSetupToken } from "../auth-token.js";
import { isRemoteEnvironment } from "../oauth-env.js";
import { createVpsAwareOAuthHandlers } from "../oauth-flow.js";
import { applyAuthProfileConfig, writeOAuthCredentials } from "../onboard-auth.js";
import { openUrl } from "../onboard-helpers.js";
import {
applyOpenAICodexModelDefault,
OPENAI_CODEX_DEFAULT_MODEL,
} from "../openai-codex-model-default.js";
import { loginOpenAICodexOAuth } from "../openai-codex-oauth.js";
import {
applyDefaultModel,
mergeConfigPatch,
pickAuthMethod,
resolveProviderMatch,
} from "../provider-auth-helpers.js";
import { loadValidConfigOrThrow, updateConfig } from "./shared.js";
function guardCancel<T>(value: T | symbol): T {
if (typeof value === "symbol" || isCancel(value)) {
cancel("Cancelled.");
process.exit(0);
}
return value;
}
const confirm = async (params: Parameters<typeof clackConfirm>[0]) =>
guardCancel(
await clackConfirm({
...params,
message: stylePromptMessage(params.message),
}),
);
const text = async (params: Parameters<typeof clackText>[0]) =>
guardCancel(
await clackText({
...params,
message: stylePromptMessage(params.message),
}),
);
const select = async <T>(params: Parameters<typeof clackSelect<T>>[0]) =>
guardCancel(
await clackSelect({
...params,
message: stylePromptMessage(params.message),
options: params.options.map((opt) =>
opt.hint === undefined ? opt : { ...opt, hint: stylePromptHint(opt.hint) },
),
}),
);
type TokenProvider = "anthropic";
function resolveTokenProvider(raw?: string): TokenProvider | "custom" | null {
const trimmed = raw?.trim();
if (!trimmed) {
return null;
}
const normalized = normalizeProviderId(trimmed);
if (normalized === "anthropic") {
return "anthropic";
}
return "custom";
}
function resolveDefaultTokenProfileId(provider: string): string {
return `${normalizeProviderId(provider)}:manual`;
}
export async function modelsAuthSetupTokenCommand(
opts: { provider?: string; yes?: boolean },
runtime: RuntimeEnv,
) {
const provider = resolveTokenProvider(opts.provider ?? "anthropic");
if (provider !== "anthropic") {
throw new Error("Only --provider anthropic is supported for setup-token.");
}
if (!process.stdin.isTTY) {
throw new Error("setup-token requires an interactive TTY.");
}
if (!opts.yes) {
const proceed = await confirm({
message: "Have you run `claude setup-token` and copied the token?",
initialValue: true,
});
if (!proceed) {
return;
}
}
const tokenInput = await text({
message: "Paste Anthropic setup-token",
validate: (value) => validateAnthropicSetupToken(String(value ?? "")),
});
const token = String(tokenInput ?? "").trim();
const profileId = resolveDefaultTokenProfileId(provider);
upsertAuthProfile({
profileId,
credential: {
type: "token",
provider,
token,
},
});
await updateConfig((cfg) =>
applyAuthProfileConfig(cfg, {
profileId,
provider,
mode: "token",
}),
);
logConfigUpdated(runtime);
runtime.log(`Auth profile: ${profileId} (${provider}/token)`);
}
export async function modelsAuthPasteTokenCommand(
opts: {
provider?: string;
profileId?: string;
expiresIn?: string;
},
runtime: RuntimeEnv,
) {
const rawProvider = opts.provider?.trim();
if (!rawProvider) {
throw new Error("Missing --provider.");
}
const provider = normalizeProviderId(rawProvider);
const profileId = opts.profileId?.trim() || resolveDefaultTokenProfileId(provider);
const tokenInput = await text({
message: `Paste token for ${provider}`,
validate: (value) => (value?.trim() ? undefined : "Required"),
});
const token = String(tokenInput ?? "").trim();
const expires =
opts.expiresIn?.trim() && opts.expiresIn.trim().length > 0
? Date.now() + parseDurationMs(String(opts.expiresIn ?? "").trim(), { defaultUnit: "d" })
: undefined;
upsertAuthProfile({
profileId,
credential: {
type: "token",
provider,
token,
...(expires ? { expires } : {}),
},
});
await updateConfig((cfg) => applyAuthProfileConfig(cfg, { profileId, provider, mode: "token" }));
logConfigUpdated(runtime);
runtime.log(`Auth profile: ${profileId} (${provider}/token)`);
}
export async function modelsAuthAddCommand(_opts: Record<string, never>, runtime: RuntimeEnv) {
const provider = await select({
message: "Token provider",
options: [
{ value: "anthropic", label: "anthropic" },
{ value: "custom", label: "custom (type provider id)" },
],
});
const providerId =
provider === "custom"
? normalizeProviderId(
String(
await text({
message: "Provider id",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
),
)
: provider;
const method = (await select({
message: "Token method",
options: [
...(providerId === "anthropic"
? [
{
value: "setup-token",
label: "setup-token (claude)",
hint: "Paste a setup-token from `claude setup-token`",
},
]
: []),
{ value: "paste", label: "paste token" },
],
})) as "setup-token" | "paste";
if (method === "setup-token") {
await modelsAuthSetupTokenCommand({ provider: providerId }, runtime);
return;
}
const profileIdDefault = resolveDefaultTokenProfileId(providerId);
const profileId = String(
await text({
message: "Profile id",
initialValue: profileIdDefault,
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
const wantsExpiry = await confirm({
message: "Does this token expire?",
initialValue: false,
});
const expiresIn = wantsExpiry
? String(
await 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)";
}
},
}),
).trim()
: undefined;
await modelsAuthPasteTokenCommand({ provider: providerId, profileId, expiresIn }, runtime);
}
type LoginOptions = {
provider?: string;
method?: string;
setDefault?: boolean;
};
export function resolveRequestedLoginProviderOrThrow(
providers: ProviderPlugin[],
rawProvider?: string,
): ProviderPlugin | null {
const requested = rawProvider?.trim();
if (!requested) {
return null;
}
const matched = resolveProviderMatch(providers, requested);
if (matched) {
return matched;
}
const available = providers
.map((provider) => provider.id)
.filter(Boolean)
.toSorted((a, b) => a.localeCompare(b));
const availableText = available.length > 0 ? available.join(", ") : "(none)";
throw new Error(
`Unknown provider "${requested}". Loaded providers: ${availableText}. Verify plugins via \`${formatCliCommand("openclaw plugins list --json")}\`.`,
);
}
function credentialMode(credential: AuthProfileCredential): "api_key" | "oauth" | "token" {
if (credential.type === "api_key") {
return "api_key";
}
if (credential.type === "token") {
return "token";
}
return "oauth";
}
async function runBuiltInOpenAICodexLogin(params: {
opts: LoginOptions;
runtime: RuntimeEnv;
prompter: ReturnType<typeof createClackPrompter>;
agentDir: string;
}) {
const creds = await loginOpenAICodexOAuth({
prompter: params.prompter,
runtime: params.runtime,
isRemote: isRemoteEnvironment(),
openUrl: async (url) => {
await openUrl(url);
},
localBrowserMessage: "Complete sign-in in browser…",
});
if (!creds) {
throw new Error("OpenAI Codex OAuth did not return credentials.");
}
const profileId = await writeOAuthCredentials("openai-codex", creds, params.agentDir, {
syncSiblingAgents: true,
});
await updateConfig((cfg) => {
let next = applyAuthProfileConfig(cfg, {
profileId,
provider: "openai-codex",
mode: "oauth",
});
if (params.opts.setDefault) {
next = applyOpenAICodexModelDefault(next).next;
}
return next;
});
logConfigUpdated(params.runtime);
params.runtime.log(`Auth profile: ${profileId} (openai-codex/oauth)`);
if (params.opts.setDefault) {
params.runtime.log(`Default model set to ${OPENAI_CODEX_DEFAULT_MODEL}`);
} else {
params.runtime.log(
`Default model available: ${OPENAI_CODEX_DEFAULT_MODEL} (use --set-default to apply)`,
);
}
}
export async function modelsAuthLoginCommand(opts: LoginOptions, runtime: RuntimeEnv) {
if (!process.stdin.isTTY) {
throw new Error("models auth login requires an interactive TTY.");
}
const config = await loadValidConfigOrThrow();
const defaultAgentId = resolveDefaultAgentId(config);
const agentDir = resolveAgentDir(config, defaultAgentId);
const workspaceDir =
resolveAgentWorkspaceDir(config, defaultAgentId) ?? resolveDefaultAgentWorkspaceDir();
const requestedProviderId = normalizeProviderId(String(opts.provider ?? ""));
const prompter = createClackPrompter();
if (requestedProviderId === "openai-codex") {
await runBuiltInOpenAICodexLogin({
opts,
runtime,
prompter,
agentDir,
});
return;
}
const providers = resolvePluginProviders({ config, workspaceDir });
if (providers.length === 0) {
throw new Error(
`No provider plugins found. Install one via \`${formatCliCommand("openclaw plugins install")}\`.`,
);
}
const requestedProvider = resolveRequestedLoginProviderOrThrow(providers, opts.provider);
const selectedProvider =
requestedProvider ??
(await prompter
.select({
message: "Select a provider",
options: providers.map((provider) => ({
value: provider.id,
label: provider.label,
hint: provider.docsPath ? `Docs: ${provider.docsPath}` : undefined,
})),
})
.then((id) => resolveProviderMatch(providers, String(id))));
if (!selectedProvider) {
throw new Error("Unknown provider. Use --provider <id> to pick a provider plugin.");
}
const chosenMethod =
pickAuthMethod(selectedProvider, opts.method) ??
(selectedProvider.auth.length === 1
? selectedProvider.auth[0]
: await prompter
.select({
message: `Auth method for ${selectedProvider.label}`,
options: selectedProvider.auth.map((method) => ({
value: method.id,
label: method.label,
hint: method.hint,
})),
})
.then((id) => selectedProvider.auth.find((method) => method.id === String(id))));
if (!chosenMethod) {
throw new Error("Unknown auth method. Use --method <id> to select one.");
}
const isRemote = isRemoteEnvironment();
const result: ProviderAuthResult = await chosenMethod.run({
config,
agentDir,
workspaceDir,
prompter,
runtime,
isRemote,
openUrl: async (url) => {
await openUrl(url);
},
oauth: {
createVpsAwareHandlers: (params) => createVpsAwareOAuthHandlers(params),
},
});
for (const profile of result.profiles) {
upsertAuthProfile({
profileId: profile.profileId,
credential: profile.credential,
agentDir,
});
}
await updateConfig((cfg) => {
let next = cfg;
if (result.configPatch) {
next = mergeConfigPatch(next, result.configPatch);
}
for (const profile of result.profiles) {
next = applyAuthProfileConfig(next, {
profileId: profile.profileId,
provider: profile.credential.provider,
mode: credentialMode(profile.credential),
});
}
if (opts.setDefault && result.defaultModel) {
next = applyDefaultModel(next, result.defaultModel);
}
return next;
});
logConfigUpdated(runtime);
for (const profile of result.profiles) {
runtime.log(
`Auth profile: ${profile.profileId} (${profile.credential.provider}/${credentialMode(profile.credential)})`,
);
}
if (result.defaultModel) {
runtime.log(
opts.setDefault
? `Default model set to ${result.defaultModel}`
: `Default model available: ${result.defaultModel} (use --set-default to apply)`,
);
}
if (result.notes && result.notes.length > 0) {
await prompter.note(result.notes.join("\n"), "Provider notes");
}
}