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, + }; +}