diff --git a/src/cli/program/register.onboard.ts b/src/cli/program/register.onboard.ts index 4cd14ec04ff..4c8193ce900 100644 --- a/src/cli/program/register.onboard.ts +++ b/src/cli/program/register.onboard.ts @@ -7,6 +7,7 @@ import type { GatewayAuthChoice, GatewayBind, NodeManagerChoice, + SecretInputMode, TailscaleMode, } from "../../commands/onboard-types.js"; import { onboardCommand } from "../../commands/onboard.js"; @@ -74,6 +75,10 @@ export function registerOnboardCommand(program: Command) { "Auth profile id (non-interactive; default: :manual)", ) .option("--token-expires-in ", "Optional token expiry duration (e.g. 365d, 12h)") + .option( + "--secret-input-mode ", + "API key persistence mode: plaintext|ref (default: plaintext)", + ) .option("--cloudflare-ai-gateway-account-id ", "Cloudflare Account ID") .option("--cloudflare-ai-gateway-gateway-id ", "Cloudflare AI Gateway ID"); @@ -129,6 +134,7 @@ export function registerOnboardCommand(program: Command) { token: opts.token as string | undefined, tokenProfileId: opts.tokenProfileId as string | undefined, tokenExpiresIn: opts.tokenExpiresIn as string | undefined, + secretInputMode: opts.secretInputMode as SecretInputMode | undefined, anthropicApiKey: opts.anthropicApiKey as string | undefined, openaiApiKey: opts.openaiApiKey as string | undefined, mistralApiKey: opts.mistralApiKey as string | undefined, diff --git a/src/commands/auth-choice.apply-helpers.ts b/src/commands/auth-choice.apply-helpers.ts index 8e7e0853567..f3033bcb7fd 100644 --- a/src/commands/auth-choice.apply-helpers.ts +++ b/src/commands/auth-choice.apply-helpers.ts @@ -3,6 +3,7 @@ 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"; export function createAuthChoiceAgentModelNoter( params: ApplyAuthChoiceParams, @@ -78,12 +79,55 @@ export function normalizeTokenProviderInput( 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; +}): Promise { + 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"; + } + return await params.prompter.select({ + message: "How should OpenClaw store this API key?", + initialValue: "plaintext", + options: [ + { + value: "plaintext", + label: "Plaintext on disk", + hint: "Default and fully backward-compatible", + }, + { + value: "ref", + label: "Env secret reference", + hint: "Stores env ref only (no plaintext key in auth-profiles)", + }, + ], + }); +} + export async function maybeApplyApiKeyFromOption(params: { token: string | undefined; tokenProvider: string | undefined; + secretInputMode?: SecretInputMode; expectedProviders: string[]; normalize: (value: string) => string; - setCredential: (apiKey: string) => Promise; + setCredential: (apiKey: string, mode?: SecretInputMode) => Promise; }): Promise { const tokenProvider = normalizeTokenProviderInput(params.tokenProvider); const expectedProviders = params.expectedProviders @@ -93,13 +137,14 @@ export async function maybeApplyApiKeyFromOption(params: { return undefined; } const apiKey = params.normalize(params.token); - await params.setCredential(apiKey); + await params.setCredential(apiKey, params.secretInputMode); return apiKey; } export async function ensureApiKeyFromOptionEnvOrPrompt(params: { token: string | undefined; tokenProvider: string | undefined; + secretInputMode?: SecretInputMode; expectedProviders: string[]; provider: string; envLabel: string; @@ -107,13 +152,14 @@ export async function ensureApiKeyFromOptionEnvOrPrompt(params: { normalize: (value: string) => string; validate: (value: string) => string | undefined; prompter: WizardPrompter; - setCredential: (apiKey: string) => Promise; + setCredential: (apiKey: string, mode?: SecretInputMode) => Promise; noteMessage?: string; noteTitle?: string; }): Promise { const optionApiKey = await maybeApplyApiKeyFromOption({ token: params.token, tokenProvider: params.tokenProvider, + secretInputMode: params.secretInputMode, expectedProviders: params.expectedProviders, normalize: params.normalize, setCredential: params.setCredential, @@ -133,6 +179,7 @@ export async function ensureApiKeyFromOptionEnvOrPrompt(params: { normalize: params.normalize, validate: params.validate, prompter: params.prompter, + secretInputMode: params.secretInputMode, setCredential: params.setCredential, }); } @@ -144,7 +191,8 @@ export async function ensureApiKeyFromEnvOrPrompt(params: { normalize: (value: string) => string; validate: (value: string) => string | undefined; prompter: WizardPrompter; - setCredential: (apiKey: string) => Promise; + secretInputMode?: SecretInputMode; + setCredential: (apiKey: string, mode?: SecretInputMode) => Promise; }): Promise { const envKey = resolveEnvApiKey(params.provider); if (envKey) { @@ -153,7 +201,11 @@ export async function ensureApiKeyFromEnvOrPrompt(params: { initialValue: true, }); if (useExisting) { - await params.setCredential(envKey.apiKey); + const mode = await resolveSecretInputModeForEnvSelection({ + prompter: params.prompter, + explicitMode: params.secretInputMode, + }); + await params.setCredential(envKey.apiKey, mode); return envKey.apiKey; } } @@ -163,6 +215,6 @@ export async function ensureApiKeyFromEnvOrPrompt(params: { validate: params.validate, }); const apiKey = params.normalize(String(key ?? "")); - await params.setCredential(apiKey); + await params.setCredential(apiKey, params.secretInputMode); return apiKey; } diff --git a/src/commands/auth-choice.apply.anthropic.ts b/src/commands/auth-choice.apply.anthropic.ts index b910768ea0f..4b43ba1042f 100644 --- a/src/commands/auth-choice.apply.anthropic.ts +++ b/src/commands/auth-choice.apply.anthropic.ts @@ -4,6 +4,10 @@ import { normalizeApiKeyInput, validateApiKeyInput, } from "./auth-choice.api-key.js"; +import { + normalizeSecretInputModeInput, + resolveSecretInputModeForEnvSelection, +} from "./auth-choice.apply-helpers.js"; import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; import { buildTokenProfileId, validateAnthropicSetupToken } from "./auth-token.js"; import { applyAgentDefaultModelPrimary } from "./onboard-auth.config-shared.js"; @@ -14,6 +18,7 @@ const DEFAULT_ANTHROPIC_MODEL = "anthropic/claude-sonnet-4-6"; export async function applyAuthChoiceAnthropic( params: ApplyAuthChoiceParams, ): Promise { + const requestedSecretInputMode = normalizeSecretInputModeInput(params.opts?.secretInputMode); if ( params.authChoice === "setup-token" || params.authChoice === "oauth" || @@ -74,7 +79,9 @@ export async function applyAuthChoiceAnthropic( const envKey = process.env.ANTHROPIC_API_KEY?.trim(); if (params.opts?.token) { - await setAnthropicApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir); + await setAnthropicApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir, { + secretInputMode: requestedSecretInputMode, + }); hasCredential = true; } @@ -84,7 +91,11 @@ export async function applyAuthChoiceAnthropic( initialValue: true, }); if (useExisting) { - await setAnthropicApiKey(envKey, params.agentDir); + const mode = await resolveSecretInputModeForEnvSelection({ + prompter: params.prompter, + explicitMode: requestedSecretInputMode, + }); + await setAnthropicApiKey(envKey, params.agentDir, { secretInputMode: mode }); hasCredential = true; } } @@ -93,7 +104,9 @@ export async function applyAuthChoiceAnthropic( message: "Enter Anthropic API key", validate: validateApiKeyInput, }); - await setAnthropicApiKey(normalizeApiKeyInput(String(key ?? "")), params.agentDir); + await setAnthropicApiKey(normalizeApiKeyInput(String(key ?? "")), params.agentDir, { + secretInputMode: requestedSecretInputMode, + }); } nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "anthropic:default", diff --git a/src/commands/auth-choice.apply.api-providers.ts b/src/commands/auth-choice.apply.api-providers.ts index 2b1e80387da..61e7c3d4ab9 100644 --- a/src/commands/auth-choice.apply.api-providers.ts +++ b/src/commands/auth-choice.apply.api-providers.ts @@ -6,10 +6,12 @@ import { 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"; @@ -19,6 +21,7 @@ import { applyGoogleGeminiModelDefault, GOOGLE_GEMINI_DEFAULT_MODEL, } from "./google-gemini-model-default.js"; +import type { ApiKeyStorageOptions } from "./onboard-auth.credentials.js"; import { applyAuthProfileConfig, applyCloudflareAiGatewayConfig, @@ -80,7 +83,7 @@ import { setZaiApiKey, ZAI_DEFAULT_MODEL_REF, } from "./onboard-auth.js"; -import type { AuthChoice } from "./onboard-types.js"; +import type { AuthChoice, SecretInputMode } from "./onboard-types.js"; import { OPENCODE_ZEN_DEFAULT_MODEL } from "./opencode-zen-model-default.js"; import { detectZaiEndpoint } from "./zai-endpoint-detect.js"; @@ -124,7 +127,11 @@ type SimpleApiKeyProviderFlow = { expectedProviders: string[]; envLabel: string; promptMessage: string; - setCredential: (apiKey: string, agentDir?: string) => void | Promise; + setCredential: ( + apiKey: string, + agentDir?: string, + options?: ApiKeyStorageOptions, + ) => void | Promise; defaultModel: string; applyDefaultConfig: ApiKeyProviderConfigApplier; applyProviderConfig: ApiKeyProviderConfigApplier; @@ -327,6 +334,7 @@ export async function applyAuthChoiceApiProviders( let authChoice = params.authChoice; const normalizedTokenProvider = normalizeTokenProviderInput(params.opts?.tokenProvider); + const requestedSecretInputMode = normalizeSecretInputModeInput(params.opts?.secretInputMode); if (authChoice === "apiKey" && params.opts?.tokenProvider) { if (normalizedTokenProvider !== "anthropic" && normalizedTokenProvider !== "openai") { authChoice = API_KEY_TOKEN_PROVIDER_AUTH_CHOICE[normalizedTokenProvider ?? ""] ?? authChoice; @@ -355,7 +363,7 @@ export async function applyAuthChoiceApiProviders( expectedProviders: string[]; envLabel: string; promptMessage: string; - setCredential: (apiKey: string) => void | Promise; + setCredential: (apiKey: string, mode?: SecretInputMode) => void | Promise; defaultModel: string; applyDefaultConfig: ( config: ApplyAuthChoiceParams["config"], @@ -374,11 +382,12 @@ export async function applyAuthChoiceApiProviders( token: params.opts?.token, provider, tokenProvider, + secretInputMode: requestedSecretInputMode, expectedProviders, envLabel, promptMessage, - setCredential: async (apiKey) => { - await setCredential(apiKey); + setCredential: async (apiKey, mode) => { + await setCredential(apiKey, mode); }, noteMessage, noteTitle, @@ -421,6 +430,7 @@ export async function applyAuthChoiceApiProviders( await ensureApiKeyFromOptionEnvOrPrompt({ token: params.opts?.token, tokenProvider: normalizedTokenProvider, + secretInputMode: requestedSecretInputMode, expectedProviders: ["litellm"], provider: "litellm", envLabel: "LITELLM_API_KEY", @@ -428,7 +438,8 @@ export async function applyAuthChoiceApiProviders( normalize: normalizeApiKeyInput, validate: validateApiKeyInput, prompter: params.prompter, - setCredential: async (apiKey) => setLitellmApiKey(apiKey, params.agentDir), + setCredential: async (apiKey, mode) => + setLitellmApiKey(apiKey, params.agentDir, { secretInputMode: mode }), noteMessage: "LiteLLM provides a unified API to 100+ LLM providers.\nGet your API key from your LiteLLM proxy or https://litellm.ai\nDefault proxy runs on http://localhost:4000", noteTitle: "LiteLLM", @@ -460,8 +471,10 @@ export async function applyAuthChoiceApiProviders( expectedProviders: simpleApiKeyProviderFlow.expectedProviders, envLabel: simpleApiKeyProviderFlow.envLabel, promptMessage: simpleApiKeyProviderFlow.promptMessage, - setCredential: async (apiKey) => - simpleApiKeyProviderFlow.setCredential(apiKey, params.agentDir), + setCredential: async (apiKey, mode) => + simpleApiKeyProviderFlow.setCredential(apiKey, params.agentDir, { + secretInputMode: mode ?? requestedSecretInputMode, + }), defaultModel: simpleApiKeyProviderFlow.defaultModel, applyDefaultConfig: simpleApiKeyProviderFlow.applyDefaultConfig, applyProviderConfig: simpleApiKeyProviderFlow.applyProviderConfig, @@ -498,6 +511,9 @@ 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; } @@ -509,12 +525,22 @@ export async function applyAuthChoiceApiProviders( }); if (useExisting) { await ensureAccountGateway(); + const mode = await resolveSecretInputModeForEnvSelection({ + prompter: params.prompter, + explicitMode: requestedSecretInputMode, + }); + await setCloudflareAiGatewayConfig(accountId, gatewayId, envKey.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; } @@ -525,9 +551,10 @@ export async function applyAuthChoiceApiProviders( validate: validateApiKeyInput, }); resolvedApiKey = normalizeApiKeyInput(String(key ?? "")); + await setCloudflareAiGatewayConfig(accountId, gatewayId, resolvedApiKey, params.agentDir, { + secretInputMode: requestedSecretInputMode, + }); } - - await setCloudflareAiGatewayConfig(accountId, gatewayId, resolvedApiKey, params.agentDir); nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "cloudflare-ai-gateway:default", provider: "cloudflare-ai-gateway", @@ -555,13 +582,15 @@ export async function applyAuthChoiceApiProviders( token: params.opts?.token, provider: "google", tokenProvider: normalizedTokenProvider, + secretInputMode: requestedSecretInputMode, expectedProviders: ["google"], envLabel: "GEMINI_API_KEY", promptMessage: "Enter Gemini API key", normalize: normalizeApiKeyInput, validate: validateApiKeyInput, prompter: params.prompter, - setCredential: async (apiKey) => setGeminiApiKey(apiKey, params.agentDir), + setCredential: async (apiKey, mode) => + setGeminiApiKey(apiKey, params.agentDir, { secretInputMode: mode }), }); nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "google:default", @@ -597,13 +626,15 @@ export async function applyAuthChoiceApiProviders( token: params.opts?.token, provider: "zai", tokenProvider: normalizedTokenProvider, + secretInputMode: requestedSecretInputMode, expectedProviders: ["zai"], envLabel: "ZAI_API_KEY", promptMessage: "Enter Z.AI API key", normalize: normalizeApiKeyInput, validate: validateApiKeyInput, prompter: params.prompter, - setCredential: async (apiKey) => setZaiApiKey(apiKey, params.agentDir), + setCredential: async (apiKey, mode) => + setZaiApiKey(apiKey, params.agentDir, { secretInputMode: mode }), }); // zai-api-key: auto-detect endpoint + choose a working default model. diff --git a/src/commands/auth-choice.apply.huggingface.ts b/src/commands/auth-choice.apply.huggingface.ts index 3f4c980879f..0361de96519 100644 --- a/src/commands/auth-choice.apply.huggingface.ts +++ b/src/commands/auth-choice.apply.huggingface.ts @@ -6,6 +6,7 @@ import { normalizeApiKeyInput, validateApiKeyInput } from "./auth-choice.api-key import { createAuthChoiceAgentModelNoter, ensureApiKeyFromOptionEnvOrPrompt, + normalizeSecretInputModeInput, } from "./auth-choice.apply-helpers.js"; import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; import { applyDefaultModelChoice } from "./auth-choice.default-model.js"; @@ -27,10 +28,12 @@ export async function applyAuthChoiceHuggingface( let nextConfig = params.config; let agentModelOverride: string | undefined; const noteAgentModel = createAuthChoiceAgentModelNoter(params); + const requestedSecretInputMode = normalizeSecretInputModeInput(params.opts?.secretInputMode); const hfKey = await ensureApiKeyFromOptionEnvOrPrompt({ token: params.opts?.token, tokenProvider: params.opts?.tokenProvider, + secretInputMode: requestedSecretInputMode, expectedProviders: ["huggingface"], provider: "huggingface", envLabel: "Hugging Face token", @@ -38,7 +41,8 @@ export async function applyAuthChoiceHuggingface( normalize: normalizeApiKeyInput, validate: validateApiKeyInput, prompter: params.prompter, - setCredential: async (apiKey) => setHuggingfaceApiKey(apiKey, params.agentDir), + setCredential: async (apiKey, mode) => + setHuggingfaceApiKey(apiKey, params.agentDir, { secretInputMode: mode }), noteMessage: [ "Hugging Face Inference Providers offer OpenAI-compatible chat completions.", "Create a token at: https://huggingface.co/settings/tokens (fine-grained, 'Make calls to Inference Providers').", diff --git a/src/commands/auth-choice.apply.minimax.test.ts b/src/commands/auth-choice.apply.minimax.test.ts index 533344c6652..aba00dada92 100644 --- a/src/commands/auth-choice.apply.minimax.test.ts +++ b/src/commands/auth-choice.apply.minimax.test.ts @@ -126,7 +126,7 @@ describe("applyAuthChoiceMiniMax", () => { }, ); - it("uses env token for minimax-api-key-cn when confirmed", async () => { + it("uses env token for minimax-api-key-cn as plaintext by default", async () => { const agentDir = await setupTempState(); process.env.MINIMAX_API_KEY = "mm-env-token"; delete process.env.MINIMAX_OAUTH_TOKEN; @@ -153,11 +153,37 @@ describe("applyAuthChoiceMiniMax", () => { expect(text).not.toHaveBeenCalled(); expect(confirm).toHaveBeenCalled(); + const parsed = await readAuthProfiles(agentDir); + expect(parsed.profiles?.["minimax-cn:default"]?.key).toBe("mm-env-token"); + expect(parsed.profiles?.["minimax-cn:default"]?.keyRef).toBeUndefined(); + }); + + it("uses env token for minimax-api-key-cn as keyRef in ref mode", async () => { + const agentDir = await setupTempState(); + process.env.MINIMAX_API_KEY = "mm-env-token"; + delete process.env.MINIMAX_OAUTH_TOKEN; + + const text = vi.fn(async () => "should-not-be-used"); + const confirm = vi.fn(async () => true); + + const result = await applyAuthChoiceMiniMax({ + authChoice: "minimax-api-key-cn", + config: {}, + prompter: createMinimaxPrompter({ text, confirm }), + runtime: createExitThrowingRuntime(), + setDefaultModel: true, + opts: { + secretInputMode: "ref", + }, + }); + + expect(result).not.toBeNull(); const parsed = await readAuthProfiles(agentDir); expect(parsed.profiles?.["minimax-cn:default"]?.keyRef).toEqual({ source: "env", id: "MINIMAX_API_KEY", }); + expect(parsed.profiles?.["minimax-cn:default"]?.key).toBeUndefined(); }); it("uses minimax-api-lightning default model", async () => { diff --git a/src/commands/auth-choice.apply.minimax.ts b/src/commands/auth-choice.apply.minimax.ts index d7c99ff8f0d..5a16a3f87c7 100644 --- a/src/commands/auth-choice.apply.minimax.ts +++ b/src/commands/auth-choice.apply.minimax.ts @@ -3,6 +3,7 @@ import { createAuthChoiceDefaultModelApplier, createAuthChoiceModelStateBridge, ensureApiKeyFromOptionEnvOrPrompt, + normalizeSecretInputModeInput, } from "./auth-choice.apply-helpers.js"; import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; import { applyAuthChoicePluginProvider } from "./auth-choice.apply.plugin-provider.js"; @@ -31,6 +32,7 @@ export async function applyAuthChoiceMiniMax( setAgentModelOverride: (model) => (agentModelOverride = model), }), ); + const requestedSecretInputMode = normalizeSecretInputModeInput(params.opts?.secretInputMode); const ensureMinimaxApiKey = async (opts: { profileId: string; promptMessage: string; @@ -38,6 +40,7 @@ export async function applyAuthChoiceMiniMax( await ensureApiKeyFromOptionEnvOrPrompt({ token: params.opts?.token, tokenProvider: params.opts?.tokenProvider, + secretInputMode: requestedSecretInputMode, expectedProviders: ["minimax", "minimax-cn"], provider: "minimax", envLabel: "MINIMAX_API_KEY", @@ -45,7 +48,8 @@ export async function applyAuthChoiceMiniMax( normalize: normalizeApiKeyInput, validate: validateApiKeyInput, prompter: params.prompter, - setCredential: async (apiKey) => setMinimaxApiKey(apiKey, params.agentDir, opts.profileId), + setCredential: async (apiKey, mode) => + setMinimaxApiKey(apiKey, params.agentDir, opts.profileId, { secretInputMode: mode }), }); }; const applyMinimaxApiVariant = async (opts: { diff --git a/src/commands/auth-choice.apply.openrouter.ts b/src/commands/auth-choice.apply.openrouter.ts index bacbe1f290c..18785042566 100644 --- a/src/commands/auth-choice.apply.openrouter.ts +++ b/src/commands/auth-choice.apply.openrouter.ts @@ -5,7 +5,11 @@ import { normalizeApiKeyInput, validateApiKeyInput, } from "./auth-choice.api-key.js"; -import { createAuthChoiceAgentModelNoter } from "./auth-choice.apply-helpers.js"; +import { + createAuthChoiceAgentModelNoter, + 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"; import { @@ -22,6 +26,7 @@ export async function applyAuthChoiceOpenRouter( let nextConfig = params.config; let agentModelOverride: string | undefined; const noteAgentModel = createAuthChoiceAgentModelNoter(params); + const requestedSecretInputMode = normalizeSecretInputModeInput(params.opts?.secretInputMode); const store = ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false }); const profileOrder = resolveAuthProfileOrder({ @@ -43,7 +48,9 @@ export async function applyAuthChoiceOpenRouter( } if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "openrouter") { - await setOpenrouterApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir); + await setOpenrouterApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir, { + secretInputMode: requestedSecretInputMode, + }); hasCredential = true; } @@ -55,7 +62,11 @@ export async function applyAuthChoiceOpenRouter( initialValue: true, }); if (useExisting) { - await setOpenrouterApiKey(envKey.apiKey, params.agentDir); + const mode = await resolveSecretInputModeForEnvSelection({ + prompter: params.prompter, + explicitMode: requestedSecretInputMode, + }); + await setOpenrouterApiKey(envKey.apiKey, params.agentDir, { secretInputMode: mode }); hasCredential = true; } } @@ -66,7 +77,9 @@ export async function applyAuthChoiceOpenRouter( message: "Enter OpenRouter API key", validate: validateApiKeyInput, }); - await setOpenrouterApiKey(normalizeApiKeyInput(String(key ?? "")), params.agentDir); + await setOpenrouterApiKey(normalizeApiKeyInput(String(key ?? "")), params.agentDir, { + secretInputMode: requestedSecretInputMode, + }); hasCredential = true; } diff --git a/src/commands/auth-choice.apply.xai.ts b/src/commands/auth-choice.apply.xai.ts index d925dc3872a..309d2af03e8 100644 --- a/src/commands/auth-choice.apply.xai.ts +++ b/src/commands/auth-choice.apply.xai.ts @@ -4,7 +4,11 @@ import { normalizeApiKeyInput, validateApiKeyInput, } from "./auth-choice.api-key.js"; -import { createAuthChoiceAgentModelNoter } from "./auth-choice.apply-helpers.js"; +import { + createAuthChoiceAgentModelNoter, + 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"; import { @@ -25,11 +29,14 @@ export async function applyAuthChoiceXAI( let nextConfig = params.config; 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); + setXaiApiKey(normalizeApiKeyInput(optsKey), params.agentDir, { + secretInputMode: requestedSecretInputMode, + }); hasCredential = true; } @@ -41,7 +48,11 @@ export async function applyAuthChoiceXAI( initialValue: true, }); if (useExisting) { - setXaiApiKey(envKey.apiKey, params.agentDir); + const mode = await resolveSecretInputModeForEnvSelection({ + prompter: params.prompter, + explicitMode: requestedSecretInputMode, + }); + setXaiApiKey(envKey.apiKey, params.agentDir, { secretInputMode: mode }); hasCredential = true; } } @@ -52,7 +63,9 @@ export async function applyAuthChoiceXAI( message: "Enter xAI API key", validate: validateApiKeyInput, }); - setXaiApiKey(normalizeApiKeyInput(String(key)), params.agentDir); + setXaiApiKey(normalizeApiKeyInput(String(key)), params.agentDir, { + secretInputMode: requestedSecretInputMode, + }); } nextConfig = applyAuthProfileConfig(nextConfig, { diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index b4a37aa7c14..5f4b6b0ac38 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -629,6 +629,9 @@ describe("applyAuthChoice", () => { envValue: string; profileId: string; provider: string; + opts?: { secretInputMode?: "ref" }; + expectedKey?: string; + expectedKeyRef?: { source: "env"; id: string }; expectedModel?: string; expectedModelPrefix?: string; }> = [ @@ -638,6 +641,7 @@ describe("applyAuthChoice", () => { envValue: "sk-synthetic-env", profileId: "synthetic:default", provider: "synthetic", + expectedKey: "sk-synthetic-env", expectedModelPrefix: "synthetic/", }, { @@ -646,6 +650,7 @@ describe("applyAuthChoice", () => { envValue: "sk-openrouter-test", profileId: "openrouter:default", provider: "openrouter", + expectedKey: "sk-openrouter-test", expectedModel: "openrouter/auto", }, { @@ -654,6 +659,17 @@ describe("applyAuthChoice", () => { envValue: "gateway-test-key", profileId: "vercel-ai-gateway:default", provider: "vercel-ai-gateway", + expectedKey: "gateway-test-key", + expectedModel: "vercel-ai-gateway/anthropic/claude-opus-4.6", + }, + { + authChoice: "ai-gateway-api-key", + envKey: "AI_GATEWAY_API_KEY", + envValue: "gateway-ref-key", + profileId: "vercel-ai-gateway:default", + provider: "vercel-ai-gateway", + opts: { secretInputMode: "ref" }, + expectedKeyRef: { source: "env", id: "AI_GATEWAY_API_KEY" }, expectedModel: "vercel-ai-gateway/anthropic/claude-opus-4.6", }, ]; @@ -674,6 +690,7 @@ describe("applyAuthChoice", () => { prompter, runtime, setDefaultModel: true, + opts: scenario.opts, }); expect(confirm).toHaveBeenCalledWith( @@ -698,10 +715,14 @@ describe("applyAuthChoice", () => { ), ).toBe(true); } - expect((await readAuthProfile(scenario.profileId))?.keyRef).toEqual({ - source: "env", - id: scenario.envKey, - }); + const profile = await readAuthProfile(scenario.profileId); + if (scenario.expectedKeyRef) { + expect(profile?.keyRef).toEqual(scenario.expectedKeyRef); + expect(profile?.key).toBeUndefined(); + } else { + expect(profile?.key).toBe(scenario.expectedKey); + expect(profile?.keyRef).toBeUndefined(); + } } }); @@ -910,10 +931,7 @@ describe("applyAuthChoice", () => { expect(await readAuthProfile("litellm:default")).toMatchObject({ type: "api_key", - keyRef: { - source: "env", - id: "LITELLM_API_KEY", - }, + key: "sk-litellm-test", }); }); @@ -923,9 +941,10 @@ describe("applyAuthChoice", () => { textValues: string[]; confirmValue: boolean; opts?: { - cloudflareAiGatewayAccountId: string; - cloudflareAiGatewayGatewayId: string; - cloudflareAiGatewayApiKey: string; + secretInputMode?: "ref"; + cloudflareAiGatewayAccountId?: string; + cloudflareAiGatewayGatewayId?: string; + cloudflareAiGatewayApiKey?: string; }; expectEnvPrompt: boolean; expectedKey?: string; @@ -937,13 +956,27 @@ describe("applyAuthChoice", () => { textValues: ["cf-account-id", "cf-gateway-id"], confirmValue: true, expectEnvPrompt: true, + expectedKey: "cf-gateway-test-key", + expectedMetadata: { + accountId: "cf-account-id", + gatewayId: "cf-gateway-id", + }, + }, + { + envGatewayKey: "cf-gateway-ref-key", + textValues: ["cf-account-id-ref", "cf-gateway-id-ref"], + confirmValue: true, + opts: { + secretInputMode: "ref", + }, + expectEnvPrompt: true, expectedKeyRef: { source: "env", id: "CLOUDFLARE_AI_GATEWAY_API_KEY", }, expectedMetadata: { - accountId: "cf-account-id", - gatewayId: "cf-gateway-id", + accountId: "cf-account-id-ref", + gatewayId: "cf-gateway-id-ref", }, }, { diff --git a/src/commands/onboard-auth.credentials.ts b/src/commands/onboard-auth.credentials.ts index ec69c671048..8e512cd2133 100644 --- a/src/commands/onboard-auth.credentials.ts +++ b/src/commands/onboard-auth.credentials.ts @@ -8,6 +8,7 @@ import { isSecretRef, type SecretInput, type SecretRef } from "../config/types.s import { KILOCODE_DEFAULT_MODEL_REF } from "../providers/kilocode-shared.js"; import { PROVIDER_ENV_VARS } from "../secrets/provider-env-vars.js"; import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; +import type { SecretInputMode } from "./onboard-types.js"; export { CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF } from "../agents/cloudflare-ai-gateway.js"; export { MISTRAL_DEFAULT_MODEL_REF, XAI_DEFAULT_MODEL_REF } from "./onboard-auth.models.js"; export { KILOCODE_DEFAULT_MODEL_REF }; @@ -15,6 +16,11 @@ export { KILOCODE_DEFAULT_MODEL_REF }; const resolveAuthAgentDir = (agentDir?: string) => agentDir ?? resolveOpenClawAgentDir(); const ENV_REF_PATTERN = /^\$\{([A-Z][A-Z0-9_]*)\}$/; + +export type ApiKeyStorageOptions = { + secretInputMode?: SecretInputMode; +}; + function buildEnvSecretRef(id: string): SecretRef { return { source: "env", id }; } @@ -27,21 +33,22 @@ function parseEnvSecretRef(value: string): SecretRef | null { return buildEnvSecretRef(match[1]); } -function inferProviderEnvSecretRef(provider: string, value: string): SecretRef | null { +function resolveProviderDefaultEnvSecretRef(provider: string): SecretRef { const envVars = PROVIDER_ENV_VARS[provider]; - if (!envVars || value.length === 0) { - return null; + const envVar = envVars?.find((candidate) => candidate.trim().length > 0); + if (!envVar) { + throw new Error( + `Provider "${provider}" does not have a default env var mapping for secret-input-mode=ref.`, + ); } - for (const envVar of envVars) { - const envValue = normalizeSecretInput(process.env[envVar] ?? ""); - if (envValue && envValue === value) { - return buildEnvSecretRef(envVar); - } - } - return null; + return buildEnvSecretRef(envVar); } -function resolveApiKeySecretInput(provider: string, input: SecretInput): SecretInput { +function resolveApiKeySecretInput( + provider: string, + input: SecretInput, + options?: ApiKeyStorageOptions, +): SecretInput { if (isSecretRef(input)) { return input; } @@ -50,9 +57,8 @@ function resolveApiKeySecretInput(provider: string, input: SecretInput): SecretI if (inlineEnvRef) { return inlineEnvRef; } - const inferredEnvRef = inferProviderEnvSecretRef(provider, normalized); - if (inferredEnvRef) { - return inferredEnvRef; + if (options?.secretInputMode === "ref") { + return resolveProviderDefaultEnvSecretRef(provider); } return normalized; } @@ -61,6 +67,7 @@ function buildApiKeyCredential( provider: string, input: SecretInput, metadata?: Record, + options?: ApiKeyStorageOptions, ): { type: "api_key"; provider: string; @@ -68,7 +75,7 @@ function buildApiKeyCredential( keyRef?: SecretRef; metadata?: Record; } { - const secretInput = resolveApiKeySecretInput(provider, input); + const secretInput = resolveApiKeySecretInput(provider, input, options); if (typeof secretInput === "string") { return { type: "api_key", @@ -186,20 +193,40 @@ export async function writeOAuthCredentials( return profileId; } -export async function setAnthropicApiKey(key: SecretInput, agentDir?: string) { +export async function setAnthropicApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { // Write to resolved agent dir so gateway finds credentials on startup. upsertAuthProfile({ profileId: "anthropic:default", - credential: buildApiKeyCredential("anthropic", key), + credential: buildApiKeyCredential("anthropic", key, undefined, options), agentDir: resolveAuthAgentDir(agentDir), }); } -export async function setGeminiApiKey(key: SecretInput, agentDir?: string) { +export async function setOpenaiApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + upsertAuthProfile({ + profileId: "openai:default", + credential: buildApiKeyCredential("openai", key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export async function setGeminiApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { // Write to resolved agent dir so gateway finds credentials on startup. upsertAuthProfile({ profileId: "google:default", - credential: buildApiKeyCredential("google", key), + credential: buildApiKeyCredential("google", key, undefined, options), agentDir: resolveAuthAgentDir(agentDir), }); } @@ -208,48 +235,89 @@ export async function setMinimaxApiKey( key: SecretInput, agentDir?: string, profileId: string = "minimax:default", + options?: ApiKeyStorageOptions, ) { const provider = profileId.split(":")[0] ?? "minimax"; // Write to resolved agent dir so gateway finds credentials on startup. upsertAuthProfile({ profileId, - credential: buildApiKeyCredential(provider, key), + credential: buildApiKeyCredential(provider, key, undefined, options), agentDir: resolveAuthAgentDir(agentDir), }); } -export async function setMoonshotApiKey(key: SecretInput, agentDir?: string) { +export async function setMoonshotApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { // Write to resolved agent dir so gateway finds credentials on startup. upsertAuthProfile({ profileId: "moonshot:default", - credential: buildApiKeyCredential("moonshot", key), + credential: buildApiKeyCredential("moonshot", key, undefined, options), agentDir: resolveAuthAgentDir(agentDir), }); } -export async function setKimiCodingApiKey(key: SecretInput, agentDir?: string) { +export async function setKimiCodingApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { // Write to resolved agent dir so gateway finds credentials on startup. upsertAuthProfile({ profileId: "kimi-coding:default", - credential: buildApiKeyCredential("kimi-coding", key), + credential: buildApiKeyCredential("kimi-coding", key, undefined, options), agentDir: resolveAuthAgentDir(agentDir), }); } -export async function setSyntheticApiKey(key: SecretInput, agentDir?: string) { +export async function setVolcengineApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + upsertAuthProfile({ + profileId: "volcengine:default", + credential: buildApiKeyCredential("volcengine", key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export async function setByteplusApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + upsertAuthProfile({ + profileId: "byteplus:default", + credential: buildApiKeyCredential("byteplus", key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export async function setSyntheticApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { // Write to resolved agent dir so gateway finds credentials on startup. upsertAuthProfile({ profileId: "synthetic:default", - credential: buildApiKeyCredential("synthetic", key), + credential: buildApiKeyCredential("synthetic", key, undefined, options), agentDir: resolveAuthAgentDir(agentDir), }); } -export async function setVeniceApiKey(key: SecretInput, agentDir?: string) { +export async function setVeniceApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { // Write to resolved agent dir so gateway finds credentials on startup. upsertAuthProfile({ profileId: "venice:default", - credential: buildApiKeyCredential("venice", key), + credential: buildApiKeyCredential("venice", key, undefined, options), agentDir: resolveAuthAgentDir(agentDir), }); } @@ -262,29 +330,41 @@ export const TOGETHER_DEFAULT_MODEL_REF = "together/moonshotai/Kimi-K2.5"; export const LITELLM_DEFAULT_MODEL_REF = "litellm/claude-opus-4-6"; export const VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF = "vercel-ai-gateway/anthropic/claude-opus-4.6"; -export async function setZaiApiKey(key: SecretInput, agentDir?: string) { +export async function setZaiApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { // Write to resolved agent dir so gateway finds credentials on startup. upsertAuthProfile({ profileId: "zai:default", - credential: buildApiKeyCredential("zai", key), + credential: buildApiKeyCredential("zai", key, undefined, options), agentDir: resolveAuthAgentDir(agentDir), }); } -export async function setXiaomiApiKey(key: SecretInput, agentDir?: string) { +export async function setXiaomiApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { upsertAuthProfile({ profileId: "xiaomi:default", - credential: buildApiKeyCredential("xiaomi", key), + credential: buildApiKeyCredential("xiaomi", key, undefined, options), agentDir: resolveAuthAgentDir(agentDir), }); } -export async function setOpenrouterApiKey(key: SecretInput, agentDir?: string) { +export async function setOpenrouterApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { // Never persist the literal "undefined" (e.g. when prompt returns undefined and caller used String(key)). const safeKey = typeof key === "string" && key === "undefined" ? "" : key; upsertAuthProfile({ profileId: "openrouter:default", - credential: buildApiKeyCredential("openrouter", safeKey), + credential: buildApiKeyCredential("openrouter", safeKey, undefined, options), agentDir: resolveAuthAgentDir(agentDir), }); } @@ -294,95 +374,125 @@ export async function setCloudflareAiGatewayConfig( gatewayId: string, apiKey: SecretInput, agentDir?: string, + options?: ApiKeyStorageOptions, ) { const normalizedAccountId = accountId.trim(); const normalizedGatewayId = gatewayId.trim(); upsertAuthProfile({ profileId: "cloudflare-ai-gateway:default", - credential: buildApiKeyCredential("cloudflare-ai-gateway", apiKey, { - accountId: normalizedAccountId, - gatewayId: normalizedGatewayId, - }), + credential: buildApiKeyCredential( + "cloudflare-ai-gateway", + apiKey, + { + accountId: normalizedAccountId, + gatewayId: normalizedGatewayId, + }, + options, + ), agentDir: resolveAuthAgentDir(agentDir), }); } -export async function setLitellmApiKey(key: SecretInput, agentDir?: string) { +export async function setLitellmApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { upsertAuthProfile({ profileId: "litellm:default", - credential: buildApiKeyCredential("litellm", key), + credential: buildApiKeyCredential("litellm", key, undefined, options), agentDir: resolveAuthAgentDir(agentDir), }); } -export async function setVercelAiGatewayApiKey(key: SecretInput, agentDir?: string) { +export async function setVercelAiGatewayApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { upsertAuthProfile({ profileId: "vercel-ai-gateway:default", - credential: buildApiKeyCredential("vercel-ai-gateway", key), + credential: buildApiKeyCredential("vercel-ai-gateway", key, undefined, options), agentDir: resolveAuthAgentDir(agentDir), }); } -export async function setOpencodeZenApiKey(key: SecretInput, agentDir?: string) { +export async function setOpencodeZenApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { upsertAuthProfile({ profileId: "opencode:default", - credential: buildApiKeyCredential("opencode", key), + credential: buildApiKeyCredential("opencode", key, undefined, options), agentDir: resolveAuthAgentDir(agentDir), }); } -export async function setTogetherApiKey(key: SecretInput, agentDir?: string) { +export async function setTogetherApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { upsertAuthProfile({ profileId: "together:default", - credential: buildApiKeyCredential("together", key), + credential: buildApiKeyCredential("together", key, undefined, options), agentDir: resolveAuthAgentDir(agentDir), }); } -export async function setHuggingfaceApiKey(key: SecretInput, agentDir?: string) { +export async function setHuggingfaceApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { upsertAuthProfile({ profileId: "huggingface:default", - credential: buildApiKeyCredential("huggingface", key), + credential: buildApiKeyCredential("huggingface", key, undefined, options), agentDir: resolveAuthAgentDir(agentDir), }); } -export function setQianfanApiKey(key: SecretInput, agentDir?: string) { +export function setQianfanApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { upsertAuthProfile({ profileId: "qianfan:default", - credential: buildApiKeyCredential("qianfan", key), + credential: buildApiKeyCredential("qianfan", key, undefined, options), agentDir: resolveAuthAgentDir(agentDir), }); } -export function setXaiApiKey(key: SecretInput, agentDir?: string) { +export function setXaiApiKey(key: SecretInput, agentDir?: string, options?: ApiKeyStorageOptions) { upsertAuthProfile({ profileId: "xai:default", - credential: buildApiKeyCredential("xai", key), + credential: buildApiKeyCredential("xai", key, undefined, options), agentDir: resolveAuthAgentDir(agentDir), }); } -export async function setMistralApiKey(key: string, agentDir?: string) { +export async function setMistralApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { upsertAuthProfile({ profileId: "mistral:default", - credential: { - type: "api_key", - provider: "mistral", - key, - }, + credential: buildApiKeyCredential("mistral", key, undefined, options), agentDir: resolveAuthAgentDir(agentDir), }); } -export async function setKilocodeApiKey(key: string, agentDir?: string) { +export async function setKilocodeApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { upsertAuthProfile({ profileId: "kilocode:default", - credential: { - type: "api_key", - provider: "kilocode", - key, - }, + credential: buildApiKeyCredential("kilocode", key, undefined, options), agentDir: resolveAuthAgentDir(agentDir), }); } diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index fa655752b1f..95b480ce433 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -87,6 +87,7 @@ export type NodeManagerChoice = "npm" | "pnpm" | "bun"; export type ChannelChoice = ChannelId; // Legacy alias (pre-rename). export type ProviderChoice = ChannelChoice; +export type SecretInputMode = "plaintext" | "ref"; export type OnboardOptions = { mode?: OnboardMode; @@ -106,6 +107,8 @@ export type OnboardOptions = { tokenProfileId?: string; /** Used when `authChoice=token` in non-interactive mode. */ tokenExpiresIn?: string; + /** API key persistence mode for onboarding flows (default: plaintext). */ + secretInputMode?: SecretInputMode; anthropicApiKey?: string; openaiApiKey?: string; mistralApiKey?: string; diff --git a/src/commands/onboard.test.ts b/src/commands/onboard.test.ts new file mode 100644 index 00000000000..c1150c73d0f --- /dev/null +++ b/src/commands/onboard.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { RuntimeEnv } from "../runtime.js"; + +const mocks = vi.hoisted(() => ({ + runInteractiveOnboarding: vi.fn(async () => {}), + runNonInteractiveOnboarding: vi.fn(async () => {}), +})); + +vi.mock("./onboard-interactive.js", () => ({ + runInteractiveOnboarding: mocks.runInteractiveOnboarding, +})); + +vi.mock("./onboard-non-interactive.js", () => ({ + runNonInteractiveOnboarding: mocks.runNonInteractiveOnboarding, +})); + +const { onboardCommand } = await import("./onboard.js"); + +function makeRuntime(): RuntimeEnv { + return { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn() as unknown as RuntimeEnv["exit"], + }; +} + +describe("onboardCommand", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("fails fast for invalid secret-input-mode before onboarding starts", async () => { + const runtime = makeRuntime(); + + await onboardCommand( + { + secretInputMode: "invalid" as never, + }, + runtime, + ); + + expect(runtime.error).toHaveBeenCalledWith( + 'Invalid --secret-input-mode. Use "plaintext" or "ref".', + ); + expect(runtime.exit).toHaveBeenCalledWith(1); + expect(mocks.runInteractiveOnboarding).not.toHaveBeenCalled(); + expect(mocks.runNonInteractiveOnboarding).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/onboard.ts b/src/commands/onboard.ts index 2ddcb309cb0..c2affc60d78 100644 --- a/src/commands/onboard.ts +++ b/src/commands/onboard.ts @@ -35,6 +35,15 @@ export async function onboardCommand(opts: OnboardOptions, runtime: RuntimeEnv = normalizedAuthChoice === opts.authChoice && flow === opts.flow ? opts : { ...opts, authChoice: normalizedAuthChoice, flow }; + if ( + normalizedOpts.secretInputMode && + normalizedOpts.secretInputMode !== "plaintext" && + normalizedOpts.secretInputMode !== "ref" + ) { + runtime.error('Invalid --secret-input-mode. Use "plaintext" or "ref".'); + runtime.exit(1); + return; + } if (normalizedOpts.nonInteractive && normalizedOpts.acceptRisk !== true) { runtime.error( diff --git a/src/secrets/provider-env-vars.ts b/src/secrets/provider-env-vars.ts index 843acff4ac2..9d2100d1852 100644 --- a/src/secrets/provider-env-vars.ts +++ b/src/secrets/provider-env-vars.ts @@ -19,6 +19,8 @@ export const PROVIDER_ENV_VARS: Record = { huggingface: ["HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"], qianfan: ["QIANFAN_API_KEY"], xai: ["XAI_API_KEY"], + mistral: ["MISTRAL_API_KEY"], + kilocode: ["KILOCODE_API_KEY"], volcengine: ["VOLCANO_ENGINE_API_KEY"], byteplus: ["BYTEPLUS_API_KEY"], };