diff --git a/docs/cli/claws.md b/docs/cli/claws.md index b4e311e4587a..fb99101cd950 100644 --- a/docs/cli/claws.md +++ b/docs/cli/claws.md @@ -1,8 +1,9 @@ --- -summary: "Validate, preview, and add experimental Claw agent packages" +summary: "Add, inspect, and remove experimental Claw agent packages" read_when: - You want to validate a grouped Claw manifest - You want to preview or add one agent from a Claw + - You need to inspect Claw ownership, drift, or cleanup behavior title: "Claws" --- @@ -130,12 +131,68 @@ workspace files, installs or reuses declared skill and plugin artifacts, and records provenance. Existing files are not overwritten, and retries fail closed when owned content drifted. Later Claws stages add other declared resources. +## Inspect installed state + +```bash +openclaw claws status +openclaw claws status incident-triage --json +``` + +`status` compares the installed agent and its recorded workspace and package +provenance with current state. It reports incomplete installs, missing +resources, and drift without changing local state. + +Claw provenance distinguishes two relationships: + +- **Managed:** the Claw introduced and currently manages the resource. It is a + cleanup candidate when unchanged and no conflicting owner remains. +- **Referenced:** the resource existed independently or is shared. Removal + releases this Claw's reference and retains the resource by default. + +This is not a reference count. Ordinary plugin, skill, and agent commands keep +their existing behavior; Claws add provenance and guarded lifecycle operations +on top. + +## Remove an installed Claw + +Preview removal before selecting cleanup: + +```bash +openclaw claws remove incident-triage --dry-run --json +openclaw claws remove incident-triage \ + --yes \ + --plan-integrity +``` + +The default removes eligible managed state and releases referenced state. +Modified files and resources with another current owner are retained or +blocked. Cleanup choices are part of the plan digest; `--yes` never broadens +them. Globally installed plugins are retained while this Claw's reference is +released; use the ordinary plugin lifecycle separately when you intend to +uninstall a process-wide plugin. + +To remove unchanged Claw-introduced references that have no other current +owner, include `--remove-unused` in both preview and apply. To select exact +referenced resources instead, repeat `--remove-referenced`: + +```bash +openclaw claws remove incident-triage \ + --dry-run \ + --remove-referenced 'plugin:@acme/audit-plugin@2.0.0' +``` + +Use `--force-referenced` only after reviewing the displayed dependents, +independent owners, and pre-existing origin. It allows selected cleanup despite +those conflicts; it does not skip plan-integrity consent. + ## Command reference -| Command | Purpose | -| ------------------------ | ---------------------------------------------- | -| `claws inspect ` | Validate a package directory or JSON manifest. | -| `claws add ` | Preview or create one new agent and workspace. | +| Command | Purpose | +| ------------------------------ | --------------------------------------------------- | +| `claws inspect ` | Validate a package directory or JSON manifest. | +| `claws add ` | Preview or create one new agent and workspace. | +| `claws status [claw-or-agent]` | Report installed state, ownership, and drift. | +| `claws remove ` | Preview or remove the agent and eligible resources. | Use `--json` for experimental machine-readable output. diff --git a/docs/docs_map.md b/docs/docs_map.md index 8bb782b5d13f..311814a9d39f 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -1372,6 +1372,8 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H1: openclaw claws - H2: Create a grouped manifest - H2: Inspect and preview + - H2: Inspect installed state + - H2: Remove an installed Claw - H2: Command reference - H2: See also diff --git a/src/claws/lifecycle-config-removal.ts b/src/claws/lifecycle-config-removal.ts new file mode 100644 index 000000000000..1d2d2ae8ba42 --- /dev/null +++ b/src/claws/lifecycle-config-removal.ts @@ -0,0 +1,152 @@ +import { createHash } from "node:crypto"; +import { stableStringify } from "../agents/stable-stringify.js"; +import { getRuntimeConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + AgentConfigPreconditionError, + deleteAgentConfigEntry, +} from "../gateway/server-methods/agents-config-mutations.js"; +import { normalizeAgentId } from "../routing/session-key.js"; +import { + deletionEffects, + type ClawCleanupTargets, + type ClawTrashPath, +} from "./lifecycle-delete-support.js"; + +export type ConfigCommit = (transform: (config: OpenClawConfig) => OpenClawConfig) => Promise; + +export function digestClawAgentConfig( + agent: NonNullable["list"]>[number], +): string { + return `sha256:${createHash("sha256").update(stableStringify(agent)).digest("hex")}`; +} + +export function digestClawAgentRemovalSurface(config: OpenClawConfig, agentId: string): string { + const normalizedId = normalizeAgentId(agentId); + const surface = { + bindings: (config.bindings ?? []).filter( + (binding) => normalizeAgentId(binding.agentId) === normalizedId, + ), + agentToAgentAllow: (config.tools?.agentToAgent?.allow ?? []).filter( + (entry) => entry === normalizedId, + ), + }; + return `sha256:${createHash("sha256").update(stableStringify(surface)).digest("hex")}`; +} + +export async function claimClawAgentConfigRemoval(params: { + agentId: string; + expectedDigest: string; + expectedRemovalSurfaceDigest: string; + expectedState: "present" | "missing"; + fallbackWorkspace: string; + config?: OpenClawConfig; + commitConfig?: ConfigCommit; + trashPath?: ClawTrashPath; + onModified: () => Error; +}): Promise<{ + agentRemoved: boolean; + cleanupTargets?: ClawCleanupTargets; + configBeforeDelete: OpenClawConfig; + nextConfig: OpenClawConfig; +}> { + if (params.commitConfig) { + let result: + | { + agentRemoved: boolean; + cleanupTargets?: ClawCleanupTargets; + configBeforeDelete: OpenClawConfig; + nextConfig: OpenClawConfig; + } + | undefined; + await params.commitConfig((config) => { + const effects = deletionEffects(config, params.agentId, params.fallbackWorkspace); + const agent = config.agents?.list?.find((candidate) => candidate.id === params.agentId); + if ( + (agent && digestClawAgentConfig(agent) !== params.expectedDigest) || + digestClawAgentRemovalSurface(config, params.agentId) !== + params.expectedRemovalSurfaceDigest + ) { + throw params.onModified(); + } + result = { + agentRemoved: Boolean(agent), + ...(params.trashPath + ? { + cleanupTargets: { + workspaceDir: effects.workspace, + agentDir: effects.agentDir, + sessionsDir: effects.sessionsDir, + }, + } + : {}), + configBeforeDelete: config, + nextConfig: effects.pruned.config, + }; + return effects.pruned.config; + }); + if (!result) { + throw new Error("Claw config removal did not run its commit transform."); + } + return result; + } + + const configBeforeDelete = params.config ?? getRuntimeConfig(); + try { + const committed = await deleteAgentConfigEntry({ + agentId: params.agentId, + allowMissing: params.expectedState === "missing", + fallbackWorkspace: params.fallbackWorkspace, + validateConfig: (config) => { + if ( + digestClawAgentRemovalSurface(config, params.agentId) !== + params.expectedRemovalSurfaceDigest + ) { + throw params.onModified(); + } + }, + validate: (agent) => { + if (params.expectedState === "missing") { + throw params.onModified(); + } + if (digestClawAgentConfig(agent) !== params.expectedDigest) { + throw params.onModified(); + } + }, + }); + const fallbackEffects = deletionEffects( + configBeforeDelete, + params.agentId, + params.fallbackWorkspace, + ); + return { + agentRemoved: Boolean(committed.result), + cleanupTargets: committed.result ?? { + workspaceDir: fallbackEffects.workspace, + agentDir: fallbackEffects.agentDir, + sessionsDir: fallbackEffects.sessionsDir, + }, + configBeforeDelete, + nextConfig: committed.nextConfig, + }; + } catch (error) { + if (!(error instanceof AgentConfigPreconditionError)) { + throw error; + } + const latestConfig = getRuntimeConfig(); + if (latestConfig.agents?.list?.some((agent) => agent.id === params.agentId)) { + throw params.onModified(); + } + const effects = deletionEffects(latestConfig, params.agentId, params.fallbackWorkspace); + return { + agentRemoved: false, + cleanupTargets: { + workspaceDir: effects.workspace, + agentDir: effects.agentDir, + sessionsDir: effects.sessionsDir, + }, + configBeforeDelete, + nextConfig: latestConfig, + }; + } +} diff --git a/src/claws/lifecycle-delete-support.ts b/src/claws/lifecycle-delete-support.ts new file mode 100644 index 000000000000..ad3cfc5a2e29 --- /dev/null +++ b/src/claws/lifecycle-delete-support.ts @@ -0,0 +1,273 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import type { DatabaseSync } from "node:sqlite"; +import { findOverlappingWorkspaceAgentIds } from "../agents/agent-delete-safety.js"; +import { resolveAgentDir } from "../agents/agent-scope.js"; +import { + prepareLegacyWorkspaceStateReset, + removeLegacyWorkspaceStateForReset, +} from "../agents/workspace-legacy-state.js"; +import { + deleteWorkspaceState, + prepareWorkspaceStateDeletion, +} from "../agents/workspace-state-store.js"; +import { pruneAgentConfig } from "../commands/agents.config.js"; +import { moveToTrash } from "../commands/onboard-helpers.js"; +import { resolveSessionTranscriptsDirForAgent } from "../config/sessions.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { + openOpenClawStateDatabase, + type OpenClawStateDatabaseOptions, +} from "../state/openclaw-state-db.js"; +import type { PersistedClawInstall } from "./provenance.js"; +import type { PersistedClawWorkspaceFile } from "./workspace.js"; + +type WorkspaceFileRow = { + schema_version: string; + agent_id: string; + workspace: string; + target_path: string; + source_path: string; + content_digest: string; + status: PersistedClawWorkspaceFile["status"]; + created_at_ms: number | bigint; + updated_at_ms: number | bigint; +}; + +export function clawStateTableExists(db: DatabaseSync, name: string): boolean { + return Boolean( + db /* sqlite-allow-raw: schema probe for optional Claw state tables. */ + .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?") + .get(name), + ); +} + +function rowToWorkspaceFile(row: WorkspaceFileRow): PersistedClawWorkspaceFile { + return { + schemaVersion: row.schema_version as PersistedClawWorkspaceFile["schemaVersion"], + agentId: row.agent_id, + workspace: row.workspace, + path: row.target_path, + sourcePath: row.source_path, + contentDigest: row.content_digest, + status: row.status, + createdAtMs: Number(row.created_at_ms), + updatedAtMs: Number(row.updated_at_ms), + }; +} + +export function readAllClawWorkspaceFiles( + options: OpenClawStateDatabaseOptions, +): PersistedClawWorkspaceFile[] { + const database = openOpenClawStateDatabase(options); + if (!clawStateTableExists(database.db, "claw_workspace_files")) { + return []; + } + const rows = database.db /* sqlite-allow-raw: read-only Claw workspace-file orphan inventory. */ + .prepare( + `SELECT schema_version, agent_id, workspace, target_path, source_path, + content_digest, status, created_at_ms, updated_at_ms + FROM claw_workspace_files + ORDER BY agent_id, target_path`, + ) + .all() as WorkspaceFileRow[]; + return rows.map(rowToWorkspaceFile); +} + +export function synthesizeOrphanInstall(params: { + agentId: string; + clawName?: string; + workspace?: string; + updatedAtMs?: number; +}): PersistedClawInstall { + const updatedAtMs = params.updatedAtMs ?? 0; + return { + schemaVersion: "openclaw.clawInstallRecord.v1" as PersistedClawInstall["schemaVersion"], + claw: { + kind: "development", + name: params.clawName ?? `orphan:${params.agentId}`, + version: "0.0.0", + packageRoot: "", + manifestPath: "", + integrityKind: "development-snapshot", + integrity: "sha256:orphan", + byteLength: 0, + }, + manifestSchemaVersion: 1, + planIntegrity: "sha256:orphan", + agentId: params.agentId, + workspace: params.workspace ?? "", + agentConfigDigest: "sha256:missing", + agentOwnedPaths: [], + status: "partial", + addedAtMs: updatedAtMs, + updatedAtMs, + }; +} + +export function deletionEffects(config: OpenClawConfig, agentId: string, fallbackWorkspace = "") { + const agent = config.agents?.list?.find((candidate) => candidate.id === agentId); + const pruned = pruneAgentConfig(config, agentId); + const workspace = agent?.workspace ?? fallbackWorkspace; + const agentDir = resolveAgentDir(config, agentId); + const sessionsDir = resolveSessionTranscriptsDirForAgent(agentId); + const workspaceSharedWith = workspace + ? findOverlappingWorkspaceAgentIds(config, agentId, workspace) + : []; + return { + pruned, + workspace, + agentDir, + sessionsDir, + workspaceSharedWith, + workspaceRetained: workspaceSharedWith.length > 0, + }; +} + +type AttachedCronJob = { + id: string; + name: string; + enabled: boolean; + agentId: string | null; + ownerAgentId: string | null; +}; + +/** Inventories cron jobs that would retain a reference to a removed agent. */ +export function readAttachedCronJobs( + agentId: string, + options: OpenClawStateDatabaseOptions, +): AttachedCronJob[] { + const database = openOpenClawStateDatabase(options); + if (!clawStateTableExists(database.db, "cron_jobs")) { + return []; + } + return database.db /* sqlite-allow-raw: read-only cron references for Claw removal planning. */ + .prepare( + `SELECT job_id AS id, name, enabled, agent_id AS agentId, owner_agent_id AS ownerAgentId + FROM cron_jobs + WHERE agent_id = ? OR owner_agent_id = ? + ORDER BY job_id`, + ) + .all(agentId, agentId) + .map((row) => { + const value = row as { + id: string; + name: string; + enabled: number; + agentId: string | null; + ownerAgentId: string | null; + }; + return { + id: value.id, + name: value.name, + enabled: value.enabled === 1, + agentId: value.agentId, + ownerAgentId: value.ownerAgentId, + }; + }); +} + +export type ClawCleanupTargets = { + workspaceDir: string; + agentDir: string; + sessionsDir: string; +}; +export type ClawTrashPath = typeof moveToTrash; + +/** Returns true when removing a workspace would discard anything outside Claw provenance. */ +export async function workspaceContainsUntrackedEntries( + workspaceRoot: string, + trackedPaths: string[], +): Promise { + const tracked = new Set(trackedPaths.map((entry) => path.normalize(entry))); + const trackedDirectories = new Set(); + for (const trackedPath of tracked) { + let parent = path.dirname(trackedPath); + while (parent && parent !== ".") { + trackedDirectories.add(parent); + const next = path.dirname(parent); + if (next === parent) { + break; + } + parent = next; + } + } + const walk = async (absoluteDir: string, relativeDir = ""): Promise => { + const entries = await fs.readdir(absoluteDir, { withFileTypes: true }); + for (const entry of entries) { + const relativeEntry = path.join(relativeDir, entry.name); + if (entry.isDirectory() && !entry.isSymbolicLink()) { + if (!trackedDirectories.has(path.normalize(relativeEntry))) { + return true; + } + if (await walk(path.join(absoluteDir, entry.name), relativeEntry)) { + return true; + } + continue; + } + if (!tracked.has(path.normalize(relativeEntry))) { + return true; + } + } + return false; + }; + try { + return await walk(workspaceRoot); + } catch (error) { + return (error as NodeJS.ErrnoException).code !== "ENOENT"; + } +} + +/** Applies canonical post-config filesystem cleanup and reports every failed effect. */ +export async function cleanupClawAgentFilesystem(params: { + agentId: string; + nextConfig: OpenClawConfig; + targets: ClawCleanupTargets; + runtime: RuntimeEnv; + trashPath?: ClawTrashPath; + retainWorkspace?: boolean; +}): Promise { + const errors: string[] = []; + const trashPath = params.trashPath ?? moveToTrash; + const workspaceSharedWith = params.targets.workspaceDir + ? findOverlappingWorkspaceAgentIds( + params.nextConfig, + params.agentId, + params.targets.workspaceDir, + ) + : []; + if (params.targets.workspaceDir && !params.retainWorkspace && workspaceSharedWith.length === 0) { + const legacyPlan = prepareLegacyWorkspaceStateReset(params.targets.workspaceDir); + const statePlan = prepareWorkspaceStateDeletion(params.targets.workspaceDir); + const workspaceRemoved = await trashPath(params.targets.workspaceDir, params.runtime); + if (workspaceRemoved) { + try { + const legacyCleanup = await removeLegacyWorkspaceStateForReset(legacyPlan); + for (const warning of legacyCleanup.warnings) { + params.runtime.log(warning); + } + deleteWorkspaceState(statePlan); + } catch (error) { + errors.push(error instanceof Error ? error.message : String(error)); + } + } else { + errors.push(`Could not trash workspace ${params.targets.workspaceDir}.`); + } + } + if (!(await trashPath(params.targets.agentDir, params.runtime))) { + errors.push(`Could not trash agent state ${params.targets.agentDir}.`); + } + if (!(await trashPath(params.targets.sessionsDir, params.runtime))) { + errors.push(`Could not trash session transcripts ${params.targets.sessionsDir}.`); + } + return errors; +} + +export const clawRemoveQuietRuntime: RuntimeEnv = { + log: (..._args: unknown[]) => undefined, + error: (..._args: unknown[]) => undefined, + exit: (code?: number): never => { + throw new Error(`Unexpected exit during Claw removal cleanup: ${code ?? 1}`); + }, +}; diff --git a/src/claws/lifecycle-state.test.ts b/src/claws/lifecycle-state.test.ts new file mode 100644 index 000000000000..3d3d4844dd83 --- /dev/null +++ b/src/claws/lifecycle-state.test.ts @@ -0,0 +1,589 @@ +import { link, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + closeOpenClawStateDatabaseForTest, + openOpenClawStateDatabase, +} from "../state/openclaw-state-db.js"; +import { applyClawAddPlan } from "./add.js"; +import { claimClawAgentConfigRemoval } from "./lifecycle-config-removal.js"; +import { applyClawRemovePlan, buildClawRemovePlan, readClawStatus } from "./lifecycle-state.js"; +import { buildClawAddPlan } from "./lifecycle.js"; +import { + persistClawInstallRecord, + persistClawPackageRef, + readClawPackageRefs, +} from "./provenance.js"; +import { parseClawManifest } from "./schema.js"; +import type { ClawSourceIdentity } from "./types.js"; + +afterEach(() => closeOpenClawStateDatabaseForTest()); +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +const packageIntegrity = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + +async function fixture(params: { id?: string; name?: string; withFile?: boolean } = {}) { + const root = tempDirs.make("openclaw-claw-remove-"); + if (params.withFile) { + await writeFile(join(root, "SOUL.md"), "managed\n", "utf8"); + } + const parsed = parseClawManifest({ + schemaVersion: 1, + agent: { id: params.id ?? "worker", name: "Worker" }, + workspace: params.withFile ? { bootstrapFiles: { "SOUL.md": { source: "SOUL.md" } } } : {}, + }); + if (!parsed.ok) { + throw new Error(JSON.stringify(parsed.diagnostics)); + } + const source: ClawSourceIdentity = { + kind: "package", + name: params.name ?? "@acme/worker", + version: "1.0.0", + packageRoot: root, + manifestPath: join(root, "openclaw.claw.json"), + integrityKind: "artifact", + integrity: "sha256:manifest", + byteLength: 100, + }; + const plan = await buildClawAddPlan({ + manifest: parsed.manifest, + source, + context: { workspace: join(root, `workspace-${params.id ?? "worker"}`) }, + }); + return { root, plan, env: { OPENCLAW_STATE_DIR: join(root, "state") } }; +} + +async function addFixture(params: { withFile?: boolean } = {}) { + const current = await fixture(params); + let config: OpenClawConfig = {}; + await applyClawAddPlan(current.plan, { + consentPlanIntegrity: current.plan.planIntegrity, + env: current.env, + commitConfig: async (transform) => { + config = transform(config); + }, + }); + return { ...current, getConfig: () => config }; +} + +describe("Claw status and remove", () => { + it("rejects cleanup when an expected-missing agent id was recreated", async () => { + await expect( + claimClawAgentConfigRemoval({ + agentId: "worker", + expectedDigest: "sha256:missing", + expectedRemovalSurfaceDigest: "sha256:unused", + expectedState: "missing", + fallbackWorkspace: "/tmp/old-worker", + config: { agents: { list: [{ id: "worker", workspace: "/tmp/new-worker" }] } }, + onModified: () => new Error("agent recreated"), + }), + ).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("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: { list: [] } } }), + ).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, { + env: current.env, + config: {}, + consentPlanIntegrity: remove.planIntegrity, + commitConfig: async (transform) => { + transform({}); + }, + purgeSessions: async () => undefined, + trashPath: async () => true, + }); + 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.list[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("rejects consent when a binding changes without changing the binding count", 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, { + env: current.env, + config, + consentPlanIntegrity: plan.planIntegrity, + commitConfig: async (transform) => { + transform(changedConfig); + }, + }), + ).rejects.toMatchObject({ code: "agent_modified" }); + }); + + it("previews and blocks operator-owned cron jobs attached to the agent", async () => { + const current = await addFixture(); + const database = openOpenClawStateDatabase({ env: current.env }); + database.db + .prepare( + `INSERT INTO cron_jobs ( + store_key, job_id, name, enabled, created_at_ms, agent_id, owner_agent_id, + schedule_kind, session_target, wake_mode, payload_kind, job_json, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + "default", + "operator-job", + "Operator job", + 1, + 1, + "worker", + "worker", + "every", + "isolated", + "now", + "agentTurn", + "{}", + 1, + ); + + 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("removes the agent and unchanged files but only releases package refs", async () => { + const current = await addFixture({ withFile: true }); + 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(), + }); + let config = current.getConfig(); + const result = await applyClawRemovePlan(plan, { + consentPlanIntegrity: plan.planIntegrity, + env: current.env, + config, + commitConfig: async (transform) => { + config = transform(config); + }, + }); + expect(result).toMatchObject({ + status: "complete", + agentRemoved: true, + packageRefsReleased: 1, + workspaceFiles: [{ path: "SOUL.md", action: "deleted" }], + }); + expect(config.agents?.list?.some((agent) => agent.id === "worker")).toBe(false); + await expect(readFile(join(current.plan.agent.workspace, "SOUL.md"), "utf8")).rejects.toThrow(); + await expect(readClawStatus("worker", { env: current.env, config })).resolves.toMatchObject({ + summary: { claws: 0 }, + }); + }); + + 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 }), + ); + let config = current.getConfig(); + const result = await applyClawRemovePlan(plan, { + consentPlanIntegrity: plan.planIntegrity, + env: current.env, + config, + commitConfig: async (transform) => { + config = transform(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, { + env: current.env, + config, + consentPlanIntegrity: plan.planIntegrity, + commitConfig: async (transform) => { + transform(config); + }, + 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(), + }); + let config = current.getConfig(); + + const result = await applyClawRemovePlan(plan, { + consentPlanIntegrity: plan.planIntegrity, + env: current.env, + config, + commitConfig: async (transform) => { + config = transform(config); + await writeFile(target, "replacement\n", "utf8"); + }, + }); + + 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(), + }); + let config = current.getConfig(); + + const result = await applyClawRemovePlan(plan, { + consentPlanIntegrity: plan.planIntegrity, + env: current.env, + config, + commitConfig: async (transform) => { + config = transform(config); + await rm(target); + await link(join(current.root, "SOUL.md"), target); + }, + }); + + 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 })).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 nextConfig = config; + let purgedAgentId: string | undefined; + + const result = await applyClawRemovePlan(plan, { + consentPlanIntegrity: plan.planIntegrity, + env: current.env, + config, + commitConfig: async (transform) => { + nextConfig = transform(nextConfig); + }, + 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: nextConfig }), + ).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, + }, + ); + let 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, + }); + + await expect( + applyClawRemovePlan(plan, { + env: current.env, + config, + consentPlanIntegrity: plan.planIntegrity, + packageDeps, + commitConfig: async (transform) => { + config = transform(config); + }, + }), + ).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 agentIndex = config.agents!.list!.findIndex((agent) => agent.id === "worker"); + const agent = config.agents!.list![agentIndex]!; + config.agents!.list![agentIndex] = { ...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, { + 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, { + 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, + }); + let config: OpenClawConfig = { + agents: { list: [first.plan.agent.config, second.plan.agent.config] }, + }; + const remove = await buildClawRemovePlan("worker-a", { env: first.env, config }); + await applyClawRemovePlan(remove, { + consentPlanIntegrity: remove.planIntegrity, + env: first.env, + config, + commitConfig: async (transform) => { + config = transform(config); + }, + }); + + expect(readClawPackageRefs({ env: first.env, agentId: "worker-b" })).toMatchObject([ + { + ref: "audit", + relationship: "referenced", + origin: "claw-introduced", + independentOwner: false, + }, + ]); + }); +}); diff --git a/src/claws/lifecycle-state.ts b/src/claws/lifecycle-state.ts new file mode 100644 index 000000000000..52170a311393 --- /dev/null +++ b/src/claws/lifecycle-state.ts @@ -0,0 +1,701 @@ +import { createHash, randomUUID } from "node:crypto"; +import { stableStringify } from "../agents/stable-stringify.js"; +import { getRuntimeConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { root as fsSafeRoot, FsSafeError } from "../infra/fs-safe.js"; +import { + closeOpenClawAgentDatabaseByPath, + resolveOpenClawAgentSqlitePath, +} from "../state/openclaw-agent-db.js"; +import { + runOpenClawStateWriteTransaction, + type OpenClawStateDatabaseOptions, +} from "../state/openclaw-state-db.js"; +import { + claimClawAgentConfigRemoval, + digestClawAgentConfig, + digestClawAgentRemovalSurface, + type ConfigCommit, +} from "./lifecycle-config-removal.js"; +import { + clawRemoveQuietRuntime, + clawStateTableExists, + cleanupClawAgentFilesystem, + deletionEffects, + readAllClawWorkspaceFiles, + readAttachedCronJobs, + synthesizeOrphanInstall, + workspaceContainsUntrackedEntries, + type ClawTrashPath, +} from "./lifecycle-delete-support.js"; +import { projectClawPackageRemovePlan } from "./package-remove-plan.js"; +import { + applyClawPackageRemovals, + inspectClawPackage, + planClawPackageRemovals, + type ClawPackageInspection, + type ClawPackageRemovalResult, + type ClawReferencedCleanup, + type PackageRemovalDeps, +} from "./package-remove.js"; +import { + readClawInstallRecords, + readClawPackageRefs, + updateClawInstallRecordStatus, + type PersistedClawInstall, +} from "./provenance.js"; +import { CLAW_OUTPUT_STABILITY } from "./types.js"; +import { readClawWorkspaceFiles, type PersistedClawWorkspaceFile } from "./workspace.js"; + +const CLAW_STATUS_SCHEMA_VERSION = "openclaw.clawStatus.v1" as const; +export const CLAW_REMOVE_PLAN_SCHEMA_VERSION = "openclaw.clawRemovePlan.v1" as const; +export const CLAW_REMOVE_RESULT_SCHEMA_VERSION = "openclaw.clawRemoveResult.v1" as const; +const MAX_FILE_BYTES = 1024 * 1024; + +type ClawManagedFileStatus = PersistedClawWorkspaceFile & { + state: "unchanged" | "modified" | "missing" | "unsafe"; + message?: string; +}; +type ClawStatusRecord = { + install: PersistedClawInstall; + orphaned?: boolean; + agentState: "present" | "modified" | "missing"; + workspaceFiles: ClawManagedFileStatus[]; + packages: ClawPackageInspection[]; +}; +type ClawStatusResult = { + schemaVersion: typeof CLAW_STATUS_SCHEMA_VERSION; + stability: typeof CLAW_OUTPUT_STABILITY; + target?: string; + records: ClawStatusRecord[]; + summary: { + claws: number; + partial: number; + missingAgents: number; + driftedFiles: number; + packageRefs: number; + missingPackages: number; + driftedPackages: number; + incompletePackages: number; + }; +}; +type ClawRemovePlanAction = { + kind: + | "agent" + | "configBinding" + | "agentAllow" + | "workspace" + | "agentState" + | "sessionIndex" + | "sessionTranscripts" + | "scheduledJob" + | "workspaceFile" + | "packageRef" + | "installRecord"; + id: string; + action: "remove" | "delete" | "retain" | "release" | "uninstall" | "trash"; + target: string; + blocked: boolean; + reason?: string; + details?: Record; +}; +type ClawRemovePlan = { + schemaVersion: typeof CLAW_REMOVE_PLAN_SCHEMA_VERSION; + stability: typeof CLAW_OUTPUT_STABILITY; + dryRun: true; + mutationAllowed: false; + planIntegrity: string; + target: string; + agentId?: string; + actions: ClawRemovePlanAction[]; + blockers: Array<{ code: string; message: string }>; +}; +type RemovedWorkspaceFile = { + path: string; + action: "deleted" | "missing" | "retainedModified" | "error"; + message?: string; +}; +type ClawRemoveResult = { + schemaVersion: typeof CLAW_REMOVE_RESULT_SCHEMA_VERSION; + stability: typeof CLAW_OUTPUT_STABILITY; + dryRun: false; + status: "complete" | "partial"; + agentId: string; + agentRemoved: boolean; + workspaceFiles: RemovedWorkspaceFile[]; + packages: ClawPackageRemovalResult[]; + packageRefsReleased: number; + error?: { code: string; message: string }; +}; + +export class ClawRemoveError extends Error { + constructor( + readonly code: string, + message: string, + ) { + super(message); + this.name = "ClawRemoveError"; + } +} + +async function inspectFile(record: PersistedClawWorkspaceFile): Promise { + try { + const workspace = await fsSafeRoot(record.workspace, { + hardlinks: "reject", + maxBytes: MAX_FILE_BYTES, + symlinks: "reject", + }); + if (!(await workspace.exists(record.path))) { + return { ...record, state: "missing" }; + } + const content = await workspace.readBytes(record.path, { maxBytes: MAX_FILE_BYTES }); + const digest = `sha256:${createHash("sha256").update(content).digest("hex")}`; + return { ...record, state: digest === record.contentDigest ? "unchanged" : "modified" }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return { ...record, state: "missing" }; + } + return { + ...record, + state: "unsafe", + message: error instanceof Error ? error.message : String(error), + }; + } +} + +export async function readClawStatus( + target?: string, + options: OpenClawStateDatabaseOptions & { + config?: OpenClawConfig; + packageDeps?: PackageRemovalDeps; + } = {}, +): Promise { + const config = options.config ?? getRuntimeConfig(); + const allInstalls = readClawInstallRecords(options); + const installAgentIds = new Set(allInstalls.map((install) => install.agentId)); + const allPackageRefs = readClawPackageRefs(options); + const allWorkspaceFiles = readAllClawWorkspaceFiles(options); + const orphanAgentIds = new Set(); + for (const packageRef of allPackageRefs) { + if (!installAgentIds.has(packageRef.agentId)) { + orphanAgentIds.add(packageRef.agentId); + } + } + for (const file of allWorkspaceFiles) { + if (!installAgentIds.has(file.agentId)) { + orphanAgentIds.add(file.agentId); + } + } + const orphanInstalls = [...orphanAgentIds].map((agentId) => { + const packageRef = allPackageRefs.find((candidate) => candidate.agentId === agentId); + const file = allWorkspaceFiles.find((candidate) => candidate.agentId === agentId); + return synthesizeOrphanInstall({ + agentId, + clawName: packageRef?.clawName, + workspace: file?.workspace, + updatedAtMs: Math.max(packageRef?.updatedAtMs ?? 0, file?.updatedAtMs ?? 0), + }); + }); + const installs = [...allInstalls, ...orphanInstalls].filter( + (install) => !target || install.agentId === target || install.claw.name === target, + ); + const records: ClawStatusRecord[] = []; + for (const install of installs) { + const agent = config.agents?.list?.find((candidate) => candidate.id === install.agentId); + const packageRefs = allPackageRefs.filter( + (packageRef) => packageRef.agentId === install.agentId, + ); + const workspaceFiles = installAgentIds.has(install.agentId) + ? readClawWorkspaceFiles(install.agentId, options) + : allWorkspaceFiles.filter((file) => file.agentId === install.agentId); + records.push({ + install, + ...(installAgentIds.has(install.agentId) ? {} : { orphaned: true }), + agentState: !agent + ? "missing" + : digestClawAgentConfig(agent) === install.agentConfigDigest + ? "present" + : "modified", + workspaceFiles: await Promise.all(workspaceFiles.map(inspectFile)), + packages: await Promise.all( + packageRefs.map((packageRef) => + inspectClawPackage(install, packageRef, options.packageDeps), + ), + ), + }); + } + return { + schemaVersion: CLAW_STATUS_SCHEMA_VERSION, + stability: CLAW_OUTPUT_STABILITY, + ...(target ? { target } : {}), + records, + summary: { + claws: records.length, + partial: records.filter((record) => record.install.status !== "complete").length, + missingAgents: records.filter((record) => record.agentState === "missing").length, + driftedFiles: records + .flatMap((record) => record.workspaceFiles) + .filter((file) => file.state !== "unchanged").length, + packageRefs: records.flatMap((record) => record.packages).length, + missingPackages: records + .flatMap((record) => record.packages) + .filter((pkg) => pkg.state === "missing").length, + driftedPackages: records + .flatMap((record) => record.packages) + .filter((pkg) => pkg.state === "modified" || pkg.state === "ambiguous").length, + incompletePackages: records + .flatMap((record) => record.packages) + .filter((pkg) => pkg.state === "incomplete").length, + }, + }; +} + +export async function buildClawRemovePlan( + target: string, + options: OpenClawStateDatabaseOptions & { + config?: OpenClawConfig; + packageDeps?: PackageRemovalDeps; + referencedCleanup?: ClawReferencedCleanup; + } = {}, +): Promise { + const status = await readClawStatus(target, options); + const blockers: ClawRemovePlan["blockers"] = []; + if (status.records.length === 0) { + blockers.push({ + code: "claw_not_found", + message: `No installed Claw matches ${JSON.stringify(target)}.`, + }); + } else if (status.records.length > 1) { + blockers.push({ + code: "claw_ambiguous", + message: `Claw name ${JSON.stringify(target)} matches multiple agents; use an agent id.`, + }); + } + const record = status.records.length === 1 ? status.records[0] : undefined; + if (record?.agentState === "modified") { + blockers.push({ + code: "agent_modified", + message: `Agent ${JSON.stringify(record.install.agentId)} changed after add.`, + }); + } + for (const file of record?.workspaceFiles ?? []) { + if (file.state === "unsafe") { + blockers.push({ + code: "workspace_file_unsafe", + message: `${file.path}: ${file.message ?? "unsafe file"}`, + }); + } + } + const actions: ClawRemovePlanAction[] = []; + if (record) { + const packageDecisions = await planClawPackageRemovals(record.install, record.packages, { + ...options, + deps: options.packageDeps, + referencedCleanup: options.referencedCleanup, + }); + const packagePlan = projectClawPackageRemovePlan({ + decisions: packageDecisions, + inspections: record.packages, + cleanup: options.referencedCleanup, + }); + blockers.push(...packagePlan.blockers); + const effects = deletionEffects( + options.config ?? getRuntimeConfig(), + record.install.agentId, + record.install.workspace, + ); + const workspaceHasModifiedFiles = record.workspaceFiles.some( + (file) => file.state === "modified", + ); + const workspaceHasUntrackedEntries = await workspaceContainsUntrackedEntries( + record.install.workspace, + record.workspaceFiles.map((file) => file.path), + ); + const attachedJobs = readAttachedCronJobs(record.install.agentId, options); + for (const job of attachedJobs) { + blockers.push({ + code: "agent_job_attached", + message: `Cron job ${JSON.stringify(job.id)} still references agent ${JSON.stringify(record.install.agentId)}; reassign or remove it first.`, + }); + } + actions.push({ + kind: "agent", + id: record.install.agentId, + action: "remove", + target: `agents.list[${record.install.agentId}]`, + blocked: record.agentState === "modified", + details: { + expectedState: record.agentState, + configDigest: record.install.agentConfigDigest, + removalSurfaceDigest: digestClawAgentRemovalSurface( + options.config ?? getRuntimeConfig(), + record.install.agentId, + ), + ownedPaths: record.install.agentOwnedPaths, + }, + ...(record.agentState === "modified" ? { reason: "Agent config digest changed." } : {}), + }); + if (effects.pruned.removedBindings > 0) { + actions.push({ + kind: "configBinding", + id: record.install.agentId, + action: "remove", + target: `bindings[agentId=${record.install.agentId}]`, + blocked: record.agentState === "modified", + details: { count: effects.pruned.removedBindings }, + }); + } + if (effects.pruned.removedAllow > 0) { + actions.push({ + kind: "agentAllow", + id: record.install.agentId, + action: "remove", + target: `tools.agentToAgent.allow[${record.install.agentId}]`, + blocked: record.agentState === "modified", + details: { count: effects.pruned.removedAllow }, + }); + } + if (effects.workspace) { + actions.push({ + kind: "workspace", + id: record.install.agentId, + action: + effects.workspaceRetained || workspaceHasModifiedFiles || workspaceHasUntrackedEntries + ? "retain" + : "trash", + target: effects.workspace, + blocked: record.agentState === "modified", + details: { + retained: + effects.workspaceRetained || workspaceHasModifiedFiles || workspaceHasUntrackedEntries, + sharedWith: effects.workspaceSharedWith, + }, + ...(effects.workspaceRetained + ? { reason: "Workspace overlaps another agent." } + : workspaceHasModifiedFiles + ? { reason: "Workspace contains locally modified Claw-managed files." } + : workspaceHasUntrackedEntries + ? { reason: "Workspace contains files or directories not managed by this Claw." } + : {}), + }); + } + if (effects.agentDir) { + actions.push({ + kind: "agentState", + id: record.install.agentId, + action: "trash", + target: effects.agentDir, + blocked: record.agentState === "modified", + }); + } + actions.push({ + kind: "sessionIndex", + id: record.install.agentId, + action: "delete", + target: `session store entries for agent:${record.install.agentId}`, + blocked: record.agentState === "modified", + }); + actions.push({ + kind: "sessionTranscripts", + id: record.install.agentId, + action: "trash", + target: effects.sessionsDir, + blocked: record.agentState === "modified", + }); + for (const job of attachedJobs) { + actions.push({ + kind: "scheduledJob", + id: job.id, + action: "retain", + target: `cron_jobs:${job.id}`, + blocked: true, + reason: "Operator-owned scheduled work must be reassigned or removed explicitly.", + details: { + name: job.name, + enabled: job.enabled, + agentId: job.agentId, + ownerAgentId: job.ownerAgentId, + }, + }); + } + for (const file of record.workspaceFiles) { + actions.push({ + kind: "workspaceFile", + id: file.path, + action: file.state === "unchanged" ? "delete" : "retain", + target: `${file.workspace}:${file.path}`, + blocked: file.state === "unsafe", + details: { + expectedState: file.state, + contentDigest: file.contentDigest, + workspace: file.workspace, + }, + ...(file.state === "modified" + ? { reason: "Local content changed; preserve the file." } + : {}), + }); + } + actions.push(...packagePlan.actions); + actions.push({ + kind: "installRecord", + id: record.install.agentId, + action: "remove", + target: `claw_installs:${record.install.agentId}`, + blocked: false, + details: { + expectedStatus: record.install.status, + planIntegrity: record.install.planIntegrity, + sourceIntegrity: record.install.claw.integrity, + }, + }); + } + const planIdentity = { + target, + agentId: record?.install.agentId, + actions, + blockers, + }; + return { + schemaVersion: CLAW_REMOVE_PLAN_SCHEMA_VERSION, + stability: CLAW_OUTPUT_STABILITY, + dryRun: true, + mutationAllowed: false, + planIntegrity: `sha256:${createHash("sha256") + .update(stableStringify(planIdentity)) + .digest("hex")}`, + target, + ...(record ? { agentId: record.install.agentId } : {}), + actions, + blockers, + }; +} + +async function removeFile(record: ClawManagedFileStatus): Promise { + if (record.state === "missing") { + return { path: record.path, action: "missing" }; + } + if (record.state === "modified") { + return { path: record.path, action: "retainedModified" }; + } + try { + const workspace = await fsSafeRoot(record.workspace, { + hardlinks: "reject", + maxBytes: MAX_FILE_BYTES, + symlinks: "reject", + }); + if (!(await workspace.exists(record.path))) { + return { path: record.path, action: "missing" }; + } + const stagedPath = `${record.path}.openclaw-claw-remove-${randomUUID()}`; + await workspace.move(record.path, stagedPath, { overwrite: false }); + const content = await workspace.readBytes(stagedPath, { maxBytes: MAX_FILE_BYTES }); + const digest = `sha256:${createHash("sha256").update(content).digest("hex")}`; + if (digest !== record.contentDigest) { + await workspace.move(stagedPath, record.path, { overwrite: false }); + return { path: record.path, action: "retainedModified" }; + } + await workspace.remove(stagedPath); + return { path: record.path, action: "deleted" }; + } catch (error) { + return { + path: record.path, + action: "error", + message: error instanceof FsSafeError ? `${error.code}: ${error.message}` : String(error), + }; + } +} +function releaseRows( + agentId: string, + files: RemovedWorkspaceFile[], + complete: boolean, + options: OpenClawStateDatabaseOptions, +): void { + runOpenClawStateWriteTransaction(({ db }) => { + if (clawStateTableExists(db, "claw_workspace_files")) { + for (const file of files.filter((candidate) => candidate.action !== "error")) { + db /* sqlite-allow-raw: remove one owned Claw workspace-file row. */ + .prepare("DELETE FROM claw_workspace_files WHERE agent_id = ? AND target_path = ?") + .run(agentId, file.path); + } + } + if (!complete) { + return; + } + if (clawStateTableExists(db, "claw_package_refs")) { + db /* sqlite-allow-raw: release package refs for a removed Claw agent. */ + .prepare("DELETE FROM claw_package_refs WHERE agent_id = ?") + .run(agentId); + } + if (clawStateTableExists(db, "claw_installs")) { + db /* sqlite-allow-raw: remove the completed Claw install owner row. */ + .prepare("DELETE FROM claw_installs WHERE agent_id = ?") + .run(agentId); + } + }, options); +} + +type PurgeSessions = (config: OpenClawConfig, agentId: string) => Promise; +export async function applyClawRemovePlan( + plan: ClawRemovePlan, + options: OpenClawStateDatabaseOptions & { + config?: OpenClawConfig; + commitConfig?: ConfigCommit; + packageDeps?: PackageRemovalDeps; + referencedCleanup?: ClawReferencedCleanup; + purgeSessions?: PurgeSessions; + trashPath?: ClawTrashPath; + consentPlanIntegrity?: string; + } = {}, +): Promise { + if (options.consentPlanIntegrity !== plan.planIntegrity) { + throw new ClawRemoveError( + "plan_integrity_mismatch", + "Consent does not match the current Claw remove plan; run remove --dry-run again.", + ); + } + if (plan.blockers.length > 0 || !plan.agentId) { + throw new ClawRemoveError("remove_blocked", "The Claw remove plan contains blockers."); + } + const currentPlan = await buildClawRemovePlan(plan.target, options); + if (currentPlan.planIntegrity !== plan.planIntegrity) { + throw new ClawRemoveError("remove_changed", "Claw-owned state changed after remove planning."); + } + const agentId = plan.agentId; + const plannedAgentAction = plan.actions.find( + (action) => action.kind === "agent" && action.id === agentId, + ); + const expectedRemovalSurfaceDigest = plannedAgentAction?.details?.removalSurfaceDigest; + if (typeof expectedRemovalSurfaceDigest !== "string") { + throw new ClawRemoveError("remove_changed", "Claw remove plan is missing config state."); + } + const current = await readClawStatus(plan.agentId, options); + const record = current.records[0]; + if ( + !record || + record.agentState === "modified" || + record.workspaceFiles.some((file) => file.state === "unsafe") + ) { + throw new ClawRemoveError("remove_changed", "Claw-owned state changed after remove planning."); + } + const packageDecisions = await planClawPackageRemovals(record.install, record.packages, { + ...options, + deps: options.packageDeps, + referencedCleanup: options.referencedCleanup, + }); + const plannedPackages = plan.actions + .filter((action) => action.kind === "packageRef") + .map((action) => `${action.id}:${action.action}`) + .toSorted(); + const currentPackages = packageDecisions + .map( + (decision) => + `${decision.packageRef.kind}:${decision.packageRef.ref}@${decision.packageRef.version}:${decision.action === "uninstall" ? "uninstall" : "release"}`, + ) + .toSorted(); + if (JSON.stringify(plannedPackages) !== JSON.stringify(currentPackages)) { + throw new ClawRemoveError("remove_changed", "Package ownership changed after remove planning."); + } + const configRemoval = await claimClawAgentConfigRemoval({ + agentId, + expectedDigest: record.install.agentConfigDigest, + expectedRemovalSurfaceDigest, + expectedState: record.agentState, + fallbackWorkspace: record.install.workspace, + config: options.config, + commitConfig: options.commitConfig, + trashPath: options.trashPath, + onModified: () => new ClawRemoveError("agent_modified", "Agent config changed during remove."), + }); + const { + agentRemoved, + cleanupTargets, + configBeforeDelete, + nextConfig: committedNextConfig, + } = configRemoval; + if (!options.commitConfig || options.purgeSessions) { + const purgeSessions = + options.purgeSessions ?? + (await import("../config/sessions/cleanup-service.js")).purgeAgentSessionStoreEntries; + await purgeSessions(configBeforeDelete, agentId); + } + closeOpenClawAgentDatabaseByPath(resolveOpenClawAgentSqlitePath({ agentId, env: options.env })); + const packages = await applyClawPackageRemovals( + packageDecisions.toSorted( + (left, right) => + Number(left.packageRef.relationship === "referenced") - + Number(right.packageRef.relationship === "referenced"), + ), + { + ...options, + deps: options.packageDeps, + }, + ); + const packageErrors = packages.filter((pkg) => pkg.action === "error"); + if (packageErrors.length > 0) { + updateClawInstallRecordStatus(agentId, "partial", options); + return { + schemaVersion: CLAW_REMOVE_RESULT_SCHEMA_VERSION, + stability: CLAW_OUTPUT_STABILITY, + dryRun: false, + status: "partial", + agentId: plan.agentId, + agentRemoved, + workspaceFiles: [], + packages, + packageRefsReleased: 0, + error: { + code: "package_cleanup_failed", + message: packageErrors.map((pkg) => pkg.reason).join("; "), + }, + }; + } + const workspaceFiles: RemovedWorkspaceFile[] = []; + for (const file of record.workspaceFiles) { + workspaceFiles.push(await removeFile(file)); + } + const cleanupErrors = workspaceFiles + .filter((file) => file.action === "error") + .map((file) => file.message ?? `Could not remove ${file.path}.`); + if (cleanupErrors.length === 0 && cleanupTargets && committedNextConfig) { + const workspaceHasRemainingEntries = await workspaceContainsUntrackedEntries( + cleanupTargets.workspaceDir, + record.workspaceFiles.map((file) => file.path), + ); + cleanupErrors.push( + ...(await cleanupClawAgentFilesystem({ + agentId, + nextConfig: committedNextConfig, + targets: cleanupTargets, + runtime: clawRemoveQuietRuntime, + trashPath: options.trashPath, + retainWorkspace: + workspaceHasRemainingEntries || + workspaceFiles.some((file) => file.action === "retainedModified"), + })), + ); + } + const complete = cleanupErrors.length === 0; + if (!complete) { + updateClawInstallRecordStatus(agentId, "partial", options); + } + releaseRows(plan.agentId, workspaceFiles, complete, options); + return { + schemaVersion: CLAW_REMOVE_RESULT_SCHEMA_VERSION, + stability: CLAW_OUTPUT_STABILITY, + dryRun: false, + status: complete ? "complete" : "partial", + agentId: plan.agentId, + agentRemoved, + workspaceFiles, + packages, + packageRefsReleased: complete ? record.packages.length : 0, + ...(complete + ? {} + : { + error: { + code: "workspace_cleanup_failed", + message: cleanupErrors.join("; "), + }, + }), + }; +} diff --git a/src/claws/lifecycle.e2e.test.ts b/src/claws/lifecycle.e2e.test.ts index 9932a69f27de..415baf695aef 100644 --- a/src/claws/lifecycle.e2e.test.ts +++ b/src/claws/lifecycle.e2e.test.ts @@ -39,7 +39,11 @@ async function runOpenClaw( return { ok: true as const, stdout: result.stdout, stderr: result.stderr, stateDir }; } catch (error) { if (!options?.expectFailure) { - throw error; + const failed = error as Error & { stdout?: string; stderr?: string }; + throw new Error( + `${failed.message}\nstdout:\n${failed.stdout ?? ""}\nstderr:\n${failed.stderr ?? ""}`, + { cause: error }, + ); } const failed = error as Error & { stdout?: string; stderr?: string; code?: number }; return { @@ -197,6 +201,70 @@ describe("claws lifecycle cli e2e", () => { ); }); + it("reports and removes a Claw-created agent through plan-first lifecycle commands", async () => { + const addPreview = await runOpenClaw([ + "claws", + "add", + "src/claws/fixtures/workspace-agent.claw.json", + "--dry-run", + "--json", + ]); + const addPlan = parseJson(addPreview.stdout) as { planIntegrity: string }; + const added = await runOpenClaw( + [ + "claws", + "add", + "src/claws/fixtures/workspace-agent.claw.json", + "--yes", + "--plan-integrity", + addPlan.planIntegrity, + "--json", + ], + { stateDir: addPreview.stateDir }, + ); + const status = await runOpenClaw(["claws", "status", "workspace-agent", "--json"], { + stateDir: added.stateDir, + }); + expect(parseJson(status.stdout)).toMatchObject({ + schemaVersion: "openclaw.clawStatus.v1", + summary: { claws: 1, driftedFiles: 0 }, + records: [{ install: { agentId: "workspace-agent" }, agentState: "present" }], + }); + + const preview = await runOpenClaw( + ["claws", "remove", "workspace-agent", "--dry-run", "--json"], + { stateDir: added.stateDir }, + ); + const removePlan = parseJson(preview.stdout) as { planIntegrity: string }; + expect(removePlan).toMatchObject({ + schemaVersion: "openclaw.clawRemovePlan.v1", + mutationAllowed: false, + agentId: "workspace-agent", + blockers: [], + }); + + const removed = await runOpenClaw( + [ + "claws", + "remove", + "workspace-agent", + "--yes", + "--plan-integrity", + removePlan.planIntegrity, + "--json", + ], + { stateDir: added.stateDir }, + ); + expect(parseJson(removed.stdout)).toMatchObject({ + schemaVersion: "openclaw.clawRemoveResult.v1", + status: "complete", + agentId: "workspace-agent", + agentRemoved: true, + }); + const config = JSON.parse(await readFile(join(added.stateDir, "openclaw.json"), "utf8")); + expect(config.agents).toEqual({ list: [{ id: "main", default: true }] }); + }); + it("blocks mutation when declared components need later lifecycle slices", async () => { const root = tempDirs.make("openclaw-claws-deferred-components-"); const deferredManifestPath = join(root, "deferred.claw.json"); diff --git a/src/claws/package-remove-plan.ts b/src/claws/package-remove-plan.ts new file mode 100644 index 000000000000..8e72e8bf66be --- /dev/null +++ b/src/claws/package-remove-plan.ts @@ -0,0 +1,73 @@ +import { + clawPackageRemovalSelector, + type ClawPackageInspection, + type ClawPackageRemovalDecision, + type ClawReferencedCleanup, +} from "./package-remove.js"; + +type PackageRemoveAction = { + kind: "packageRef"; + id: string; + action: "release" | "uninstall"; + target: string; + blocked: boolean; + reason?: string; + details: Record; +}; + +type PackageRemoveBlocker = { code: string; message: string }; + +export function projectClawPackageRemovePlan(params: { + decisions: ClawPackageRemovalDecision[]; + inspections: ClawPackageInspection[]; + cleanup?: ClawReferencedCleanup; +}): { actions: PackageRemoveAction[]; blockers: PackageRemoveBlocker[] } { + const selected = new Set(params.cleanup?.selected ?? []); + const blockers: PackageRemoveBlocker[] = []; + const actions = params.decisions.map((decision): PackageRemoveAction => { + const pkg = decision.packageRef; + const selector = clawPackageRemovalSelector(pkg); + selected.delete(selector); + if (decision.blocked) { + blockers.push({ + code: "referenced_cleanup_requires_override", + message: `${selector}: ${decision.reason ?? "explicit conflict override is required"}`, + }); + } + const inspected = params.inspections.find( + (candidate) => + candidate.kind === pkg.kind && + candidate.source === pkg.source && + candidate.ref === pkg.ref && + candidate.version === pkg.version, + ); + return { + kind: "packageRef", + id: selector, + action: decision.action === "uninstall" ? "uninstall" : "release", + target: `${pkg.source}:${pkg.ref}@${pkg.version}`, + blocked: Boolean(decision.blocked), + details: { + expectedState: inspected?.state ?? "incomplete", + status: pkg.status, + relationship: pkg.relationship, + origin: pkg.origin, + independentOwner: pkg.independentOwner, + affectedClawAgentIds: decision.affectedClawAgentIds, + cleanupMode: params.cleanup?.mode ?? "retain", + availableCleanupModes: + pkg.relationship === "referenced" + ? ["retain", "remove-if-unused", "remove-selected"] + : ["remove"], + }, + ...(decision.reason ? { reason: decision.reason } : {}), + }; + }); + for (const selector of selected) { + blockers.push({ + code: "referenced_cleanup_not_found", + message: `Selected referenced resource ${JSON.stringify(selector)} is not owned by this Claw.`, + }); + } + return { actions, blockers }; +} diff --git a/src/claws/package-remove.test.ts b/src/claws/package-remove.test.ts new file mode 100644 index 000000000000..fb0d7152e790 --- /dev/null +++ b/src/claws/package-remove.test.ts @@ -0,0 +1,428 @@ +import { describe, expect, it, vi } from "vitest"; +import { applyClawPackageRemovals, planClawPackageRemovals } from "./package-remove.js"; +import type { PersistedClawInstall, PersistedClawPackageRef } from "./provenance.js"; + +const install = { + workspace: "/tmp/claw-workspace", +} as PersistedClawInstall; + +function packageRef(overrides: Partial = {}): PersistedClawPackageRef { + return { + schemaVersion: "openclaw.clawPackageRef.v1", + agentId: "worker", + clawName: "@acme/worker", + kind: "plugin", + source: "clawhub", + ref: "audit", + version: "1.0.0", + integrity: "sha256:audit", + status: "complete", + relationship: "referenced", + origin: "claw-introduced", + independentOwner: false, + installedAtMs: 1, + updatedAtMs: 1, + ...overrides, + }; +} + +function packageRefStore(...initial: PersistedClawPackageRef[]) { + let refs = initial; + return { + acquirePackageLease: vi.fn(() => ({ heartbeat: vi.fn(), release: vi.fn() })), + readPackageRefs: vi.fn(() => refs), + readInstallRecords: vi.fn(() => []), + claimPackageRef: vi.fn( + (ref: PersistedClawPackageRef, status: PersistedClawPackageRef["status"]) => { + const claimed = { ...ref, status }; + refs = refs.map((candidate) => + candidate.agentId === ref.agentId && + candidate.kind === ref.kind && + candidate.source === ref.source && + candidate.ref === ref.ref && + candidate.version === ref.version + ? claimed + : candidate, + ); + return claimed; + }, + ), + }; +} + +describe("Claw package removal", () => { + it("retains referenced plugins by default while releasing the Claw reference", async () => { + const ref = packageRef(); + const decisions = await planClawPackageRemovals(install, [ref], { + deps: { + readPackageRefs: vi.fn().mockReturnValue([ref]), + resolvePlugin: vi.fn(), + }, + }); + + expect(decisions).toMatchObject([ + { + action: "retain", + reason: "Referenced resources are retained unless a cleanup mode selects them.", + }, + ]); + }); + + it("removes an unused Claw-introduced reference through the canonical plugin lifecycle", async () => { + const ref = packageRef(); + const store = packageRefStore(ref); + const uninstallPlugin = vi.fn().mockResolvedValue(undefined); + const decisions = await planClawPackageRemovals(install, [ref], { + deps: { + ...store, + resolvePlugin: vi.fn().mockResolvedValue({ + status: "found", + pluginId: "audit", + record: { source: "clawhub", integrity: "sha256:audit", installedAt: 1 }, + installedVersion: "1.0.0", + }), + }, + referencedCleanup: { mode: "remove-if-unused" }, + }); + + expect(decisions).toMatchObject([{ action: "uninstall", pluginId: "audit" }]); + await expect( + applyClawPackageRemovals(decisions, { + deps: { + ...store, + uninstallPlugin, + resolvePlugin: vi.fn().mockResolvedValue({ + status: "found", + pluginId: "audit", + record: { source: "clawhub", integrity: "sha256:audit", installedAt: 1 }, + installedVersion: "1.0.0", + }), + }, + }), + ).resolves.toMatchObject([{ action: "uninstalled" }]); + expect(uninstallPlugin).toHaveBeenCalledWith("audit", { + force: true, + invalidateRuntimeCache: false, + clawManaged: true, + }); + }); + + it("rechecks plugin identity under the lifecycle lease before uninstalling", async () => { + const ref = packageRef(); + const store = packageRefStore(ref); + const uninstallPlugin = vi.fn(); + + await expect( + applyClawPackageRemovals( + [ + { + packageRef: ref, + workspace: install.workspace, + action: "uninstall", + affectedClawAgentIds: [], + pluginId: "audit", + }, + ], + { + deps: { + ...store, + uninstallPlugin, + resolvePlugin: vi.fn().mockResolvedValue({ + status: "found", + pluginId: "replacement", + record: { source: "clawhub", integrity: "sha256:replacement" }, + installedVersion: "2.0.0", + }), + }, + }, + ), + ).resolves.toMatchObject([ + { + action: "error", + reason: "Plugin audit@1.0.0 changed after removal planning.", + }, + ]); + + expect(uninstallPlugin).not.toHaveBeenCalled(); + expect(store.claimPackageRef).toHaveBeenLastCalledWith( + expect.objectContaining({ ref: "audit" }), + "complete", + expect.anything(), + ); + }); + + it("leaves failed provenance when an error occurs after uninstall starts", async () => { + const ref = packageRef(); + const store = packageRefStore(ref); + const heartbeat = vi.fn(() => { + throw new Error("lease lost"); + }); + + await expect( + applyClawPackageRemovals( + [ + { + packageRef: ref, + workspace: install.workspace, + action: "uninstall", + affectedClawAgentIds: [], + pluginId: "audit", + }, + ], + { + deps: { + ...store, + acquirePackageLease: vi.fn(() => ({ heartbeat, release: vi.fn() })), + uninstallPlugin: vi.fn().mockResolvedValue(undefined), + resolvePlugin: vi.fn().mockResolvedValue({ + status: "found", + pluginId: "audit", + record: { source: "clawhub", integrity: "sha256:audit", installedAt: 1 }, + installedVersion: "1.0.0", + }), + }, + }, + ), + ).resolves.toMatchObject([{ action: "error", reason: "lease lost" }]); + + expect(store.claimPackageRef).toHaveBeenLastCalledWith( + expect.objectContaining({ ref: "audit" }), + "failed", + expect.anything(), + ); + }); + + it("requires an explicit override to remove a selected shared reference", async () => { + const ref = packageRef(); + const other = packageRef({ agentId: "other" }); + const deps = { + readPackageRefs: vi.fn().mockReturnValue([ref, other]), + resolvePlugin: vi.fn().mockResolvedValue({ + status: "found", + pluginId: "audit", + record: { source: "clawhub", integrity: "sha256:audit", installedAt: 1 }, + installedVersion: "1.0.0", + }), + }; + const selected = ["plugin:audit@1.0.0"]; + + await expect( + planClawPackageRemovals(install, [ref], { + deps, + referencedCleanup: { mode: "remove-selected", selected }, + }), + ).resolves.toMatchObject([ + { action: "retain", blocked: true, affectedClawAgentIds: ["other"] }, + ]); + await expect( + planClawPackageRemovals(install, [ref], { + deps, + referencedCleanup: { mode: "remove-selected", selected, allowConflicts: true }, + }), + ).resolves.toMatchObject([ + { + action: "uninstall", + allowConflicts: true, + affectedClawAgentIds: ["other"], + }, + ]); + }); + + it.each([ + ["independently-owned", packageRef({ independentOwner: true })], + ["pending", packageRef({ status: "pending" })], + ["shared", packageRef()], + ])("retains %s artifacts while releasing the Claw reference", async (scenario, ref) => { + const other = packageRef({ agentId: "other" }); + const decisions = await planClawPackageRemovals(install, [ref], { + deps: { + readPackageRefs: vi.fn().mockReturnValue(scenario === "shared" ? [ref, other] : [ref]), + resolvePlugin: vi.fn(), + }, + }); + expect(decisions).toMatchObject([{ action: "retain", reason: expect.any(String) }]); + }); + + it("does not inspect global plugin artifact state during removal planning", async () => { + const ref = packageRef(); + const decisions = await planClawPackageRemovals(install, [ref], { + deps: { + readPackageRefs: vi.fn().mockReturnValue([ref]), + resolvePlugin: vi.fn(), + }, + }); + expect(decisions).toMatchObject([ + { + action: "retain", + reason: "Referenced resources are retained unless a cleanup mode selects them.", + }, + ]); + }); + + it("retains a same-version plugin whose installed integrity drifted", async () => { + const ref = packageRef(); + const decisions = await planClawPackageRemovals(install, [ref], { + deps: { + readPackageRefs: vi.fn().mockReturnValue([ref]), + resolvePlugin: vi.fn().mockResolvedValue({ + status: "found", + pluginId: "audit", + record: { source: "clawhub", integrity: "sha256:replacement" }, + installedVersion: "1.0.0", + }), + }, + }); + expect(decisions).toMatchObject([ + { + action: "retain", + reason: "Referenced resources are retained unless a cleanup mode selects them.", + }, + ]); + }); + + it("retains a plugin reinstalled directly after Claw provenance", async () => { + const ref = packageRef({ updatedAtMs: 10 }); + const decisions = await planClawPackageRemovals(install, [ref], { + deps: { + readPackageRefs: vi.fn().mockReturnValue([ref]), + resolvePlugin: vi.fn().mockResolvedValue({ + status: "found", + pluginId: "audit", + record: { + source: "clawhub", + integrity: "sha256:audit", + installedAt: new Date(20).toISOString(), + }, + installedVersion: "1.0.0", + }), + }, + }); + + expect(decisions).toMatchObject([ + { + action: "retain", + reason: "Referenced resources are retained unless a cleanup mode selects them.", + }, + ]); + }); + + it("treats equal skill refs in separate agent workspaces as separate artifacts", async () => { + const ref = packageRef({ kind: "skill", ref: "triage", relationship: "managed" }); + const other = packageRef({ + kind: "skill", + ref: "triage", + relationship: "managed", + agentId: "other", + }); + const skillPlan = { + workspaceDir: install.workspace, + slug: "triage", + version: "1.0.0", + installedAt: 1, + targetDir: "/tmp/claw-workspace/skills/triage", + skillFilePath: "SKILL.md", + skillFileSha256: "abc", + }; + const decisions = await planClawPackageRemovals(install, [ref], { + deps: { + readPackageRefs: vi.fn().mockReturnValue([ref, other]), + readInstallRecords: vi.fn().mockReturnValue([ + { ...install, agentId: "worker" }, + { ...install, agentId: "other", workspace: "/tmp/other-workspace" }, + ]), + planSkill: vi.fn().mockResolvedValue({ ok: true, plan: skillPlan }), + }, + }); + expect(decisions).toMatchObject([{ action: "uninstall", skillPlan }]); + }); + + it("retains a skill referenced by another Claw in the same workspace", async () => { + const ref = packageRef({ kind: "skill", ref: "triage", relationship: "managed" }); + const other = packageRef({ + kind: "skill", + ref: "triage", + relationship: "managed", + agentId: "other", + }); + const decisions = await planClawPackageRemovals(install, [ref], { + deps: { + readPackageRefs: vi.fn().mockReturnValue([ref, other]), + readInstallRecords: vi.fn().mockReturnValue([ + { ...install, agentId: "worker" }, + { ...install, agentId: "other" }, + ]), + planSkill: vi.fn(), + }, + }); + + expect(decisions).toMatchObject([ + { action: "retain", reason: "Another Claw still references this package." }, + ]); + }); + + it("retains an orphan skill when its workspace provenance is missing", async () => { + const ref = packageRef({ kind: "skill", ref: "triage", relationship: "managed" }); + const planSkill = vi.fn(); + const decisions = await planClawPackageRemovals({ ...install, workspace: "" }, [ref], { + deps: { + readPackageRefs: vi.fn().mockReturnValue([ref]), + planSkill, + }, + }); + + expect(decisions).toMatchObject([ + { action: "retain", reason: "Skill workspace provenance is missing." }, + ]); + expect(planSkill).not.toHaveBeenCalled(); + }); + + it("releases a global plugin reference while another Claw is also being removed", async () => { + const ref = packageRef(); + const other = packageRef({ agentId: "other" }); + const decisions = await planClawPackageRemovals(install, [ref], { + deps: { + readPackageRefs: vi.fn().mockReturnValue([ref, other]), + resolvePlugin: vi.fn(), + }, + }); + let refs = [ref, other]; + const claimPackageRef = vi.fn((claimedRef: PersistedClawPackageRef) => { + refs = refs.map((candidate) => ({ + ...candidate, + status: "pending" as const, + })); + return { ...claimedRef, status: "pending" as const }; + }); + + await expect( + applyClawPackageRemovals(decisions, { + deps: { + acquirePackageLease: vi.fn(() => ({ heartbeat: vi.fn(), release: vi.fn() })), + readPackageRefs: vi.fn(() => refs), + claimPackageRef, + }, + }), + ).resolves.toMatchObject([{ action: "retained" }]); + }); + + it("releases a reference whose independent ownership was derived from install time", async () => { + const persisted = packageRef({ independentOwner: false }); + const derived = packageRef({ independentOwner: true }); + const store = packageRefStore(persisted); + + await expect( + applyClawPackageRemovals( + [ + { + packageRef: derived, + workspace: install.workspace, + action: "retain", + reason: "Package is independently owned outside this Claw.", + affectedClawAgentIds: [], + }, + ], + { deps: store }, + ), + ).resolves.toMatchObject([{ action: "retained" }]); + }); +}); diff --git a/src/claws/package-remove.ts b/src/claws/package-remove.ts new file mode 100644 index 000000000000..2b388187d536 --- /dev/null +++ b/src/claws/package-remove.ts @@ -0,0 +1,566 @@ +import { runPluginUninstallCommand } from "../cli/plugins-uninstall-command.js"; +import { normalizeClawHubSha256Integrity } from "../infra/clawhub.js"; +import { resolveInstalledClawHubPlugin } from "../plugins/plugin-install-preflight.js"; +import { + applyClawHubSkillUninstall, + planClawHubSkillUninstall, + type ClawHubSkillUninstallPlan, +} from "../skills/lifecycle/clawhub-uninstall.js"; +import { + acquireClawPackageLifecycleLease, + maintainClawPackageLifecycleLease, + type MaintainedClawPackageLifecycleLease, +} from "../state/claw-package-lifecycle-lease.js"; +import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js"; +import { + readClawPackageRefs, + readClawInstallRecords, + updateClawPackageRefStatus, + type PersistedClawInstall, + type PersistedClawPackageRef, +} from "./provenance.js"; + +type ClawReferencedCleanupMode = "retain" | "remove-if-unused" | "remove-selected"; + +export type ClawReferencedCleanup = { + mode: ClawReferencedCleanupMode; + selected?: readonly string[]; + allowConflicts?: boolean; +}; + +export type ClawPackageRemovalDecision = { + packageRef: PersistedClawPackageRef; + workspace: string; + action: "uninstall" | "retain"; + blocked?: boolean; + allowConflicts?: boolean; + reason?: string; + affectedClawAgentIds: string[]; + pluginId?: string; + skillPlan?: ClawHubSkillUninstallPlan; +}; + +export type ClawPackageRemovalResult = { + kind: PersistedClawPackageRef["kind"]; + ref: string; + version: string; + action: "uninstalled" | "retained" | "error"; + reason?: string; +}; + +export type PackageRemovalDeps = { + readPackageRefs?: typeof readClawPackageRefs; + readInstallRecords?: typeof readClawInstallRecords; + claimPackageRef?: typeof updateClawPackageRefStatus; + resolvePlugin?: typeof resolveInstalledClawHubPlugin; + planSkill?: typeof planClawHubSkillUninstall; + uninstallSkill?: typeof applyClawHubSkillUninstall; + uninstallPlugin?: typeof runPluginUninstallCommand; + acquirePackageLease?: typeof acquireClawPackageLifecycleLease; +}; + +type ClawPackageState = "present" | "missing" | "modified" | "ambiguous" | "incomplete"; +export type ClawPackageInspection = PersistedClawPackageRef & { + state: ClawPackageState; + message?: string; +}; + +function sameArtifact(left: PersistedClawPackageRef, right: PersistedClawPackageRef): boolean { + return left.kind === right.kind && left.source === right.source && left.ref === right.ref; +} + +function sameVersionedArtifact( + left: PersistedClawPackageRef, + right: PersistedClawPackageRef, +): boolean { + return sameArtifact(left, right) && left.version === right.version; +} + +export function clawPackageRemovalSelector(packageRef: PersistedClawPackageRef): string { + return `${packageRef.kind}:${packageRef.ref}@${packageRef.version}`; +} + +function sameRecordedState(left: PersistedClawPackageRef, right: PersistedClawPackageRef): boolean { + return ( + left.status === right.status && + left.relationship === right.relationship && + left.origin === right.origin && + (left.independentOwner === right.independentOwner || + (right.independentOwner && !left.independentOwner)) + ); +} + +function otherClawAgentIds(params: { + packageRef: PersistedClawPackageRef; + workspace: string; + refs: PersistedClawPackageRef[]; + installs: PersistedClawInstall[]; + statuses?: ReadonlySet; +}): string[] { + return params.refs + .filter((candidate) => { + if ( + candidate.agentId === params.packageRef.agentId || + !sameArtifact(candidate, params.packageRef) || + (params.statuses && !params.statuses.has(candidate.status)) + ) { + return false; + } + if (params.packageRef.kind === "plugin") { + return true; + } + return params.installs.some( + (install) => + install.agentId === candidate.agentId && install.workspace === params.workspace, + ); + }) + .map((candidate) => candidate.agentId) + .toSorted(); +} + +function hasAnotherClawOwner(params: { + packageRef: PersistedClawPackageRef; + workspace: string; + refs: PersistedClawPackageRef[]; + installs: PersistedClawInstall[]; + statuses?: ReadonlySet; +}): boolean { + return otherClawAgentIds(params).length > 0; +} + +function ownerInstallIsNewer( + installedAt: string | number | undefined, + packageRef: PersistedClawPackageRef, +): boolean { + const timestamp = typeof installedAt === "number" ? installedAt : Date.parse(installedAt ?? ""); + return Number.isFinite(timestamp) && timestamp > packageRef.updatedAtMs; +} + +function pluginIntegrityMatches(actual: string | undefined, expected: string): boolean { + if (!actual) { + return false; + } + const normalizedActual = normalizeClawHubSha256Integrity(actual); + const normalizedExpected = normalizeClawHubSha256Integrity(expected); + return normalizedActual && normalizedExpected + ? normalizedActual === normalizedExpected + : actual === expected; +} + +export async function inspectClawPackage( + install: PersistedClawInstall, + packageRef: PersistedClawPackageRef, + deps: PackageRemovalDeps = {}, +): Promise { + if (packageRef.status !== "complete") { + return { ...packageRef, state: "incomplete", message: "Package installation is incomplete." }; + } + if (packageRef.kind === "plugin") { + const resolution = await (deps.resolvePlugin ?? resolveInstalledClawHubPlugin)({ + clawhubPackage: packageRef.ref, + }); + if (resolution.status !== "found") { + return { + ...packageRef, + state: resolution.status, + message: + resolution.status === "ambiguous" + ? "Installed plugin identity is ambiguous." + : "Installed plugin is missing.", + }; + } + if ( + resolution.installedVersion !== packageRef.version || + !pluginIntegrityMatches(resolution.record.integrity, packageRef.integrity) + ) { + return { + ...packageRef, + state: "modified", + message: "Installed plugin version changed after the Claw was added.", + }; + } + return { + ...packageRef, + independentOwner: + packageRef.independentOwner || + ownerInstallIsNewer(resolution.record.installedAt, packageRef), + state: "present", + }; + } + if (!install.workspace) { + return { + ...packageRef, + state: "ambiguous", + message: "Skill workspace provenance is missing.", + }; + } + const skill = await (deps.planSkill ?? planClawHubSkillUninstall)({ + workspaceDir: install.workspace, + slug: packageRef.ref, + expectedVersion: packageRef.version, + }); + return skill.ok + ? { + ...packageRef, + independentOwner: + packageRef.independentOwner || ownerInstallIsNewer(skill.plan.installedAt, packageRef), + state: "present", + } + : { ...packageRef, state: skill.code, message: skill.error }; +} + +export async function planClawPackageRemovals( + install: PersistedClawInstall, + packages: PersistedClawPackageRef[], + options: OpenClawStateDatabaseOptions & { + deps?: PackageRemovalDeps; + referencedCleanup?: ClawReferencedCleanup; + } = {}, +): Promise { + const deps = options.deps ?? {}; + const cleanup = options.referencedCleanup ?? { mode: "retain" }; + const selected = new Set(cleanup.selected ?? []); + const allRefs = (deps.readPackageRefs ?? readClawPackageRefs)(options); + let cachedInstalls: PersistedClawInstall[] | undefined; + const allInstalls = (): PersistedClawInstall[] => + (cachedInstalls ??= (deps.readInstallRecords ?? readClawInstallRecords)(options)); + const decisions: ClawPackageRemovalDecision[] = []; + for (const packageRef of packages) { + const affectedClawAgentIds = otherClawAgentIds({ + packageRef, + workspace: install.workspace, + refs: allRefs, + installs: packageRef.kind === "plugin" || !install.workspace ? [] : allInstalls(), + statuses: new Set(["pending", "complete"]), + }); + const retain = (reason: string): void => { + decisions.push({ + packageRef, + workspace: install.workspace, + action: "retain", + reason, + affectedClawAgentIds, + }); + }; + if (packageRef.status !== "complete") { + retain("Package installation is incomplete."); + continue; + } + const selector = clawPackageRemovalSelector(packageRef); + const explicitlySelected = cleanup.mode === "remove-selected" && selected.has(selector); + const managedCleanup = packageRef.relationship === "managed"; + if (explicitlySelected && managedCleanup) { + decisions.push({ + packageRef, + workspace: install.workspace, + action: "retain", + blocked: true, + reason: "--remove-referenced only accepts resources with a referenced relationship.", + affectedClawAgentIds, + }); + continue; + } + if (!managedCleanup && !explicitlySelected && cleanup.mode !== "remove-if-unused") { + retain("Referenced resources are retained unless a cleanup mode selects them."); + continue; + } + if (!explicitlySelected && affectedClawAgentIds.length > 0) { + retain("Another Claw still references this package."); + continue; + } + if ( + !explicitlySelected && + (packageRef.independentOwner || packageRef.origin === "pre-existing") + ) { + retain("Package has a current non-Claw owner or pre-existing origin."); + continue; + } + + let pluginId: string | undefined; + let ownerIsNewer: boolean; + let skillPlan: ClawHubSkillUninstallPlan | undefined; + if (packageRef.kind === "plugin") { + const resolution = await (deps.resolvePlugin ?? resolveInstalledClawHubPlugin)({ + clawhubPackage: packageRef.ref, + }); + if (resolution.status !== "found") { + retain( + resolution.status === "ambiguous" + ? "Installed plugin identity is ambiguous." + : "Installed plugin is missing.", + ); + continue; + } + if ( + resolution.installedVersion !== packageRef.version || + !pluginIntegrityMatches(resolution.record.integrity, packageRef.integrity) + ) { + retain("Installed plugin changed after the Claw was added."); + continue; + } + pluginId = resolution.pluginId; + ownerIsNewer = ownerInstallIsNewer(resolution.record.installedAt, packageRef); + } else { + if (!install.workspace) { + retain("Skill workspace provenance is missing."); + continue; + } + const skill = await (deps.planSkill ?? planClawHubSkillUninstall)({ + workspaceDir: install.workspace, + slug: packageRef.ref, + expectedVersion: packageRef.version, + }); + if (!skill.ok) { + retain(skill.error); + continue; + } + skillPlan = skill.plan; + ownerIsNewer = ownerInstallIsNewer(skill.plan.installedAt, packageRef); + } + + const independentlyOwned = packageRef.independentOwner || ownerIsNewer; + const hasConflicts = + affectedClawAgentIds.length > 0 || independentlyOwned || packageRef.origin === "pre-existing"; + if (!explicitlySelected && hasConflicts) { + retain( + affectedClawAgentIds.length > 0 + ? "Another Claw still references this package." + : "Package has a current non-Claw owner or pre-existing origin.", + ); + continue; + } + if (!explicitlySelected && packageRef.origin !== "claw-introduced") { + retain("Only Claw-introduced referenced resources qualify for remove-if-unused."); + continue; + } + if (explicitlySelected && hasConflicts && !cleanup.allowConflicts) { + decisions.push({ + packageRef, + workspace: install.workspace, + action: "retain", + blocked: true, + reason: + "Selected resource has other Claw dependents, a non-Claw owner, or pre-existing origin; explicit conflict override is required.", + affectedClawAgentIds, + ...(pluginId ? { pluginId } : {}), + ...(skillPlan ? { skillPlan } : {}), + }); + continue; + } + decisions.push({ + packageRef, + workspace: install.workspace, + action: "uninstall", + ...(explicitlySelected && cleanup.allowConflicts ? { allowConflicts: true } : {}), + affectedClawAgentIds, + ...(pluginId ? { pluginId } : {}), + ...(skillPlan ? { skillPlan } : {}), + }); + } + return decisions; +} + +export async function applyClawPackageRemovals( + decisions: ClawPackageRemovalDecision[], + options: OpenClawStateDatabaseOptions & { deps?: PackageRemovalDeps } = {}, +): Promise { + const deps = options.deps ?? {}; + const results: ClawPackageRemovalResult[] = []; + for (const decision of decisions) { + const base = { + kind: decision.packageRef.kind, + ref: decision.packageRef.ref, + version: decision.packageRef.version, + }; + let packageLease: MaintainedClawPackageLifecycleLease | null = null; + let claimed = false; + let externalMutationStarted = false; + try { + const leaseArtifact = + decision.packageRef.kind === "skill" + ? { + kind: decision.packageRef.kind, + source: decision.packageRef.source, + ref: decision.packageRef.ref, + workspace: decision.workspace, + } + : { + kind: decision.packageRef.kind, + source: decision.packageRef.source, + ref: decision.packageRef.ref, + }; + const acquiredLease = (deps.acquirePackageLease ?? acquireClawPackageLifecycleLease)( + leaseArtifact, + { env: options.env, path: options.path, required: true }, + ); + if (!acquiredLease) { + throw new Error( + `Could not acquire package lifecycle lease for ${decision.packageRef.ref}.`, + ); + } + packageLease = maintainClawPackageLifecycleLease(acquiredLease); + const currentRefs = (deps.readPackageRefs ?? readClawPackageRefs)(options); + const currentInstalls = + decision.packageRef.kind === "plugin" + ? [] + : (deps.readInstallRecords ?? readClawInstallRecords)(options); + const currentRef = currentRefs.find( + (candidate) => + candidate.agentId === decision.packageRef.agentId && + sameVersionedArtifact(candidate, decision.packageRef), + ); + if (decision.blocked) { + throw new Error(decision.reason ?? "Package cleanup is blocked."); + } + if (decision.action === "retain") { + if (!currentRef || !sameRecordedState(currentRef, decision.packageRef)) { + throw new Error( + `Package ${decision.packageRef.ref}@${decision.packageRef.version} ownership changed after removal planning.`, + ); + } + if (currentRef.status === "complete") { + (deps.claimPackageRef ?? updateClawPackageRefStatus)(currentRef, "pending", options); + claimed = true; + } + if (decision.reason === "Another Claw still references this package.") { + const postClaimRefs = (deps.readPackageRefs ?? readClawPackageRefs)(options); + const postClaimInstalls = + decision.packageRef.kind === "plugin" + ? [] + : (deps.readInstallRecords ?? readClawInstallRecords)(options); + if ( + !hasAnotherClawOwner({ + packageRef: decision.packageRef, + workspace: decision.workspace, + refs: postClaimRefs, + installs: postClaimInstalls, + statuses: new Set(["complete"]), + }) + ) { + throw new Error( + `Package ${decision.packageRef.ref}@${decision.packageRef.version} no longer has another surviving Claw owner.`, + ); + } + } + results.push({ ...base, action: "retained", reason: decision.reason }); + continue; + } + const sharedPackage = hasAnotherClawOwner({ + packageRef: decision.packageRef, + workspace: decision.workspace, + refs: currentRefs, + installs: currentInstalls, + statuses: new Set(["complete"]), + }); + if ( + !currentRef || + currentRef.status !== "complete" || + !sameRecordedState(currentRef, decision.packageRef) || + (sharedPackage && !decision.allowConflicts) + ) { + throw new Error( + `Package ${decision.packageRef.ref}@${decision.packageRef.version} ownership changed after removal planning.`, + ); + } + (deps.claimPackageRef ?? updateClawPackageRefStatus)(currentRef, "pending", options); + claimed = true; + const postClaimRefs = (deps.readPackageRefs ?? readClawPackageRefs)(options); + const postClaimInstalls = + decision.packageRef.kind === "plugin" + ? [] + : (deps.readInstallRecords ?? readClawInstallRecords)(options); + const postClaimRef = postClaimRefs.find( + (candidate) => + candidate.agentId === decision.packageRef.agentId && + sameVersionedArtifact(candidate, decision.packageRef), + ); + const postClaimShared = hasAnotherClawOwner({ + packageRef: decision.packageRef, + workspace: decision.workspace, + refs: postClaimRefs, + installs: postClaimInstalls, + statuses: new Set(["complete"]), + }); + if ( + !postClaimRef || + postClaimRef.status !== "pending" || + postClaimRef.relationship !== decision.packageRef.relationship || + postClaimRef.origin !== decision.packageRef.origin || + (postClaimRef.independentOwner !== decision.packageRef.independentOwner && + !decision.packageRef.independentOwner) || + (postClaimShared && !decision.allowConflicts) + ) { + throw new Error( + `Package ${decision.packageRef.ref}@${decision.packageRef.version} ownership changed while claiming removal.`, + ); + } + if (decision.packageRef.kind === "plugin") { + if (!decision.pluginId) { + throw new Error("Plugin removal plan is missing canonical install identity."); + } + const resolution = await (deps.resolvePlugin ?? resolveInstalledClawHubPlugin)({ + clawhubPackage: decision.packageRef.ref, + }); + if ( + resolution.status !== "found" || + resolution.pluginId !== decision.pluginId || + resolution.installedVersion !== decision.packageRef.version || + !pluginIntegrityMatches(resolution.record.integrity, decision.packageRef.integrity) || + ownerInstallIsNewer(resolution.record.installedAt, decision.packageRef) + ) { + throw new Error( + `Plugin ${decision.packageRef.ref}@${decision.packageRef.version} changed after removal planning.`, + ); + } + externalMutationStarted = true; + await (deps.uninstallPlugin ?? runPluginUninstallCommand)(decision.pluginId, { + force: true, + invalidateRuntimeCache: false, + clawManaged: true, + }); + } else { + if (!decision.skillPlan) { + throw new Error("Skill removal plan is missing canonical uninstall state."); + } + externalMutationStarted = true; + const removed = await (deps.uninstallSkill ?? applyClawHubSkillUninstall)( + decision.skillPlan, + ); + if (!removed.ok) { + throw new Error(removed.error); + } + } + packageLease.assertCurrent(); + (deps.claimPackageRef ?? updateClawPackageRefStatus)( + decision.packageRef, + "complete", + options, + ); + results.push({ ...base, action: "uninstalled" }); + } catch (error) { + if (claimed) { + try { + (deps.claimPackageRef ?? updateClawPackageRefStatus)( + decision.packageRef, + externalMutationStarted ? "failed" : "complete", + options, + ); + } catch { + // Preserve the original cleanup failure as the actionable result. + } + } + results.push({ + ...base, + action: "error", + reason: error instanceof Error ? error.message : String(error), + }); + } finally { + try { + packageLease?.release(); + } catch { + // Lease expiry recovers cleanup when the shared state database is unavailable. + } + } + } + return results; +} diff --git a/src/claws/packages.test.ts b/src/claws/packages.test.ts index 1acf993a5056..343c228df48b 100644 --- a/src/claws/packages.test.ts +++ b/src/claws/packages.test.ts @@ -59,13 +59,21 @@ function plan( }; } +const integrity = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const pluginPackage = { + kind: "plugin", + source: "clawhub", + ref: "@owner/audit", + version: "2.0.1", + integrity, +} as const; + const completePackageRef = vi.fn( (ref: PersistedClawPackageRef, status: PersistedClawPackageRef["status"]) => ({ ...ref, status, }), ); - const pluginIntegrity = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; function pluginPackageRef( ref: string, @@ -89,7 +97,7 @@ function pluginPackageRef( ...overrides, }; } - +const acquirePackageLease = vi.fn(() => ({ heartbeat: vi.fn(), release: vi.fn() })); const probePlugin = vi.fn(async ({ spec }: { spec: string }) => { const pluginId = spec.slice(spec.lastIndexOf("/") + 1).split("@")[0]!; const packageName = spec.replace(/^clawhub:/, "").replace(/@[^@]+$/, ""); @@ -104,15 +112,20 @@ const probePlugin = vi.fn(async ({ spec }: { spec: string }) => { clawhubUrl: "https://clawhub.ai", clawhubPackage: packageName, clawhubFamily: "code-plugin" as const, - integrity: pluginIntegrity, + integrity, }, }; }); describe("installClawPackages", () => { it("installs skill packages into the planned workspace with the resolved digest", async () => { - const integrity = `sha256-${Buffer.from("a".repeat(64), "hex").toString("base64")}`; - const pending = { kind: "skill", ref: "@owner/triage", status: "pending", integrity }; + const skillIntegrity = `sha256-${Buffer.from("a".repeat(64), "hex").toString("base64")}`; + const pending = { + kind: "skill", + ref: "@owner/triage", + status: "pending", + integrity: skillIntegrity, + }; const installSkill = vi.fn().mockResolvedValue({ ok: true, slug: "triage", @@ -129,15 +142,18 @@ describe("installClawPackages", () => { source: "clawhub", ref: "@owner/triage", version: "1.2.3", - integrity, + integrity: skillIntegrity, }, ]), { deps: { installSkill, - preflightSkill: vi.fn().mockResolvedValue({ ok: true, action: "install", integrity }), + preflightSkill: vi + .fn() + .mockResolvedValue({ ok: true, action: "install", integrity: skillIntegrity }), persistPackageRef, completePackageRef, + acquirePackageLease, }, onExternalMutation, }, @@ -148,12 +164,13 @@ describe("installClawPackages", () => { workspaceDir: "/tmp/incident-2", slug: "@owner/triage", version: "1.2.3", - expectedIntegrity: integrity, + expectedIntegrity: skillIntegrity, + clawManaged: true, }), ); expect(persistPackageRef).toHaveBeenCalledWith( expect.anything(), - expect.objectContaining({ integrity }), + expect.objectContaining({ integrity: skillIntegrity }), expect.objectContaining({ status: "pending", relationship: "managed", @@ -172,30 +189,20 @@ describe("installClawPackages", () => { kind: "plugin", ref: "@owner/audit", status: "pending", - integrity: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + integrity, }); const preflightPlugin = vi.fn().mockResolvedValue({ ok: true, action: "install" }); - await installClawPackages( - plan([ - { - kind: "plugin", - source: "clawhub", - ref: "@owner/audit", - version: "2.0.1", - integrity: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - }, - ]), - { - deps: { - installPlugin, - probePlugin, - preflightPlugin, - persistPackageRef, - completePackageRef, - }, + await installClawPackages(plan([pluginPackage]), { + deps: { + installPlugin, + probePlugin, + preflightPlugin, + persistPackageRef, + completePackageRef, + acquirePackageLease, }, - ); + }); expect(installPlugin).toHaveBeenCalledWith( expect.objectContaining({ @@ -207,6 +214,7 @@ describe("installClawPackages", () => { expectedPluginId: "audit", }, invalidateRuntimeCache: false, + clawManaged: true, }), ); expect(persistPackageRef).toHaveBeenCalledWith( @@ -233,29 +241,17 @@ describe("installClawPackages", () => { installedIntegrity: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", }); - await installClawPackages( - plan( - [ - { - kind: "plugin", - source: "clawhub", - ref: "@owner/audit", - version: "2.0.1", - integrity: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - }, - ], - "reuse", - ), - { - deps: { - installPlugin, - probePlugin, - preflightPlugin, - persistPackageRef, - completePackageRef, - }, + await installClawPackages(plan([pluginPackage], "reuse"), { + deps: { + installPlugin, + probePlugin, + preflightPlugin, + persistPackageRef, + completePackageRef, + readPackageRefs: vi.fn().mockReturnValue([]), + acquirePackageLease, }, - ); + }); expect(installPlugin).not.toHaveBeenCalled(); expect(persistPackageRef).toHaveBeenCalledWith( @@ -272,36 +268,100 @@ describe("installClawPackages", () => { ); }); + it("inherits Claw-introduced origin when another Claw already owns the plugin", async () => { + const persistPackageRef = vi.fn().mockReturnValue({ kind: "plugin" }); + const existing = { + relationship: "referenced", + origin: "claw-introduced", + independentOwner: false, + } as PersistedClawPackageRef; + + await installClawPackages(plan([pluginPackage], "reuse"), { + deps: { + installPlugin: vi.fn(), + probePlugin, + preflightPlugin: vi.fn().mockResolvedValue({ + ok: true, + action: "reuse", + installedId: "audit", + installedIntegrity: integrity, + }), + persistPackageRef, + completePackageRef, + readPackageRefs: vi.fn().mockReturnValue([existing]), + acquirePackageLease, + }, + }); + + expect(persistPackageRef).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + relationship: "referenced", + origin: "claw-introduced", + independentOwner: false, + }), + ); + }); + + it("preserves a newer independent plugin reinstall when another Claw reuses it", async () => { + const persistPackageRef = vi.fn().mockReturnValue({ kind: "plugin" }); + const existing = { + relationship: "referenced", + origin: "claw-introduced", + independentOwner: false, + updatedAtMs: 10, + } as PersistedClawPackageRef; + + await installClawPackages(plan([pluginPackage], "reuse"), { + deps: { + installPlugin: vi.fn(), + probePlugin, + preflightPlugin: vi.fn().mockResolvedValue({ + ok: true, + action: "reuse", + installedId: "audit", + installedIntegrity: integrity, + installedAt: new Date(20).toISOString(), + }), + persistPackageRef, + completePackageRef, + readPackageRefs: vi.fn().mockReturnValue([existing]), + acquirePackageLease, + }, + }); + + expect(persistPackageRef).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + relationship: "referenced", + origin: "pre-existing", + independentOwner: true, + }), + ); + }); + it("marks the pending ref failed when a plugin install fails", async () => { const pending = { kind: "plugin", ref: "@owner/audit", status: "pending", - integrity: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + integrity, } as PersistedClawPackageRef; const persistPackageRef = vi.fn().mockReturnValue(pending); await expect( - installClawPackages( - plan([ - { - kind: "plugin", - source: "clawhub", - ref: "@owner/audit", - version: "2.0.1", - integrity: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - }, - ]), - { - deps: { - installPlugin: vi.fn().mockRejectedValue(new Error("registry unavailable")), - probePlugin, - preflightPlugin: vi.fn().mockResolvedValue({ ok: true, action: "install" }), - persistPackageRef, - completePackageRef, - }, + installClawPackages(plan([pluginPackage]), { + deps: { + installPlugin: vi.fn().mockRejectedValue(new Error("registry unavailable")), + probePlugin, + preflightPlugin: vi.fn().mockResolvedValue({ ok: true, action: "install" }), + persistPackageRef, + completePackageRef, + acquirePackageLease, }, - ), + }), ).rejects.toMatchObject({ code: "package_install_failed", message: "registry unavailable", @@ -310,7 +370,8 @@ describe("installClawPackages", () => { }); it("removes a newly installed plugin when a later package fails", async () => { - const integrity = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const rollbackIntegrity = + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const installPlugin = vi .fn() .mockResolvedValueOnce(undefined) @@ -329,8 +390,20 @@ describe("installClawPackages", () => { await expect( installClawPackages( plan([ - { kind: "plugin", source: "clawhub", ref: "@owner/first", version: "1.0.0", integrity }, - { kind: "plugin", source: "clawhub", ref: "@owner/second", version: "1.0.0", integrity }, + { + kind: "plugin", + source: "clawhub", + ref: "@owner/first", + version: "1.0.0", + integrity: rollbackIntegrity, + }, + { + kind: "plugin", + source: "clawhub", + ref: "@owner/second", + version: "1.0.0", + integrity: rollbackIntegrity, + }, ]), { deps: { @@ -341,13 +414,14 @@ describe("installClawPackages", () => { persistPackageRef, completePackageRef, readPackageRefs, + acquirePackageLease, resolvePlugin: vi.fn().mockResolvedValue({ status: "found", pluginId: "first", installedVersion: "1.0.0", record: { source: "clawhub", - integrity, + integrity: rollbackIntegrity, installedAt: new Date(1_500).toISOString(), }, }), @@ -425,33 +499,23 @@ describe("installClawPackages", () => { kind: "plugin", ref: "@owner/audit", status: "pending", - integrity: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + integrity, } as PersistedClawPackageRef; const failingCompletePackageRef = vi.fn(() => { throw new Error("state database unavailable"); }); await expect( - installClawPackages( - plan([ - { - kind: "plugin", - source: "clawhub", - ref: "@owner/audit", - version: "2.0.1", - integrity: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - }, - ]), - { - deps: { - installPlugin: vi.fn().mockRejectedValue(new Error("registry unavailable")), - probePlugin, - preflightPlugin: vi.fn().mockResolvedValue({ ok: true, action: "install" }), - persistPackageRef: vi.fn().mockReturnValue(pending), - completePackageRef: failingCompletePackageRef, - }, + installClawPackages(plan([pluginPackage]), { + deps: { + installPlugin: vi.fn().mockRejectedValue(new Error("registry unavailable")), + probePlugin, + preflightPlugin: vi.fn().mockResolvedValue({ ok: true, action: "install" }), + persistPackageRef: vi.fn().mockReturnValue(pending), + completePackageRef: failingCompletePackageRef, + acquirePackageLease, }, - ), + }), ).rejects.toMatchObject({ code: "package_install_failed", message: "registry unavailable", @@ -466,35 +530,31 @@ describe("installClawPackages", () => { const preflightPlugin = vi.fn().mockResolvedValue({ ok: true, action: "reuse" }); await expect( - installClawPackages( - plan([ - { - kind: "plugin", - source: "clawhub", - ref: "@owner/audit", - version: "2.0.1", - integrity: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - }, - ]), - { - deps: { - installPlugin, - probePlugin, - preflightPlugin, - persistPackageRef, - completePackageRef, - }, + installClawPackages(plan([pluginPackage]), { + deps: { + installPlugin, + probePlugin, + preflightPlugin, + persistPackageRef, + completePackageRef, + acquirePackageLease, }, - ), + }), ).rejects.toMatchObject({ code: "package_owner_state_changed" }); expect(installPlugin).not.toHaveBeenCalled(); expect(persistPackageRef).not.toHaveBeenCalled(); }); it("invalidates consent when a skill trust warning changes after planning", async () => { - const integrity = `sha256-${Buffer.from("a".repeat(64), "hex").toString("base64")}`; + const skillIntegrity = `sha256-${Buffer.from("a".repeat(64), "hex").toString("base64")}`; const planned = plan([ - { kind: "skill", source: "clawhub", ref: "@owner/triage", version: "1.2.3", integrity }, + { + kind: "skill", + source: "clawhub", + ref: "@owner/triage", + version: "1.2.3", + integrity: skillIntegrity, + }, ]); Object.assign(planned.actions[0]!.details!, { riskWarning: "review warning one" }); @@ -504,24 +564,17 @@ describe("installClawPackages", () => { preflightSkill: vi.fn().mockResolvedValue({ ok: true, action: "install", - integrity, + integrity: skillIntegrity, warning: "review warning two", }), + acquirePackageLease, }, }), ).rejects.toMatchObject({ code: "package_owner_state_changed" }); }); it("invalidates consent when a plugin trust warning changes after planning", async () => { - const planned = plan([ - { - kind: "plugin", - source: "clawhub", - ref: "@owner/audit", - version: "2.0.1", - integrity: pluginIntegrity, - }, - ]); + const planned = plan([pluginPackage]); Object.assign(planned.actions[0]!.details!, { riskWarning: "review warning one" }); await expect( @@ -531,8 +584,9 @@ describe("installClawPackages", () => { ok: true, pluginId: "audit", warning: "review warning two", - clawhub: { integrity: pluginIntegrity }, + clawhub: { integrity }, }), + acquirePackageLease, }, }), ).rejects.toMatchObject({ code: "package_owner_state_changed" }); diff --git a/src/claws/provenance.ts b/src/claws/provenance.ts index d244a6fc15f0..310285e9f759 100644 --- a/src/claws/provenance.ts +++ b/src/claws/provenance.ts @@ -53,6 +53,54 @@ export type PersistedClawInstall = { updatedAtMs: number; }; +type InstallRow = { + schema_version: string; + source_kind: "package" | "development"; + claw_name: string; + claw_version: string; + package_root: string; + manifest_path: string; + integrity_kind: "artifact" | "development-snapshot"; + integrity: string; + source_byte_length: number | bigint; + manifest_schema_version: number | bigint; + plan_integrity: string; + agent_id: string; + workspace: string; + agent_config_digest: string; + agent_owned_paths_json: string; + status: ClawInstallStatus; + added_at_ms: number | bigint; + updated_at_ms: number | bigint; +}; + +function rowToInstall(row: InstallRow): PersistedClawInstall { + return { + schemaVersion: CLAW_INSTALL_RECORD_SCHEMA_VERSION, + claw: { + kind: row.source_kind, + name: row.claw_name, + version: row.claw_version, + packageRoot: row.package_root, + manifestPath: row.manifest_path, + integrityKind: row.integrity_kind, + integrity: row.integrity, + byteLength: Number(row.source_byte_length), + }, + manifestSchemaVersion: Number( + row.manifest_schema_version, + ) as ClawAddPlan["manifestSchemaVersion"], + planIntegrity: row.plan_integrity, + agentId: row.agent_id, + workspace: row.workspace, + agentConfigDigest: row.agent_config_digest, + agentOwnedPaths: JSON.parse(row.agent_owned_paths_json) as string[], + status: row.status, + addedAtMs: Number(row.added_at_ms), + updatedAtMs: Number(row.updated_at_ms), + }; +} + function digestAgentConfig(plan: ClawAddPlan): string { return `sha256:${createHash("sha256").update(stableStringify(plan.agent.config)).digest("hex")}`; } @@ -266,6 +314,26 @@ export function deleteClawInstallRecord( }, options); } +export function readClawInstallRecords( + options: OpenClawStateDatabaseOptions = {}, +): PersistedClawInstall[] { + const database = openOpenClawStateDatabase(options); + // sqlite-allow-raw: read-only Claw install inventory ordered by stable agent id. + const rows = + database.db /* sqlite-allow-raw: read-only Claw install inventory ordered by stable agent id. */ + .prepare( + `SELECT schema_version, source_kind, claw_name, claw_version, package_root, + manifest_path, integrity_kind, integrity, source_byte_length, + manifest_schema_version, plan_integrity, agent_id, workspace, + agent_config_digest, agent_owned_paths_json, status, added_at_ms, + updated_at_ms + FROM claw_installs + ORDER BY agent_id`, + ) + .all() as InstallRow[]; + return rows.map(rowToInstall); +} + const CLAW_PACKAGE_REF_SCHEMA_VERSION = "openclaw.clawPackageRef.v1" as const; type ClawPackageRefStatus = "pending" | "complete" | "failed" | "rolled_back"; type ClawPackageRelationship = "managed" | "referenced"; @@ -489,6 +557,7 @@ export function updateClawPackageRefStatus( export function readClawPackageRefs( options: OpenClawStateDatabaseOptions & { + agentId?: string; kind?: ClawPackage["kind"]; source?: ClawPackage["source"]; ref?: string; @@ -501,6 +570,7 @@ export function readClawPackageRefs( const conditions: string[] = []; const params: Record = {}; for (const [column, value] of [ + ["agent_id", options.agentId], ["package_kind", options.kind], ["package_source", options.source], ["package_ref", options.ref], diff --git a/src/claws/workspace.ts b/src/claws/workspace.ts index d3441dc0a82b..e198b2ff08d3 100644 --- a/src/claws/workspace.ts +++ b/src/claws/workspace.ts @@ -4,6 +4,7 @@ import { realpath } from "node:fs/promises"; import { isAbsolute, relative, resolve, sep } from "node:path"; import { root as fsSafeRoot, FsSafeError } from "../infra/fs-safe.js"; import { + openOpenClawStateDatabase, runOpenClawStateWriteTransaction, type OpenClawStateDatabaseOptions, } from "../state/openclaw-state-db.js"; @@ -35,6 +36,32 @@ export class ClawWorkspaceWriteError extends Error { } } +type WorkspaceFileRow = { + schema_version: string; + agent_id: string; + workspace: string; + target_path: string; + source_path: string; + content_digest: string; + status: PersistedClawWorkspaceFile["status"]; + created_at_ms: number | bigint; + updated_at_ms: number | bigint; +}; + +function rowToWorkspaceFile(row: WorkspaceFileRow): PersistedClawWorkspaceFile { + return { + schemaVersion: CLAW_WORKSPACE_FILE_RECORD_SCHEMA_VERSION, + agentId: row.agent_id, + workspace: row.workspace, + path: row.target_path, + sourcePath: row.source_path, + contentDigest: row.content_digest, + status: row.status, + createdAtMs: Number(row.created_at_ms), + updatedAtMs: Number(row.updated_at_ms), + }; +} + function diagnostic(action: ClawAddPlanAction, code: string, message: string): ClawDiagnostic { return { level: "error", @@ -183,6 +210,25 @@ function workspaceFileActions(plan: ClawAddPlan): ClawAddPlanAction[] { return plan.actions.filter((action) => action.kind === "workspaceFile"); } +export function readClawWorkspaceFiles( + agentId: string, + options: OpenClawStateDatabaseOptions = {}, +): PersistedClawWorkspaceFile[] { + const database = openOpenClawStateDatabase(options); + // sqlite-allow-raw: read-only Claw workspace-file lookup with a closed agent-id filter. + const rows = + database.db /* sqlite-allow-raw: read-only Claw workspace-file lookup with a closed agent-id filter. */ + .prepare( + `SELECT schema_version, agent_id, workspace, target_path, source_path, + content_digest, status, created_at_ms, updated_at_ms + FROM claw_workspace_files + WHERE agent_id = ? + ORDER BY target_path`, + ) + .all(agentId) as WorkspaceFileRow[]; + return rows.map(rowToWorkspaceFile); +} + export async function createClawWorkspaceFiles( plan: ClawAddPlan, options: OpenClawStateDatabaseOptions & { nowMs?: number } = {}, diff --git a/src/cli/claws-cli.runtime.ts b/src/cli/claws-cli.runtime.ts index ae760c3ec9c9..568cd3c2363d 100644 --- a/src/cli/claws-cli.runtime.ts +++ b/src/cli/claws-cli.runtime.ts @@ -6,6 +6,14 @@ import { ClawAddMutationError, } from "../claws/add.js"; import { assertExperimentalClawsEnabled } from "../claws/experimental.js"; +import { + applyClawRemovePlan, + buildClawRemovePlan, + CLAW_REMOVE_PLAN_SCHEMA_VERSION, + CLAW_REMOVE_RESULT_SCHEMA_VERSION, + ClawRemoveError, + readClawStatus, +} from "../claws/lifecycle-state.js"; import { buildClawAddPlan } from "../claws/lifecycle.js"; import { preflightClawPackage } from "../claws/packages.js"; import { readClawInstallRecord } from "../claws/provenance.js"; @@ -24,7 +32,12 @@ import { } from "../cron/store.js"; import { redactSensitiveText } from "../logging/redact.js"; import { defaultRuntime, writeRuntimeJson, type RuntimeEnv } from "../runtime.js"; -import type { ClawsAddOptions, ClawsInspectOptions } from "./claws-cli.js"; +import type { + ClawsAddOptions, + ClawsInspectOptions, + ClawsRemoveOptions, + ClawsStatusOptions, +} from "./claws-cli.js"; type DiagnosticLike = { level: string; code: string; path: string; message: string }; @@ -108,6 +121,28 @@ function failNonDryRun(opts: ClawsAddOptions, runtime: RuntimeEnv): boolean { return true; } +function requireRemoveConsent(opts: ClawsRemoveOptions, runtime: RuntimeEnv): boolean { + if (opts.dryRun || (opts.yes && opts.planIntegrity)) { + return false; + } + const code = opts.yes ? "plan_integrity_required" : "consent_required"; + const message = opts.yes + ? "Claw remove consent must include --plan-integrity from the exact dry-run plan." + : "Claw remove requires explicit consent; pass --dry-run to preview or --yes with --plan-integrity to remove owned state."; + if (opts.json) { + writeRuntimeJson(runtime, { + schemaVersion: CLAW_REMOVE_PLAN_SCHEMA_VERSION, + stability: CLAW_OUTPUT_STABILITY, + ok: false, + error: { code, message }, + }); + } else { + runtime.error(message); + } + runtime.exit(1); + return true; +} + export async function runClawsInspectCommand( sourcePath: string, opts: ClawsInspectOptions, @@ -302,3 +337,118 @@ export async function runClawsAddCommand( runtime.exit(1); } } + +export async function runClawsStatusCommand( + target: string | undefined, + opts: ClawsStatusOptions, + runtime: RuntimeEnv = defaultRuntime, +): Promise { + assertExperimentalClawsEnabled(); + const status = await readClawStatus(target); + if (opts.json) { + writeRuntimeJson(runtime, status); + } else { + logExperimentalWarning(runtime); + runtime.log(`Installed Claws: ${status.summary.claws}`); + for (const record of status.records) { + runtime.log( + `${record.install.agentId}: ${record.install.claw.name}@${record.install.claw.version} (${record.install.status})`, + ); + runtime.log( + ` Agent: ${record.agentState}; files: ${record.workspaceFiles.length}; packages: ${record.packages.length}`, + ); + } + } + if (target && status.records.length === 0) { + runtime.exit(1); + } +} + +export async function runClawsRemoveCommand( + target: string, + opts: ClawsRemoveOptions, + runtime: RuntimeEnv = defaultRuntime, +): Promise { + assertExperimentalClawsEnabled(); + if (requireRemoveConsent(opts, runtime)) { + return; + } + const selected = opts.removeReferenced ?? []; + if (opts.removeUnused && selected.length > 0) { + runtime.error("Choose either --remove-unused or --remove-referenced, not both."); + runtime.exit(1); + return; + } + if (opts.forceReferenced && selected.length === 0) { + runtime.error("--force-referenced requires at least one --remove-referenced selector."); + runtime.exit(1); + return; + } + const referencedCleanup = selected.length + ? { + mode: "remove-selected" as const, + selected, + allowConflicts: Boolean(opts.forceReferenced), + } + : opts.removeUnused + ? { mode: "remove-if-unused" as const } + : { mode: "retain" as const }; + const plan = await buildClawRemovePlan(target, { referencedCleanup }); + if (opts.dryRun || plan.blockers.length > 0) { + if (opts.json) { + writeRuntimeJson(runtime, plan); + } else { + logExperimentalWarning(runtime); + runtime.log(`Remove actions: ${plan.actions.length}`); + runtime.log(`Plan integrity: ${plan.planIntegrity}`); + for (const action of plan.actions.filter((candidate) => candidate.kind === "packageRef")) { + runtime.log( + ` Package ${action.target}: ${action.action}${action.reason ? ` (${action.reason})` : ""}`, + ); + } + if (plan.blockers.length > 0) { + runtime.error(plan.blockers.map((blocker) => blocker.message).join("\n")); + } + } + if (plan.blockers.length > 0) { + runtime.exit(1); + } + return; + } + try { + const result = await applyClawRemovePlan(plan, { + consentPlanIntegrity: opts.planIntegrity, + referencedCleanup, + }); + if (opts.json) { + writeRuntimeJson(runtime, result); + } else { + logExperimentalWarning(runtime); + runtime.log(`Removed agent: ${result.agentId}`); + runtime.log(`Status: ${result.status}`); + for (const pkg of result.packages) { + runtime.log( + ` Package ${pkg.kind}:${pkg.ref}@${pkg.version}: ${pkg.action}${pkg.reason ? ` (${pkg.reason})` : ""}`, + ); + } + runtime.log(`Package references released: ${result.packageRefsReleased}`); + } + if (result.status !== "complete") { + runtime.exit(1); + } + } catch (error) { + const code = error instanceof ClawRemoveError ? error.code : "remove_failed"; + const message = error instanceof Error ? error.message : String(error); + if (opts.json) { + writeRuntimeJson(runtime, { + schemaVersion: CLAW_REMOVE_RESULT_SCHEMA_VERSION, + stability: CLAW_OUTPUT_STABILITY, + status: "failed", + error: { code, message }, + }); + } else { + runtime.error(message); + } + runtime.exit(1); + } +} diff --git a/src/cli/claws-cli.test.ts b/src/cli/claws-cli.test.ts index 19d4222f142e..5859e8f64bbf 100644 --- a/src/cli/claws-cli.test.ts +++ b/src/cli/claws-cli.test.ts @@ -27,6 +27,9 @@ const mocks = vi.hoisted(() => { runtime, loadConfig: vi.fn<() => Record>(() => ({})), applyClawAddPlan: vi.fn(), + readClawStatus: vi.fn(), + buildClawRemovePlan: vi.fn(), + applyClawRemovePlan: vi.fn(), }; }); @@ -48,6 +51,15 @@ vi.mock("../claws/add.js", async () => ({ 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, +})); + const { registerClawsCli } = await import("./claws-cli.js"); const tempDirs = useAutoCleanupTempDirTracker(afterEach); @@ -133,6 +145,42 @@ describe("claws cli", () => { 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.list[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: [], + packageRefsReleased: 1, + }); }); afterEach(() => { @@ -148,12 +196,17 @@ describe("claws cli", () => { expect(program.commands.map((command) => command.name())).not.toContain("claws"); }); - it("registers inspect and add without exposing the prototype apply or feed commands", () => { + 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"]); + expect(claws?.commands.map((command) => command.name())).toEqual([ + "inspect", + "add", + "status", + "remove", + ]); }); it("prints versioned experimental JSON for a development manifest", async () => { @@ -479,4 +532,124 @@ describe("claws cli", () => { }); 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("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" }), + { + 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" }, + }); + }); }); diff --git a/src/cli/claws-cli.ts b/src/cli/claws-cli.ts index 12a44bcb763e..4df2adc3b5e5 100644 --- a/src/cli/claws-cli.ts +++ b/src/cli/claws-cli.ts @@ -16,6 +16,21 @@ export type ClawsAddOptions = { workspace?: string; }; +export type ClawsStatusOptions = { json?: boolean }; +export type ClawsRemoveOptions = { + dryRun?: boolean; + yes?: boolean; + planIntegrity?: string; + removeUnused?: boolean; + removeReferenced?: string[]; + forceReferenced?: boolean; + json?: boolean; +}; + +function collectOption(value: string, previous: string[]): string[] { + return [...previous, value]; +} + export function registerClawsCli(program: Command) { if (!isExperimentalClawsEnabled()) { return; @@ -47,5 +62,44 @@ export function registerClawsCli(program: Command) { await runClawsAddCommand(source, opts); }); + claws + .command("status") + .description("Show installed Claw agents and managed-state drift") + .argument("[claw-or-agent]", "Installed package name or final agent id") + .option("--json", "Print JSON", false) + .action(async (target: string | undefined, opts: ClawsStatusOptions) => { + const { runClawsStatusCommand } = await import("./claws-cli.runtime.js"); + await runClawsStatusCommand(target, opts); + }); + + claws + .command("remove") + .description("Plan or remove one Claw-created agent and owned state") + .argument("", "Installed package name or final agent id") + .option("--dry-run", "Preview removal without mutating state", false) + .option("--yes", "Confirm removal", false) + .option("--plan-integrity ", "Bind consent to an exact removal plan") + .option( + "--remove-unused", + "Remove unchanged Claw-introduced references with no other current owner", + false, + ) + .option( + "--remove-referenced ", + "Remove an exact referenced resource (repeatable)", + collectOption, + [], + ) + .option( + "--force-referenced", + "Allow selected cleanup despite other dependents, owners, or pre-existing origin", + false, + ) + .option("--json", "Print JSON", false) + .action(async (target: string, opts: ClawsRemoveOptions) => { + const { runClawsRemoveCommand } = await import("./claws-cli.runtime.js"); + await runClawsRemoveCommand(target, opts); + }); + applyParentDefaultHelpAction(claws); } diff --git a/src/cli/plugins-cli.install.test.ts b/src/cli/plugins-cli.install.test.ts index a40988b8d49e..51be4f0c696a 100644 --- a/src/cli/plugins-cli.install.test.ts +++ b/src/cli/plugins-cli.install.test.ts @@ -1658,6 +1658,7 @@ describe("plugins cli install", () => { "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", ); expect(record.clawpackSize).toBe(4096); + expect(readConfigFileSnapshotForWrite).toHaveBeenCalledTimes(2); expect(writeConfigFile).toHaveBeenCalledWith(enabledCfg); expect(runtimeLogsContain("Installed plugin: demo")).toBe(true); expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); diff --git a/src/cli/plugins-uninstall-command.ts b/src/cli/plugins-uninstall-command.ts index 55455e8d0f66..94a7aab377d3 100644 --- a/src/cli/plugins-uninstall-command.ts +++ b/src/cli/plugins-uninstall-command.ts @@ -5,11 +5,13 @@ import { theme } from "../../packages/terminal-core/src/theme.js"; import { assertConfigWriteAllowedInCurrentMode, readConfigFileSnapshot } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { parseClawHubPluginSpec } from "../infra/clawhub.js"; import { tracePluginLifecyclePhase, tracePluginLifecyclePhaseAsync, } from "../plugins/plugin-lifecycle-trace.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; +import { withClawPackageLifecycleLease } from "../state/claw-package-lifecycle-lease.js"; import { shortenHomePath } from "../utils.js"; type PluginUninstallOptions = { @@ -19,7 +21,7 @@ type PluginUninstallOptions = { force?: boolean; dryRun?: boolean; invalidateRuntimeCache?: boolean; - /** True when a Claw lifecycle caller already owns package coordination. */ + /** True when a Claw lifecycle caller already owns the package lease. */ clawManaged?: boolean; }; @@ -185,45 +187,60 @@ export async function runPluginUninstallCommand( } } - const nextInstallRecords = removePluginInstallRecordFromRecords(installRecords, pluginId); - await tracePluginLifecyclePhaseAsync( - "config mutation", - () => - commitPluginInstallRecordsWithConfig({ - previousInstallRecords: installRecords, - nextInstallRecords, - nextConfig, - ...(snapshot.hash !== undefined ? { baseHash: snapshot.hash } : {}), - writeOptions: { - allowConfigSizeDrop: true, - auditOrigin: "plugin-install", - afterWrite: { mode: "restart", reason: "plugin source changed" }, - }, - }), - { command: "uninstall" }, - ); - const directoryResult = await applyPluginUninstallDirectoryRemoval(plan.directoryRemoval); - for (const warning of directoryResult.warnings) { - runtime.log(theme.warn(warning)); + const uninstall = async () => { + const nextInstallRecords = removePluginInstallRecordFromRecords(installRecords, pluginId); + await tracePluginLifecyclePhaseAsync( + "config mutation", + () => + commitPluginInstallRecordsWithConfig({ + previousInstallRecords: installRecords, + nextInstallRecords, + nextConfig, + ...(snapshot.hash !== undefined ? { baseHash: snapshot.hash } : {}), + writeOptions: { + allowConfigSizeDrop: true, + auditOrigin: "plugin-install", + afterWrite: { mode: "restart", reason: "plugin source changed" }, + }, + }), + { command: "uninstall" }, + ); + const directoryResult = await applyPluginUninstallDirectoryRemoval(plan.directoryRemoval); + for (const warning of directoryResult.warnings) { + runtime.log(theme.warn(warning)); + } + await refreshPluginRegistryAfterConfigMutation({ + config: nextConfig, + reason: "source-changed", + installRecords: nextInstallRecords, + invalidateRuntimeCache: opts.invalidateRuntimeCache, + traceCommand: "uninstall", + logger: { + warn: (message) => runtime.log(theme.warn(message)), + }, + }); + + const removed = formatUninstallActionLabels({ + ...plan.actions, + directory: directoryResult.directoryRemoved, + }); + + runtime.log( + `Uninstalled plugin "${pluginId}". Removed: ${removed.length > 0 ? removed.join(", ") : "nothing"}.`, + ); + runtime.log("Restart the gateway to apply changes."); + }; + const installRecord = cfg.plugins?.installs?.[pluginId]; + const clawhubPackage = + installRecord?.source === "clawhub" + ? (installRecord.clawhubPackage ?? parseClawHubPluginSpec(installRecord.spec ?? "")?.name) + : undefined; + if (opts.clawManaged || !clawhubPackage) { + return await uninstall(); } - await refreshPluginRegistryAfterConfigMutation({ - config: nextConfig, - reason: "source-changed", - installRecords: nextInstallRecords, - invalidateRuntimeCache: opts.invalidateRuntimeCache, - traceCommand: "uninstall", - logger: { - warn: (message) => runtime.log(theme.warn(message)), - }, - }); - - const removed = formatUninstallActionLabels({ - ...plan.actions, - directory: directoryResult.directoryRemoved, - }); - - runtime.log( - `Uninstalled plugin "${pluginId}". Removed: ${removed.length > 0 ? removed.join(", ") : "nothing"}.`, + await withClawPackageLifecycleLease( + { kind: "plugin", source: "clawhub", ref: clawhubPackage }, + uninstall, + { required: true }, ); - runtime.log("Restart the gateway to apply changes."); } diff --git a/src/gateway/server-methods/agents-config-mutations.ts b/src/gateway/server-methods/agents-config-mutations.ts index 87d00d29efbb..7ca6e49aff30 100644 --- a/src/gateway/server-methods/agents-config-mutations.ts +++ b/src/gateway/server-methods/agents-config-mutations.ts @@ -9,6 +9,7 @@ import { } from "../../commands/agents.config.js"; import { mutateConfigFileWithRetry } from "../../config/config.js"; import { resolveSessionTranscriptsDirForAgent } from "../../config/sessions.js"; +import type { AgentConfig } from "../../config/types.agents.js"; import type { IdentityConfig } from "../../config/types.base.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; @@ -54,21 +55,38 @@ export async function updateAgentConfigEntry(params: { } /** Removes an agent entry and returns filesystem roots the caller should clean up. */ -export async function deleteAgentConfigEntry(params: { agentId: string }): Promise<{ +export async function deleteAgentConfigEntry(params: { + agentId: string; + validate?: (agent: AgentConfig) => void; + validateConfig?: (config: OpenClawConfig) => void; + allowMissing?: boolean; + fallbackWorkspace?: string; +}): Promise<{ nextConfig: OpenClawConfig; result: AgentDeleteMutationResult | undefined; }> { - const committed = await mutateConfigFileWithRetry({ + const committed = await mutateConfigFileWithRetry({ afterWrite: { mode: "auto" }, mutate: (draft) => { - if (!isConfiguredAgent(draft, params.agentId)) { + params.validateConfig?.(draft); + const configured = isConfiguredAgent(draft, params.agentId); + if (!configured && !params.allowMissing) { throw new AgentConfigPreconditionError(`agent "${params.agentId}" not found`); } - const workspaceDir = resolveAgentWorkspaceDir(draft, params.agentId); + const agent = listAgentEntries(draft).find((candidate) => candidate.id === params.agentId); + if (agent) { + params.validate?.(agent); + } + const workspaceDir = agent + ? resolveAgentWorkspaceDir(draft, params.agentId) + : (params.fallbackWorkspace ?? ""); const agentDir = resolveAgentDir(draft, params.agentId); const sessionsDir = resolveSessionTranscriptsDirForAgent(params.agentId); const result = pruneAgentConfig(draft, params.agentId); Object.assign(draft, result.config); + if (!agent) { + return undefined; + } return { workspaceDir, agentDir, diff --git a/src/gateway/server-methods/agents-mutate.test.ts b/src/gateway/server-methods/agents-mutate.test.ts index 2a7204de82bc..48c200f2fa39 100644 --- a/src/gateway/server-methods/agents-mutate.test.ts +++ b/src/gateway/server-methods/agents-mutate.test.ts @@ -362,6 +362,7 @@ beforeEach(() => { mocks.cronRemoveAgentJobsTransactional .mockReset() .mockImplementation(async (_agentId: string, commit: () => Promise) => await commit()); + mocks.loadConfigReturn = {}; mocks.listAgentEntries.mockImplementation((cfg: unknown) => getAgentList(cfg)); mocks.findAgentEntryIndex.mockImplementation((list: unknown, agentId?: string) => (Array.isArray(list) ? (list as MockAgentEntry[]) : []).findIndex( @@ -1252,7 +1253,9 @@ describe("agents.delete", () => { isSymbolicLink: () => false, } as unknown as import("node:fs").Stats); mocks.fsRealpath.mockImplementation(async (pathname: string) => pathname); - mocks.loadConfigReturn = {}; + mocks.loadConfigReturn = { + agents: { list: [{ id: "test-agent", workspace: "/workspace/test-agent" }] }, + }; mocks.findAgentEntryIndex.mockReturnValue(0); mocks.pruneAgentConfig.mockReturnValue({ config: {}, removedBindings: 2 }); mocks.movePathToTrash.mockReset().mockResolvedValue("/trashed"); diff --git a/src/plugins/plugin-install-preflight.test.ts b/src/plugins/plugin-install-preflight.test.ts index 92039ecb38f2..81fad6130811 100644 --- a/src/plugins/plugin-install-preflight.test.ts +++ b/src/plugins/plugin-install-preflight.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it, vi } from "vitest"; -import { preflightPluginInstall } from "./plugin-install-preflight.js"; +import { + preflightPluginInstall, + resolveInstalledClawHubPlugin, +} from "./plugin-install-preflight.js"; describe("preflightPluginInstall", () => { it("reuses an exact installed version", async () => { @@ -8,10 +11,20 @@ describe("preflightPluginInstall", () => { rawSpec: "clawhub:@acme/audit@1.2.3", expectedVersion: "1.2.3", loadInstallRecords: vi.fn().mockResolvedValue({ - audit: { source: "clawhub", clawhubPackage: "@acme/audit", resolvedVersion: "1.2.3" }, + audit: { + source: "clawhub", + clawhubPackage: "@acme/audit", + resolvedVersion: "1.2.3", + installedAt: "2026-07-17T00:00:00.000Z", + }, }), }); - expect(result).toMatchObject({ ok: true, action: "reuse", installedVersion: "1.2.3" }); + expect(result).toMatchObject({ + ok: true, + action: "reuse", + installedVersion: "1.2.3", + installedAt: "2026-07-17T00:00:00.000Z", + }); }); it("rejects a different installed version", async () => { @@ -30,3 +43,36 @@ describe("preflightPluginInstall", () => { }); }); }); + +describe("resolveInstalledClawHubPlugin", () => { + it("returns the runtime plugin id for one ClawHub package", async () => { + await expect( + resolveInstalledClawHubPlugin({ + clawhubPackage: "@acme/audit", + loadInstallRecords: vi.fn().mockResolvedValue({ + "audit-runtime": { + source: "clawhub", + clawhubPackage: "@acme/audit", + resolvedVersion: "1.2.3", + }, + }), + }), + ).resolves.toMatchObject({ + status: "found", + pluginId: "audit-runtime", + installedVersion: "1.2.3", + }); + }); + + it("reports ambiguous package identities instead of choosing one", async () => { + await expect( + resolveInstalledClawHubPlugin({ + clawhubPackage: "audit", + loadInstallRecords: vi.fn().mockResolvedValue({ + first: { source: "clawhub", clawhubPackage: "audit", version: "1.0.0" }, + second: { source: "clawhub", clawhubPackage: "audit", version: "1.0.0" }, + }), + }), + ).resolves.toEqual({ status: "ambiguous", pluginIds: ["first", "second"] }); + }); +}); diff --git a/src/skills/lifecycle/clawhub-uninstall.test.ts b/src/skills/lifecycle/clawhub-uninstall.test.ts new file mode 100644 index 000000000000..fa3edcf8261e --- /dev/null +++ b/src/skills/lifecycle/clawhub-uninstall.test.ts @@ -0,0 +1,126 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js"; +import { applyClawHubSkillUninstall, planClawHubSkillUninstall } from "./clawhub-uninstall.js"; +import { digestClawHubSkillTree } from "./skill-tree-digest.js"; + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +async function fixture() { + const workspaceDir = tempDirs.make("openclaw-skill-uninstall-"); + const slug = "triage"; + const skillDir = join(workspaceDir, "skills", slug); + const content = "---\nname: triage\n---\n"; + const sha256 = createHash("sha256").update(content).digest("hex"); + const installedAt = 123; + const registry = "https://clawhub.ai"; + await mkdir(join(skillDir, ".clawhub"), { recursive: true }); + await mkdir(join(workspaceDir, ".clawhub"), { recursive: true }); + await writeFile(join(skillDir, "SKILL.md"), content); + const fileTreeSha256 = await digestClawHubSkillTree(skillDir); + await writeFile( + join(skillDir, ".clawhub", "origin.json"), + JSON.stringify({ + version: 1, + registry, + slug, + installedVersion: "1.0.0", + installedAt, + skillFile: { path: "SKILL.md", sha256 }, + fileTreeSha256, + }), + ); + await writeFile( + join(workspaceDir, ".clawhub", "lock.json"), + JSON.stringify({ + version: 1, + skills: { + [slug]: { + version: "1.0.0", + registry, + installedAt, + skillFile: { path: "SKILL.md", sha256 }, + fileTreeSha256, + }, + }, + }), + ); + return { workspaceDir, slug, skillDir }; +} + +describe("ClawHub skill uninstall lifecycle", () => { + it("plans and removes an unchanged tracked skill", async () => { + const current = await fixture(); + const planned = await planClawHubSkillUninstall({ + workspaceDir: current.workspaceDir, + slug: current.slug, + expectedVersion: "1.0.0", + }); + expect(planned).toMatchObject({ ok: true, plan: { slug: "triage", version: "1.0.0" } }); + if (!planned.ok) { + throw new Error(planned.error); + } + await expect(applyClawHubSkillUninstall(planned.plan)).resolves.toEqual({ ok: true }); + await expect(readFile(join(current.skillDir, "SKILL.md"), "utf8")).rejects.toThrow(); + const lock = JSON.parse( + await readFile(join(current.workspaceDir, ".clawhub", "lock.json"), "utf8"), + ); + expect(lock.skills).toEqual({}); + }); + + it("retains a locally modified skill", async () => { + const current = await fixture(); + await writeFile(join(current.skillDir, "SKILL.md"), "operator edit\n"); + await expect( + planClawHubSkillUninstall({ + workspaceDir: current.workspaceDir, + slug: current.slug, + expectedVersion: "1.0.0", + }), + ).resolves.toMatchObject({ ok: false, code: "modified" }); + }); + + it("retains a skill with modified auxiliary files", async () => { + const current = await fixture(); + await writeFile(join(current.skillDir, "script.js"), "operator addition\n"); + await expect( + planClawHubSkillUninstall({ + workspaceDir: current.workspaceDir, + slug: current.slug, + expectedVersion: "1.0.0", + }), + ).resolves.toMatchObject({ ok: false, code: "modified" }); + }); + + it("restores the staged skill when lockfile untracking fails", async () => { + const current = await fixture(); + const planned = await planClawHubSkillUninstall({ + workspaceDir: current.workspaceDir, + slug: current.slug, + expectedVersion: "1.0.0", + }); + if (!planned.ok) { + throw new Error(planned.error); + } + + await expect( + applyClawHubSkillUninstall(planned.plan, { + untrack: async () => { + throw new Error("lockfile write failed"); + }, + }), + ).resolves.toMatchObject({ + ok: false, + error: expect.stringContaining("lockfile write failed"), + }); + await expect(readFile(join(current.skillDir, "SKILL.md"), "utf8")).resolves.toContain( + "name: triage", + ); + const lock = JSON.parse( + await readFile(join(current.workspaceDir, ".clawhub", "lock.json"), "utf8"), + ); + expect(lock.skills.triage).toBeDefined(); + }); +}); diff --git a/src/skills/lifecycle/clawhub-uninstall.ts b/src/skills/lifecycle/clawhub-uninstall.ts new file mode 100644 index 000000000000..c209ec703c9d --- /dev/null +++ b/src/skills/lifecycle/clawhub-uninstall.ts @@ -0,0 +1,173 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { sha256Hex } from "../../infra/crypto-digest.js"; +import { normalizeTrackedSkillSlug, resolveWorkspaceSkillInstallDir } from "./archive-install.js"; +import { resolveClawHubSkillStatusLinkSync, untrackClawHubSkill } from "./clawhub.js"; +import { digestClawHubSkillTree } from "./skill-tree-digest.js"; + +export type ClawHubSkillUninstallPlan = { + workspaceDir: string; + slug: string; + version: string; + installedAt: number; + targetDir: string; + skillFilePath: string; + skillFileSha256: string; + fileTreeSha256: string; +}; + +type ClawHubSkillUninstallPlanResult = + | { ok: true; plan: ClawHubSkillUninstallPlan } + | { + ok: false; + code: "missing" | "ambiguous" | "modified"; + error: string; + }; + +export async function planClawHubSkillUninstall(params: { + workspaceDir: string; + slug: string; + expectedVersion: string; +}): Promise { + let slug: string; + try { + slug = normalizeTrackedSkillSlug(params.slug); + } catch (error) { + return { ok: false, code: "ambiguous", error: String(error) }; + } + const targetDir = resolveWorkspaceSkillInstallDir(params.workspaceDir, slug); + const link = resolveClawHubSkillStatusLinkSync({ + workspaceDir: params.workspaceDir, + skillDir: targetDir, + skillKey: slug, + }); + if (!link) { + return { + ok: false, + code: "missing", + error: `Skill ${JSON.stringify(slug)} is not a tracked ClawHub install.`, + }; + } + if (!link.valid || !link.skillFile || !link.fileTreeSha256) { + return { + ok: false, + code: "ambiguous", + error: link.valid + ? `Skill ${JSON.stringify(slug)} has no complete installed-file digest.` + : link.reason, + }; + } + if (link.installedVersion !== params.expectedVersion) { + return { + ok: false, + code: "modified", + error: `Skill ${JSON.stringify(slug)} is at ${link.installedVersion}, expected ${params.expectedVersion}.`, + }; + } + const skillFilePath = path.join(targetDir, link.skillFile.path); + let content: Buffer; + try { + const stat = await fs.lstat(targetDir); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + return { + ok: false, + code: "ambiguous", + error: `Skill ${JSON.stringify(slug)} is not a regular managed directory.`, + }; + } + content = await fs.readFile(skillFilePath); + } catch (error) { + return { ok: false, code: "missing", error: String(error) }; + } + if (sha256Hex(content) !== link.skillFile.sha256) { + return { + ok: false, + code: "modified", + error: `Skill ${JSON.stringify(slug)} has local SKILL.md changes.`, + }; + } + let fileTreeSha256: string; + try { + fileTreeSha256 = await digestClawHubSkillTree(targetDir); + } catch (error) { + return { ok: false, code: "ambiguous", error: String(error) }; + } + if (fileTreeSha256 !== link.fileTreeSha256) { + return { + ok: false, + code: "modified", + error: `Skill ${JSON.stringify(slug)} has local file changes.`, + }; + } + return { + ok: true, + plan: { + workspaceDir: params.workspaceDir, + slug, + version: link.installedVersion, + installedAt: link.installedAt, + targetDir, + skillFilePath: link.skillFile.path, + skillFileSha256: link.skillFile.sha256, + fileTreeSha256, + }, + }; +} + +export async function applyClawHubSkillUninstall( + plan: ClawHubSkillUninstallPlan, + deps: { + readFile?: typeof fs.readFile; + removeDir?: typeof fs.rm; + rename?: typeof fs.rename; + untrack?: typeof untrackClawHubSkill; + } = {}, +): Promise<{ ok: true } | { ok: false; error: string }> { + const current = await planClawHubSkillUninstall({ + workspaceDir: plan.workspaceDir, + slug: plan.slug, + expectedVersion: plan.version, + }); + if (!current.ok) { + return { ok: false, error: current.error }; + } + const stagedDir = `${plan.targetDir}.openclaw-skill-remove-${randomUUID()}`; + let staged = false; + let restoreTracking: (() => Promise) | undefined; + const rename = deps.rename ?? fs.rename; + try { + await rename(plan.targetDir, stagedDir); + staged = true; + const content = await (deps.readFile ?? fs.readFile)(path.join(stagedDir, plan.skillFilePath)); + if (sha256Hex(content) !== plan.skillFileSha256) { + await rename(stagedDir, plan.targetDir); + return { ok: false, error: `Skill ${JSON.stringify(plan.slug)} changed during removal.` }; + } + if ((await digestClawHubSkillTree(stagedDir)) !== plan.fileTreeSha256) { + await rename(stagedDir, plan.targetDir); + return { ok: false, error: `Skill ${JSON.stringify(plan.slug)} changed during removal.` }; + } + restoreTracking = await (deps.untrack ?? untrackClawHubSkill)(plan.workspaceDir, plan.slug); + await (deps.removeDir ?? fs.rm)(stagedDir, { recursive: true, force: false }); + return { ok: true }; + } catch (error) { + const rollbackErrors: string[] = []; + try { + await restoreTracking?.(); + } catch (rollbackError) { + rollbackErrors.push(`could not restore lockfile: ${String(rollbackError)}`); + } + if (staged) { + try { + await rename(stagedDir, plan.targetDir); + } catch (rollbackError) { + rollbackErrors.push(`could not restore skill directory: ${String(rollbackError)}`); + } + } + return { + ok: false, + error: `${String(error)}${rollbackErrors.length > 0 ? `; rollback incomplete: ${rollbackErrors.join("; ")}` : ""}`, + }; + } +} diff --git a/src/skills/lifecycle/clawhub.test.ts b/src/skills/lifecycle/clawhub.test.ts index a35936abdfab..c37ab546ace9 100644 --- a/src/skills/lifecycle/clawhub.test.ts +++ b/src/skills/lifecycle/clawhub.test.ts @@ -23,6 +23,7 @@ const withExtractedArchiveRootMock = vi.fn(); const installPackageDirMock = vi.fn(); const evaluateSkillInstallPolicyMock = vi.fn(); const pathExistsMock = vi.fn(); +const digestClawHubSkillTreeMock = vi.fn(async () => `sha256:${"a".repeat(64)}`); const tempDirs = createTrackedTempDirs(); vi.mock("../../infra/clawhub.js", async (importOriginal) => ({ @@ -60,6 +61,10 @@ vi.mock("../../infra/fs-safe.js", () => ({ pathExists: pathExistsMock, })); +vi.mock("./skill-tree-digest.js", () => ({ + digestClawHubSkillTree: digestClawHubSkillTreeMock, +})); + const { installSkillFromClawHub, preflightSkillFromClawHub, diff --git a/src/skills/lifecycle/clawhub.ts b/src/skills/lifecycle/clawhub.ts index e11b99b537a8..1d4641c08f9c 100644 --- a/src/skills/lifecycle/clawhub.ts +++ b/src/skills/lifecycle/clawhub.ts @@ -31,6 +31,8 @@ import { formatErrorMessage } from "../../infra/errors.js"; import { pathExists } from "../../infra/fs-safe.js"; import { withExtractedArchiveRoot } from "../../infra/install-flow.js"; import { readJsonIfExists, tryReadJson, writeJson } from "../../infra/json-files.js"; +import { markClawPackageIndependentlyOwned } from "../../state/claw-package-adoption.js"; +import { withClawPackageLifecycleLease } from "../../state/claw-package-lifecycle-lease.js"; import { CLAWHUB_SKILL_ARCHIVE_ROOT_MARKERS, installExtractedSkillRoot, @@ -38,6 +40,7 @@ import { resolveWorkspaceSkillInstallDir, validateRequestedSkillSlug, } from "./archive-install.js"; +import { digestClawHubSkillTree } from "./skill-tree-digest.js"; const DOT_DIR = ".clawhub"; const LEGACY_DOT_DIR = ".clawdhub"; @@ -76,6 +79,7 @@ type ClawHubSkillLockEntry = { sourceUrl?: string; artifact?: ClawHubSkillDownloadedArtifactLock; skillFile?: ClawHubSkillFileLock; + fileTreeSha256?: string; verification?: ClawHubSkillVerificationLock; }; @@ -89,6 +93,7 @@ type ClawHubSkillOrigin = { sourceUrl?: string; artifact?: ClawHubSkillDownloadedArtifactLock; skillFile?: ClawHubSkillFileLock; + fileTreeSha256?: string; }; type ClawHubSkillsLockfile = { @@ -115,6 +120,7 @@ export type ClawHubSkillStatusLink = sourceUrl?: string; artifact?: ClawHubSkillDownloadedArtifactLock; skillFile?: ClawHubSkillFileLock; + fileTreeSha256?: string; } | { status: "invalid"; @@ -244,6 +250,7 @@ type ClawHubInstallParams = { onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise; logger?: Logger; config?: OpenClawConfig; + clawManaged?: boolean; }; function normalizeExpectedArtifactIntegrity(expectedIntegrity: string): string; @@ -664,6 +671,9 @@ function normalizeClawHubSkillOrigin( } const artifact = normalizeDownloadedArtifactLock((raw as { artifact?: unknown }).artifact); const skillFile = normalizeSkillFileLock((raw as { skillFile?: unknown }).skillFile); + const fileTreeSha256 = normalizeOptionalStringValue( + (raw as { fileTreeSha256?: unknown }).fileTreeSha256, + ); return { version: 1, registry: normalizeStoredRegistry(raw.registry), @@ -674,6 +684,7 @@ function normalizeClawHubSkillOrigin( ...(sourceUrl ? { sourceUrl } : {}), ...(artifact ? { artifact } : {}), ...(skillFile ? { skillFile } : {}), + ...(fileTreeSha256 ? { fileTreeSha256 } : {}), }; } return null; @@ -889,6 +900,7 @@ export function resolveClawHubSkillStatusLinkSync(params: { const lockedOwnerHandle = normalizeOptionalStringValue(locked.ownerHandle); const lockedArtifact = normalizeDownloadedArtifactLock(locked.artifact); const lockedSkillFile = normalizeSkillFileLock(locked.skillFile); + const lockedFileTreeSha256 = normalizeOptionalStringValue(locked.fileTreeSha256); const provenanceMatches = originRead.origin.ownerHandle === lockedOwnerHandle && originRead.origin.sourceUrl === lockedSourceUrl && @@ -896,7 +908,8 @@ export function resolveClawHubSkillStatusLinkSync(params: { originRead.origin.artifact?.sha256 === lockedArtifact?.sha256 && originRead.origin.artifact?.integrity === lockedArtifact?.integrity && originRead.origin.skillFile?.path === lockedSkillFile?.path && - originRead.origin.skillFile?.sha256 === lockedSkillFile?.sha256; + originRead.origin.skillFile?.sha256 === lockedSkillFile?.sha256 && + originRead.origin.fileTreeSha256 === lockedFileTreeSha256; // A linked status is a trust signal. Only expose provenance when both // install records agree, so a one-sided origin edit cannot become trusted. if ( @@ -930,6 +943,7 @@ export function resolveClawHubSkillStatusLinkSync(params: { ...(lockedSourceUrl ? { sourceUrl: lockedSourceUrl } : {}), ...(lockedArtifact ? { artifact: lockedArtifact } : {}), ...(lockedSkillFile ? { skillFile: lockedSkillFile } : {}), + ...(lockedFileTreeSha256 ? { fileTreeSha256: lockedFileTreeSha256 } : {}), }; } @@ -1501,6 +1515,7 @@ async function performClawHubSkillInstall( const installedAt = Date.now(); const artifact = buildDownloadedArtifactLock(archive); + const fileTreeSha256 = await digestClawHubSkillTree(install.targetDir); const verificationVersion = latestResolution?.installKind === "github" && !params.version ? undefined : version; const [skillFile, verification] = await Promise.all([ @@ -1526,6 +1541,7 @@ async function performClawHubSkillInstall( ...(sourceUrl ? { sourceUrl } : {}), artifact, ...(skillFile ? { skillFile } : {}), + fileTreeSha256, }); const lock = await readClawHubSkillsLockfile(params.workspaceDir); lock.skills[params.slug] = { @@ -1536,9 +1552,19 @@ async function performClawHubSkillInstall( ...(sourceUrl ? { sourceUrl } : {}), artifact, ...(skillFile ? { skillFile } : {}), + fileTreeSha256, ...(verification ? { verification } : {}), }; await writeClawHubSkillsLockfile(params.workspaceDir, lock); + if (!params.clawManaged) { + markClawPackageIndependentlyOwned({ + kind: "skill", + source: "clawhub", + ref: params.slug, + version, + workspace: params.workspaceDir, + }); + } await reportClawHubSkillInstallTelemetry({ baseUrl: params.baseUrl, slug: params.slug, @@ -1766,7 +1792,18 @@ export async function installSkillFromClawHub(params: { /** True when a Claw lifecycle caller already owns package coordination. */ clawManaged?: boolean; }): Promise { - return await installRequestedSkillFromClawHub(params); + if (params.clawManaged) { + return await installRequestedSkillFromClawHub(params); + } + return await withClawPackageLifecycleLease( + { + kind: "skill", + source: "clawhub", + ref: params.slug, + workspace: params.workspaceDir, + }, + () => installRequestedSkillFromClawHub(params), + ); } export async function updateSkillsFromClawHub(params: { @@ -1804,18 +1841,28 @@ export async function updateSkillsFromClawHub(params: { }); continue; } - const install = await installTrackedSkillFromClawHub({ - workspaceDir: params.workspaceDir, - slug: tracked.slug, - ...(tracked.ownerHandle ? { ownerHandle: tracked.ownerHandle } : {}), - baseUrl: tracked.baseUrl, - force: true, - forceInstall: params.forceInstall, - acknowledgeClawHubRisk: params.acknowledgeClawHubRisk, - onClawHubRisk: params.onClawHubRisk, - logger: params.logger, - config: params.config, - }); + const install = await withClawPackageLifecycleLease( + { + kind: "skill", + source: "clawhub", + ref: tracked.slug, + workspace: params.workspaceDir, + }, + () => + installTrackedSkillFromClawHub({ + workspaceDir: params.workspaceDir, + slug: tracked.slug, + ...(tracked.ownerHandle ? { ownerHandle: tracked.ownerHandle } : {}), + baseUrl: tracked.baseUrl, + force: true, + forceInstall: params.forceInstall, + acknowledgeClawHubRisk: params.acknowledgeClawHubRisk, + onClawHubRisk: params.onClawHubRisk, + logger: params.logger, + config: params.config, + }), + { required: true }, + ); if (!install.ok) { results.push(install); continue; @@ -1838,13 +1885,25 @@ export async function readTrackedClawHubSkillSlugs(workspaceDir: string): Promis return Object.keys(lock.skills).toSorted(); } -export async function untrackClawHubSkill(workspaceDir: string, slug: string): Promise { +export async function untrackClawHubSkill( + workspaceDir: string, + slug: string, +): Promise<() => Promise> { const trackedSlug = normalizeTrackedSkillSlug(slug); const lock = await readClawHubSkillsLockfile(workspaceDir); - if (!lock.skills[trackedSlug]) { - return; + const previous = lock.skills[trackedSlug]; + if (!previous) { + return async () => undefined; } delete lock.skills[trackedSlug]; await writeClawHubSkillsLockfile(workspaceDir, lock); + return async () => { + const current = await readClawHubSkillsLockfile(workspaceDir); + if (current.skills[trackedSlug]) { + throw new Error(`Skill ${JSON.stringify(trackedSlug)} was retracked during rollback.`); + } + current.skills[trackedSlug] = previous; + await writeClawHubSkillsLockfile(workspaceDir, current); + }; } /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/skills/lifecycle/skill-tree-digest.ts b/src/skills/lifecycle/skill-tree-digest.ts new file mode 100644 index 000000000000..c68c51fe4b75 --- /dev/null +++ b/src/skills/lifecycle/skill-tree-digest.ts @@ -0,0 +1,51 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; + +const EXCLUDED_METADATA_DIRS = new Set([".clawhub", ".clawdhub"]); + +type SkillTreeEntry = { + path: string; + sha256?: string; + type: "directory" | "file"; +}; + +async function collectEntries(root: string, relativeDir = ""): Promise { + const absoluteDir = path.join(root, relativeDir); + const entries = await fs.readdir(absoluteDir, { withFileTypes: true }); + const collected: SkillTreeEntry[] = []; + for (const entry of entries.toSorted((left, right) => + left.name < right.name ? -1 : left.name > right.name ? 1 : 0, + )) { + if (!relativeDir && EXCLUDED_METADATA_DIRS.has(entry.name)) { + continue; + } + const relativePath = path.join(relativeDir, entry.name); + const portablePath = relativePath.split(path.sep).join("/"); + const stat = await fs.lstat(path.join(root, relativePath)); + if (stat.isSymbolicLink() || (!stat.isDirectory() && !stat.isFile())) { + throw new Error(`Skill tree contains unsupported entry ${JSON.stringify(portablePath)}.`); + } + if (stat.isDirectory()) { + collected.push({ path: portablePath, type: "directory" }); + collected.push(...(await collectEntries(root, relativePath))); + continue; + } + if (stat.nlink > 1) { + throw new Error(`Skill tree contains hard-linked file ${JSON.stringify(portablePath)}.`); + } + const content = await fs.readFile(path.join(root, relativePath)); + collected.push({ + path: portablePath, + type: "file", + sha256: createHash("sha256").update(content).digest("hex"), + }); + } + return collected; +} + +/** Digests every installed skill file except OpenClaw's own provenance metadata. */ +export async function digestClawHubSkillTree(skillDir: string): Promise { + const entries = await collectEntries(skillDir); + return `sha256:${createHash("sha256").update(JSON.stringify(entries)).digest("hex")}`; +} diff --git a/src/state/claw-package-adoption.test.ts b/src/state/claw-package-adoption.test.ts new file mode 100644 index 000000000000..aa2242da1994 --- /dev/null +++ b/src/state/claw-package-adoption.test.ts @@ -0,0 +1,251 @@ +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; +import { applyClawPackageRemovals, planClawPackageRemovals } from "../claws/package-remove.js"; +import { + persistClawInstallRecord, + persistClawPackageRef, + readClawPackageRefs, +} from "../claws/provenance.js"; +import type { ClawAddPlan } from "../claws/types.js"; +import { markClawPackageIndependentlyOwned } from "./claw-package-adoption.js"; +import { acquireClawPackageLifecycleLease } from "./claw-package-lifecycle-lease.js"; +import { closeOpenClawStateDatabaseForTest } from "./openclaw-state-db.js"; + +afterEach(() => closeOpenClawStateDatabaseForTest()); +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +const packageIntegrity = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + +function plan(agentId: string, workspace: string): ClawAddPlan { + return { + schemaVersion: "openclaw.clawAddPlan.v1", + manifestSchemaVersion: 1, + stability: "experimental", + dryRun: true, + mutationAllowed: false, + planIntegrity: `sha256:${agentId}`, + claw: { + kind: "package", + name: `@acme/${agentId}`, + version: "1.0.0", + packageRoot: "/tmp/claw", + manifestPath: "/tmp/claw/CLAW.md", + integrityKind: "artifact", + integrity: "sha256:claw", + byteLength: 100, + }, + agent: { + requestedId: agentId, + finalId: agentId, + workspace, + config: { id: agentId, workspace }, + }, + summary: { + totalActions: 0, + agentActions: 0, + workspaceActions: 0, + packageActions: 0, + mcpServerActions: 0, + cronJobActions: 0, + blockedActions: 0, + capabilityEscalations: 0, + }, + actions: [], + capabilityChanges: [], + readiness: { ready: true, requirements: [] }, + blockers: [], + diagnostics: [], + }; +} + +describe("Claw package independent adoption", () => { + it("does not fail an ordinary install when Claw state is unavailable", () => { + const path = join(tempDirs.make("claw-adoption-invalid-"), "state.sqlite"); + writeFileSync(path, "not sqlite"); + + expect( + markClawPackageIndependentlyOwned( + { + kind: "plugin", + source: "clawhub", + ref: "@acme/audit", + version: "1.0.0", + }, + { path }, + ), + ).toBe(0); + }); + + it("marks every shared plugin reference independently owned", () => { + const env = { OPENCLAW_STATE_DIR: tempDirs.make("claw-adoption-") }; + for (const agentId of ["first", "second"]) { + const current = plan(agentId, `/tmp/${agentId}`); + persistClawInstallRecord(current, { env }); + persistClawPackageRef( + current, + { + kind: "plugin", + source: "clawhub", + ref: "@acme/audit", + version: "1.0.0", + integrity: packageIntegrity, + }, + { + env, + relationship: "referenced", + origin: "claw-introduced", + independentOwner: false, + }, + ); + } + + expect( + markClawPackageIndependentlyOwned( + { + kind: "plugin", + source: "clawhub", + ref: "@acme/audit", + version: "1.0.0", + }, + { env, nowMs: 42 }, + ), + ).toBe(2); + expect(readClawPackageRefs({ env })).toMatchObject([ + { origin: "claw-introduced", independentOwner: true, updatedAtMs: 42 }, + { origin: "claw-introduced", independentOwner: true, updatedAtMs: 42 }, + ]); + }); + + it("scopes skill adoption to the owning agent workspace", () => { + const env = { OPENCLAW_STATE_DIR: tempDirs.make("claw-adoption-") }; + for (const agentId of ["first", "second"]) { + const current = plan(agentId, `/tmp/${agentId}`); + persistClawInstallRecord(current, { env }); + persistClawPackageRef( + current, + { + kind: "skill", + source: "clawhub", + ref: "triage", + version: "1.0.0", + integrity: packageIntegrity, + }, + { + env, + relationship: "managed", + origin: "claw-introduced", + independentOwner: false, + }, + ); + } + + expect( + markClawPackageIndependentlyOwned( + { + kind: "skill", + source: "clawhub", + ref: "triage", + version: "1.0.0", + workspace: "/tmp/first", + }, + { env }, + ), + ).toBe(1); + expect(readClawPackageRefs({ env, agentId: "first" })).toMatchObject([ + { origin: "claw-introduced", independentOwner: true }, + ]); + expect(readClawPackageRefs({ env, agentId: "second" })).toMatchObject([ + { origin: "claw-introduced", independentOwner: false }, + ]); + }); + + it("retains global plugins and releases their Claw references", async () => { + const env = { OPENCLAW_STATE_DIR: tempDirs.make("claw-adoption-race-") }; + const current = plan("worker", "/tmp/worker"); + const install = persistClawInstallRecord(current, { env }); + const ref = persistClawPackageRef( + current, + { + kind: "plugin", + source: "clawhub", + ref: "@acme/audit", + version: "1.0.0", + integrity: packageIntegrity, + }, + { + env, + relationship: "referenced", + origin: "claw-introduced", + independentOwner: false, + }, + ); + const decisions = await planClawPackageRemovals(install, [ref], { env }); + + const results = await applyClawPackageRemovals(decisions, { env }); + + expect(results).toMatchObject([{ action: "retained" }]); + const directLease = acquireClawPackageLifecycleLease( + { kind: "plugin", source: "clawhub", ref: "@acme/audit" }, + { env, required: true }, + ); + expect(directLease).not.toBeNull(); + directLease?.release(); + }); + + it("serializes all skill mutations that share a workspace lockfile", () => { + const env = { OPENCLAW_STATE_DIR: tempDirs.make("claw-skill-lease-") }; + const first = acquireClawPackageLifecycleLease( + { kind: "skill", source: "clawhub", ref: "triage", workspace: "/tmp/worker" }, + { env, required: true }, + ); + expect(() => + acquireClawPackageLifecycleLease( + { kind: "skill", source: "clawhub", ref: "summarize", workspace: "/tmp/worker" }, + { env, required: true }, + ), + ).toThrow("being changed by another OpenClaw lifecycle"); + const otherWorkspace = acquireClawPackageLifecycleLease( + { kind: "skill", source: "clawhub", ref: "triage", workspace: "/tmp/other" }, + { env, required: true }, + ); + expect(otherWorkspace).not.toBeNull(); + otherWorkspace?.release(); + first?.release(); + }); + + it("leases a direct operation before the first Claw package reference exists", () => { + const env = { OPENCLAW_STATE_DIR: tempDirs.make("claw-first-lease-") }; + const directLease = acquireClawPackageLifecycleLease( + { kind: "plugin", source: "clawhub", ref: "@acme/audit" }, + { env }, + ); + expect(directLease).not.toBeNull(); + expect(() => + acquireClawPackageLifecycleLease( + { kind: "plugin", source: "clawhub", ref: "@acme/audit" }, + { env }, + ), + ).toThrow("being changed by another OpenClaw lifecycle"); + expect(() => + acquireClawPackageLifecycleLease( + { kind: "plugin", source: "clawhub", ref: "@acme/audit" }, + { env, required: true }, + ), + ).toThrow("being changed by another OpenClaw lifecycle"); + directLease?.release(); + }); + + it("fails open only for optional direct leases when lifecycle state is unavailable", () => { + const invalidDatabasePath = tempDirs.make("claw-invalid-db-path-"); + const artifact = { kind: "plugin", source: "clawhub", ref: "@acme/audit" } as const; + expect(acquireClawPackageLifecycleLease(artifact, { path: invalidDatabasePath })).toBeNull(); + expect(() => + acquireClawPackageLifecycleLease(artifact, { + path: invalidDatabasePath, + required: true, + }), + ).toThrow(); + }); +});