From 7fafaf50f46e9db6f6dba32211754e31a187a5c8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 3 Aug 2026 08:34:47 -0700 Subject: [PATCH] fix(onboard): keep referenced provider secrets out of plaintext (#118702) * fix(onboard): keep referenced provider secrets out of plaintext * fix(onboard): retain public credential owner type usage * docs(onboarding): explain preserved credential profiles in reference mode --- docs/cli/onboard.md | 2 +- docs/start/wizard-cli-automation.md | 2 +- docs/start/wizard-cli-reference.md | 8 +- docs/start/wizard.md | 9 +- .../onboard-non-interactive/api-keys.test.ts | 42 +++ .../local/auth-choice.test.ts | 250 ++++++++++++++++++ .../local/auth-choice.ts | 85 +++--- .../provider-self-hosted-setup.test.ts | 200 ++++++++++++++ src/plugins/provider-self-hosted-setup.ts | 85 +++--- 9 files changed, 595 insertions(+), 88 deletions(-) diff --git a/docs/cli/onboard.md b/docs/cli/onboard.md index 537e5a4f9843..c9a75f200424 100644 --- a/docs/cli/onboard.md +++ b/docs/cli/onboard.md @@ -262,7 +262,7 @@ openclaw onboard --non-interactive \ --accept-risk ``` -With `--secret-input-mode ref`, onboarding writes env-backed refs instead of plaintext key values: for auth-profile-backed providers this writes `keyRef: { source: "env", provider: "default", id: }`; for custom providers it writes `models.providers..apiKey` the same way (for example `{ source: "env", provider: "default", id: "CUSTOM_API_KEY" }`). Contract: set the provider env var in the onboarding process environment (for example `OPENAI_API_KEY`) and do not also pass an inline key flag unless that env var is set - a flag value without the matching env var fails fast with guidance. +With `--secret-input-mode ref`, onboarding stores new credentials as env-backed refs instead of plaintext: auth profiles use `keyRef: { source: "env", provider: "default", id: }`, and custom providers use `models.providers..apiKey` (for example `{ source: "env", provider: "default", id: "CUSTOM_API_KEY" }`). Set the provider env var when adding a new credential; an inline key flag without its matching env var fails fast. Existing resolvable named auth profiles and their `env`, `file`, or `exec` references are reused unchanged, without a new `apiKey` or `keyRef` write or additional provider env var. Existing plaintext profile credentials are not migrated; run `openclaw secrets configure --apply`, then `openclaw secrets audit --check`. See [Secrets management](/gateway/secrets). ### Gateway auth (non-interactive) diff --git a/docs/start/wizard-cli-automation.md b/docs/start/wizard-cli-automation.md index e5eeef841aed..a2ea0a1aa64c 100644 --- a/docs/start/wizard-cli-automation.md +++ b/docs/start/wizard-cli-automation.md @@ -32,7 +32,7 @@ Add `--json` for a machine-readable summary. - `--gateway-port` defaults to `18789`; only pass it to override. - `--skip-bootstrap` skips creating default workspace files, for automation that pre-seeds its own workspace. -- `--secret-input-mode ref` stores an env-backed reference (`{ source: "env", provider: "default", id: "" }`) in the auth profile instead of the plaintext key. In non-interactive `ref` mode, the provider env var must already be set in the process environment: passing an inline key flag without its matching env var fails fast. +- `--secret-input-mode ref` stores new credentials as env-backed references (`{ source: "env", provider: "default", id: "" }`); set the provider env var when adding a credential or passing an inline key flag. Existing resolvable named profiles and their `env`, `file`, or `exec` references are reused unchanged, without a new credential write or additional provider env var. Existing plaintext is not migrated; run `openclaw secrets configure --apply`, then `openclaw secrets audit --check`. See [Secrets management](/gateway/secrets). ```bash openclaw onboard --non-interactive --accept-risk \ diff --git a/docs/start/wizard-cli-reference.md b/docs/start/wizard-cli-reference.md index 0d7c5fd93b6e..dfe7277ee9c2 100644 --- a/docs/start/wizard-cli-reference.md +++ b/docs/start/wizard-cli-reference.md @@ -300,11 +300,13 @@ Credential storage mode: - Env refs: validates variable name + non-empty value in the current onboarding environment. - Provider refs: validates provider config and resolves the requested id. - 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. +- In non-interactive mode, `--secret-input-mode ref` creates only env-backed references for new credentials. + - Set the provider env var in the onboarding process environment when adding a new credential. - Inline key flags (for example `--openai-api-key`) require that env var to be set; otherwise onboarding fails fast. - - For custom providers, non-interactive `ref` mode stores `models.providers..apiKey` as `{ source: "env", provider: "default", id: "CUSTOM_API_KEY" }`. + - Existing resolvable named auth profiles are reused unchanged, including existing `env`, `file`, and `exec` references; no new `apiKey` or `keyRef` is written and no additional provider env var is required. + - For new custom-provider credentials, non-interactive `ref` mode stores `models.providers..apiKey` as `{ source: "env", provider: "default", 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 profile credentials remain unchanged; reference mode does not migrate them. Run `openclaw secrets configure --apply`, then `openclaw secrets audit --check`. See [Secrets management](/gateway/secrets). - Gateway auth credentials support plaintext and SecretRef choices in interactive setup: - Token mode: **Generate/store plaintext token** (default) or **Use SecretRef**. - Password mode: plaintext or SecretRef. diff --git a/docs/start/wizard.md b/docs/start/wizard.md index cd7c0de4ef1b..32b41717ad34 100644 --- a/docs/start/wizard.md +++ b/docs/start/wizard.md @@ -159,9 +159,12 @@ Local mode (default) walks through these steps: Security note: if this agent will run tools or process webhook/hook content, prefer the strongest latest-generation model available and keep tool policy strict - weaker or older tiers are easier to prompt-inject. - For non-interactive runs, `--secret-input-mode ref` stores env-backed refs - instead of plaintext API key values; the referenced env var must already - be set, or onboarding fails fast. Interactive secret reference mode can + For non-interactive runs, `--secret-input-mode ref` stores new credentials + as env-backed refs; set the provider env var when adding a credential. + Existing resolvable named profiles and their `env`, `file`, or `exec` refs + are reused unchanged without a new credential write or additional provider + env var. Previously stored plaintext is not migrated; see + [Secrets management](/gateway/secrets). Interactive secret reference mode can point at an environment variable or a configured provider ref (`file` or `exec`), with a fast preflight check before saving. After model/auth setup, the wizard offers an optional live completion test; a failure can return to diff --git a/src/commands/onboard-non-interactive/api-keys.test.ts b/src/commands/onboard-non-interactive/api-keys.test.ts index 7231a920cfd6..e276aa66d2f1 100644 --- a/src/commands/onboard-non-interactive/api-keys.test.ts +++ b/src/commands/onboard-non-interactive/api-keys.test.ts @@ -249,4 +249,46 @@ describe("resolveNonInteractiveApiKey", () => { const [profileParams] = resolveApiKeyForProfile.mock.calls[0] ?? []; expect(profileParams?.profileId).toBe("custom-models-custom-local:default"); }); + + it("retains existing profile reuse in secret-ref mode without inventing an env reference", async () => { + const runtime = createRuntime(); + authStore.profiles["custom-models-custom-local:default"] = { + type: "api_key", + provider: "custom-models-custom-local", + key: "fixture-profile-key", + }; + resolveEnvApiKey.mockReturnValue(null); + + const result = await resolveNonInteractiveApiKey({ + provider: "custom-models-custom-local", + cfg: {}, + flagName: "--custom-api-key", + envVar: "CUSTOM_API_KEY", + runtime: runtime as never, + secretInputMode: "ref", + }); + + expect(result).toEqual({ key: "fixture-profile-key", source: "profile" }); + expect(runtime.error).not.toHaveBeenCalled(); + expect(runtime.exit).not.toHaveBeenCalled(); + }); + + it("keeps intentionally keyless providers optional in secret-ref mode", async () => { + const runtime = createRuntime(); + resolveEnvApiKey.mockReturnValue(null); + + const result = await resolveNonInteractiveApiKey({ + provider: "custom-models-custom-local", + cfg: {}, + flagName: "--custom-api-key", + envVar: "CUSTOM_API_KEY", + runtime: runtime as never, + required: false, + secretInputMode: "ref", + }); + + expect(result).toBeNull(); + expect(runtime.error).not.toHaveBeenCalled(); + expect(runtime.exit).not.toHaveBeenCalled(); + }); }); diff --git a/src/commands/onboard-non-interactive/local/auth-choice.test.ts b/src/commands/onboard-non-interactive/local/auth-choice.test.ts index f39a06e3f538..6875de0c3bbb 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.test.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.test.ts @@ -2,8 +2,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../config/config.js"; import { resolveAgentModelPrimaryValue } from "../../../config/model-input.js"; +import { commitNonInteractiveOnboardConfig } from "../config-write.js"; import { applyNonInteractiveAuthChoice } from "./auth-choice.js"; +const writeWizardConfigFile = vi.hoisted(() => vi.fn(async (config: OpenClawConfig) => config)); +vi.mock("../../../wizard/setup.shared.js", () => ({ writeWizardConfigFile })); + const formatAuthChoiceChoicesForCli = vi.hoisted(() => vi.fn(() => "custom-api-key|skip|demo-provider-api-key"), ); @@ -234,6 +238,252 @@ describe("applyNonInteractiveAuthChoice", () => { expect(apiKeyParams?.secretInputMode).toBe("ref"); }); + it("never commits an existing profile key as plaintext during custom secret-ref onboarding", async () => { + const runtime = createRuntime(); + const nextConfig = { agents: { defaults: {} } } as OpenClawConfig; + const profileKey = "fixture-custom-profile-secret"; + resolveNonInteractiveApiKey.mockResolvedValueOnce({ key: profileKey, source: "profile" }); + + const result = await applyNonInteractiveAuthChoice({ + nextConfig, + authChoice: "custom-api-key", + opts: { + customBaseUrl: "https://models.custom.local/v1", + customModelId: "local-large", + secretInputMode: "ref", + } as never, + runtime: runtime as never, + baseConfig: nextConfig, + target, + }); + + expect(result).not.toBeNull(); + await commitNonInteractiveOnboardConfig({ + nextConfig: result!, + baseConfig: nextConfig, + }); + + const persistedConfig = writeWizardConfigFile.mock.calls.at(-1)?.[0]; + expect( + persistedConfig?.models?.providers?.["custom-models-custom-local"]?.apiKey, + ).toBeUndefined(); + expect(JSON.stringify(persistedConfig)).not.toContain(profileKey); + expect(runtime.error).not.toHaveBeenCalled(); + expect(runtime.exit).not.toHaveBeenCalled(); + }); + + it.each([ + { source: "flag", key: "fixture-custom-literal-secret" }, + { source: "env", key: "fixture-custom-anonymous-env-secret" }, + ] as const)( + "never serializes an unreferenceable custom $source key in secret-ref mode", + async (resolved) => { + const runtime = createRuntime(); + const nextConfig = { agents: { defaults: {} } } as OpenClawConfig; + resolveNonInteractiveApiKey.mockResolvedValueOnce(resolved); + + const result = await applyNonInteractiveAuthChoice({ + nextConfig, + authChoice: "custom-api-key", + opts: { + customBaseUrl: "https://models.custom.local/v1", + customModelId: "local-large", + secretInputMode: "ref", + } as never, + runtime: runtime as never, + baseConfig: nextConfig, + target, + }); + + expect(result).toBeNull(); + expect(writeWizardConfigFile).not.toHaveBeenCalled(); + expect(runtime.exit).toHaveBeenCalledWith(1); + const errorText = runtime.error.mock.calls.map(([message]) => String(message)).join("\n"); + expect(errorText).toContain("CUSTOM_API_KEY"); + expect(errorText).toContain("--secret-input-mode ref"); + expect(errorText).not.toContain(resolved.key); + }, + ); + + it.each([ + { source: "env", provider: "default", id: "EXISTING_CUSTOM_API_KEY" }, + { source: "file", provider: "local", id: "/providers/custom" }, + { source: "exec", provider: "vault", id: "custom-provider" }, + ] as const)( + "preserves existing $source custom SecretRefs when reusing an auth profile", + async (ref) => { + const runtime = createRuntime(); + const providerId = "custom-models-custom-local"; + const nextConfig = { + models: { + providers: { + [providerId]: { + baseUrl: "https://models.custom.local/v1", + apiKey: ref, + models: [], + }, + }, + }, + } as OpenClawConfig; + resolveNonInteractiveApiKey.mockResolvedValueOnce({ + key: "fixture-existing-profile-secret", + source: "profile", + }); + + const result = await applyNonInteractiveAuthChoice({ + nextConfig, + authChoice: "custom-api-key", + opts: { + customBaseUrl: "https://models.custom.local/v1", + customModelId: "local-large", + secretInputMode: "ref", + } as never, + runtime: runtime as never, + baseConfig: nextConfig, + target, + }); + + expect(result?.models?.providers?.[providerId]?.apiKey).toEqual(ref); + expect(runtime.error).not.toHaveBeenCalled(); + }, + ); + + it("preserves intentionally keyless custom setup in secret-ref mode", async () => { + const runtime = createRuntime(); + const nextConfig = { agents: { defaults: {} } } as OpenClawConfig; + resolveNonInteractiveApiKey.mockResolvedValueOnce(null); + + const result = await applyNonInteractiveAuthChoice({ + nextConfig, + authChoice: "custom-api-key", + opts: { + customBaseUrl: "https://models.custom.local/v1", + customModelId: "local-large", + secretInputMode: "ref", + } as never, + runtime: runtime as never, + baseConfig: nextConfig, + target, + }); + + expect(result?.models?.providers?.["custom-models-custom-local"]?.apiKey).toBeUndefined(); + expect(runtime.error).not.toHaveBeenCalled(); + expect(runtime.exit).not.toHaveBeenCalled(); + }); + + it("preserves existing custom profile serialization in explicit plaintext mode", async () => { + const runtime = createRuntime(); + const nextConfig = { agents: { defaults: {} } } as OpenClawConfig; + resolveNonInteractiveApiKey.mockResolvedValueOnce({ + key: "fixture-plaintext-profile-key", + source: "profile", + }); + + const result = await applyNonInteractiveAuthChoice({ + nextConfig, + authChoice: "custom-api-key", + opts: { + customBaseUrl: "https://models.custom.local/v1", + customModelId: "local-large", + secretInputMode: "plaintext", + } as never, + runtime: runtime as never, + baseConfig: nextConfig, + target, + }); + + expect(result?.models?.providers?.["custom-models-custom-local"]?.apiKey).toBe( + "fixture-plaintext-profile-key", + ); + }); + + it.each([ + { source: "profile", key: "fixture-plugin-profile-secret" }, + { source: "flag", key: "fixture-plugin-literal-secret" }, + { source: "env", key: "fixture-plugin-env-secret" }, + ] as const)( + "rejects non-referenceable $source plugin credentials in secret-ref mode", + async (resolved) => { + const runtime = createRuntime(); + const nextConfig = { agents: { defaults: {} } } as OpenClawConfig; + applyNonInteractivePluginProviderChoice.mockResolvedValueOnce(nextConfig as never); + + await applyNonInteractiveAuthChoice({ + nextConfig, + authChoice: "demo-provider-api-key", + opts: { secretInputMode: "ref" } as never, + runtime: runtime as never, + baseConfig: nextConfig, + target, + }); + + const [pluginParams] = applyNonInteractivePluginProviderChoice.mock.calls.at( + -1, + ) as unknown as [ + { + toApiKeyCredential: (params: { provider: string; resolved: typeof resolved }) => unknown; + }, + ]; + const credential = pluginParams.toApiKeyCredential({ + provider: "demo-provider", + resolved, + }); + + expect(credential).toBeNull(); + expect(runtime.exit).toHaveBeenCalledWith(1); + const errorText = runtime.error.mock.calls.map(([message]) => String(message)).join("\n"); + expect(errorText).toContain("--secret-input-mode ref"); + expect(errorText).toContain("demo-provider"); + expect(errorText).not.toContain(resolved.key); + }, + ); + + it("preserves env-backed plugin credentials and profile metadata in secret-ref mode", async () => { + const runtime = createRuntime(); + const nextConfig = { agents: { defaults: {} } } as OpenClawConfig; + applyNonInteractivePluginProviderChoice.mockResolvedValueOnce(nextConfig as never); + + await applyNonInteractiveAuthChoice({ + nextConfig, + authChoice: "demo-provider-api-key", + opts: { secretInputMode: "ref" } as never, + runtime: runtime as never, + baseConfig: nextConfig, + target, + }); + + const [pluginParams] = applyNonInteractivePluginProviderChoice.mock.calls.at(-1) as unknown as [ + { + toApiKeyCredential: (params: { + provider: string; + resolved: { key: string; source: "env"; envVarName: string }; + email: string; + metadata: Record; + }) => unknown; + }, + ]; + expect( + pluginParams.toApiKeyCredential({ + provider: "demo-provider", + resolved: { + key: "fixture-valid-plugin-env-secret", + source: "env", + envVarName: "DEMO_PROVIDER_API_KEY", + }, + email: "operator@example.test", + metadata: { account: "work" }, + }), + ).toEqual({ + type: "api_key", + provider: "demo-provider", + keyRef: { source: "env", provider: "default", id: "DEMO_PROVIDER_API_KEY" }, + email: "operator@example.test", + metadata: { account: "work" }, + }); + expect(runtime.error).not.toHaveBeenCalled(); + expect(runtime.exit).not.toHaveBeenCalled(); + }); + it("stores custom provider OpenAI Responses compatibility", async () => { const runtime = createRuntime(); const nextConfig = { agents: { defaults: {} } } as OpenClawConfig; diff --git a/src/commands/onboard-non-interactive/local/auth-choice.ts b/src/commands/onboard-non-interactive/local/auth-choice.ts index 7d765984a543..c9d455455ba7 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.ts @@ -63,21 +63,26 @@ export async function applyNonInteractiveAuthChoice(params: { runtime.exit(1); return null; } - const toStoredSecretInput = (resolved: ResolvedNonInteractiveApiKey): SecretInput | null => { + const toStoredSecretInput = (paramsLocal: { + resolved: ResolvedNonInteractiveApiKey; + provider: string; + envVarName?: string; + }): SecretInput | null => { + const { resolved } = paramsLocal; const storePlaintextSecret = requestedSecretInputMode !== "ref"; // pragma: allowlist secret if (storePlaintextSecret) { return resolved.key; } - if (resolved.source !== "env") { - return resolved.key; - } - if (!resolved.envVarName) { - // Secret refs need a durable env-var id; provider auto-detection without - // a concrete name cannot be serialized as a config reference. + if (resolved.source !== "env" || !resolved.envVarName) { + // Existing profiles may be reused, but neither serializer may turn their + // resolved secret or a literal flag into plaintext when refs were requested. + const envHint = paramsLocal.envVarName + ? `Set ${paramsLocal.envVarName} in env and retry` + : "Set the provider API key env var and retry"; runtime.error( [ - `Unable to determine which environment variable to store as a ref for provider "${authChoice}".`, - "Set an explicit provider env var and retry, or use --secret-input-mode plaintext.", + `--secret-input-mode ref requires an explicit environment variable for provider "${paramsLocal.provider}".`, + `${envHint}, or use --secret-input-mode plaintext.`, ].join("\n"), ); runtime.exit(1); @@ -104,39 +109,17 @@ export async function applyNonInteractiveAuthChoice(params: { email?: string; metadata?: Record; }): ApiKeyCredential | null => { - const storeSecretRef = - requestedSecretInputMode === "ref" && paramsLocal.resolved.source === "env"; // pragma: allowlist secret - if (storeSecretRef) { - if (!paramsLocal.resolved.envVarName) { - // Plugin profile credentials have the same secret-ref contract as core - // provider config: the stored ref must name a specific env variable. - runtime.error( - [ - `--secret-input-mode ref requires an explicit environment variable for provider "${paramsLocal.provider}".`, - "Set the provider API key env var and retry, or use --secret-input-mode plaintext.", - ].join("\n"), - ); - runtime.exit(1); - return null; - } - return { - type: "api_key", - provider: paramsLocal.provider, - keyRef: { - source: "env", - provider: resolveDefaultSecretProviderAlias(baseConfig, "env", { - preferFirstProviderForSource: true, - }), - id: paramsLocal.resolved.envVarName, - }, - ...(paramsLocal.email ? { email: paramsLocal.email } : {}), - ...(paramsLocal.metadata ? { metadata: paramsLocal.metadata } : {}), - }; + const stored = toStoredSecretInput({ + resolved: paramsLocal.resolved, + provider: paramsLocal.provider, + }); + if (!stored) { + return null; } return { type: "api_key", provider: paramsLocal.provider, - key: paramsLocal.resolved.key, + ...(typeof stored === "string" ? { key: stored } : { keyRef: stored }), ...(paramsLocal.email ? { email: paramsLocal.email } : {}), ...(paramsLocal.metadata ? { metadata: paramsLocal.metadata } : {}), }; @@ -273,19 +256,21 @@ export async function applyNonInteractiveAuthChoice(params: { required: false, }); let customApiKeyInput: SecretInput | undefined; - if (resolvedCustomApiKey) { - const storeCustomApiKeyAsRef = requestedSecretInputMode === "ref"; // pragma: allowlist secret - if (storeCustomApiKeyAsRef) { - // Reuse the same SecretInput conversion as core providers so custom - // endpoints preserve env-ref storage semantics. - const stored = toStoredSecretInput(resolvedCustomApiKey); - if (!stored) { - return null; - } - customApiKeyInput = stored; - } else { - customApiKeyInput = resolvedCustomApiKey.key; + if ( + resolvedCustomApiKey && + (requestedSecretInputMode !== "ref" || resolvedCustomApiKey.source !== "profile") + ) { + // Profile ownership stays in the auth store; serializing its resolved + // value would expose plaintext and overwrite an existing SecretRef. + const stored = toStoredSecretInput({ + resolved: resolvedCustomApiKey, + provider: resolvedProviderId.providerId, + envVarName: "CUSTOM_API_KEY", + }); + if (!stored) { + return null; } + customApiKeyInput = stored; } const result = applyCustomApiConfig({ config: nextConfig, diff --git a/src/plugins/provider-self-hosted-setup.test.ts b/src/plugins/provider-self-hosted-setup.test.ts index 2fb6e9750263..144a80e2885d 100644 --- a/src/plugins/provider-self-hosted-setup.test.ts +++ b/src/plugins/provider-self-hosted-setup.test.ts @@ -866,6 +866,206 @@ describe("configureOpenAICompatibleSelfHostedProviderNonInteractive", () => { }); }); + it.each([ + { providerId: "vllm", providerLabel: "vLLM", envVar: "VLLM_API_KEY" }, + { providerId: "sglang", providerLabel: "SGLang", envVar: "SGLANG_API_KEY" }, + { providerId: "lmstudio", providerLabel: "LM Studio", envVar: "LM_API_TOKEN" }, + ])("reuses an existing $providerLabel auth profile in ref mode", async (params) => { + const modelId = "Qwen/Qwen3-32B"; + const profileSecret = "fixture-existing-self-hosted-profile-secret"; + const selectedProfileId = `${params.providerId}:owner@example.com`; + const backupProfileId = `${params.providerId}:backup`; + const ctx = createContext({ providerId: params.providerId, modelId }); + ctx.opts.secretInputMode = "ref"; + const existingAuth = { + profiles: { + [selectedProfileId]: { + provider: params.providerId, + mode: "api_key" as const, + email: "owner@example.com", + displayName: "Operator Account", + }, + [backupProfileId]: { provider: params.providerId, mode: "api_key" as const }, + }, + order: { [params.providerId]: [selectedProfileId, backupProfileId] }, + }; + ctx.config = { ...ctx.config, auth: existingAuth }; + vi.mocked(ctx.resolveApiKey).mockResolvedValueOnce({ + key: profileSecret, + source: "profile", + }); + vi.mocked(ctx.toApiKeyCredential).mockImplementationOnce(() => { + ctx.runtime.error("Cannot encode an existing profile as a SecretRef."); + ctx.runtime.exit(1); + return null; + }); + + const cfg = await configureSelfHostedTestProvider({ ctx, ...params }); + + expect(cfg?.auth).toEqual(existingAuth); + expect(cfg?.auth?.profiles?.[`${params.providerId}:default`]).toBeUndefined(); + expect(readPrimaryModel(cfg)).toBe(`${params.providerId}/${modelId}`); + expect(JSON.stringify(cfg)).not.toContain(profileSecret); + expect(ctx.toApiKeyCredential).not.toHaveBeenCalled(); + expect(upsertAuthProfileWithLock).not.toHaveBeenCalled(); + expect(ctx.runtime.error).not.toHaveBeenCalled(); + expect(ctx.runtime.exit).not.toHaveBeenCalled(); + }); + + it.each([ + { + providerId: "lmstudio", + providerLabel: "LM Studio", + envVar: "LM_API_TOKEN", + marker: "custom-local", + }, + { + providerId: "lmstudio", + providerLabel: "LM Studio", + envVar: "LM_API_TOKEN", + marker: "lmstudio-local", + }, + ])("keeps the $providerLabel non-secret marker keyless in ref mode", async (params) => { + const modelId = "Qwen/Qwen3-32B"; + const ctx = createContext({ providerId: params.providerId, modelId }); + ctx.opts.secretInputMode = "ref"; + vi.mocked(ctx.resolveApiKey).mockResolvedValueOnce({ key: params.marker, source: "flag" }); + vi.mocked(ctx.toApiKeyCredential).mockImplementationOnce(() => { + ctx.runtime.error("A synthetic non-secret marker must not become an auth credential."); + ctx.runtime.exit(1); + return null; + }); + + const cfg = await configureSelfHostedTestProvider({ ctx, ...params }); + + expect(readPrimaryModel(cfg)).toBe(`${params.providerId}/${modelId}`); + expect(cfg?.auth?.profiles?.[`${params.providerId}:default`]).toBeUndefined(); + expect(ctx.toApiKeyCredential).not.toHaveBeenCalled(); + expect(upsertAuthProfileWithLock).not.toHaveBeenCalled(); + expect(ctx.runtime.error).not.toHaveBeenCalled(); + expect(ctx.runtime.exit).not.toHaveBeenCalled(); + }); + + it.each([ + { providerId: "vllm", providerLabel: "vLLM", envVar: "VLLM_API_KEY", key: "custom-local" }, + { providerId: "vllm", providerLabel: "vLLM", envVar: "VLLM_API_KEY", key: "lmstudio-local" }, + { + providerId: "lmstudio", + providerLabel: "LM Studio", + envVar: "LM_API_TOKEN", + key: "ollama-local", + }, + { + providerId: "lmstudio", + providerLabel: "LM Studio", + envVar: "LM_API_TOKEN", + key: "oauth:lmstudio", + }, + { + providerId: "lmstudio", + providerLabel: "LM Studio", + envVar: "LM_API_TOKEN", + key: "secretref-env:LM_API_TOKEN", + }, + { + providerId: "vllm", + providerLabel: "vLLM", + envVar: "VLLM_API_KEY", + key: "fixture-genuine-self-hosted-secret", + }, + { providerId: "vllm", providerLabel: "vLLM", envVar: "VLLM_API_KEY", key: "OPENAI_API_KEY" }, + ])( + "rejects unowned marker or genuine flag value $key for $providerLabel in ref mode", + async ({ key, ...params }) => { + const ctx = createContext({ providerId: params.providerId, modelId: "Qwen/Qwen3-32B" }); + ctx.opts.secretInputMode = "ref"; + vi.mocked(ctx.resolveApiKey).mockResolvedValueOnce({ key, source: "flag" }); + vi.mocked(ctx.toApiKeyCredential).mockImplementationOnce(() => { + ctx.runtime.error("SecretRef mode requires an explicit environment variable."); + ctx.runtime.exit(1); + return null; + }); + + const cfg = await configureSelfHostedTestProvider({ ctx, ...params }); + + expect(cfg).toBeNull(); + expect(ctx.toApiKeyCredential).toHaveBeenCalledOnce(); + expect(upsertAuthProfileWithLock).not.toHaveBeenCalled(); + expect(ctx.runtime.error).toHaveBeenCalledOnce(); + expect(ctx.runtime.exit).toHaveBeenCalledWith(1); + }, + ); + + it("does not treat another provider's marker as keyless in plaintext mode", async () => { + const ctx = createContext({ providerId: "vllm", modelId: "Qwen/Qwen3-32B" }); + ctx.opts.secretInputMode = "plaintext"; + vi.mocked(ctx.resolveApiKey).mockResolvedValueOnce({ key: "lmstudio-local", source: "flag" }); + + const cfg = await configureSelfHostedTestProvider({ + ctx, + providerId: "vllm", + providerLabel: "vLLM", + envVar: "VLLM_API_KEY", + }); + + expect(ctx.toApiKeyCredential).toHaveBeenCalledOnce(); + expect(upsertAuthProfileWithLock).toHaveBeenCalledWith({ + profileId: "vllm:default", + agentDir: ctx.agentDir, + credential: { type: "api_key", provider: "vllm", key: "lmstudio-local" }, + }); + expect(cfg?.auth?.profiles?.["vllm:default"]).toEqual({ + provider: "vllm", + mode: "api_key", + }); + }); + + it("preserves an environment SecretRef when persisting a new auth profile", async () => { + const ctx = createContext({ providerId: "vllm", modelId: "Qwen/Qwen3-32B" }); + ctx.opts.secretInputMode = "ref"; + vi.mocked(ctx.resolveApiKey).mockResolvedValueOnce({ + key: "fixture-existing-environment-secret", + source: "env", + envVarName: "VLLM_API_KEY", + }); + const credential = { + type: "api_key" as const, + provider: "vllm", + keyRef: { source: "env" as const, provider: "default", id: "VLLM_API_KEY" }, + }; + vi.mocked(ctx.toApiKeyCredential).mockReturnValueOnce(credential); + + const cfg = await configureSelfHostedTestProvider({ + ctx, + providerId: "vllm", + providerLabel: "vLLM", + envVar: "VLLM_API_KEY", + }); + + expect(upsertAuthProfileWithLock).toHaveBeenCalledWith({ + profileId: "vllm:default", + agentDir: ctx.agentDir, + credential, + }); + expect(JSON.stringify(cfg)).not.toContain("fixture-existing-environment-secret"); + }); + + it("does not write an auth profile when no usable credential is available", async () => { + const ctx = createContext({ providerId: "vllm", modelId: "Qwen/Qwen3-32B" }); + vi.mocked(ctx.resolveApiKey).mockResolvedValueOnce(null); + + const cfg = await configureSelfHostedTestProvider({ + ctx, + providerId: "vllm", + providerLabel: "vLLM", + envVar: "VLLM_API_KEY", + }); + + expect(cfg).toBeNull(); + expect(ctx.toApiKeyCredential).not.toHaveBeenCalled(); + expect(upsertAuthProfileWithLock).not.toHaveBeenCalled(); + }); + it("exits without touching auth when custom model id is missing", async () => { const ctx = createContext({ providerId: "vllm", diff --git a/src/plugins/provider-self-hosted-setup.ts b/src/plugins/provider-self-hosted-setup.ts index 82d80a2105fa..65f988ba3429 100644 --- a/src/plugins/provider-self-hosted-setup.ts +++ b/src/plugins/provider-self-hosted-setup.ts @@ -6,9 +6,13 @@ import { normalizeOptionalString, normalizeStringifiedOptionalString, } from "@openclaw/normalization-core/string-coerce"; -import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; -import type { ApiKeyCredential, AuthProfileCredential } from "../agents/auth-profiles/types.js"; +import { + normalizeTrimmedStringList, + uniqueStrings, +} from "@openclaw/normalization-core/string-normalization"; +import type { AuthProfileCredential } from "../agents/auth-profiles/types.js"; import { upsertAuthProfileWithLock } from "../agents/auth-profiles/upsert-with-lock.js"; +import { CUSTOM_LOCAL_AUTH_MARKER, isNonSecretApiKeyMarker } from "../agents/model-auth-markers.js"; import { parseConfiguredModelVisibilityEntries } from "../agents/model-selection-shared.js"; import { asObject, @@ -28,6 +32,7 @@ import type { SsrFPolicy } from "../infra/net/ssrf.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js"; import type { WizardPrompter } from "../wizard/prompts.js"; +import { listOpenClawPluginManifestMetadata } from "./manifest-metadata-scan.js"; import { applyAuthProfileConfig } from "./provider-auth-helpers.js"; import type { ProviderCatalogContext, @@ -504,15 +509,29 @@ function buildMissingNonInteractiveModelIdMessage(params: { ].join("\n"); } -function buildSelfHostedProviderCredential(params: { - ctx: ProviderAuthMethodNonInteractiveContext; - providerId: string; - resolved: ProviderNonInteractiveApiKeyResult; -}): ApiKeyCredential | null { - return params.ctx.toApiKeyCredential({ - provider: params.providerId, - resolved: params.resolved, - }); +function isProviderOwnedSyntheticAuthMarker( + providerId: string, + resolved: ProviderNonInteractiveApiKeyResult, +): boolean { + if ( + resolved.source !== "flag" || + !isNonSecretApiKeyMarker(resolved.key, { includeEnvVarName: false }) + ) { + return false; + } + const normalizedProvider = normalizeProviderId(providerId); + const matchesProvider = (provider: string) => + normalizeProviderId(provider) === normalizedProvider; + const normalizedValue = resolved.key.trim(); + // A marker is only a keyless capability when its provider's own plugin declares it. + return listOpenClawPluginManifestMetadata().some( + ({ origin, manifest }) => + origin === "bundled" && + normalizeTrimmedStringList(manifest.providers).some(matchesProvider) && + normalizeTrimmedStringList(manifest.syntheticAuthRefs).some(matchesProvider) && + (normalizedValue === CUSTOM_LOCAL_AUTH_MARKER || + normalizeTrimmedStringList(manifest.nonSecretAuthMarkers).includes(normalizedValue)), + ); } export async function configureOpenAICompatibleSelfHostedProviderNonInteractive(params: { @@ -554,15 +573,8 @@ export async function configureOpenAICompatibleSelfHostedProviderNonInteractive( return null; } - const credential = buildSelfHostedProviderCredential({ - ctx: params.ctx, - providerId: params.providerId, - resolved, - }); - if (!credential) { - return null; - } - + const usesSyntheticAuthMarker = isProviderOwnedSyntheticAuthMarker(params.providerId, resolved); + const storesCredential = !usesSyntheticAuthMarker && resolved.source !== "profile"; const configured = buildOpenAICompatibleSelfHostedProviderConfig({ cfg: params.ctx.config, providerId: params.providerId, @@ -574,17 +586,30 @@ export async function configureOpenAICompatibleSelfHostedProviderNonInteractive( contextWindow: params.contextWindow, maxTokens: params.maxTokens, }); - await upsertAuthProfileWithLock({ - profileId: configured.profileId, - credential, - agentDir: params.ctx.agentDir, - }); + // Existing profiles own their credentials; recognized synthetic markers are + // keyless capabilities. Neither should be serialized into a new auth profile. + if (storesCredential) { + const credential = params.ctx.toApiKeyCredential({ + provider: params.providerId, + resolved, + }); + if (!credential) { + return null; + } + await upsertAuthProfileWithLock({ + profileId: configured.profileId, + credential, + agentDir: params.ctx.agentDir, + }); + } - const withProfile = applyAuthProfileConfig(configured.config, { - profileId: configured.profileId, - provider: params.providerId, - mode: "api_key", - }); + const withProfile = storesCredential + ? applyAuthProfileConfig(configured.config, { + profileId: configured.profileId, + provider: params.providerId, + mode: "api_key", + }) + : configured.config; params.ctx.runtime.log(`Default ${params.providerLabel} model: ${modelId}`); return applyProviderDefaultModel(withProfile, configured.modelRef); }