refactor: isolate provider sdk auth and model helpers

This commit is contained in:
Peter Steinberger 2026-03-16 21:47:21 -07:00
parent ad7924b0ac
commit dde89d2a83
No known key found for this signature in database
35 changed files with 1118 additions and 1062 deletions

View File

@ -41,10 +41,6 @@ import { collectWhatsAppStatusIssues } from "./status-issues.js";
const meta = getChatChannelMeta("whatsapp");
async function loadWhatsAppChannelRuntime() {
return await import("./channel.runtime.js");
}
function normalizeWhatsAppPayloadText(text: string | undefined): string {
return (text ?? "").replace(/^(?:[ \t]*\r?\n)+/, "");
}

View File

@ -1,10 +1,10 @@
import {
promptSecretRefForSetup,
resolveSecretInputModeForEnvSelection,
} from "../../commands/auth-choice.apply-helpers.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { DmPolicy, GroupPolicy } from "../../config/types.js";
import type { SecretInput } from "../../config/types.secrets.js";
import {
promptSecretRefForSetup,
resolveSecretInputModeForEnvSelection,
} from "../../plugins/provider-auth-input.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
import type { WizardPrompter } from "../../wizard/prompts.js";
import {

View File

@ -1,48 +1,5 @@
const DEFAULT_KEY_PREVIEW = { head: 4, tail: 4 };
export function normalizeApiKeyInput(raw: string): string {
const trimmed = String(raw ?? "").trim();
if (!trimmed) {
return "";
}
// Handle shell-style assignments: export KEY="value" or KEY=value
const assignmentMatch = trimmed.match(/^(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=\s*(.+)$/);
const valuePart = assignmentMatch ? assignmentMatch[1].trim() : trimmed;
const unquoted =
valuePart.length >= 2 &&
((valuePart.startsWith('"') && valuePart.endsWith('"')) ||
(valuePart.startsWith("'") && valuePart.endsWith("'")) ||
(valuePart.startsWith("`") && valuePart.endsWith("`")))
? valuePart.slice(1, -1)
: valuePart;
const withoutSemicolon = unquoted.endsWith(";") ? unquoted.slice(0, -1) : unquoted;
return withoutSemicolon.trim();
}
export const validateApiKeyInput = (value: string) =>
normalizeApiKeyInput(value).length > 0 ? undefined : "Required";
export function formatApiKeyPreview(
raw: string,
opts: { head?: number; tail?: number } = {},
): string {
const trimmed = raw.trim();
if (!trimmed) {
return "…";
}
const head = opts.head ?? DEFAULT_KEY_PREVIEW.head;
const tail = opts.tail ?? DEFAULT_KEY_PREVIEW.tail;
if (trimmed.length <= head + tail) {
const shortHead = Math.min(2, trimmed.length);
const shortTail = Math.min(2, trimmed.length - shortHead);
if (shortTail <= 0) {
return `${trimmed.slice(0, shortHead)}`;
}
return `${trimmed.slice(0, shortHead)}${trimmed.slice(-shortTail)}`;
}
return `${trimmed.slice(0, head)}${trimmed.slice(-tail)}`;
}
export {
formatApiKeyPreview,
normalizeApiKeyInput,
validateApiKeyInput,
} from "../plugins/provider-auth-input.js";

View File

@ -1,280 +1,19 @@
import { resolveEnvApiKey } from "../agents/model-auth.js";
import type { OpenClawConfig } from "../config/types.js";
import {
isValidEnvSecretRefId,
type SecretInput,
type SecretRef,
} from "../config/types.secrets.js";
import { encodeJsonPointerToken } from "../secrets/json-pointer.js";
import { PROVIDER_ENV_VARS } from "../secrets/provider-env-vars.js";
import {
formatExecSecretRefIdValidationMessage,
isValidExecSecretRefId,
isValidFileSecretRefId,
resolveDefaultSecretProviderAlias,
} from "../secrets/ref-contract.js";
import { resolveSecretRefString } from "../secrets/resolve.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { formatApiKeyPreview } from "./auth-choice.api-key.js";
import type { ApplyAuthChoiceParams } from "./auth-choice.apply.js";
import { applyDefaultModelChoice } from "./auth-choice.default-model.js";
import type { SecretInputMode } from "./onboard-types.js";
const ENV_SOURCE_LABEL_RE = /(?:^|:\s)([A-Z][A-Z0-9_]*)$/;
type SecretRefChoice = "env" | "provider"; // pragma: allowlist secret
export type SecretInputModePromptCopy = {
modeMessage?: string;
plaintextLabel?: string;
plaintextHint?: string;
refLabel?: string;
refHint?: string;
};
export type SecretRefSetupPromptCopy = {
sourceMessage?: string;
envVarMessage?: string;
envVarPlaceholder?: string;
envVarFormatError?: string;
envVarMissingError?: (envVar: string) => string;
noProvidersMessage?: string;
envValidatedMessage?: (envVar: string) => string;
providerValidatedMessage?: (provider: string, id: string, source: "file" | "exec") => string;
};
function formatErrorMessage(error: unknown): string {
if (error instanceof Error && typeof error.message === "string" && error.message.trim()) {
return error.message;
}
return String(error);
}
function extractEnvVarFromSourceLabel(source: string): string | undefined {
const match = ENV_SOURCE_LABEL_RE.exec(source.trim());
return match?.[1];
}
function resolveDefaultProviderEnvVar(provider: string): string | undefined {
const envVars = PROVIDER_ENV_VARS[provider];
return envVars?.find((candidate) => candidate.trim().length > 0);
}
function resolveDefaultFilePointerId(provider: string): string {
return `/providers/${encodeJsonPointerToken(provider)}/apiKey`;
}
function resolveRefFallbackInput(params: {
config: OpenClawConfig;
provider: string;
preferredEnvVar?: string;
}): { ref: SecretRef; resolvedValue: string } {
const fallbackEnvVar = params.preferredEnvVar ?? resolveDefaultProviderEnvVar(params.provider);
if (!fallbackEnvVar) {
throw new Error(
`No default environment variable mapping found for provider "${params.provider}". Set a provider-specific env var, or re-run setup in an interactive terminal to configure a ref.`,
);
}
const value = process.env[fallbackEnvVar]?.trim();
if (!value) {
throw new Error(
`Environment variable "${fallbackEnvVar}" is required for --secret-input-mode ref in non-interactive setup.`,
);
}
return {
ref: {
source: "env",
provider: resolveDefaultSecretProviderAlias(params.config, "env", {
preferFirstProviderForSource: true,
}),
id: fallbackEnvVar,
},
resolvedValue: value,
};
}
export async function promptSecretRefForSetup(params: {
provider: string;
config: OpenClawConfig;
prompter: WizardPrompter;
preferredEnvVar?: string;
copy?: SecretRefSetupPromptCopy;
}): Promise<{ ref: SecretRef; resolvedValue: string }> {
const defaultEnvVar =
params.preferredEnvVar ?? resolveDefaultProviderEnvVar(params.provider) ?? "";
const defaultFilePointer = resolveDefaultFilePointerId(params.provider);
let sourceChoice: SecretRefChoice = "env"; // pragma: allowlist secret
while (true) {
const sourceRaw: SecretRefChoice = await params.prompter.select<SecretRefChoice>({
message: params.copy?.sourceMessage ?? "Where is this API key stored?",
initialValue: sourceChoice,
options: [
{
value: "env",
label: "Environment variable",
hint: "Reference a variable from your runtime environment",
},
{
value: "provider",
label: "Configured secret provider",
hint: "Use a configured file or exec secret provider",
},
],
});
const source: SecretRefChoice = sourceRaw === "provider" ? "provider" : "env";
sourceChoice = source;
if (source === "env") {
const envVarRaw = await params.prompter.text({
message: params.copy?.envVarMessage ?? "Environment variable name",
initialValue: defaultEnvVar || undefined,
placeholder: params.copy?.envVarPlaceholder ?? "OPENAI_API_KEY",
validate: (value) => {
const candidate = value.trim();
if (!isValidEnvSecretRefId(candidate)) {
return (
params.copy?.envVarFormatError ??
'Use an env var name like "OPENAI_API_KEY" (uppercase letters, numbers, underscores).'
);
}
if (!process.env[candidate]?.trim()) {
return (
params.copy?.envVarMissingError?.(candidate) ??
`Environment variable "${candidate}" is missing or empty in this session.`
);
}
return undefined;
},
});
const envCandidate = String(envVarRaw ?? "").trim();
const envVar =
envCandidate && isValidEnvSecretRefId(envCandidate) ? envCandidate : defaultEnvVar;
if (!envVar) {
throw new Error(
`No valid environment variable name provided for provider "${params.provider}".`,
);
}
const ref: SecretRef = {
source: "env",
provider: resolveDefaultSecretProviderAlias(params.config, "env", {
preferFirstProviderForSource: true,
}),
id: envVar,
};
const resolvedValue = await resolveSecretRefString(ref, {
config: params.config,
env: process.env,
});
await params.prompter.note(
params.copy?.envValidatedMessage?.(envVar) ??
`Validated environment variable ${envVar}. OpenClaw will store a reference, not the key value.`,
"Reference validated",
);
return { ref, resolvedValue };
}
const externalProviders = Object.entries(params.config.secrets?.providers ?? {}).filter(
([, provider]) => provider?.source === "file" || provider?.source === "exec",
);
if (externalProviders.length === 0) {
await params.prompter.note(
params.copy?.noProvidersMessage ??
"No file/exec secret providers are configured yet. Add one under secrets.providers, or select Environment variable.",
"No providers configured",
);
continue;
}
const defaultProvider = resolveDefaultSecretProviderAlias(params.config, "file", {
preferFirstProviderForSource: true,
});
const selectedProvider = await params.prompter.select<string>({
message: "Select secret provider",
initialValue:
externalProviders.find(([providerName]) => providerName === defaultProvider)?.[0] ??
externalProviders[0]?.[0],
options: externalProviders.map(([providerName, provider]) => ({
value: providerName,
label: providerName,
hint: provider?.source === "exec" ? "Exec provider" : "File provider",
})),
});
const providerEntry = params.config.secrets?.providers?.[selectedProvider];
if (!providerEntry || (providerEntry.source !== "file" && providerEntry.source !== "exec")) {
await params.prompter.note(
`Provider "${selectedProvider}" is not a file/exec provider.`,
"Invalid provider",
);
continue;
}
const idPrompt =
providerEntry.source === "file"
? "Secret id (JSON pointer for json mode, or 'value' for singleValue mode)"
: "Secret id for the exec provider";
const idDefault =
providerEntry.source === "file"
? providerEntry.mode === "singleValue"
? "value"
: defaultFilePointer
: `${params.provider}/apiKey`;
const idRaw = await params.prompter.text({
message: idPrompt,
initialValue: idDefault,
placeholder: providerEntry.source === "file" ? "/providers/openai/apiKey" : "openai/api-key",
validate: (value) => {
const candidate = value.trim();
if (!candidate) {
return "Secret id cannot be empty.";
}
if (
providerEntry.source === "file" &&
providerEntry.mode !== "singleValue" &&
!isValidFileSecretRefId(candidate)
) {
return 'Use an absolute JSON pointer like "/providers/openai/apiKey".';
}
if (
providerEntry.source === "file" &&
providerEntry.mode === "singleValue" &&
candidate !== "value"
) {
return 'singleValue mode expects id "value".';
}
if (providerEntry.source === "exec" && !isValidExecSecretRefId(candidate)) {
return formatExecSecretRefIdValidationMessage();
}
return undefined;
},
});
const id = String(idRaw ?? "").trim() || idDefault;
const ref: SecretRef = {
source: providerEntry.source,
provider: selectedProvider,
id,
};
try {
const resolvedValue = await resolveSecretRefString(ref, {
config: params.config,
env: process.env,
});
await params.prompter.note(
params.copy?.providerValidatedMessage?.(selectedProvider, id, providerEntry.source) ??
`Validated ${providerEntry.source} reference ${selectedProvider}:${id}. OpenClaw will store a reference, not the key value.`,
"Reference validated",
);
return { ref, resolvedValue };
} catch (error) {
await params.prompter.note(
[
`Could not validate provider reference ${selectedProvider}:${id}.`,
formatErrorMessage(error),
"Check your provider configuration and try again.",
].join("\n"),
"Reference check failed",
);
}
}
}
export type {
SecretInputModePromptCopy,
SecretRefSetupPromptCopy,
} from "../plugins/provider-auth-input.js";
export {
ensureApiKeyFromEnvOrPrompt,
ensureApiKeyFromOptionEnvOrPrompt,
maybeApplyApiKeyFromOption,
normalizeSecretInputModeInput,
normalizeTokenProviderInput,
promptSecretRefForSetup,
resolveSecretInputModeForEnvSelection,
} from "../plugins/provider-auth-input.js";
export function createAuthChoiceAgentModelNoter(
params: ApplyAuthChoiceParams,
@ -358,180 +97,3 @@ export function createAuthChoiceDefaultModelApplierForMutableState(
}),
);
}
export function normalizeTokenProviderInput(
tokenProvider: string | null | undefined,
): string | undefined {
const normalized = String(tokenProvider ?? "")
.trim()
.toLowerCase();
return normalized || undefined;
}
export function normalizeSecretInputModeInput(
secretInputMode: string | null | undefined,
): SecretInputMode | undefined {
const normalized = String(secretInputMode ?? "")
.trim()
.toLowerCase();
if (normalized === "plaintext" || normalized === "ref") {
return normalized;
}
return undefined;
}
export async function resolveSecretInputModeForEnvSelection(params: {
prompter: WizardPrompter;
explicitMode?: SecretInputMode;
copy?: SecretInputModePromptCopy;
}): Promise<SecretInputMode> {
if (params.explicitMode) {
return params.explicitMode;
}
// Some tests pass partial prompt harnesses without a select implementation.
// Preserve backward-compatible behavior by defaulting to plaintext in that case.
if (typeof params.prompter.select !== "function") {
return "plaintext";
}
const selected = await params.prompter.select<SecretInputMode>({
message: params.copy?.modeMessage ?? "How do you want to provide this API key?",
initialValue: "plaintext",
options: [
{
value: "plaintext",
label: params.copy?.plaintextLabel ?? "Paste API key now",
hint: params.copy?.plaintextHint ?? "Stores the key directly in OpenClaw config",
},
{
value: "ref",
label: params.copy?.refLabel ?? "Use external secret provider",
hint:
params.copy?.refHint ??
"Stores a reference to env or configured external secret providers",
},
],
});
return selected === "ref" ? "ref" : "plaintext";
}
export async function maybeApplyApiKeyFromOption(params: {
token: string | undefined;
tokenProvider: string | undefined;
secretInputMode?: SecretInputMode;
expectedProviders: string[];
normalize: (value: string) => string;
setCredential: (apiKey: SecretInput, mode?: SecretInputMode) => Promise<void>;
}): Promise<string | undefined> {
const tokenProvider = normalizeTokenProviderInput(params.tokenProvider);
const expectedProviders = params.expectedProviders
.map((provider) => normalizeTokenProviderInput(provider))
.filter((provider): provider is string => Boolean(provider));
if (!params.token || !tokenProvider || !expectedProviders.includes(tokenProvider)) {
return undefined;
}
const apiKey = params.normalize(params.token);
await params.setCredential(apiKey, params.secretInputMode);
return apiKey;
}
export async function ensureApiKeyFromOptionEnvOrPrompt(params: {
token: string | undefined;
tokenProvider: string | undefined;
secretInputMode?: SecretInputMode;
config: OpenClawConfig;
expectedProviders: string[];
provider: string;
envLabel: string;
promptMessage: string;
normalize: (value: string) => string;
validate: (value: string) => string | undefined;
prompter: WizardPrompter;
setCredential: (apiKey: SecretInput, mode?: SecretInputMode) => Promise<void>;
noteMessage?: string;
noteTitle?: string;
}): Promise<string> {
const optionApiKey = await maybeApplyApiKeyFromOption({
token: params.token,
tokenProvider: params.tokenProvider,
secretInputMode: params.secretInputMode,
expectedProviders: params.expectedProviders,
normalize: params.normalize,
setCredential: params.setCredential,
});
if (optionApiKey) {
return optionApiKey;
}
if (params.noteMessage) {
await params.prompter.note(params.noteMessage, params.noteTitle);
}
return await ensureApiKeyFromEnvOrPrompt({
config: params.config,
provider: params.provider,
envLabel: params.envLabel,
promptMessage: params.promptMessage,
normalize: params.normalize,
validate: params.validate,
prompter: params.prompter,
secretInputMode: params.secretInputMode,
setCredential: params.setCredential,
});
}
export async function ensureApiKeyFromEnvOrPrompt(params: {
config: OpenClawConfig;
provider: string;
envLabel: string;
promptMessage: string;
normalize: (value: string) => string;
validate: (value: string) => string | undefined;
prompter: WizardPrompter;
secretInputMode?: SecretInputMode;
setCredential: (apiKey: SecretInput, mode?: SecretInputMode) => Promise<void>;
}): Promise<string> {
const selectedMode = await resolveSecretInputModeForEnvSelection({
prompter: params.prompter,
explicitMode: params.secretInputMode,
});
const envKey = resolveEnvApiKey(params.provider);
if (selectedMode === "ref") {
if (typeof params.prompter.select !== "function") {
const fallback = resolveRefFallbackInput({
config: params.config,
provider: params.provider,
preferredEnvVar: envKey?.source ? extractEnvVarFromSourceLabel(envKey.source) : undefined,
});
await params.setCredential(fallback.ref, selectedMode);
return fallback.resolvedValue;
}
const resolved = await promptSecretRefForSetup({
provider: params.provider,
config: params.config,
prompter: params.prompter,
preferredEnvVar: envKey?.source ? extractEnvVarFromSourceLabel(envKey.source) : undefined,
});
await params.setCredential(resolved.ref, selectedMode);
return resolved.resolvedValue;
}
if (envKey && selectedMode === "plaintext") {
const useExisting = await params.prompter.confirm({
message: `Use existing ${params.envLabel} (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`,
initialValue: true,
});
if (useExisting) {
await params.setCredential(envKey.apiKey, selectedMode);
return envKey.apiKey;
}
}
const key = await params.prompter.text({
message: params.promptMessage,
validate: params.validate,
});
const apiKey = params.normalize(String(key ?? ""));
await params.setCredential(apiKey, selectedMode);
return apiKey;
}

View File

@ -8,7 +8,7 @@ import { upsertAuthProfile } from "../agents/auth-profiles.js";
import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js";
import { enablePluginInConfig } from "../plugins/enable.js";
import { applyAuthProfileConfig } from "../plugins/provider-auth-helpers.js";
import type { ProviderAuthMethod } from "../plugins/types.js";
import type { ProviderAuthMethod, ProviderAuthOptionBag } from "../plugins/types.js";
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
import { isRemoteEnvironment } from "./oauth-env.js";
import { createVpsAwareOAuthHandlers } from "./oauth-flow.js";
@ -97,7 +97,7 @@ export async function runProviderPluginAuthMethod(params: {
workspaceDir,
prompter: params.prompter,
runtime: params.runtime,
opts: params.opts,
opts: params.opts as ProviderAuthOptionBag | undefined,
secretInputMode: params.secretInputMode,
allowSecretRefPrompt: params.allowSecretRefPrompt,
isRemote,
@ -173,7 +173,7 @@ export async function applyAuthChoiceLoadedPluginProvider(
workspaceDir,
secretInputMode: params.opts?.secretInputMode,
allowSecretRefPrompt: false,
opts: params.opts,
opts: params.opts as ProviderAuthOptionBag | undefined,
});
let nextConfig = applied.config;
@ -260,7 +260,7 @@ export async function applyAuthChoicePluginProvider(
workspaceDir,
secretInputMode: params.opts?.secretInputMode,
allowSecretRefPrompt: false,
opts: params.opts,
opts: params.opts as ProviderAuthOptionBag | undefined,
});
nextConfig = applied.config;

View File

@ -1,38 +1,8 @@
import { normalizeProviderId } from "../agents/model-selection.js";
export const ANTHROPIC_SETUP_TOKEN_PREFIX = "sk-ant-oat01-";
export const ANTHROPIC_SETUP_TOKEN_MIN_LENGTH = 80;
export const DEFAULT_TOKEN_PROFILE_NAME = "default";
export function normalizeTokenProfileName(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
return DEFAULT_TOKEN_PROFILE_NAME;
}
const slug = trimmed
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-+|-+$/g, "");
return slug || DEFAULT_TOKEN_PROFILE_NAME;
}
export function buildTokenProfileId(params: { provider: string; name: string }): string {
const provider = normalizeProviderId(params.provider);
const name = normalizeTokenProfileName(params.name);
return `${provider}:${name}`;
}
export function validateAnthropicSetupToken(raw: string): string | undefined {
const trimmed = raw.trim();
if (!trimmed) {
return "Required";
}
if (!trimmed.startsWith(ANTHROPIC_SETUP_TOKEN_PREFIX)) {
return `Expected token starting with ${ANTHROPIC_SETUP_TOKEN_PREFIX}`;
}
if (trimmed.length < ANTHROPIC_SETUP_TOKEN_MIN_LENGTH) {
return "Token looks too short; paste the full setup-token";
}
return undefined;
}
export {
ANTHROPIC_SETUP_TOKEN_MIN_LENGTH,
ANTHROPIC_SETUP_TOKEN_PREFIX,
buildTokenProfileId,
DEFAULT_TOKEN_PROFILE_NAME,
normalizeTokenProfileName,
validateAnthropicSetupToken,
} from "../plugins/provider-auth-token.js";

View File

@ -1,11 +1,4 @@
import type { OpenClawConfig } from "../config/config.js";
import { applyAgentDefaultPrimaryModel } from "./model-default.js";
export const GOOGLE_GEMINI_DEFAULT_MODEL = "google/gemini-3.1-pro-preview";
export function applyGoogleGeminiModelDefault(cfg: OpenClawConfig): {
next: OpenClawConfig;
changed: boolean;
} {
return applyAgentDefaultPrimaryModel({ cfg, model: GOOGLE_GEMINI_DEFAULT_MODEL });
}
export {
applyGoogleGeminiModelDefault,
GOOGLE_GEMINI_DEFAULT_MODEL,
} from "../plugins/provider-model-defaults.js";

View File

@ -1,41 +1 @@
import { DEFAULT_PROVIDER } from "../agents/defaults.js";
import { resolveAllowlistModelKey } from "../agents/model-selection.js";
import type { OpenClawConfig } from "../config/config.js";
export function ensureModelAllowlistEntry(params: {
cfg: OpenClawConfig;
modelRef: string;
defaultProvider?: string;
}): OpenClawConfig {
const rawModelRef = params.modelRef.trim();
if (!rawModelRef) {
return params.cfg;
}
const models = { ...params.cfg.agents?.defaults?.models };
const keySet = new Set<string>([rawModelRef]);
const canonicalKey = resolveAllowlistModelKey(
rawModelRef,
params.defaultProvider ?? DEFAULT_PROVIDER,
);
if (canonicalKey) {
keySet.add(canonicalKey);
}
for (const key of keySet) {
models[key] = {
...models[key],
};
}
return {
...params.cfg,
agents: {
...params.cfg.agents,
defaults: {
...params.cfg.agents?.defaults,
models,
},
},
};
}
export { ensureModelAllowlistEntry } from "../plugins/provider-model-allowlist.js";

View File

@ -1,45 +1,4 @@
import type { OpenClawConfig } from "../config/config.js";
import type { AgentModelListConfig } from "../config/types.js";
export function resolvePrimaryModel(model?: AgentModelListConfig | string): string | undefined {
if (typeof model === "string") {
return model;
}
if (model && typeof model === "object" && typeof model.primary === "string") {
return model.primary;
}
return undefined;
}
export function applyAgentDefaultPrimaryModel(params: {
cfg: OpenClawConfig;
model: string;
legacyModels?: Set<string>;
}): { next: OpenClawConfig; changed: boolean } {
const current = resolvePrimaryModel(params.cfg.agents?.defaults?.model)?.trim();
const normalizedCurrent = current && params.legacyModels?.has(current) ? params.model : current;
if (normalizedCurrent === params.model) {
return { next: params.cfg, changed: false };
}
return {
next: {
...params.cfg,
agents: {
...params.cfg.agents,
defaults: {
...params.cfg.agents?.defaults,
model:
params.cfg.agents?.defaults?.model &&
typeof params.cfg.agents.defaults.model === "object"
? {
...params.cfg.agents.defaults.model,
primary: params.model,
}
: { primary: params.model },
},
},
},
changed: true,
};
}
export {
applyAgentDefaultPrimaryModel,
resolvePrimaryModel,
} from "../plugins/provider-model-primary.js";

View File

@ -11,10 +11,13 @@ import {
} from "../agents/model-selection.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveAgentModelPrimaryValue } from "../config/model-input.js";
import { applyPrimaryModel } from "../plugins/provider-model-primary.js";
import type { ProviderPlugin } from "../plugins/types.js";
import type { WizardPrompter, WizardSelectOption } from "../wizard/prompts.js";
import { formatTokenK } from "./models/shared.js";
export { applyPrimaryModel } from "../plugins/provider-model-primary.js";
const KEEP_VALUE = "__keep__";
const MANUAL_VALUE = "__manual__";
const PROVIDER_FILTER_THRESHOLD = 30;
@ -516,33 +519,6 @@ export async function promptModelAllowlist(params: {
return { models: [] };
}
export function applyPrimaryModel(cfg: OpenClawConfig, model: string): OpenClawConfig {
const defaults = cfg.agents?.defaults;
const existingModel = defaults?.model;
const existingModels = defaults?.models;
const fallbacks =
typeof existingModel === "object" && existingModel !== null && "fallbacks" in existingModel
? (existingModel as { fallbacks?: string[] }).fallbacks
: undefined;
return {
...cfg,
agents: {
...cfg.agents,
defaults: {
...defaults,
model: {
...(fallbacks ? { fallbacks } : undefined),
primary: model,
},
models: {
...existingModels,
[model]: existingModels?.[model] ?? {},
},
},
},
};
}
export function applyModelAllowlist(cfg: OpenClawConfig, models: string[]): OpenClawConfig {
const defaults = cfg.agents?.defaults;
const normalized = normalizeModelKeys(models);

View File

@ -1,53 +1 @@
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
type OAuthPrompt = { message: string; placeholder?: string };
const validateRequiredInput = (value: string) => (value.trim().length > 0 ? undefined : "Required");
export function createVpsAwareOAuthHandlers(params: {
isRemote: boolean;
prompter: WizardPrompter;
runtime: RuntimeEnv;
spin: ReturnType<WizardPrompter["progress"]>;
openUrl: (url: string) => Promise<unknown>;
localBrowserMessage: string;
manualPromptMessage?: string;
}): {
onAuth: (event: { url: string }) => Promise<void>;
onPrompt: (prompt: OAuthPrompt) => Promise<string>;
} {
const manualPromptMessage = params.manualPromptMessage ?? "Paste the redirect URL";
let manualCodePromise: Promise<string> | undefined;
return {
onAuth: async ({ url }) => {
if (params.isRemote) {
params.spin.stop("OAuth URL ready");
params.runtime.log(`\nOpen this URL in your LOCAL browser:\n\n${url}\n`);
manualCodePromise = params.prompter
.text({
message: manualPromptMessage,
validate: validateRequiredInput,
})
.then((value) => String(value));
return;
}
params.spin.update(params.localBrowserMessage);
await params.openUrl(url);
params.runtime.log(`Open: ${url}`);
},
onPrompt: async (prompt) => {
if (manualCodePromise) {
return manualCodePromise;
}
const code = await params.prompter.text({
message: prompt.message,
placeholder: prompt.placeholder,
validate: validateRequiredInput,
});
return String(code);
},
};
}
export * from "../plugins/provider-oauth-flow.js";

View File

@ -1,164 +1 @@
import path from "node:path";
import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig } from "../config/config.js";
import { note } from "../terminal/note.js";
const TLS_CERT_ERROR_CODES = new Set([
"UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
"CERT_HAS_EXPIRED",
"DEPTH_ZERO_SELF_SIGNED_CERT",
"SELF_SIGNED_CERT_IN_CHAIN",
"ERR_TLS_CERT_ALTNAME_INVALID",
]);
const TLS_CERT_ERROR_PATTERNS = [
/unable to get local issuer certificate/i,
/unable to verify the first certificate/i,
/self[- ]signed certificate/i,
/certificate has expired/i,
];
const OPENAI_AUTH_PROBE_URL =
"https://auth.openai.com/oauth/authorize?response_type=code&client_id=openclaw-preflight&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback&scope=openid+profile+email";
type PreflightFailureKind = "tls-cert" | "network";
export type OpenAIOAuthTlsPreflightResult =
| { ok: true }
| {
ok: false;
kind: PreflightFailureKind;
code?: string;
message: string;
};
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
}
function extractFailure(error: unknown): {
code?: string;
message: string;
kind: PreflightFailureKind;
} {
const root = asRecord(error);
const rootCause = asRecord(root?.cause);
const code = typeof rootCause?.code === "string" ? rootCause.code : undefined;
const message =
typeof rootCause?.message === "string"
? rootCause.message
: typeof root?.message === "string"
? root.message
: String(error);
const isTlsCertError =
(code ? TLS_CERT_ERROR_CODES.has(code) : false) ||
TLS_CERT_ERROR_PATTERNS.some((pattern) => pattern.test(message));
return {
code,
message,
kind: isTlsCertError ? "tls-cert" : "network",
};
}
function resolveHomebrewPrefixFromExecPath(execPath: string): string | null {
const marker = `${path.sep}Cellar${path.sep}`;
const idx = execPath.indexOf(marker);
if (idx > 0) {
return execPath.slice(0, idx);
}
const envPrefix = process.env.HOMEBREW_PREFIX?.trim();
return envPrefix ? envPrefix : null;
}
function resolveCertBundlePath(): string | null {
const prefix = resolveHomebrewPrefixFromExecPath(process.execPath);
if (!prefix) {
return null;
}
return path.join(prefix, "etc", "openssl@3", "cert.pem");
}
function hasOpenAICodexOAuthProfile(cfg: OpenClawConfig): boolean {
const profiles = cfg.auth?.profiles;
if (!profiles) {
return false;
}
return Object.values(profiles).some(
(profile) => profile.provider === "openai-codex" && profile.mode === "oauth",
);
}
function shouldRunOpenAIOAuthTlsPrerequisites(params: {
cfg: OpenClawConfig;
deep?: boolean;
}): boolean {
if (params.deep === true) {
return true;
}
return hasOpenAICodexOAuthProfile(params.cfg);
}
export async function runOpenAIOAuthTlsPreflight(options?: {
timeoutMs?: number;
fetchImpl?: typeof fetch;
}): Promise<OpenAIOAuthTlsPreflightResult> {
const timeoutMs = options?.timeoutMs ?? 5000;
const fetchImpl = options?.fetchImpl ?? fetch;
try {
await fetchImpl(OPENAI_AUTH_PROBE_URL, {
method: "GET",
redirect: "manual",
signal: AbortSignal.timeout(timeoutMs),
});
return { ok: true };
} catch (error) {
const failure = extractFailure(error);
return {
ok: false,
kind: failure.kind,
code: failure.code,
message: failure.message,
};
}
}
export function formatOpenAIOAuthTlsPreflightFix(
result: Exclude<OpenAIOAuthTlsPreflightResult, { ok: true }>,
): string {
if (result.kind !== "tls-cert") {
return [
"OpenAI OAuth prerequisites check failed due to a network error before the browser flow.",
`Cause: ${result.message}`,
"Verify DNS/firewall/proxy access to auth.openai.com and retry.",
].join("\n");
}
const certBundlePath = resolveCertBundlePath();
const lines = [
"OpenAI OAuth prerequisites check failed: Node/OpenSSL cannot validate TLS certificates.",
`Cause: ${result.code ? `${result.code} (${result.message})` : result.message}`,
"",
"Fix (Homebrew Node/OpenSSL):",
`- ${formatCliCommand("brew postinstall ca-certificates")}`,
`- ${formatCliCommand("brew postinstall openssl@3")}`,
];
if (certBundlePath) {
lines.push(`- Verify cert bundle exists: ${certBundlePath}`);
}
lines.push("- Retry the OAuth login flow.");
return lines.join("\n");
}
export async function noteOpenAIOAuthTlsPrerequisites(params: {
cfg: OpenClawConfig;
deep?: boolean;
}): Promise<void> {
if (!shouldRunOpenAIOAuthTlsPrerequisites(params)) {
return;
}
const result = await runOpenAIOAuthTlsPreflight({ timeoutMs: 4000 });
if (result.ok || result.kind !== "tls-cert") {
return;
}
note(formatOpenAIOAuthTlsPreflightFix(result), "OAuth TLS prerequisites");
}
export * from "../plugins/provider-openai-codex-oauth-tls.js";

View File

@ -8,6 +8,7 @@ import { resolveDefaultAgentWorkspaceDir } from "../../../agents/workspace.js";
import type { OpenClawConfig } from "../../../config/config.js";
import { enablePluginInConfig } from "../../../plugins/enable.js";
import type {
ProviderAuthOptionBag,
ProviderNonInteractiveApiKeyCredentialParams,
ProviderResolveNonInteractiveApiKeyParams,
} from "../../../plugins/types.js";
@ -130,7 +131,7 @@ export async function applyNonInteractivePluginProviderChoice(params: {
authChoice: params.authChoice,
config: enableResult.config,
baseConfig: params.baseConfig,
opts: params.opts,
opts: params.opts as ProviderAuthOptionBag,
runtime: params.runtime,
agentDir,
workspaceDir,

View File

@ -1,4 +1,5 @@
import type { ChannelId } from "../channels/plugins/types.js";
import type { SecretInputMode } from "../plugins/provider-auth-types.js";
import type { GatewayDaemonRuntime } from "./daemon-runtime.js";
export type OnboardMode = "local" | "remote";
@ -90,7 +91,7 @@ export type NodeManagerChoice = "npm" | "pnpm" | "bun";
export type ChannelChoice = ChannelId;
// Legacy alias (pre-rename).
export type ProviderChoice = ChannelChoice;
export type SecretInputMode = "plaintext" | "ref"; // pragma: allowlist secret
export type { SecretInputMode } from "../plugins/provider-auth-types.js";
export type OnboardOptions = {
mode?: OnboardMode;

View File

@ -1,65 +1 @@
import { loginOpenAICodex, type OAuthCredentials } from "@mariozechner/pi-ai/oauth";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { createVpsAwareOAuthHandlers } from "./oauth-flow.js";
import {
formatOpenAIOAuthTlsPreflightFix,
runOpenAIOAuthTlsPreflight,
} from "./oauth-tls-preflight.js";
export async function loginOpenAICodexOAuth(params: {
prompter: WizardPrompter;
runtime: RuntimeEnv;
isRemote: boolean;
openUrl: (url: string) => Promise<void>;
localBrowserMessage?: string;
}): Promise<OAuthCredentials | null> {
const { prompter, runtime, isRemote, openUrl, localBrowserMessage } = params;
const preflight = await runOpenAIOAuthTlsPreflight();
if (!preflight.ok && preflight.kind === "tls-cert") {
const hint = formatOpenAIOAuthTlsPreflightFix(preflight);
runtime.error(hint);
await prompter.note(hint, "OAuth prerequisites");
throw new Error(preflight.message);
}
await prompter.note(
isRemote
? [
"You are running in a remote/VPS environment.",
"A URL will be shown for you to open in your LOCAL browser.",
"After signing in, paste the redirect URL back here.",
].join("\n")
: [
"Browser will open for OpenAI authentication.",
"If the callback doesn't auto-complete, paste the redirect URL.",
"OpenAI OAuth uses localhost:1455 for the callback.",
].join("\n"),
"OpenAI Codex OAuth",
);
const spin = prompter.progress("Starting OAuth flow…");
try {
const { onAuth: baseOnAuth, onPrompt } = createVpsAwareOAuthHandlers({
isRemote,
prompter,
runtime,
spin,
openUrl,
localBrowserMessage: localBrowserMessage ?? "Complete sign-in in browser…",
});
const creds = await loginOpenAICodex({
onAuth: baseOnAuth,
onPrompt,
onProgress: (msg: string) => spin.update(msg),
});
spin.stop("OpenAI OAuth complete");
return creds ?? null;
} catch (err) {
spin.stop("OpenAI OAuth failed");
runtime.error(String(err));
await prompter.note("Trouble with OAuth? See https://docs.openclaw.ai/start/faq", "OAuth help");
throw err;
}
}
export { loginOpenAICodexOAuth } from "../plugins/provider-openai-codex-oauth.js";

View File

@ -1,47 +1,5 @@
import type { OpenClawConfig } from "../config/config.js";
import { ensureModelAllowlistEntry } from "./model-allowlist.js";
export const OPENAI_DEFAULT_MODEL = "openai/gpt-5.1-codex";
export function applyOpenAIProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
const next = ensureModelAllowlistEntry({
cfg,
modelRef: OPENAI_DEFAULT_MODEL,
});
const models = { ...next.agents?.defaults?.models };
models[OPENAI_DEFAULT_MODEL] = {
...models[OPENAI_DEFAULT_MODEL],
alias: models[OPENAI_DEFAULT_MODEL]?.alias ?? "GPT",
};
return {
...next,
agents: {
...next.agents,
defaults: {
...next.agents?.defaults,
models,
},
},
};
}
export function applyOpenAIConfig(cfg: OpenClawConfig): OpenClawConfig {
const next = applyOpenAIProviderConfig(cfg);
return {
...next,
agents: {
...next.agents,
defaults: {
...next.agents?.defaults,
model:
next.agents?.defaults?.model && typeof next.agents.defaults.model === "object"
? {
...next.agents.defaults.model,
primary: OPENAI_DEFAULT_MODEL,
}
: { primary: OPENAI_DEFAULT_MODEL },
},
},
};
}
export {
applyOpenAIConfig,
applyOpenAIProviderConfig,
OPENAI_DEFAULT_MODEL,
} from "../plugins/provider-model-defaults.js";

View File

@ -1,11 +1,4 @@
import type { OpenClawConfig } from "../config/config.js";
import { applyAgentDefaultPrimaryModel } from "./model-default.js";
export const OPENCODE_GO_DEFAULT_MODEL_REF = "opencode-go/kimi-k2.5";
export function applyOpencodeGoModelDefault(cfg: OpenClawConfig): {
next: OpenClawConfig;
changed: boolean;
} {
return applyAgentDefaultPrimaryModel({ cfg, model: OPENCODE_GO_DEFAULT_MODEL_REF });
}
export {
applyOpencodeGoModelDefault,
OPENCODE_GO_DEFAULT_MODEL_REF,
} from "../plugins/provider-model-defaults.js";

View File

@ -1,19 +1,4 @@
import type { OpenClawConfig } from "../config/config.js";
import { applyAgentDefaultPrimaryModel } from "./model-default.js";
export const OPENCODE_ZEN_DEFAULT_MODEL = "opencode/claude-opus-4-6";
const LEGACY_OPENCODE_ZEN_DEFAULT_MODELS = new Set([
"opencode/claude-opus-4-5",
"opencode-zen/claude-opus-4-5",
]);
export function applyOpencodeZenModelDefault(cfg: OpenClawConfig): {
next: OpenClawConfig;
changed: boolean;
} {
return applyAgentDefaultPrimaryModel({
cfg,
model: OPENCODE_ZEN_DEFAULT_MODEL,
legacyModels: LEGACY_OPENCODE_ZEN_DEFAULT_MODELS,
});
}
export {
applyOpencodeZenModelDefault,
OPENCODE_ZEN_DEFAULT_MODEL,
} from "../plugins/provider-model-defaults.js";

View File

@ -13,6 +13,7 @@ import type {
ProviderAuthMethodNonInteractiveContext,
ProviderNonInteractiveApiKeyResult,
} from "../plugins/types.js";
import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js";
import type { WizardPrompter } from "../wizard/prompts.js";
export {
@ -240,11 +241,10 @@ export async function configureOpenAICompatibleSelfHostedProviderNonInteractive(
contextWindow?: number;
maxTokens?: number;
}): Promise<OpenClawConfig | null> {
const baseUrl = (params.ctx.opts.customBaseUrl?.trim() || params.defaultBaseUrl).replace(
/\/+$/,
"",
);
const modelId = params.ctx.opts.customModelId?.trim();
const baseUrl = (
normalizeOptionalSecretInput(params.ctx.opts.customBaseUrl) ?? params.defaultBaseUrl
).replace(/\/+$/, "");
const modelId = normalizeOptionalSecretInput(params.ctx.opts.customModelId);
if (!modelId) {
params.ctx.runtime.error(
buildMissingNonInteractiveModelIdMessage({
@ -259,7 +259,7 @@ export async function configureOpenAICompatibleSelfHostedProviderNonInteractive(
const resolved = await params.ctx.resolveApiKey({
provider: params.providerId,
flagValue: params.ctx.opts.customApiKey,
flagValue: normalizeOptionalSecretInput(params.ctx.opts.customApiKey),
flagName: "--custom-api-key",
envVar: params.defaultApiKeyEnvVar,
envVarName: params.defaultApiKeyEnvVar,

View File

@ -21,17 +21,20 @@ export {
formatApiKeyPreview,
normalizeApiKeyInput,
validateApiKeyInput,
} from "../commands/auth-choice.api-key.js";
} from "../plugins/provider-auth-input.js";
export {
ensureApiKeyFromOptionEnvOrPrompt,
normalizeSecretInputModeInput,
promptSecretRefForSetup,
resolveSecretInputModeForEnvSelection,
} from "../commands/auth-choice.apply-helpers.js";
export { buildTokenProfileId, validateAnthropicSetupToken } from "../commands/auth-token.js";
} from "../plugins/provider-auth-input.js";
export {
buildTokenProfileId,
validateAnthropicSetupToken,
} from "../plugins/provider-auth-token.js";
export { applyAuthProfileConfig, buildApiKeyCredential } from "../plugins/provider-auth-helpers.js";
export { githubCopilotLoginCommand } from "../providers/github-copilot-auth.js";
export { loginOpenAICodexOAuth } from "../commands/openai-codex-oauth.js";
export { loginOpenAICodexOAuth } from "../plugins/provider-openai-codex-oauth.js";
export { createProviderApiKeyAuthMethod } from "../plugins/provider-api-key-auth.js";
export { coerceSecretRef } from "../config/types.secrets.js";
export { resolveDefaultSecretProviderAlias } from "../secrets/ref-contract.js";

View File

@ -14,10 +14,10 @@ export { normalizeProviderId } from "../agents/provider-id.js";
export {
applyGoogleGeminiModelDefault,
GOOGLE_GEMINI_DEFAULT_MODEL,
} from "../commands/google-gemini-model-default.js";
export { applyOpenAIConfig, OPENAI_DEFAULT_MODEL } from "../commands/openai-model-default.js";
export { OPENCODE_GO_DEFAULT_MODEL_REF } from "../commands/opencode-go-model-default.js";
export { OPENCODE_ZEN_DEFAULT_MODEL } from "../commands/opencode-zen-model-default.js";
} from "../plugins/provider-model-defaults.js";
export { applyOpenAIConfig, OPENAI_DEFAULT_MODEL } from "../plugins/provider-model-defaults.js";
export { OPENCODE_GO_DEFAULT_MODEL_REF } from "../plugins/provider-model-defaults.js";
export { OPENCODE_ZEN_DEFAULT_MODEL } from "../plugins/provider-model-defaults.js";
export { OPENCODE_ZEN_DEFAULT_MODEL_REF } from "../agents/opencode-zen-models.js";
export * from "../plugins/provider-model-definitions.js";

View File

@ -13,4 +13,4 @@ export {
applyProviderConfigWithDefaultModels,
applyProviderConfigWithModelCatalog,
} from "../plugins/provider-onboarding-config.js";
export { ensureModelAllowlistEntry } from "../commands/model-allowlist.js";
export { ensureModelAllowlistEntry } from "../plugins/provider-model-allowlist.js";

View File

@ -1,7 +1,10 @@
import { normalizeApiKeyInput, validateApiKeyInput } from "../commands/auth-choice.api-key.js";
import { ensureApiKeyFromOptionEnvOrPrompt } from "../commands/auth-choice.apply-helpers.js";
import { applyPrimaryModel } from "../commands/model-picker.js";
import { applyAuthProfileConfig, buildApiKeyCredential } from "./provider-auth-helpers.js";
import {
ensureApiKeyFromOptionEnvOrPrompt,
normalizeApiKeyInput,
validateApiKeyInput,
} from "./provider-auth-input.js";
import { applyPrimaryModel } from "./provider-model-primary.js";
export {
applyAuthProfileConfig,

View File

@ -4,7 +4,6 @@ import type { OAuthCredentials } from "@mariozechner/pi-ai";
import { resolveOpenClawAgentDir } from "../agents/agent-paths.js";
import { upsertAuthProfile } from "../agents/auth-profiles.js";
import { normalizeProviderIdForAuth } from "../agents/provider-id.js";
import type { SecretInputMode } from "../commands/onboard-types.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import {
@ -15,6 +14,7 @@ import {
} from "../config/types.secrets.js";
import { PROVIDER_ENV_VARS } from "../secrets/provider-env-vars.js";
import { normalizeSecretInput } from "../utils/normalize-secret-input.js";
import type { SecretInputMode } from "./provider-auth-types.js";
const ENV_REF_PATTERN = /^\$\{([A-Z][A-Z0-9_]*)\}$/;

View File

@ -0,0 +1,496 @@
import { resolveEnvApiKey } from "../agents/model-auth.js";
import type { OpenClawConfig } from "../config/types.js";
import {
isValidEnvSecretRefId,
type SecretInput,
type SecretRef,
} from "../config/types.secrets.js";
import { encodeJsonPointerToken } from "../secrets/json-pointer.js";
import { PROVIDER_ENV_VARS } from "../secrets/provider-env-vars.js";
import {
formatExecSecretRefIdValidationMessage,
isValidExecSecretRefId,
isValidFileSecretRefId,
resolveDefaultSecretProviderAlias,
} from "../secrets/ref-contract.js";
import { resolveSecretRefString } from "../secrets/resolve.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import type { SecretInputMode } from "./provider-auth-types.js";
const DEFAULT_KEY_PREVIEW = { head: 4, tail: 4 };
const ENV_SOURCE_LABEL_RE = /(?:^|:\s)([A-Z][A-Z0-9_]*)$/;
type SecretRefChoice = "env" | "provider"; // pragma: allowlist secret
export type SecretInputModePromptCopy = {
modeMessage?: string;
plaintextLabel?: string;
plaintextHint?: string;
refLabel?: string;
refHint?: string;
};
export type SecretRefSetupPromptCopy = {
sourceMessage?: string;
envVarMessage?: string;
envVarPlaceholder?: string;
envVarFormatError?: string;
envVarMissingError?: (envVar: string) => string;
noProvidersMessage?: string;
envValidatedMessage?: (envVar: string) => string;
providerValidatedMessage?: (provider: string, id: string, source: "file" | "exec") => string;
};
export function normalizeApiKeyInput(raw: string): string {
const trimmed = String(raw ?? "").trim();
if (!trimmed) {
return "";
}
const assignmentMatch = trimmed.match(/^(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=\s*(.+)$/);
const valuePart = assignmentMatch ? assignmentMatch[1].trim() : trimmed;
const unquoted =
valuePart.length >= 2 &&
((valuePart.startsWith('"') && valuePart.endsWith('"')) ||
(valuePart.startsWith("'") && valuePart.endsWith("'")) ||
(valuePart.startsWith("`") && valuePart.endsWith("`")))
? valuePart.slice(1, -1)
: valuePart;
const withoutSemicolon = unquoted.endsWith(";") ? unquoted.slice(0, -1) : unquoted;
return withoutSemicolon.trim();
}
export const validateApiKeyInput = (value: string) =>
normalizeApiKeyInput(value).length > 0 ? undefined : "Required";
export function formatApiKeyPreview(
raw: string,
opts: { head?: number; tail?: number } = {},
): string {
const trimmed = raw.trim();
if (!trimmed) {
return "…";
}
const head = opts.head ?? DEFAULT_KEY_PREVIEW.head;
const tail = opts.tail ?? DEFAULT_KEY_PREVIEW.tail;
if (trimmed.length <= head + tail) {
const shortHead = Math.min(2, trimmed.length);
const shortTail = Math.min(2, trimmed.length - shortHead);
if (shortTail <= 0) {
return `${trimmed.slice(0, shortHead)}`;
}
return `${trimmed.slice(0, shortHead)}${trimmed.slice(-shortTail)}`;
}
return `${trimmed.slice(0, head)}${trimmed.slice(-tail)}`;
}
function formatErrorMessage(error: unknown): string {
if (error instanceof Error && typeof error.message === "string" && error.message.trim()) {
return error.message;
}
return String(error);
}
function extractEnvVarFromSourceLabel(source: string): string | undefined {
const match = ENV_SOURCE_LABEL_RE.exec(source.trim());
return match?.[1];
}
function resolveDefaultProviderEnvVar(provider: string): string | undefined {
const envVars = PROVIDER_ENV_VARS[provider];
return envVars?.find((candidate) => candidate.trim().length > 0);
}
function resolveDefaultFilePointerId(provider: string): string {
return `/providers/${encodeJsonPointerToken(provider)}/apiKey`;
}
function resolveRefFallbackInput(params: {
config: OpenClawConfig;
provider: string;
preferredEnvVar?: string;
}): { ref: SecretRef; resolvedValue: string } {
const fallbackEnvVar = params.preferredEnvVar ?? resolveDefaultProviderEnvVar(params.provider);
if (!fallbackEnvVar) {
throw new Error(
`No default environment variable mapping found for provider "${params.provider}". Set a provider-specific env var, or re-run setup in an interactive terminal to configure a ref.`,
);
}
const value = process.env[fallbackEnvVar]?.trim();
if (!value) {
throw new Error(
`Environment variable "${fallbackEnvVar}" is required for --secret-input-mode ref in non-interactive setup.`,
);
}
return {
ref: {
source: "env",
provider: resolveDefaultSecretProviderAlias(params.config, "env", {
preferFirstProviderForSource: true,
}),
id: fallbackEnvVar,
},
resolvedValue: value,
};
}
export async function promptSecretRefForSetup(params: {
provider: string;
config: OpenClawConfig;
prompter: WizardPrompter;
preferredEnvVar?: string;
copy?: SecretRefSetupPromptCopy;
}): Promise<{ ref: SecretRef; resolvedValue: string }> {
const defaultEnvVar =
params.preferredEnvVar ?? resolveDefaultProviderEnvVar(params.provider) ?? "";
const defaultFilePointer = resolveDefaultFilePointerId(params.provider);
let sourceChoice: SecretRefChoice = "env"; // pragma: allowlist secret
while (true) {
const sourceRaw: SecretRefChoice = await params.prompter.select<SecretRefChoice>({
message: params.copy?.sourceMessage ?? "Where is this API key stored?",
initialValue: sourceChoice,
options: [
{
value: "env",
label: "Environment variable",
hint: "Reference a variable from your runtime environment",
},
{
value: "provider",
label: "Configured secret provider",
hint: "Use a configured file or exec secret provider",
},
],
});
const source: SecretRefChoice = sourceRaw === "provider" ? "provider" : "env";
sourceChoice = source;
if (source === "env") {
const envVarRaw = await params.prompter.text({
message: params.copy?.envVarMessage ?? "Environment variable name",
initialValue: defaultEnvVar || undefined,
placeholder: params.copy?.envVarPlaceholder ?? "OPENAI_API_KEY",
validate: (value) => {
const candidate = value.trim();
if (!isValidEnvSecretRefId(candidate)) {
return (
params.copy?.envVarFormatError ??
'Use an env var name like "OPENAI_API_KEY" (uppercase letters, numbers, underscores).'
);
}
if (!process.env[candidate]?.trim()) {
return (
params.copy?.envVarMissingError?.(candidate) ??
`Environment variable "${candidate}" is missing or empty in this session.`
);
}
return undefined;
},
});
const envCandidate = String(envVarRaw ?? "").trim();
const envVar =
envCandidate && isValidEnvSecretRefId(envCandidate) ? envCandidate : defaultEnvVar;
if (!envVar) {
throw new Error(
`No valid environment variable name provided for provider "${params.provider}".`,
);
}
const ref: SecretRef = {
source: "env",
provider: resolveDefaultSecretProviderAlias(params.config, "env", {
preferFirstProviderForSource: true,
}),
id: envVar,
};
const resolvedValue = await resolveSecretRefString(ref, {
config: params.config,
env: process.env,
});
await params.prompter.note(
params.copy?.envValidatedMessage?.(envVar) ??
`Validated environment variable ${envVar}. OpenClaw will store a reference, not the key value.`,
"Reference validated",
);
return { ref, resolvedValue };
}
const externalProviders = Object.entries(params.config.secrets?.providers ?? {}).filter(
([, provider]) => provider?.source === "file" || provider?.source === "exec",
);
if (externalProviders.length === 0) {
await params.prompter.note(
params.copy?.noProvidersMessage ??
"No file/exec secret providers are configured yet. Add one under secrets.providers, or select Environment variable.",
"No providers configured",
);
continue;
}
const defaultProvider = resolveDefaultSecretProviderAlias(params.config, "file", {
preferFirstProviderForSource: true,
});
const selectedProvider = await params.prompter.select<string>({
message: "Select secret provider",
initialValue:
externalProviders.find(([providerName]) => providerName === defaultProvider)?.[0] ??
externalProviders[0]?.[0],
options: externalProviders.map(([providerName, provider]) => ({
value: providerName,
label: providerName,
hint: provider?.source === "exec" ? "Exec provider" : "File provider",
})),
});
const providerEntry = params.config.secrets?.providers?.[selectedProvider];
if (!providerEntry || (providerEntry.source !== "file" && providerEntry.source !== "exec")) {
await params.prompter.note(
`Provider "${selectedProvider}" is not a file/exec provider.`,
"Invalid provider",
);
continue;
}
const idPrompt =
providerEntry.source === "file"
? "Secret id (JSON pointer for json mode, or 'value' for singleValue mode)"
: "Secret id for the exec provider";
const idDefault =
providerEntry.source === "file"
? providerEntry.mode === "singleValue"
? "value"
: defaultFilePointer
: `${params.provider}/apiKey`;
const idRaw = await params.prompter.text({
message: idPrompt,
initialValue: idDefault,
placeholder: providerEntry.source === "file" ? "/providers/openai/apiKey" : "openai/api-key",
validate: (value) => {
const candidate = value.trim();
if (!candidate) {
return "Secret id cannot be empty.";
}
if (
providerEntry.source === "file" &&
providerEntry.mode !== "singleValue" &&
!isValidFileSecretRefId(candidate)
) {
return 'Use an absolute JSON pointer like "/providers/openai/apiKey".';
}
if (
providerEntry.source === "file" &&
providerEntry.mode === "singleValue" &&
candidate !== "value"
) {
return 'singleValue mode expects id "value".';
}
if (providerEntry.source === "exec" && !isValidExecSecretRefId(candidate)) {
return formatExecSecretRefIdValidationMessage();
}
return undefined;
},
});
const id = String(idRaw ?? "").trim() || idDefault;
const ref: SecretRef = {
source: providerEntry.source,
provider: selectedProvider,
id,
};
try {
const resolvedValue = await resolveSecretRefString(ref, {
config: params.config,
env: process.env,
});
await params.prompter.note(
params.copy?.providerValidatedMessage?.(selectedProvider, id, providerEntry.source) ??
`Validated ${providerEntry.source} reference ${selectedProvider}:${id}. OpenClaw will store a reference, not the key value.`,
"Reference validated",
);
return { ref, resolvedValue };
} catch (error) {
await params.prompter.note(
[
`Could not validate provider reference ${selectedProvider}:${id}.`,
formatErrorMessage(error),
"Check your provider configuration and try again.",
].join("\n"),
"Reference check failed",
);
}
}
}
export function normalizeTokenProviderInput(
tokenProvider: string | null | undefined,
): string | undefined {
const normalized = String(tokenProvider ?? "")
.trim()
.toLowerCase();
return normalized || undefined;
}
export function normalizeSecretInputModeInput(
secretInputMode: string | null | undefined,
): SecretInputMode | undefined {
const normalized = String(secretInputMode ?? "")
.trim()
.toLowerCase();
if (normalized === "plaintext" || normalized === "ref") {
return normalized;
}
return undefined;
}
export async function resolveSecretInputModeForEnvSelection(params: {
prompter: WizardPrompter;
explicitMode?: SecretInputMode;
copy?: SecretInputModePromptCopy;
}): Promise<SecretInputMode> {
if (params.explicitMode) {
return params.explicitMode;
}
if (typeof params.prompter.select !== "function") {
return "plaintext";
}
const selected = await params.prompter.select<SecretInputMode>({
message: params.copy?.modeMessage ?? "How do you want to provide this API key?",
initialValue: "plaintext",
options: [
{
value: "plaintext",
label: params.copy?.plaintextLabel ?? "Paste API key now",
hint: params.copy?.plaintextHint ?? "Stores the key directly in OpenClaw config",
},
{
value: "ref",
label: params.copy?.refLabel ?? "Use external secret provider",
hint:
params.copy?.refHint ??
"Stores a reference to env or configured external secret providers",
},
],
});
return selected === "ref" ? "ref" : "plaintext";
}
export async function maybeApplyApiKeyFromOption(params: {
token: string | undefined;
tokenProvider: string | undefined;
secretInputMode?: SecretInputMode;
expectedProviders: string[];
normalize: (value: string) => string;
setCredential: (apiKey: SecretInput, mode?: SecretInputMode) => Promise<void>;
}): Promise<string | undefined> {
const tokenProvider = normalizeTokenProviderInput(params.tokenProvider);
const expectedProviders = params.expectedProviders
.map((provider) => normalizeTokenProviderInput(provider))
.filter((provider): provider is string => Boolean(provider));
if (!params.token || !tokenProvider || !expectedProviders.includes(tokenProvider)) {
return undefined;
}
const apiKey = params.normalize(params.token);
await params.setCredential(apiKey, params.secretInputMode);
return apiKey;
}
export async function ensureApiKeyFromOptionEnvOrPrompt(params: {
token: string | undefined;
tokenProvider: string | undefined;
secretInputMode?: SecretInputMode;
config: OpenClawConfig;
expectedProviders: string[];
provider: string;
envLabel: string;
promptMessage: string;
normalize: (value: string) => string;
validate: (value: string) => string | undefined;
prompter: WizardPrompter;
setCredential: (apiKey: SecretInput, mode?: SecretInputMode) => Promise<void>;
noteMessage?: string;
noteTitle?: string;
}): Promise<string> {
const optionApiKey = await maybeApplyApiKeyFromOption({
token: params.token,
tokenProvider: params.tokenProvider,
secretInputMode: params.secretInputMode,
expectedProviders: params.expectedProviders,
normalize: params.normalize,
setCredential: params.setCredential,
});
if (optionApiKey) {
return optionApiKey;
}
if (params.noteMessage) {
await params.prompter.note(params.noteMessage, params.noteTitle);
}
return await ensureApiKeyFromEnvOrPrompt({
config: params.config,
provider: params.provider,
envLabel: params.envLabel,
promptMessage: params.promptMessage,
normalize: params.normalize,
validate: params.validate,
prompter: params.prompter,
secretInputMode: params.secretInputMode,
setCredential: params.setCredential,
});
}
export async function ensureApiKeyFromEnvOrPrompt(params: {
config: OpenClawConfig;
provider: string;
envLabel: string;
promptMessage: string;
normalize: (value: string) => string;
validate: (value: string) => string | undefined;
prompter: WizardPrompter;
secretInputMode?: SecretInputMode;
setCredential: (apiKey: SecretInput, mode?: SecretInputMode) => Promise<void>;
}): Promise<string> {
const selectedMode = await resolveSecretInputModeForEnvSelection({
prompter: params.prompter,
explicitMode: params.secretInputMode,
});
const envKey = resolveEnvApiKey(params.provider);
if (selectedMode === "ref") {
if (typeof params.prompter.select !== "function") {
const fallback = resolveRefFallbackInput({
config: params.config,
provider: params.provider,
preferredEnvVar: envKey?.source ? extractEnvVarFromSourceLabel(envKey.source) : undefined,
});
await params.setCredential(fallback.ref, selectedMode);
return fallback.resolvedValue;
}
const resolved = await promptSecretRefForSetup({
provider: params.provider,
config: params.config,
prompter: params.prompter,
preferredEnvVar: envKey?.source ? extractEnvVarFromSourceLabel(envKey.source) : undefined,
});
await params.setCredential(resolved.ref, selectedMode);
return resolved.resolvedValue;
}
if (envKey && selectedMode === "plaintext") {
const useExisting = await params.prompter.confirm({
message: `Use existing ${params.envLabel} (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`,
initialValue: true,
});
if (useExisting) {
await params.setCredential(envKey.apiKey, selectedMode);
return envKey.apiKey;
}
}
const key = await params.prompter.text({
message: params.promptMessage,
validate: params.validate,
});
const apiKey = params.normalize(String(key ?? ""));
await params.setCredential(apiKey, selectedMode);
return apiKey;
}

View File

@ -0,0 +1,38 @@
import { normalizeProviderId } from "../agents/model-selection.js";
export const ANTHROPIC_SETUP_TOKEN_PREFIX = "sk-ant-oat01-";
export const ANTHROPIC_SETUP_TOKEN_MIN_LENGTH = 80;
export const DEFAULT_TOKEN_PROFILE_NAME = "default";
export function normalizeTokenProfileName(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
return DEFAULT_TOKEN_PROFILE_NAME;
}
const slug = trimmed
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-+|-+$/g, "");
return slug || DEFAULT_TOKEN_PROFILE_NAME;
}
export function buildTokenProfileId(params: { provider: string; name: string }): string {
const provider = normalizeProviderId(params.provider);
const name = normalizeTokenProfileName(params.name);
return `${provider}:${name}`;
}
export function validateAnthropicSetupToken(raw: string): string | undefined {
const trimmed = raw.trim();
if (!trimmed) {
return "Required";
}
if (!trimmed.startsWith(ANTHROPIC_SETUP_TOKEN_PREFIX)) {
return `Expected token starting with ${ANTHROPIC_SETUP_TOKEN_PREFIX}`;
}
if (trimmed.length < ANTHROPIC_SETUP_TOKEN_MIN_LENGTH) {
return "Token looks too short; paste the full setup-token";
}
return undefined;
}

View File

@ -0,0 +1 @@
export type SecretInputMode = "plaintext" | "ref"; // pragma: allowlist secret

View File

@ -0,0 +1,41 @@
import { DEFAULT_PROVIDER } from "../agents/defaults.js";
import { resolveAllowlistModelKey } from "../agents/model-selection.js";
import type { OpenClawConfig } from "../config/config.js";
export function ensureModelAllowlistEntry(params: {
cfg: OpenClawConfig;
modelRef: string;
defaultProvider?: string;
}): OpenClawConfig {
const rawModelRef = params.modelRef.trim();
if (!rawModelRef) {
return params.cfg;
}
const models = { ...params.cfg.agents?.defaults?.models };
const keySet = new Set<string>([rawModelRef]);
const canonicalKey = resolveAllowlistModelKey(
rawModelRef,
params.defaultProvider ?? DEFAULT_PROVIDER,
);
if (canonicalKey) {
keySet.add(canonicalKey);
}
for (const key of keySet) {
models[key] = {
...models[key],
};
}
return {
...params.cfg,
agents: {
...params.cfg.agents,
defaults: {
...params.cfg.agents?.defaults,
models,
},
},
};
}

View File

@ -0,0 +1,81 @@
import type { OpenClawConfig } from "../config/config.js";
import { ensureModelAllowlistEntry } from "./provider-model-allowlist.js";
import { applyAgentDefaultPrimaryModel } from "./provider-model-primary.js";
export const GOOGLE_GEMINI_DEFAULT_MODEL = "google/gemini-3.1-pro-preview";
export const OPENAI_DEFAULT_MODEL = "openai/gpt-5.1-codex";
export const OPENCODE_GO_DEFAULT_MODEL_REF = "opencode-go/kimi-k2.5";
export const OPENCODE_ZEN_DEFAULT_MODEL = "opencode/claude-opus-4-6";
const LEGACY_OPENCODE_ZEN_DEFAULT_MODELS = new Set([
"opencode/claude-opus-4-5",
"opencode-zen/claude-opus-4-5",
]);
export function applyGoogleGeminiModelDefault(cfg: OpenClawConfig): {
next: OpenClawConfig;
changed: boolean;
} {
return applyAgentDefaultPrimaryModel({ cfg, model: GOOGLE_GEMINI_DEFAULT_MODEL });
}
export function applyOpenAIProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
const next = ensureModelAllowlistEntry({
cfg,
modelRef: OPENAI_DEFAULT_MODEL,
});
const models = { ...next.agents?.defaults?.models };
models[OPENAI_DEFAULT_MODEL] = {
...models[OPENAI_DEFAULT_MODEL],
alias: models[OPENAI_DEFAULT_MODEL]?.alias ?? "GPT",
};
return {
...next,
agents: {
...next.agents,
defaults: {
...next.agents?.defaults,
models,
},
},
};
}
export function applyOpenAIConfig(cfg: OpenClawConfig): OpenClawConfig {
const next = applyOpenAIProviderConfig(cfg);
return {
...next,
agents: {
...next.agents,
defaults: {
...next.agents?.defaults,
model:
next.agents?.defaults?.model && typeof next.agents.defaults.model === "object"
? {
...next.agents.defaults.model,
primary: OPENAI_DEFAULT_MODEL,
}
: { primary: OPENAI_DEFAULT_MODEL },
},
},
};
}
export function applyOpencodeGoModelDefault(cfg: OpenClawConfig): {
next: OpenClawConfig;
changed: boolean;
} {
return applyAgentDefaultPrimaryModel({ cfg, model: OPENCODE_GO_DEFAULT_MODEL_REF });
}
export function applyOpencodeZenModelDefault(cfg: OpenClawConfig): {
next: OpenClawConfig;
changed: boolean;
} {
return applyAgentDefaultPrimaryModel({
cfg,
model: OPENCODE_ZEN_DEFAULT_MODEL,
legacyModels: LEGACY_OPENCODE_ZEN_DEFAULT_MODELS,
});
}

View File

@ -0,0 +1,72 @@
import type { OpenClawConfig } from "../config/config.js";
import type { AgentModelListConfig } from "../config/types.js";
export function resolvePrimaryModel(model?: AgentModelListConfig | string): string | undefined {
if (typeof model === "string") {
return model;
}
if (model && typeof model === "object" && typeof model.primary === "string") {
return model.primary;
}
return undefined;
}
export function applyAgentDefaultPrimaryModel(params: {
cfg: OpenClawConfig;
model: string;
legacyModels?: Set<string>;
}): { next: OpenClawConfig; changed: boolean } {
const current = resolvePrimaryModel(params.cfg.agents?.defaults?.model)?.trim();
const normalizedCurrent = current && params.legacyModels?.has(current) ? params.model : current;
if (normalizedCurrent === params.model) {
return { next: params.cfg, changed: false };
}
return {
next: {
...params.cfg,
agents: {
...params.cfg.agents,
defaults: {
...params.cfg.agents?.defaults,
model:
params.cfg.agents?.defaults?.model &&
typeof params.cfg.agents.defaults.model === "object"
? {
...params.cfg.agents.defaults.model,
primary: params.model,
}
: { primary: params.model },
},
},
},
changed: true,
};
}
export function applyPrimaryModel(cfg: OpenClawConfig, model: string): OpenClawConfig {
const defaults = cfg.agents?.defaults;
const existingModel = defaults?.model;
const existingModels = defaults?.models;
const fallbacks =
typeof existingModel === "object" && existingModel !== null && "fallbacks" in existingModel
? (existingModel as { fallbacks?: string[] }).fallbacks
: undefined;
return {
...cfg,
agents: {
...cfg.agents,
defaults: {
...defaults,
model: {
...(fallbacks ? { fallbacks } : undefined),
primary: model,
},
models: {
...existingModels,
[model]: existingModels?.[model] ?? {},
},
},
},
};
}

View File

@ -0,0 +1,53 @@
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
export type OAuthPrompt = { message: string; placeholder?: string };
const validateRequiredInput = (value: string) => (value.trim().length > 0 ? undefined : "Required");
export function createVpsAwareOAuthHandlers(params: {
isRemote: boolean;
prompter: WizardPrompter;
runtime: RuntimeEnv;
spin: ReturnType<WizardPrompter["progress"]>;
openUrl: (url: string) => Promise<unknown>;
localBrowserMessage: string;
manualPromptMessage?: string;
}): {
onAuth: (event: { url: string }) => Promise<void>;
onPrompt: (prompt: OAuthPrompt) => Promise<string>;
} {
const manualPromptMessage = params.manualPromptMessage ?? "Paste the redirect URL";
let manualCodePromise: Promise<string> | undefined;
return {
onAuth: async ({ url }) => {
if (params.isRemote) {
params.spin.stop("OAuth URL ready");
params.runtime.log(`\nOpen this URL in your LOCAL browser:\n\n${url}\n`);
manualCodePromise = params.prompter
.text({
message: manualPromptMessage,
validate: validateRequiredInput,
})
.then((value) => String(value));
return;
}
params.spin.update(params.localBrowserMessage);
await params.openUrl(url);
params.runtime.log(`Open: ${url}`);
},
onPrompt: async (prompt) => {
if (manualCodePromise) {
return manualCodePromise;
}
const code = await params.prompter.text({
message: prompt.message,
placeholder: prompt.placeholder,
validate: validateRequiredInput,
});
return String(code);
},
};
}

View File

@ -0,0 +1,164 @@
import path from "node:path";
import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig } from "../config/config.js";
import { note } from "../terminal/note.js";
const TLS_CERT_ERROR_CODES = new Set([
"UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
"CERT_HAS_EXPIRED",
"DEPTH_ZERO_SELF_SIGNED_CERT",
"SELF_SIGNED_CERT_IN_CHAIN",
"ERR_TLS_CERT_ALTNAME_INVALID",
]);
const TLS_CERT_ERROR_PATTERNS = [
/unable to get local issuer certificate/i,
/unable to verify the first certificate/i,
/self[- ]signed certificate/i,
/certificate has expired/i,
];
const OPENAI_AUTH_PROBE_URL =
"https://auth.openai.com/oauth/authorize?response_type=code&client_id=openclaw-preflight&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback&scope=openid+profile+email";
type PreflightFailureKind = "tls-cert" | "network";
export type OpenAIOAuthTlsPreflightResult =
| { ok: true }
| {
ok: false;
kind: PreflightFailureKind;
code?: string;
message: string;
};
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
}
function extractFailure(error: unknown): {
code?: string;
message: string;
kind: PreflightFailureKind;
} {
const root = asRecord(error);
const rootCause = asRecord(root?.cause);
const code = typeof rootCause?.code === "string" ? rootCause.code : undefined;
const message =
typeof rootCause?.message === "string"
? rootCause.message
: typeof root?.message === "string"
? root.message
: String(error);
const isTlsCertError =
(code ? TLS_CERT_ERROR_CODES.has(code) : false) ||
TLS_CERT_ERROR_PATTERNS.some((pattern) => pattern.test(message));
return {
code,
message,
kind: isTlsCertError ? "tls-cert" : "network",
};
}
function resolveHomebrewPrefixFromExecPath(execPath: string): string | null {
const marker = `${path.sep}Cellar${path.sep}`;
const idx = execPath.indexOf(marker);
if (idx > 0) {
return execPath.slice(0, idx);
}
const envPrefix = process.env.HOMEBREW_PREFIX?.trim();
return envPrefix ? envPrefix : null;
}
function resolveCertBundlePath(): string | null {
const prefix = resolveHomebrewPrefixFromExecPath(process.execPath);
if (!prefix) {
return null;
}
return path.join(prefix, "etc", "openssl@3", "cert.pem");
}
function hasOpenAICodexOAuthProfile(cfg: OpenClawConfig): boolean {
const profiles = cfg.auth?.profiles;
if (!profiles) {
return false;
}
return Object.values(profiles).some(
(profile) => profile.provider === "openai-codex" && profile.mode === "oauth",
);
}
function shouldRunOpenAIOAuthTlsPrerequisites(params: {
cfg: OpenClawConfig;
deep?: boolean;
}): boolean {
if (params.deep === true) {
return true;
}
return hasOpenAICodexOAuthProfile(params.cfg);
}
export async function runOpenAIOAuthTlsPreflight(options?: {
timeoutMs?: number;
fetchImpl?: typeof fetch;
}): Promise<OpenAIOAuthTlsPreflightResult> {
const timeoutMs = options?.timeoutMs ?? 5000;
const fetchImpl = options?.fetchImpl ?? fetch;
try {
await fetchImpl(OPENAI_AUTH_PROBE_URL, {
method: "GET",
redirect: "manual",
signal: AbortSignal.timeout(timeoutMs),
});
return { ok: true };
} catch (error) {
const failure = extractFailure(error);
return {
ok: false,
kind: failure.kind,
code: failure.code,
message: failure.message,
};
}
}
export function formatOpenAIOAuthTlsPreflightFix(
result: Exclude<OpenAIOAuthTlsPreflightResult, { ok: true }>,
): string {
if (result.kind !== "tls-cert") {
return [
"OpenAI OAuth prerequisites check failed due to a network error before the browser flow.",
`Cause: ${result.message}`,
"Verify DNS/firewall/proxy access to auth.openai.com and retry.",
].join("\n");
}
const certBundlePath = resolveCertBundlePath();
const lines = [
"OpenAI OAuth prerequisites check failed: Node/OpenSSL cannot validate TLS certificates.",
`Cause: ${result.code ? `${result.code} (${result.message})` : result.message}`,
"",
"Fix (Homebrew Node/OpenSSL):",
`- ${formatCliCommand("brew postinstall ca-certificates")}`,
`- ${formatCliCommand("brew postinstall openssl@3")}`,
];
if (certBundlePath) {
lines.push(`- Verify cert bundle exists: ${certBundlePath}`);
}
lines.push("- Retry the OAuth login flow.");
return lines.join("\n");
}
export async function noteOpenAIOAuthTlsPrerequisites(params: {
cfg: OpenClawConfig;
deep?: boolean;
}): Promise<void> {
if (!shouldRunOpenAIOAuthTlsPrerequisites(params)) {
return;
}
const result = await runOpenAIOAuthTlsPreflight({ timeoutMs: 4000 });
if (result.ok || result.kind !== "tls-cert") {
return;
}
note(formatOpenAIOAuthTlsPreflightFix(result), "OAuth TLS prerequisites");
}

View File

@ -0,0 +1,65 @@
import { loginOpenAICodex, type OAuthCredentials } from "@mariozechner/pi-ai/oauth";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { createVpsAwareOAuthHandlers } from "./provider-oauth-flow.js";
import {
formatOpenAIOAuthTlsPreflightFix,
runOpenAIOAuthTlsPreflight,
} from "./provider-openai-codex-oauth-tls.js";
export async function loginOpenAICodexOAuth(params: {
prompter: WizardPrompter;
runtime: RuntimeEnv;
isRemote: boolean;
openUrl: (url: string) => Promise<void>;
localBrowserMessage?: string;
}): Promise<OAuthCredentials | null> {
const { prompter, runtime, isRemote, openUrl, localBrowserMessage } = params;
const preflight = await runOpenAIOAuthTlsPreflight();
if (!preflight.ok && preflight.kind === "tls-cert") {
const hint = formatOpenAIOAuthTlsPreflightFix(preflight);
runtime.error(hint);
await prompter.note(hint, "OAuth prerequisites");
throw new Error(preflight.message);
}
await prompter.note(
isRemote
? [
"You are running in a remote/VPS environment.",
"A URL will be shown for you to open in your LOCAL browser.",
"After signing in, paste the redirect URL back here.",
].join("\n")
: [
"Browser will open for OpenAI authentication.",
"If the callback doesn't auto-complete, paste the redirect URL.",
"OpenAI OAuth uses localhost:1455 for the callback.",
].join("\n"),
"OpenAI Codex OAuth",
);
const spin = prompter.progress("Starting OAuth flow…");
try {
const { onAuth: baseOnAuth, onPrompt } = createVpsAwareOAuthHandlers({
isRemote,
prompter,
runtime,
spin,
openUrl,
localBrowserMessage: localBrowserMessage ?? "Complete sign-in in browser…",
});
const creds = await loginOpenAICodex({
onAuth: baseOnAuth,
onPrompt,
onProgress: (msg: string) => spin.update(msg),
});
spin.stop("OpenAI OAuth complete");
return creds ?? null;
} catch (err) {
spin.stop("OpenAI OAuth failed");
runtime.error(String(err));
await prompter.note("Trouble with OAuth? See https://docs.openclaw.ai/start/faq", "OAuth help");
throw err;
}
}

View File

@ -17,8 +17,6 @@ import type { AnyAgentTool } from "../agents/tools/common.js";
import type { ThinkLevel } from "../auto-reply/thinking.js";
import type { ReplyPayload } from "../auto-reply/types.js";
import type { ChannelId, ChannelPlugin } from "../channels/plugins/types.js";
import type { createVpsAwareOAuthHandlers } from "../commands/oauth-flow.js";
import type { OnboardOptions } from "../commands/onboard-types.js";
import type { OpenClawConfig } from "../config/config.js";
import type { ModelProviderConfig } from "../config/types.js";
import type { GatewayRequestHandler } from "../gateway/server-methods/types.js";
@ -39,11 +37,20 @@ import type {
SpeechVoiceOption,
} from "../tts/provider-types.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import type { SecretInputMode } from "./provider-auth-types.js";
import type { createVpsAwareOAuthHandlers } from "./provider-oauth-flow.js";
import type { PluginRuntime } from "./runtime/types.js";
export type { PluginRuntime } from "./runtime/types.js";
export type { AnyAgentTool } from "../agents/tools/common.js";
export type ProviderAuthOptionBag = {
token?: string;
tokenProvider?: string;
secretInputMode?: SecretInputMode;
[key: string]: unknown;
};
export type PluginLogger = {
debug?: (message: string) => void;
info: (message: string) => void;
@ -144,7 +151,7 @@ export type ProviderAuthContext = {
* `--token/--token-provider` pairs. Direct `models auth login` usually
* leaves this undefined.
*/
opts?: Partial<OnboardOptions>;
opts?: ProviderAuthOptionBag;
/**
* Onboarding secret persistence preference.
*
@ -152,7 +159,7 @@ export type ProviderAuthContext = {
* plaintext or env/file/exec ref storage. Ad-hoc `models auth login` flows
* usually leave it undefined.
*/
secretInputMode?: OnboardOptions["secretInputMode"];
secretInputMode?: SecretInputMode;
/**
* Whether the provider auth flow should offer the onboarding secret-storage
* mode picker when `secretInputMode` is unset.
@ -196,7 +203,7 @@ export type ProviderAuthMethodNonInteractiveContext = {
authChoice: string;
config: OpenClawConfig;
baseConfig: OpenClawConfig;
opts: OnboardOptions;
opts: ProviderAuthOptionBag;
runtime: RuntimeEnv;
agentDir?: string;
workspaceDir?: string;

View File

@ -1,7 +1,3 @@
import {
promptSecretRefForSetup,
resolveSecretInputModeForEnvSelection,
} from "../commands/auth-choice.apply-helpers.js";
import {
normalizeGatewayTokenInput,
randomToken,
@ -23,6 +19,10 @@ import {
} from "../gateway/gateway-config-prompts.shared.js";
import { DEFAULT_DANGEROUS_NODE_COMMANDS } from "../gateway/node-command-policy.js";
import { findTailscaleBinary } from "../infra/tailscale.js";
import {
promptSecretRefForSetup,
resolveSecretInputModeForEnvSelection,
} from "../plugins/provider-auth-input.js";
import type { RuntimeEnv } from "../runtime.js";
import { validateIPv4AddressInput } from "../shared/net/ipv4.js";
import type { WizardPrompter } from "./prompts.js";