feat(secrets): expand onboarding secret-ref flows and custom-provider parity

This commit is contained in:
joshavant
2026-02-24 22:26:33 -06:00
committed by Peter Steinberger
parent e8637c79b3
commit 5e3a86fd2f
23 changed files with 857 additions and 417 deletions

View File

@@ -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.<id>.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:

View File

@@ -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:

View File

@@ -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" }`.
</Accordion>
</AccordionGroup>

View File

@@ -177,6 +177,10 @@ What you set:
<Accordion title="Custom provider">
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.<id>.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.
<Note>

View File

@@ -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.

View File

@@ -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<WizardPrompter["text"]>()
.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<WizardPrompter["text"]>().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",

View File

@@ -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<SecretRefSourceChoice>({
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<void> {
@@ -111,22 +275,23 @@ export async function resolveSecretInputModeForEnvSelection(params: {
if (typeof params.prompter.select !== "function") {
return "plaintext";
}
return await params.prompter.select<SecretInputMode>({
message: "How should OpenClaw store this API key?",
const selected = await params.prompter.select<SecretInputMode>({
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<void>;
setCredential: (apiKey: SecretInput, mode?: SecretInputMode) => Promise<void>;
}): Promise<string | undefined> {
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<void>;
setCredential: (apiKey: SecretInput, mode?: SecretInputMode) => Promise<void>;
noteMessage?: string;
noteTitle?: string;
}): Promise<string> {
@@ -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<void>;
setCredential: (apiKey: SecretInput, mode?: SecretInputMode) => Promise<void>;
}): Promise<string> {
const selectedMode = await resolveSecretInputModeForEnvSelection({
prompter: params.prompter,
explicitMode: params.secretInputMode,
});
const envKey = resolveEnvApiKey(params.provider);
if (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;
}

View File

@@ -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",

View File

@@ -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<void>;
@@ -363,7 +358,7 @@ export async function applyAuthChoiceApiProviders(
expectedProviders: string[];
envLabel: string;
promptMessage: string;
setCredential: (apiKey: string, mode?: SecretInputMode) => void | Promise<void>;
setCredential: (apiKey: SecretInput, mode?: SecretInputMode) => void | Promise<void>;
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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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;
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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<WizardPrompter["select"]>[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<WizardPrompter["text"]>()
.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",

View File

@@ -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<string, string> }
| 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", () => {

View File

@@ -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<string>
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<CustomApiResult> {
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;

View File

@@ -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<string | undefined> {
const cfg = await readJsonFile<ProviderAuthConfigSnapshot>(configPath);
const apiKey = cfg.models?.providers?.[CUSTOM_LOCAL_PROVIDER_ID]?.apiKey;
return typeof apiKey === "string" ? apiKey : undefined;
}
async function readCustomLocalProviderApiKeyInput(
configPath: string,
): Promise<string | { source?: string; id?: string } | undefined> {
const cfg = await readJsonFile<ProviderAuthConfigSnapshot>(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<string, unknown> = {
authChoice,
secretInputMode: "ref",
[optionKey]: providedSecret,
skipSkills: true,
};
const envOverrides: Record<string, string | undefined> = {
[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-",

View File

@@ -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) {

View File

@@ -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<typeof resolveNonInteractiveApiKey>[0],
): ReturnType<typeof resolveNonInteractiveApiKey> =>
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<OpenClawConfig | null> => {
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) {

View File

@@ -367,6 +367,7 @@ export async function runOnboardingWizard(
prompter,
runtime,
config: nextConfig,
secretInputMode: opts.secretInputMode,
});
nextConfig = customResult.config;
} else {