mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
1e06fd4430
commit
7fafaf50f4
+1
-1
@@ -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)
|
||||
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user