From 9a784d275e555433bac8e56e5067e2a57adfe43e Mon Sep 17 00:00:00 2001 From: kumarabhirup Date: Sun, 15 Mar 2026 04:16:01 -0700 Subject: [PATCH] feat(cli): integrate Dench Cloud setup into bootstrap flow Bootstrap now prompts for Dench Cloud API key and model, syncs bundled plugins generically, and migrates legacy dench-cloud-provider. --- ...otstrap-external.bootstrap-command.test.ts | 420 +++++++++- src/cli/bootstrap-external.test.ts | 66 ++ src/cli/bootstrap-external.ts | 752 ++++++++++++++++-- src/cli/program/register.bootstrap.ts | 8 + src/cli/web-runtime.ts | 9 + 5 files changed, 1171 insertions(+), 84 deletions(-) diff --git a/src/cli/bootstrap-external.bootstrap-command.test.ts b/src/cli/bootstrap-external.bootstrap-command.test.ts index 63a19f70916..f544248b970 100644 --- a/src/cli/bootstrap-external.bootstrap-command.test.ts +++ b/src/cli/bootstrap-external.bootstrap-command.test.ts @@ -12,7 +12,12 @@ const promptMocks = vi.hoisted(() => { return { cancelSignal, confirmDecision: false as boolean | symbol, + confirmDecisions: [] as Array, + selectValue: "" as string | symbol, + textValue: "" as string | symbol, confirm: vi.fn(async () => false as boolean | symbol), + select: vi.fn(async () => "" as string | symbol), + text: vi.fn(async () => "" as string | symbol), isCancel: vi.fn((value: unknown) => value === cancelSignal), spinner: vi.fn(() => ({ start: vi.fn(), @@ -24,6 +29,8 @@ const promptMocks = vi.hoisted(() => { vi.mock("@clack/prompts", () => ({ confirm: promptMocks.confirm, + select: promptMocks.select, + text: promptMocks.text, isCancel: promptMocks.isCancel, spinner: promptMocks.spinner, })); @@ -61,10 +68,23 @@ function createWebProfilesResponse(params?: { const payload = params?.payload ?? { profiles: [], activeProfile: "dench" }; return { status, + ok: status >= 200 && status < 300, json: async () => payload, } as unknown as Response; } +function createJsonResponse(params?: { + status?: number; + payload?: unknown; +}): Response { + const status = params?.status ?? 200; + return { + status, + ok: status >= 200 && status < 300, + json: async () => params?.payload ?? {}, + } as unknown as Response; +} + function createTempStateDir(): string { const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}`; const dir = path.join(os.tmpdir(), `denchclaw-bootstrap-${suffix}`); @@ -101,6 +121,41 @@ function writeBootstrapFixtures(stateDir: string): void { ); } +function parseConfigSetValue(raw: string): unknown { + try { + return JSON.parse(raw); + } catch { + if (raw === "true") return true; + if (raw === "false") return false; + const numeric = Number(raw); + if (Number.isFinite(numeric) && raw.trim() !== "") { + return numeric; + } + return raw; + } +} + +function applyConfigSet(stateDir: string, keyPath: string, rawValue: string): void { + const configPath = path.join(stateDir, "openclaw.json"); + const current = existsSync(configPath) + ? JSON.parse(readFileSync(configPath, "utf-8")) + : {}; + const segments = keyPath.split("."); + let cursor: Record = current; + for (const segment of segments.slice(0, -1)) { + const next = cursor[segment]; + if (!next || typeof next !== "object" || Array.isArray(next)) { + cursor[segment] = {}; + } + cursor = cursor[segment] as Record; + } + const leaf = segments.at(-1); + if (leaf) { + cursor[leaf] = parseConfigSetValue(rawValue); + } + writeFileSync(configPath, JSON.stringify(current)); +} + function createMockChild(params: { code: number; stdout?: string; @@ -187,8 +242,19 @@ describe("bootstrapCommand always-onboard behavior", () => { VITEST: "true", }; promptMocks.confirmDecision = false; + promptMocks.confirmDecisions = []; + promptMocks.selectValue = "gpt-5.4"; + promptMocks.textValue = "dench_test_key"; promptMocks.confirm.mockReset(); - promptMocks.confirm.mockImplementation(async () => promptMocks.confirmDecision); + promptMocks.confirm.mockImplementation(async () => + promptMocks.confirmDecisions.length > 0 + ? promptMocks.confirmDecisions.shift()! + : promptMocks.confirmDecision + ); + promptMocks.select.mockReset(); + promptMocks.select.mockImplementation(async () => promptMocks.selectValue); + promptMocks.text.mockReset(); + promptMocks.text.mockImplementation(async () => promptMocks.textValue); promptMocks.isCancel.mockReset(); promptMocks.isCancel.mockImplementation((value: unknown) => value === promptMocks.cancelSignal); promptMocks.spinner.mockClear(); @@ -255,6 +321,19 @@ describe("bootstrapCommand always-onboard behavior", () => { } return createMockChild({ code: 0, stdout: "ok\n" }) as never; } + if ( + commandString === "openclaw" && + argList.includes("config") && + argList.includes("set") + ) { + const setIndex = argList.lastIndexOf("set"); + const keyPath = argList[setIndex + 1]; + const rawValue = argList[setIndex + 2]; + if (keyPath && rawValue !== undefined) { + applyConfigSet(stateDir, keyPath, rawValue); + } + return createMockChild({ code: 0, stdout: "ok\n" }) as never; + } if (commandString === "openclaw" && argList.includes("health")) { healthCallCount += 1; if (alwaysHealthFail || healthCallCount <= healthFailuresBeforeSuccess) { @@ -530,6 +609,333 @@ describe("bootstrapCommand always-onboard behavior", () => { expect(onboardCall?.args).toContain("--reset"); }); + it("uses bootstrap-owned Dench Cloud setup and skips OpenClaw auth onboarding", async () => { + writeFileSync( + path.join(stateDir, "openclaw.json"), + JSON.stringify({ + agents: { + defaults: { + model: { primary: "vercel-ai-gateway/anthropic/claude-opus-4.6" }, + }, + }, + gateway: { mode: "local" }, + plugins: { + allow: ["dench-cloud-provider"], + load: { + paths: [path.join(stateDir, "extensions", "dench-cloud-provider")], + }, + entries: { + "dench-cloud-provider": { + enabled: true, + }, + }, + }, + }), + ); + mkdirSync(path.join(stateDir, "extensions", "dench-cloud-provider"), { recursive: true }); + writeFileSync( + path.join(stateDir, "extensions", "dench-cloud-provider", "index.ts"), + "export {};\n", + ); + fetchBehavior = async (url: string) => { + if (url.includes("gateway.merseoriginals.com/v1/models")) { + return createJsonResponse({ status: 200, payload: { object: "list", data: [] } }); + } + if (url.includes("gateway.merseoriginals.com/v1/public/models")) { + return createJsonResponse({ + status: 200, + payload: { + object: "list", + data: [ + { + id: "gpt-5.4", + stableId: "gpt-5.4", + name: "GPT-5.4", + provider: "openai", + transportProvider: "openai", + input: ["text", "image"], + contextWindow: 128000, + maxTokens: 128000, + supportsStreaming: true, + supportsImages: true, + supportsResponses: true, + supportsReasoning: false, + cost: { + input: 3.375, + output: 20.25, + cacheRead: 0, + cacheWrite: 0, + marginPercent: 0.35, + }, + }, + { + id: "claude-opus-4.6", + stableId: "anthropic.claude-opus-4-6-v1", + name: "Claude Opus 4.6", + provider: "anthropic", + transportProvider: "bedrock", + input: ["text", "image"], + contextWindow: 200000, + maxTokens: 64000, + supportsStreaming: true, + supportsImages: true, + supportsResponses: true, + supportsReasoning: false, + cost: { + input: 6.75, + output: 33.75, + cacheRead: 0, + cacheWrite: 0, + marginPercent: 0.35, + }, + }, + ], + }, + }); + } + if (url.includes("/api/profiles")) { + return createWebProfilesResponse(); + } + return createJsonResponse({ status: 404, payload: {} }); + }; + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + await bootstrapCommand( + { + nonInteractive: true, + noOpen: true, + skipUpdate: true, + denchCloud: true, + denchCloudApiKey: "dench_live_key", + denchCloudModel: "anthropic.claude-opus-4-6-v1", + }, + runtime, + ); + + const onboardCall = spawnCalls.find( + (call) => call.command === "openclaw" && call.args.includes("onboard"), + ); + expect(onboardCall?.args).toEqual( + expect.arrayContaining([ + "--profile", + "dench", + "onboard", + "--non-interactive", + "--auth-choice", + "skip", + ]), + ); + + const updatedConfig = JSON.parse(readFileSync(path.join(stateDir, "openclaw.json"), "utf-8")); + expect(updatedConfig.models.providers["dench-cloud"].apiKey).toBe("dench_live_key"); + expect(updatedConfig.agents.defaults.model.primary).toBe( + "dench-cloud/anthropic.claude-opus-4-6-v1", + ); + expect(updatedConfig.agents.defaults.models["dench-cloud/anthropic.claude-opus-4-6-v1"]).toEqual( + expect.objectContaining({ alias: "Claude Opus 4.6 (Dench Cloud)" }), + ); + expect(updatedConfig.plugins.allow).toContain("posthog-analytics"); + expect(updatedConfig.plugins.allow).toContain("dench-ai-gateway"); + expect(updatedConfig.plugins.allow).not.toContain("dench-cloud-provider"); + expect(updatedConfig.plugins.entries["dench-cloud-provider"]).toBeUndefined(); + expect(updatedConfig.plugins.entries["dench-ai-gateway"]).toEqual( + expect.objectContaining({ + enabled: true, + config: expect.objectContaining({ + gatewayUrl: "https://gateway.merseoriginals.com", + }), + }), + ); + expect(updatedConfig.plugins.installs["posthog-analytics"]).toEqual( + expect.objectContaining({ + source: "path", + installPath: expect.stringContaining(path.join("extensions", "posthog-analytics")), + }), + ); + expect(updatedConfig.plugins.installs["dench-ai-gateway"]).toEqual( + expect.objectContaining({ + source: "path", + installPath: expect.stringContaining(path.join("extensions", "dench-ai-gateway")), + }), + ); + expect(existsSync(path.join(stateDir, "extensions", "dench-cloud-provider"))).toBe(false); + }); + + it("falls back to DenchClaw's bundled model list when the public gateway catalog is unavailable", async () => { + fetchBehavior = async (url: string) => { + if (url.includes("gateway.merseoriginals.com/v1/models")) { + return createJsonResponse({ status: 200, payload: { object: "list", data: [] } }); + } + if (url.includes("gateway.merseoriginals.com/v1/public/models")) { + return createJsonResponse({ status: 503, payload: {} }); + } + if (url.includes("/api/profiles")) { + return createWebProfilesResponse(); + } + return createJsonResponse({ status: 404, payload: {} }); + }; + promptMocks.textValue = "dench_retry_key"; + promptMocks.selectValue = "anthropic.claude-sonnet-4-6-v1"; + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + await bootstrapCommand( + { + denchCloud: true, + noOpen: true, + skipUpdate: true, + }, + runtime, + ); + + expect(promptMocks.text).toHaveBeenCalledTimes(1); + expect(promptMocks.select).toHaveBeenCalledTimes(1); + const onboardCall = spawnCalls.find( + (call) => call.command === "openclaw" && call.args.includes("onboard"), + ); + expect(onboardCall?.options?.stdio).toBe("inherit"); + expect(onboardCall?.args).toEqual(expect.arrayContaining(["--auth-choice", "skip"])); + expect(onboardCall?.args).not.toContain("--non-interactive"); + const updatedConfig = JSON.parse(readFileSync(path.join(stateDir, "openclaw.json"), "utf-8")); + expect(updatedConfig.agents.defaults.model.primary).toBe( + "dench-cloud/anthropic.claude-sonnet-4-6-v1", + ); + expect(updatedConfig.models.providers["dench-cloud"].models).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "gpt-5.4" }), + expect.objectContaining({ id: "anthropic.claude-opus-4-6-v1" }), + expect.objectContaining({ id: "anthropic.claude-sonnet-4-6-v1" }), + ]), + ); + }); + + it("re-prompts for Dench Cloud every bootstrap and pre-fills the saved key and model", async () => { + writeFileSync( + path.join(stateDir, "openclaw.json"), + JSON.stringify({ + agents: { + defaults: { + model: { primary: "dench-cloud/anthropic.claude-opus-4-6-v1" }, + }, + }, + models: { + providers: { + "dench-cloud": { + baseUrl: "https://gateway.merseoriginals.com/v1", + apiKey: "dench_saved_key", + }, + }, + }, + gateway: { mode: "local" }, + }), + ); + fetchBehavior = async (url: string) => { + if (url.includes("gateway.merseoriginals.com/v1/models")) { + return createJsonResponse({ status: 200, payload: { object: "list", data: [] } }); + } + if (url.includes("gateway.merseoriginals.com/v1/public/models")) { + return createJsonResponse({ + status: 200, + payload: { + object: "list", + data: [ + { + id: "gpt-5.4", + stableId: "gpt-5.4", + name: "GPT-5.4", + provider: "openai", + transportProvider: "openai", + input: ["text", "image"], + contextWindow: 128000, + maxTokens: 128000, + supportsStreaming: true, + supportsImages: true, + supportsResponses: true, + supportsReasoning: false, + cost: { + input: 3.375, + output: 20.25, + cacheRead: 0, + cacheWrite: 0, + marginPercent: 0.35, + }, + }, + { + id: "claude-opus-4.6", + stableId: "anthropic.claude-opus-4-6-v1", + name: "Claude Opus 4.6", + provider: "anthropic", + transportProvider: "bedrock", + input: ["text", "image"], + contextWindow: 200000, + maxTokens: 64000, + supportsStreaming: true, + supportsImages: true, + supportsResponses: true, + supportsReasoning: false, + cost: { + input: 6.75, + output: 33.75, + cacheRead: 0, + cacheWrite: 0, + marginPercent: 0.35, + }, + }, + ], + }, + }); + } + if (url.includes("/api/profiles")) { + return createWebProfilesResponse(); + } + return createJsonResponse({ status: 404, payload: {} }); + }; + promptMocks.confirmDecision = true; + promptMocks.textValue = "dench_saved_key"; + promptMocks.selectValue = "anthropic.claude-opus-4-6-v1"; + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + await withForcedStdinTty(true, async () => { + await bootstrapCommand( + { + noOpen: true, + skipUpdate: true, + }, + runtime, + ); + }); + + expect(promptMocks.confirm).toHaveBeenCalledTimes(1); + expect(promptMocks.text).toHaveBeenCalledWith( + expect.objectContaining({ + initialValue: "dench_saved_key", + }), + ); + expect(promptMocks.select).toHaveBeenCalledWith( + expect.objectContaining({ + initialValue: "anthropic.claude-opus-4-6-v1", + }), + ); + + const onboardCall = spawnCalls.find( + (call) => call.command === "openclaw" && call.args.includes("onboard"), + ); + expect(onboardCall?.options?.stdio).toBe("inherit"); + expect(onboardCall?.args).toEqual(expect.arrayContaining(["--auth-choice", "skip"])); + expect(onboardCall?.args).not.toContain("--non-interactive"); + }); + it("runs update before onboarding when --update-now is set", async () => { const runtime: RuntimeEnv = { log: vi.fn(), @@ -560,7 +966,7 @@ describe("bootstrapCommand always-onboard behavior", () => { }); it("runs update before onboarding when interactive prompt is accepted", async () => { - promptMocks.confirmDecision = true; + promptMocks.confirmDecisions = [true, false]; const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), @@ -576,7 +982,7 @@ describe("bootstrapCommand always-onboard behavior", () => { ); }); - expect(promptMocks.confirm).toHaveBeenCalledTimes(1); + expect(promptMocks.confirm).toHaveBeenCalledTimes(2); const updateIndex = spawnCalls.findIndex( (call) => call.command === "openclaw" && call.args.includes("update") && call.args.includes("--yes"), @@ -592,7 +998,7 @@ describe("bootstrapCommand always-onboard behavior", () => { it("skips update prompt right after installing openclaw@latest (avoids redundant update checks)", async () => { forceGlobalMissing = true; - promptMocks.confirmDecision = true; + promptMocks.confirmDecision = false; const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), @@ -621,12 +1027,12 @@ describe("bootstrapCommand always-onboard behavior", () => { ); expect(installedGlobalOpenClaw).toBe(true); - expect(promptMocks.confirm).toHaveBeenCalledTimes(0); + expect(promptMocks.confirm).toHaveBeenCalledTimes(1); expect(updateCalled).toBe(false); }); it("skips update when interactive prompt is declined", async () => { - promptMocks.confirmDecision = false; + promptMocks.confirmDecisions = [false, false]; const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), @@ -642,7 +1048,7 @@ describe("bootstrapCommand always-onboard behavior", () => { ); }); - expect(promptMocks.confirm).toHaveBeenCalledTimes(1); + expect(promptMocks.confirm).toHaveBeenCalledTimes(2); const updateCalled = spawnCalls.some( (call) => call.command === "openclaw" && call.args.includes("update") && call.args.includes("--yes"), diff --git a/src/cli/bootstrap-external.test.ts b/src/cli/bootstrap-external.test.ts index 3a0e2f0ca5e..61590820a10 100644 --- a/src/cli/bootstrap-external.test.ts +++ b/src/cli/bootstrap-external.test.ts @@ -108,6 +108,30 @@ describe("bootstrap-external diagnostics", () => { } }); + it("passes agent-auth for Dench Cloud when apiKey is stored on the custom provider config", () => { + const dir = createTempStateDir(); + writeConfig(dir, { + agents: { defaults: { model: { primary: "dench-cloud/anthropic.claude-opus-4-6-v1" } } }, + models: { + providers: { + "dench-cloud": { + baseUrl: "https://gateway.merseoriginals.com/v1", + apiKey: "dench_cfg_key_123", + }, + }, + }, + }); + + try { + const diagnostics = buildBootstrapDiagnostics(baseParams(dir)); + const auth = getCheck(diagnostics, "agent-auth"); + expect(auth.status).toBe("pass"); + expect(auth.detail).toContain("Custom provider credentials"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("fails agent-auth when key exists for wrong provider (catches provider mismatch)", () => { const dir = createTempStateDir(); writeConfig(dir, { @@ -157,6 +181,19 @@ describe("bootstrap-external diagnostics", () => { expect(diagnostics.hasFailures).toBe(true); }); + it("surfaces actionable remediation for gateway scope failures", () => { + const diagnostics = buildBootstrapDiagnostics({ + ...baseParams(stateDir), + gatewayProbe: { ok: false as const, detail: "missing scope: operator.write" }, + }); + + const gateway = getCheck(diagnostics, "gateway"); + expect(gateway.status).toBe("fail"); + expect(String(gateway.remediation)).toContain("scope check failed"); + expect(String(gateway.remediation)).toContain("onboard"); + expect(String(gateway.remediation)).toContain("OPENCLAW_GATEWAY_PASSWORD"); + }); + it("includes break-glass guidance only for device signature/token mismatch failures", () => { const diagnostics = buildBootstrapDiagnostics({ ...baseParams(stateDir), @@ -267,6 +304,22 @@ describe("checkAgentAuth", () => { expect(result.detail).toContain("auth-profiles.json"); }); + it("returns ok when a custom provider apiKey exists in openclaw.json", () => { + writeConfig(stateDir, { + models: { + providers: { + "dench-cloud": { + apiKey: "dench_cfg_key_123", + }, + }, + }, + }); + + const result = checkAgentAuth(stateDir, "dench-cloud"); + expect(result.ok).toBe(true); + expect(result.detail).toContain("Custom provider credentials"); + }); + it("returns not ok when key exists for a different provider", () => { writeAuthProfiles(stateDir, { profiles: { @@ -366,6 +419,19 @@ describe("readExistingGatewayPort", () => { expect(readExistingGatewayPort(stateDir)).toBe(19001); }); + it("parses JSON5 config files with comments and trailing commas", () => { + writeFileSync( + path.join(stateDir, "openclaw.json"), + `{ + // json5 comment + gateway: { + port: 19007, + }, + }`, + ); + expect(readExistingGatewayPort(stateDir)).toBe(19007); + }); + it("rejects zero and negative ports (invalid port values)", () => { writeConfig(stateDir, { gateway: { port: 0 } }); expect(readExistingGatewayPort(stateDir)).toBeUndefined(); diff --git a/src/cli/bootstrap-external.ts b/src/cli/bootstrap-external.ts index 52c877cd7e9..d90e8f05dfc 100644 --- a/src/cli/bootstrap-external.ts +++ b/src/cli/bootstrap-external.ts @@ -1,8 +1,18 @@ import { spawn, type StdioOptions } from "node:child_process"; -import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { + cpSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; import path from "node:path"; import process from "node:process"; -import { confirm, isCancel, spinner } from "@clack/prompts"; +import { confirm, isCancel, select, spinner, text } from "@clack/prompts"; +import json5 from "json5"; import { isTruthyEnvValue } from "../infra/env.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { readTelemetryConfig, markNoticeShown } from "../telemetry/config.js"; @@ -10,6 +20,19 @@ import { track } from "../telemetry/telemetry.js"; import { stylePromptMessage } from "../terminal/prompt-style.js"; import { theme } from "../terminal/theme.js"; import { VERSION } from "../version.js"; +import { + buildDenchCloudConfigPatch, + DEFAULT_DENCH_CLOUD_GATEWAY_URL, + fetchDenchCloudCatalog, + formatDenchCloudModelHint, + normalizeDenchGatewayUrl, + readConfiguredDenchCloudSettings, + RECOMMENDED_DENCH_CLOUD_MODEL_ID, + resolveDenchCloudModel, + validateDenchCloudApiKey, + type DenchCloudCatalogLoadResult, + type DenchCloudCatalogModel, +} from "./dench-cloud.js"; import { applyCliProfileEnv } from "./profile.js"; import { DEFAULT_WEB_APP_PORT, @@ -68,6 +91,10 @@ export type BootstrapOptions = { json?: boolean; gatewayPort?: string | number; webPort?: string | number; + denchCloud?: boolean; + denchCloudApiKey?: string; + denchCloudModel?: string; + denchGatewayUrl?: string; }; type BootstrapSummary = { @@ -149,6 +176,26 @@ type GatewayAutoFixResult = { logExcerpts: GatewayLogExcerpt[]; }; +type BundledPluginSpec = { + pluginId: string; + sourceDirName: string; + enabled?: boolean; + config?: Record; +}; + +type BundledPluginSyncResult = { + installedPluginIds: string[]; + migratedLegacyDenchPlugin: boolean; +}; + +type DenchCloudBootstrapSelection = { + enabled: boolean; + apiKey?: string; + gatewayUrl?: string; + selectedModel?: string; + catalog?: DenchCloudCatalogLoadResult; +}; + function resolveCommandForPlatform(command: string): string { if (process.platform !== "win32") { return command; @@ -315,7 +362,7 @@ export function isPersistedPortAcceptable(port: number | undefined): port is num export function readExistingGatewayPort(stateDir: string): number | undefined { for (const name of ["openclaw.json", "config.json"]) { try { - const raw = JSON.parse(readFileSync(path.join(stateDir, name), "utf-8")) as { + const raw = json5.parse(readFileSync(path.join(stateDir, name), "utf-8")) as { gateway?: { port?: unknown }; }; const port = @@ -386,82 +433,218 @@ function resolveGatewayLaunchAgentLabel(profile: string): string { return `ai.openclaw.${normalized}`; } -async function installBundledPlugins(params: { +function uniqueStrings(values: string[]): string[] { + return [...new Set(values.map((value) => value.trim()).filter(Boolean))]; +} + +function asRecord(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function normalizeFilesystemPath(value: string): string { + try { + return realpathSync.native(value); + } catch { + return path.resolve(value); + } +} + +function readBundledPluginVersion(pluginDir: string): string | undefined { + const packageJsonPath = path.join(pluginDir, "package.json"); + if (!existsSync(packageJsonPath)) { + return undefined; + } + try { + const raw = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as { + version?: unknown; + }; + return typeof raw.version === "string" && raw.version.trim().length > 0 + ? raw.version.trim() + : undefined; + } catch { + return undefined; + } +} + +function readConfiguredPluginAllowlist(stateDir: string): string[] { + const raw = readBootstrapConfig(stateDir) as { + plugins?: { + allow?: unknown; + }; + } | undefined; + return Array.isArray(raw?.plugins?.allow) + ? raw.plugins.allow.filter((value): value is string => typeof value === "string") + : []; +} + +function readConfiguredPluginLoadPaths(stateDir: string): string[] { + const raw = readBootstrapConfig(stateDir) as { + plugins?: { + load?: { + paths?: unknown; + }; + }; + } | undefined; + return Array.isArray(raw?.plugins?.load?.paths) + ? raw.plugins.load.paths.filter((value): value is string => typeof value === "string") + : []; +} + +function isLegacyDenchCloudPluginPath(value: string): boolean { + return value.replaceAll("\\", "/").includes("/dench-cloud-provider"); +} + +async function setOpenClawConfigJson(params: { + openclawCommand: string; + profile: string; + key: string; + value: unknown; + errorMessage: string; +}): Promise { + await runOpenClawOrThrow({ + openclawCommand: params.openclawCommand, + args: [ + "--profile", + params.profile, + "config", + "set", + params.key, + JSON.stringify(params.value), + ], + timeoutMs: 30_000, + errorMessage: params.errorMessage, + }); +} + +async function syncBundledPlugins(params: { openclawCommand: string; profile: string; stateDir: string; - posthogKey: string; -}): Promise { + plugins: BundledPluginSpec[]; + restartGateway?: boolean; +}): Promise { try { - const pluginSrc = path.join(resolveCliPackageRoot(), "extensions", "posthog-analytics"); - if (!existsSync(pluginSrc)) return false; + const packageRoot = resolveCliPackageRoot(); + const installedPluginIds: string[] = []; + const rawConfig = readBootstrapConfig(params.stateDir) ?? {}; + const nextConfig = { + ...rawConfig, + }; + const pluginsConfig = { + ...asRecord(nextConfig.plugins), + }; + const loadConfig = { + ...asRecord(pluginsConfig.load), + }; + const installs = { + ...asRecord(pluginsConfig.installs), + }; + const entries = { + ...asRecord(pluginsConfig.entries), + }; + const currentAllow = readConfiguredPluginAllowlist(params.stateDir); + const currentLoadPaths = readConfiguredPluginLoadPaths(params.stateDir); + const nextAllow = currentAllow.filter( + (value) => value !== "dench-cloud-provider", + ); + const nextLoadPaths = currentLoadPaths.filter( + (value) => !isLegacyDenchCloudPluginPath(value), + ); + const legacyPluginDir = path.join(params.stateDir, "extensions", "dench-cloud-provider"); + const hadLegacyEntry = entries["dench-cloud-provider"] !== undefined; + const hadLegacyInstall = installs["dench-cloud-provider"] !== undefined; + delete entries["dench-cloud-provider"]; + delete installs["dench-cloud-provider"]; + const migratedLegacyDenchPlugin = + nextAllow.length !== currentAllow.length || + nextLoadPaths.length !== currentLoadPaths.length || + hadLegacyEntry || + hadLegacyInstall || + existsSync(legacyPluginDir); - const pluginDest = path.join(params.stateDir, "extensions", "posthog-analytics"); - mkdirSync(path.dirname(pluginDest), { recursive: true }); - cpSync(pluginSrc, pluginDest, { recursive: true, force: true }); + for (const plugin of params.plugins) { + const pluginSrc = path.join(packageRoot, "extensions", plugin.sourceDirName); + if (!existsSync(pluginSrc)) { + continue; + } - await runOpenClawOrThrow({ - openclawCommand: params.openclawCommand, - args: [ - "--profile", params.profile, - "config", "set", - "plugins.allow", '["posthog-analytics"]', - ], - timeoutMs: 30_000, - errorMessage: "Failed to set plugins.allow for posthog-analytics.", - }); + const pluginDest = path.join(params.stateDir, "extensions", plugin.sourceDirName); + mkdirSync(path.dirname(pluginDest), { recursive: true }); + cpSync(pluginSrc, pluginDest, { recursive: true, force: true }); + const normalizedPluginSrc = normalizeFilesystemPath(pluginSrc); + const normalizedPluginDest = normalizeFilesystemPath(pluginDest); + nextAllow.push(plugin.pluginId); + nextLoadPaths.push(normalizedPluginDest); + installedPluginIds.push(plugin.pluginId); - await runOpenClawOrThrow({ - openclawCommand: params.openclawCommand, - args: [ - "--profile", params.profile, - "config", "set", - "plugins.load.paths", JSON.stringify([pluginDest]), - ], - timeoutMs: 30_000, - errorMessage: "Failed to set plugins.load.paths for posthog-analytics.", - }); + const existingEntry = { + ...asRecord(entries[plugin.pluginId]), + }; + if (plugin.enabled !== undefined) { + existingEntry.enabled = plugin.enabled; + } + if (plugin.config && Object.keys(plugin.config).length > 0) { + existingEntry.config = { + ...asRecord(existingEntry.config), + ...plugin.config, + }; + } + if (Object.keys(existingEntry).length > 0) { + entries[plugin.pluginId] = existingEntry; + } - if (params.posthogKey) { - await runOpenClawOrThrow({ - openclawCommand: params.openclawCommand, - args: [ - "--profile", params.profile, - "config", "set", - "plugins.entries.posthog-analytics.enabled", "true", - ], - timeoutMs: 30_000, - errorMessage: "Failed to enable posthog-analytics plugin.", - }); - await runOpenClawOrThrow({ - openclawCommand: params.openclawCommand, - args: [ - "--profile", params.profile, - "config", "set", - "plugins.entries.posthog-analytics.config.apiKey", params.posthogKey, - ], - timeoutMs: 30_000, - errorMessage: "Failed to set posthog-analytics API key.", - }); + const installRecord: Record = { + source: "path", + sourcePath: normalizedPluginSrc, + installPath: normalizedPluginDest, + installedAt: new Date().toISOString(), + }; + const version = readBundledPluginVersion(pluginSrc); + if (version) { + installRecord.version = version; + } + installs[plugin.pluginId] = installRecord; } - // Restart the gateway so it loads the new/updated plugin. - // On first bootstrap the gateway isn't running yet, so this - // is a harmless no-op caught by the outer try/catch. - try { - await runOpenClawOrThrow({ - openclawCommand: params.openclawCommand, - args: ["--profile", params.profile, "gateway", "restart"], - timeoutMs: 60_000, - errorMessage: "Failed to restart gateway after plugin install.", - }); - } catch { - // Gateway may not be running yet (first bootstrap) — ignore. + pluginsConfig.allow = uniqueStrings(nextAllow); + loadConfig.paths = uniqueStrings(nextLoadPaths); + pluginsConfig.load = loadConfig; + pluginsConfig.entries = entries; + pluginsConfig.installs = installs; + nextConfig.plugins = pluginsConfig; + writeFileSync( + path.join(params.stateDir, "openclaw.json"), + `${JSON.stringify(nextConfig, null, 2)}\n`, + ); + + if (migratedLegacyDenchPlugin) { + rmSync(legacyPluginDir, { recursive: true, force: true }); } - return true; + if (params.restartGateway) { + try { + await runOpenClawOrThrow({ + openclawCommand: params.openclawCommand, + args: ["--profile", params.profile, "gateway", "restart"], + timeoutMs: 60_000, + errorMessage: "Failed to restart gateway after plugin install.", + }); + } catch { + // Gateway may not be running yet (first bootstrap) — ignore. + } + } + + return { + installedPluginIds, + migratedLegacyDenchPlugin, + }; } catch { - return false; + return { + installedPluginIds: [], + migratedLegacyDenchPlugin: false, + }; } } @@ -1251,6 +1434,13 @@ function remediationForGatewayFailure( `Last resort (security downgrade): \`openclaw --profile ${profile} config set gateway.controlUi.dangerouslyDisableDeviceAuth true\`. Revert after recovery: \`openclaw --profile ${profile} config set gateway.controlUi.dangerouslyDisableDeviceAuth false\`.`, ].join(" "); } + if (normalized.includes("missing scope")) { + return [ + `Gateway scope check failed (${detail}).`, + `Re-run \`openclaw --profile ${profile} onboard --install-daemon --reset\` to re-pair with full operator scopes.`, + `If the problem persists, set OPENCLAW_GATEWAY_PASSWORD and restart the web runtime.`, + ].join(" "); + } if ( normalized.includes("unauthorized") || normalized.includes("token") || @@ -1308,7 +1498,7 @@ function readBootstrapConfig(stateDir: string): Record | undefi continue; } try { - const raw = JSON.parse(readFileSync(configPath, "utf-8")); + const raw = json5.parse(readFileSync(configPath, "utf-8")); if (raw && typeof raw === "object") { return raw as Record; } @@ -1348,6 +1538,25 @@ export function checkAgentAuth( if (!provider) { return { ok: false, detail: "No model provider configured." }; } + const rawConfig = readBootstrapConfig(stateDir) as { + models?: { + providers?: Record; + }; + } | undefined; + const customProvider = rawConfig?.models?.providers?.[provider]; + if (customProvider && typeof customProvider === "object") { + const apiKey = (customProvider as Record).apiKey; + if ( + (typeof apiKey === "string" && apiKey.trim().length > 0) || + (apiKey && typeof apiKey === "object") + ) { + return { + ok: true, + provider, + detail: `Custom provider credentials configured for ${provider}.`, + }; + } + } const authPath = path.join(stateDir, "agents", "main", "agent", "auth-profiles.json"); if (!existsSync(authPath)) { return { @@ -1357,7 +1566,7 @@ export function checkAgentAuth( }; } try { - const raw = JSON.parse(readFileSync(authPath, "utf-8")); + const raw = json5.parse(readFileSync(authPath, "utf-8")); const profiles = raw?.profiles; if (!profiles || typeof profiles !== "object") { return { ok: false, provider, detail: `auth-profiles.json has no profiles configured.` }; @@ -1576,6 +1785,287 @@ function logBootstrapChecklist(diagnostics: BootstrapDiagnostics, runtime: Runti } } +function isExplicitDenchCloudRequest(opts: BootstrapOptions): boolean { + return Boolean( + opts.denchCloud || + opts.denchCloudApiKey?.trim() || + opts.denchCloudModel?.trim() || + opts.denchGatewayUrl?.trim(), + ); +} + +function resolveDenchCloudApiKeyCandidate(params: { + opts: BootstrapOptions; + existingApiKey?: string; +}): string | undefined { + return ( + params.opts.denchCloudApiKey?.trim() || + process.env.DENCH_CLOUD_API_KEY?.trim() || + process.env.DENCH_API_KEY?.trim() || + params.existingApiKey?.trim() + ); +} + +async function promptForDenchCloudApiKey(initialValue?: string): Promise { + const value = await text({ + message: stylePromptMessage( + "Enter your Dench Cloud API key (sign up at dench.com and get it at dench.com/settings)", + ), + ...(initialValue ? { initialValue } : {}), + validate: (input) => (input?.trim().length ? undefined : "API key is required."), + }); + if (isCancel(value)) { + return undefined; + } + return String(value).trim(); +} + +async function promptForDenchCloudModel(params: { + models: DenchCloudCatalogModel[]; + initialStableId?: string; +}): Promise { + const sorted = [...params.models].sort((a, b) => { + const aRec = a.id === RECOMMENDED_DENCH_CLOUD_MODEL_ID ? 0 : 1; + const bRec = b.id === RECOMMENDED_DENCH_CLOUD_MODEL_ID ? 0 : 1; + return aRec - bRec; + }); + const selection = await select({ + message: stylePromptMessage("Choose your default Dench Cloud model"), + options: sorted.map((model) => ({ + value: model.stableId, + label: model.displayName, + hint: formatDenchCloudModelHint(model), + })), + ...(params.initialStableId ? { initialValue: params.initialStableId } : {}), + }); + if (isCancel(selection)) { + return undefined; + } + return String(selection); +} + +async function applyDenchCloudBootstrapConfig(params: { + openclawCommand: string; + profile: string; + stateDir: string; + gatewayUrl: string; + apiKey: string; + catalog: DenchCloudCatalogLoadResult; + selectedModel: string; +}): Promise { + const raw = readBootstrapConfig(params.stateDir) as { + agents?: { + defaults?: { + models?: unknown; + }; + }; + } | undefined; + const existingAgentModels = + raw?.agents?.defaults?.models && typeof raw.agents.defaults.models === "object" + ? (raw.agents.defaults.models as Record) + : {}; + const configPatch = buildDenchCloudConfigPatch({ + gatewayUrl: params.gatewayUrl, + apiKey: params.apiKey, + models: params.catalog.models, + }); + const nextAgentModels = { + ...existingAgentModels, + ...((configPatch.agents?.defaults?.models as Record | undefined) ?? {}), + }; + + await runOpenClawOrThrow({ + openclawCommand: params.openclawCommand, + args: ["--profile", params.profile, "config", "set", "models.mode", "merge"], + timeoutMs: 30_000, + errorMessage: "Failed to set models.mode=merge for Dench Cloud.", + }); + + await setOpenClawConfigJson({ + openclawCommand: params.openclawCommand, + profile: params.profile, + key: "models.providers.dench-cloud", + value: configPatch.models.providers["dench-cloud"], + errorMessage: "Failed to configure models.providers.dench-cloud.", + }); + + await runOpenClawOrThrow({ + openclawCommand: params.openclawCommand, + args: [ + "--profile", + params.profile, + "config", + "set", + "agents.defaults.model.primary", + `dench-cloud/${params.selectedModel}`, + ], + timeoutMs: 30_000, + errorMessage: "Failed to set the default Dench Cloud model.", + }); + + await setOpenClawConfigJson({ + openclawCommand: params.openclawCommand, + profile: params.profile, + key: "agents.defaults.models", + value: nextAgentModels, + errorMessage: "Failed to update agents.defaults.models for Dench Cloud.", + }); +} + +async function resolveDenchCloudBootstrapSelection(params: { + opts: BootstrapOptions; + nonInteractive: boolean; + stateDir: string; + runtime: RuntimeEnv; +}): Promise { + const rawConfig = readBootstrapConfig(params.stateDir); + const existing = readConfiguredDenchCloudSettings(rawConfig); + const explicitRequest = isExplicitDenchCloudRequest(params.opts); + const currentProvider = resolveModelProvider(params.stateDir); + const existingDenchConfigured = currentProvider === "dench-cloud" && Boolean(existing.apiKey); + const gatewayUrl = normalizeDenchGatewayUrl( + params.opts.denchGatewayUrl?.trim() || + process.env.DENCH_GATEWAY_URL?.trim() || + existing.gatewayUrl || + DEFAULT_DENCH_CLOUD_GATEWAY_URL, + ); + + if (params.nonInteractive) { + if (!explicitRequest && !existingDenchConfigured) { + return { enabled: false }; + } + + const apiKey = resolveDenchCloudApiKeyCandidate({ + opts: params.opts, + existingApiKey: existing.apiKey, + }); + if (!apiKey) { + throw new Error( + "Dench Cloud bootstrap requires --dench-cloud-api-key or DENCH_CLOUD_API_KEY in non-interactive mode.", + ); + } + + await validateDenchCloudApiKey(gatewayUrl, apiKey); + const catalog = await fetchDenchCloudCatalog(gatewayUrl); + const selected = resolveDenchCloudModel( + catalog.models, + params.opts.denchCloudModel?.trim() || + process.env.DENCH_CLOUD_MODEL?.trim() || + existing.selectedModel, + ); + if (!selected) { + throw new Error("Configured Dench Cloud model is not available."); + } + + return { + enabled: true, + apiKey, + gatewayUrl, + selectedModel: selected.stableId, + catalog, + }; + } + + const wantsDenchCloud = explicitRequest + ? true + : await confirm({ + message: stylePromptMessage( + "Use Dench API Key for inference? Sign up on dench.com and get your API key at dench.com/settings.", + ), + initialValue: existingDenchConfigured || !currentProvider, + }); + if (isCancel(wantsDenchCloud) || !wantsDenchCloud) { + return { enabled: false }; + } + + let apiKey = resolveDenchCloudApiKeyCandidate({ + opts: params.opts, + existingApiKey: existing.apiKey, + }); + const showSpinners = !params.opts.json; + + while (true) { + apiKey = await promptForDenchCloudApiKey(apiKey); + if (!apiKey) { + throw new Error("Dench Cloud setup cancelled before an API key was provided."); + } + + const keySpinner = showSpinners ? spinner() : null; + keySpinner?.start("Validating API key…"); + try { + await validateDenchCloudApiKey(gatewayUrl, apiKey); + keySpinner?.stop("API key is valid."); + } catch (error) { + keySpinner?.stop("API key validation failed."); + params.runtime.log(theme.warn(error instanceof Error ? error.message : String(error))); + const retry = await confirm({ + message: stylePromptMessage("Try another Dench Cloud API key?"), + initialValue: true, + }); + if (isCancel(retry) || !retry) { + throw error instanceof Error ? error : new Error(String(error)); + } + continue; + } + + const catalogSpinner = showSpinners ? spinner() : null; + catalogSpinner?.start("Fetching available models…"); + const catalog = await fetchDenchCloudCatalog(gatewayUrl); + if (catalog.source === "fallback") { + catalogSpinner?.stop( + `Model catalog fallback active (${catalog.detail ?? "public catalog unavailable"}).`, + ); + } else { + catalogSpinner?.stop("Models loaded."); + } + + const explicitModel = params.opts.denchCloudModel?.trim() || process.env.DENCH_CLOUD_MODEL?.trim(); + const preselected = resolveDenchCloudModel(catalog.models, explicitModel || existing.selectedModel); + if (!preselected && explicitModel) { + params.runtime.log(theme.warn(`Configured Dench Cloud model "${explicitModel}" is unavailable.`)); + } + const selection = await promptForDenchCloudModel({ + models: catalog.models, + initialStableId: preselected?.stableId || existing.selectedModel, + }); + if (!selection) { + throw new Error("Dench Cloud setup cancelled during model selection."); + } + const selected = resolveDenchCloudModel(catalog.models, selection); + if (!selected) { + throw new Error("No Dench Cloud model could be selected."); + } + + const verifySpinner = showSpinners ? spinner() : null; + verifySpinner?.start("Verifying Dench Cloud configuration…"); + try { + await validateDenchCloudApiKey(gatewayUrl, apiKey); + verifySpinner?.stop("Dench Cloud ready."); + } catch (error) { + verifySpinner?.stop("Verification failed."); + params.runtime.log( + theme.warn(error instanceof Error ? error.message : String(error)), + ); + const retry = await confirm({ + message: stylePromptMessage("Re-enter your Dench Cloud API key?"), + initialValue: true, + }); + if (isCancel(retry) || !retry) { + throw error instanceof Error ? error : new Error(String(error)); + } + continue; + } + + return { + enabled: true, + apiKey, + gatewayUrl, + selectedModel: selected.stableId, + catalog, + }; + } +} + async function shouldRunUpdate(params: { opts: BootstrapOptions; runtime: RuntimeEnv; @@ -1684,6 +2174,15 @@ export async function bootstrapCommand( // port, or find an available one in the DenchClaw range (19001+). // NEVER claim OpenClaw's default port (18789) — that belongs to the host // OpenClaw installation and sharing it causes port-hijack on restart. + // + // When a persisted port exists, trust it unconditionally — the process + // occupying it is almost certainly our own gateway from a previous run. + // The onboard step will stop/replace the existing daemon on the same profile. + // Only scan for a free port on first run (no persisted port) when 19001 is + // occupied by something external. + const preCloudSpinner = !opts.json ? spinner() : null; + preCloudSpinner?.start("Preparing gateway configuration…"); + const explicitPort = parseOptionalPort(opts.gatewayPort); let gatewayPort: number; let portAutoAssigned = false; @@ -1692,19 +2191,18 @@ export async function bootstrapCommand( gatewayPort = explicitPort; } else { const existingPort = readExistingGatewayPort(stateDir); - if ( - isPersistedPortAcceptable(existingPort) && - (await isPortAvailable(existingPort)) - ) { + if (isPersistedPortAcceptable(existingPort)) { gatewayPort = existingPort; } else if (await isPortAvailable(DENCHCLAW_GATEWAY_PORT_START)) { gatewayPort = DENCHCLAW_GATEWAY_PORT_START; } else { + preCloudSpinner?.message("Scanning for available port…"); const availablePort = await findAvailablePort( DENCHCLAW_GATEWAY_PORT_START + 1, MAX_PORT_SCAN_ATTEMPTS, ); if (!availablePort) { + preCloudSpinner?.stop("Port scan failed."); throw new Error( `Could not find an available gateway port between ${DENCHCLAW_GATEWAY_PORT_START} and ${DENCHCLAW_GATEWAY_PORT_START + MAX_PORT_SCAN_ATTEMPTS}. ` + `Please specify a port explicitly with --gateway-port.`, @@ -1725,28 +2223,97 @@ export async function bootstrapCommand( // Pin OpenClaw to the managed default workspace before onboarding so bootstrap // never drifts into creating/using legacy workspace-* paths. + preCloudSpinner?.message("Configuring default workspace…"); await ensureDefaultWorkspacePath(openclawCommand, profile, workspaceDir); - const packageRoot = resolveCliPackageRoot(); + preCloudSpinner?.stop("Gateway ready."); - // Install bundled plugins BEFORE onboard so the gateway daemon starts with - // plugins.allow already configured, suppressing "plugins.allow is empty" warnings. - const posthogPluginInstalled = await installBundledPlugins({ + const denchCloudSelection = await resolveDenchCloudBootstrapSelection({ + opts, + nonInteractive, + stateDir, + runtime, + }); + + const packageRoot = resolveCliPackageRoot(); + const managedBundledPlugins: BundledPluginSpec[] = [ + { + pluginId: "posthog-analytics", + sourceDirName: "posthog-analytics", + ...(process.env.POSTHOG_KEY + ? { + enabled: true, + config: { + apiKey: process.env.POSTHOG_KEY, + }, + } + : {}), + }, + { + pluginId: "dench-ai-gateway", + sourceDirName: "dench-ai-gateway", + enabled: true, + config: { + gatewayUrl: + denchCloudSelection.gatewayUrl || + opts.denchGatewayUrl?.trim() || + process.env.DENCH_GATEWAY_URL?.trim() || + DEFAULT_DENCH_CLOUD_GATEWAY_URL, + }, + }, + ]; + + // Trust managed bundled plugins BEFORE onboard so the gateway daemon never + // starts with transient "untracked local plugin" warnings for DenchClaw-owned + // extensions. + const preOnboardSpinner = !opts.json ? spinner() : null; + preOnboardSpinner?.start("Syncing bundled plugins…"); + const preOnboardPlugins = await syncBundledPlugins({ openclawCommand, profile, stateDir, - posthogKey: process.env.POSTHOG_KEY || "", + plugins: managedBundledPlugins, + restartGateway: true, }); + const posthogPluginInstalled = preOnboardPlugins.installedPluginIds.includes("posthog-analytics"); // Ensure gateway.mode=local BEFORE onboard so the daemon starts successfully. // Previously this ran post-onboard, but onboard --install-daemon starts the // gateway immediately — if gateway.mode is unset at that point the daemon // blocks with "set gateway.mode=local" and enters a crash loop. + preOnboardSpinner?.message("Configuring gateway…"); await ensureGatewayModeLocal(openclawCommand, profile); // Persist the assigned port so the daemon binds to the correct port on first // start rather than falling back to the default. await ensureGatewayPort(openclawCommand, profile, gatewayPort); + // Push plugin trust through the CLI as the LAST config step before onboard. + // syncBundledPlugins writes plugins.allow / plugins.load.paths to the raw + // JSON file, but subsequent `openclaw config set` calls may clobber them. + // Re-applying via the CLI ensures OpenClaw's own config resolution sees them. + if (preOnboardPlugins.installedPluginIds.length > 0) { + preOnboardSpinner?.message("Trusting managed plugins…"); + await setOpenClawConfigJson({ + openclawCommand, + profile, + key: "plugins.allow", + value: preOnboardPlugins.installedPluginIds, + errorMessage: "Failed to set plugins.allow for managed plugins.", + }); + const pluginLoadPaths = managedBundledPlugins.map((plugin) => + normalizeFilesystemPath(path.join(stateDir, "extensions", plugin.sourceDirName)), + ); + await setOpenClawConfigJson({ + openclawCommand, + profile, + key: "plugins.load.paths", + value: pluginLoadPaths, + errorMessage: "Failed to set plugins.load.paths for managed plugins.", + }); + } + + preOnboardSpinner?.stop("Ready to onboard."); + const onboardArgv = [ "--profile", profile, @@ -1763,6 +2330,9 @@ export async function bootstrapCommand( if (nonInteractive) { onboardArgv.push("--non-interactive"); } + if (denchCloudSelection.enabled) { + onboardArgv.push("--auth-choice", "skip"); + } onboardArgv.push("--accept-risk", "--skip-ui"); @@ -1800,6 +2370,34 @@ export async function bootstrapCommand( // messaging-only, so enforce this on every bootstrap run. await ensureToolsProfile(openclawCommand, profile); + if ( + denchCloudSelection.enabled && + denchCloudSelection.apiKey && + denchCloudSelection.gatewayUrl && + denchCloudSelection.selectedModel && + denchCloudSelection.catalog + ) { + postOnboardSpinner?.message("Applying Dench Cloud model config…"); + await applyDenchCloudBootstrapConfig({ + openclawCommand, + profile, + stateDir, + gatewayUrl: denchCloudSelection.gatewayUrl, + apiKey: denchCloudSelection.apiKey, + catalog: denchCloudSelection.catalog, + selectedModel: denchCloudSelection.selectedModel, + }); + } + + postOnboardSpinner?.message("Refreshing managed plugin config…"); + await syncBundledPlugins({ + openclawCommand, + profile, + stateDir, + plugins: managedBundledPlugins, + restartGateway: true, + }); + postOnboardSpinner?.message("Configuring subagent defaults…"); await ensureSubagentDefaults(openclawCommand, profile); diff --git a/src/cli/program/register.bootstrap.ts b/src/cli/program/register.bootstrap.ts index 750ce63aadf..282015a3fd9 100644 --- a/src/cli/program/register.bootstrap.ts +++ b/src/cli/program/register.bootstrap.ts @@ -17,6 +17,10 @@ export function registerBootstrapCommand(program: Command) { .option("--update-now", "Run OpenClaw update before onboarding", false) .option("--gateway-port ", "Gateway port override for first-run onboarding") .option("--web-port ", "Preferred web UI port (default: 3100)") + .option("--dench-cloud", "Configure Dench Cloud and skip OpenClaw provider onboarding", false) + .option("--dench-cloud-api-key ", "Dench Cloud API key for bootstrap-driven setup") + .option("--dench-cloud-model ", "Stable or public Dench Cloud model id to use as default") + .option("--dench-gateway-url ", "Override the Dench Cloud gateway base URL") .option("--no-open", "Do not open the browser automatically") .option("--json", "Output summary as JSON", false) .addHelpText( @@ -35,6 +39,10 @@ export function registerBootstrapCommand(program: Command) { updateNow: Boolean(opts.updateNow), gatewayPort: opts.gatewayPort as string | undefined, webPort: opts.webPort as string | undefined, + denchCloud: opts.denchCloud ? true : undefined, + denchCloudApiKey: opts.denchCloudApiKey as string | undefined, + denchCloudModel: opts.denchCloudModel as string | undefined, + denchGatewayUrl: opts.denchGatewayUrl as string | undefined, noOpen: Boolean(opts.open === false), json: Boolean(opts.json), }); diff --git a/src/cli/web-runtime.ts b/src/cli/web-runtime.ts index fb614ae987c..2c1605b1e5a 100644 --- a/src/cli/web-runtime.ts +++ b/src/cli/web-runtime.ts @@ -803,6 +803,14 @@ export function startManagedWebRuntime(params: { const outFd = openSync(path.join(logsDir, "web-app.log"), "a"); const errFd = openSync(path.join(logsDir, "web-app.err.log"), "a"); + const gatewayAuthEnv: Record = {}; + for (const key of ["OPENCLAW_GATEWAY_TOKEN", "OPENCLAW_GATEWAY_PASSWORD"] as const) { + const value = params.env?.[key] ?? process.env[key]; + if (value) { + gatewayAuthEnv[key] = value; + } + } + const child = spawn(process.execPath, [runtimeServerPath], { cwd: path.dirname(runtimeServerPath), detached: true, @@ -810,6 +818,7 @@ export function startManagedWebRuntime(params: { env: { ...process.env, ...params.env, + ...gatewayAuthEnv, PORT: String(params.port), HOSTNAME: "127.0.0.1", OPENCLAW_GATEWAY_PORT: String(params.gatewayPort),