mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
Add plan-first Claw status and remove (#102296)
* Add plan-first Claw status and remove * test(claws): cover status and removal * docs(claws): document status and removal * fix(claws): report incomplete root state * test(claws): complete adoption plan fixtures * fix(claws): revalidate package removal ownership * style(plugins): format lifecycle uninstall * fix(claws): align agent removal preconditions --------- Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
This commit is contained in:
+62
-5
@@ -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 <SHA256_FROM_DRY_RUN>
|
||||
```
|
||||
|
||||
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 <source>` | Validate a package directory or JSON manifest. |
|
||||
| `claws add <source>` | Preview or create one new agent and workspace. |
|
||||
| Command | Purpose |
|
||||
| ------------------------------ | --------------------------------------------------- |
|
||||
| `claws inspect <source>` | Validate a package directory or JSON manifest. |
|
||||
| `claws add <source>` | Preview or create one new agent and workspace. |
|
||||
| `claws status [claw-or-agent]` | Report installed state, ownership, and drift. |
|
||||
| `claws remove <claw-or-agent>` | Preview or remove the agent and eligible resources. |
|
||||
|
||||
Use `--json` for experimental machine-readable output.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<void>;
|
||||
|
||||
export function digestClawAgentConfig(
|
||||
agent: NonNullable<NonNullable<OpenClawConfig["agents"]>["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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<boolean> {
|
||||
const tracked = new Set(trackedPaths.map((entry) => path.normalize(entry)));
|
||||
const trackedDirectories = new Set<string>();
|
||||
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<boolean> => {
|
||||
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<string[]> {
|
||||
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}`);
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
};
|
||||
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<ClawManagedFileStatus> {
|
||||
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<ClawStatusResult> {
|
||||
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<string>();
|
||||
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<ClawRemovePlan> {
|
||||
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<RemovedWorkspaceFile> {
|
||||
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<void>;
|
||||
export async function applyClawRemovePlan(
|
||||
plan: ClawRemovePlan,
|
||||
options: OpenClawStateDatabaseOptions & {
|
||||
config?: OpenClawConfig;
|
||||
commitConfig?: ConfigCommit;
|
||||
packageDeps?: PackageRemovalDeps;
|
||||
referencedCleanup?: ClawReferencedCleanup;
|
||||
purgeSessions?: PurgeSessions;
|
||||
trashPath?: ClawTrashPath;
|
||||
consentPlanIntegrity?: string;
|
||||
} = {},
|
||||
): Promise<ClawRemoveResult> {
|
||||
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("; "),
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
};
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -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> = {}): 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" }]);
|
||||
});
|
||||
});
|
||||
@@ -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<PersistedClawPackageRef["status"]>;
|
||||
}): 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<PersistedClawPackageRef["status"]>;
|
||||
}): 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<ClawPackageInspection> {
|
||||
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<ClawPackageRemovalDecision[]> {
|
||||
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<ClawPackageRemovalResult[]> {
|
||||
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;
|
||||
}
|
||||
+181
-127
@@ -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" });
|
||||
|
||||
@@ -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<string, string> = {};
|
||||
for (const [column, value] of [
|
||||
["agent_id", options.agentId],
|
||||
["package_kind", options.kind],
|
||||
["package_source", options.source],
|
||||
["package_ref", options.ref],
|
||||
|
||||
@@ -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 } = {},
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+175
-2
@@ -27,6 +27,9 @@ const mocks = vi.hoisted(() => {
|
||||
runtime,
|
||||
loadConfig: vi.fn<() => Record<string, unknown>>(() => ({})),
|
||||
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<typeof import("../claws/lifecycle-state.js")>(
|
||||
"../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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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("<claw-or-agent>", "Installed package name or final agent id")
|
||||
.option("--dry-run", "Preview removal without mutating state", false)
|
||||
.option("--yes", "Confirm removal", false)
|
||||
.option("--plan-integrity <digest>", "Bind consent to an exact removal plan")
|
||||
.option(
|
||||
"--remove-unused",
|
||||
"Remove unchanged Claw-introduced references with no other current owner",
|
||||
false,
|
||||
)
|
||||
.option(
|
||||
"--remove-referenced <kind:ref@version>",
|
||||
"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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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<AgentDeleteMutationResult>({
|
||||
const committed = await mutateConfigFileWithRetry<AgentDeleteMutationResult | undefined>({
|
||||
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,
|
||||
|
||||
@@ -362,6 +362,7 @@ beforeEach(() => {
|
||||
mocks.cronRemoveAgentJobsTransactional
|
||||
.mockReset()
|
||||
.mockImplementation(async (_agentId: string, commit: () => Promise<unknown>) => 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");
|
||||
|
||||
@@ -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"] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<ClawHubSkillUninstallPlanResult> {
|
||||
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<void>) | 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("; ")}` : ""}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<boolean>;
|
||||
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<InstallClawHubSkillResult> {
|
||||
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<void> {
|
||||
export async function untrackClawHubSkill(
|
||||
workspaceDir: string,
|
||||
slug: string,
|
||||
): Promise<() => Promise<void>> {
|
||||
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. */
|
||||
|
||||
@@ -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<SkillTreeEntry[]> {
|
||||
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<string> {
|
||||
const entries = await collectEntries(skillDir);
|
||||
return `sha256:${createHash("sha256").update(JSON.stringify(entries)).digest("hex")}`;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user