fix(cli): enforce full tool profile in dench bootstrap

This commit is contained in:
kumarabhirup 2026-03-04 13:18:39 -08:00
parent 2c5e5a8ac1
commit 1c93a3b525
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
2 changed files with 153 additions and 59 deletions

View File

@ -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<T>(isTTY: boolean, fn: () => Promise<T>): 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<typeof vi.fn>;
@ -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")) {

View File

@ -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 | undefined>): 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<void> {
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<boolean> {
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 <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 <port>\` if needed.`;
return `Web UI did not respond on ${port}. Ensure the apps/web directory exists and rerun with \`denchclaw bootstrap --web-port <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"}`);