From 5e3a86fd2f3eb4cb5fca3077d0ad490e8405f9ba Mon Sep 17 00:00:00 2001 From: joshavant <830519+joshavant@users.noreply.github.com> Date: Tue, 24 Feb 2026 22:26:33 -0600 Subject: [PATCH] feat(secrets): expand onboarding secret-ref flows and custom-provider parity --- docs/cli/onboard.md | 18 +- docs/gateway/secrets.md | 9 + docs/start/wizard-cli-automation.md | 22 ++ docs/start/wizard-cli-reference.md | 18 +- docs/start/wizard.md | 2 + .../auth-choice.apply-helpers.test.ts | 63 ++++- src/commands/auth-choice.apply-helpers.ts | 236 +++++++++++++++--- src/commands/auth-choice.apply.anthropic.ts | 56 ++--- .../auth-choice.apply.api-providers.ts | 78 ++---- src/commands/auth-choice.apply.byteplus.ts | 59 ++--- src/commands/auth-choice.apply.huggingface.ts | 1 + src/commands/auth-choice.apply.minimax.ts | 1 + src/commands/auth-choice.apply.openai.ts | 55 ++-- src/commands/auth-choice.apply.openrouter.ts | 45 ++-- src/commands/auth-choice.apply.volcengine.ts | 59 ++--- src/commands/auth-choice.apply.xai.ts | 61 ++--- src/commands/auth-choice.test.ts | 85 ++++++- src/commands/onboard-custom.test.ts | 70 +++++- src/commands/onboard-custom.ts | 98 ++++++-- ...oard-non-interactive.provider-auth.test.ts | 137 +++++++++- .../onboard-non-interactive/api-keys.ts | 38 ++- .../local/auth-choice.ts | 62 +++-- src/wizard/onboarding.ts | 1 + 23 files changed, 857 insertions(+), 417 deletions(-) diff --git a/docs/cli/onboard.md b/docs/cli/onboard.md index 47f33ba72bf..9e9bf585a0d 100644 --- a/docs/cli/onboard.md +++ b/docs/cli/onboard.md @@ -49,7 +49,23 @@ openclaw onboard --non-interactive \ --accept-risk ``` -With `--secret-input-mode ref`, onboarding writes provider default env refs (for example `OPENAI_API_KEY`) into auth profiles instead of plaintext key values. +With `--secret-input-mode ref`, onboarding writes env-backed refs instead of plaintext key values. +For auth-profile backed providers this writes `keyRef` entries; for custom providers this writes `models.providers..apiKey` as an env ref (for example `{ source: "env", id: "CUSTOM_API_KEY" }`). + +Non-interactive `ref` mode contract: + +- Set the provider env var in the onboarding process environment (for example `OPENAI_API_KEY`). +- Do not pass inline key flags (for example `--openai-api-key`) unless that env var is also set. +- If an inline key flag is passed without the required env var, onboarding fails fast with guidance. + +Interactive onboarding behavior with reference mode: + +- Choose **Use secret reference** when prompted. +- Then choose either: + - Environment variable + - Encrypted `sops` file (JSON pointer) +- Onboarding performs a fast preflight validation before saving the ref. + - If validation fails, onboarding shows the error and lets you retry. Non-interactive Z.AI endpoint choices: diff --git a/docs/gateway/secrets.md b/docs/gateway/secrets.md index 2e195e494d5..91dbda364a8 100644 --- a/docs/gateway/secrets.md +++ b/docs/gateway/secrets.md @@ -24,6 +24,15 @@ Secrets are resolved into an in-memory runtime snapshot. This keeps external secret source outages off the hot request path. +## Onboarding reference preflight + +When onboarding runs in interactive mode and you choose secret reference storage, OpenClaw performs a fast preflight check before saving: + +- Env refs: validates env var name and confirms a non-empty value is visible during onboarding. +- File refs (`sops`): validates `secrets.sources.file`, decrypts, and resolves the JSON pointer. + +If validation fails, onboarding shows the error and lets you retry with a different ref/source. + ## SecretRef contract Use one object shape everywhere: diff --git a/docs/start/wizard-cli-automation.md b/docs/start/wizard-cli-automation.md index 0e4fc57f5ea..a81cecbcee3 100644 --- a/docs/start/wizard-cli-automation.md +++ b/docs/start/wizard-cli-automation.md @@ -33,6 +33,10 @@ openclaw onboard --non-interactive \ Add `--json` for a machine-readable summary. Use `--secret-input-mode ref` to store env-backed refs in auth profiles instead of plaintext values. +Interactive selection between env refs and encrypted file refs (`sops`) is available in the onboarding wizard flow. + +In non-interactive `ref` mode, provider env vars must be set in the process environment. +Passing inline key flags without the matching env var now fails fast. Example: @@ -145,6 +149,24 @@ openclaw onboard --non-interactive \ `--custom-api-key` is optional. If omitted, onboarding checks `CUSTOM_API_KEY`. + Ref-mode variant: + + ```bash + export CUSTOM_API_KEY="your-key" + openclaw onboard --non-interactive \ + --mode local \ + --auth-choice custom-api-key \ + --custom-base-url "https://llm.example.com/v1" \ + --custom-model-id "foo-large" \ + --secret-input-mode ref \ + --custom-provider-id "my-custom" \ + --custom-compatibility anthropic \ + --gateway-port 18789 \ + --gateway-bind loopback + ``` + + In this mode, onboarding stores `apiKey` as `{ source: "env", id: "CUSTOM_API_KEY" }`. + diff --git a/docs/start/wizard-cli-reference.md b/docs/start/wizard-cli-reference.md index a02f462934b..354fb491d49 100644 --- a/docs/start/wizard-cli-reference.md +++ b/docs/start/wizard-cli-reference.md @@ -177,6 +177,10 @@ What you set: Works with OpenAI-compatible and Anthropic-compatible endpoints. + Interactive onboarding supports the same API key storage choices as other provider API key flows: + - **Paste API key now** (plaintext) + - **Use secret reference** (env or encrypted `sops` file pointer, with preflight validation) + Non-interactive flags: - `--auth-choice custom-api-key` - `--custom-base-url` @@ -204,7 +208,19 @@ Credential and profile paths: API key storage mode: - Default onboarding behavior persists API keys as plaintext values in auth profiles. -- `--secret-input-mode ref` stores env-backed refs in auth profiles instead of plaintext values (for example `keyRef: { source: "env", id: "OPENAI_API_KEY" }`). +- `--secret-input-mode ref` enables reference mode instead of plaintext key storage. + In interactive onboarding, you can choose either: + - environment variable ref (for example `keyRef: { source: "env", id: "OPENAI_API_KEY" }`) + - encrypted file ref via `sops` JSON pointer (for example `keyRef: { source: "file", id: "/providers/openai/apiKey" }`) +- Interactive reference mode runs a fast preflight validation before saving. + - Env refs: validates variable name + non-empty value in the current onboarding environment. + - File refs: validates `secrets.sources.file` + `sops` decrypt + JSON pointer resolution. + - If preflight fails, onboarding shows the error and lets you retry. +- In non-interactive mode, `--secret-input-mode ref` is env-backed only. + - Set the provider env var in the onboarding process environment. + - Inline key flags (for example `--openai-api-key`) require that env var to be set; otherwise onboarding fails fast. + - For custom providers, non-interactive `ref` mode stores `models.providers..apiKey` as `{ source: "env", id: "CUSTOM_API_KEY" }`. + - In that custom-provider case, `--custom-api-key` requires `CUSTOM_API_KEY` to be set; otherwise onboarding fails fast. - Existing plaintext setups continue to work unchanged. diff --git a/docs/start/wizard.md b/docs/start/wizard.md index a676b3c5e2e..240d02818bd 100644 --- a/docs/start/wizard.md +++ b/docs/start/wizard.md @@ -66,6 +66,8 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control). 1. **Model/Auth** — Anthropic API key (recommended), OpenAI, or Custom Provider (OpenAI-compatible, Anthropic-compatible, or Unknown auto-detect). Pick a default model. For non-interactive runs, `--secret-input-mode ref` stores env-backed refs in auth profiles instead of plaintext API key values. + In non-interactive `ref` mode, the provider env var must be set; passing inline key flags without that env var fails fast. + In interactive runs, choosing secret reference mode lets you point at either an environment variable or an encrypted `sops` file pointer, with a fast preflight validation before saving. 2. **Workspace** — Location for agent files (default `~/.openclaw/workspace`). Seeds bootstrap files. 3. **Gateway** — Port, bind address, auth mode, Tailscale exposure. 4. **Channels** — WhatsApp, Telegram, Discord, Google Chat, Mattermost, Signal, BlueBubbles, or iMessage. diff --git a/src/commands/auth-choice.apply-helpers.test.ts b/src/commands/auth-choice.apply-helpers.test.ts index 3a5a7853843..5c2c71ef789 100644 --- a/src/commands/auth-choice.apply-helpers.test.ts +++ b/src/commands/auth-choice.apply-helpers.test.ts @@ -26,11 +26,13 @@ function restoreMinimaxEnv(): void { function createPrompter(params?: { confirm?: WizardPrompter["confirm"]; note?: WizardPrompter["note"]; + select?: WizardPrompter["select"]; text?: WizardPrompter["text"]; }): WizardPrompter { return { confirm: params?.confirm ?? (vi.fn(async () => true) as WizardPrompter["confirm"]), note: params?.note ?? (vi.fn(async () => undefined) as WizardPrompter["note"]), + ...(params?.select ? { select: params.select } : {}), text: params?.text ?? (vi.fn(async () => "prompt-key") as WizardPrompter["text"]), } as unknown as WizardPrompter; } @@ -53,6 +55,7 @@ async function runEnsureMinimaxApiKeyFlow(params: { confirmResult: boolean; text const setCredential = vi.fn(async () => undefined); const result = await ensureApiKeyFromEnvOrPrompt({ + config: {}, provider: "minimax", envLabel: "MINIMAX_API_KEY", promptMessage: "Enter key", @@ -143,7 +146,7 @@ describe("ensureApiKeyFromEnvOrPrompt", () => { }); expect(result).toBe("prompted-key"); - expect(setCredential).toHaveBeenCalledWith("prompted-key", undefined); + expect(setCredential).toHaveBeenCalledWith("prompted-key", "plaintext"); expect(text).toHaveBeenCalledWith( expect.objectContaining({ message: "Enter key", @@ -162,6 +165,7 @@ describe("ensureApiKeyFromEnvOrPrompt", () => { const setCredential = vi.fn(async () => undefined); const result = await ensureApiKeyFromEnvOrPrompt({ + config: {}, provider: "minimax", envLabel: "MINIMAX_API_KEY", promptMessage: "Enter key", @@ -173,38 +177,69 @@ describe("ensureApiKeyFromEnvOrPrompt", () => { }); expect(result).toBe("env-key"); - expect(setCredential).toHaveBeenCalledWith("${MINIMAX_API_KEY}", "ref"); + expect(setCredential).toHaveBeenCalledWith({ source: "env", id: "MINIMAX_API_KEY" }, "ref"); expect(text).not.toHaveBeenCalled(); }); - it("shows a ref-mode note when plaintext input is provided in ref mode", async () => { - delete process.env.MINIMAX_API_KEY; + it("re-prompts after sops ref validation failure and succeeds with env ref", async () => { + process.env.MINIMAX_API_KEY = "env-key"; delete process.env.MINIMAX_OAUTH_TOKEN; - const { confirm, note, text } = createPromptSpies({ - confirmResult: false, - textResult: " prompted-key ", - }); + const selectValues: Array<"file" | "env"> = ["file", "env"]; + const select = vi.fn(async () => selectValues.shift() ?? "env") as WizardPrompter["select"]; + const text = vi + .fn() + .mockResolvedValueOnce("/providers/minimax/apiKey") + .mockResolvedValueOnce("MINIMAX_API_KEY"); + const note = vi.fn(async () => undefined); const setCredential = vi.fn(async () => undefined); const result = await ensureApiKeyFromEnvOrPrompt({ + config: {}, provider: "minimax", envLabel: "MINIMAX_API_KEY", promptMessage: "Enter key", normalize: (value) => value.trim(), validate: () => undefined, - prompter: createPrompter({ confirm, note, text }), + prompter: createPrompter({ select, text, note }), secretInputMode: "ref", setCredential, }); - expect(result).toBe("prompted-key"); - expect(setCredential).toHaveBeenCalledWith("prompted-key", "ref"); + expect(result).toBe("env-key"); + expect(setCredential).toHaveBeenCalledWith({ source: "env", id: "MINIMAX_API_KEY" }, "ref"); expect(note).toHaveBeenCalledWith( - expect.stringContaining("secret-input-mode=ref stores an env reference"), - "Ref mode note", + expect.stringContaining("Could not validate this encrypted file reference."), + "Reference check failed", ); }); + + it("never includes resolved env secret values in reference validation notes", async () => { + process.env.MINIMAX_API_KEY = "sk-minimax-redacted-value"; + delete process.env.MINIMAX_OAUTH_TOKEN; + + const select = vi.fn(async () => "env") as WizardPrompter["select"]; + const text = vi.fn().mockResolvedValue("MINIMAX_API_KEY"); + const note = vi.fn(async () => undefined); + const setCredential = vi.fn(async () => undefined); + + const result = await ensureApiKeyFromEnvOrPrompt({ + config: {}, + provider: "minimax", + envLabel: "MINIMAX_API_KEY", + promptMessage: "Enter key", + normalize: (value) => value.trim(), + validate: () => undefined, + prompter: createPrompter({ select, text, note }), + secretInputMode: "ref", + setCredential, + }); + + expect(result).toBe("sk-minimax-redacted-value"); + const noteMessages = note.mock.calls.map((call) => String(call.at(0) ?? "")).join("\n"); + expect(noteMessages).toContain("Validated environment variable MINIMAX_API_KEY."); + expect(noteMessages).not.toContain("sk-minimax-redacted-value"); + }); }); describe("ensureApiKeyFromOptionEnvOrPrompt", () => { @@ -218,6 +253,7 @@ describe("ensureApiKeyFromOptionEnvOrPrompt", () => { const result = await ensureApiKeyFromOptionEnvOrPrompt({ token: " opts-key ", tokenProvider: " HUGGINGFACE ", + config: {}, expectedProviders: ["huggingface"], provider: "huggingface", envLabel: "HF_TOKEN", @@ -250,6 +286,7 @@ describe("ensureApiKeyFromOptionEnvOrPrompt", () => { const result = await ensureApiKeyFromOptionEnvOrPrompt({ token: "opts-key", tokenProvider: "openai", + config: {}, expectedProviders: ["minimax"], provider: "minimax", envLabel: "MINIMAX_API_KEY", diff --git a/src/commands/auth-choice.apply-helpers.ts b/src/commands/auth-choice.apply-helpers.ts index 0591ce8bf62..5857683db9b 100644 --- a/src/commands/auth-choice.apply-helpers.ts +++ b/src/commands/auth-choice.apply-helpers.ts @@ -1,18 +1,182 @@ import { resolveEnvApiKey } from "../agents/model-auth.js"; +import type { OpenClawConfig } from "../config/types.js"; +import type { SecretInput, SecretRef } from "../config/types.secrets.js"; +import { encodeJsonPointerToken } from "../secrets/json-pointer.js"; +import { PROVIDER_ENV_VARS } from "../secrets/provider-env-vars.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 INLINE_ENV_REF_RE = /^\$\{([A-Z][A-Z0-9_]*)\}$/; const ENV_SOURCE_LABEL_RE = /(?:^|:\s)([A-Z][A-Z0-9_]*)$/; +const ENV_SECRET_REF_ID_RE = /^[A-Z][A-Z0-9_]{0,127}$/; +const FILE_SECRET_REF_SEGMENT_RE = /^(?:[^~]|~0|~1)*$/; + +type SecretRefSourceChoice = "env" | "file"; + +function isValidFileSecretRefId(value: string): boolean { + if (!value.startsWith("/")) { + return false; + } + return value + .slice(1) + .split("/") + .every((segment) => FILE_SECRET_REF_SEGMENT_RE.test(segment)); +} + +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 resolveDefaultSopsPointerId(provider: string): string { + return `/providers/${encodeJsonPointerToken(provider)}/apiKey`; +} + +function resolveRefFallbackInput(params: { + provider: string; + preferredEnvVar?: string; + envKeyValue?: string; +}): { input: SecretInput; resolvedValue: string } { + const fallbackEnvVar = params.preferredEnvVar ?? resolveDefaultProviderEnvVar(params.provider); + if (fallbackEnvVar) { + const value = process.env[fallbackEnvVar]?.trim(); + if (value) { + return { + input: { source: "env", id: fallbackEnvVar }, + resolvedValue: value, + }; + } + } + if (params.envKeyValue?.trim()) { + return { + input: params.envKeyValue.trim(), + resolvedValue: params.envKeyValue.trim(), + }; + } + throw new Error( + `No environment variable found for provider "${params.provider}". Re-run onboarding in an interactive terminal to set a secret reference.`, + ); +} + +async function resolveApiKeyRefForOnboarding(params: { + provider: string; + config: OpenClawConfig; + prompter: WizardPrompter; + preferredEnvVar?: string; +}): Promise<{ ref: SecretRef; resolvedValue: string }> { + const defaultEnvVar = + params.preferredEnvVar ?? resolveDefaultProviderEnvVar(params.provider) ?? ""; + const defaultFilePointer = resolveDefaultSopsPointerId(params.provider); + let sourceChoice: SecretRefSourceChoice = "env"; + + while (true) { + const sourceRaw: SecretRefSourceChoice = await params.prompter.select({ + message: "Where is this API key stored?", + initialValue: sourceChoice, + options: [ + { + value: "env", + label: "Environment variable", + hint: "Reference a variable from your runtime environment", + }, + { + value: "file", + label: "Encrypted sops file", + hint: "Reference a JSON pointer from secrets.sources.file", + }, + ], + }); + const source: SecretRefSourceChoice = sourceRaw === "file" ? "file" : "env"; + sourceChoice = source; + + if (source === "env") { + const envVarRaw = await params.prompter.text({ + message: "Environment variable name", + initialValue: defaultEnvVar || undefined, + placeholder: "OPENAI_API_KEY", + validate: (value) => { + const candidate = value.trim(); + if (!ENV_SECRET_REF_ID_RE.test(candidate)) { + return 'Use an env var name like "OPENAI_API_KEY" (uppercase letters, numbers, underscores).'; + } + if (!process.env[candidate]?.trim()) { + return `Environment variable "${candidate}" is missing or empty in this session.`; + } + return undefined; + }, + }); + const envCandidate = String(envVarRaw ?? "").trim(); + const envVar = + envCandidate && ENV_SECRET_REF_ID_RE.test(envCandidate) ? envCandidate : defaultEnvVar; + if (!envVar) { + throw new Error( + `No valid environment variable name provided for provider "${params.provider}".`, + ); + } + const ref: SecretRef = { source: "env", id: envVar }; + const resolvedValue = await resolveSecretRefString(ref, { + config: params.config, + env: process.env, + }); + await params.prompter.note( + `Validated environment variable ${envVar}. OpenClaw will store a reference, not the key value.`, + "Reference validated", + ); + return { ref, resolvedValue }; + } + + const pointerRaw = await params.prompter.text({ + message: "JSON pointer inside encrypted secrets file", + initialValue: defaultFilePointer, + placeholder: "/providers/openai/apiKey", + validate: (value) => { + const candidate = value.trim(); + if (!isValidFileSecretRefId(candidate)) { + return 'Use an absolute JSON pointer like "/providers/openai/apiKey".'; + } + return undefined; + }, + }); + const pointer = String(pointerRaw ?? "").trim() || defaultFilePointer; + const ref: SecretRef = { source: "file", id: pointer }; + try { + const resolvedValue = await resolveSecretRefString(ref, { + config: params.config, + env: process.env, + }); + await params.prompter.note( + `Validated encrypted file reference ${pointer}. OpenClaw will store a reference, not the key value.`, + "Reference validated", + ); + return { ref, resolvedValue }; + } catch (error) { + await params.prompter.note( + [ + "Could not validate this encrypted file reference.", + formatErrorMessage(error), + "Check secrets.sources.file configuration and sops key access, then try again.", + ].join("\n"), + "Reference check failed", + ); + } + } +} + export function createAuthChoiceAgentModelNoter( params: ApplyAuthChoiceParams, ): (model: string) => Promise { @@ -111,22 +275,23 @@ export async function resolveSecretInputModeForEnvSelection(params: { if (typeof params.prompter.select !== "function") { return "plaintext"; } - return await params.prompter.select({ - message: "How should OpenClaw store this API key?", + const selected = await params.prompter.select({ + message: "How do you want to provide this API key?", initialValue: "plaintext", options: [ { value: "plaintext", - label: "Plaintext on disk", - hint: "Default and fully backward-compatible", + label: "Paste API key now", + hint: "Stores the key directly in OpenClaw config", }, { value: "ref", - label: "Env secret reference", - hint: "Stores env ref only (no plaintext key in auth-profiles)", + label: "Use secret reference", + hint: "Stores a reference to env or encrypted sops secrets", }, ], }); + return selected === "ref" ? "ref" : "plaintext"; } export async function maybeApplyApiKeyFromOption(params: { @@ -135,7 +300,7 @@ export async function maybeApplyApiKeyFromOption(params: { secretInputMode?: SecretInputMode; expectedProviders: string[]; normalize: (value: string) => string; - setCredential: (apiKey: string, mode?: SecretInputMode) => Promise; + setCredential: (apiKey: SecretInput, mode?: SecretInputMode) => Promise; }): Promise { const tokenProvider = normalizeTokenProviderInput(params.tokenProvider); const expectedProviders = params.expectedProviders @@ -153,6 +318,7 @@ export async function ensureApiKeyFromOptionEnvOrPrompt(params: { token: string | undefined; tokenProvider: string | undefined; secretInputMode?: SecretInputMode; + config: OpenClawConfig; expectedProviders: string[]; provider: string; envLabel: string; @@ -160,7 +326,7 @@ export async function ensureApiKeyFromOptionEnvOrPrompt(params: { normalize: (value: string) => string; validate: (value: string) => string | undefined; prompter: WizardPrompter; - setCredential: (apiKey: string, mode?: SecretInputMode) => Promise; + setCredential: (apiKey: SecretInput, mode?: SecretInputMode) => Promise; noteMessage?: string; noteTitle?: string; }): Promise { @@ -181,6 +347,7 @@ export async function ensureApiKeyFromOptionEnvOrPrompt(params: { } return await ensureApiKeyFromEnvOrPrompt({ + config: params.config, provider: params.provider, envLabel: params.envLabel, promptMessage: params.promptMessage, @@ -193,6 +360,7 @@ export async function ensureApiKeyFromOptionEnvOrPrompt(params: { } export async function ensureApiKeyFromEnvOrPrompt(params: { + config: OpenClawConfig; provider: string; envLabel: string; promptMessage: string; @@ -200,27 +368,41 @@ export async function ensureApiKeyFromEnvOrPrompt(params: { validate: (value: string) => string | undefined; prompter: WizardPrompter; secretInputMode?: SecretInputMode; - setCredential: (apiKey: string, mode?: SecretInputMode) => Promise; + setCredential: (apiKey: SecretInput, mode?: SecretInputMode) => Promise; }): Promise { + const selectedMode = await resolveSecretInputModeForEnvSelection({ + prompter: params.prompter, + explicitMode: params.secretInputMode, + }); const envKey = resolveEnvApiKey(params.provider); - if (envKey) { + + if (selectedMode === "ref") { + if (typeof params.prompter.select !== "function") { + const fallback = resolveRefFallbackInput({ + provider: params.provider, + preferredEnvVar: envKey?.source ? extractEnvVarFromSourceLabel(envKey.source) : undefined, + envKeyValue: envKey?.apiKey, + }); + await params.setCredential(fallback.input, selectedMode); + return fallback.resolvedValue; + } + const resolved = await resolveApiKeyRefForOnboarding({ + 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) { - const mode = await resolveSecretInputModeForEnvSelection({ - prompter: params.prompter, - explicitMode: params.secretInputMode, - }); - const explicitEnvRef = - mode === "ref" - ? (() => { - const envVar = extractEnvVarFromSourceLabel(envKey.source); - return envVar ? `\${${envVar}}` : envKey.apiKey; - })() - : envKey.apiKey; - await params.setCredential(explicitEnvRef, mode); + await params.setCredential(envKey.apiKey, selectedMode); return envKey.apiKey; } } @@ -230,12 +412,6 @@ export async function ensureApiKeyFromEnvOrPrompt(params: { validate: params.validate, }); const apiKey = params.normalize(String(key ?? "")); - if (params.secretInputMode === "ref" && !INLINE_ENV_REF_RE.test(apiKey)) { - await params.prompter.note( - "secret-input-mode=ref stores an env reference, not plaintext key input. Enter ${ENV_VAR} to target a specific variable, or keep current input to use the provider default env var.", - "Ref mode note", - ); - } - await params.setCredential(apiKey, params.secretInputMode); + await params.setCredential(apiKey, selectedMode); return apiKey; } diff --git a/src/commands/auth-choice.apply.anthropic.ts b/src/commands/auth-choice.apply.anthropic.ts index 4b43ba1042f..5f82426ef10 100644 --- a/src/commands/auth-choice.apply.anthropic.ts +++ b/src/commands/auth-choice.apply.anthropic.ts @@ -1,12 +1,8 @@ import { upsertAuthProfile } from "../agents/auth-profiles.js"; -import { - formatApiKeyPreview, - normalizeApiKeyInput, - validateApiKeyInput, -} from "./auth-choice.api-key.js"; +import { normalizeApiKeyInput, validateApiKeyInput } from "./auth-choice.api-key.js"; import { normalizeSecretInputModeInput, - resolveSecretInputModeForEnvSelection, + ensureApiKeyFromOptionEnvOrPrompt, } from "./auth-choice.apply-helpers.js"; import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; import { buildTokenProfileId, validateAnthropicSetupToken } from "./auth-token.js"; @@ -75,39 +71,21 @@ export async function applyAuthChoiceAnthropic( } let nextConfig = params.config; - let hasCredential = false; - const envKey = process.env.ANTHROPIC_API_KEY?.trim(); - - if (params.opts?.token) { - await setAnthropicApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir, { - secretInputMode: requestedSecretInputMode, - }); - hasCredential = true; - } - - if (!hasCredential && envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing ANTHROPIC_API_KEY (env, ${formatApiKeyPreview(envKey)})?`, - initialValue: true, - }); - if (useExisting) { - const mode = await resolveSecretInputModeForEnvSelection({ - prompter: params.prompter, - explicitMode: requestedSecretInputMode, - }); - await setAnthropicApiKey(envKey, params.agentDir, { secretInputMode: mode }); - hasCredential = true; - } - } - if (!hasCredential) { - const key = await params.prompter.text({ - message: "Enter Anthropic API key", - validate: validateApiKeyInput, - }); - await setAnthropicApiKey(normalizeApiKeyInput(String(key ?? "")), params.agentDir, { - secretInputMode: requestedSecretInputMode, - }); - } + await ensureApiKeyFromOptionEnvOrPrompt({ + token: params.opts?.token, + tokenProvider: params.opts?.tokenProvider ?? "anthropic", + secretInputMode: requestedSecretInputMode, + config: nextConfig, + expectedProviders: ["anthropic"], + provider: "anthropic", + envLabel: "ANTHROPIC_API_KEY", + promptMessage: "Enter Anthropic API key", + normalize: normalizeApiKeyInput, + validate: validateApiKeyInput, + prompter: params.prompter, + setCredential: async (apiKey, mode) => + setAnthropicApiKey(apiKey, params.agentDir, { secretInputMode: mode }), + }); nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "anthropic:default", provider: "anthropic", diff --git a/src/commands/auth-choice.apply.api-providers.ts b/src/commands/auth-choice.apply.api-providers.ts index 61e7c3d4ab9..2be73ee14f2 100644 --- a/src/commands/auth-choice.apply.api-providers.ts +++ b/src/commands/auth-choice.apply.api-providers.ts @@ -1,17 +1,12 @@ import { ensureAuthProfileStore, resolveAuthProfileOrder } from "../agents/auth-profiles.js"; -import { resolveEnvApiKey } from "../agents/model-auth.js"; -import { - formatApiKeyPreview, - normalizeApiKeyInput, - validateApiKeyInput, -} from "./auth-choice.api-key.js"; +import type { SecretInput } from "../config/types.secrets.js"; +import { normalizeApiKeyInput, validateApiKeyInput } from "./auth-choice.api-key.js"; import { normalizeSecretInputModeInput, createAuthChoiceAgentModelNoter, createAuthChoiceDefaultModelApplier, createAuthChoiceModelStateBridge, ensureApiKeyFromOptionEnvOrPrompt, - resolveSecretInputModeForEnvSelection, normalizeTokenProviderInput, } from "./auth-choice.apply-helpers.js"; import { applyAuthChoiceHuggingface } from "./auth-choice.apply.huggingface.js"; @@ -128,7 +123,7 @@ type SimpleApiKeyProviderFlow = { envLabel: string; promptMessage: string; setCredential: ( - apiKey: string, + apiKey: SecretInput, agentDir?: string, options?: ApiKeyStorageOptions, ) => void | Promise; @@ -363,7 +358,7 @@ export async function applyAuthChoiceApiProviders( expectedProviders: string[]; envLabel: string; promptMessage: string; - setCredential: (apiKey: string, mode?: SecretInputMode) => void | Promise; + setCredential: (apiKey: SecretInput, mode?: SecretInputMode) => void | Promise; defaultModel: string; applyDefaultConfig: ( config: ApplyAuthChoiceParams["config"], @@ -383,6 +378,7 @@ export async function applyAuthChoiceApiProviders( provider, tokenProvider, secretInputMode: requestedSecretInputMode, + config: nextConfig, expectedProviders, envLabel, promptMessage, @@ -431,6 +427,7 @@ export async function applyAuthChoiceApiProviders( token: params.opts?.token, tokenProvider: normalizedTokenProvider, secretInputMode: requestedSecretInputMode, + config: nextConfig, expectedProviders: ["litellm"], provider: "litellm", envLabel: "LITELLM_API_KEY", @@ -508,53 +505,26 @@ export async function applyAuthChoiceApiProviders( } }; - const optsApiKey = normalizeApiKeyInput(params.opts?.cloudflareAiGatewayApiKey ?? ""); - let resolvedApiKey = ""; - if (accountId && gatewayId && optsApiKey) { - await setCloudflareAiGatewayConfig(accountId, gatewayId, optsApiKey, params.agentDir, { - secretInputMode: requestedSecretInputMode, - }); - resolvedApiKey = optsApiKey; - } + await ensureAccountGateway(); - const envKey = resolveEnvApiKey("cloudflare-ai-gateway"); - if (!resolvedApiKey && envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing CLOUDFLARE_AI_GATEWAY_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - await ensureAccountGateway(); - const mode = await resolveSecretInputModeForEnvSelection({ - prompter: params.prompter, - explicitMode: requestedSecretInputMode, - }); - await setCloudflareAiGatewayConfig(accountId, gatewayId, envKey.apiKey, params.agentDir, { + await ensureApiKeyFromOptionEnvOrPrompt({ + token: params.opts?.cloudflareAiGatewayApiKey, + tokenProvider: "cloudflare-ai-gateway", + secretInputMode: requestedSecretInputMode, + config: nextConfig, + expectedProviders: ["cloudflare-ai-gateway"], + provider: "cloudflare-ai-gateway", + envLabel: "CLOUDFLARE_AI_GATEWAY_API_KEY", + promptMessage: "Enter Cloudflare AI Gateway API key", + normalize: normalizeApiKeyInput, + validate: validateApiKeyInput, + prompter: params.prompter, + setCredential: async (apiKey, mode) => + setCloudflareAiGatewayConfig(accountId, gatewayId, apiKey, params.agentDir, { secretInputMode: mode, - }); - resolvedApiKey = normalizeApiKeyInput(envKey.apiKey); - } - } + }), + }); - if (!resolvedApiKey && optsApiKey) { - await ensureAccountGateway(); - await setCloudflareAiGatewayConfig(accountId, gatewayId, optsApiKey, params.agentDir, { - secretInputMode: requestedSecretInputMode, - }); - resolvedApiKey = optsApiKey; - } - - if (!resolvedApiKey) { - await ensureAccountGateway(); - const key = await params.prompter.text({ - message: "Enter Cloudflare AI Gateway API key", - validate: validateApiKeyInput, - }); - resolvedApiKey = normalizeApiKeyInput(String(key ?? "")); - await setCloudflareAiGatewayConfig(accountId, gatewayId, resolvedApiKey, params.agentDir, { - secretInputMode: requestedSecretInputMode, - }); - } nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "cloudflare-ai-gateway:default", provider: "cloudflare-ai-gateway", @@ -583,6 +553,7 @@ export async function applyAuthChoiceApiProviders( provider: "google", tokenProvider: normalizedTokenProvider, secretInputMode: requestedSecretInputMode, + config: nextConfig, expectedProviders: ["google"], envLabel: "GEMINI_API_KEY", promptMessage: "Enter Gemini API key", @@ -627,6 +598,7 @@ export async function applyAuthChoiceApiProviders( provider: "zai", tokenProvider: normalizedTokenProvider, secretInputMode: requestedSecretInputMode, + config: nextConfig, expectedProviders: ["zai"], envLabel: "ZAI_API_KEY", promptMessage: "Enter Z.AI API key", diff --git a/src/commands/auth-choice.apply.byteplus.ts b/src/commands/auth-choice.apply.byteplus.ts index 87ae7a75595..80cfa377bde 100644 --- a/src/commands/auth-choice.apply.byteplus.ts +++ b/src/commands/auth-choice.apply.byteplus.ts @@ -1,12 +1,7 @@ -import { resolveEnvApiKey } from "../agents/model-auth.js"; -import { - formatApiKeyPreview, - normalizeApiKeyInput, - validateApiKeyInput, -} from "./auth-choice.api-key.js"; +import { normalizeApiKeyInput, validateApiKeyInput } from "./auth-choice.api-key.js"; import { + ensureApiKeyFromOptionEnvOrPrompt, normalizeSecretInputModeInput, - resolveSecretInputModeForEnvSelection, } from "./auth-choice.apply-helpers.js"; import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; import { applyPrimaryModel } from "./model-picker.js"; @@ -23,44 +18,20 @@ export async function applyAuthChoiceBytePlus( } const requestedSecretInputMode = normalizeSecretInputModeInput(params.opts?.secretInputMode); - const envKey = resolveEnvApiKey("byteplus"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing BYTEPLUS_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - const mode = await resolveSecretInputModeForEnvSelection({ - prompter: params.prompter, - explicitMode: requestedSecretInputMode, - }); - await setByteplusApiKey(envKey.apiKey, params.agentDir, { secretInputMode: mode }); - const configWithAuth = applyAuthProfileConfig(params.config, { - profileId: "byteplus:default", - provider: "byteplus", - mode: "api_key", - }); - const configWithModel = applyPrimaryModel(configWithAuth, BYTEPLUS_DEFAULT_MODEL); - return { - config: configWithModel, - agentModelOverride: BYTEPLUS_DEFAULT_MODEL, - }; - } - } - - let key: string | undefined; - if (params.opts?.byteplusApiKey) { - key = params.opts.byteplusApiKey; - } else { - key = await params.prompter.text({ - message: "Enter BytePlus API key", - validate: validateApiKeyInput, - }); - } - - const trimmed = normalizeApiKeyInput(String(key)); - await setByteplusApiKey(trimmed, params.agentDir, { + await ensureApiKeyFromOptionEnvOrPrompt({ + token: params.opts?.byteplusApiKey, + tokenProvider: "byteplus", secretInputMode: requestedSecretInputMode, + config: params.config, + expectedProviders: ["byteplus"], + provider: "byteplus", + envLabel: "BYTEPLUS_API_KEY", + promptMessage: "Enter BytePlus API key", + normalize: normalizeApiKeyInput, + validate: validateApiKeyInput, + prompter: params.prompter, + setCredential: async (apiKey, mode) => + setByteplusApiKey(apiKey, params.agentDir, { secretInputMode: mode }), }); const configWithAuth = applyAuthProfileConfig(params.config, { profileId: "byteplus:default", diff --git a/src/commands/auth-choice.apply.huggingface.ts b/src/commands/auth-choice.apply.huggingface.ts index 0361de96519..91bfd533cb0 100644 --- a/src/commands/auth-choice.apply.huggingface.ts +++ b/src/commands/auth-choice.apply.huggingface.ts @@ -34,6 +34,7 @@ export async function applyAuthChoiceHuggingface( token: params.opts?.token, tokenProvider: params.opts?.tokenProvider, secretInputMode: requestedSecretInputMode, + config: nextConfig, expectedProviders: ["huggingface"], provider: "huggingface", envLabel: "Hugging Face token", diff --git a/src/commands/auth-choice.apply.minimax.ts b/src/commands/auth-choice.apply.minimax.ts index 5a16a3f87c7..9b6c83fc204 100644 --- a/src/commands/auth-choice.apply.minimax.ts +++ b/src/commands/auth-choice.apply.minimax.ts @@ -41,6 +41,7 @@ export async function applyAuthChoiceMiniMax( token: params.opts?.token, tokenProvider: params.opts?.tokenProvider, secretInputMode: requestedSecretInputMode, + config: nextConfig, expectedProviders: ["minimax", "minimax-cn"], provider: "minimax", envLabel: "MINIMAX_API_KEY", diff --git a/src/commands/auth-choice.apply.openai.ts b/src/commands/auth-choice.apply.openai.ts index d0418381afe..57059307920 100644 --- a/src/commands/auth-choice.apply.openai.ts +++ b/src/commands/auth-choice.apply.openai.ts @@ -1,13 +1,8 @@ -import { resolveEnvApiKey } from "../agents/model-auth.js"; -import { - formatApiKeyPreview, - normalizeApiKeyInput, - validateApiKeyInput, -} from "./auth-choice.api-key.js"; +import { normalizeApiKeyInput, validateApiKeyInput } from "./auth-choice.api-key.js"; import { createAuthChoiceAgentModelNoter, + ensureApiKeyFromOptionEnvOrPrompt, normalizeSecretInputModeInput, - resolveSecretInputModeForEnvSelection, } from "./auth-choice.apply-helpers.js"; import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; import { applyDefaultModelChoice } from "./auth-choice.default-model.js"; @@ -55,40 +50,20 @@ export async function applyAuthChoiceOpenAI( return { config: nextConfig, agentModelOverride }; }; - const envKey = resolveEnvApiKey("openai"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing OPENAI_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - const mode = await resolveSecretInputModeForEnvSelection({ - prompter: params.prompter, - explicitMode: requestedSecretInputMode, - }); - await setOpenaiApiKey(envKey.apiKey, params.agentDir, { secretInputMode: mode }); - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "openai:default", - provider: "openai", - mode: "api_key", - }); - return await applyOpenAiDefaultModelChoice(); - } - } - - let key: string | undefined; - if (params.opts?.token && params.opts?.tokenProvider === "openai") { - key = params.opts.token; - } else { - key = await params.prompter.text({ - message: "Enter OpenAI API key", - validate: validateApiKeyInput, - }); - } - - const trimmed = normalizeApiKeyInput(String(key)); - await setOpenaiApiKey(trimmed, params.agentDir, { + await ensureApiKeyFromOptionEnvOrPrompt({ + token: params.opts?.token, + tokenProvider: params.opts?.tokenProvider, secretInputMode: requestedSecretInputMode, + config: nextConfig, + expectedProviders: ["openai"], + provider: "openai", + envLabel: "OPENAI_API_KEY", + promptMessage: "Enter OpenAI API key", + normalize: normalizeApiKeyInput, + validate: validateApiKeyInput, + prompter: params.prompter, + setCredential: async (apiKey, mode) => + setOpenaiApiKey(apiKey, params.agentDir, { secretInputMode: mode }), }); nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "openai:default", diff --git a/src/commands/auth-choice.apply.openrouter.ts b/src/commands/auth-choice.apply.openrouter.ts index 18785042566..4cf01762615 100644 --- a/src/commands/auth-choice.apply.openrouter.ts +++ b/src/commands/auth-choice.apply.openrouter.ts @@ -1,14 +1,9 @@ import { ensureAuthProfileStore, resolveAuthProfileOrder } from "../agents/auth-profiles.js"; -import { resolveEnvApiKey } from "../agents/model-auth.js"; -import { - formatApiKeyPreview, - normalizeApiKeyInput, - validateApiKeyInput, -} from "./auth-choice.api-key.js"; +import { normalizeApiKeyInput, validateApiKeyInput } from "./auth-choice.api-key.js"; import { createAuthChoiceAgentModelNoter, + ensureApiKeyFromOptionEnvOrPrompt, normalizeSecretInputModeInput, - resolveSecretInputModeForEnvSelection, } from "./auth-choice.apply-helpers.js"; import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; import { applyDefaultModelChoice } from "./auth-choice.default-model.js"; @@ -55,30 +50,20 @@ export async function applyAuthChoiceOpenRouter( } if (!hasCredential) { - const envKey = resolveEnvApiKey("openrouter"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing OPENROUTER_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - const mode = await resolveSecretInputModeForEnvSelection({ - prompter: params.prompter, - explicitMode: requestedSecretInputMode, - }); - await setOpenrouterApiKey(envKey.apiKey, params.agentDir, { secretInputMode: mode }); - hasCredential = true; - } - } - } - - if (!hasCredential) { - const key = await params.prompter.text({ - message: "Enter OpenRouter API key", - validate: validateApiKeyInput, - }); - await setOpenrouterApiKey(normalizeApiKeyInput(String(key ?? "")), params.agentDir, { + await ensureApiKeyFromOptionEnvOrPrompt({ + token: params.opts?.token, + tokenProvider: params.opts?.tokenProvider, secretInputMode: requestedSecretInputMode, + config: nextConfig, + expectedProviders: ["openrouter"], + provider: "openrouter", + envLabel: "OPENROUTER_API_KEY", + promptMessage: "Enter OpenRouter API key", + normalize: normalizeApiKeyInput, + validate: validateApiKeyInput, + prompter: params.prompter, + setCredential: async (apiKey, mode) => + setOpenrouterApiKey(apiKey, params.agentDir, { secretInputMode: mode }), }); hasCredential = true; } diff --git a/src/commands/auth-choice.apply.volcengine.ts b/src/commands/auth-choice.apply.volcengine.ts index 3974234755a..c98f442ae4e 100644 --- a/src/commands/auth-choice.apply.volcengine.ts +++ b/src/commands/auth-choice.apply.volcengine.ts @@ -1,12 +1,7 @@ -import { resolveEnvApiKey } from "../agents/model-auth.js"; -import { - formatApiKeyPreview, - normalizeApiKeyInput, - validateApiKeyInput, -} from "./auth-choice.api-key.js"; +import { normalizeApiKeyInput, validateApiKeyInput } from "./auth-choice.api-key.js"; import { + ensureApiKeyFromOptionEnvOrPrompt, normalizeSecretInputModeInput, - resolveSecretInputModeForEnvSelection, } from "./auth-choice.apply-helpers.js"; import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; import { applyPrimaryModel } from "./model-picker.js"; @@ -23,44 +18,20 @@ export async function applyAuthChoiceVolcengine( } const requestedSecretInputMode = normalizeSecretInputModeInput(params.opts?.secretInputMode); - const envKey = resolveEnvApiKey("volcengine"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing VOLCANO_ENGINE_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - const mode = await resolveSecretInputModeForEnvSelection({ - prompter: params.prompter, - explicitMode: requestedSecretInputMode, - }); - await setVolcengineApiKey(envKey.apiKey, params.agentDir, { secretInputMode: mode }); - const configWithAuth = applyAuthProfileConfig(params.config, { - profileId: "volcengine:default", - provider: "volcengine", - mode: "api_key", - }); - const configWithModel = applyPrimaryModel(configWithAuth, VOLCENGINE_DEFAULT_MODEL); - return { - config: configWithModel, - agentModelOverride: VOLCENGINE_DEFAULT_MODEL, - }; - } - } - - let key: string | undefined; - if (params.opts?.volcengineApiKey) { - key = params.opts.volcengineApiKey; - } else { - key = await params.prompter.text({ - message: "Enter Volcano Engine API Key", - validate: validateApiKeyInput, - }); - } - - const trimmed = normalizeApiKeyInput(String(key)); - await setVolcengineApiKey(trimmed, params.agentDir, { + await ensureApiKeyFromOptionEnvOrPrompt({ + token: params.opts?.volcengineApiKey, + tokenProvider: "volcengine", secretInputMode: requestedSecretInputMode, + config: params.config, + expectedProviders: ["volcengine"], + provider: "volcengine", + envLabel: "VOLCANO_ENGINE_API_KEY", + promptMessage: "Enter Volcano Engine API key", + normalize: normalizeApiKeyInput, + validate: validateApiKeyInput, + prompter: params.prompter, + setCredential: async (apiKey, mode) => + setVolcengineApiKey(apiKey, params.agentDir, { secretInputMode: mode }), }); const configWithAuth = applyAuthProfileConfig(params.config, { profileId: "volcengine:default", diff --git a/src/commands/auth-choice.apply.xai.ts b/src/commands/auth-choice.apply.xai.ts index 309d2af03e8..68e9ac651c3 100644 --- a/src/commands/auth-choice.apply.xai.ts +++ b/src/commands/auth-choice.apply.xai.ts @@ -1,13 +1,8 @@ -import { resolveEnvApiKey } from "../agents/model-auth.js"; -import { - formatApiKeyPreview, - normalizeApiKeyInput, - validateApiKeyInput, -} from "./auth-choice.api-key.js"; +import { normalizeApiKeyInput, validateApiKeyInput } from "./auth-choice.api-key.js"; import { createAuthChoiceAgentModelNoter, + ensureApiKeyFromOptionEnvOrPrompt, normalizeSecretInputModeInput, - resolveSecretInputModeForEnvSelection, } from "./auth-choice.apply-helpers.js"; import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; import { applyDefaultModelChoice } from "./auth-choice.default-model.js"; @@ -30,43 +25,21 @@ export async function applyAuthChoiceXAI( let agentModelOverride: string | undefined; const noteAgentModel = createAuthChoiceAgentModelNoter(params); const requestedSecretInputMode = normalizeSecretInputModeInput(params.opts?.secretInputMode); - - let hasCredential = false; - const optsKey = params.opts?.xaiApiKey?.trim(); - if (optsKey) { - setXaiApiKey(normalizeApiKeyInput(optsKey), params.agentDir, { - secretInputMode: requestedSecretInputMode, - }); - hasCredential = true; - } - - if (!hasCredential) { - const envKey = resolveEnvApiKey("xai"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing XAI_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - const mode = await resolveSecretInputModeForEnvSelection({ - prompter: params.prompter, - explicitMode: requestedSecretInputMode, - }); - setXaiApiKey(envKey.apiKey, params.agentDir, { secretInputMode: mode }); - hasCredential = true; - } - } - } - - if (!hasCredential) { - const key = await params.prompter.text({ - message: "Enter xAI API key", - validate: validateApiKeyInput, - }); - setXaiApiKey(normalizeApiKeyInput(String(key)), params.agentDir, { - secretInputMode: requestedSecretInputMode, - }); - } + await ensureApiKeyFromOptionEnvOrPrompt({ + token: params.opts?.xaiApiKey, + tokenProvider: "xai", + secretInputMode: requestedSecretInputMode, + config: nextConfig, + expectedProviders: ["xai"], + provider: "xai", + envLabel: "XAI_API_KEY", + promptMessage: "Enter xAI API key", + normalize: normalizeApiKeyInput, + validate: validateApiKeyInput, + prompter: params.prompter, + setCredential: async (apiKey, mode) => + setXaiApiKey(apiKey, params.agentDir, { secretInputMode: mode }), + }); nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "xai:default", diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index 5f4b6b0ac38..4dfd423d3d3 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -630,6 +630,8 @@ describe("applyAuthChoice", () => { profileId: string; provider: string; opts?: { secretInputMode?: "ref" }; + expectEnvPrompt: boolean; + expectedTextCalls: number; expectedKey?: string; expectedKeyRef?: { source: "env"; id: string }; expectedModel?: string; @@ -641,6 +643,8 @@ describe("applyAuthChoice", () => { envValue: "sk-synthetic-env", profileId: "synthetic:default", provider: "synthetic", + expectEnvPrompt: true, + expectedTextCalls: 0, expectedKey: "sk-synthetic-env", expectedModelPrefix: "synthetic/", }, @@ -650,6 +654,8 @@ describe("applyAuthChoice", () => { envValue: "sk-openrouter-test", profileId: "openrouter:default", provider: "openrouter", + expectEnvPrompt: true, + expectedTextCalls: 0, expectedKey: "sk-openrouter-test", expectedModel: "openrouter/auto", }, @@ -659,6 +665,8 @@ describe("applyAuthChoice", () => { envValue: "gateway-test-key", profileId: "vercel-ai-gateway:default", provider: "vercel-ai-gateway", + expectEnvPrompt: true, + expectedTextCalls: 0, expectedKey: "gateway-test-key", expectedModel: "vercel-ai-gateway/anthropic/claude-opus-4.6", }, @@ -669,6 +677,8 @@ describe("applyAuthChoice", () => { profileId: "vercel-ai-gateway:default", provider: "vercel-ai-gateway", opts: { secretInputMode: "ref" }, + expectEnvPrompt: false, + expectedTextCalls: 1, expectedKeyRef: { source: "env", id: "AI_GATEWAY_API_KEY" }, expectedModel: "vercel-ai-gateway/anthropic/claude-opus-4.6", }, @@ -693,12 +703,16 @@ describe("applyAuthChoice", () => { opts: scenario.opts, }); - expect(confirm).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining(scenario.envKey), - }), - ); - expect(text).not.toHaveBeenCalled(); + if (scenario.expectEnvPrompt) { + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining(scenario.envKey), + }), + ); + } else { + expect(confirm).not.toHaveBeenCalled(); + } + expect(text).toHaveBeenCalledTimes(scenario.expectedTextCalls); expect(result.config.auth?.profiles?.[scenario.profileId]).toMatchObject({ provider: scenario.provider, mode: "api_key", @@ -726,6 +740,57 @@ describe("applyAuthChoice", () => { } }); + it("retries ref setup when sops preflight fails and can switch to env ref", async () => { + await setupTempState(); + process.env.OPENAI_API_KEY = "sk-openai-env"; + + const selectValues: Array<"file" | "env"> = ["file", "env"]; + const select = vi.fn(async (params: Parameters[0]) => { + if (params.options.some((option) => option.value === "file")) { + return (selectValues.shift() ?? "env") as never; + } + return (params.options[0]?.value ?? "env") as never; + }); + const text = vi + .fn() + .mockResolvedValueOnce("/providers/openai/apiKey") + .mockResolvedValueOnce("OPENAI_API_KEY"); + const note = vi.fn(async () => undefined); + + const prompter = createPrompter({ + select, + text, + note, + confirm: vi.fn(async () => true), + }); + const runtime = createExitThrowingRuntime(); + + const result = await applyAuthChoice({ + authChoice: "openai-api-key", + config: {}, + prompter, + runtime, + setDefaultModel: false, + opts: { secretInputMode: "ref" }, + }); + + expect(result.config.auth?.profiles?.["openai:default"]).toMatchObject({ + provider: "openai", + mode: "api_key", + }); + expect(note).toHaveBeenCalledWith( + expect.stringContaining("Could not validate this encrypted file reference."), + "Reference check failed", + ); + expect(note).toHaveBeenCalledWith( + expect.stringContaining("Validated environment variable OPENAI_API_KEY."), + "Reference validated", + ); + expect(await readAuthProfile("openai:default")).toMatchObject({ + keyRef: { source: "env", id: "OPENAI_API_KEY" }, + }); + }); + it("keeps existing default model for explicit provider keys when setDefaultModel=false", async () => { const scenarios: Array<{ authChoice: "xai-api-key" | "opencode-zen"; @@ -947,6 +1012,7 @@ describe("applyAuthChoice", () => { cloudflareAiGatewayApiKey?: string; }; expectEnvPrompt: boolean; + expectedTextCalls: number; expectedKey?: string; expectedKeyRef?: { source: string; id: string }; expectedMetadata: { accountId: string; gatewayId: string }; @@ -956,6 +1022,7 @@ describe("applyAuthChoice", () => { textValues: ["cf-account-id", "cf-gateway-id"], confirmValue: true, expectEnvPrompt: true, + expectedTextCalls: 2, expectedKey: "cf-gateway-test-key", expectedMetadata: { accountId: "cf-account-id", @@ -969,7 +1036,8 @@ describe("applyAuthChoice", () => { opts: { secretInputMode: "ref", }, - expectEnvPrompt: true, + expectEnvPrompt: false, + expectedTextCalls: 3, expectedKeyRef: { source: "env", id: "CLOUDFLARE_AI_GATEWAY_API_KEY", @@ -988,6 +1056,7 @@ describe("applyAuthChoice", () => { cloudflareAiGatewayApiKey: "cf-direct-key", }, expectEnvPrompt: false, + expectedTextCalls: 0, expectedKey: "cf-direct-key", expectedMetadata: { accountId: "acc-direct", @@ -1027,7 +1096,7 @@ describe("applyAuthChoice", () => { } else { expect(confirm).not.toHaveBeenCalled(); } - expect(text).toHaveBeenCalledTimes(scenario.textValues.length); + expect(text).toHaveBeenCalledTimes(scenario.expectedTextCalls); expect(result.config.auth?.profiles?.["cloudflare-ai-gateway:default"]).toMatchObject({ provider: "cloudflare-ai-gateway", mode: "api_key", diff --git a/src/commands/onboard-custom.test.ts b/src/commands/onboard-custom.test.ts index c79c30daff2..24ccd17635a 100644 --- a/src/commands/onboard-custom.test.ts +++ b/src/commands/onboard-custom.test.ts @@ -78,48 +78,49 @@ function expectOpenAiCompatResult(params: { describe("promptCustomApiConfig", () => { afterEach(() => { vi.unstubAllGlobals(); + vi.unstubAllEnvs(); vi.useRealTimers(); }); it("handles openai flow and saves alias", async () => { const prompter = createTestPrompter({ text: ["http://localhost:11434/v1", "", "llama3", "custom", "local"], - select: ["openai"], + select: ["plaintext", "openai"], }); stubFetchSequence([{ ok: true }]); const result = await runPromptCustomApi(prompter); - expectOpenAiCompatResult({ prompter, textCalls: 5, selectCalls: 1, result }); + expectOpenAiCompatResult({ prompter, textCalls: 5, selectCalls: 2, result }); expect(result.config.agents?.defaults?.models?.["custom/llama3"]?.alias).toBe("local"); }); it("retries when verification fails", async () => { const prompter = createTestPrompter({ text: ["http://localhost:11434/v1", "", "bad-model", "good-model", "custom", ""], - select: ["openai", "model"], + select: ["plaintext", "openai", "model"], }); stubFetchSequence([{ ok: false, status: 400 }, { ok: true }]); await runPromptCustomApi(prompter); expect(prompter.text).toHaveBeenCalledTimes(6); - expect(prompter.select).toHaveBeenCalledTimes(2); + expect(prompter.select).toHaveBeenCalledTimes(3); }); it("detects openai compatibility when unknown", async () => { const prompter = createTestPrompter({ text: ["https://example.com/v1", "test-key", "detected-model", "custom", "alias"], - select: ["unknown"], + select: ["plaintext", "unknown"], }); stubFetchSequence([{ ok: true }]); const result = await runPromptCustomApi(prompter); - expectOpenAiCompatResult({ prompter, textCalls: 5, selectCalls: 1, result }); + expectOpenAiCompatResult({ prompter, textCalls: 5, selectCalls: 2, result }); }); it("uses expanded max_tokens for openai verification probes", async () => { const prompter = createTestPrompter({ text: ["https://example.com/v1", "test-key", "detected-model", "custom", "alias"], - select: ["openai"], + select: ["plaintext", "openai"], }); const fetchMock = stubFetchSequence([{ ok: true }]); @@ -133,7 +134,7 @@ describe("promptCustomApiConfig", () => { it("uses expanded max_tokens for anthropic verification probes", async () => { const prompter = createTestPrompter({ text: ["https://example.com", "test-key", "detected-model", "custom", "alias"], - select: ["unknown"], + select: ["plaintext", "unknown"], }); const fetchMock = stubFetchSequence([{ ok: false, status: 404 }, { ok: true }]); @@ -156,7 +157,7 @@ describe("promptCustomApiConfig", () => { "custom", "", ], - select: ["unknown", "baseUrl"], + select: ["plaintext", "unknown", "baseUrl", "plaintext"], }); stubFetchSequence([{ ok: false, status: 404 }, { ok: false, status: 404 }, { ok: true }]); await runPromptCustomApi(prompter); @@ -170,7 +171,7 @@ describe("promptCustomApiConfig", () => { it("renames provider id when baseUrl differs", async () => { const prompter = createTestPrompter({ text: ["http://localhost:11434/v1", "", "llama3", "custom", ""], - select: ["openai"], + select: ["plaintext", "openai"], }); stubFetchSequence([{ ok: true }]); const result = await runPromptCustomApi(prompter, { @@ -204,7 +205,7 @@ describe("promptCustomApiConfig", () => { vi.useFakeTimers(); const prompter = createTestPrompter({ text: ["http://localhost:11434/v1", "", "slow-model", "fast-model", "custom", ""], - select: ["openai", "model"], + select: ["plaintext", "openai", "model"], }); const fetchMock = vi @@ -224,6 +225,53 @@ describe("promptCustomApiConfig", () => { expect(prompter.text).toHaveBeenCalledTimes(6); }); + + it("stores env SecretRef for custom provider when selected", async () => { + vi.stubEnv("CUSTOM_PROVIDER_API_KEY", "test-env-key"); + const prompter = createTestPrompter({ + text: ["https://example.com/v1", "CUSTOM_PROVIDER_API_KEY", "detected-model", "custom", ""], + select: ["ref", "env", "openai"], + }); + const fetchMock = stubFetchSequence([{ ok: true }]); + + const result = await runPromptCustomApi(prompter); + + expect(result.config.models?.providers?.custom?.apiKey).toEqual({ + source: "env", + id: "CUSTOM_PROVIDER_API_KEY", + }); + const firstCall = fetchMock.mock.calls[0]?.[1] as + | { headers?: Record } + | undefined; + expect(firstCall?.headers?.Authorization).toBe("Bearer test-env-key"); + }); + + it("re-prompts source after encrypted file ref preflight fails and succeeds with env ref", async () => { + vi.stubEnv("CUSTOM_PROVIDER_API_KEY", "test-env-key"); + const prompter = createTestPrompter({ + text: [ + "https://example.com/v1", + "/providers/custom/apiKey", + "CUSTOM_PROVIDER_API_KEY", + "detected-model", + "custom", + "", + ], + select: ["ref", "file", "env", "openai"], + }); + stubFetchSequence([{ ok: true }]); + + const result = await runPromptCustomApi(prompter); + + expect(prompter.note).toHaveBeenCalledWith( + expect.stringContaining("Could not validate this encrypted file reference."), + "Reference check failed", + ); + expect(result.config.models?.providers?.custom?.apiKey).toEqual({ + source: "env", + id: "CUSTOM_PROVIDER_API_KEY", + }); + }); }); describe("applyCustomApiConfig", () => { diff --git a/src/commands/onboard-custom.ts b/src/commands/onboard-custom.ts index 509032e9b5d..11b7fcc75da 100644 --- a/src/commands/onboard-custom.ts +++ b/src/commands/onboard-custom.ts @@ -2,12 +2,18 @@ import { DEFAULT_PROVIDER } from "../agents/defaults.js"; import { buildModelAliasIndex, modelKey } from "../agents/model-selection.js"; import type { OpenClawConfig } from "../config/config.js"; import type { ModelProviderConfig } from "../config/types.models.js"; +import { isSecretRef, type SecretInput } from "../config/types.secrets.js"; import type { RuntimeEnv } from "../runtime.js"; import { fetchWithTimeout } from "../utils/fetch-timeout.js"; -import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js"; +import { + normalizeSecretInput, + normalizeOptionalSecretInput, +} from "../utils/normalize-secret-input.js"; import type { WizardPrompter } from "../wizard/prompts.js"; +import { ensureApiKeyFromEnvOrPrompt } from "./auth-choice.apply-helpers.js"; import { applyPrimaryModel } from "./model-picker.js"; import { normalizeAlias } from "./models/shared.js"; +import type { SecretInputMode } from "./onboard-types.js"; const DEFAULT_OLLAMA_BASE_URL = "http://127.0.0.1:11434/v1"; const DEFAULT_CONTEXT_WINDOW = 4096; @@ -63,7 +69,7 @@ export type ApplyCustomApiConfigParams = { baseUrl: string; modelId: string; compatibility: CustomApiCompatibility; - apiKey?: string; + apiKey?: SecretInput; providerId?: string; alias?: string; }; @@ -246,6 +252,13 @@ type VerificationResult = { error?: unknown; }; +function normalizeOptionalProviderApiKey(value: unknown): SecretInput | undefined { + if (isSecretRef(value)) { + return value; + } + return normalizeOptionalSecretInput(value); +} + function resolveVerificationEndpoint(params: { baseUrl: string; modelId: string; @@ -338,8 +351,10 @@ async function requestAnthropicVerification(params: { async function promptBaseUrlAndKey(params: { prompter: WizardPrompter; + config: OpenClawConfig; + secretInputMode?: SecretInputMode; initialBaseUrl?: string; -}): Promise<{ baseUrl: string; apiKey: string }> { +}): Promise<{ baseUrl: string; apiKey?: SecretInput; resolvedApiKey: string }> { const baseUrlInput = await params.prompter.text({ message: "API Base URL", initialValue: params.initialBaseUrl ?? DEFAULT_OLLAMA_BASE_URL, @@ -353,12 +368,27 @@ async function promptBaseUrlAndKey(params: { } }, }); - const apiKeyInput = await params.prompter.text({ - message: "API Key (leave blank if not required)", - placeholder: "sk-...", - initialValue: "", + const baseUrl = baseUrlInput.trim(); + const providerHint = buildEndpointIdFromUrl(baseUrl) || "custom"; + let apiKeyInput: SecretInput | undefined; + const resolvedApiKey = await ensureApiKeyFromEnvOrPrompt({ + config: params.config, + provider: providerHint, + envLabel: "CUSTOM_API_KEY", + promptMessage: "API Key (leave blank if not required)", + normalize: normalizeSecretInput, + validate: () => undefined, + prompter: params.prompter, + secretInputMode: params.secretInputMode, + setCredential: async (apiKey) => { + apiKeyInput = apiKey; + }, }); - return { baseUrl: baseUrlInput.trim(), apiKey: apiKeyInput.trim() }; + return { + baseUrl, + apiKey: normalizeOptionalProviderApiKey(apiKeyInput), + resolvedApiKey: normalizeSecretInput(resolvedApiKey), + }; } type CustomApiRetryChoice = "baseUrl" | "model" | "both"; @@ -386,22 +416,27 @@ async function promptCustomApiModelId(prompter: WizardPrompter): Promise async function applyCustomApiRetryChoice(params: { prompter: WizardPrompter; + config: OpenClawConfig; + secretInputMode?: SecretInputMode; retryChoice: CustomApiRetryChoice; - current: { baseUrl: string; apiKey: string; modelId: string }; -}): Promise<{ baseUrl: string; apiKey: string; modelId: string }> { - let { baseUrl, apiKey, modelId } = params.current; + current: { baseUrl: string; apiKey?: SecretInput; resolvedApiKey: string; modelId: string }; +}): Promise<{ baseUrl: string; apiKey?: SecretInput; resolvedApiKey: string; modelId: string }> { + let { baseUrl, apiKey, resolvedApiKey, modelId } = params.current; if (params.retryChoice === "baseUrl" || params.retryChoice === "both") { const retryInput = await promptBaseUrlAndKey({ prompter: params.prompter, + config: params.config, + secretInputMode: params.secretInputMode, initialBaseUrl: baseUrl, }); baseUrl = retryInput.baseUrl; apiKey = retryInput.apiKey; + resolvedApiKey = retryInput.resolvedApiKey; } if (params.retryChoice === "model" || params.retryChoice === "both") { modelId = await promptCustomApiModelId(params.prompter); } - return { baseUrl, apiKey, modelId }; + return { baseUrl, apiKey, resolvedApiKey, modelId }; } function resolveProviderApi( @@ -542,7 +577,8 @@ export function applyCustomApiConfig(params: ApplyCustomApiConfigParams): Custom const mergedModels = hasModel ? existingModels : [...existingModels, nextModel]; const { apiKey: existingApiKey, ...existingProviderRest } = existingProvider ?? {}; const normalizedApiKey = - normalizeOptionalSecretInput(params.apiKey) ?? normalizeOptionalSecretInput(existingApiKey); + normalizeOptionalProviderApiKey(params.apiKey) ?? + normalizeOptionalProviderApiKey(existingApiKey); let config: OpenClawConfig = { ...params.config, @@ -596,12 +632,18 @@ export async function promptCustomApiConfig(params: { prompter: WizardPrompter; runtime: RuntimeEnv; config: OpenClawConfig; + secretInputMode?: SecretInputMode; }): Promise { const { prompter, runtime, config } = params; - const baseInput = await promptBaseUrlAndKey({ prompter }); + const baseInput = await promptBaseUrlAndKey({ + prompter, + config, + secretInputMode: params.secretInputMode, + }); let baseUrl = baseInput.baseUrl; let apiKey = baseInput.apiKey; + let resolvedApiKey = baseInput.resolvedApiKey; const compatibilityChoice = await prompter.select({ message: "Endpoint compatibility", @@ -621,13 +663,21 @@ export async function promptCustomApiConfig(params: { let verifiedFromProbe = false; if (!compatibility) { const probeSpinner = prompter.progress("Detecting endpoint type..."); - const openaiProbe = await requestOpenAiVerification({ baseUrl, apiKey, modelId }); + const openaiProbe = await requestOpenAiVerification({ + baseUrl, + apiKey: resolvedApiKey, + modelId, + }); if (openaiProbe.ok) { probeSpinner.stop("Detected OpenAI-compatible endpoint."); compatibility = "openai"; verifiedFromProbe = true; } else { - const anthropicProbe = await requestAnthropicVerification({ baseUrl, apiKey, modelId }); + const anthropicProbe = await requestAnthropicVerification({ + baseUrl, + apiKey: resolvedApiKey, + modelId, + }); if (anthropicProbe.ok) { probeSpinner.stop("Detected Anthropic-compatible endpoint."); compatibility = "anthropic"; @@ -639,10 +689,12 @@ export async function promptCustomApiConfig(params: { "Endpoint detection", ); const retryChoice = await promptCustomApiRetryChoice(prompter); - ({ baseUrl, apiKey, modelId } = await applyCustomApiRetryChoice({ + ({ baseUrl, apiKey, resolvedApiKey, modelId } = await applyCustomApiRetryChoice({ prompter, + config, + secretInputMode: params.secretInputMode, retryChoice, - current: { baseUrl, apiKey, modelId }, + current: { baseUrl, apiKey, resolvedApiKey, modelId }, })); continue; } @@ -656,8 +708,8 @@ export async function promptCustomApiConfig(params: { const verifySpinner = prompter.progress("Verifying..."); const result = compatibility === "anthropic" - ? await requestAnthropicVerification({ baseUrl, apiKey, modelId }) - : await requestOpenAiVerification({ baseUrl, apiKey, modelId }); + ? await requestAnthropicVerification({ baseUrl, apiKey: resolvedApiKey, modelId }) + : await requestOpenAiVerification({ baseUrl, apiKey: resolvedApiKey, modelId }); if (result.ok) { verifySpinner.stop("Verification successful."); break; @@ -668,10 +720,12 @@ export async function promptCustomApiConfig(params: { verifySpinner.stop(`Verification failed: ${formatVerificationError(result.error)}`); } const retryChoice = await promptCustomApiRetryChoice(prompter); - ({ baseUrl, apiKey, modelId } = await applyCustomApiRetryChoice({ + ({ baseUrl, apiKey, resolvedApiKey, modelId } = await applyCustomApiRetryChoice({ prompter, + config, + secretInputMode: params.secretInputMode, retryChoice, - current: { baseUrl, apiKey, modelId }, + current: { baseUrl, apiKey, resolvedApiKey, modelId }, })); if (compatibilityChoice === "unknown") { compatibility = null; diff --git a/src/commands/onboard-non-interactive.provider-auth.test.ts b/src/commands/onboard-non-interactive.provider-auth.test.ts index 1bca5a57ec3..b584788104b 100644 --- a/src/commands/onboard-non-interactive.provider-auth.test.ts +++ b/src/commands/onboard-non-interactive.provider-auth.test.ts @@ -48,7 +48,7 @@ type ProviderAuthConfigSnapshot = { { baseUrl?: string; api?: string; - apiKey?: string; + apiKey?: string | { source?: string; id?: string }; models?: Array<{ id?: string }>; } >; @@ -145,6 +145,14 @@ async function runCustomLocalNonInteractive( } async function readCustomLocalProviderApiKey(configPath: string): Promise { + const cfg = await readJsonFile(configPath); + const apiKey = cfg.models?.providers?.[CUSTOM_LOCAL_PROVIDER_ID]?.apiKey; + return typeof apiKey === "string" ? apiKey : undefined; +} + +async function readCustomLocalProviderApiKeyInput( + configPath: string, +): Promise { const cfg = await readJsonFile(configPath); return cfg.models?.providers?.[CUSTOM_LOCAL_PROVIDER_ID]?.apiKey; } @@ -349,6 +357,91 @@ describe("onboard (non-interactive): provider auth", () => { }); }); + it.each([ + { + name: "anthropic", + prefix: "openclaw-onboard-ref-flag-anthropic-", + authChoice: "apiKey", + optionKey: "anthropicApiKey", + flagName: "--anthropic-api-key", + envVar: "ANTHROPIC_API_KEY", + }, + { + name: "openai", + prefix: "openclaw-onboard-ref-flag-openai-", + authChoice: "openai-api-key", + optionKey: "openaiApiKey", + flagName: "--openai-api-key", + envVar: "OPENAI_API_KEY", + }, + { + name: "openrouter", + prefix: "openclaw-onboard-ref-flag-openrouter-", + authChoice: "openrouter-api-key", + optionKey: "openrouterApiKey", + flagName: "--openrouter-api-key", + envVar: "OPENROUTER_API_KEY", + }, + { + name: "xai", + prefix: "openclaw-onboard-ref-flag-xai-", + authChoice: "xai-api-key", + optionKey: "xaiApiKey", + flagName: "--xai-api-key", + envVar: "XAI_API_KEY", + }, + { + name: "volcengine", + prefix: "openclaw-onboard-ref-flag-volcengine-", + authChoice: "volcengine-api-key", + optionKey: "volcengineApiKey", + flagName: "--volcengine-api-key", + envVar: "VOLCANO_ENGINE_API_KEY", + }, + { + name: "byteplus", + prefix: "openclaw-onboard-ref-flag-byteplus-", + authChoice: "byteplus-api-key", + optionKey: "byteplusApiKey", + flagName: "--byteplus-api-key", + envVar: "BYTEPLUS_API_KEY", + }, + ])( + "fails fast for $name when --secret-input-mode ref uses explicit key without env and does not leak the key", + async ({ prefix, authChoice, optionKey, flagName, envVar }) => { + await withOnboardEnv(prefix, async ({ runtime }) => { + const providedSecret = `${envVar.toLowerCase()}-should-not-leak`; + const options: Record = { + authChoice, + secretInputMode: "ref", + [optionKey]: providedSecret, + skipSkills: true, + }; + const envOverrides: Record = { + [envVar]: undefined, + }; + + await withEnvAsync(envOverrides, async () => { + let thrown: Error | undefined; + try { + await runNonInteractiveOnboardingWithDefaults(runtime, options); + } catch (error) { + thrown = error as Error; + } + expect(thrown).toBeDefined(); + const message = String(thrown?.message ?? ""); + expect(message).toContain( + `${flagName} cannot be used with --secret-input-mode ref unless ${envVar} is set in env.`, + ); + expect(message).toContain( + `Set ${envVar} in env and omit ${flagName}, or use --secret-input-mode plaintext.`, + ); + expect(message).not.toContain(providedSecret); + }); + }); + }, + ); + it("rejects vLLM auth choice in non-interactive mode", async () => { await withOnboardEnv("openclaw-onboard-vllm-non-interactive-", async ({ runtime }) => { await expect( @@ -508,6 +601,48 @@ describe("onboard (non-interactive): provider auth", () => { ); }); + it("stores CUSTOM_API_KEY env ref for non-interactive custom provider auth in ref mode", async () => { + await withOnboardEnv( + "openclaw-onboard-custom-provider-env-ref-", + async ({ configPath, runtime }) => { + process.env.CUSTOM_API_KEY = "custom-env-key"; + await runCustomLocalNonInteractive(runtime, { + secretInputMode: "ref", + }); + expect(await readCustomLocalProviderApiKeyInput(configPath)).toEqual({ + source: "env", + id: "CUSTOM_API_KEY", + }); + }, + ); + }); + + it("fails fast for custom provider ref mode when --custom-api-key is set but CUSTOM_API_KEY env is missing", async () => { + await withOnboardEnv("openclaw-onboard-custom-provider-ref-flag-", async ({ runtime }) => { + const providedSecret = "custom-inline-key-should-not-leak"; + await withEnvAsync({ CUSTOM_API_KEY: undefined }, async () => { + let thrown: Error | undefined; + try { + await runCustomLocalNonInteractive(runtime, { + secretInputMode: "ref", + customApiKey: providedSecret, + }); + } catch (error) { + thrown = error as Error; + } + expect(thrown).toBeDefined(); + const message = String(thrown?.message ?? ""); + expect(message).toContain( + "--custom-api-key cannot be used with --secret-input-mode ref unless CUSTOM_API_KEY is set in env.", + ); + expect(message).toContain( + "Set CUSTOM_API_KEY in env and omit --custom-api-key, or use --secret-input-mode plaintext.", + ); + expect(message).not.toContain(providedSecret); + }); + }); + }); + it("uses matching profile fallback for non-interactive custom provider auth", async () => { await withOnboardEnv( "openclaw-onboard-custom-provider-profile-fallback-", diff --git a/src/commands/onboard-non-interactive/api-keys.ts b/src/commands/onboard-non-interactive/api-keys.ts index 11fda28352c..1ae23103684 100644 --- a/src/commands/onboard-non-interactive/api-keys.ts +++ b/src/commands/onboard-non-interactive/api-keys.ts @@ -7,6 +7,7 @@ import { resolveEnvApiKey } from "../../agents/model-auth.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { RuntimeEnv } from "../../runtime.js"; import { normalizeOptionalSecretInput } from "../../utils/normalize-secret-input.js"; +import type { SecretInputMode } from "../onboard-types.js"; export type NonInteractiveApiKeySource = "flag" | "env" | "profile"; @@ -50,23 +51,38 @@ export async function resolveNonInteractiveApiKey(params: { agentDir?: string; allowProfile?: boolean; required?: boolean; + secretInputMode?: SecretInputMode; }): Promise<{ key: string; source: NonInteractiveApiKeySource } | null> { const flagKey = normalizeOptionalSecretInput(params.flagValue); + const envResolved = resolveEnvApiKey(params.provider); + const explicitEnvVar = params.envVarName?.trim(); + const explicitEnvKey = explicitEnvVar + ? normalizeOptionalSecretInput(process.env[explicitEnvVar]) + : undefined; + const resolvedEnvKey = envResolved?.apiKey ?? explicitEnvKey; + + if (params.secretInputMode === "ref") { + if (!resolvedEnvKey && flagKey) { + params.runtime.error( + [ + `${params.flagName} cannot be used with --secret-input-mode ref unless ${params.envVar} is set in env.`, + `Set ${params.envVar} in env and omit ${params.flagName}, or use --secret-input-mode plaintext.`, + ].join("\n"), + ); + params.runtime.exit(1); + return null; + } + if (resolvedEnvKey) { + return { key: resolvedEnvKey, source: "env" }; + } + } + if (flagKey) { return { key: flagKey, source: "flag" }; } - const envResolved = resolveEnvApiKey(params.provider); - if (envResolved?.apiKey) { - return { key: envResolved.apiKey, source: "env" }; - } - - const explicitEnvVar = params.envVarName?.trim(); - if (explicitEnvVar) { - const explicitEnvKey = normalizeOptionalSecretInput(process.env[explicitEnvVar]); - if (explicitEnvKey) { - return { key: explicitEnvKey, source: "env" }; - } + if (resolvedEnvKey) { + return { key: resolvedEnvKey, source: "env" }; } if (params.allowProfile ?? true) { diff --git a/src/commands/onboard-non-interactive/local/auth-choice.ts b/src/commands/onboard-non-interactive/local/auth-choice.ts index 04292ea4578..a2917048f33 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.ts @@ -2,6 +2,7 @@ import { upsertAuthProfile } from "../../../agents/auth-profiles.js"; import { normalizeProviderId } from "../../../agents/model-selection.js"; import { parseDurationMs } from "../../../cli/parse-duration.js"; import type { OpenClawConfig } from "../../../config/config.js"; +import type { SecretInput } from "../../../config/types.secrets.js"; import type { RuntimeEnv } from "../../../runtime.js"; import { normalizeSecretInput } from "../../../utils/normalize-secret-input.js"; import { normalizeSecretInputModeInput } from "../../auth-choice.apply-helpers.js"; @@ -84,6 +85,13 @@ export async function applyNonInteractiveAuthChoice(params: { const apiKeyStorageOptions = requestedSecretInputMode ? { secretInputMode: requestedSecretInputMode } : undefined; + const resolveApiKey = ( + input: Parameters[0], + ): ReturnType => + resolveNonInteractiveApiKey({ + ...input, + secretInputMode: requestedSecretInputMode, + }); if (authChoice === "claude-cli" || authChoice === "codex-cli") { runtime.error( @@ -119,7 +127,7 @@ export async function applyNonInteractiveAuthChoice(params: { } if (authChoice === "apiKey") { - const resolved = await resolveNonInteractiveApiKey({ + const resolved = await resolveApiKey({ provider: "anthropic", cfg: baseConfig, flagValue: opts.anthropicApiKey, @@ -196,7 +204,7 @@ export async function applyNonInteractiveAuthChoice(params: { } if (authChoice === "gemini-api-key") { - const resolved = await resolveNonInteractiveApiKey({ + const resolved = await resolveApiKey({ provider: "google", cfg: baseConfig, flagValue: opts.geminiApiKey, @@ -225,7 +233,7 @@ export async function applyNonInteractiveAuthChoice(params: { authChoice === "zai-global" || authChoice === "zai-cn" ) { - const resolved = await resolveNonInteractiveApiKey({ + const resolved = await resolveApiKey({ provider: "zai", cfg: baseConfig, flagValue: opts.zaiApiKey, @@ -274,7 +282,7 @@ export async function applyNonInteractiveAuthChoice(params: { } if (authChoice === "xiaomi-api-key") { - const resolved = await resolveNonInteractiveApiKey({ + const resolved = await resolveApiKey({ provider: "xiaomi", cfg: baseConfig, flagValue: opts.xiaomiApiKey, @@ -297,7 +305,7 @@ export async function applyNonInteractiveAuthChoice(params: { } if (authChoice === "xai-api-key") { - const resolved = await resolveNonInteractiveApiKey({ + const resolved = await resolveApiKey({ provider: "xai", cfg: baseConfig, flagValue: opts.xaiApiKey, @@ -320,7 +328,7 @@ export async function applyNonInteractiveAuthChoice(params: { } if (authChoice === "mistral-api-key") { - const resolved = await resolveNonInteractiveApiKey({ + const resolved = await resolveApiKey({ provider: "mistral", cfg: baseConfig, flagValue: opts.mistralApiKey, @@ -343,7 +351,7 @@ export async function applyNonInteractiveAuthChoice(params: { } if (authChoice === "volcengine-api-key") { - const resolved = await resolveNonInteractiveApiKey({ + const resolved = await resolveApiKey({ provider: "volcengine", cfg: baseConfig, flagValue: opts.volcengineApiKey, @@ -366,7 +374,7 @@ export async function applyNonInteractiveAuthChoice(params: { } if (authChoice === "byteplus-api-key") { - const resolved = await resolveNonInteractiveApiKey({ + const resolved = await resolveApiKey({ provider: "byteplus", cfg: baseConfig, flagValue: opts.byteplusApiKey, @@ -389,7 +397,7 @@ export async function applyNonInteractiveAuthChoice(params: { } if (authChoice === "qianfan-api-key") { - const resolved = await resolveNonInteractiveApiKey({ + const resolved = await resolveApiKey({ provider: "qianfan", cfg: baseConfig, flagValue: opts.qianfanApiKey, @@ -412,7 +420,7 @@ export async function applyNonInteractiveAuthChoice(params: { } if (authChoice === "openai-api-key") { - const resolved = await resolveNonInteractiveApiKey({ + const resolved = await resolveApiKey({ provider: "openai", cfg: baseConfig, flagValue: opts.openaiApiKey, @@ -435,7 +443,7 @@ export async function applyNonInteractiveAuthChoice(params: { } if (authChoice === "openrouter-api-key") { - const resolved = await resolveNonInteractiveApiKey({ + const resolved = await resolveApiKey({ provider: "openrouter", cfg: baseConfig, flagValue: opts.openrouterApiKey, @@ -458,7 +466,7 @@ export async function applyNonInteractiveAuthChoice(params: { } if (authChoice === "kilocode-api-key") { - const resolved = await resolveNonInteractiveApiKey({ + const resolved = await resolveApiKey({ provider: "kilocode", cfg: baseConfig, flagValue: opts.kilocodeApiKey, @@ -481,7 +489,7 @@ export async function applyNonInteractiveAuthChoice(params: { } if (authChoice === "litellm-api-key") { - const resolved = await resolveNonInteractiveApiKey({ + const resolved = await resolveApiKey({ provider: "litellm", cfg: baseConfig, flagValue: opts.litellmApiKey, @@ -504,7 +512,7 @@ export async function applyNonInteractiveAuthChoice(params: { } if (authChoice === "ai-gateway-api-key") { - const resolved = await resolveNonInteractiveApiKey({ + const resolved = await resolveApiKey({ provider: "vercel-ai-gateway", cfg: baseConfig, flagValue: opts.aiGatewayApiKey, @@ -539,7 +547,7 @@ export async function applyNonInteractiveAuthChoice(params: { runtime.exit(1); return null; } - const resolved = await resolveNonInteractiveApiKey({ + const resolved = await resolveApiKey({ provider: "cloudflare-ai-gateway", cfg: baseConfig, flagValue: opts.cloudflareAiGatewayApiKey, @@ -573,7 +581,7 @@ export async function applyNonInteractiveAuthChoice(params: { const applyMoonshotApiKeyChoice = async ( applyConfig: (cfg: OpenClawConfig) => OpenClawConfig, ): Promise => { - const resolved = await resolveNonInteractiveApiKey({ + const resolved = await resolveApiKey({ provider: "moonshot", cfg: baseConfig, flagValue: opts.moonshotApiKey, @@ -604,7 +612,7 @@ export async function applyNonInteractiveAuthChoice(params: { } if (authChoice === "kimi-code-api-key") { - const resolved = await resolveNonInteractiveApiKey({ + const resolved = await resolveApiKey({ provider: "kimi-coding", cfg: baseConfig, flagValue: opts.kimiCodeApiKey, @@ -627,7 +635,7 @@ export async function applyNonInteractiveAuthChoice(params: { } if (authChoice === "synthetic-api-key") { - const resolved = await resolveNonInteractiveApiKey({ + const resolved = await resolveApiKey({ provider: "synthetic", cfg: baseConfig, flagValue: opts.syntheticApiKey, @@ -650,7 +658,7 @@ export async function applyNonInteractiveAuthChoice(params: { } if (authChoice === "venice-api-key") { - const resolved = await resolveNonInteractiveApiKey({ + const resolved = await resolveApiKey({ provider: "venice", cfg: baseConfig, flagValue: opts.veniceApiKey, @@ -681,7 +689,7 @@ export async function applyNonInteractiveAuthChoice(params: { const isCn = authChoice === "minimax-api-key-cn"; const providerId = isCn ? "minimax-cn" : "minimax"; const profileId = `${providerId}:default`; - const resolved = await resolveNonInteractiveApiKey({ + const resolved = await resolveApiKey({ provider: providerId, cfg: baseConfig, flagValue: opts.minimaxApiKey, @@ -712,7 +720,7 @@ export async function applyNonInteractiveAuthChoice(params: { } if (authChoice === "opencode-zen") { - const resolved = await resolveNonInteractiveApiKey({ + const resolved = await resolveApiKey({ provider: "opencode", cfg: baseConfig, flagValue: opts.opencodeZenApiKey, @@ -735,7 +743,7 @@ export async function applyNonInteractiveAuthChoice(params: { } if (authChoice === "together-api-key") { - const resolved = await resolveNonInteractiveApiKey({ + const resolved = await resolveApiKey({ provider: "together", cfg: baseConfig, flagValue: opts.togetherApiKey, @@ -758,7 +766,7 @@ export async function applyNonInteractiveAuthChoice(params: { } if (authChoice === "huggingface-api-key") { - const resolved = await resolveNonInteractiveApiKey({ + const resolved = await resolveApiKey({ provider: "huggingface", cfg: baseConfig, flagValue: opts.huggingfaceApiKey, @@ -794,7 +802,7 @@ export async function applyNonInteractiveAuthChoice(params: { baseUrl: customAuth.baseUrl, providerId: customAuth.providerId, }); - const resolvedCustomApiKey = await resolveNonInteractiveApiKey({ + const resolvedCustomApiKey = await resolveApiKey({ provider: resolvedProviderId.providerId, cfg: baseConfig, flagValue: customAuth.apiKey, @@ -804,12 +812,16 @@ export async function applyNonInteractiveAuthChoice(params: { runtime, required: false, }); + const customApiKeyInput: SecretInput | undefined = + requestedSecretInputMode === "ref" && resolvedCustomApiKey?.source === "env" + ? { source: "env", id: "CUSTOM_API_KEY" } + : resolvedCustomApiKey?.key; const result = applyCustomApiConfig({ config: nextConfig, baseUrl: customAuth.baseUrl, modelId: customAuth.modelId, compatibility: customAuth.compatibility, - apiKey: resolvedCustomApiKey?.key, + apiKey: customApiKeyInput, providerId: customAuth.providerId, }); if (result.providerIdRenamedFrom && result.providerId) { diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index 301375fbb59..49a6e292ed2 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -367,6 +367,7 @@ export async function runOnboardingWizard( prompter, runtime, config: nextConfig, + secretInputMode: opts.secretInputMode, }); nextConfig = customResult.config; } else {