From 22927b0834926a4188fb248fa2309aa04b1800ff Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 4 Feb 2026 20:38:46 -0500 Subject: [PATCH] fix: infer --auth-choice from API key flags during non-interactive onboarding (#9241) * fix: infer --auth-choice from API key flags during non-interactive onboarding When --anthropic-api-key (or other provider key flags) is passed without an explicit --auth-choice, the auth choice defaults to "skip", silently discarding the API key. This means the gateway starts without credentials and fails on every inbound message with "No API key found for provider". Add inferAuthChoiceFromFlags() to derive the correct auth choice from whichever provider API key flag was supplied, so credentials are persisted to auth-profiles.json as expected. Fixes #8481 * fix: infer auth choice from API key flags (#8484) (thanks @f-trycua) * refactor: centralize auth choice inference flags (#8484) (thanks @f-trycua) --------- Co-authored-by: f-trycua --- CHANGELOG.md | 1 + ...-interactive.cloudflare-ai-gateway.test.ts | 92 +++++++++++++++++++ src/commands/onboard-non-interactive/local.ts | 15 ++- .../local/auth-choice-inference.ts | 67 ++++++++++++++ 4 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 src/commands/onboard-non-interactive/local/auth-choice-inference.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e62ef73977..0019fe81102 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai - Web UI: fix agent model selection saves for default/non-default agents and wrap long workspace paths. Thanks @Takhoffman. - Web UI: resolve header logo path when `gateway.controlUi.basePath` is set. (#7178) Thanks @Yeom-JinHo. - Web UI: apply button styling to the new-messages indicator. +- Onboarding: infer auth choice from non-interactive API key flags. (#8484) Thanks @f-trycua. - Security: keep untrusted channel metadata out of system prompts (Slack/Discord). Thanks @KonstantinMirin. - Security: enforce sandboxed media paths for message tool attachments. (#9182) Thanks @victormier. - Security: require explicit credentials for gateway URL overrides to prevent credential leakage. (#8113) Thanks @victormier. diff --git a/src/commands/onboard-non-interactive.cloudflare-ai-gateway.test.ts b/src/commands/onboard-non-interactive.cloudflare-ai-gateway.test.ts index c3cc5667e80..c6196317cfc 100644 --- a/src/commands/onboard-non-interactive.cloudflare-ai-gateway.test.ts +++ b/src/commands/onboard-non-interactive.cloudflare-ai-gateway.test.ts @@ -96,4 +96,96 @@ describe("onboard (non-interactive): Cloudflare AI Gateway", () => { process.env.OPENCLAW_GATEWAY_PASSWORD = prev.password; } }, 60_000); + + it("infers auth choice from API key flags", async () => { + const prev = { + home: process.env.HOME, + stateDir: process.env.OPENCLAW_STATE_DIR, + configPath: process.env.OPENCLAW_CONFIG_PATH, + skipChannels: process.env.OPENCLAW_SKIP_CHANNELS, + skipGmail: process.env.OPENCLAW_SKIP_GMAIL_WATCHER, + skipCron: process.env.OPENCLAW_SKIP_CRON, + skipCanvas: process.env.OPENCLAW_SKIP_CANVAS_HOST, + token: process.env.OPENCLAW_GATEWAY_TOKEN, + password: process.env.OPENCLAW_GATEWAY_PASSWORD, + }; + + process.env.OPENCLAW_SKIP_CHANNELS = "1"; + process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1"; + process.env.OPENCLAW_SKIP_CRON = "1"; + process.env.OPENCLAW_SKIP_CANVAS_HOST = "1"; + delete process.env.OPENCLAW_GATEWAY_TOKEN; + delete process.env.OPENCLAW_GATEWAY_PASSWORD; + + const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-onboard-cf-gateway-infer-")); + process.env.HOME = tempHome; + process.env.OPENCLAW_STATE_DIR = tempHome; + process.env.OPENCLAW_CONFIG_PATH = path.join(tempHome, "openclaw.json"); + vi.resetModules(); + + const runtime = { + log: () => {}, + error: (msg: string) => { + throw new Error(msg); + }, + exit: (code: number) => { + throw new Error(`exit:${code}`); + }, + }; + + try { + const { runNonInteractiveOnboarding } = await import("./onboard-non-interactive.js"); + await runNonInteractiveOnboarding( + { + nonInteractive: true, + cloudflareAiGatewayAccountId: "cf-account-id", + cloudflareAiGatewayGatewayId: "cf-gateway-id", + cloudflareAiGatewayApiKey: "cf-gateway-test-key", + skipHealth: true, + skipChannels: true, + skipSkills: true, + json: true, + }, + runtime, + ); + + const { CONFIG_PATH } = await import("../config/config.js"); + const cfg = JSON.parse(await fs.readFile(CONFIG_PATH, "utf8")) as { + auth?: { + profiles?: Record; + }; + agents?: { defaults?: { model?: { primary?: string } } }; + }; + + expect(cfg.auth?.profiles?.["cloudflare-ai-gateway:default"]?.provider).toBe( + "cloudflare-ai-gateway", + ); + expect(cfg.auth?.profiles?.["cloudflare-ai-gateway:default"]?.mode).toBe("api_key"); + expect(cfg.agents?.defaults?.model?.primary).toBe("cloudflare-ai-gateway/claude-sonnet-4-5"); + + const { ensureAuthProfileStore } = await import("../agents/auth-profiles.js"); + const store = ensureAuthProfileStore(); + const profile = store.profiles["cloudflare-ai-gateway:default"]; + expect(profile?.type).toBe("api_key"); + if (profile?.type === "api_key") { + expect(profile.provider).toBe("cloudflare-ai-gateway"); + expect(profile.key).toBe("cf-gateway-test-key"); + expect(profile.metadata).toEqual({ + accountId: "cf-account-id", + gatewayId: "cf-gateway-id", + }); + } + } finally { + await fs.rm(tempHome, { recursive: true, force: true }); + process.env.HOME = prev.home; + process.env.OPENCLAW_STATE_DIR = prev.stateDir; + process.env.OPENCLAW_CONFIG_PATH = prev.configPath; + process.env.OPENCLAW_SKIP_CHANNELS = prev.skipChannels; + process.env.OPENCLAW_SKIP_GMAIL_WATCHER = prev.skipGmail; + process.env.OPENCLAW_SKIP_CRON = prev.skipCron; + process.env.OPENCLAW_SKIP_CANVAS_HOST = prev.skipCanvas; + process.env.OPENCLAW_GATEWAY_TOKEN = prev.token; + process.env.OPENCLAW_GATEWAY_PASSWORD = prev.password; + } + }, 60_000); }); diff --git a/src/commands/onboard-non-interactive/local.ts b/src/commands/onboard-non-interactive/local.ts index 5546e9d2863..3768c7a8297 100644 --- a/src/commands/onboard-non-interactive/local.ts +++ b/src/commands/onboard-non-interactive/local.ts @@ -13,6 +13,7 @@ import { resolveControlUiLinks, waitForGatewayReachable, } from "../onboard-helpers.js"; +import { inferAuthChoiceFromFlags } from "./local/auth-choice-inference.js"; import { applyNonInteractiveAuthChoice } from "./local/auth-choice.js"; import { installGatewayDaemonNonInteractive } from "./local/daemon-install.js"; import { applyNonInteractiveGatewayConfig } from "./local/gateway-config.js"; @@ -49,7 +50,19 @@ export async function runNonInteractiveOnboardingLocal(params: { }, }; - const authChoice = opts.authChoice ?? "skip"; + const inferredAuthChoice = inferAuthChoiceFromFlags(opts); + if (!opts.authChoice && inferredAuthChoice.matches.length > 1) { + runtime.error( + [ + "Multiple API key flags were provided for non-interactive onboarding.", + "Use a single provider flag or pass --auth-choice explicitly.", + `Flags: ${inferredAuthChoice.matches.map((match) => match.label).join(", ")}`, + ].join("\n"), + ); + runtime.exit(1); + return; + } + const authChoice = opts.authChoice ?? inferredAuthChoice.choice ?? "skip"; const nextConfigAfterAuth = await applyNonInteractiveAuthChoice({ nextConfig, authChoice, diff --git a/src/commands/onboard-non-interactive/local/auth-choice-inference.ts b/src/commands/onboard-non-interactive/local/auth-choice-inference.ts new file mode 100644 index 00000000000..c747c92d544 --- /dev/null +++ b/src/commands/onboard-non-interactive/local/auth-choice-inference.ts @@ -0,0 +1,67 @@ +import type { AuthChoice, OnboardOptions } from "../../onboard-types.js"; + +type AuthChoiceFlag = { + flag: keyof AuthChoiceFlagOptions; + authChoice: AuthChoice; + label: string; +}; + +type AuthChoiceFlagOptions = Pick< + OnboardOptions, + | "anthropicApiKey" + | "geminiApiKey" + | "openaiApiKey" + | "openrouterApiKey" + | "aiGatewayApiKey" + | "cloudflareAiGatewayApiKey" + | "moonshotApiKey" + | "kimiCodeApiKey" + | "syntheticApiKey" + | "veniceApiKey" + | "zaiApiKey" + | "xiaomiApiKey" + | "minimaxApiKey" + | "opencodeZenApiKey" +>; + +const AUTH_CHOICE_FLAG_MAP = [ + { flag: "anthropicApiKey", authChoice: "apiKey", label: "--anthropic-api-key" }, + { flag: "geminiApiKey", authChoice: "gemini-api-key", label: "--gemini-api-key" }, + { flag: "openaiApiKey", authChoice: "openai-api-key", label: "--openai-api-key" }, + { flag: "openrouterApiKey", authChoice: "openrouter-api-key", label: "--openrouter-api-key" }, + { flag: "aiGatewayApiKey", authChoice: "ai-gateway-api-key", label: "--ai-gateway-api-key" }, + { + flag: "cloudflareAiGatewayApiKey", + authChoice: "cloudflare-ai-gateway-api-key", + label: "--cloudflare-ai-gateway-api-key", + }, + { flag: "moonshotApiKey", authChoice: "moonshot-api-key", label: "--moonshot-api-key" }, + { flag: "kimiCodeApiKey", authChoice: "kimi-code-api-key", label: "--kimi-code-api-key" }, + { flag: "syntheticApiKey", authChoice: "synthetic-api-key", label: "--synthetic-api-key" }, + { flag: "veniceApiKey", authChoice: "venice-api-key", label: "--venice-api-key" }, + { flag: "zaiApiKey", authChoice: "zai-api-key", label: "--zai-api-key" }, + { flag: "xiaomiApiKey", authChoice: "xiaomi-api-key", label: "--xiaomi-api-key" }, + { flag: "minimaxApiKey", authChoice: "minimax-api", label: "--minimax-api-key" }, + { flag: "opencodeZenApiKey", authChoice: "opencode-zen", label: "--opencode-zen-api-key" }, +] satisfies ReadonlyArray; + +export type AuthChoiceInference = { + choice?: AuthChoice; + matches: AuthChoiceFlag[]; +}; + +// Infer auth choice from explicit provider API key flags. +export function inferAuthChoiceFromFlags(opts: OnboardOptions): AuthChoiceInference { + const matches = AUTH_CHOICE_FLAG_MAP.filter(({ flag }) => { + const value = opts[flag]; + if (typeof value === "string") { + return value.trim().length > 0; + } + return Boolean(value); + }); + + return { + choice: matches[0]?.authChoice, + matches, + }; +}