fix: harden custom provider non-interactive auth (openclaw#14223) thanks @ENCHIGO

This commit is contained in:
Gustavo Madeira Santana
2026-02-11 14:42:04 -05:00
parent 30090df2b9
commit 5b98d6514e
9 changed files with 481 additions and 48 deletions

View File

@@ -303,7 +303,7 @@ Options:
- `--non-interactive`
- `--mode <local|remote>`
- `--flow <quickstart|advanced|manual>` (manual is an alias for advanced)
- `--auth-choice <setup-token|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|moonshot-api-key-cn|kimi-code-api-key|synthetic-api-key|venice-api-key|gemini-api-key|zai-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip>`
- `--auth-choice <setup-token|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|moonshot-api-key-cn|kimi-code-api-key|synthetic-api-key|venice-api-key|gemini-api-key|zai-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|custom-api-key|skip>`
- `--token-provider <id>` (non-interactive; used with `--auth-choice token`)
- `--token <token>` (non-interactive; used with `--auth-choice token`)
- `--token-profile-id <id>` (non-interactive; default: `<provider>:manual`)
@@ -318,6 +318,11 @@ Options:
- `--zai-api-key <key>`
- `--minimax-api-key <key>`
- `--opencode-zen-api-key <key>`
- `--custom-base-url <url>` (non-interactive; used with `--auth-choice custom-api-key`)
- `--custom-model-id <id>` (non-interactive; used with `--auth-choice custom-api-key`)
- `--custom-api-key <key>` (non-interactive; optional; used with `--auth-choice custom-api-key`; falls back to `CUSTOM_API_KEY` when omitted)
- `--custom-provider-id <id>` (non-interactive; optional custom provider id)
- `--custom-compatibility <openai|anthropic>` (non-interactive; optional; default `openai`)
- `--gateway-port <port>`
- `--gateway-bind <loopback|lan|tailnet|auto|custom>`
- `--gateway-auth <token|password>`

View File

@@ -26,6 +26,19 @@ openclaw onboard --flow manual
openclaw onboard --mode remote --remote-url ws://gateway-host:18789
```
Non-interactive custom provider:
```bash
openclaw onboard --non-interactive \
--auth-choice custom-api-key \
--custom-base-url "https://llm.example.com/v1" \
--custom-model-id "foo-large" \
--custom-api-key "$CUSTOM_API_KEY" \
--custom-compatibility openai
```
`--custom-api-key` is optional in non-interactive mode. If omitted, onboarding checks `CUSTOM_API_KEY`.
Flow notes:
- `quickstart`: minimal prompts, auto-generates a gateway token.

View File

@@ -106,6 +106,23 @@ Add `--json` for a machine-readable summary.
--gateway-bind loopback
```
</Accordion>
<Accordion title="Custom provider example">
```bash
openclaw onboard --non-interactive \
--mode local \
--auth-choice custom-api-key \
--custom-base-url "https://llm.example.com/v1" \
--custom-model-id "foo-large" \
--custom-api-key "$CUSTOM_API_KEY" \
--custom-provider-id "my-custom" \
--custom-compatibility anthropic \
--gateway-port 18789 \
--gateway-bind loopback
```
`--custom-api-key` is optional. If omitted, onboarding checks `CUSTOM_API_KEY`.
</Accordion>
</AccordionGroup>
## Add another agent

View File

@@ -175,6 +175,18 @@ What you set:
Moonshot (Kimi K2) and Kimi Coding configs are auto-written.
More detail: [Moonshot AI (Kimi + Kimi Coding)](/providers/moonshot).
</Accordion>
<Accordion title="Custom provider">
Works with OpenAI-compatible and Anthropic-compatible endpoints.
Non-interactive flags:
- `--auth-choice custom-api-key`
- `--custom-base-url`
- `--custom-model-id`
- `--custom-api-key` (optional; falls back to `CUSTOM_API_KEY`)
- `--custom-provider-id` (optional)
- `--custom-compatibility <openai|anthropic>` (optional; default `openai`)
</Accordion>
<Accordion title="Skip">
Leaves auth unconfigured.
</Accordion>

View File

@@ -1,6 +1,10 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { defaultRuntime } from "../runtime.js";
import { promptCustomApiConfig } from "./onboard-custom.js";
import {
applyCustomApiConfig,
parseNonInteractiveCustomApiFlags,
promptCustomApiConfig,
} from "./onboard-custom.js";
// Mock dependencies
vi.mock("./model-picker.js", () => ({
@@ -268,3 +272,75 @@ describe("promptCustomApiConfig", () => {
expect(prompter.text).toHaveBeenCalledTimes(6);
});
});
describe("applyCustomApiConfig", () => {
it("rejects invalid compatibility values at runtime", () => {
expect(() =>
applyCustomApiConfig({
config: {},
baseUrl: "https://llm.example.com/v1",
modelId: "foo-large",
compatibility: "invalid" as unknown as "openai",
}),
).toThrow('Custom provider compatibility must be "openai" or "anthropic".');
});
it("rejects explicit provider ids that normalize to empty", () => {
expect(() =>
applyCustomApiConfig({
config: {},
baseUrl: "https://llm.example.com/v1",
modelId: "foo-large",
compatibility: "openai",
providerId: "!!!",
}),
).toThrow("Custom provider ID must include letters, numbers, or hyphens.");
});
});
describe("parseNonInteractiveCustomApiFlags", () => {
it("parses required flags and defaults compatibility to openai", () => {
const result = parseNonInteractiveCustomApiFlags({
baseUrl: " https://llm.example.com/v1 ",
modelId: " foo-large ",
apiKey: " custom-test-key ",
providerId: " my-custom ",
});
expect(result).toEqual({
baseUrl: "https://llm.example.com/v1",
modelId: "foo-large",
compatibility: "openai",
apiKey: "custom-test-key",
providerId: "my-custom",
});
});
it("rejects missing required flags", () => {
expect(() =>
parseNonInteractiveCustomApiFlags({
baseUrl: "https://llm.example.com/v1",
}),
).toThrow('Auth choice "custom-api-key" requires a base URL and model ID.');
});
it("rejects invalid compatibility values", () => {
expect(() =>
parseNonInteractiveCustomApiFlags({
baseUrl: "https://llm.example.com/v1",
modelId: "foo-large",
compatibility: "xmlrpc",
}),
).toThrow('Invalid --custom-compatibility (use "openai" or "anthropic").');
});
it("rejects invalid explicit provider ids", () => {
expect(() =>
parseNonInteractiveCustomApiFlags({
baseUrl: "https://llm.example.com/v1",
modelId: "foo-large",
providerId: "!!!",
}),
).toThrow("Custom provider ID must include letters, numbers, or hyphens.");
});
});

View File

@@ -32,6 +32,51 @@ export type ApplyCustomApiConfigParams = {
alias?: string;
};
export type ParseNonInteractiveCustomApiFlagsParams = {
baseUrl?: string;
modelId?: string;
compatibility?: string;
apiKey?: string;
providerId?: string;
};
export type ParsedNonInteractiveCustomApiFlags = {
baseUrl: string;
modelId: string;
compatibility: CustomApiCompatibility;
apiKey?: string;
providerId?: string;
};
export type CustomApiErrorCode =
| "missing_required"
| "invalid_compatibility"
| "invalid_base_url"
| "invalid_model_id"
| "invalid_provider_id"
| "invalid_alias";
export class CustomApiError extends Error {
readonly code: CustomApiErrorCode;
constructor(code: CustomApiErrorCode, message: string) {
super(message);
this.name = "CustomApiError";
this.code = code;
}
}
export type ResolveCustomProviderIdParams = {
config: OpenClawConfig;
baseUrl: string;
providerId?: string;
};
export type ResolvedCustomProviderId = {
providerId: string;
providerIdRenamedFrom?: string;
};
const COMPATIBILITY_OPTIONS: Array<{
value: CustomApiCompatibilityChoice;
label: string;
@@ -260,27 +305,108 @@ function resolveProviderApi(
return compatibility === "anthropic" ? "anthropic-messages" : "openai-completions";
}
export function applyCustomApiConfig(params: ApplyCustomApiConfigParams): CustomApiResult {
const baseUrl = params.baseUrl.trim();
try {
new URL(baseUrl);
} catch {
throw new Error("Custom provider base URL must be a valid URL.");
function parseCustomApiCompatibility(raw?: string): CustomApiCompatibility {
const compatibilityRaw = raw?.trim().toLowerCase();
if (!compatibilityRaw) {
return "openai";
}
const modelId = params.modelId.trim();
if (!modelId) {
throw new Error("Custom provider model ID is required.");
if (compatibilityRaw !== "openai" && compatibilityRaw !== "anthropic") {
throw new CustomApiError(
"invalid_compatibility",
'Invalid --custom-compatibility (use "openai" or "anthropic").',
);
}
return compatibilityRaw;
}
export function resolveCustomProviderId(
params: ResolveCustomProviderIdParams,
): ResolvedCustomProviderId {
const providers = params.config.models?.providers ?? {};
const requestedProviderId = params.providerId?.trim() || buildEndpointIdFromUrl(baseUrl);
const baseUrl = params.baseUrl.trim();
const explicitProviderId = params.providerId?.trim();
if (explicitProviderId && !normalizeEndpointId(explicitProviderId)) {
throw new CustomApiError(
"invalid_provider_id",
"Custom provider ID must include letters, numbers, or hyphens.",
);
}
const requestedProviderId = explicitProviderId || buildEndpointIdFromUrl(baseUrl);
const providerIdResult = resolveUniqueEndpointId({
requestedId: requestedProviderId,
baseUrl,
providers,
});
return {
providerId: providerIdResult.providerId,
...(providerIdResult.renamed
? {
providerIdRenamedFrom: normalizeEndpointId(requestedProviderId) || "custom",
}
: {}),
};
}
export function parseNonInteractiveCustomApiFlags(
params: ParseNonInteractiveCustomApiFlagsParams,
): ParsedNonInteractiveCustomApiFlags {
const baseUrl = params.baseUrl?.trim() ?? "";
const modelId = params.modelId?.trim() ?? "";
if (!baseUrl || !modelId) {
throw new CustomApiError(
"missing_required",
[
'Auth choice "custom-api-key" requires a base URL and model ID.',
"Use --custom-base-url and --custom-model-id.",
].join("\n"),
);
}
const apiKey = params.apiKey?.trim();
const providerId = params.providerId?.trim();
if (providerId && !normalizeEndpointId(providerId)) {
throw new CustomApiError(
"invalid_provider_id",
"Custom provider ID must include letters, numbers, or hyphens.",
);
}
return {
baseUrl,
modelId,
compatibility: parseCustomApiCompatibility(params.compatibility),
...(apiKey ? { apiKey } : {}),
...(providerId ? { providerId } : {}),
};
}
export function applyCustomApiConfig(params: ApplyCustomApiConfigParams): CustomApiResult {
const baseUrl = params.baseUrl.trim();
try {
new URL(baseUrl);
} catch {
throw new CustomApiError("invalid_base_url", "Custom provider base URL must be a valid URL.");
}
if (params.compatibility !== "openai" && params.compatibility !== "anthropic") {
throw new CustomApiError(
"invalid_compatibility",
'Custom provider compatibility must be "openai" or "anthropic".',
);
}
const modelId = params.modelId.trim();
if (!modelId) {
throw new CustomApiError("invalid_model_id", "Custom provider model ID is required.");
}
const providerIdResult = resolveCustomProviderId({
config: params.config,
baseUrl,
providerId: params.providerId,
});
const providerId = providerIdResult.providerId;
const providers = params.config.models?.providers ?? {};
const modelRef = modelKey(providerId, modelId);
const alias = params.alias?.trim() ?? "";
@@ -290,7 +416,7 @@ export function applyCustomApiConfig(params: ApplyCustomApiConfigParams): Custom
modelRef,
});
if (aliasError) {
throw new Error(aliasError);
throw new CustomApiError("invalid_alias", aliasError);
}
const existingProvider = providers[providerId];
@@ -352,10 +478,8 @@ export function applyCustomApiConfig(params: ApplyCustomApiConfigParams): Custom
config,
providerId,
modelId,
...(providerIdResult.renamed
? {
providerIdRenamedFrom: normalizeEndpointId(requestedProviderId) || "custom",
}
...(providerIdResult.providerIdRenamedFrom
? { providerIdRenamedFrom: providerIdResult.providerIdRenamedFrom }
: {}),
};
}

View File

@@ -20,6 +20,7 @@ type EnvSnapshot = {
skipCanvas: string | undefined;
token: string | undefined;
password: string | undefined;
customApiKey: string | undefined;
disableConfigCache: string | undefined;
};
@@ -39,6 +40,7 @@ function captureEnv(): EnvSnapshot {
skipCanvas: process.env.OPENCLAW_SKIP_CANVAS_HOST,
token: process.env.OPENCLAW_GATEWAY_TOKEN,
password: process.env.OPENCLAW_GATEWAY_PASSWORD,
customApiKey: process.env.CUSTOM_API_KEY,
disableConfigCache: process.env.OPENCLAW_DISABLE_CONFIG_CACHE,
};
}
@@ -61,6 +63,7 @@ function restoreEnv(prev: EnvSnapshot): void {
restoreEnvVar("OPENCLAW_SKIP_CANVAS_HOST", prev.skipCanvas);
restoreEnvVar("OPENCLAW_GATEWAY_TOKEN", prev.token);
restoreEnvVar("OPENCLAW_GATEWAY_PASSWORD", prev.password);
restoreEnvVar("CUSTOM_API_KEY", prev.customApiKey);
restoreEnvVar("OPENCLAW_DISABLE_CONFIG_CACHE", prev.disableConfigCache);
}
@@ -77,6 +80,7 @@ async function withOnboardEnv(
process.env.OPENCLAW_DISABLE_CONFIG_CACHE = "1";
delete process.env.OPENCLAW_GATEWAY_TOKEN;
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
delete process.env.CUSTOM_API_KEY;
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
const configPath = path.join(tempHome, "openclaw.json");
@@ -408,4 +412,156 @@ describe("onboard (non-interactive): provider auth", () => {
},
);
}, 60_000);
it("uses CUSTOM_API_KEY env fallback for non-interactive custom provider auth", async () => {
await withOnboardEnv(
"openclaw-onboard-custom-provider-env-fallback-",
async ({ configPath, runtime }) => {
process.env.CUSTOM_API_KEY = "custom-env-key";
await runNonInteractive(
{
nonInteractive: true,
authChoice: "custom-api-key",
customBaseUrl: "https://models.custom.local/v1",
customModelId: "local-large",
skipHealth: true,
skipChannels: true,
skipSkills: true,
json: true,
},
runtime,
);
const cfg = await readJsonFile<{
models?: {
providers?: Record<
string,
{
apiKey?: string;
}
>;
};
}>(configPath);
expect(cfg.models?.providers?.["custom-models-custom-local"]?.apiKey).toBe(
"custom-env-key",
);
},
);
}, 60_000);
it("uses matching profile fallback for non-interactive custom provider auth", async () => {
await withOnboardEnv(
"openclaw-onboard-custom-provider-profile-fallback-",
async ({ configPath, runtime }) => {
const { upsertAuthProfile } = await import("../agents/auth-profiles.js");
upsertAuthProfile({
profileId: "custom-models-custom-local:default",
credential: {
type: "api_key",
provider: "custom-models-custom-local",
key: "custom-profile-key",
},
});
await runNonInteractive(
{
nonInteractive: true,
authChoice: "custom-api-key",
customBaseUrl: "https://models.custom.local/v1",
customModelId: "local-large",
skipHealth: true,
skipChannels: true,
skipSkills: true,
json: true,
},
runtime,
);
const cfg = await readJsonFile<{
models?: {
providers?: Record<
string,
{
apiKey?: string;
}
>;
};
}>(configPath);
expect(cfg.models?.providers?.["custom-models-custom-local"]?.apiKey).toBe(
"custom-profile-key",
);
},
);
}, 60_000);
it("fails custom provider auth when compatibility is invalid", async () => {
await withOnboardEnv(
"openclaw-onboard-custom-provider-invalid-compat-",
async ({ runtime }) => {
await expect(
runNonInteractive(
{
nonInteractive: true,
authChoice: "custom-api-key",
customBaseUrl: "https://models.custom.local/v1",
customModelId: "local-large",
customCompatibility: "xmlrpc",
skipHealth: true,
skipChannels: true,
skipSkills: true,
json: true,
},
runtime,
),
).rejects.toThrow('Invalid --custom-compatibility (use "openai" or "anthropic").');
},
);
}, 60_000);
it("fails custom provider auth when explicit provider id is invalid", async () => {
await withOnboardEnv("openclaw-onboard-custom-provider-invalid-id-", async ({ runtime }) => {
await expect(
runNonInteractive(
{
nonInteractive: true,
authChoice: "custom-api-key",
customBaseUrl: "https://models.custom.local/v1",
customModelId: "local-large",
customProviderId: "!!!",
skipHealth: true,
skipChannels: true,
skipSkills: true,
json: true,
},
runtime,
),
).rejects.toThrow(
"Invalid custom provider config: Custom provider ID must include letters, numbers, or hyphens.",
);
});
}, 60_000);
it("fails inferred custom auth when required flags are incomplete", async () => {
await withOnboardEnv(
"openclaw-onboard-custom-provider-missing-required-",
async ({ runtime }) => {
await expect(
runNonInteractive(
{
nonInteractive: true,
customApiKey: "custom-test-key",
skipHealth: true,
skipChannels: true,
skipSkills: true,
json: true,
},
runtime,
),
).rejects.toThrow('Auth choice "custom-api-key" requires a base URL and model ID.');
},
);
}, 60_000);
});

View File

@@ -45,9 +45,11 @@ export async function resolveNonInteractiveApiKey(params: {
flagValue?: string;
flagName: string;
envVar: string;
envVarName?: string;
runtime: RuntimeEnv;
agentDir?: string;
allowProfile?: boolean;
required?: boolean;
}): Promise<{ key: string; source: NonInteractiveApiKeySource } | null> {
const flagKey = normalizeOptionalSecretInput(params.flagValue);
if (flagKey) {
@@ -59,6 +61,14 @@ export async function resolveNonInteractiveApiKey(params: {
return { key: envResolved.apiKey, source: "env" };
}
const explicitEnvVar = params.envVarName?.trim();
if (explicitEnvVar) {
const explicitEnvKey = normalizeOptionalSecretInput(process.env[explicitEnvVar]);
if (explicitEnvKey) {
return { key: explicitEnvKey, source: "env" };
}
}
if (params.allowProfile ?? true) {
const profileKey = await resolveApiKeyFromProfiles({
provider: params.provider,
@@ -70,6 +80,10 @@ export async function resolveNonInteractiveApiKey(params: {
}
}
if (params.required === false) {
return null;
}
const profileHint =
params.allowProfile === false ? "" : `, or existing ${params.provider} API-key profile`;
params.runtime.error(`Missing ${params.flagName} (or ${params.envVar} in env${profileHint}).`);

View File

@@ -46,7 +46,12 @@ import {
setXiaomiApiKey,
setZaiApiKey,
} from "../../onboard-auth.js";
import { applyCustomApiConfig } from "../../onboard-custom.js";
import {
applyCustomApiConfig,
CustomApiError,
parseNonInteractiveCustomApiFlags,
resolveCustomProviderId,
} from "../../onboard-custom.js";
import { applyOpenAIConfig } from "../../openai-model-default.js";
import { resolveNonInteractiveApiKey } from "../api-keys.js";
@@ -596,39 +601,37 @@ export async function applyNonInteractiveAuthChoice(params: {
}
if (authChoice === "custom-api-key") {
const baseUrl = opts.customBaseUrl?.trim() ?? "";
const modelId = opts.customModelId?.trim() ?? "";
if (!baseUrl || !modelId) {
runtime.error(
[
'Auth choice "custom-api-key" requires a base URL and model ID.',
"Use --custom-base-url and --custom-model-id.",
].join("\n"),
);
runtime.exit(1);
return null;
}
const compatibilityRaw = opts.customCompatibility?.trim().toLowerCase();
let compatibility: "openai" | "anthropic" = "openai";
if (compatibilityRaw) {
if (compatibilityRaw !== "openai" && compatibilityRaw !== "anthropic") {
runtime.error('Invalid --custom-compatibility (use "openai" or "anthropic").');
runtime.exit(1);
return null;
}
compatibility = compatibilityRaw;
}
try {
const result = applyCustomApiConfig({
config: nextConfig,
baseUrl,
modelId,
compatibility,
const customAuth = parseNonInteractiveCustomApiFlags({
baseUrl: opts.customBaseUrl,
modelId: opts.customModelId,
compatibility: opts.customCompatibility,
apiKey: opts.customApiKey,
providerId: opts.customProviderId,
});
const resolvedProviderId = resolveCustomProviderId({
config: nextConfig,
baseUrl: customAuth.baseUrl,
providerId: customAuth.providerId,
});
const resolvedCustomApiKey = await resolveNonInteractiveApiKey({
provider: resolvedProviderId.providerId,
cfg: baseConfig,
flagValue: customAuth.apiKey,
flagName: "--custom-api-key",
envVar: "CUSTOM_API_KEY",
envVarName: "CUSTOM_API_KEY",
runtime,
required: false,
});
const result = applyCustomApiConfig({
config: nextConfig,
baseUrl: customAuth.baseUrl,
modelId: customAuth.modelId,
compatibility: customAuth.compatibility,
apiKey: resolvedCustomApiKey?.key,
providerId: customAuth.providerId,
});
if (result.providerIdRenamedFrom && result.providerId) {
runtime.log(
`Custom provider ID "${result.providerIdRenamedFrom}" already exists for a different base URL. Using "${result.providerId}".`,
@@ -636,6 +639,19 @@ export async function applyNonInteractiveAuthChoice(params: {
}
return result.config;
} catch (err) {
if (err instanceof CustomApiError) {
switch (err.code) {
case "missing_required":
case "invalid_compatibility":
runtime.error(err.message);
break;
default:
runtime.error(`Invalid custom provider config: ${err.message}`);
break;
}
runtime.exit(1);
return null;
}
const reason = err instanceof Error ? err.message : String(err);
runtime.error(`Invalid custom provider config: ${reason}`);
runtime.exit(1);