import { link, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import { loadConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { cronJobReadView } from "../cron/job-read-view.js"; import { normalizeCronJobCreate } from "../cron/normalize.js"; import { upsertCronJobRow } from "../cron/store/row-codec.js"; import type { CronStoredJob } from "../cron/types.js"; import { readAgentDeletionJournal } from "../state/agent-deletion-journal.js"; import { listOpenClawRegisteredAgentDatabases, registerOpenClawAgentDatabase, } from "../state/openclaw-agent-db-registry.js"; import { closeOpenClawStateDatabaseForTest, openOpenClawStateDatabase, } from "../state/openclaw-state-db.js"; import { createOpenClawTestState, type OpenClawTestState, } from "../test-utils/openclaw-test-state.js"; import { clawCronGatewayInput, markClawCronRefRemoved, readClawCronRefs } from "./cron.js"; import { withClawAgentConfigRemoval } from "./lifecycle-config-removal.js"; import { quiescentClawMonitorGateway } from "./lifecycle-remove.test-support.js"; import { applyClawRemovePlan, buildClawRemovePlan, readClawStatus } from "./lifecycle-state.js"; import { createClawRemoveTestFixtures } from "./lifecycle-state.test-helpers.js"; import { persistClawInstallRecord, persistClawPackageRef, readClawPackageRefs, } from "./provenance.js"; let state: OpenClawTestState; beforeEach(async () => { state = await createOpenClawTestState({ prefix: "claw-remove-config-" }); await state.writeConfig({}); }); afterEach(async () => { closeOpenClawStateDatabaseForTest(); await state.cleanup(); }); const tempDirs = useAutoCleanupTempDirTracker(afterEach); const { fixture, addFixture } = createClawRemoveTestFixtures(tempDirs, () => state); const packageIntegrity = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; function cronReadView(agentId: string, ref: ReturnType[number]) { const normalized = normalizeCronJobCreate(clawCronGatewayInput(agentId, ref)); if (!normalized || !ref.schedulerJobId) { throw new Error("expected complete cron provenance"); } return cronJobReadView({ ...normalized, id: ref.schedulerJobId, createdAtMs: 1, updatedAtMs: 1, state: { nextRunAtMs: 100, lastRunAtMs: 50, lastStatus: "ok" }, }); } function seedAttachedCronJob( env: NodeJS.ProcessEnv, job: Pick, ): void { const database = openOpenClawStateDatabase({ env }); upsertCronJobRow( database.db, "default", { ...job, agentId: "worker", owner: { agentId: "worker" }, enabled: true, createdAtMs: 1, updatedAtMs: 1, sessionTarget: "isolated", wakeMode: "now", payload: { kind: "agentTurn", message: "Run scheduled job" }, state: {}, }, 0, ); } describe("Claw status and remove", () => { it("previews locally without probing Gateway when there are no attached jobs, but never applies without one", async () => { const current = await addFixture({ withFile: true }); const inspect = vi.fn(async () => { throw new Error("Gateway offline"); }); const plan = await buildClawRemovePlan("worker", { env: current.env, config: current.getConfig(), monitorGateway: { ...quiescentClawMonitorGateway, inspect }, }); expect(plan.blockers).toEqual([]); expect(inspect).not.toHaveBeenCalled(); await expect( applyClawRemovePlan(plan, { env: current.env, config: current.getConfig(), consentPlanIntegrity: plan.planIntegrity, }), ).rejects.toMatchObject({ code: "monitor_gateway_required" }); await expect(readFile(join(current.plan.agent.workspace, "SOUL.md"), "utf8")).resolves.toBe( "managed\n", ); }); it("rejects cleanup when an expected-missing agent id was recreated", async () => { await expect( withClawAgentConfigRemoval( { agentId: "worker", expectedDigest: "sha256:missing", expectedRemovalSurfaceDigest: "sha256:unused", expectedState: "missing", fallbackWorkspace: "/tmp/old-worker", config: { agents: { entries: { worker: { workspace: "/tmp/new-worker" } } } }, onModified: () => new Error("agent recreated"), }, (commitRemoval) => commitRemoval(), ), ).rejects.toThrow("agent recreated"); }); it("reports installed agent, managed files, and package references", async () => { const current = await addFixture({ withFile: true }); persistClawPackageRef( current.plan, { kind: "plugin", source: "clawhub", ref: "audit", version: "2.0.0", integrity: packageIntegrity, }, { env: current.env, nowMs: 2 }, ); const status = await readClawStatus("worker", { env: current.env, config: current.getConfig(), }); expect(status).toMatchObject({ summary: { claws: 1, partial: 0, missingAgents: 0, driftedFiles: 0, packageRefs: 1, missingPackages: 1, }, records: [ { install: { agentId: "worker", claw: { name: "@acme/worker" } }, agentState: "present", workspaceFiles: [{ path: "SOUL.md", state: "unchanged" }], packages: [{ kind: "plugin", ref: "audit", state: "missing" }], }, ], }); }); it("reports adapter identity drift for an installed extension without mutating provenance", async () => { const current = await addFixture(); const extension = { id: "audit-tools", format: "claude" as const, detectedFormat: "claude" as const, mapped: ["skills"], unavailable: ["agents"], adapterIdentity: "openclaw/previous", }; persistClawPackageRef( current.plan, { kind: "plugin", source: "clawhub", ref: "audit", version: "2.0.0", integrity: packageIntegrity, extension, }, { env: current.env, nowMs: 2, relationship: "referenced" }, ); const status = await readClawStatus("worker", { env: current.env, config: current.getConfig(), packageDeps: { resolvePlugin: async () => ({ status: "found" as const, pluginId: "audit", installedVersion: "2.0.0", record: { source: "clawhub", integrity: packageIntegrity }, }), }, }); expect(status.summary.driftedPackages).toBe(1); expect(status.records[0]?.packages[0]).toMatchObject({ state: "present", extension, extensionCompatibility: { state: "drifted", mapped: ["agents", "skills"], unavailable: [], adapterIdentity: "openclaw/v1", }, }); expect(readClawPackageRefs({ env: current.env })[0]?.extension).toEqual(extension); }); it("reports unavailable extension inspection separately from package drift", async () => { const current = await addFixture(); const extension = { id: "audit-tools", format: "claude" as const, detectedFormat: "claude" as const, mapped: ["skills"], unavailable: ["agents"], adapterIdentity: "openclaw/current", }; persistClawPackageRef( current.plan, { kind: "plugin", source: "clawhub", ref: "audit", version: "2.0.0", integrity: packageIntegrity, extension, }, { env: current.env, nowMs: 2, relationship: "referenced" }, ); const status = await readClawStatus("worker", { env: current.env, config: current.getConfig(), packageDeps: { resolvePlugin: async () => ({ status: "found" as const, pluginId: "audit", installedVersion: "2.0.0", record: { source: "clawhub", integrity: packageIntegrity }, }), }, packagePreflight: async () => ({ ok: false, code: "extension_unavailable", message: "Canonical extension inspection is unavailable.", }), }); expect(status.summary).toMatchObject({ driftedPackages: 0, unavailableExtensions: 1 }); expect(status.records[0]?.packages[0]).toMatchObject({ state: "present", extensionCompatibility: { state: "unavailable", message: "Canonical extension inspection is unavailable.", }, }); }); it("counts every non-complete root install as partial", async () => { const current = await fixture(); persistClawInstallRecord(current.plan, { env: current.env, status: "config_committed" }); await expect( readClawStatus("worker", { env: current.env, config: { agents: { entries: {} } } }), ).resolves.toMatchObject({ summary: { claws: 1, partial: 1 } }); }); it("reports orphaned subordinate ownership without a root install row", async () => { const current = await fixture(); persistClawPackageRef( current.plan, { kind: "plugin", source: "clawhub", ref: "audit", version: "2.0.0", integrity: packageIntegrity, }, { env: current.env, nowMs: 2 }, ); await expect(readClawStatus("worker", { env: current.env, config: {} })).resolves.toMatchObject( { summary: { claws: 1, partial: 1, missingAgents: 1, packageRefs: 1 }, records: [ { orphaned: true, install: { agentId: "worker", status: "partial" }, packages: [{ ref: "audit", state: "missing" }], }, ], }, ); const remove = await buildClawRemovePlan("worker", { env: current.env, config: {} }); const removed = await applyClawRemovePlan(remove, { monitorGateway: quiescentClawMonitorGateway, trashPath: async () => true, env: current.env, config: {}, consentPlanIntegrity: remove.planIntegrity, purgeSessions: async () => undefined, }); expect(removed).toMatchObject({ status: "complete", agentRemoved: false }); await expect(readClawStatus("worker", { env: current.env, config: {} })).resolves.toMatchObject( { summary: { claws: 0 }, }, ); }); it("previews all canonical agent config deletion effects", async () => { const current = await addFixture(); const config: OpenClawConfig = { ...current.getConfig(), bindings: [{ match: { channel: "telegram", accountId: "*" }, agentId: "worker" }], tools: { agentToAgent: { allow: ["worker"] } }, } as OpenClawConfig; const plan = await buildClawRemovePlan("worker", { env: current.env, config }); expect(plan.actions).toEqual( expect.arrayContaining([ expect.objectContaining({ kind: "agent", target: 'agents.entries["worker"]' }), expect.objectContaining({ kind: "configBinding", target: "bindings[agentId=worker]" }), expect.objectContaining({ kind: "agentAllow", target: "tools.agentToAgent.allow[worker]" }), expect.objectContaining({ kind: "workspace", action: "trash" }), expect.objectContaining({ kind: "agentState", action: "trash" }), expect.objectContaining({ kind: "sessionIndex", action: "delete" }), expect.objectContaining({ kind: "sessionTranscripts", action: "trash" }), ]), ); }); it("refuses changed bindings and retains the cleanup fence", async () => { const current = await addFixture(); const config: OpenClawConfig = { ...current.getConfig(), bindings: [{ match: { channel: "telegram", accountId: "first" }, agentId: "worker" }], } as OpenClawConfig; const plan = await buildClawRemovePlan("worker", { env: current.env, config }); const changedConfig: OpenClawConfig = { ...config, bindings: [{ match: { channel: "telegram", accountId: "second" }, agentId: "worker" }], } as OpenClawConfig; await expect( applyClawRemovePlan(plan, { monitorGateway: { ...quiescentClawMonitorGateway, quiesce: async () => { await state.writeConfig(changedConfig); }, }, env: current.env, config, consentPlanIntegrity: plan.planIntegrity, }), ).resolves.toMatchObject({ status: "partial", agentRemoved: false, error: { code: "agent_modified" }, }); expect(readAgentDeletionJournal("worker", { env: current.env })).toMatchObject({ cleanupCompleted: false, }); }); it("previews and blocks operator-owned cron jobs attached to the agent", async () => { const current = await addFixture(); seedAttachedCronJob(current.env, { id: "operator-job", name: "Operator job", schedule: { kind: "every", everyMs: 60_000 }, }); const plan = await buildClawRemovePlan("worker", { env: current.env, config: current.getConfig(), }); expect(plan.blockers).toContainEqual(expect.objectContaining({ code: "agent_job_attached" })); expect(plan.actions).toContainEqual( expect.objectContaining({ kind: "scheduledJob", id: "operator-job", action: "retain", blocked: true, }), ); }); it.each([false, true])( "keeps Claw-owned cron removal actions consistent (independent jobs=%s)", async (withIndependentJobs) => { const current = await addFixture({ withCron: true }); seedAttachedCronJob(current.env, { id: "scheduler-daily", name: "Claw job", schedule: { kind: "cron", expr: "0 9 * * *", tz: "UTC" }, }); if (withIndependentJobs) { for (const id of ["z-operator-job", "a-operator-job"]) { seedAttachedCronJob(current.env, { id, name: "Operator job", schedule: { kind: "every", everyMs: 60_000 }, }); } } const plan = await buildClawRemovePlan("worker", { env: current.env, config: current.getConfig(), }); const independentIds = withIndependentJobs ? ["a-operator-job", "z-operator-job"] : []; expect(plan.blockers).toEqual( independentIds.map((id) => ({ code: "agent_job_attached", message: expect.stringContaining(JSON.stringify(id)), })), ); expect(plan.actions.filter((action) => action.kind === "scheduledJob")).toEqual( independentIds.map((id) => expect.objectContaining({ id, action: "retain", blocked: true }), ), ); expect(plan.actions.filter((action) => action.kind === "cronJob")).toEqual([ expect.objectContaining({ id: "daily-report", target: "scheduler-daily", action: "remove", blocked: false, }), ]); }, ); it("removes the agent and unchanged files but only releases package refs", async () => { const current = await addFixture({ withFile: true }); const databasePath = join( current.env.OPENCLAW_STATE_DIR, "agents", "worker", "agent", "openclaw-agent.sqlite", ); registerOpenClawAgentDatabase({ agentId: "worker", path: databasePath, env: current.env }); persistClawPackageRef( current.plan, { kind: "skill", source: "clawhub", ref: "triage", version: "1.0.0", integrity: packageIntegrity, }, { env: current.env }, ); const plan = await buildClawRemovePlan("worker", { env: current.env, config: current.getConfig(), }); const config = current.getConfig(); const result = await applyClawRemovePlan(plan, { monitorGateway: quiescentClawMonitorGateway, trashPath: async () => true, consentPlanIntegrity: plan.planIntegrity, env: current.env, config, }); expect(result).toMatchObject({ status: "complete", agentRemoved: true, packageRefsReleased: 1, workspaceFiles: [{ path: "SOUL.md", action: "deleted" }], }); expect(loadConfig().agents?.entries?.worker).toBeUndefined(); expect( listOpenClawRegisteredAgentDatabases({ env: current.env }).map((entry) => entry.agentId), ).not.toContain("worker"); await expect(readFile(join(current.plan.agent.workspace, "SOUL.md"), "utf8")).rejects.toThrow(); await expect( readClawStatus("worker", { env: current.env, config: loadConfig() }), ).resolves.toMatchObject({ summary: { claws: 0 }, }); }); it("removes scheduler-owned cron jobs before agent config", async () => { const current = await addFixture({ withCron: true }); const plan = await buildClawRemovePlan("worker", { env: current.env, config: current.getConfig(), }); expect(plan.actions).toContainEqual( expect.objectContaining({ kind: "cronJob", id: "daily-report", action: "remove", target: "scheduler-daily", }), ); const config = current.getConfig(); const order: string[] = []; const result = await applyClawRemovePlan(plan, { monitorGateway: quiescentClawMonitorGateway, trashPath: async () => true, consentPlanIntegrity: plan.planIntegrity, env: current.env, config, cronGateway: { get: async () => cronReadView("worker", readClawCronRefs("worker", { env: current.env })[0]!), remove: async (id) => { expect(loadConfig().agents?.entries?.worker).toBeDefined(); order.push(`cron:${id}`); return { ok: true }; }, }, }); expect(order).toEqual(["cron:scheduler-daily"]); expect(loadConfig().agents?.entries?.worker).toBeUndefined(); expect(result).toMatchObject({ status: "complete", cronJobs: [ { manifestId: "daily-report", schedulerJobId: "scheduler-daily", action: "removed" }, ], }); }); it("accepts scheduler defaults when removing a Claw cron job", async () => { const current = await addFixture({ withCron: true }); const plan = await buildClawRemovePlan("worker", { env: current.env, config: current.getConfig(), }); const ref = readClawCronRefs("worker", { env: current.env })[0]!; const live = cronReadView("worker", ref); const remove = vi.fn().mockResolvedValue({ ok: true }); const result = await applyClawRemovePlan(plan, { monitorGateway: quiescentClawMonitorGateway, trashPath: async () => true, consentPlanIntegrity: plan.planIntegrity, env: current.env, config: current.getConfig(), cronGateway: { get: async () => ({ ...live, payload: { ...live.payload, toolsAllow: ["*"] }, scheduledToolPolicy: { version: 1, mode: "trusted" }, }), remove, }, }); expect(remove).toHaveBeenCalledWith(ref.schedulerJobId); expect(result).toMatchObject({ status: "complete", agentRemoved: true, cronJobs: [{ manifestId: "daily-report", action: "removed" }], }); }); it("fails removal planning when source MCP config cannot be read", async () => { const current = await addFixture({ withCron: true }); await expect( buildClawRemovePlan("worker", { env: current.env, config: current.getConfig(), listMcpServers: async () => ({ ok: false, path: "config", error: "Config file is invalid.", }), }), ).rejects.toMatchObject({ code: "mcp_config_unavailable" }); }); it("retains the agent when recurring work cannot be disabled", async () => { const current = await addFixture({ withCron: true }); const plan = await buildClawRemovePlan("worker", { env: current.env, config: current.getConfig(), }); const result = await applyClawRemovePlan(plan, { monitorGateway: quiescentClawMonitorGateway, trashPath: async () => true, consentPlanIntegrity: plan.planIntegrity, env: current.env, config: current.getConfig(), cronGateway: { get: async () => cronReadView("worker", readClawCronRefs("worker", { env: current.env })[0]!), remove: async () => { throw new Error("scheduler unavailable"); }, }, }); expect(result).toMatchObject({ status: "partial", agentRemoved: false, error: { code: "cron_cleanup_failed", message: "scheduler unavailable" }, cronJobs: [{ manifestId: "daily-report", action: "error" }], }); }); it("reconciles a lost cron.remove response when the gateway confirms absence", async () => { const current = await addFixture({ withCron: true }); const plan = await buildClawRemovePlan("worker", { env: current.env, config: current.getConfig(), }); const ref = readClawCronRefs("worker", { env: current.env })[0]!; let present = true; const config = current.getConfig(); const result = await applyClawRemovePlan(plan, { monitorGateway: quiescentClawMonitorGateway, trashPath: async () => true, consentPlanIntegrity: plan.planIntegrity, env: current.env, config, cronGateway: { get: async () => (present ? cronReadView("worker", ref) : undefined), remove: async () => { present = false; throw new Error("response lost"); }, }, }); expect(result).toMatchObject({ status: "complete", cronJobs: [{ manifestId: "daily-report", action: "removed" }], }); }); it("preserves a live cron job that changed after planning", async () => { const current = await addFixture({ withCron: true }); const plan = await buildClawRemovePlan("worker", { env: current.env, config: current.getConfig(), }); const remove = vi.fn(); const result = await applyClawRemovePlan(plan, { monitorGateway: quiescentClawMonitorGateway, trashPath: async () => true, consentPlanIntegrity: plan.planIntegrity, env: current.env, config: current.getConfig(), cronGateway: { get: async () => ({ ...cronReadView("worker", readClawCronRefs("worker", { env: current.env })[0]!), schedule: { kind: "cron", expr: "0 12 * * *", tz: "UTC" }, }), remove, }, }); expect(result).toMatchObject({ status: "partial", agentRemoved: false, error: { code: "cron_cleanup_failed", message: expect.stringContaining("changed") }, }); expect(remove).not.toHaveBeenCalled(); }); it("finishes local cleanup without repeating a confirmed remote cron removal", async () => { const current = await addFixture({ withCron: true }); markClawCronRefRemoved("worker", "daily-report", { env: current.env }); const plan = await buildClawRemovePlan("worker", { env: current.env, config: current.getConfig(), }); const config = current.getConfig(); const remoteRemovals: string[] = []; const result = await applyClawRemovePlan(plan, { monitorGateway: quiescentClawMonitorGateway, trashPath: async () => true, consentPlanIntegrity: plan.planIntegrity, env: current.env, config, cronGateway: { remove: async (id) => { remoteRemovals.push(id); return { ok: true }; }, }, }); expect(remoteRemovals).toEqual([]); expect(result).toMatchObject({ status: "complete", cronJobs: [{ manifestId: "daily-report", action: "removed" }], }); }); it("preserves modified files while releasing their provenance", async () => { const current = await addFixture({ withFile: true }); const target = join(current.plan.agent.workspace, "SOUL.md"); await writeFile(target, "operator edit\n", "utf8"); const plan = await buildClawRemovePlan("worker", { env: current.env, config: current.getConfig(), }); expect(plan.actions).toContainEqual( expect.objectContaining({ kind: "workspace", action: "retain" }), ); const trashPath = vi.fn().mockResolvedValue(true); expect(plan.actions).toContainEqual( expect.objectContaining({ kind: "workspaceFile", action: "retain", blocked: false }), ); const config = current.getConfig(); const result = await applyClawRemovePlan(plan, { monitorGateway: quiescentClawMonitorGateway, consentPlanIntegrity: plan.planIntegrity, env: current.env, config, trashPath, }); expect(result.workspaceFiles).toEqual([{ path: "SOUL.md", action: "retainedModified" }]); await expect(readFile(target, "utf8")).resolves.toBe("operator edit\n"); expect(trashPath).not.toHaveBeenCalledWith(current.plan.agent.workspace, expect.anything()); }); it("preserves a workspace containing operator-created files", async () => { const current = await addFixture({ withFile: true }); const operatorFile = join(current.plan.agent.workspace, "operator-notes.md"); await writeFile(operatorFile, "keep me\n", "utf8"); const config = current.getConfig(); const plan = await buildClawRemovePlan("worker", { env: current.env, config }); expect(plan.actions).toContainEqual( expect.objectContaining({ kind: "workspace", action: "retain" }), ); const trashPath = vi.fn().mockResolvedValue(true); await expect( applyClawRemovePlan(plan, { monitorGateway: quiescentClawMonitorGateway, env: current.env, config, consentPlanIntegrity: plan.planIntegrity, purgeSessions: async () => undefined, trashPath, }), ).resolves.toMatchObject({ status: "complete" }); await expect(readFile(operatorFile, "utf8")).resolves.toBe("keep me\n"); expect(trashPath).not.toHaveBeenCalledWith(current.plan.agent.workspace, expect.anything()); }); it("retains a replacement introduced after planning instead of deleting it", async () => { const current = await addFixture({ withFile: true }); const target = join(current.plan.agent.workspace, "SOUL.md"); const plan = await buildClawRemovePlan("worker", { env: current.env, config: current.getConfig(), }); const config = current.getConfig(); const result = await applyClawRemovePlan(plan, { monitorGateway: { ...quiescentClawMonitorGateway, drain: async () => { expect(loadConfig().agents?.entries?.worker).toBeUndefined(); await writeFile(target, "replacement\n", "utf8"); }, }, trashPath: async () => true, consentPlanIntegrity: plan.planIntegrity, env: current.env, config, }); expect(result).toMatchObject({ status: "complete", workspaceFiles: [{ path: "SOUL.md", action: "retainedModified" }], }); await expect(readFile(target, "utf8")).resolves.toBe("replacement\n"); }); it("keeps the install ledger when workspace cleanup becomes unsafe after config commit", async () => { const current = await addFixture({ withFile: true }); const target = join(current.plan.agent.workspace, "SOUL.md"); const plan = await buildClawRemovePlan("worker", { env: current.env, config: current.getConfig(), }); const config = current.getConfig(); const result = await applyClawRemovePlan(plan, { monitorGateway: { ...quiescentClawMonitorGateway, drain: async () => { expect(loadConfig().agents?.entries?.worker).toBeUndefined(); await rm(target); await link(join(current.root, "SOUL.md"), target); }, }, trashPath: async () => true, consentPlanIntegrity: plan.planIntegrity, env: current.env, config, }); expect(result).toMatchObject({ status: "partial", agentRemoved: true, workspaceFiles: [{ path: "SOUL.md", action: "error" }], error: { code: "workspace_cleanup_failed" }, }); await expect( readClawStatus("worker", { env: current.env, config: loadConfig() }), ).resolves.toMatchObject({ summary: { claws: 1, missingAgents: 1 }, records: [{ install: { status: "partial" }, workspaceFiles: [{ state: "unsafe" }] }], }); }); it("purges session indexes and keeps provenance when canonical trash cleanup fails", async () => { const current = await addFixture(); const config = current.getConfig(); const plan = await buildClawRemovePlan("worker", { env: current.env, config }); let purgedAgentId: string | undefined; const result = await applyClawRemovePlan(plan, { monitorGateway: quiescentClawMonitorGateway, consentPlanIntegrity: plan.planIntegrity, env: current.env, config, purgeSessions: async (_cfg, agentId) => { purgedAgentId = agentId; }, trashPath: async () => false, }); expect(purgedAgentId).toBe("worker"); expect(result).toMatchObject({ status: "partial", agentRemoved: true, error: { code: "workspace_cleanup_failed" }, }); await expect( readClawStatus("worker", { env: current.env, config: loadConfig() }), ).resolves.toMatchObject({ records: [{ install: { status: "partial" } }] }); }); it("releases global plugin references without uninstalling the plugin", async () => { const current = await addFixture(); persistClawPackageRef( current.plan, { kind: "plugin", source: "clawhub", ref: "audit", version: "1.0.0", integrity: packageIntegrity, }, { env: current.env, relationship: "referenced", origin: "claw-introduced", independentOwner: false, }, ); const config = current.getConfig(); const resolvePlugin = vi.fn().mockResolvedValue({ status: "found", pluginId: "audit", record: { source: "clawhub", integrity: packageIntegrity }, installedVersion: "1.0.0", }); const packageDeps = { resolvePlugin, acquirePackageLease: vi.fn(() => ({ heartbeat: vi.fn(), release: vi.fn() })), }; const plan = await buildClawRemovePlan("worker", { env: current.env, config, packageDeps, }); expect(plan.actions).toContainEqual( expect.objectContaining({ kind: "packageRef", action: "release", reason: expect.stringContaining("Claw add introduced this shared requirement"), details: expect.objectContaining({ introducedByClawAdd: true }), }), ); await expect( applyClawRemovePlan(plan, { monitorGateway: quiescentClawMonitorGateway, trashPath: async () => true, env: current.env, config, consentPlanIntegrity: plan.planIntegrity, packageDeps, }), ).resolves.toMatchObject({ status: "complete", agentRemoved: true }); }); it("blocks removal when the created agent config changed", async () => { const current = await addFixture(); const config = current.getConfig(); const agent = config.agents!.entries!.worker!; config.agents!.entries!.worker = { ...agent, name: "Operator edit" }; const plan = await buildClawRemovePlan("worker", { env: current.env, config }); expect(plan.blockers).toContainEqual(expect.objectContaining({ code: "agent_modified" })); await expect( applyClawRemovePlan(plan, { monitorGateway: quiescentClawMonitorGateway, trashPath: async () => true, env: current.env, config, consentPlanIntegrity: plan.planIntegrity, }), ).rejects.toMatchObject({ code: "remove_blocked", }); }); it("rejects removal consent for a different plan identity", async () => { const current = await addFixture(); const config = current.getConfig(); const plan = await buildClawRemovePlan("worker", { env: current.env, config }); await expect( applyClawRemovePlan(plan, { monitorGateway: quiescentClawMonitorGateway, trashPath: async () => true, env: current.env, config, consentPlanIntegrity: "sha256:stale", }), ).rejects.toMatchObject({ code: "plan_integrity_mismatch" }); }); it("requires an agent id when a package identity has multiple installs", async () => { const first = await fixture({ id: "worker-a", name: "@acme/shared" }); const second = await fixture({ id: "worker-b", name: "@acme/shared" }); persistClawInstallRecord(first.plan, { env: first.env }); persistClawInstallRecord(second.plan, { env: first.env }); const plan = await buildClawRemovePlan("@acme/shared", { env: first.env, config: {} }); expect(plan.blockers).toContainEqual(expect.objectContaining({ code: "claw_ambiguous" })); }); it("keeps Claw-introduced plugin origin on every surviving Claw reference", async () => { const first = await fixture({ id: "worker-a", name: "@acme/first" }); const second = await fixture({ id: "worker-b", name: "@acme/second" }); persistClawInstallRecord(first.plan, { env: first.env, nowMs: 1 }); persistClawInstallRecord(second.plan, { env: first.env, nowMs: 2 }); const plugin = { kind: "plugin", source: "clawhub", ref: "audit", version: "1.0.0", integrity: packageIntegrity, } as const; persistClawPackageRef(first.plan, plugin, { env: first.env, nowMs: 1, relationship: "referenced", origin: "claw-introduced", independentOwner: false, }); persistClawPackageRef(second.plan, plugin, { env: first.env, nowMs: 2, relationship: "referenced", origin: "claw-introduced", independentOwner: false, }); const { id: firstId, ...firstConfig } = first.plan.agent.config; const { id: secondId, ...secondConfig } = second.plan.agent.config; const config: OpenClawConfig = { agents: { entries: { [firstId]: firstConfig, [secondId]: secondConfig } }, }; await state.writeConfig(config); const remove = await buildClawRemovePlan("worker-a", { env: first.env, config }); await applyClawRemovePlan(remove, { monitorGateway: quiescentClawMonitorGateway, trashPath: async () => true, consentPlanIntegrity: remove.planIntegrity, env: first.env, config, }); expect(readClawPackageRefs({ env: first.env, agentId: "worker-b" })).toMatchObject([ { ref: "audit", relationship: "referenced", origin: "claw-introduced", independentOwner: false, }, ]); }); });