Merge pull request #104 from DenchHQ/bp/3-dench-cloud-bootstrap

feat(cli): integrate Dench Cloud setup into bootstrap flow
This commit is contained in:
Kumar Abhirup 2026-03-15 04:20:42 -07:00 committed by GitHub
commit c89c3d8be2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 1171 additions and 84 deletions

View File

@ -12,7 +12,12 @@ const promptMocks = vi.hoisted(() => {
return {
cancelSignal,
confirmDecision: false as boolean | symbol,
confirmDecisions: [] as Array<boolean | symbol>,
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<string, unknown> = 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<string, unknown>;
}
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"),

View File

@ -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();

View File

@ -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<string, string | boolean>;
};
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<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: 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<void> {
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<boolean> {
plugins: BundledPluginSpec[];
restartGateway?: boolean;
}): Promise<BundledPluginSyncResult> {
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<string, unknown> = {
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<string, unknown> | 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<string, unknown>;
}
@ -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<string, unknown>;
};
} | undefined;
const customProvider = rawConfig?.models?.providers?.[provider];
if (customProvider && typeof customProvider === "object") {
const apiKey = (customProvider as Record<string, unknown>).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<string | undefined> {
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<string | undefined> {
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<void> {
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<string, unknown>)
: {};
const configPatch = buildDenchCloudConfigPatch({
gatewayUrl: params.gatewayUrl,
apiKey: params.apiKey,
models: params.catalog.models,
});
const nextAgentModels = {
...existingAgentModels,
...((configPatch.agents?.defaults?.models as Record<string, unknown> | 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<DenchCloudBootstrapSelection> {
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);

View File

@ -17,6 +17,10 @@ export function registerBootstrapCommand(program: Command) {
.option("--update-now", "Run OpenClaw update before onboarding", false)
.option("--gateway-port <port>", "Gateway port override for first-run onboarding")
.option("--web-port <port>", "Preferred web UI port (default: 3100)")
.option("--dench-cloud", "Configure Dench Cloud and skip OpenClaw provider onboarding", false)
.option("--dench-cloud-api-key <key>", "Dench Cloud API key for bootstrap-driven setup")
.option("--dench-cloud-model <id>", "Stable or public Dench Cloud model id to use as default")
.option("--dench-gateway-url <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),
});

View File

@ -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<string, string> = {};
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),