openclaw / src /commands /models /auth.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
f778c12 verified
Raw History Blame Contribute Delete
45.7 kB
/** Commands for adding, pasting, and logging into provider model auth profiles. */
import {
cancel,
confirm as clackConfirm,
isCancel,
password as clackPassword,
select as clackSelect,
text as clackText,
} from "@clack/prompts";
import { readByteStreamWithLimit } from "@openclaw/media-core/read-byte-stream-with-limit";
import { expectDefined } from "@openclaw/normalization-core";
import { resolveExpiresAtMsFromDurationMs } from "@openclaw/normalization-core/number-coercion";
import {
normalizeOptionalString,
normalizeStringifiedOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import { styleSelectParams } from "../../../packages/terminal-core/src/prompt-select-styled-params.js";
import { stylePromptMessage } from "../../../packages/terminal-core/src/prompt-style.js";
import { resolveAgentWorkspaceDir } from "../../agents/agent-scope.js";
import { removeProviderAuthProfilesWithLock } from "../../agents/auth-profiles.js";
import {
promoteAuthProfileInOrder,
upsertAuthProfileWithLockOrThrow,
} from "../../agents/auth-profiles/profiles.js";
import type { AuthProfileCredential } from "../../agents/auth-profiles/types.js";
import { normalizeProviderId } from "../../agents/model-ref-shared.js";
import { isCliProvider } from "../../agents/model-selection-cli.js";
import { resolveProviderIdForAuth } from "../../agents/provider-auth-aliases.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 { normalizeAgentModelRefForConfig } from "../../config/model-input.js";
import type { ConfigFileSnapshot, OpenClawConfig } from "../../config/types.openclaw.js";
import {
applyDefaultModel,
applyProviderAuthConfigPatch,
pickAuthMethod,
restorePriorAgentsDefaultsModelUnlessOptIn,
resolveProviderMatch,
} from "../../plugins/provider-auth-choice-helpers.js";
import {
createProviderAuthConfigPatch,
writeProviderAuthConfig,
} from "../../plugins/provider-auth-config.js";
import { applyAuthProfileConfig } from "../../plugins/provider-auth-helpers.js";
import { runProviderPluginAuthMethodUnpersisted } from "../../plugins/provider-auth-method.js";
import { persistProviderAuthProfilesAfterLogin } from "../../plugins/provider-auth-persistence.js";
import type { ProviderAuthContext } from "../../plugins/provider-authentication.types.js";
import { resolvePluginProvidersCore } from "../../plugins/providers.runtime.js";
import {
resolvePluginSetupProviderCore,
resolvePluginSetupRegistry,
} from "../../plugins/setup-registry.js";
import type {
ProviderAuthMethod,
ProviderAuthResult,
ProviderPlugin,
} from "../../plugins/types.js";
import type { RuntimeEnv } from "../../runtime.js";
import {
ProviderAuthConfigApplyError,
ProviderCredentialsSavedError,
} from "../../shared/provider-auth-result.js";
import { isRecord } from "../../utils.js";
import { normalizeSecretInput } from "../../utils/normalize-secret-input.js";
import { createClackPrompter } from "../../wizard/clack-prompter.js";
import type { WizardPrompter } from "../../wizard/prompts.js";
import { validateAnthropicSetupToken } from "../auth-token.js";
import { repairCodexRuntimePluginInstallForModelSelection } from "../codex-runtime-plugin-install.js";
import { repairCopilotRuntimePluginInstallForModelSelection } from "../copilot-runtime-plugin-install.js";
import { saveModelProviderApiKey } from "./auth-api-key.js";
import { tryImportProviderCredential } from "./auth-credential-import.js";
import {
looksLikeOpenAIApiKey,
normalizeManualAuthProvider,
resolveDefaultTokenProfileId,
validateOpenAICodexApiKeyInput,
} from "./auth-manual-input.js";
import {
applyProviderLoginDefaultModel,
completeProviderModelAccess,
prepareProviderModelAccess,
withoutProviderModelPolicy,
type PreparedProviderModelAccess,
} from "./auth-model-policy.js";
import { refreshRunningGatewayAuthState, type ModelAuthRefreshOutcome } from "./auth-refresh.js";
import {
loadValidConfigSnapshotOrThrow,
resolveModelsTargetAgent,
updateConfig,
} from "./shared.js";
function resolveManualTokenExpiryMs(expiresIn: string | undefined): number | undefined {
const normalizedExpiresIn = normalizeStringifiedOptionalString(expiresIn);
if (!normalizedExpiresIn) {
return undefined;
}
const durationMs = parseDurationMs(normalizedExpiresIn, { defaultUnit: "d" });
const expires = resolveExpiresAtMsFromDurationMs(durationMs);
if (expires === undefined) {
throw new Error("Invalid expiry duration: resulting token expiry is outside Date range.");
}
return expires;
}
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 password = async (params: Parameters<typeof clackPassword>[0]) =>
guardCancel(
await clackPassword({
...params,
message: stylePromptMessage(params.message),
}),
);
const select = async <T>(params: Parameters<typeof clackSelect<T>>[0]) =>
guardCancel(await clackSelect(styleSelectParams(params)));
const MODELS_AUTH_STDIN_MAX_BYTES = 1024 * 1024;
async function readPipedStdin(): Promise<string> {
const bytes = await readByteStreamWithLimit(process.stdin, {
maxBytes: MODELS_AUTH_STDIN_MAX_BYTES,
onOverflow: ({ maxBytes }) => new Error(`Piped auth input exceeds ${maxBytes} bytes.`),
});
return bytes.toString("utf8");
}
async function readPastedSecret(params: {
message: string;
masked: boolean;
validate?: (value: string | undefined) => string | undefined;
}): Promise<string> {
const promptParams = { message: params.message, validate: params.validate };
const input = process.stdin.isTTY
? await (params.masked ? password(promptParams) : text(promptParams))
: await readPipedStdin();
const normalized = normalizeSecretInput(input);
const validationMessage = params.validate?.(normalized);
if (validationMessage) {
throw new Error(validationMessage);
}
return normalized;
}
function isOpenAIProvider(provider: string): boolean {
return normalizeManualAuthProvider(provider) === "openai";
}
type ResolvedModelsAuthContext = {
config: OpenClawConfig;
configSnapshot: ConfigFileSnapshot;
agentId: string;
agentDir: string;
workspaceDir: string;
providers: ProviderPlugin[];
};
function listProvidersWithAuthMethods(providers: ProviderPlugin[]): ProviderPlugin[] {
return providers.filter((provider) => provider.auth.length > 0);
}
function listTokenAuthMethods(provider: ProviderPlugin): ProviderAuthMethod[] {
return provider.auth.filter((method) => method.kind === "token");
}
function listProvidersWithTokenMethods(providers: ProviderPlugin[]): ProviderPlugin[] {
return providers.filter((provider) => listTokenAuthMethods(provider).length > 0);
}
function mergeSetupProviders(
providers: readonly ProviderPlugin[],
setupProviders: readonly ProviderPlugin[],
): ProviderPlugin[] {
if (setupProviders.length === 0) {
return [...providers];
}
const setupById = new Map(
setupProviders.map((provider) => [normalizeProviderId(provider.id), provider] as const),
);
const merged = providers.map(
(provider) => setupById.get(normalizeProviderId(provider.id)) ?? provider,
);
const existing = new Set(merged.map((provider) => normalizeProviderId(provider.id)));
for (const provider of setupProviders) {
if (!existing.has(normalizeProviderId(provider.id))) {
merged.push(provider);
}
}
return merged;
}
function preferSetupAuthProviders(params: {
providers: readonly ProviderPlugin[];
config: OpenClawConfig;
workspaceDir: string;
requestedProvider?: string;
ownerPluginId?: string;
}): ProviderPlugin[] {
if (params.ownerPluginId) {
return mergeSetupProviders(
params.providers,
resolvePluginSetupRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
pluginIds: [params.ownerPluginId],
}).providers.map((entry) => entry.provider),
);
}
const requestedProvider = params.requestedProvider
? normalizeManualAuthProvider(params.requestedProvider)
: undefined;
if (requestedProvider) {
const setupProvider = resolvePluginSetupProviderCore({
provider: requestedProvider,
config: params.config,
workspaceDir: params.workspaceDir,
});
return setupProvider ? [setupProvider] : [...params.providers];
}
const setupProviders = resolvePluginSetupRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
}).providers.map((entry) => entry.provider);
return mergeSetupProviders(params.providers, setupProviders);
}
async function resolveModelsAuthContext(params?: {
requestedProvider?: string;
rawAgentId?: string | null;
config?: OpenClawConfig;
ownerPluginId?: string;
}): Promise<ResolvedModelsAuthContext> {
const configSnapshot = await loadValidConfigSnapshotOrThrow();
const config = params?.config ?? configSnapshot.runtimeConfig;
const { agentId, agentDir } = await resolveModelsAuthAgent(params?.rawAgentId, config);
const workspaceDir =
resolveAgentWorkspaceDir(config, agentId) ?? resolveDefaultAgentWorkspaceDir();
const requestedProvider = params?.requestedProvider?.trim();
const providerRef = requestedProvider
? normalizeManualAuthProvider(requestedProvider)
: undefined;
// Auth setup also runs inside the Gateway; discovery must not replace its live registry.
const providers = resolvePluginProvidersCore({
config,
workspaceDir,
mode: "setup",
includeUntrustedWorkspacePlugins: false,
...(params?.ownerPluginId ? { onlyPluginIds: [params.ownerPluginId] } : {}),
...(providerRef ? { providerRefs: [providerRef] } : {}),
});
const authProviders = preferSetupAuthProviders({
providers,
config,
workspaceDir,
requestedProvider: providerRef,
ownerPluginId: params?.ownerPluginId,
});
return {
config,
configSnapshot,
agentId,
agentDir,
workspaceDir,
providers: authProviders,
};
}
async function resolveModelsAuthAgent(rawAgentId?: string | null, config?: OpenClawConfig) {
const cfg = config ?? (await loadValidConfigSnapshotOrThrow()).runtimeConfig;
return resolveModelsTargetAgent(cfg, rawAgentId ?? undefined, { kind: "mutation" });
}
function resolveRequestedProviderOrThrow(
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 resolveTokenMethodOrThrow(
provider: ProviderPlugin,
rawMethod?: string,
): ProviderAuthMethod | null {
const tokenMethods = listTokenAuthMethods(provider);
if (rawMethod?.trim()) {
const matched = pickAuthMethod(provider, rawMethod);
if (matched && matched.kind === "token") {
return matched;
}
const available = tokenMethods.map((method) => method.id).join(", ") || "(none)";
throw new Error(
`Unknown token auth method "${rawMethod}" for provider "${provider.id}". Available token methods: ${available}.`,
);
}
return null;
}
async function pickProviderAuthMethod(params: {
provider: ProviderPlugin;
requestedMethod?: string;
prompter: WizardPrompter;
}) {
const rawRequestedMethod = params.requestedMethod?.trim();
if (rawRequestedMethod) {
return pickAuthMethod(params.provider, rawRequestedMethod);
}
const oauthMethod = params.provider.auth.find((method) => method.kind === "oauth");
if (oauthMethod) {
return oauthMethod;
}
if (params.provider.auth.length === 1) {
return params.provider.auth[0] ?? null;
}
return await params.prompter
.select({
message: `Auth method for ${params.provider.label}`,
options: params.provider.auth.map((method) => ({
value: method.id,
label: method.label,
hint: method.hint,
})),
})
.then((id) => params.provider.auth.find((method) => method.id === id) ?? null);
}
async function pickProviderTokenMethod(params: {
provider: ProviderPlugin;
requestedMethod?: string;
prompter: WizardPrompter;
}) {
const explicitTokenMethod = resolveTokenMethodOrThrow(params.provider, params.requestedMethod);
if (explicitTokenMethod) {
return explicitTokenMethod;
}
const tokenMethods = listTokenAuthMethods(params.provider);
if (tokenMethods.length === 0) {
return null;
}
const setupTokenMethod = tokenMethods.find((method) => method.id === "setup-token");
if (setupTokenMethod) {
return setupTokenMethod;
}
if (tokenMethods.length === 1) {
return tokenMethods[0] ?? null;
}
return await params.prompter
.select({
message: `Token method for ${params.provider.label}`,
options: tokenMethods.map((method) => ({
value: method.id,
label: method.label,
hint: method.hint,
})),
})
.then((id) => tokenMethods.find((method) => method.id === id) ?? null);
}
async function refreshProviderAuthAfterLogin(
params: Pick<
ModelsAuthLoginFlowOptions,
"refreshAfterLogin" | "runtime" | "signal" | "assertCurrent"
> & {
agentId: string;
},
): Promise<ModelAuthRefreshOutcome> {
if (!params.refreshAfterLogin) {
return refreshRunningGatewayAuthState(params.agentId, "login", params.runtime);
}
try {
await params.refreshAfterLogin(params.agentId);
return "refreshed";
} catch {
params.signal?.throwIfAborted();
params.assertCurrent?.();
return "gateway-rejected";
}
}
async function persistProviderAuthResult(params: {
result: ProviderAuthResult;
profiles?: ProviderAuthResult["profiles"];
config: OpenClawConfig;
configSnapshot: ConfigFileSnapshot;
agentId: string;
agentDir: string;
runtime: RuntimeEnv;
prompter: WizardPrompter;
setDefault?: boolean;
env?: NodeJS.ProcessEnv;
beforePersistentEffect?: () => void | Promise<void>;
assertCurrent?: () => void;
signal?: AbortSignal;
refreshAfterLogin?: ModelsAuthLoginFlowOptions["refreshAfterLogin"];
}): Promise<{ profiles: ProviderAuthResult["profiles"]; authRefresh: ModelAuthRefreshOutcome }> {
const defaultModel = params.result.defaultModel
? normalizeAgentModelRefForConfig(params.result.defaultModel)
: undefined;
const profiles = params.profiles ?? params.result.profiles;
const persistedProfiles: ProviderAuthResult["profiles"] = [];
// Match source and runtime rows using the config owner's canonical model identities.
const loginConfig = applyProviderAuthConfigPatch(params.config, {});
const configPatch = params.result.configPatch
? createProviderAuthConfigPatch(
loginConfig,
restorePriorAgentsDefaultsModelUnlessOptIn({
cfg: applyProviderAuthConfigPatch(
loginConfig,
profiles.length > 0
? withoutProviderModelPolicy(params.result.configPatch, loginConfig)
: params.result.configPatch,
{
replaceDefaultModels: params.result.replaceDefaultModels,
},
),
priorAgentsDefaultsModel: loginConfig.agents?.defaults?.model,
setDefault: params.setDefault,
}),
)
: undefined;
const shouldUpdateConfig =
(isRecord(configPatch) && Object.keys(configPatch).length > 0) ||
Boolean(params.setDefault && defaultModel);
if (profiles.length > 0 || shouldUpdateConfig) {
await params.beforePersistentEffect?.();
}
try {
for (const candidate of profiles) {
const persisted = await persistProviderAuthProfilesAfterLogin({
profiles: [candidate],
beforeWrite: params.assertCurrent,
config: params.config,
env: params.env,
agentDir: params.agentDir,
...(params.env?.OPENCLAW_STATE_DIR ? { stateDir: params.env.OPENCLAW_STATE_DIR } : {}),
});
const profile = expectDefined(persisted[0], "persisted auth profile");
persistedProfiles.push(profile);
params.assertCurrent?.();
await promotePersistedAuthProfile({
config: params.config,
agentDir: params.agentDir,
provider: profile.credential.provider,
profileId: profile.profileId,
});
}
// Replay only the login's changes; the writer may have newer unrelated settings.
if (shouldUpdateConfig) {
const updated = await writeProviderAuthConfig({
config: params.config,
configSnapshot: params.configSnapshot,
configPatch,
credentialsSaved: persistedProfiles.length > 0,
writeOptions: { assertCurrent: params.assertCurrent },
finalizeConfig: (replayed, cfg) => {
const priorAgentsDefaultsModel = cfg.agents?.defaults?.model;
const next = restorePriorAgentsDefaultsModelUnlessOptIn({
cfg: replayed,
priorAgentsDefaultsModel,
setDefault: params.setDefault,
});
if (params.setDefault && defaultModel) {
return profiles.length > 0
? applyProviderLoginDefaultModel(next, defaultModel)
: applyDefaultModel(next, defaultModel);
}
return next;
},
});
if (defaultModel) {
const repaired = await repairCodexRuntimePluginInstallForModelSelection({
cfg: updated,
model: defaultModel,
});
const copilotRepaired = await repairCopilotRuntimePluginInstallForModelSelection({
cfg: updated,
model: defaultModel,
});
for (const warning of [...repaired.warnings, ...copilotRepaired.warnings]) {
params.runtime.error?.(warning);
}
}
logConfigUpdated(params.runtime);
}
const authRefresh = await refreshProviderAuthAfterLogin(params);
for (const profile of persistedProfiles) {
params.runtime.log(
`Auth profile: ${profile.profileId} (${profile.credential.provider}/${credentialMode(profile.credential)})`,
);
}
if (defaultModel) {
params.runtime.log(
params.setDefault
? `Default model set to ${defaultModel}`
: `Default model available: ${defaultModel} (current default unchanged; run ${formatCliCommand(`openclaw models set ${defaultModel}`)} to apply)`,
);
}
if (params.result.notes && params.result.notes.length > 0) {
await params.prompter.note(params.result.notes.join("\n"), "Provider notes");
}
return { profiles: persistedProfiles, authRefresh };
} catch (error) {
if (persistedProfiles.length > 0 && !(error instanceof ProviderCredentialsSavedError)) {
throw new ProviderCredentialsSavedError(
error instanceof Error
? `Provider credentials were saved, but sign-in did not finish: ${error.message}`
: "Provider credentials were saved, but sign-in did not finish.",
{ cause: error },
);
}
throw error;
}
}
function resolveConfiguredAuthSelectionForProvider(
cfg: OpenClawConfig,
provider: string,
): { createIfMissing: boolean; order?: string[] } {
const providerAuthKey = resolveProviderIdForAuth(provider, { config: cfg });
for (const [orderProvider, profileIds] of Object.entries(cfg.auth?.order ?? {})) {
if (
profileIds.length > 0 &&
resolveProviderIdForAuth(orderProvider, { config: cfg }) === providerAuthKey
) {
return { createIfMissing: true, order: profileIds };
}
}
const profileIds = Object.entries(cfg.auth?.profiles ?? {})
.filter(
([, profile]) =>
resolveProviderIdForAuth(profile.provider, { config: cfg, storedCredential: true }) ===
providerAuthKey,
)
.map(([profileId]) => profileId);
return profileIds.length > 0
? { createIfMissing: true, order: profileIds }
: { createIfMissing: false };
}
async function promotePersistedAuthProfile(params: {
config: OpenClawConfig;
agentDir: string;
provider: string;
profileId: string;
}): Promise<void> {
const selection = resolveConfiguredAuthSelectionForProvider(params.config, params.provider);
const promotion = await promoteAuthProfileInOrder({
agentDir: params.agentDir,
provider: params.provider,
profileId: params.profileId,
createIfMissing: selection.createIfMissing,
...(selection.order ? { createFromOrder: selection.order } : {}),
});
if (!promotion.ok) {
throw new ProviderCredentialsSavedError(
"The auth profile was saved, but its order could not be updated because the auth store is busy. Wait a moment, then retry the login.",
);
}
}
async function runProviderAuthMethod(params: {
config: OpenClawConfig;
configSnapshot: ConfigFileSnapshot;
agentId: string;
agentDir: string;
workspaceDir: string;
provider: ProviderPlugin;
method: ProviderAuthMethod;
runtime: RuntimeEnv;
prompter: WizardPrompter;
profileId?: string;
setDefault?: boolean;
credentialOnly?: boolean;
assertCurrent?: () => void;
env?: NodeJS.ProcessEnv;
isRemote?: boolean;
signal?: AbortSignal;
openUrl?: (url: string) => Promise<void>;
browserAuthorization?: ProviderAuthContext["oauth"]["authorize"];
beforePersistentEffect?: () => void | Promise<void>;
refreshAfterLogin?: ModelsAuthLoginFlowOptions["refreshAfterLogin"];
onModelAccessRequested?: (request: PreparedProviderModelAccess) => void;
}): Promise<{
result: ProviderAuthResult;
profiles: ProviderAuthResult["profiles"];
authRefresh: ModelAuthRefreshOutcome;
}> {
params.signal?.throwIfAborted();
params.assertCurrent?.();
const modelAccess = prepareProviderModelAccess({
config: params.config,
agentId: params.agentId,
provider: params.provider.id,
providerLabel: params.provider.label,
});
const result = await runProviderPluginAuthMethodUnpersisted({
method: params.method,
config: params.config,
credentialOnly: params.credentialOnly,
assertCurrent: params.assertCurrent,
env: params.env ?? process.env,
agentDir: params.agentDir,
workspaceDir: params.workspaceDir,
prompter: params.prompter,
runtime: params.runtime,
allowSecretRefPrompt: false,
isRemote: params.isRemote,
signal: params.signal,
openUrl: params.openUrl,
browserAuthorization: params.browserAuthorization,
});
params.signal?.throwIfAborted();
const connectionResult = params.credentialOnly
? {
profiles: result.profiles,
notes: result.notes,
...(result.configPatch
? {
configPatch: {
...(result.configPatch.models?.providers
? { models: { providers: result.configPatch.models.providers } }
: {}),
...(result.configPatch.plugins ? { plugins: result.configPatch.plugins } : {}),
},
}
: {}),
}
: result;
const profiles = resolveLoginProfiles({
result: connectionResult,
requestedProfileId: params.profileId,
});
const { profiles: persistedProfiles, authRefresh } = await persistProviderAuthResult({
result: connectionResult,
profiles,
assertCurrent: params.assertCurrent,
signal: params.signal,
config: params.config,
configSnapshot: params.configSnapshot,
agentId: params.agentId,
agentDir: params.agentDir,
runtime: params.runtime,
prompter: params.prompter,
setDefault: params.setDefault,
env: params.env ?? process.env,
beforePersistentEffect: params.beforePersistentEffect,
refreshAfterLogin: params.refreshAfterLogin,
});
if (persistedProfiles.length > 0) {
await completeProviderModelAccess({
prepared: modelAccess,
prompter: params.prompter,
onRequested: params.onModelAccessRequested,
runtime: params.runtime,
assertCurrent: () => {
params.signal?.throwIfAborted();
params.assertCurrent?.();
},
}).catch((error: unknown) => {
throw new ProviderAuthConfigApplyError(error);
});
}
return { result: connectionResult, profiles: persistedProfiles, authRefresh };
}
/** Runs an interactive provider setup-token auth flow. */
export async function modelsAuthSetupTokenCommand(
opts: { provider?: string; yes?: boolean; agent?: string },
runtime: RuntimeEnv,
) {
if (!process.stdin.isTTY) {
throw new Error(
`setup-token requires an interactive TTY. In automation, use ${formatCliCommand("openclaw models auth paste-token --provider <provider>")} instead.`,
);
}
const { config, configSnapshot, agentId, agentDir, workspaceDir, providers } =
await resolveModelsAuthContext({
requestedProvider: opts.provider,
rawAgentId: opts.agent,
});
const tokenProviders = listProvidersWithTokenMethods(providers);
if (tokenProviders.length === 0) {
throw new Error(
`No provider token-auth plugins found. Install one via \`${formatCliCommand("openclaw plugins install")}\`.`,
);
}
const provider =
resolveRequestedProviderOrThrow(tokenProviders, opts.provider) ?? tokenProviders[0] ?? null;
if (!provider) {
throw new Error(
`No token-capable provider is available. Run ${formatCliCommand("openclaw plugins list")} to verify provider plugins are installed.`,
);
}
if (!opts.yes) {
const proceed = await confirm({
message: `Continue with ${provider.label} token auth?`,
initialValue: true,
});
if (!proceed) {
return;
}
}
const prompter = createClackPrompter();
const method = await pickProviderTokenMethod({ provider, prompter });
if (!method) {
throw new Error(`Provider "${provider.id}" does not expose a token auth method.`);
}
await runProviderAuthMethod({
config,
configSnapshot,
agentId,
agentDir,
workspaceDir,
provider,
method,
runtime,
prompter,
});
}
/** Reads a pasted bearer/setup token and stores it as an auth profile. */
export async function modelsAuthPasteTokenCommand(
opts: {
provider?: string;
profileId?: string;
expiresIn?: string;
agent?: string;
},
runtime: RuntimeEnv,
) {
const { agentId, agentDir } = await resolveModelsAuthAgent(opts.agent);
const rawProvider = normalizeOptionalString(opts.provider);
if (!rawProvider) {
throw new Error(
`Missing --provider. Run ${formatCliCommand("openclaw models status")} or ${formatCliCommand("openclaw plugins list")} to choose a provider.`,
);
}
const provider = normalizeManualAuthProvider(rawProvider);
const profileId =
normalizeOptionalString(opts.profileId) || resolveDefaultTokenProfileId(provider);
const validateTokenInput = (value: string | undefined): string | undefined => {
const trimmed = value?.trim();
if (!trimmed) {
return "Required";
}
if (provider === "anthropic") {
return validateAnthropicSetupToken(trimmed.replaceAll(/\s+/g, ""));
}
if (isOpenAIProvider(provider) && looksLikeOpenAIApiKey(trimmed)) {
return `That looks like an OpenAI API key. Use ${formatCliCommand("openclaw models auth paste-api-key --provider openai")} for API-key auth.`;
}
return undefined;
};
const tokenInput = await readPastedSecret({
message: `Paste token for ${provider}`,
masked: true,
validate: validateTokenInput,
});
const token =
provider === "anthropic"
? tokenInput.replaceAll(/\s+/g, "").trim()
: (normalizeOptionalString(tokenInput) ?? "");
const expires = resolveManualTokenExpiryMs(opts.expiresIn);
await upsertAuthProfileWithLockOrThrow({
profileId,
credential: {
type: "token",
provider,
token,
...(expires ? { expires } : {}),
},
agentDir,
});
await updateConfig((cfg) => applyAuthProfileConfig(cfg, { profileId, provider, mode: "token" }));
await refreshRunningGatewayAuthState(agentId, "login", runtime);
logConfigUpdated(runtime);
runtime.log(`Auth profile: ${profileId} (${provider}/token)`);
if (provider === "anthropic") {
runtime.log("Anthropic setup-token auth is supported in OpenClaw.");
runtime.log("OpenClaw prefers Claude CLI reuse when it is available on the host.");
runtime.log("Anthropic staff told us this OpenClaw path is allowed again.");
}
}
/** Reads a pasted API key and stores it as an auth profile. */
export async function modelsAuthPasteApiKeyCommand(
opts: {
provider?: string;
profileId?: string;
agent?: string;
},
runtime: RuntimeEnv,
) {
const config = (await loadValidConfigSnapshotOrThrow()).runtimeConfig;
const { agentId, agentDir } = await resolveModelsAuthAgent(opts.agent, config);
const rawProvider = normalizeOptionalString(opts.provider);
if (!rawProvider) {
throw new Error(
`Missing --provider. Run ${formatCliCommand("openclaw models status")} or ${formatCliCommand("openclaw plugins list")} to choose a provider.`,
);
}
const provider = normalizeManualAuthProvider(rawProvider);
const key = await readPastedSecret({
message: `Paste API key for ${provider}`,
masked: true,
validate: (value) => {
const trimmed = value?.trim();
if (!trimmed) {
return "Required";
}
if (isOpenAIProvider(provider)) {
return validateOpenAICodexApiKeyInput(trimmed);
}
return undefined;
},
});
const { profileId, warning } = await saveModelProviderApiKey({
config,
provider,
apiKey: key,
agentDir,
profileId: normalizeOptionalString(opts.profileId),
});
await refreshRunningGatewayAuthState(agentId, "login", runtime);
if (warning) {
runtime.error(warning);
}
logConfigUpdated(runtime);
runtime.log(`Auth profile: ${profileId} (${provider}/api_key)`);
}
/** Interactive helper for adding token auth profiles, with provider/method prompts. */
export async function modelsAuthAddCommand(opts: { agent?: string }, runtime: RuntimeEnv) {
const { config, configSnapshot, agentId, agentDir, workspaceDir, providers } =
await resolveModelsAuthContext({
rawAgentId: opts.agent,
});
const tokenProviders = listProvidersWithTokenMethods(providers);
const provider = await select({
message: "Token provider",
options: [
...tokenProviders.map((providerPlugin) => ({
value: providerPlugin.id,
label: providerPlugin.id,
hint: providerPlugin.docsPath ? `Docs: ${providerPlugin.docsPath}` : undefined,
})),
{ value: "custom", label: "custom (type provider id)" },
],
});
const providerId =
provider === "custom"
? normalizeProviderId(
await text({
message: "Provider id",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
)
: provider;
const providerPlugin =
provider === "custom" ? null : resolveRequestedProviderOrThrow(tokenProviders, providerId);
if (providerPlugin) {
const tokenMethods = listTokenAuthMethods(providerPlugin);
const methodId =
tokenMethods.length > 0
? await select({
message: "Token method",
options: [
...tokenMethods.map((method) => ({
value: method.id,
label: method.label,
hint: method.hint,
})),
{ value: "paste", label: "paste token" },
],
})
: "paste";
if (methodId !== "paste") {
const prompter = createClackPrompter();
const method = tokenMethods.find((candidate) => candidate.id === methodId);
if (!method) {
throw new Error(
`Unknown token auth method "${methodId}". Run ${formatCliCommand("openclaw models auth login --provider " + providerPlugin.id)} to choose interactively.`,
);
}
await runProviderAuthMethod({
config,
configSnapshot,
agentId,
agentDir,
workspaceDir,
provider: providerPlugin,
method,
runtime,
prompter,
});
return;
}
}
const profileIdDefault = resolveDefaultTokenProfileId(providerId);
const profileId = (
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
? (
await text({
message: "Expires in (duration)",
initialValue: "365d",
validate: (value) => {
try {
parseDurationMs(value ?? "", { defaultUnit: "d" });
return undefined;
} catch {
return "Invalid duration (e.g. 365d, 12h, 30m)";
}
},
})
).trim()
: undefined;
await modelsAuthPasteTokenCommand(
{ provider: providerId, profileId, expiresIn, agent: opts.agent },
runtime,
);
}
type LoginOptions = {
provider?: string;
method?: string;
profileId?: string;
setDefault?: boolean;
yes?: boolean;
agent?: string;
/**
* When true, remove any existing auth profiles for the resolved provider
* before invoking the auth flow. This is the escape hatch for stuck
* cached OAuth profiles where the standard `auth login` short-circuits
* because credentials already exist on disk.
*/
force?: boolean;
};
export type ModelsAuthLoginFlowResult = {
providerId: string;
methodId: string;
authRefresh: ModelAuthRefreshOutcome;
defaultModel?: string;
imported?: boolean;
profiles: Array<{
profileId: string;
provider: string;
mode: "api_key" | "oauth" | "token";
}>;
};
export type ModelsAuthLoginFlowOptions = LoginOptions & {
ownerPluginId?: string;
credentialOnly?: boolean;
assertCurrent?: () => void;
config?: OpenClawConfig;
runtime: RuntimeEnv;
prompter: WizardPrompter;
onModelAccessRequested?: (request: PreparedProviderModelAccess) => void;
env?: NodeJS.ProcessEnv;
isRemote?: boolean;
signal?: AbortSignal;
openUrl?: (url: string) => Promise<void>;
browserAuthorization?: ProviderAuthContext["oauth"]["authorize"];
beforePersistentEffect?: () => void | Promise<void>;
/** Publish a hosted login through its current Gateway instead of a separate CLI connection. */
refreshAfterLogin?: (agentId: string) => Promise<void>;
};
/** Resolves a requested login provider or throws with available provider details. */
export function resolveRequestedLoginProviderOrThrow(
providers: ProviderPlugin[],
rawProvider?: string,
): ProviderPlugin | null {
return resolveRequestedProviderOrThrow(providers, rawProvider);
}
function credentialMode(credential: AuthProfileCredential): "api_key" | "oauth" | "token" {
if (credential.type === "api_key") {
return "api_key";
}
if (credential.type === "token") {
return "token";
}
return "oauth";
}
/** Applies an optional profile-id override to a single returned login profile. */
function resolveLoginProfiles(params: {
result: ProviderAuthResult;
requestedProfileId?: string;
}): ProviderAuthResult["profiles"] {
const requestedProfileId = params.requestedProfileId?.trim();
if (!requestedProfileId) {
return params.result.profiles;
}
if (params.result.profiles.length !== 1) {
throw new Error(
"--profile-id requires exactly one returned auth profile from the selected auth method.",
);
}
const [profile] = params.result.profiles;
return [{ ...expectDefined(profile, "auth profile"), profileId: requestedProfileId }];
}
function maybeLogOpenAICodexNativeSearchTip(runtime: RuntimeEnv, providerId: string) {
if (providerId !== "openai") {
return;
}
runtime.log(
`Tip: Codex-capable models can use native Codex web search. Configure the \`web_search\` tool with \`${formatCliCommand("openclaw configure --section web")}\`. Docs: https://docs.openclaw.ai/tools/web`,
);
}
export async function runModelsAuthLoginFlowCore(
opts: ModelsAuthLoginFlowOptions,
): Promise<ModelsAuthLoginFlowResult> {
return runModelsAuthLoginFlow(opts, true);
}
export async function runModelsAuthLoginFlowForGateway(
opts: ModelsAuthLoginFlowOptions,
): Promise<ModelsAuthLoginFlowResult> {
return runModelsAuthLoginFlow(opts, false);
}
async function runModelsAuthLoginFlow(
opts: ModelsAuthLoginFlowOptions,
showScopeNote: boolean,
): Promise<ModelsAuthLoginFlowResult> {
const requestedProviderId = opts.provider
? normalizeManualAuthProvider(opts.provider)
: undefined;
let context = await resolveModelsAuthContext({
requestedProvider: requestedProviderId,
rawAgentId: opts.agent,
config: opts.config,
ownerPluginId: opts.ownerPluginId,
});
const prompter = opts.prompter;
let authProviders = listProvidersWithAuthMethods(context.providers);
let requestedProvider = requestedProviderId
? resolveProviderMatch(authProviders, requestedProviderId)
: null;
const useProviderPicker =
!opts.ownerPluginId &&
requestedProviderId !== undefined &&
requestedProvider === null &&
isCliProvider(requestedProviderId, context.config);
if (useProviderPicker) {
context = await resolveModelsAuthContext({
rawAgentId: opts.agent,
config: context.config,
});
authProviders = listProvidersWithAuthMethods(context.providers);
}
if (authProviders.length === 0) {
throw new Error(
`No provider plugins found. Install one via \`${formatCliCommand("openclaw plugins install")}\`.`,
);
}
if (useProviderPicker) {
await prompter.note(
`Provider "${requestedProviderId}" uses its own CLI login. Select a provider with an OpenClaw auth flow.`,
"Provider auth",
);
} else if (requestedProviderId && !requestedProvider) {
requestedProvider = resolveRequestedLoginProviderOrThrow(authProviders, requestedProviderId);
}
if (showScopeNote) {
await prompter.note(
[
"Scope: System / agent",
`Agent: ${context.agentId}`,
"Location: the machine running OpenClaw",
`For personal model accounts on a Gateway, run ${formatCliCommand("openclaw models accounts login --help")}.`,
].join("\n"),
"Provider sign-in",
);
}
const selectedProvider =
requestedProvider ??
(await prompter
.select({
message: "Select a provider",
options: authProviders.map((provider) => ({
value: provider.id,
label: provider.label,
hint: provider.docsPath ? `Docs: ${provider.docsPath}` : undefined,
})),
})
.then((id) => resolveProviderMatch(authProviders, id)));
if (!selectedProvider) {
throw new Error(
`Unknown provider. Run ${formatCliCommand("openclaw models status")} or ${formatCliCommand("openclaw plugins list")} to see available provider plugins.`,
);
}
if (opts.ownerPluginId && selectedProvider.id !== opts.provider) {
throw new Error("The selected provider login is no longer available.");
}
const chosenMethod = opts.ownerPluginId
? selectedProvider.auth.find((method) => method.id === opts.method)
: await pickProviderAuthMethod({
provider: selectedProvider,
requestedMethod: opts.method,
prompter,
});
if (!chosenMethod) {
throw new Error(
`Unknown auth method. Run ${formatCliCommand("openclaw models auth login --provider " + selectedProvider.id)} without --method to choose interactively.`,
);
}
const modelAccess = prepareProviderModelAccess({
config: context.config,
agentId: context.agentId,
provider: selectedProvider.id,
providerLabel: selectedProvider.label,
});
const imported =
!opts.credentialOnly && !opts.force && !opts.profileId && !opts.setDefault
? await tryImportProviderCredential({
method: chosenMethod,
providerId: selectedProvider.id,
config: context.config,
agentId: context.agentId,
runtime: opts.runtime,
signal: opts.signal,
beforePersistentEffect: opts.beforePersistentEffect,
})
: undefined;
if (imported && "unavailableReason" in imported) {
await prompter.note(imported.unavailableReason, "Existing CLI sign-in");
} else if (imported) {
await promotePersistedAuthProfile({
config: context.config,
agentDir: context.agentDir,
provider: imported.provider,
profileId: imported.profileId,
});
const authRefresh = await refreshProviderAuthAfterLogin({ ...opts, agentId: context.agentId });
if (imported.configUpdated) {
logConfigUpdated(opts.runtime);
}
opts.runtime.log(
`Auth profile: ${imported.profileId} (${imported.provider}/${imported.mode}, imported)`,
);
await completeProviderModelAccess({
prepared: modelAccess,
prompter,
onRequested: opts.onModelAccessRequested,
runtime: opts.runtime,
assertCurrent: () => {
opts.signal?.throwIfAborted();
opts.assertCurrent?.();
},
}).catch((error: unknown) => {
throw new ProviderAuthConfigApplyError(error);
});
return {
providerId: selectedProvider.id,
methodId: chosenMethod.id,
authRefresh,
imported: true,
profiles: [
{ profileId: imported.profileId, provider: imported.provider, mode: imported.mode },
],
};
}
if (opts.force) {
await opts.beforePersistentEffect?.();
// Purge existing profiles for this provider only after we have a valid
// auth method to invoke. Running the purge earlier (before method
// resolution) would delete the user's working credentials and then
// throw on an unresolvable `--method`, leaving them without a usable
// profile and no auth flow started. This is the documented escape
// hatch for stuck OAuth credentials (expired token, swapped account,
// etc.) where `auth login` would otherwise short-circuit on the cached
// profile.
try {
const clearedStore = await removeProviderAuthProfilesWithLock({
cfg: context.config,
provider: selectedProvider.id,
agentDir: context.agentDir,
});
if (!clearedStore) {
throw new Error(
"auth store is busy; close other OpenClaw commands using this state directory and retry",
);
}
opts.runtime.log(
`Removed cached auth profiles for provider "${selectedProvider.id}" (--force). Running fresh auth flow.`,
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(
`Could not clear cached profiles for "${selectedProvider.id}" before re-login: ${message}. Re-login was not started because --force must remove cached profiles first.`,
{ cause: err },
);
}
await refreshRunningGatewayAuthState(context.agentId, "logout", opts.runtime);
}
const { result, profiles, authRefresh } = await runProviderAuthMethod({
config: context.config,
configSnapshot: context.configSnapshot,
agentId: context.agentId,
agentDir: context.agentDir,
workspaceDir: context.workspaceDir,
provider: selectedProvider,
method: chosenMethod,
runtime: opts.runtime,
prompter,
profileId: opts.profileId,
setDefault: opts.setDefault,
credentialOnly: opts.credentialOnly,
assertCurrent: opts.assertCurrent,
env: opts.env,
isRemote: opts.isRemote,
signal: opts.signal,
openUrl: opts.openUrl,
browserAuthorization: opts.browserAuthorization,
beforePersistentEffect: opts.beforePersistentEffect,
refreshAfterLogin: opts.refreshAfterLogin,
onModelAccessRequested: opts.onModelAccessRequested,
});
maybeLogOpenAICodexNativeSearchTip(opts.runtime, selectedProvider.id);
return {
providerId: selectedProvider.id,
methodId: chosenMethod.id,
authRefresh,
...(result.defaultModel ? { defaultModel: result.defaultModel } : {}),
profiles: profiles.map((profile) => ({
profileId: profile.profileId,
provider: profile.credential.provider,
mode: credentialMode(profile.credential),
})),
};
}
export async function modelsAuthLoginCommand(opts: LoginOptions, runtime: RuntimeEnv) {
if (!process.stdin.isTTY) {
throw new Error(
`models auth login requires an interactive TTY. In automation, use ${formatCliCommand("openclaw models auth paste-token --provider <provider>")} when token auth is available.`,
);
}
await runModelsAuthLoginFlowCore({
...opts,
runtime,
prompter: createClackPrompter(),
});
}
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */