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
This commit is contained in:
Peter Steinberger
2026-08-03 08:34:47 -07:00
committed by GitHub
parent 1e06fd4430
commit 7fafaf50f4
9 changed files with 595 additions and 88 deletions
+1 -1
View File
@@ -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: <envVar> }`; for custom providers it writes `models.providers.<id>.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: <envVar> }`, and custom providers use `models.providers.<id>.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)
+1 -1
View File
@@ -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: "<ENV_VAR>" }`) 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: "<ENV_VAR>" }`); 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 \
+5 -3
View File
@@ -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.<id>.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.<id>.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.
+6 -3
View File
@@ -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
@@ -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();
});
});
@@ -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<string, string>;
}) => 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;
@@ -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<string, string>;
}): 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,
@@ -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",
+55 -30
View File
@@ -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);
}