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:
commit
c89c3d8be2
@ -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"),
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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),
|
||||
});
|
||||
|
||||
@ -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),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user