diff --git a/src/cli/bootstrap-external.bootstrap-command.test.ts b/src/cli/bootstrap-external.bootstrap-command.test.ts index 6cf77bf6afd..371c43074b1 100644 --- a/src/cli/bootstrap-external.bootstrap-command.test.ts +++ b/src/cli/bootstrap-external.bootstrap-command.test.ts @@ -47,7 +47,7 @@ function createWebProfilesResponse(params?: { payload?: { profiles?: unknown[]; activeProfile?: string }; }): Response { const status = params?.status ?? 200; - const payload = params?.payload ?? { profiles: [], activeProfile: "ironclaw" }; + const payload = params?.payload ?? { profiles: [], activeProfile: "dench" }; return { status, json: async () => payload, @@ -56,12 +56,13 @@ function createWebProfilesResponse(params?: { function createTempStateDir(): string { const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}`; - const dir = path.join(os.tmpdir(), `ironclaw-bootstrap-${suffix}`); + const dir = path.join(os.tmpdir(), `denchclaw-bootstrap-${suffix}`); mkdirSync(dir, { recursive: true }); return dir; } function writeBootstrapFixtures(stateDir: string): void { + mkdirSync(stateDir, { recursive: true }); const config = { agents: { defaults: { @@ -137,6 +138,7 @@ async function withForcedStdinTty(isTTY: boolean, fn: () => Promise): Prom describe("bootstrapCommand always-onboard behavior", () => { const originalEnv = { ...process.env }; const spawnMock = vi.mocked(spawn); + let homeDir = ""; let stateDir = ""; let spawnCalls: SpawnCall[] = []; let fetchMock: ReturnType; @@ -148,7 +150,8 @@ describe("bootstrapCommand always-onboard behavior", () => { let alwaysHealthFail = false; beforeEach(() => { - stateDir = createTempStateDir(); + homeDir = createTempStateDir(); + stateDir = path.join(homeDir, ".openclaw-dench"); writeBootstrapFixtures(stateDir); spawnCalls = []; forceGlobalMissing = false; @@ -158,7 +161,10 @@ describe("bootstrapCommand always-onboard behavior", () => { alwaysHealthFail = false; process.env = { ...originalEnv, - OPENCLAW_PROFILE: "ironclaw", + HOME: homeDir, + USERPROFILE: homeDir, + OPENCLAW_HOME: homeDir, + OPENCLAW_PROFILE: "dench", OPENCLAW_STATE_DIR: stateDir, VITEST: "true", }; @@ -256,7 +262,7 @@ describe("bootstrapCommand always-onboard behavior", () => { afterEach(() => { process.env = originalEnv; - rmSync(stateDir, { recursive: true, force: true }); + rmSync(homeDir || stateDir, { recursive: true, force: true }); vi.unstubAllGlobals(); vi.restoreAllMocks(); }); @@ -284,7 +290,7 @@ describe("bootstrapCommand always-onboard behavior", () => { expect(onboardCalls[0]?.args).toEqual( expect.arrayContaining([ "--profile", - "ironclaw", + "dench", "onboard", "--install-daemon", "--non-interactive", @@ -296,13 +302,13 @@ describe("bootstrapCommand always-onboard behavior", () => { expect(summary.onboarded).toBe(true); }); - it("accepts bootstrap --profile and propagates it to onboard subprocesses", async () => { + it("ignores bootstrap --profile override and keeps dench profile (prevents profile drift)", async () => { const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), exit: vi.fn(), }; - process.env.OPENCLAW_PROFILE = "ironclaw"; + process.env.OPENCLAW_PROFILE = "dench"; const summary = await bootstrapCommand( { @@ -317,8 +323,9 @@ describe("bootstrapCommand always-onboard behavior", () => { const onboardCall = spawnCalls.find( (call) => call.command === "openclaw" && call.args.includes("onboard"), ); - expect(onboardCall?.args).toEqual(expect.arrayContaining(["--profile", "team-a"])); - expect(summary.profile).toBe("team-a"); + expect(onboardCall?.args).toEqual(expect.arrayContaining(["--profile", "dench"])); + expect(onboardCall?.args.includes("team-a")).toBe(false); + expect(summary.profile).toBe("dench"); }); it("adds --reset to onboarding args when --force-onboard is requested", async () => { @@ -485,8 +492,8 @@ describe("bootstrapCommand always-onboard behavior", () => { expect(summary.workspaceSeed?.reason).toBe("already-exists"); expect(readFileSync(workspaceDbPath, "utf-8")).toBe("existing-db-content"); const identityContent = readFileSync(identityPath, "utf-8"); - expect(identityContent).toContain("You are **Ironclaw**"); - expect(identityContent).toContain("~skills/crm/SKILL.md"); + expect(identityContent).toContain("You are **DenchClaw**"); + expect(identityContent).toContain(path.join(workspaceDir, "skills", "crm", "SKILL.md")); expect(identityContent).not.toContain("# stale identity"); }); @@ -529,18 +536,18 @@ describe("bootstrapCommand always-onboard behavior", () => { const identityPath = path.join(managedWorkspace, "IDENTITY.md"); expect(existsSync(identityPath)).toBe(true); const identityContent = readFileSync(identityPath, "utf-8"); - expect(identityContent).toContain("You are **Ironclaw**"); - expect(identityContent).toContain("~skills/crm/SKILL.md"); + expect(identityContent).toContain("You are **DenchClaw**"); + expect(identityContent).toContain(path.join(managedWorkspace, "skills", "crm", "SKILL.md")); }); - it("installs CRM skill into managed profile skills directory (keeps it out of editable workspace)", async () => { + it("installs CRM skill into managed workspace skills directory (prevents state-root drift)", async () => { const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), exit: vi.fn(), }; - const targetSkill = path.join(stateDir, "skills", "crm", "SKILL.md"); - const workspaceSkill = path.join(stateDir, "workspace", "skills", "crm", "SKILL.md"); + const targetSkill = path.join(stateDir, "workspace", "skills", "crm", "SKILL.md"); + const legacySkill = path.join(stateDir, "skills", "crm", "SKILL.md"); expect(existsSync(targetSkill)).toBe(false); await bootstrapCommand( @@ -553,7 +560,7 @@ describe("bootstrapCommand always-onboard behavior", () => { ); expect(existsSync(targetSkill)).toBe(true); - expect(existsSync(workspaceSkill)).toBe(false); + expect(existsSync(legacySkill)).toBe(false); expect(readFileSync(targetSkill, "utf-8")).toContain("name: database-crm-system"); }); @@ -563,7 +570,7 @@ describe("bootstrapCommand always-onboard behavior", () => { error: vi.fn(), exit: vi.fn(), }; - const targetDir = path.join(stateDir, "skills", "crm"); + const targetDir = path.join(stateDir, "workspace", "skills", "crm"); const targetSkill = path.join(targetDir, "SKILL.md"); mkdirSync(targetDir, { recursive: true }); writeFileSync(targetSkill, "name: crm\n# custom\n"); @@ -609,17 +616,83 @@ describe("bootstrapCommand always-onboard behavior", () => { expect(workspaceConfigSetCalls.length).toBeGreaterThan(0); const lastArgs = workspaceConfigSetCalls.at(-1)?.args ?? []; expect(lastArgs).toEqual( - expect.arrayContaining([ - "--profile", - "ironclaw", - "config", - "set", - "agents.defaults.workspace", - ]), + expect.arrayContaining(["--profile", "dench", "config", "set", "agents.defaults.workspace"]), ); const configuredWorkspace = lastArgs.at(-1) ?? ""; - expect(configuredWorkspace).toContain(path.join(".openclaw-ironclaw", "workspace")); - expect(configuredWorkspace).not.toContain("workspace-ironclaw"); + expect(configuredWorkspace).toContain(path.join(".openclaw-dench", "workspace")); + expect(configuredWorkspace).not.toContain("workspace-dench"); + }); + + it("forces tools.profile to full during bootstrap (prevents messaging-only tool drift)", async () => { + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + await bootstrapCommand( + { + nonInteractive: true, + noOpen: true, + skipUpdate: true, + }, + runtime, + ); + + const toolsProfileSetCalls = spawnCalls.filter( + (call) => + call.command === "openclaw" && + call.args.includes("config") && + call.args.includes("set") && + call.args.includes("tools.profile"), + ); + + expect(toolsProfileSetCalls.length).toBeGreaterThan(0); + const lastArgs = toolsProfileSetCalls.at(-1)?.args ?? []; + expect(lastArgs).toEqual( + expect.arrayContaining(["--profile", "dench", "config", "set", "tools.profile", "full"]), + ); + expect(lastArgs).not.toContain("messaging"); + }); + + it("reapplies tools.profile full on repeated bootstrap runs (setup/restart safety)", async () => { + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + await bootstrapCommand( + { + nonInteractive: true, + noOpen: true, + skipUpdate: true, + }, + runtime, + ); + await bootstrapCommand( + { + nonInteractive: true, + noOpen: true, + skipUpdate: true, + }, + runtime, + ); + + const toolsProfileSetCalls = spawnCalls.filter( + (call) => + call.command === "openclaw" && + call.args.includes("config") && + call.args.includes("set") && + call.args.includes("tools.profile"), + ); + + expect(toolsProfileSetCalls).toHaveLength(2); + for (const call of toolsProfileSetCalls) { + expect(call.args).toEqual( + expect.arrayContaining(["--profile", "dench", "config", "set", "tools.profile", "full"]), + ); + } }); it("keeps CRM in managed skills even when workspace path is custom", async () => { @@ -641,8 +714,8 @@ describe("bootstrapCommand always-onboard behavior", () => { gateway: { mode: "local" }, }), ); - const managedSkill = path.join(stateDir, "skills", "crm", "SKILL.md"); - const workspaceSkill = path.join(customWorkspace, "skills", "crm", "SKILL.md"); + const managedWorkspaceSkill = path.join(stateDir, "workspace", "skills", "crm", "SKILL.md"); + const customWorkspaceSkill = path.join(customWorkspace, "skills", "crm", "SKILL.md"); await bootstrapCommand( { @@ -653,10 +726,8 @@ describe("bootstrapCommand always-onboard behavior", () => { runtime, ); - expect(existsSync(managedSkill)).toBe(true); - expect(existsSync(workspaceSkill)).toBe(false); - const managedWorkspaceSkill = path.join(stateDir, "workspace", "skills", "crm", "SKILL.md"); expect(existsSync(managedWorkspaceSkill)).toBe(true); + expect(existsSync(customWorkspaceSkill)).toBe(false); }); it("uses inherited stdio for onboarding in interactive mode (shows wizard prompts)", async () => { @@ -778,11 +849,21 @@ describe("bootstrapCommand always-onboard behavior", () => { (call) => call.command === "openclaw" && call.args.includes("gateway") && call.args.includes("start"), ); + const toolsProfileSetCall = spawnCalls.find( + (call) => + call.command === "openclaw" && + call.args.includes("config") && + call.args.includes("set") && + call.args.includes("tools.profile"), + ); expect(doctorFixCalled).toBe(true); expect(gatewayStopCalled).toBe(true); expect(gatewayInstallCalled).toBe(true); expect(gatewayStartCalled).toBe(true); + expect(toolsProfileSetCall?.args).toEqual( + expect.arrayContaining(["--profile", "dench", "config", "set", "tools.profile", "full"]), + ); expect(summary.gatewayReachable).toBe(true); expect(summary.gatewayAutoFix?.attempted).toBe(true); expect(summary.gatewayAutoFix?.recovered).toBe(true); @@ -798,7 +879,7 @@ describe("bootstrapCommand always-onboard behavior", () => { } return createWebProfilesResponse({ status: 200, - payload: { profiles: [], activeProfile: "ironclaw" }, + payload: { profiles: [], activeProfile: "dench" }, }); } if (url.includes("127.0.0.1:3101/api/profiles")) { diff --git a/src/cli/bootstrap-external.ts b/src/cli/bootstrap-external.ts index 08b16d5ac59..2266b9a956b 100644 --- a/src/cli/bootstrap-external.ts +++ b/src/cli/bootstrap-external.ts @@ -13,16 +13,17 @@ import { theme } from "../terminal/theme.js"; import { applyCliProfileEnv } from "./profile.js"; import { seedWorkspaceFromAssets, type WorkspaceSeedResult } from "./workspace-seed.js"; -const DEFAULT_IRONCLAW_PROFILE = "ironclaw"; -const IRONCLAW_STATE_DIRNAME = ".openclaw-ironclaw"; +const DEFAULT_DENCHCLAW_PROFILE = "dench"; +const DENCHCLAW_STATE_DIRNAME = ".openclaw-dench"; const DEFAULT_GATEWAY_PORT = 18789; -const IRONCLAW_GATEWAY_PORT_START = 19001; +const DENCHCLAW_GATEWAY_PORT_START = 19001; const MAX_PORT_SCAN_ATTEMPTS = 100; const DEFAULT_WEB_APP_PORT = 3100; const WEB_APP_PROBE_ATTEMPTS = 20; const WEB_APP_PROBE_DELAY_MS = 750; const DEFAULT_BOOTSTRAP_ROLLOUT_STAGE = "default"; const DEFAULT_GATEWAY_LAUNCH_AGENT_LABEL = "ai.openclaw.gateway"; +const REQUIRED_TOOLS_PROFILE = "full"; type BootstrapRolloutStage = "internal" | "beta" | "default"; type BootstrapCheckStatus = "pass" | "warn" | "fail"; @@ -267,13 +268,13 @@ export function resolveBootstrapRolloutStage( env: NodeJS.ProcessEnv = process.env, ): BootstrapRolloutStage { return normalizeBootstrapRolloutStage( - env.IRONCLAW_BOOTSTRAP_ROLLOUT ?? env.OPENCLAW_BOOTSTRAP_ROLLOUT, + env.DENCHCLAW_BOOTSTRAP_ROLLOUT ?? env.OPENCLAW_BOOTSTRAP_ROLLOUT, ); } export function isLegacyFallbackEnabled(env: NodeJS.ProcessEnv = process.env): boolean { return ( - isTruthyEnvValue(env.IRONCLAW_BOOTSTRAP_LEGACY_FALLBACK) || + isTruthyEnvValue(env.DENCHCLAW_BOOTSTRAP_LEGACY_FALLBACK) || isTruthyEnvValue(env.OPENCLAW_BOOTSTRAP_LEGACY_FALLBACK) ); } @@ -302,7 +303,7 @@ function firstNonEmptyLine(...values: Array): string | undef function resolveProfileStateDir(profile: string, env: NodeJS.ProcessEnv = process.env): string { void profile; const home = resolveRequiredHomeDir(env, os.homedir); - return path.join(home, IRONCLAW_STATE_DIRNAME); + return path.join(home, DENCHCLAW_STATE_DIRNAME); } function resolveGatewayLaunchAgentLabel(profile: string): string { @@ -376,6 +377,15 @@ async function ensureSubagentDefaults(openclawCommand: string, profile: string): } } +async function ensureToolsProfile(openclawCommand: string, profile: string): Promise { + await runOpenClawOrThrow({ + openclawCommand, + args: ["--profile", profile, "config", "set", "tools.profile", REQUIRED_TOOLS_PROFILE], + timeoutMs: 10_000, + errorMessage: `Failed to set tools.profile=${REQUIRED_TOOLS_PROFILE}.`, + }); +} + async function probeForWebApp(port: number): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 1_500); @@ -923,11 +933,11 @@ function remediationForGatewayFailure( if (normalized.includes("address already in use") || normalized.includes("eaddrinuse")) { return `Port ${port} is busy. The bootstrap will auto-assign an available port, or you can explicitly specify one with \`--gateway-port \`.`; } - return `Run \`openclaw --profile ${profile} doctor --fix\` and retry \`ironclaw bootstrap --profile ${profile} --force-onboard\`.`; + return `Run \`openclaw --profile ${profile} doctor --fix\` and retry \`denchclaw bootstrap --profile ${profile} --force-onboard\`.`; } function remediationForWebUiFailure(port: number): string { - return `Web UI did not respond on ${port}. Ensure the apps/web directory exists and rerun with \`ironclaw bootstrap --web-port \` if needed.`; + return `Web UI did not respond on ${port}. Ensure the apps/web directory exists and rerun with \`denchclaw bootstrap --web-port \` if needed.`; } function describeWorkspaceSeedResult(result: WorkspaceSeedResult): string { @@ -1077,15 +1087,15 @@ export function buildBootstrapDiagnostics(params: { ); } - if (params.profile === DEFAULT_IRONCLAW_PROFILE) { + if (params.profile === DEFAULT_DENCHCLAW_PROFILE) { checks.push(createCheck("profile", "pass", `Profile pinned: ${params.profile}.`)); } else { checks.push( createCheck( "profile", "fail", - `Ironclaw profile drift detected (${params.profile}).`, - `Ironclaw requires \`--profile ${DEFAULT_IRONCLAW_PROFILE}\`. Re-run bootstrap to repair environment defaults.`, + `DenchClaw profile drift detected (${params.profile}).`, + `DenchClaw requires \`--profile ${DEFAULT_DENCHCLAW_PROFILE}\`. Re-run bootstrap to repair environment defaults.`, ), ); } @@ -1118,7 +1128,7 @@ export function buildBootstrapDiagnostics(params: { "agent-auth", "fail", authCheck.detail, - `Run \`openclaw --profile ${DEFAULT_IRONCLAW_PROFILE} onboard --install-daemon\` to configure API keys.`, + `Run \`openclaw --profile ${DEFAULT_DENCHCLAW_PROFILE} onboard --install-daemon\` to configure API keys.`, ), ); } @@ -1136,7 +1146,7 @@ export function buildBootstrapDiagnostics(params: { ); } - const expectedStateDir = resolveProfileStateDir(DEFAULT_IRONCLAW_PROFILE, env); + const expectedStateDir = resolveProfileStateDir(DEFAULT_DENCHCLAW_PROFILE, env); const usesPinnedStateDir = path.resolve(stateDir) === path.resolve(expectedStateDir); if (usesPinnedStateDir) { checks.push(createCheck("state-isolation", "pass", `State dir pinned: ${stateDir}.`)); @@ -1146,13 +1156,13 @@ export function buildBootstrapDiagnostics(params: { "state-isolation", "fail", `Unexpected state dir: ${stateDir}.`, - `Ironclaw requires \`${expectedStateDir}\`. Re-run bootstrap to restore pinned defaults.`, + `DenchClaw requires \`${expectedStateDir}\`. Re-run bootstrap to restore pinned defaults.`, ), ); } const launchAgentLabel = resolveGatewayLaunchAgentLabel(params.profile); - const expectedLaunchAgentLabel = resolveGatewayLaunchAgentLabel(DEFAULT_IRONCLAW_PROFILE); + const expectedLaunchAgentLabel = resolveGatewayLaunchAgentLabel(DEFAULT_DENCHCLAW_PROFILE); if (launchAgentLabel === expectedLaunchAgentLabel) { checks.push(createCheck("daemon-label", "pass", `Gateway service label: ${launchAgentLabel}.`)); } else { @@ -1161,7 +1171,7 @@ export function buildBootstrapDiagnostics(params: { "daemon-label", "fail", `Gateway service label mismatch (${launchAgentLabel}).`, - `Ironclaw requires launch agent label ${expectedLaunchAgentLabel}.`, + `DenchClaw requires launch agent label ${expectedLaunchAgentLabel}.`, ), ); } @@ -1172,14 +1182,14 @@ export function buildBootstrapDiagnostics(params: { params.rolloutStage === "default" ? "pass" : "warn", `Bootstrap rollout stage: ${params.rolloutStage}${params.legacyFallbackEnabled ? " (legacy fallback enabled)" : ""}.`, params.rolloutStage === "beta" - ? "Enable beta cutover by setting IRONCLAW_BOOTSTRAP_BETA_OPT_IN=1." + ? "Enable beta cutover by setting DENCHCLAW_BOOTSTRAP_BETA_OPT_IN=1." : undefined, ), ); - const migrationSuiteOk = isTruthyEnvValue(env.IRONCLAW_BOOTSTRAP_MIGRATION_SUITE_OK); - const onboardingE2EOk = isTruthyEnvValue(env.IRONCLAW_BOOTSTRAP_ONBOARDING_E2E_OK); - const enforceCutoverGates = isTruthyEnvValue(env.IRONCLAW_BOOTSTRAP_ENFORCE_SAFETY_GATES); + const migrationSuiteOk = isTruthyEnvValue(env.DENCHCLAW_BOOTSTRAP_MIGRATION_SUITE_OK); + const onboardingE2EOk = isTruthyEnvValue(env.DENCHCLAW_BOOTSTRAP_ONBOARDING_E2E_OK); + const enforceCutoverGates = isTruthyEnvValue(env.DENCHCLAW_BOOTSTRAP_ENFORCE_SAFETY_GATES); const cutoverGatePassed = migrationSuiteOk && onboardingE2EOk; checks.push( createCheck( @@ -1188,7 +1198,7 @@ export function buildBootstrapDiagnostics(params: { `Cutover gate: migrationSuite=${migrationSuiteOk ? "pass" : "missing"}, onboardingE2E=${onboardingE2EOk ? "pass" : "missing"}.`, cutoverGatePassed ? undefined - : "Run migration contracts + onboarding E2E and set IRONCLAW_BOOTSTRAP_MIGRATION_SUITE_OK=1 and IRONCLAW_BOOTSTRAP_ONBOARDING_E2E_OK=1 before full cutover.", + : "Run migration contracts + onboarding E2E and set DENCHCLAW_BOOTSTRAP_MIGRATION_SUITE_OK=1 and DENCHCLAW_BOOTSTRAP_ONBOARDING_E2E_OK=1 before full cutover.", ), ); @@ -1297,14 +1307,14 @@ export async function bootstrapCommand( } else if (await isPortAvailable(DEFAULT_GATEWAY_PORT)) { gatewayPort = DEFAULT_GATEWAY_PORT; } else { - // Default port is taken, find an available one starting from Ironclaw range + // Default port is taken, find an available one starting from DenchClaw range const availablePort = await findAvailablePort( - IRONCLAW_GATEWAY_PORT_START, + DENCHCLAW_GATEWAY_PORT_START, MAX_PORT_SCAN_ATTEMPTS, ); if (!availablePort) { throw new Error( - `Could not find an available gateway port between ${IRONCLAW_GATEWAY_PORT_START} and ${IRONCLAW_GATEWAY_PORT_START + MAX_PORT_SCAN_ATTEMPTS}. ` + + `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.`, ); } @@ -1373,6 +1383,9 @@ export async function bootstrapCommand( // Persist the assigned port so all runtime clients (including web) resolve // the same gateway target on subsequent requests. await ensureGatewayPort(openclawCommand, profile, gatewayPort); + // DenchClaw requires the full tool profile; onboarding defaults can drift to + // messaging-only, so enforce this on every bootstrap run. + await ensureToolsProfile(openclawCommand, profile); await ensureSubagentDefaults(openclawCommand, profile); @@ -1476,7 +1489,7 @@ export async function bootstrapCommand( } logBootstrapChecklist(diagnostics, runtime); runtime.log(""); - runtime.log(theme.heading("IronClaw ready")); + runtime.log(theme.heading("DenchClaw ready")); runtime.log(`Profile: ${profile}`); runtime.log(`OpenClaw CLI: ${installResult.version ?? "detected"}`); runtime.log(`Gateway: ${gatewayProbe.ok ? "reachable" : "check failed"}`);