// Tests for the experimental grouped Claws CLI. import { mkdir, realpath, writeFile } from "node:fs/promises"; import { basename, dirname, join } from "node:path"; import { Command } from "commander"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import { persistClawInstallRecord } from "../claws/provenance.js"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; const mocks = vi.hoisted(() => { const logs: string[] = []; const errors: string[] = []; const runtime = { log: vi.fn((value: unknown) => logs.push(String(value))), error: vi.fn((value: unknown) => errors.push(String(value))), writeJson: vi.fn((value: unknown, space = 2) => logs.push(JSON.stringify(value, null, space > 0 ? space : undefined)), ), writeStdout: vi.fn(), exit: vi.fn((code: number) => { throw new Error(`__exit__:${code}`); }), }; return { logs, errors, runtime, loadConfig: vi.fn<() => Record>(() => ({})), listConfiguredMcpServers: vi.fn(), closeReadOnlyDatabase: vi.fn(), stateTableGet: vi.fn(), openExistingOpenClawStateDatabaseReadOnly: vi.fn(), applyClawAddPlan: vi.fn(), readClawStatus: vi.fn(), buildClawRemovePlan: vi.fn(), applyClawRemovePlan: vi.fn(), applyClawUpdatePlan: vi.fn(), buildClawUpdatePlan: vi.fn(), exportClawAgent: vi.fn(), callGatewayFromCli: vi.fn(), sleep: vi.fn(), }; }); vi.mock("../runtime.js", async () => ({ ...(await vi.importActual("../runtime.js")), defaultRuntime: mocks.runtime, writeRuntimeJson: (runtime: typeof mocks.runtime, value: unknown, space = 2) => runtime.writeJson(value, space), })); vi.mock("../config/config.js", async () => ({ ...(await vi.importActual("../config/config.js")), getRuntimeConfig: mocks.loadConfig, loadConfig: mocks.loadConfig, })); vi.mock("../config/mcp-config.js", async () => ({ ...(await vi.importActual("../config/mcp-config.js")), listConfiguredMcpServers: mocks.listConfiguredMcpServers, })); vi.mock("./gateway-rpc.js", () => ({ callGatewayFromCli: mocks.callGatewayFromCli, })); vi.mock("../utils/sleep.js", () => ({ sleep: mocks.sleep, })); vi.mock("../state/openclaw-state-db.js", async () => ({ ...(await vi.importActual( "../state/openclaw-state-db.js", )), openExistingOpenClawStateDatabaseReadOnly: mocks.openExistingOpenClawStateDatabaseReadOnly, })); vi.mock("../claws/add.js", async () => ({ ...(await vi.importActual("../claws/add.js")), applyClawAddPlan: mocks.applyClawAddPlan, })); vi.mock("../claws/lifecycle-state.js", async () => ({ ...(await vi.importActual( "../claws/lifecycle-state.js", )), readClawStatus: mocks.readClawStatus, buildClawRemovePlan: mocks.buildClawRemovePlan, applyClawRemovePlan: mocks.applyClawRemovePlan, })); vi.mock("../claws/export.js", async () => ({ ...(await vi.importActual("../claws/export.js")), exportClawAgent: mocks.exportClawAgent, })); vi.mock("../claws/update-plan.js", async () => ({ ...(await vi.importActual("../claws/update-plan.js")), buildClawUpdatePlan: mocks.buildClawUpdatePlan, })); vi.mock("../claws/update-apply.js", async () => ({ ...(await vi.importActual("../claws/update-apply.js")), applyClawUpdatePlan: mocks.applyClawUpdatePlan, })); const { registerClawsCli } = await import("./claws-cli.js"); const { waitUntilGatewayConfigApplied } = await import("./claws-cli.gateway-readiness.js"); const { runClawsAddCommand } = await import("./claws-cli.runtime.js"); const { ClawUpdateMutationError } = await import("../claws/update-apply.js"); const tempDirs = useAutoCleanupTempDirTracker(afterEach); const minimalManifest = { schemaVersion: 1, agent: { id: "demo-agent", name: "Demo Agent" } }; async function writeManifest(value: unknown = minimalManifest): Promise { const dir = tempDirs.make("openclaw-claws-cli-"); const path = join(dir, "openclaw.claw.json"); await writeFile(path, JSON.stringify(value), "utf8"); return path; } async function writePackage(): Promise<{ root: string; workspace: string }> { const root = tempDirs.make("openclaw-claws-cli-package-"); await mkdir(join(root, "workspace")); await writeFile(join(root, "workspace", "AGENTS.md"), "# Demo\n", "utf8"); await writeFile( join(root, "package.json"), JSON.stringify({ name: "@acme/demo-agent", version: "1.2.3", openclaw: { claw: "openclaw.claw.json" }, }), "utf8", ); await writeFile( join(root, "openclaw.claw.json"), JSON.stringify({ schemaVersion: 1, agent: { id: "demo-agent", name: "Demo Agent" }, workspace: { bootstrapFiles: { "AGENTS.md": { source: "workspace/AGENTS.md" } }, }, packages: [ { kind: "skill", source: "clawhub", ref: "@acme/demo-skill", version: "1.0.0", }, ], }), "utf8", ); return { root, workspace: join(root, "target-workspace") }; } async function canonicalFuturePath(target: string): Promise { return join(await realpath(dirname(target)), basename(target)); } async function runCli(args: string[]) { const program = new Command(); program.exitOverride(); registerClawsCli(program); try { await program.parseAsync(args, { from: "user" }); } catch (error) { if (!(error instanceof Error && error.message.startsWith("__exit__:"))) { throw error; } } } describe("claws cli", () => { beforeEach(() => { vi.stubEnv("OPENCLAW_EXPERIMENTAL_CLAWS", "1"); mocks.logs.length = 0; mocks.errors.length = 0; mocks.runtime.log.mockClear(); mocks.runtime.error.mockClear(); mocks.runtime.writeJson.mockClear(); mocks.runtime.exit.mockClear(); mocks.loadConfig.mockReset(); mocks.loadConfig.mockReturnValue({}); mocks.listConfiguredMcpServers.mockReset(); mocks.listConfiguredMcpServers.mockResolvedValue({ ok: true, path: "config", config: {}, mcpServers: {}, }); mocks.callGatewayFromCli.mockReset(); mocks.sleep.mockReset(); mocks.sleep.mockResolvedValue(undefined); mocks.closeReadOnlyDatabase.mockReset(); mocks.stateTableGet.mockReset(); mocks.stateTableGet.mockReturnValue({ 1: 1 }); mocks.openExistingOpenClawStateDatabaseReadOnly.mockReset(); mocks.openExistingOpenClawStateDatabaseReadOnly.mockReturnValue({ db: { prepare: () => ({ get: mocks.stateTableGet }) }, path: "state.sqlite", walMaintenance: { checkpoint: () => false, close: mocks.closeReadOnlyDatabase }, }); mocks.applyClawAddPlan.mockReset(); mocks.applyClawAddPlan.mockImplementation(async (plan) => ({ schemaVersion: "openclaw.clawAddResult.v1", stability: "experimental", dryRun: false, mutationAllowed: true, planIntegrity: plan.planIntegrity, status: "complete", claw: plan.claw, agent: plan.agent, workspaceCreated: true, configCommitted: true, installRecord: { agentId: plan.agent.finalId }, })); mocks.readClawStatus.mockReset(); mocks.readClawStatus.mockResolvedValue({ schemaVersion: "openclaw.clawStatus.v1", records: [], summary: { claws: 0, partial: 0, missingAgents: 0, driftedFiles: 0, packageRefs: 0 }, }); mocks.buildClawRemovePlan.mockReset(); mocks.buildClawRemovePlan.mockResolvedValue({ schemaVersion: "openclaw.clawRemovePlan.v1", dryRun: true, mutationAllowed: false, planIntegrity: "sha256:remove-plan", target: "demo-agent", agentId: "demo-agent", actions: [ { kind: "agent", id: "demo-agent", action: "remove", target: 'agents.entries["demo-agent"]', blocked: false, }, ], blockers: [], }); mocks.applyClawRemovePlan.mockReset(); mocks.applyClawRemovePlan.mockResolvedValue({ schemaVersion: "openclaw.clawRemoveResult.v1", dryRun: false, status: "complete", agentId: "demo-agent", agentRemoved: true, workspaceFiles: [], packages: [], mcpServers: [], cronJobs: [], packageRefsReleased: 1, }); mocks.buildClawUpdatePlan.mockReset(); mocks.buildClawUpdatePlan.mockResolvedValue({ schemaVersion: "openclaw.clawUpdatePlan.v1", stability: "experimental", dryRun: true, mutationAllowed: false, planIntegrity: "sha256:update-plan", found: true, agentId: "demo-agent", currentClaw: { name: "@acme/demo-agent", version: "1.0.0", integrity: "sha256:old" }, targetClaw: { name: "@acme/demo-agent", version: "1.2.3", integrity: "sha256:new" }, summary: { totalActions: 1, added: 0, changed: 1, removed: 0, released: 0, unchanged: 0, manual: 0, blocked: 0, capabilityChanges: 1, capabilityEscalations: 1, }, actions: [], capabilityChanges: [ { kind: "agent", id: "demo-agent", path: "agent.sandbox.mode", action: "change", classification: "escalation", requiresDistinctConsent: true, reason: "Agent capability field sandbox.mode changes in the target manifest.", effect: { path: "sandbox.mode", current: "non-main", desired: "all" }, current: { summary: "non-main", digest: "sha256:current" }, desired: { summary: "all", digest: "sha256:desired" }, }, ], blockers: [], diagnostics: [], }); mocks.applyClawUpdatePlan.mockReset(); mocks.applyClawUpdatePlan.mockResolvedValue({ schemaVersion: "openclaw.clawUpdateResult.v1", stability: "experimental", dryRun: false, mutationAllowed: true, status: "complete", agentId: "demo-agent", previousClaw: { name: "@acme/demo-agent", version: "1.0.0", integrity: "sha256:old" }, targetClaw: { name: "@acme/demo-agent", version: "1.2.3", integrity: "sha256:new" }, appliedActions: [], installRecord: { agentId: "demo-agent" }, }); mocks.exportClawAgent.mockReset(); mocks.exportClawAgent.mockResolvedValue({ schemaVersion: "openclaw.clawExportResult.v1", stability: "experimental", agentId: "demo-agent", outputDirectory: "/tmp/exported", manifest: { schemaVersion: 1, agent: { id: "demo-agent" }, workspace: { bootstrapFiles: {}, files: [] }, packages: [], mcpServers: {}, cronJobs: [], }, filesWritten: ["package.json", "openclaw.claw.json"], }); }); afterEach(() => { vi.restoreAllMocks(); closeOpenClawStateDatabaseForTest(); }); it("does not register without the process opt-in", () => { vi.stubEnv("OPENCLAW_EXPERIMENTAL_CLAWS", ""); const program = new Command(); registerClawsCli(program); expect(program.commands.map((command) => command.name())).not.toContain("claws"); }); it("registers the experimental grouped lifecycle without prototype apply or feed commands", () => { const program = new Command(); registerClawsCli(program); const claws = program.commands.find((command) => command.name() === "claws"); expect(claws?.commands.map((command) => command.name())).toEqual([ "inspect", "add", "status", "update", "remove", "export", ]); }); it("accepts an already-applied Gateway config revision", async () => { mocks.callGatewayFromCli.mockResolvedValue({ configRevisionHash: "revision-new", appliedConfigHash: "revision-new", }); await expect(waitUntilGatewayConfigApplied()).resolves.toBeUndefined(); expect(mocks.callGatewayFromCli).toHaveBeenCalledOnce(); expect(mocks.callGatewayFromCli).toHaveBeenCalledWith("config.get", { timeout: "5000" }, {}); expect(mocks.sleep).not.toHaveBeenCalled(); }); it("retries until the Gateway applies the persisted config revision", async () => { mocks.callGatewayFromCli .mockResolvedValueOnce({ configRevisionHash: "revision-new", appliedConfigHash: "revision-old", }) .mockResolvedValueOnce({ configRevisionHash: "revision-new", appliedConfigHash: "revision-new", }); await expect(waitUntilGatewayConfigApplied()).resolves.toBeUndefined(); expect(mocks.callGatewayFromCli).toHaveBeenCalledTimes(2); expect(mocks.sleep).toHaveBeenCalledOnce(); expect(mocks.sleep).toHaveBeenCalledWith(100); }); it("reports the last Gateway error after the reload deadline", async () => { vi.spyOn(Date, "now").mockReturnValueOnce(0).mockReturnValueOnce(0).mockReturnValue(15_000); mocks.callGatewayFromCli.mockRejectedValue(new Error("gateway unavailable")); await expect(waitUntilGatewayConfigApplied()).rejects.toThrow( "Gateway did not apply the Claw agent configuration in time: gateway unavailable", ); expect(mocks.callGatewayFromCli).toHaveBeenCalledOnce(); expect(mocks.sleep).toHaveBeenCalledOnce(); }); it("prints versioned experimental JSON for a development manifest", async () => { const manifestPath = await writeManifest(); await runCli(["claws", "inspect", manifestPath, "--json"]); expect(JSON.parse(mocks.logs[0] ?? "{}")).toMatchObject({ schemaVersion: "openclaw.clawInspect.v1", stability: "experimental", valid: true, source: { kind: "development", version: "0.0.0-development" }, manifest: { schemaVersion: 1, agent: { id: "demo-agent" } }, }); }); it("takes identity from package.json and plans one new agent", async () => { const { root, workspace } = await writePackage(); const expectedWorkspace = await canonicalFuturePath(workspace); await runCli(["claws", "add", root, "--dry-run", "--workspace", workspace, "--json"]); expect(JSON.parse(mocks.logs[0] ?? "{}")).toMatchObject({ schemaVersion: "openclaw.clawAddPlan.v1", stability: "experimental", claw: { kind: "package", name: "@acme/demo-agent", version: "1.2.3" }, agent: { finalId: "demo-agent", workspace: expectedWorkspace }, summary: { agentActions: 1, workspaceActions: 2, packageActions: 1, blockedActions: 1 }, }); expect(mocks.runtime.exit).toHaveBeenCalledWith(1); }); it("redacts credential-bearing remote MCP URLs in add previews", async () => { const manifestPath = await writeManifest({ schemaVersion: 1, agent: { id: "demo-agent", name: "Demo Agent" }, mcpServers: { remote: { url: "https://example.com/mcp?token=abc123&mode=ok", transport: "streamable-http", }, }, }); const workspace = join(tempDirs.make("openclaw-claws-add-"), "workspace"); await runClawsAddCommand(manifestPath, { dryRun: true, workspace }, mocks.runtime); const output = mocks.logs.join("\n"); expect(output).toContain("MCP remote:"); expect(output).toContain("example.com"); expect(output).not.toContain("abc123"); expect(output).toContain("token=***"); }); it("blocks adding into an existing agent instead of merging", async () => { const { root, workspace } = await writePackage(); mocks.loadConfig.mockReturnValue({ agents: { entries: { "demo-agent": {} } } }); await runCli(["claws", "add", root, "--dry-run", "--workspace", workspace, "--json"]); const payload = JSON.parse(mocks.logs[0] ?? "{}"); expect(payload.blockers).toContainEqual( expect.objectContaining({ code: "agent_id_collision" }), ); expect(mocks.runtime.exit).toHaveBeenCalledWith(1); }); it("honors an explicit unused agent id in the plan", async () => { const { root, workspace } = await writePackage(); mocks.loadConfig.mockReturnValue({ agents: { entries: { "demo-agent": {} } } }); await runCli([ "claws", "add", root, "--dry-run", "--agent-id", "demo-agent-two", "--workspace", workspace, "--json", ]); expect(JSON.parse(mocks.logs[0] ?? "{}").agent).toMatchObject({ requestedId: "demo-agent", finalId: "demo-agent-two", }); expect(mocks.runtime.exit).toHaveBeenCalledWith(1); }); it("discloses capability escalations in the human dry-run", async () => { const root = tempDirs.make("openclaw-claws-cli-profile-"); await mkdir(join(root, "profiles")); await writeFile( join(root, "profiles", "openclaw.yml"), "schemaVersion: 1\nagent:\n tools:\n allow: [read]\n", "utf8", ); const path = join(root, "openclaw.claw.json"); await writeFile( path, JSON.stringify({ schemaVersion: 1, agent: { id: "demo-agent" }, metadata: { "openclaw.config": "profiles/openclaw.yml" }, mcpServers: { docs: { command: "node", env: { API_TOKEN: "${GITHUB_TOKEN}" }, toolFilter: { include: ["search_*"] }, }, }, }), "utf8", ); await runCli(["claws", "add", path, "--dry-run"]); expect(mocks.logs).toContain("Capability escalations (2):"); expect(mocks.logs.some((line) => line.startsWith(" ! agent:demo-agent"))).toBe(true); expect(mocks.logs.some((line) => line.startsWith(" ! mcpServer:docs"))).toBe(true); expect(mocks.logs.some((line) => line.includes('"env":["API_TOKEN"]'))).toBe(true); expect(mocks.logs).toContain("The plan integrity binds every capability line above."); }); it("applies a minimal Claw only after explicit consent", async () => { const manifestPath = await writeManifest(); const workspace = join(tempDirs.make("openclaw-claws-add-"), "workspace"); const expectedWorkspace = await canonicalFuturePath(workspace); await runCli(["claws", "add", manifestPath, "--dry-run", "--workspace", workspace, "--json"]); const plan = JSON.parse(mocks.logs[0] ?? "{}"); mocks.logs.length = 0; await runCli([ "claws", "add", manifestPath, "--yes", "--plan-integrity", plan.planIntegrity, "--workspace", workspace, "--json", ]); expect(mocks.applyClawAddPlan).toHaveBeenCalledWith( expect.objectContaining({ planIntegrity: plan.planIntegrity }), expect.objectContaining({ consentPlanIntegrity: plan.planIntegrity }), ); expect(JSON.parse(mocks.logs[0] ?? "{}")).toMatchObject({ schemaVersion: "openclaw.clawAddResult.v1", stability: "experimental", status: "complete", agent: { finalId: "demo-agent", workspace: expectedWorkspace }, }); }); it("resumes consented add with the matching in-flight workspace on disk", async () => { const manifestPath = await writeManifest(); const workspace = join(tempDirs.make("openclaw-claws-add-"), "workspace"); const stateRoot = tempDirs.make("openclaw-claws-state-"); vi.stubEnv("OPENCLAW_STATE_DIR", join(stateRoot, "state")); await runCli(["claws", "add", manifestPath, "--dry-run", "--workspace", workspace, "--json"]); const plan = JSON.parse(mocks.logs[0] ?? "{}"); persistClawInstallRecord(plan, { status: "workspace_ready", nowMs: 1 }); await mkdir(workspace); await writeFile(join(workspace, "leftover.txt"), "keep", "utf8"); mocks.logs.length = 0; mocks.runtime.exit.mockClear(); mocks.applyClawAddPlan.mockClear(); mocks.loadConfig.mockReturnValue({}); await runCli([ "claws", "add", manifestPath, "--yes", "--plan-integrity", plan.planIntegrity, "--workspace", workspace, "--json", ]); expect(mocks.applyClawAddPlan).toHaveBeenCalledWith( expect.objectContaining({ planIntegrity: plan.planIntegrity, blockers: [] }), expect.objectContaining({ consentPlanIntegrity: plan.planIntegrity }), ); expect(mocks.runtime.exit).not.toHaveBeenCalled(); }); it("resumes when config committed before the workspace-ready phase advanced", async () => { const manifestPath = await writeManifest(); const workspace = join(tempDirs.make("openclaw-claws-add-"), "workspace"); const stateRoot = tempDirs.make("openclaw-claws-state-"); vi.stubEnv("OPENCLAW_STATE_DIR", join(stateRoot, "state")); await runCli(["claws", "add", manifestPath, "--dry-run", "--workspace", workspace, "--json"]); const plan = JSON.parse(mocks.logs[0] ?? "{}"); persistClawInstallRecord(plan, { status: "workspace_ready", nowMs: 1 }); await mkdir(workspace); mocks.logs.length = 0; mocks.runtime.exit.mockClear(); mocks.applyClawAddPlan.mockClear(); mocks.loadConfig.mockReturnValue({ agents: { list: [plan.agent.config] } }); await runCli([ "claws", "add", manifestPath, "--yes", "--plan-integrity", plan.planIntegrity, "--workspace", workspace, "--json", ]); expect(mocks.applyClawAddPlan).toHaveBeenCalledWith( expect.objectContaining({ planIntegrity: plan.planIntegrity, blockers: [] }), expect.objectContaining({ consentPlanIntegrity: plan.planIntegrity }), ); expect(mocks.runtime.exit).not.toHaveBeenCalled(); }); it("does not claim an on-disk workspace for a partial record without workspace ownership", async () => { const manifestPath = await writeManifest(); const workspace = join(tempDirs.make("openclaw-claws-add-"), "workspace"); const stateRoot = tempDirs.make("openclaw-claws-state-"); vi.stubEnv("OPENCLAW_STATE_DIR", join(stateRoot, "state")); await runCli(["claws", "add", manifestPath, "--dry-run", "--workspace", workspace, "--json"]); const plan = JSON.parse(mocks.logs[0] ?? "{}"); persistClawInstallRecord(plan, { status: "partial", nowMs: 1 }); await mkdir(workspace); mocks.logs.length = 0; mocks.runtime.exit.mockClear(); mocks.applyClawAddPlan.mockClear(); await runCli([ "claws", "add", manifestPath, "--yes", "--plan-integrity", plan.planIntegrity, "--workspace", workspace, "--json", ]); expect(JSON.parse(mocks.logs[0] ?? "{}")).toMatchObject({ blockers: [expect.objectContaining({ code: "workspace_collision" })], }); expect(mocks.applyClawAddPlan).not.toHaveBeenCalled(); expect(mocks.runtime.exit).toHaveBeenCalledWith(1); }); it("preserves a real agent collision while an add is still pending", async () => { const manifestPath = await writeManifest(); const workspace = join(tempDirs.make("openclaw-claws-add-"), "workspace"); const stateRoot = tempDirs.make("openclaw-claws-state-"); vi.stubEnv("OPENCLAW_STATE_DIR", join(stateRoot, "state")); await runCli(["claws", "add", manifestPath, "--dry-run", "--workspace", workspace, "--json"]); const plan = JSON.parse(mocks.logs[0] ?? "{}"); persistClawInstallRecord(plan, { status: "pending", nowMs: 1 }); mocks.logs.length = 0; mocks.runtime.exit.mockClear(); mocks.applyClawAddPlan.mockClear(); mocks.loadConfig.mockReturnValue({ agents: { list: [{ id: "demo-agent", workspace }] } }); await runCli([ "claws", "add", manifestPath, "--yes", "--plan-integrity", plan.planIntegrity, "--workspace", workspace, "--json", ]); expect(JSON.parse(mocks.logs[0] ?? "{}")).toMatchObject({ blockers: expect.arrayContaining([expect.objectContaining({ code: "agent_id_collision" })]), }); expect(mocks.applyClawAddPlan).not.toHaveBeenCalled(); expect(mocks.runtime.exit).toHaveBeenCalledWith(1); }); it("does not resume through another agent's configured workspace", async () => { const manifestPath = await writeManifest(); const workspace = join(tempDirs.make("openclaw-claws-add-"), "workspace"); const stateRoot = tempDirs.make("openclaw-claws-state-"); vi.stubEnv("OPENCLAW_STATE_DIR", join(stateRoot, "state")); await runCli(["claws", "add", manifestPath, "--dry-run", "--workspace", workspace, "--json"]); const plan = JSON.parse(mocks.logs[0] ?? "{}"); persistClawInstallRecord(plan, { status: "workspace_ready", nowMs: 1 }); mocks.logs.length = 0; mocks.runtime.exit.mockClear(); mocks.applyClawAddPlan.mockClear(); mocks.loadConfig.mockReturnValue({ agents: { list: [{ id: "other-agent", workspace }] } }); await runCli([ "claws", "add", manifestPath, "--yes", "--plan-integrity", plan.planIntegrity, "--workspace", workspace, "--json", ]); expect(JSON.parse(mocks.logs[0] ?? "{}")).toMatchObject({ blockers: [expect.objectContaining({ code: "workspace_collision" })], }); expect(mocks.applyClawAddPlan).not.toHaveBeenCalled(); }); it("requires the exact dry-run plan identity with explicit consent", async () => { const manifestPath = await writeManifest(); await runCli(["claws", "add", manifestPath, "--yes", "--json"]); expect(JSON.parse(mocks.logs[0] ?? "{}")).toMatchObject({ error: { code: "plan_integrity_required" }, }); expect(mocks.applyClawAddPlan).not.toHaveBeenCalled(); mocks.logs.length = 0; await runCli([ "claws", "add", manifestPath, "--yes", "--plan-integrity", "sha256:stale", "--json", ]); expect(JSON.parse(mocks.logs[0] ?? "{}")).toMatchObject({ status: "failed", error: { code: "plan_integrity_mismatch" }, }); expect(mocks.applyClawAddPlan).not.toHaveBeenCalled(); }); it("fails closed when add is invoked without dry-run or consent", async () => { const path = await writeManifest(); await runCli(["claws", "add", path, "--json"]); expect(JSON.parse(mocks.logs[0] ?? "{}")).toMatchObject({ stability: "experimental", ok: false, error: { code: "consent_required" }, }); expect(mocks.runtime.exit).toHaveBeenCalledWith(1); }); it("reports installed Claw status by agent id", async () => { mocks.readClawStatus.mockResolvedValue({ schemaVersion: "openclaw.clawStatus.v1", target: "demo-agent", records: [ { install: { agentId: "demo-agent" }, agentState: "present", workspaceFiles: [], packages: [], }, ], summary: { claws: 1, partial: 0, missingAgents: 0, driftedFiles: 0, packageRefs: 0 }, }); await runCli(["claws", "status", "demo-agent", "--json"]); expect(mocks.readClawStatus).toHaveBeenCalledWith("demo-agent"); expect(JSON.parse(mocks.logs[0] ?? "{}")).toMatchObject({ schemaVersion: "openclaw.clawStatus.v1", summary: { claws: 1 }, }); }); it("prints a read-only remove plan without applying it", async () => { await runCli(["claws", "remove", "demo-agent", "--dry-run", "--json"]); expect(mocks.buildClawRemovePlan).toHaveBeenCalledWith("demo-agent", { referencedCleanup: { mode: "retain" }, }); expect(mocks.applyClawRemovePlan).not.toHaveBeenCalled(); expect(JSON.parse(mocks.logs[0] ?? "{}")).toMatchObject({ schemaVersion: "openclaw.clawRemovePlan.v1", mutationAllowed: false, }); }); it("prints a read-only grouped update plan", async () => { const { root } = await writePackage(); await runCli(["claws", "update", "demo-agent", "--from", root, "--dry-run", "--json"]); expect(mocks.buildClawUpdatePlan).toHaveBeenCalledWith( expect.objectContaining({ agentId: "demo-agent", targetManifest: expect.objectContaining({ agent: { id: "demo-agent", name: "Demo Agent" }, }), targetSource: expect.objectContaining({ name: "@acme/demo-agent", version: "1.2.3" }), config: {}, sourceMcpServers: {}, }), ); expect(JSON.parse(mocks.logs[0] ?? "{}")).toMatchObject({ schemaVersion: "openclaw.clawUpdatePlan.v1", dryRun: true, mutationAllowed: false, agentId: "demo-agent", }); }); it("prints capability escalation details in human update previews", async () => { const { root } = await writePackage(); await runCli(["claws", "update", "demo-agent", "--from", root, "--dry-run"]); const output = mocks.logs.join("\n"); expect(output).toContain("Capability changes: 1; escalations requiring explicit review: 1"); expect(output).toContain("Plan integrity: sha256:update-plan"); expect(output).toContain( "Capability consent: the exact plan-integrity token binds every ! change disclosed below.", ); expect(output).toContain("! agent.sandbox.mode: non-main -> all (change)"); expect(output).toContain( 'effect: {"path":"sandbox.mode","current":"non-main","desired":"all"}', ); }); it("returns failure when an update plan contains blocked actions", async () => { const { root } = await writePackage(); mocks.buildClawUpdatePlan.mockResolvedValueOnce({ schemaVersion: "openclaw.clawUpdatePlan.v1", stability: "experimental", dryRun: true, mutationAllowed: false, planIntegrity: "sha256:blocked-plan", found: true, agentId: "demo-agent", summary: { totalActions: 1, added: 0, changed: 0, removed: 0, released: 0, unchanged: 0, manual: 1, blocked: 1, capabilityChanges: 0, capabilityEscalations: 0, }, capabilityChanges: [], actions: [ { kind: "workspaceFile", id: "SOUL.md", action: "manual", target: "workspace:SOUL.md", blocked: true, reason: "Local content changed.", }, ], blockers: [], diagnostics: [], }); await runCli(["claws", "update", "demo-agent", "--from", root, "--dry-run", "--json"]); expect(mocks.runtime.exit).toHaveBeenCalledWith(1); }); it("uses the source recorded by the installed Claw when --from is omitted", async () => { const { root } = await writePackage(); mocks.readClawStatus.mockResolvedValue({ schemaVersion: "openclaw.clawStatus.v1", records: [ { install: { agentId: "demo-agent", claw: { kind: "package", name: "@acme/demo-agent", version: "1.0.0", packageRoot: root, manifestPath: join(root, "openclaw.claw.json"), integrity: "sha256:old", }, }, workspaceFiles: [], packages: [], mcpServers: [], cronJobs: [], }, ], summary: { claws: 1 }, }); await runCli(["claws", "update", "demo-agent", "--dry-run", "--json"]); expect(mocks.readClawStatus).toHaveBeenCalledWith( "demo-agent", expect.objectContaining({ readOnly: true, sourceMcpServers: {} }), ); expect(mocks.closeReadOnlyDatabase).toHaveBeenCalled(); expect(mocks.buildClawUpdatePlan).toHaveBeenCalledWith( expect.objectContaining({ agentId: "demo-agent", targetSource: expect.objectContaining({ name: "@acme/demo-agent", version: "1.2.3" }), }), ); }); it("returns not found for a supported state database without Claws tables", async () => { mocks.stateTableGet.mockReturnValue(undefined); await runCli(["claws", "update", "demo-agent", "--dry-run", "--json"]); expect(mocks.readClawStatus).not.toHaveBeenCalled(); expect(mocks.closeReadOnlyDatabase).toHaveBeenCalled(); expect(JSON.parse(mocks.logs[0] ?? "{}")).toMatchObject({ diagnostics: [expect.objectContaining({ code: "claw_not_found", phase: "plan" })], }); expect(mocks.runtime.exit).toHaveBeenCalledWith(1); }); it("fails closed when update is invoked without dry-run", async () => { const { root } = await writePackage(); await runCli(["claws", "update", "demo-agent", "--from", root, "--json"]); expect(mocks.buildClawUpdatePlan).not.toHaveBeenCalled(); expect(JSON.parse(mocks.logs[0] ?? "{}")).toMatchObject({ schemaVersion: "openclaw.clawUpdatePlan.v1", error: { code: "consent_required" }, }); expect(mocks.runtime.exit).toHaveBeenCalledWith(1); }); it("requires exact plan integrity with update consent", async () => { const { root } = await writePackage(); await runCli(["claws", "update", "demo-agent", "--from", root, "--yes", "--json"]); expect(mocks.buildClawUpdatePlan).not.toHaveBeenCalled(); expect(JSON.parse(mocks.logs[0] ?? "{}")).toMatchObject({ error: { code: "consent_required" }, }); expect(mocks.runtime.exit).toHaveBeenCalledWith(1); }); it("applies a supported update only after explicit consent", async () => { const { root } = await writePackage(); await runCli([ "claws", "update", "demo-agent", "--from", root, "--yes", "--plan-integrity", "sha256:update-plan", "--json", ]); expect(mocks.applyClawUpdatePlan).toHaveBeenCalledWith( expect.objectContaining({ agentId: "demo-agent" }), expect.objectContaining({ targetManifest: expect.objectContaining({ agent: { id: "demo-agent", name: "Demo Agent" }, }), }), expect.objectContaining({ config: {}, sourceMcpServers: {}, consentPlanIntegrity: "sha256:update-plan", packagePreflight: expect.any(Function), cronGateway: expect.objectContaining({ add: expect.any(Function), get: expect.any(Function), remove: expect.any(Function), }), }), ); expect(JSON.parse(mocks.logs[0] ?? "{}")).toMatchObject({ schemaVersion: "openclaw.clawUpdateResult.v1", status: "complete", agentId: "demo-agent", }); }); it("reports uncertain update mutations as partial JSON", async () => { const { root } = await writePackage(); mocks.applyClawUpdatePlan.mockRejectedValueOnce( new ClawUpdateMutationError("update_partial", "artifact outcome requires reconciliation"), ); await runCli([ "claws", "update", "demo-agent", "--from", root, "--yes", "--plan-integrity", "sha256:update-plan", "--json", ]); expect(JSON.parse(mocks.logs[0] ?? "{}")).toMatchObject({ schemaVersion: "openclaw.clawUpdateResult.v1", status: "partial", error: { code: "update_partial" }, }); expect(mocks.runtime.exit).toHaveBeenCalledWith(1); }); it("applies remove only after explicit consent", async () => { await runCli([ "claws", "remove", "demo-agent", "--yes", "--plan-integrity", "sha256:remove-plan", "--json", ]); expect(mocks.applyClawRemovePlan).toHaveBeenCalledWith( expect.objectContaining({ planIntegrity: "sha256:remove-plan" }), expect.objectContaining({ consentPlanIntegrity: "sha256:remove-plan", referencedCleanup: { mode: "retain" }, }), ); expect(JSON.parse(mocks.logs[0] ?? "{}")).toMatchObject({ schemaVersion: "openclaw.clawRemoveResult.v1", status: "complete", agentId: "demo-agent", }); }); it("requires the exact dry-run identity with remove consent", async () => { await runCli(["claws", "remove", "demo-agent", "--yes", "--json"]); expect(mocks.buildClawRemovePlan).not.toHaveBeenCalled(); expect(JSON.parse(mocks.logs[0] ?? "{}")).toMatchObject({ schemaVersion: "openclaw.clawRemovePlan.v1", error: { code: "plan_integrity_required" }, }); }); it("binds selected referenced cleanup and its conflict override into the plan", async () => { await runCli([ "claws", "remove", "demo-agent", "--dry-run", "--remove-referenced", "plugin:@acme/audit@1.0.0", "--force-referenced", "--json", ]); expect(mocks.buildClawRemovePlan).toHaveBeenCalledWith("demo-agent", { referencedCleanup: { mode: "remove-selected", selected: ["plugin:@acme/audit@1.0.0"], allowConflicts: true, }, }); }); it("rejects ambiguous referenced cleanup modes", async () => { await runCli([ "claws", "remove", "demo-agent", "--dry-run", "--remove-unused", "--remove-referenced", "plugin:@acme/audit@1.0.0", "--json", ]); expect(mocks.buildClawRemovePlan).not.toHaveBeenCalled(); expect(mocks.errors).toContain( "Choose either --remove-unused or --remove-referenced, not both.", ); }); it("fails closed when remove has neither preview nor consent", async () => { await runCli(["claws", "remove", "demo-agent", "--json"]); expect(mocks.buildClawRemovePlan).not.toHaveBeenCalled(); expect(JSON.parse(mocks.logs[0] ?? "{}")).toMatchObject({ error: { code: "consent_required" }, }); }); it("exports one installed agent to a new package directory", async () => { await runCli(["claws", "export", "demo-agent", "--out", "/tmp/exported", "--json"]); expect(mocks.exportClawAgent).toHaveBeenCalledWith("demo-agent", "/tmp/exported", { config: {}, sourceMcpServers: {}, }); expect(JSON.parse(mocks.logs[0] ?? "{}")).toMatchObject({ schemaVersion: "openclaw.clawExportResult.v1", stability: "experimental", agentId: "demo-agent", }); }); });