From ba467fbd3efa9ab109e620c4e42cfe92388171c5 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Tue, 21 Jul 2026 18:55:10 -0700 Subject: [PATCH] Install ClawHub packages for new Claw agents (#102228) * Install ClawHub packages for new Claw agents * test(claws): cover skill and plugin installation * docs(claws): document package artifacts * fix(claws): bind package trust preflight * fix(claws): bind package capability consent * fix(claws): satisfy package type guards * test(claws): update package installer fixtures * test(claws): complete package plan fixtures * test(claws): model complete ClawHub results * fix(claws): serialize plugin ownership changes * fix(claws): cover legacy plugin lifecycle updates * fix(claws): preserve legacy ClawHub update selectors * test(claws): type lifecycle lease mock * refactor(claws): isolate plugin update lifecycle * fix(claws): declare managed installer coordination * test(claws): preserve partial collision result * test(claws): clean installer temp dirs --------- Co-authored-by: Patrick Erichsen --- docs/cli/claws.md | 34 +- src/claws/add.ts | 132 +++- .../fixtures/incident-response.claw.json | 28 +- src/claws/fixtures/minimal-agent.claw.json | 5 +- src/claws/fixtures/workspace-agent.claw.json | 8 +- src/claws/lifecycle.e2e.test.ts | 81 ++- src/claws/lifecycle.ts | 59 +- src/claws/packages.test.ts | 540 +++++++++++++++++ src/claws/packages.ts | 566 ++++++++++++++++++ src/claws/provenance.test.ts | 119 +++- src/claws/provenance.ts | 265 +++++++- src/claws/schema-conformance.test.ts | 18 +- src/claws/schema.test.ts | 96 ++- src/claws/types.ts | 4 +- src/cli/claws-cli.runtime.ts | 7 +- src/cli/claws-cli.test.ts | 15 +- src/cli/plugins-cli.uninstall.test.ts | 78 ++- src/cli/plugins-install-command.ts | 94 ++- src/cli/plugins-uninstall-command.ts | 11 + src/plugins/clawhub.test.ts | 1 + src/plugins/clawhub.ts | 2 + src/plugins/management-service.test.ts | 13 +- src/plugins/management-service.ts | 10 +- src/plugins/plugin-install-preflight.test.ts | 32 + src/plugins/plugin-install-preflight.ts | 116 ++++ src/plugins/uninstall-claw-references.test.ts | 54 ++ src/plugins/uninstall-claw-references.ts | 60 ++ src/plugins/update-claw-lifecycle.ts | 96 +++ src/plugins/update-installed.ts | 94 ++- src/plugins/update-source.ts | 11 +- src/plugins/update.test.ts | 82 ++- src/skills/lifecycle/clawhub.test.ts | 53 +- src/skills/lifecycle/clawhub.ts | 163 +++++ src/state/claw-package-adoption.ts | 66 ++ src/state/claw-package-lifecycle-lease.ts | 219 +++++++ .../openclaw-state-db-schema-additive.ts | 8 + src/state/openclaw-state-db.generated.d.ts | 18 + src/state/openclaw-state-db.test.ts | 44 ++ src/state/openclaw-state-schema.generated.ts | 18 + src/state/openclaw-state-schema.sql | 18 + 40 files changed, 3139 insertions(+), 199 deletions(-) create mode 100644 src/claws/packages.test.ts create mode 100644 src/claws/packages.ts create mode 100644 src/plugins/plugin-install-preflight.test.ts create mode 100644 src/plugins/plugin-install-preflight.ts create mode 100644 src/plugins/uninstall-claw-references.test.ts create mode 100644 src/plugins/uninstall-claw-references.ts create mode 100644 src/plugins/update-claw-lifecycle.ts create mode 100644 src/state/claw-package-adoption.ts create mode 100644 src/state/claw-package-lifecycle-lease.ts diff --git a/docs/cli/claws.md b/docs/cli/claws.md index fe4ea0a9463d..b4e311e4587a 100644 --- a/docs/cli/claws.md +++ b/docs/cli/claws.md @@ -67,6 +67,34 @@ sources and workspace-relative targets: } ``` +Skills and plugins use exact ClawHub versions: + +```json +{ + "packages": [ + { + "kind": "skill", + "source": "clawhub", + "ref": "incident-triage", + "version": "1.0.0" + }, + { + "kind": "plugin", + "source": "clawhub", + "ref": "@acme/audit-plugin", + "version": "2.0.0" + } + ] +} +``` + +The dry run uses the existing skill and plugin preflight paths to resolve the +exact artifact, integrity, and any ClawHub trust warning before consent. The +warning remains visible in the integrity-bound plan. Apply installs missing artifacts +or reuses matching ones and records whether the Claw introduced or referenced +each resource. Plugins remain process-wide OpenClaw capabilities rather than +per-agent installations. + ## Inspect and preview Validate the source without planning local changes: @@ -98,9 +126,9 @@ when the source, destination, or live configuration changed after preview. Use defaults collide with local state. Adding a Claw creates the new agent and workspace configuration, writes declared -workspace files, and records installation and per-file provenance. Existing -files are not overwritten, and retries fail closed when owned content drifted. -Later Claws stages add other declared resources. +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. ## Command reference diff --git a/src/claws/add.ts b/src/claws/add.ts index 5e5bef539db8..998b70227b51 100644 --- a/src/claws/add.ts +++ b/src/claws/add.ts @@ -1,4 +1,4 @@ -// Applies the agent, workspace, and managed-file slice of a consented Claw add plan. +// Applies the package, agent, workspace, and managed-file slices of a consented Claw add plan. import { lstat, mkdir, rmdir } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { findOverlappingWorkspaceAgentIds } from "../agents/agent-delete-safety.js"; @@ -8,14 +8,17 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolvePathViaExistingAncestorSync } from "../infra/boundary-path.js"; import { normalizeWindowsPathForComparison } from "../infra/path-guards.js"; import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js"; +import type { RuntimeEnv } from "../runtime.js"; import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js"; import { resolveUserPath } from "../utils.js"; +import { ClawPackageInstallError, installClawPackages } from "./packages.js"; import { deleteClawInstallRecord, persistClawInstallRecord, updateClawInstallRecordStatus, type ClawInstallStatus, type PersistedClawInstall, + type PersistedClawPackageRef, } from "./provenance.js"; import { CLAW_OUTPUT_STABILITY, type ClawAddPlan } from "./types.js"; import { @@ -34,6 +37,8 @@ type ClawAddApplyOptions = OpenClawStateDatabaseOptions & { deleteRecord?: typeof deleteClawInstallRecord; updateRecord?: typeof updateClawInstallRecordStatus; createWorkspaceFiles?: typeof createClawWorkspaceFiles; + runtime?: RuntimeEnv; + installPackages?: typeof installClawPackages; nowMs?: number; }; type AgentConfig = NonNullable["list"]>[number]; @@ -60,6 +65,7 @@ type ClawAddResult = { workspaceCreated: boolean; configCommitted: boolean; workspaceFiles: PersistedClawWorkspaceFile[]; + packages: PersistedClawPackageRef[]; installRecord?: PersistedClawInstall; error?: { code: string; @@ -70,7 +76,7 @@ type ClawAddResult = { function hasUnsupportedMutationActions(plan: ClawAddPlan): boolean { return plan.actions.some( - (action) => !["agent", "workspace", "workspaceFile"].includes(action.kind), + (action) => !["agent", "workspace", "workspaceFile", "package"].includes(action.kind), ); } @@ -126,6 +132,39 @@ function assertWorkspacePathUnchanged(workspace: string): void { } } +function partialResult(params: { + plan: ClawAddPlan; + installRecord: PersistedClawInstall; + workspaceCreated: boolean; + configCommitted: boolean; + workspaceFiles?: PersistedClawWorkspaceFile[]; + packages?: PersistedClawPackageRef[]; + installStatus?: ClawInstallStatus; + error: ClawAddResult["error"]; + nowMs?: number; +}): ClawAddResult { + return { + schemaVersion: CLAW_ADD_RESULT_SCHEMA_VERSION, + stability: CLAW_OUTPUT_STABILITY, + dryRun: false, + mutationAllowed: true, + planIntegrity: params.plan.planIntegrity, + status: "partial", + claw: params.plan.claw, + agent: params.plan.agent, + workspaceCreated: params.workspaceCreated, + configCommitted: params.configCommitted, + workspaceFiles: params.workspaceFiles ?? [], + packages: params.packages ?? [], + installRecord: { + ...params.installRecord, + status: params.installStatus ?? "partial", + updatedAtMs: params.nowMs ?? Date.now(), + }, + error: params.error, + }; +} + export async function applyClawAddPlan( plan: ClawAddPlan, options: ClawAddApplyOptions = {}, @@ -136,7 +175,7 @@ export async function applyClawAddPlan( if (hasUnsupportedMutationActions(plan)) { throw new ClawAddMutationError( "unsupported_components", - "This build can add agent settings and workspace files; declared packages, MCP servers, or cron jobs require later lifecycle slices.", + "This build can add agent settings, workspace files, and declared packages; MCP servers and cron jobs require later lifecycle slices.", ); } if (options.consentPlanIntegrity !== plan.planIntegrity) { @@ -154,6 +193,9 @@ export async function applyClawAddPlan( throw new ClawAddMutationError("provenance_failed", (error as Error).message); } + const installPackages = options.installPackages ?? installClawPackages; + let packages: PersistedClawPackageRef[] = []; + const workspace = resolve(resolveUserPath(plan.agent.workspace)); const workspacePhaseRecorded = statusAtLeast(installRecord.status, "workspace_ready"); const workspaceState = workspacePhaseRecorded @@ -198,11 +240,19 @@ export async function applyClawAddPlan( await mkdir(workspace); workspaceCreated = true; } catch (error) { - clearUnownedInstallRecord(plan.agent.finalId, ["pending", "partial"], options); - throw new ClawAddMutationError( - "workspace_collision", - `Could not create new workspace ${JSON.stringify(workspace)}: ${(error as Error).message}`, - ); + markInstallStatus(plan.agent.finalId, "partial", ["pending", "partial"], options); + return partialResult({ + plan, + installRecord, + workspaceCreated: false, + configCommitted: false, + packages, + error: { + code: "workspace_collision", + message: `Could not create new workspace ${JSON.stringify(workspace)}: ${(error as Error).message}`, + }, + nowMs: options.nowMs, + }); } try { @@ -257,7 +307,7 @@ export async function applyClawAddPlan( } throw new ClawAddMutationError( "agent_id_collision", - `Agent ${JSON.stringify(plan.agent.finalId)} was created after planning.`, + "Agent " + JSON.stringify(plan.agent.finalId) + " was created after planning.", ); } if ( @@ -266,7 +316,7 @@ export async function applyClawAddPlan( ) { throw new ClawAddMutationError( "workspace_collision", - `Workspace ${JSON.stringify(workspace)} is already assigned to an agent.`, + "Workspace " + JSON.stringify(workspace) + " is already assigned to an agent.", ); } const nextConfig: OpenClawConfig = { @@ -286,15 +336,30 @@ export async function applyClawAddPlan( options, ); } catch (error) { + let installStatus: ClawInstallStatus = "workspace_ready"; if (!configCommitted) { const removedWorkspace = await rmdir(workspace) .then(() => true) .catch(() => false); if (removedWorkspace) { - clearUnownedInstallRecord(plan.agent.finalId, ["workspace_ready", "partial"], options); + workspaceCreated = false; + installStatus = "partial"; + markInstallStatus(plan.agent.finalId, "partial", ["workspace_ready", "partial"], options); } } - throw error; + return partialResult({ + plan, + installRecord, + workspaceCreated, + configCommitted, + packages, + installStatus, + error: { + code: error instanceof ClawAddMutationError ? error.code : "config_commit_failed", + message: error instanceof Error ? error.message : String(error), + }, + nowMs: options.nowMs, + }); } const createFiles = options.createWorkspaceFiles ?? createClawWorkspaceFiles; @@ -330,6 +395,7 @@ export async function applyClawAddPlan( workspaceCreated, configCommitted, workspaceFiles: workspaceError.createdFiles, + packages, installRecord: { ...installRecord, status: "config_committed", @@ -343,6 +409,32 @@ export async function applyClawAddPlan( }; } + try { + // Package mutation is last: skills now have their workspace, and a package + // failure cannot leave a later workspace/configuration step unapplied. + packages = await installPackages(plan, options); + } catch (error) { + const packageError = + error instanceof ClawPackageInstallError + ? error + : new ClawPackageInstallError( + "package_install_failed", + error instanceof Error ? error.message : String(error), + packages, + ); + return partialResult({ + plan, + installRecord, + workspaceCreated, + configCommitted, + workspaceFiles, + packages: packageError.installedPackages, + installStatus: "config_committed", + error: { code: packageError.code, message: packageError.message }, + nowMs: options.nowMs, + }); + } + try { markInstallStatus(plan.agent.finalId, "complete", ["config_committed", "complete"], options); return { @@ -356,6 +448,7 @@ export async function applyClawAddPlan( agent: plan.agent, workspaceCreated, configCommitted, + packages, workspaceFiles, installRecord: { ...installRecord, @@ -364,19 +457,14 @@ export async function applyClawAddPlan( }, }; } catch (error) { - return { - schemaVersion: CLAW_ADD_RESULT_SCHEMA_VERSION, - stability: CLAW_OUTPUT_STABILITY, - dryRun: false, - mutationAllowed: true, - planIntegrity: plan.planIntegrity, - status: "partial", - claw: plan.claw, - agent: plan.agent, + return partialResult({ + plan, + installRecord, workspaceCreated, configCommitted, workspaceFiles, + packages, error: { code: "provenance_failed", message: (error as Error).message }, - }; + }); } } diff --git a/src/claws/fixtures/incident-response.claw.json b/src/claws/fixtures/incident-response.claw.json index 4cc72abdc53c..60504ae00632 100644 --- a/src/claws/fixtures/incident-response.claw.json +++ b/src/claws/fixtures/incident-response.claw.json @@ -9,7 +9,9 @@ "emoji": "siren" }, "groupChat": { - "mentionPatterns": ["@incident-response"] + "mentionPatterns": [ + "@incident-response" + ] }, "sandbox": { "mode": "all", @@ -17,8 +19,15 @@ "workspaceAccess": "rw" }, "tools": { - "allow": ["read", "write", "web_fetch"], - "deny": ["exec", "browser"] + "allow": [ + "read", + "write", + "web_fetch" + ], + "deny": [ + "exec", + "browser" + ] }, "heartbeat": { "every": "30m", @@ -30,8 +39,12 @@ }, "workspace": { "bootstrapFiles": { - "SOUL.md": { "source": "workspace/SOUL.md" }, - "HEARTBEAT.md": { "source": "workspace/HEARTBEAT.md" } + "SOUL.md": { + "source": "workspace/SOUL.md" + }, + "HEARTBEAT.md": { + "source": "workspace/HEARTBEAT.md" + } } }, "packages": [ @@ -51,7 +64,10 @@ "mcpServers": { "statuspage": { "command": "npx", - "args": ["--yes", "@acme/statuspage-mcp@1.0.0"], + "args": [ + "--yes", + "@acme/statuspage-mcp@1.0.0" + ], "env": { "STATUSPAGE_TOKEN": "${STATUSPAGE_TOKEN}" } diff --git a/src/claws/fixtures/minimal-agent.claw.json b/src/claws/fixtures/minimal-agent.claw.json index e2af4869edab..6b0e60a7b7f1 100644 --- a/src/claws/fixtures/minimal-agent.claw.json +++ b/src/claws/fixtures/minimal-agent.claw.json @@ -8,7 +8,10 @@ "name": "Triage" }, "tools": { - "deny": ["exec", "browser"] + "deny": [ + "exec", + "browser" + ] }, "humanDelay": { "mode": "natural" diff --git a/src/claws/fixtures/workspace-agent.claw.json b/src/claws/fixtures/workspace-agent.claw.json index 109a84c02fdd..c1ef7ca91e2f 100644 --- a/src/claws/fixtures/workspace-agent.claw.json +++ b/src/claws/fixtures/workspace-agent.claw.json @@ -9,8 +9,12 @@ }, "workspace": { "bootstrapFiles": { - "SOUL.md": { "source": "workspace/SOUL.md" }, - "HEARTBEAT.md": { "source": "workspace/HEARTBEAT.md" } + "SOUL.md": { + "source": "workspace/SOUL.md" + }, + "HEARTBEAT.md": { + "source": "workspace/HEARTBEAT.md" + } }, "files": [ { diff --git a/src/claws/lifecycle.e2e.test.ts b/src/claws/lifecycle.e2e.test.ts index 5ef69f425acf..9932a69f27de 100644 --- a/src/claws/lifecycle.e2e.test.ts +++ b/src/claws/lifecycle.e2e.test.ts @@ -1,6 +1,6 @@ // E2E coverage for experimental grouped Claw inspection and add planning. import { execFile } from "node:child_process"; -import { readFile } from "node:fs/promises"; +import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { promisify } from "node:util"; import { afterEach, describe, expect, it } from "vitest"; @@ -79,10 +79,14 @@ describe("claws lifecycle cli e2e", () => { }); }); - it("builds a complete read-only plan with deferred package blockers", async () => { - const result = await runOpenClaw(["claws", "add", manifestPath, "--dry-run", "--json"], { - expectFailure: true, - }); + it("builds a complete package-free read-only plan without network access", async () => { + const result = await runOpenClaw([ + "claws", + "add", + "src/claws/fixtures/workspace-agent.claw.json", + "--dry-run", + "--json", + ]); const add = parseJson(result.stdout); expect(add).toMatchObject({ @@ -90,22 +94,19 @@ describe("claws lifecycle cli e2e", () => { stability: "experimental", dryRun: true, mutationAllowed: false, - agent: { requestedId: "incident-response", finalId: "incident-response" }, + agent: { requestedId: "workspace-agent", finalId: "workspace-agent" }, summary: { - totalActions: 8, + totalActions: 5, agentActions: 1, - workspaceActions: 3, - packageActions: 2, - mcpServerActions: 1, - cronJobActions: 1, - blockedActions: 2, + workspaceActions: 4, + packageActions: 0, + mcpServerActions: 0, + cronJobActions: 0, + blockedActions: 0, }, - blockers: [ - { code: "package_install_unavailable", phase: "plan" }, - { code: "package_install_unavailable", phase: "plan" }, - ], + blockers: [], }); - expect(result.code).toBe(1); + expect(result.ok).toBe(true); }); it("preserves implicit main and creates exactly one agent after explicit consent", async () => { @@ -197,12 +198,43 @@ describe("claws lifecycle cli e2e", () => { }); it("blocks mutation when declared components need later lifecycle slices", async () => { - const preview = await runOpenClaw(["claws", "add", manifestPath, "--dry-run", "--json"], { - expectFailure: true, - }); + const root = tempDirs.make("openclaw-claws-deferred-components-"); + const deferredManifestPath = join(root, "deferred.claw.json"); + await writeFile( + deferredManifestPath, + JSON.stringify({ + schemaVersion: 1, + agent: { id: "deferred-components" }, + mcpServers: { status: { command: "status-mcp" } }, + cronJobs: [ + { + id: "status-check", + schedule: { cron: "0 * * * *", timezone: "UTC" }, + session: "isolated", + message: "Check status", + }, + ], + }), + "utf8", + ); + const preview = await runOpenClaw([ + "claws", + "add", + deferredManifestPath, + "--dry-run", + "--json", + ]); const plan = parseJson(preview.stdout) as { planIntegrity: string }; const result = await runOpenClaw( - ["claws", "add", manifestPath, "--yes", "--plan-integrity", plan.planIntegrity, "--json"], + [ + "claws", + "add", + deferredManifestPath, + "--yes", + "--plan-integrity", + plan.planIntegrity, + "--json", + ], { expectFailure: true, stateDir: preview.stateDir, @@ -211,10 +243,9 @@ describe("claws lifecycle cli e2e", () => { expect(result.code).toBe(1); expect(parseJson(result.stdout)).toMatchObject({ - schemaVersion: "openclaw.clawAddPlan.v1", - blockers: expect.arrayContaining([ - expect.objectContaining({ code: "package_install_unavailable" }), - ]), + schemaVersion: "openclaw.clawAddResult.v1", + status: "failed", + error: { code: "unsupported_components" }, }); }); diff --git a/src/claws/lifecycle.ts b/src/claws/lifecycle.ts index d61a114ddb0f..add3c703c19f 100644 --- a/src/claws/lifecycle.ts +++ b/src/claws/lifecycle.ts @@ -19,6 +19,7 @@ import { type ClawDiagnostic, type ClawManifest, type ClawLocalPrerequisite, + type ClawPackage, type ClawSourceIdentity, } from "./types.js"; @@ -43,6 +44,19 @@ type ClawAddPlanContext = { existingWorkspacePaths?: Iterable; existingMcpServerNames?: Iterable; existingCronJobIds?: Iterable; + packagePreflight?: ( + pkg: ClawPackage, + workspace: string, + ) => Promise<{ + ok: boolean; + action?: "install" | "reuse"; + integrity?: string; + installId?: string; + warning?: string; + installedVersion?: string; + code?: string; + message?: string; + }>; }; function canonicalWorkspacePath(value: string): string { @@ -369,20 +383,43 @@ export async function buildClawAddPlan(params: { } for (const pkg of params.manifest.packages) { - const diagnostic = blocker( - "package_install_unavailable", - "$.packages", - `Package ${JSON.stringify(`${pkg.kind}:${pkg.ref}@${pkg.version}`)} cannot be preflighted until the package-owner lifecycle slice is available.`, - ); - blockers.push(diagnostic); + const preflight = context.packagePreflight + ? await context.packagePreflight(pkg, workspace) + : { + ok: false, + code: "package_install_unavailable", + message: "Package preflight is unavailable.", + }; + const diagnostic = preflight.ok + ? undefined + : blocker( + preflight.code ?? "package_install_unavailable", + "$.packages", + preflight.message ?? "Package preflight failed.", + ); + if (diagnostic) { + blockers.push(diagnostic); + } actions.push({ kind: "package", id: `${pkg.kind}:${pkg.ref}`, action: "install", target: `${pkg.source}:${pkg.ref}@${pkg.version}`, - details: { ...pkg, expectedState: "unresolved" }, - blocked: true, - reason: diagnostic.message, + digest: preflight.integrity, + details: { + ...pkg, + ...(preflight.integrity ? { integrity: preflight.integrity } : {}), + ...(preflight.installId ? { installId: preflight.installId } : {}), + ...(preflight.warning ? { riskWarning: preflight.warning } : {}), + expectedState: !preflight.ok + ? "unresolved" + : preflight.action === "reuse" + ? "present-exact" + : "absent", + ownerAction: preflight.action, + }, + blocked: !preflight.ok, + ...(diagnostic ? { reason: diagnostic.message } : {}), }); capabilityChanges.push( capabilityChange({ @@ -396,7 +433,9 @@ export async function buildClawAddPlan(params: { source: pkg.source, ref: pkg.ref, version: pkg.version, - integrity: "unresolved", + integrity: preflight.integrity ?? "unresolved", + ...(preflight.installId ? { installId: preflight.installId } : {}), + ...(preflight.warning ? { riskWarning: preflight.warning } : {}), }, }), ); diff --git a/src/claws/packages.test.ts b/src/claws/packages.test.ts new file mode 100644 index 000000000000..1acf993a5056 --- /dev/null +++ b/src/claws/packages.test.ts @@ -0,0 +1,540 @@ +import { describe, expect, it, vi } from "vitest"; +import { installClawPackages } from "./packages.js"; +import type { PersistedClawPackageRef } from "./provenance.js"; +import type { ClawAddPlan, ResolvedClawPackage } from "./types.js"; + +function plan( + packages: ResolvedClawPackage[], + ownerAction: "install" | "reuse" = "install", +): ClawAddPlan { + return { + schemaVersion: "openclaw.clawAddPlan.v1", + manifestSchemaVersion: 1, + stability: "experimental", + dryRun: true, + mutationAllowed: false, + planIntegrity: "sha256:plan", + claw: { + kind: "package", + name: "incident-claw", + version: "1.0.0", + packageRoot: "/tmp/claw", + manifestPath: "/tmp/claw/claw.json", + integrityKind: "artifact", + integrity: "sha256:claw", + byteLength: 123, + }, + agent: { + requestedId: "incident", + finalId: "incident-2", + workspace: "/tmp/incident-2", + config: { id: "incident-2", workspace: "/tmp/incident-2" }, + }, + summary: { + totalActions: packages.length, + agentActions: 0, + workspaceActions: 0, + packageActions: packages.length, + mcpServerActions: 0, + cronJobActions: 0, + blockedActions: 0, + capabilityEscalations: 0, + }, + capabilityChanges: [], + actions: packages.map((pkg) => ({ + kind: "package", + id: `${pkg.kind}:${pkg.ref}`, + action: "install", + target: `${pkg.source}:${pkg.ref}@${pkg.version}`, + details: { + ...pkg, + ownerAction, + ...(pkg.kind === "plugin" ? { installId: pkg.ref.split("/").at(-1) } : {}), + }, + blocked: false, + })), + readiness: { ready: true, requirements: [] }, + blockers: [], + diagnostics: [], + }; +} + +const completePackageRef = vi.fn( + (ref: PersistedClawPackageRef, status: PersistedClawPackageRef["status"]) => ({ + ...ref, + status, + }), +); + +const pluginIntegrity = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +function pluginPackageRef( + ref: string, + overrides: Partial = {}, +): PersistedClawPackageRef { + return { + schemaVersion: "openclaw.clawPackageRef.v1", + agentId: "incident-2", + clawName: "incident-claw", + kind: "plugin", + source: "clawhub", + ref, + version: "1.0.0", + integrity: pluginIntegrity, + status: "complete", + relationship: "referenced", + origin: "claw-introduced", + independentOwner: false, + installedAtMs: 1_000, + updatedAtMs: 2_000, + ...overrides, + }; +} + +const probePlugin = vi.fn(async ({ spec }: { spec: string }) => { + const pluginId = spec.slice(spec.lastIndexOf("/") + 1).split("@")[0]!; + const packageName = spec.replace(/^clawhub:/, "").replace(/@[^@]+$/, ""); + return { + ok: true as const, + pluginId, + packageName, + targetDir: "/tmp/plugin", + extensions: [], + clawhub: { + source: "clawhub" as const, + clawhubUrl: "https://clawhub.ai", + clawhubPackage: packageName, + clawhubFamily: "code-plugin" as const, + integrity: pluginIntegrity, + }, + }; +}); + +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 installSkill = vi.fn().mockResolvedValue({ + ok: true, + slug: "triage", + version: "1.2.3", + targetDir: "/tmp/incident-2/skills/triage", + }); + const persistPackageRef = vi.fn().mockReturnValue(pending); + const onExternalMutation = vi.fn(); + + await installClawPackages( + plan([ + { + kind: "skill", + source: "clawhub", + ref: "@owner/triage", + version: "1.2.3", + integrity, + }, + ]), + { + deps: { + installSkill, + preflightSkill: vi.fn().mockResolvedValue({ ok: true, action: "install", integrity }), + persistPackageRef, + completePackageRef, + }, + onExternalMutation, + }, + ); + + expect(installSkill).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceDir: "/tmp/incident-2", + slug: "@owner/triage", + version: "1.2.3", + expectedIntegrity: integrity, + }), + ); + expect(persistPackageRef).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ integrity }), + expect.objectContaining({ + status: "pending", + relationship: "managed", + origin: "claw-introduced", + independentOwner: false, + }), + ); + expect(onExternalMutation).toHaveBeenCalledWith( + expect.objectContaining({ kind: "skill", ref: "@owner/triage" }), + ); + }); + + it("installs plugins through the shared plugin surface", async () => { + const installPlugin = vi.fn().mockResolvedValue(undefined); + const persistPackageRef = vi.fn().mockReturnValue({ + kind: "plugin", + ref: "@owner/audit", + status: "pending", + integrity: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }); + 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, + }, + }, + ); + + expect(installPlugin).toHaveBeenCalledWith( + expect.objectContaining({ + raw: "clawhub:@owner/audit@2.0.1", + opts: { + acknowledgeClawHubRisk: true, + expectedIntegrity: + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + expectedPluginId: "audit", + }, + invalidateRuntimeCache: false, + }), + ); + expect(persistPackageRef).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + integrity: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }), + expect.objectContaining({ + status: "pending", + relationship: "referenced", + origin: "claw-introduced", + independentOwner: false, + }), + ); + }); + + it("records a dependency ref without reinstalling an exact reused plugin", async () => { + const installPlugin = vi.fn(); + const persistPackageRef = vi.fn().mockReturnValue({ kind: "plugin" }); + const preflightPlugin = vi.fn().mockResolvedValue({ + ok: true, + action: "reuse", + installedId: "audit", + 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, + }, + }, + ); + + expect(installPlugin).not.toHaveBeenCalled(); + expect(persistPackageRef).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + integrity: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }), + expect.objectContaining({ + status: "complete", + 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", + } 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, + }, + }, + ), + ).rejects.toMatchObject({ + code: "package_install_failed", + message: "registry unavailable", + installedPackages: [expect.objectContaining({ ref: "@owner/audit", status: "failed" })], + }); + }); + + it("removes a newly installed plugin when a later package fails", async () => { + const integrity = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const installPlugin = vi + .fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error("second install failed")); + const uninstallPlugin = vi.fn().mockResolvedValue(undefined); + const refs = [ + pluginPackageRef("@owner/first", { status: "pending" }), + pluginPackageRef("@owner/second", { status: "pending" }), + ]; + const persistPackageRef = vi.fn().mockReturnValueOnce(refs[0]).mockReturnValueOnce(refs[1]); + const readPackageRefs = vi + .fn() + .mockReturnValueOnce([]) + .mockReturnValueOnce([pluginPackageRef("@owner/first")]); + + 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 }, + ]), + { + deps: { + installPlugin, + uninstallPlugin, + probePlugin, + preflightPlugin: vi.fn().mockResolvedValue({ ok: true, action: "install" }), + persistPackageRef, + completePackageRef, + readPackageRefs, + resolvePlugin: vi.fn().mockResolvedValue({ + status: "found", + pluginId: "first", + installedVersion: "1.0.0", + record: { + source: "clawhub", + integrity, + installedAt: new Date(1_500).toISOString(), + }, + }), + }, + }, + ), + ).rejects.toMatchObject({ code: "package_install_failed", message: "second install failed" }); + + expect(uninstallPlugin).toHaveBeenCalledWith( + "first", + { force: true, invalidateRuntimeCache: false, clawManaged: true }, + expect.anything(), + ); + expect(completePackageRef).toHaveBeenCalledWith( + expect.objectContaining({ ref: "@owner/first" }), + "rolled_back", + expect.anything(), + ); + }); + + it("keeps a newly installed plugin when a direct owner claims it before rollback", async () => { + const installPlugin = vi + .fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error("second install failed")); + const uninstallPlugin = vi.fn().mockResolvedValue(undefined); + const refs = [ + pluginPackageRef("@owner/first", { status: "pending" }), + pluginPackageRef("@owner/second", { status: "pending" }), + ]; + + await expect( + installClawPackages( + plan([ + { + kind: "plugin", + source: "clawhub", + ref: "@owner/first", + version: "1.0.0", + integrity: pluginIntegrity, + }, + { + kind: "plugin", + source: "clawhub", + ref: "@owner/second", + version: "1.0.0", + integrity: pluginIntegrity, + }, + ]), + { + deps: { + installPlugin, + uninstallPlugin, + probePlugin, + preflightPlugin: vi.fn().mockResolvedValue({ ok: true, action: "install" }), + persistPackageRef: vi.fn().mockReturnValueOnce(refs[0]).mockReturnValueOnce(refs[1]), + completePackageRef, + readPackageRefs: vi + .fn() + .mockReturnValueOnce([]) + .mockReturnValueOnce([pluginPackageRef("@owner/first", { independentOwner: true })]), + }, + }, + ), + ).rejects.toMatchObject({ + code: "package_rollback_failed", + message: expect.stringContaining("now has a direct owner"), + }); + + expect(uninstallPlugin).not.toHaveBeenCalled(); + }); + + it("preserves the installer error when failure provenance cannot be updated", async () => { + const pending = { + kind: "plugin", + ref: "@owner/audit", + status: "pending", + integrity: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + } 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, + }, + }, + ), + ).rejects.toMatchObject({ + code: "package_install_failed", + message: "registry unavailable", + installedPackages: [pending], + }); + expect(failingCompletePackageRef).toHaveBeenCalledWith(pending, "failed", expect.anything()); + }); + + it("invalidates consent when plugin owner state changes after planning", async () => { + const installPlugin = vi.fn(); + const persistPackageRef = vi.fn(); + 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, + }, + }, + ), + ).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 planned = plan([ + { kind: "skill", source: "clawhub", ref: "@owner/triage", version: "1.2.3", integrity }, + ]); + Object.assign(planned.actions[0]!.details!, { riskWarning: "review warning one" }); + + await expect( + installClawPackages(planned, { + deps: { + preflightSkill: vi.fn().mockResolvedValue({ + ok: true, + action: "install", + integrity, + warning: "review warning two", + }), + }, + }), + ).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, + }, + ]); + Object.assign(planned.actions[0]!.details!, { riskWarning: "review warning one" }); + + await expect( + installClawPackages(planned, { + deps: { + probePlugin: vi.fn().mockResolvedValue({ + ok: true, + pluginId: "audit", + warning: "review warning two", + clawhub: { integrity: pluginIntegrity }, + }), + }, + }), + ).rejects.toMatchObject({ code: "package_owner_state_changed" }); + }); +}); diff --git a/src/claws/packages.ts b/src/claws/packages.ts new file mode 100644 index 000000000000..d1437cebd821 --- /dev/null +++ b/src/claws/packages.ts @@ -0,0 +1,566 @@ +import { runPluginInstallCommand } from "../cli/plugins-install-command.js"; +import { runPluginUninstallCommand } from "../cli/plugins-uninstall-command.js"; +import { normalizeClawHubSha256Integrity } from "../infra/clawhub.js"; +import { installPluginFromClawHub } from "../plugins/clawhub.js"; +import { + preflightPluginInstall, + resolveInstalledClawHubPlugin, +} from "../plugins/plugin-install-preflight.js"; +import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; +import { installSkillFromClawHub, preflightSkillFromClawHub } from "../skills/lifecycle/clawhub.js"; +import { + acquireClawPackageLifecycleLease, + maintainClawPackageLifecycleLease, + type MaintainedClawPackageLifecycleLease, +} from "../state/claw-package-lifecycle-lease.js"; +import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js"; +import { + persistClawPackageRef, + readClawPackageRefs, + updateClawPackageRefStatus, + type PersistedClawPackageRef, +} from "./provenance.js"; +import type { ClawAddPlan, ClawAddPlanAction, ClawPackage, ResolvedClawPackage } from "./types.js"; + +export class ClawPackageInstallError extends Error { + constructor( + readonly code: string, + message: string, + readonly installedPackages: PersistedClawPackageRef[], + ) { + super(message); + this.name = "ClawPackageInstallError"; + } +} + +type PackageInstallerDeps = { + installPlugin?: typeof runPluginInstallCommand; + uninstallPlugin?: typeof runPluginUninstallCommand; + probePlugin?: typeof installPluginFromClawHub; + installSkill?: typeof installSkillFromClawHub; + preflightPlugin?: typeof preflightPluginInstall; + preflightSkill?: typeof preflightSkillFromClawHub; + persistPackageRef?: typeof persistClawPackageRef; + completePackageRef?: typeof updateClawPackageRefStatus; + readPackageRefs?: typeof readClawPackageRefs; + acquirePackageLease?: typeof acquireClawPackageLifecycleLease; + resolvePlugin?: typeof resolveInstalledClawHubPlugin; +}; + +type PlannedClawPackage = ResolvedClawPackage & { + ownerAction: "install" | "reuse"; + installId?: string; + riskWarning?: string; +}; +function packageFromAction(action: ClawAddPlanAction): PlannedClawPackage { + const details = action.details as + | (Partial & { + ownerAction?: "install" | "reuse"; + installId?: string; + riskWarning?: string; + }) + | undefined; + if (details?.kind !== "skill" && details?.kind !== "plugin") { + throw new Error(`Package action ${JSON.stringify(action.id)} has no valid package kind.`); + } + if ( + details.source !== "clawhub" || + !details.ref || + !details.version || + !details.integrity || + !normalizeClawHubSha256Integrity(details.integrity) + ) { + throw new Error( + `Package action ${JSON.stringify(action.id)} is not a pinned ClawHub package with integrity.`, + ); + } + if (details.ownerAction !== "install" && details.ownerAction !== "reuse") { + throw new Error(`Package action ${JSON.stringify(action.id)} has no planned owner state.`); + } + if (details.kind === "plugin" && !details.installId) { + throw new Error(`Package action ${JSON.stringify(action.id)} has no resolved plugin id.`); + } + return { + kind: details.kind, + source: details.source, + ref: details.ref, + version: details.version, + integrity: details.integrity, + ownerAction: details.ownerAction, + ...(details.installId ? { installId: details.installId } : {}), + ...(details.riskWarning ? { riskWarning: details.riskWarning } : {}), + }; +} + +function installerRuntime(runtime: RuntimeEnv): RuntimeEnv { + return { + log: (value) => runtime.log(value), + error: (value) => runtime.error(value), + exit: (code) => { + throw new Error(`Plugin installer exited with code ${code}.`); + }, + }; +} + +function ownerInstallIsNewerThanRefs( + installedAt: string | undefined, + refs: PersistedClawPackageRef[], +): boolean { + const timestamp = Date.parse(installedAt ?? ""); + return ( + Number.isFinite(timestamp) && + refs.length > 0 && + refs.every((candidate) => timestamp > candidate.updatedAtMs) + ); +} + +type ClawPackagePreflightResult = + | { + ok: true; + action: "install" | "reuse"; + integrity: string; + installId?: string; + warning?: string; + } + | { + ok: false; + code: string; + message: string; + installedVersion?: string; + integrity?: string; + installId?: string; + warning?: string; + }; + +export async function preflightClawPackage( + pkg: ClawPackage, + workspaceDir: string, +): Promise { + if (pkg.kind === "skill") { + const result = await preflightSkillFromClawHub({ + workspaceDir, + slug: pkg.ref, + version: pkg.version, + acknowledgeClawHubRisk: true, + }); + return result.ok ? result : { ok: false, code: result.code, message: result.error }; + } + const result = await preflightPluginInstall({ + clawhubPackage: pkg.ref, + rawSpec: `clawhub:${pkg.ref}@${pkg.version}`, + expectedVersion: pkg.version, + }); + if (!result.ok && result.code !== "plugin_version_conflict") { + return { + ok: false, + code: result.code, + message: result.error, + }; + } + const probe = await installPluginFromClawHub({ + spec: `clawhub:${pkg.ref}@${pkg.version}`, + dryRun: true, + acknowledgeClawHubRisk: true, + }); + if (!probe.ok) { + return { ok: false, code: probe.code ?? "plugin_preflight_failed", message: probe.error }; + } + const integrity = probe.clawhub.integrity + ? normalizeClawHubSha256Integrity(probe.clawhub.integrity) + : null; + if (!integrity) { + return { + ok: false, + code: "plugin_integrity_unavailable", + message: `Plugin ${pkg.ref}@${pkg.version} did not resolve an artifact integrity.`, + }; + } + if (!result.ok) { + return { + ok: false, + code: result.code, + installedVersion: result.installedVersion, + integrity, + installId: probe.pluginId, + ...(probe.warning ? { warning: probe.warning } : {}), + message: `Plugin ${pkg.ref}@${pkg.version} conflicts with installed version ${result.installedVersion}.`, + }; + } + if ( + result.action === "reuse" && + (result.installedId !== probe.pluginId || + !result.installedIntegrity || + normalizeClawHubSha256Integrity(result.installedIntegrity) !== integrity) + ) { + return { + ok: false, + code: "plugin_integrity_conflict", + message: `Plugin ${pkg.ref}@${pkg.version} is installed as ${result.installedId} with integrity ${result.installedIntegrity ?? "unknown"}, expected ${probe.pluginId} with ${integrity}.`, + }; + } + return { + ok: true, + action: result.action, + integrity, + installId: probe.pluginId, + ...(probe.warning ? { warning: probe.warning } : {}), + }; +} + +export async function installClawPackages( + plan: ClawAddPlan, + options: OpenClawStateDatabaseOptions & { + deps?: PackageInstallerDeps; + runtime?: RuntimeEnv; + nowMs?: number; + onExternalMutation?: (pkg: ClawPackage) => void; + } = {}, +): Promise { + const deps = options.deps ?? {}; + const installPlugin = deps.installPlugin ?? runPluginInstallCommand; + const uninstallPlugin = deps.uninstallPlugin ?? runPluginUninstallCommand; + const probePlugin = deps.probePlugin ?? installPluginFromClawHub; + const installSkill = deps.installSkill ?? installSkillFromClawHub; + const preflightPlugin = deps.preflightPlugin ?? preflightPluginInstall; + const preflightSkill = deps.preflightSkill ?? preflightSkillFromClawHub; + const persistPackageRef = deps.persistPackageRef ?? persistClawPackageRef; + const completePackageRef = deps.completePackageRef ?? updateClawPackageRefStatus; + const readPackageRefs = deps.readPackageRefs ?? readClawPackageRefs; + const acquirePackageLease = deps.acquirePackageLease ?? acquireClawPackageLifecycleLease; + const resolvePlugin = deps.resolvePlugin ?? resolveInstalledClawHubPlugin; + const runtime = options.runtime ?? defaultRuntime; + const installedPackages: PersistedClawPackageRef[] = []; + const installedPlugins: Array<{ installId: string; packageIndex: number }> = []; + + for (const action of plan.actions.filter((candidate) => candidate.kind === "package")) { + let packageLease: MaintainedClawPackageLifecycleLease | null = null; + try { + const pkg = packageFromAction(action); + const leaseArtifact = + pkg.kind === "skill" + ? { + kind: pkg.kind, + source: pkg.source, + ref: pkg.ref, + workspace: plan.agent.workspace, + } + : { kind: pkg.kind, source: pkg.source, ref: pkg.ref }; + const acquiredLease = acquirePackageLease(leaseArtifact, { + env: options.env, + path: options.path, + required: true, + }); + if (!acquiredLease) { + throw new Error(`Could not acquire package lifecycle lease for ${pkg.ref}.`); + } + packageLease = maintainClawPackageLifecycleLease(acquiredLease); + if (pkg.kind === "skill") { + const preflight = await preflightSkill({ + workspaceDir: plan.agent.workspace, + slug: pkg.ref, + version: pkg.version, + expectedIntegrity: pkg.integrity, + acknowledgeClawHubRisk: true, + }); + packageLease.assertCurrent(); + if (!preflight.ok) { + throw new Error(preflight.error); + } + if ( + preflight.action !== pkg.ownerAction || + preflight.warning !== pkg.riskWarning || + normalizeClawHubSha256Integrity(preflight.integrity) !== + normalizeClawHubSha256Integrity(pkg.integrity) + ) { + throw new ClawPackageInstallError( + "package_owner_state_changed", + `Skill ${pkg.ref}@${pkg.version} changed after planning; run add --dry-run again.`, + installedPackages, + ); + } + if (preflight.action === "reuse") { + installedPackages.push( + persistPackageRef(plan, pkg, { + ...options, + status: "complete", + relationship: "managed", + origin: "pre-existing", + independentOwner: true, + }), + ); + continue; + } + let packageRef = persistPackageRef(plan, pkg, { + ...options, + status: "pending", + relationship: "managed", + origin: "claw-introduced", + independentOwner: false, + }); + installedPackages.push(packageRef); + // The installer has no mutation receipt. Mark the boundary before calling it so a throw + // after an on-disk change is treated as uncertain instead of falsely reported as rolled back. + options.onExternalMutation?.(pkg); + const installed = await installSkill({ + workspaceDir: plan.agent.workspace, + slug: pkg.ref, + version: pkg.version, + expectedIntegrity: pkg.integrity, + acknowledgeClawHubRisk: true, + clawManaged: true, + }); + packageLease.assertCurrent(); + if (!installed.ok) { + throw new Error(installed.error); + } + packageRef = completePackageRef(packageRef, "complete", options); + installedPackages[installedPackages.length - 1] = packageRef; + continue; + } + + const probe = await probePlugin({ + spec: `clawhub:${pkg.ref}@${pkg.version}`, + dryRun: true, + acknowledgeClawHubRisk: true, + }); + if (!probe.ok) { + throw new Error(probe.error); + } + const probeIntegrity = probe.clawhub.integrity + ? normalizeClawHubSha256Integrity(probe.clawhub.integrity) + : null; + if ( + probe.pluginId !== pkg.installId || + probeIntegrity !== normalizeClawHubSha256Integrity(pkg.integrity) || + probe.warning !== pkg.riskWarning + ) { + throw new ClawPackageInstallError( + "package_owner_state_changed", + `Plugin ${pkg.ref}@${pkg.version} identity or trust state changed after planning; run add --dry-run again.`, + installedPackages, + ); + } + const preflight = await preflightPlugin({ + clawhubPackage: pkg.ref, + rawSpec: `clawhub:${pkg.ref}@${pkg.version}`, + expectedVersion: pkg.version, + }); + packageLease.assertCurrent(); + if (!preflight.ok) { + throw new Error( + preflight.code === "plugin_version_conflict" + ? `Plugin ${pkg.ref}@${pkg.version} conflicts with installed version ${preflight.installedVersion}.` + : preflight.error, + ); + } + if (preflight.action !== pkg.ownerAction) { + throw new ClawPackageInstallError( + "package_owner_state_changed", + `Plugin ${pkg.ref}@${pkg.version} owner state changed from ${pkg.ownerAction} to ${preflight.action}; run add --dry-run again.`, + installedPackages, + ); + } + if (!pkg.installId) { + throw new ClawPackageInstallError( + "plugin_identity_unresolved", + `Plugin ${pkg.ref}@${pkg.version} has no resolved install identity.`, + installedPackages, + ); + } + if (preflight.action === "reuse") { + if ( + preflight.installedId !== pkg.installId || + !preflight.installedIntegrity || + normalizeClawHubSha256Integrity(preflight.installedIntegrity) !== + normalizeClawHubSha256Integrity(pkg.integrity) + ) { + throw new ClawPackageInstallError( + "package_owner_state_changed", + `Plugin ${pkg.ref}@${pkg.version} identity changed after planning; run add --dry-run again.`, + installedPackages, + ); + } + const existingRefs = readPackageRefs({ + ...options, + kind: pkg.kind, + source: pkg.source, + ref: pkg.ref, + version: pkg.version, + }); + const inheritsClawOrigin = + existingRefs.length > 0 && + existingRefs.every( + (candidate) => candidate.origin === "claw-introduced" && !candidate.independentOwner, + ) && + !ownerInstallIsNewerThanRefs(preflight.installedAt, existingRefs); + installedPackages.push( + persistPackageRef(plan, pkg, { + ...options, + status: "complete", + relationship: "referenced", + origin: inheritsClawOrigin ? "claw-introduced" : "pre-existing", + independentOwner: !inheritsClawOrigin, + }), + ); + continue; + } + + let packageRef = persistPackageRef(plan, pkg, { + ...options, + status: "pending", + relationship: "referenced", + origin: "claw-introduced", + independentOwner: false, + }); + installedPackages.push(packageRef); + + await installPlugin({ + raw: `clawhub:${pkg.ref}@${pkg.version}`, + opts: { + acknowledgeClawHubRisk: true, + expectedIntegrity: pkg.integrity, + expectedPluginId: pkg.installId, + }, + invalidateRuntimeCache: false, + clawManaged: true, + runtime: installerRuntime(runtime), + }); + installedPlugins.push({ + installId: pkg.installId, + packageIndex: installedPackages.length - 1, + }); + packageLease.assertCurrent(); + packageRef = completePackageRef(packageRef, "complete", options); + installedPackages[installedPackages.length - 1] = packageRef; + } catch (error) { + try { + packageLease?.release(); + packageLease = null; + } catch { + // The rollback path will report a busy lease instead of mutating without ownership. + } + const pending = installedPackages.at(-1); + if (pending?.status === "pending") { + try { + installedPackages[installedPackages.length - 1] = completePackageRef( + pending, + "failed", + options, + ); + } catch { + // Preserve the installer error; pending provenance still exposes uncertain ownership. + } + } + const rollbackErrors: string[] = []; + for (const installedPlugin of installedPlugins.toReversed()) { + const packageRef = installedPackages[installedPlugin.packageIndex]; + if (!packageRef) { + continue; + } + let rollbackLease: MaintainedClawPackageLifecycleLease | null = null; + try { + const acquiredRollbackLease = acquirePackageLease( + { kind: "plugin", source: "clawhub", ref: packageRef.ref }, + { env: options.env, path: options.path, required: true }, + ); + if (!acquiredRollbackLease) { + throw new Error(`Could not acquire package lifecycle lease for ${packageRef.ref}.`, { + cause: error, + }); + } + rollbackLease = maintainClawPackageLifecycleLease(acquiredRollbackLease); + const sharedRefs = readPackageRefs({ + ...options, + kind: "plugin", + source: "clawhub", + ref: packageRef.ref, + version: packageRef.version, + integrity: packageRef.integrity, + }).filter( + (ref) => + ref.agentId !== plan.agent.finalId && + (ref.status === "pending" || ref.status === "complete"), + ); + if (sharedRefs.length > 0) { + rollbackErrors.push( + `kept plugin ${installedPlugin.installId} because another Claw now references it`, + ); + continue; + } + const currentRefs = readPackageRefs({ + ...options, + kind: "plugin", + source: "clawhub", + ref: packageRef.ref, + version: packageRef.version, + }); + if (currentRefs.some((candidate) => candidate.independentOwner)) { + rollbackErrors.push( + `kept plugin ${installedPlugin.installId} because it now has a direct owner`, + ); + continue; + } + const installed = await resolvePlugin({ clawhubPackage: packageRef.ref }); + const installedIntegrity = + installed.status === "found" && installed.record.integrity + ? normalizeClawHubSha256Integrity(installed.record.integrity) + : null; + if ( + installed.status !== "found" || + installed.pluginId !== installedPlugin.installId || + installed.installedVersion !== packageRef.version || + installedIntegrity !== normalizeClawHubSha256Integrity(packageRef.integrity) || + ownerInstallIsNewerThanRefs(installed.record.installedAt, currentRefs) + ) { + rollbackErrors.push( + `kept plugin ${installedPlugin.installId} because its installed identity changed after Claw installation`, + ); + continue; + } + await uninstallPlugin( + installedPlugin.installId, + { force: true, invalidateRuntimeCache: false, clawManaged: true }, + installerRuntime(runtime), + ); + rollbackLease.assertCurrent(); + installedPackages[installedPlugin.packageIndex] = completePackageRef( + installedPackages[installedPlugin.packageIndex] ?? packageRef, + "rolled_back", + options, + ); + } catch (rollbackError) { + rollbackErrors.push( + `could not remove plugin ${installedPlugin.installId}: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + ); + continue; + } finally { + try { + rollbackLease?.release(); + } catch { + // Lease expiry recovers cleanup when the shared state database is unavailable. + } + } + } + const message = error instanceof Error ? error.message : String(error); + if (rollbackErrors.length > 0) { + throw new ClawPackageInstallError( + "package_rollback_failed", + `${message} Rollback incomplete: ${rollbackErrors.join("; ")}.`, + installedPackages, + ); + } + if (error instanceof ClawPackageInstallError) { + throw new ClawPackageInstallError(error.code, error.message, installedPackages); + } + throw new ClawPackageInstallError("package_install_failed", message, installedPackages); + } finally { + try { + packageLease?.release(); + } catch { + // Lease expiry recovers cleanup when the shared state database is unavailable. + } + } + } + + return installedPackages; +} diff --git a/src/claws/provenance.test.ts b/src/claws/provenance.test.ts index ba6f5da56540..9efb4ba8140a 100644 --- a/src/claws/provenance.test.ts +++ b/src/claws/provenance.test.ts @@ -11,7 +11,9 @@ import { import { applyClawAddPlan, ClawAddMutationError } from "./add.js"; import { buildClawAddPlan } from "./lifecycle.js"; import { + persistClawPackageRef, persistClawInstallRecord, + readClawPackageRefs, readClawInstallRecord, updateClawInstallRecordStatus, } from "./provenance.js"; @@ -81,6 +83,44 @@ function readInstallRow(agentId: string, root: string) { } describe("Claw root install provenance", () => { + it("replays an exact package ref without losing its relationship or origin", async () => { + const { root, plan } = await makePlan(); + const pkg = { + kind: "plugin" as const, + source: "clawhub" as const, + ref: "@acme/audit", + version: "1.2.3", + integrity: `sha256:${"a".repeat(64)}`, + }; + + persistClawPackageRef(plan, pkg, { + env: stateEnv(root), + nowMs: 42, + status: "pending", + relationship: "referenced", + origin: "claw-introduced", + independentOwner: false, + }); + const replayed = persistClawPackageRef(plan, pkg, { + env: stateEnv(root), + nowMs: 84, + status: "complete", + relationship: "referenced", + origin: "pre-existing", + independentOwner: true, + }); + + expect(replayed).toMatchObject({ + status: "complete", + relationship: "referenced", + origin: "claw-introduced", + independentOwner: true, + installedAtMs: 42, + updatedAtMs: 84, + }); + expect(readClawPackageRefs({ env: stateEnv(root) })).toEqual([replayed]); + }); + it("persists package identity, agent ownership, workspace, and config digest", async () => { const { root, plan } = await makePlan(); @@ -256,9 +296,14 @@ describe("applyClawAddPlan", () => { transform({ agents: { defaults: { workspace: mainWorkspace } } }); }, }), - ).rejects.toMatchObject({ code: "workspace_collision" }); + ).resolves.toMatchObject({ + status: "partial", + workspaceCreated: false, + configCommitted: false, + error: { code: "workspace_collision" }, + }); await expect(access(plan.agent.workspace)).rejects.toThrow(); - expect(readInstallRow("worker", planRoot)).toBeUndefined(); + expect(readInstallRow("worker", planRoot)?.status).toBe("partial"); }); it("rechecks agent collisions during the config commit and cleans the reserved workspace", async () => { @@ -271,7 +316,12 @@ describe("applyClawAddPlan", () => { transform({ agents: { list: [{ id: "worker" }] } }); }, }), - ).rejects.toMatchObject({ code: "agent_id_collision" }); + ).resolves.toMatchObject({ + status: "partial", + workspaceCreated: false, + configCommitted: false, + error: { code: "agent_id_collision" }, + }); await expect(access(plan.agent.workspace)).rejects.toThrow(); }); @@ -286,7 +336,12 @@ describe("applyClawAddPlan", () => { transform({ agents: { list: [{ id: " Worker " }] } }); }, }), - ).rejects.toMatchObject({ code: "agent_id_collision" }); + ).resolves.toMatchObject({ + status: "partial", + workspaceCreated: false, + configCommitted: false, + error: { code: "agent_id_collision" }, + }); await expect(access(plan.agent.workspace)).rejects.toThrow(); }); @@ -312,7 +367,12 @@ describe("applyClawAddPlan", () => { }); }, }), - ).rejects.toMatchObject({ code: "workspace_collision" }); + ).resolves.toMatchObject({ + status: "partial", + workspaceCreated: false, + configCommitted: false, + error: { code: "workspace_collision" }, + }); await expect(access(plan.agent.workspace)).rejects.toThrow(); }); @@ -351,8 +411,13 @@ describe("applyClawAddPlan", () => { consentPlanIntegrity: plan.planIntegrity, env: stateEnv(root), }), - ).rejects.toMatchObject({ code: "workspace_collision" }); - expect(readInstallRow("worker", root)).toBeUndefined(); + ).resolves.toMatchObject({ + status: "partial", + workspaceCreated: false, + configCommitted: false, + error: { code: "workspace_collision" }, + }); + expect(readInstallRow("worker", root)?.status).toBe("partial"); }); it("records parent-directory creation failures before workspace mutation", async () => { @@ -399,20 +464,23 @@ describe("applyClawAddPlan", () => { let config: OpenClawConfig = {}; let attempts = 0; - await expect( - applyClawAddPlan(plan, { - consentPlanIntegrity: plan.planIntegrity, - env: stateEnv(root), - commitConfig: async (transform) => { - attempts += 1; - if (attempts === 1) { - await writeFile(join(plan.agent.workspace, "leftover.txt"), "keep", "utf8"); - throw new Error("config unavailable"); - } - config = transform(config); - }, - }), - ).rejects.toThrow("config unavailable"); + const first = await applyClawAddPlan(plan, { + consentPlanIntegrity: plan.planIntegrity, + env: stateEnv(root), + commitConfig: async (transform) => { + attempts += 1; + if (attempts === 1) { + await writeFile(join(plan.agent.workspace, "leftover.txt"), "keep", "utf8"); + throw new Error("config unavailable"); + } + config = transform(config); + }, + }); + + expect(first).toMatchObject({ + status: "partial", + error: { code: "config_commit_failed", message: "config unavailable" }, + }); expect(readInstallRow("worker", root)?.status).toBe("workspace_ready"); const retry = await applyClawAddPlan(plan, { @@ -484,7 +552,14 @@ describe("applyClawAddPlan", () => { const { plan } = await makePlan({ schemaVersion: 1, agent: { id: "worker" }, - packages: [{ kind: "skill", source: "clawhub", ref: "demo", version: "1.0.0" }], + packages: [ + { + kind: "skill", + source: "clawhub", + ref: "demo", + version: "1.0.0", + }, + ], }); await expect( diff --git a/src/claws/provenance.ts b/src/claws/provenance.ts index c10959a89879..d244a6fc15f0 100644 --- a/src/claws/provenance.ts +++ b/src/claws/provenance.ts @@ -7,7 +7,7 @@ import { runOpenClawStateWriteTransaction, type OpenClawStateDatabaseOptions, } from "../state/openclaw-state-db.js"; -import type { ClawAddPlan } from "./types.js"; +import type { ClawAddPlan, ClawPackage, ResolvedClawPackage } from "./types.js"; const CLAW_INSTALL_RECORD_SCHEMA_VERSION = "openclaw.clawInstallRecord.v1" as const; @@ -265,3 +265,266 @@ export function deleteClawInstallRecord( } }, options); } + +const CLAW_PACKAGE_REF_SCHEMA_VERSION = "openclaw.clawPackageRef.v1" as const; +type ClawPackageRefStatus = "pending" | "complete" | "failed" | "rolled_back"; +type ClawPackageRelationship = "managed" | "referenced"; +type ClawPackageOrigin = "claw-introduced" | "pre-existing"; + +export type PersistedClawPackageRef = { + schemaVersion: typeof CLAW_PACKAGE_REF_SCHEMA_VERSION; + agentId: string; + clawName: string; + kind: ClawPackage["kind"]; + source: ClawPackage["source"]; + ref: string; + version: string; + integrity: string; + status: ClawPackageRefStatus; + relationship: ClawPackageRelationship; + origin: ClawPackageOrigin; + independentOwner: boolean; + installedAtMs: number; + updatedAtMs: number; +}; + +type PackageRefRow = { + schema_version: string; + agent_id: string; + claw_name: string; + package_kind: ClawPackage["kind"]; + package_source: ClawPackage["source"]; + package_ref: string; + package_version: string; + package_integrity: string; + package_status: ClawPackageRefStatus; + relationship: ClawPackageRelationship; + origin: ClawPackageOrigin; + independent_owner: number | bigint; + installed_at_ms: number | bigint; + updated_at_ms: number | bigint; +}; + +function rowToPackageRef(row: PackageRefRow): PersistedClawPackageRef { + return { + schemaVersion: CLAW_PACKAGE_REF_SCHEMA_VERSION, + agentId: row.agent_id, + clawName: row.claw_name, + kind: row.package_kind, + source: row.package_source, + ref: row.package_ref, + version: row.package_version, + integrity: row.package_integrity, + status: row.package_status, + relationship: row.relationship, + origin: row.origin, + independentOwner: Number(row.independent_owner) === 1, + installedAtMs: Number(row.installed_at_ms), + updatedAtMs: Number(row.updated_at_ms), + }; +} + +export function persistClawPackageRef( + plan: ClawAddPlan, + pkg: ResolvedClawPackage, + options: OpenClawStateDatabaseOptions & { + nowMs?: number; + status?: ClawPackageRefStatus; + relationship?: ClawPackageRelationship; + origin?: ClawPackageOrigin; + independentOwner?: boolean; + } = {}, +): PersistedClawPackageRef { + const nowMs = options.nowMs ?? Date.now(); + let record: PersistedClawPackageRef = { + schemaVersion: CLAW_PACKAGE_REF_SCHEMA_VERSION, + agentId: plan.agent.finalId, + clawName: plan.claw.name, + kind: pkg.kind, + source: pkg.source, + ref: pkg.ref, + version: pkg.version, + integrity: pkg.integrity, + status: options.status ?? "complete", + relationship: options.relationship ?? (pkg.kind === "skill" ? "managed" : "referenced"), + origin: options.origin ?? "claw-introduced", + independentOwner: options.independentOwner ?? false, + installedAtMs: nowMs, + updatedAtMs: nowMs, + }; + runOpenClawStateWriteTransaction(({ db }) => { + const existing = db /* sqlite-allow-raw: exact owned package-ref replay lookup. */ + .prepare( + `SELECT schema_version, agent_id, claw_name, package_kind, package_source, + package_ref, package_version, package_integrity, package_status, relationship, origin, + independent_owner, + installed_at_ms, updated_at_ms + FROM claw_package_refs + WHERE agent_id = @agent_id + AND package_kind = @package_kind + AND package_source = @package_source + AND package_ref = @package_ref + AND package_version = @package_version`, + ) + .get({ + agent_id: record.agentId, + package_kind: record.kind, + package_source: record.source, + package_ref: record.ref, + package_version: record.version, + }) as PackageRefRow | undefined; + if (existing) { + const previous = rowToPackageRef(existing); + if (previous.integrity !== record.integrity) { + throw new Error( + `Claw package reference ${record.kind}:${record.ref}@${record.version} changed integrity from ${previous.integrity} to ${record.integrity}.`, + ); + } + record = { + ...record, + relationship: previous.relationship, + origin: previous.origin === "claw-introduced" ? "claw-introduced" : record.origin, + independentOwner: previous.independentOwner || record.independentOwner, + installedAtMs: previous.installedAtMs, + }; + db /* sqlite-allow-raw: exact owned package-ref retry update. */ + .prepare( + `UPDATE claw_package_refs + SET schema_version = @schema_version, + claw_name = @claw_name, + package_status = @package_status, + relationship = @relationship, + origin = @origin, + independent_owner = @independent_owner, + updated_at_ms = @updated_at_ms + WHERE agent_id = @agent_id + AND package_kind = @package_kind + AND package_source = @package_source + AND package_ref = @package_ref + AND package_version = @package_version + AND package_integrity = @package_integrity`, + ) + .run({ + agent_id: record.agentId, + package_kind: record.kind, + package_source: record.source, + package_ref: record.ref, + package_version: record.version, + package_integrity: record.integrity, + schema_version: record.schemaVersion, + claw_name: record.clawName, + package_status: record.status, + relationship: record.relationship, + origin: record.origin, + independent_owner: record.independentOwner ? 1 : 0, + updated_at_ms: record.updatedAtMs, + }); + return; + } + // sqlite-allow-raw: this Claw prototype state-table write is scoped to one owned row. + db.prepare( + `INSERT INTO claw_package_refs ( + agent_id, package_kind, package_source, package_ref, package_version, + package_integrity, schema_version, claw_name, package_status, relationship, origin, + independent_owner, + installed_at_ms, + updated_at_ms + ) VALUES ( + @agent_id, @package_kind, @package_source, @package_ref, @package_version, + @package_integrity, @schema_version, @claw_name, @package_status, @relationship, @origin, + @independent_owner, + @installed_at_ms, + @updated_at_ms + )`, + ).run({ + agent_id: record.agentId, + package_kind: record.kind, + package_source: record.source, + package_ref: record.ref, + package_version: record.version, + package_integrity: record.integrity, + schema_version: record.schemaVersion, + claw_name: record.clawName, + package_status: record.status, + relationship: record.relationship, + origin: record.origin, + independent_owner: record.independentOwner ? 1 : 0, + installed_at_ms: record.installedAtMs, + updated_at_ms: record.updatedAtMs, + }); + }, options); + return record; +} + +export function updateClawPackageRefStatus( + ref: PersistedClawPackageRef, + status: ClawPackageRefStatus, + options: OpenClawStateDatabaseOptions & { nowMs?: number } = {}, +): PersistedClawPackageRef { + const nowMs = options.nowMs ?? Date.now(); + runOpenClawStateWriteTransaction(({ db }) => { + // sqlite-allow-raw: this Claw package reference status update is scoped to one owned row. + db.prepare( + `UPDATE claw_package_refs + SET package_status = @package_status, updated_at_ms = @updated_at_ms + WHERE agent_id = @agent_id + AND package_kind = @package_kind + AND package_source = @package_source + AND package_ref = @package_ref + AND package_version = @package_version + AND package_integrity = @package_integrity`, + ).run({ + agent_id: ref.agentId, + package_kind: ref.kind, + package_source: ref.source, + package_ref: ref.ref, + package_version: ref.version, + package_integrity: ref.integrity, + package_status: status, + updated_at_ms: nowMs, + }); + }, options); + return { ...ref, status, updatedAtMs: nowMs }; +} + +export function readClawPackageRefs( + options: OpenClawStateDatabaseOptions & { + kind?: ClawPackage["kind"]; + source?: ClawPackage["source"]; + ref?: string; + version?: string; + integrity?: string; + status?: ClawPackageRefStatus; + } = {}, +): PersistedClawPackageRef[] { + const database = openOpenClawStateDatabase(options); + const conditions: string[] = []; + const params: Record = {}; + for (const [column, value] of [ + ["package_kind", options.kind], + ["package_source", options.source], + ["package_ref", options.ref], + ["package_version", options.version], + ["package_integrity", options.integrity], + ["package_status", options.status], + ] as const) { + if (value !== undefined) { + conditions.push(`${column} = @${column}`); + params[column] = value; + } + } + const where = conditions.length > 0 ? ` WHERE ${conditions.join(" AND ")}` : ""; + const rows = + database.db /* sqlite-allow-raw: read-only Claw package reference lookup with closed column filters. */ + .prepare( + `SELECT schema_version, agent_id, claw_name, package_kind, package_source, + package_ref, package_version, package_integrity, package_status, relationship, origin, + independent_owner, + installed_at_ms, + updated_at_ms + FROM claw_package_refs${where} + ORDER BY agent_id, package_kind, package_ref`, + ) + .all(params) as PackageRefRow[]; + return rows.map(rowToPackageRef); +} diff --git a/src/claws/schema-conformance.test.ts b/src/claws/schema-conformance.test.ts index c16ee32d1af4..02a73446b970 100644 --- a/src/claws/schema-conformance.test.ts +++ b/src/claws/schema-conformance.test.ts @@ -21,7 +21,14 @@ describe("portable Claw schema conformance", () => { (version) => { const result = parseClawManifest({ ...baseManifest, - packages: [{ kind: "skill", source: "clawhub", ref: "demo", version }], + packages: [ + { + kind: "skill", + source: "clawhub", + ref: "demo", + version, + }, + ], }); expect(result.ok).toBe(false); expect(result.diagnostics).toContainEqual( @@ -33,7 +40,14 @@ describe("portable Claw schema conformance", () => { it("requires canonical ClawHub package names", () => { const result = parseClawManifest({ ...baseManifest, - packages: [{ kind: "skill", source: "clawhub", ref: "Demo", version: "1.0.0" }], + packages: [ + { + kind: "skill", + source: "clawhub", + ref: "Demo", + version: "1.0.0", + }, + ], }); expect(result.ok).toBe(false); expect(result.diagnostics).toContainEqual( diff --git a/src/claws/schema.test.ts b/src/claws/schema.test.ts index 106a2ad49555..7bc5a0d899e9 100644 --- a/src/claws/schema.test.ts +++ b/src/claws/schema.test.ts @@ -30,8 +30,18 @@ const baseManifest = { files: [{ source: "workspace/reference/policy.md", path: "reference/policy.md" }], }, packages: [ - { kind: "skill", source: "clawhub", ref: "@acme/triage", version: "1.2.0" }, - { kind: "plugin", source: "clawhub", ref: "@acme/github", version: "2.0.1" }, + { + kind: "skill", + source: "clawhub", + ref: "@acme/triage", + version: "1.2.0", + }, + { + kind: "plugin", + source: "clawhub", + ref: "@acme/github", + version: "2.0.1", + }, ], mcpServers: { github: { @@ -131,7 +141,7 @@ describe("parseClawManifest", () => { }, ); - it("rejects required flags and connector packages", () => { + it("rejects non-v1 package fields and connector packages", () => { const connector = parseClawManifest({ ...baseManifest, packages: [{ kind: "connector", source: "clawhub", ref: "@acme/chat", version: "1.0.0" }], @@ -145,12 +155,31 @@ describe("parseClawManifest", () => { }); expect(required.ok).toBe(false); expect(required.diagnostics[0]?.path).toBe("$.packages[0]"); + + const manifestIntegrity = parseClawManifest({ + ...baseManifest, + packages: [ + { + ...baseManifest.packages[0], + integrity: `sha256:${"a".repeat(64)}`, + }, + ], + }); + expect(manifestIntegrity.ok).toBe(false); + expect(manifestIntegrity.diagnostics[0]?.path).toBe("$.packages[0]"); }); it("requires exact package versions", () => { const result = parseClawManifest({ ...baseManifest, - packages: [{ kind: "skill", source: "clawhub", ref: "demo", version: "latest" }], + packages: [ + { + kind: "skill", + source: "clawhub", + ref: "demo", + version: "latest", + }, + ], }); expect(result.ok).toBe(false); @@ -431,6 +460,65 @@ describe("readClawManifestFile", () => { }); describe("buildClawAddPlan", () => { + it("materializes resolved package identity into the consented plan", async () => { + const { source, workspace } = await createPlanSource(); + const plan = await buildClawAddPlan({ + manifest: requireManifest(), + source, + context: { + workspace, + packagePreflight: async (pkg) => ({ + ok: true, + action: "install", + integrity: `sha256:${(pkg.kind === "skill" ? "a" : "b").repeat(64)}`, + warning: `Review ${pkg.ref} before installation.`, + ...(pkg.kind === "plugin" ? { installId: "github" } : {}), + }), + }, + }); + + expect(plan.actions.filter((action) => action.kind === "package")).toEqual([ + expect.objectContaining({ + id: "skill:@acme/triage", + digest: `sha256:${"a".repeat(64)}`, + details: expect.objectContaining({ + ownerAction: "install", + riskWarning: "Review @acme/triage before installation.", + }), + blocked: false, + }), + expect.objectContaining({ + id: "plugin:@acme/github", + digest: `sha256:${"b".repeat(64)}`, + details: expect.objectContaining({ + ownerAction: "install", + installId: "github", + riskWarning: "Review @acme/github before installation.", + }), + blocked: false, + }), + ]); + expect(plan.capabilityChanges.filter((change) => change.kind === "package")).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "skill:@acme/triage", + effect: expect.objectContaining({ + integrity: `sha256:${"a".repeat(64)}`, + riskWarning: "Review @acme/triage before installation.", + }), + }), + expect.objectContaining({ + id: "plugin:@acme/github", + effect: expect.objectContaining({ + integrity: `sha256:${"b".repeat(64)}`, + installId: "github", + riskWarning: "Review @acme/github before installation.", + }), + }), + ]), + ); + }); + it("plans one new agent, workspace, packages, MCP servers, and agent-pinned cron jobs", async () => { const { source, workspace } = await createPlanSource(); const plan = await buildClawAddPlan({ diff --git a/src/claws/types.ts b/src/claws/types.ts index d30218a21386..5e6ac2c06dfa 100644 --- a/src/claws/types.ts +++ b/src/claws/types.ts @@ -76,13 +76,15 @@ type ClawWorkspace = { files: ClawWorkspaceFile[]; }; -type ClawPackage = { +export type ClawPackage = { kind: "skill" | "plugin"; source: "clawhub"; ref: string; version: string; }; +export type ResolvedClawPackage = ClawPackage & { integrity: string }; + type ClawMcpServerCommon = { toolFilter?: { include?: string[]; diff --git a/src/cli/claws-cli.runtime.ts b/src/cli/claws-cli.runtime.ts index 24256a202b33..ae760c3ec9c9 100644 --- a/src/cli/claws-cli.runtime.ts +++ b/src/cli/claws-cli.runtime.ts @@ -7,6 +7,7 @@ import { } from "../claws/add.js"; import { assertExperimentalClawsEnabled } from "../claws/experimental.js"; import { buildClawAddPlan } from "../claws/lifecycle.js"; +import { preflightClawPackage } from "../claws/packages.js"; import { readClawInstallRecord } from "../claws/provenance.js"; import { readClawManifestFile } from "../claws/reader.js"; import { @@ -189,6 +190,7 @@ export async function runClawsAddCommand( existingWorkspacePaths, existingMcpServerNames: Object.keys(config.mcp?.servers ?? {}), existingCronJobIds: cronStore.store.jobs.map((job) => job.id), + packagePreflight: preflightClawPackage, }; let plan = await buildClawAddPlan({ manifest: result.manifest, @@ -267,7 +269,10 @@ export async function runClawsAddCommand( let addResult; try { - addResult = await applyClawAddPlan(plan, { consentPlanIntegrity: opts.planIntegrity }); + addResult = await applyClawAddPlan(plan, { + consentPlanIntegrity: opts.planIntegrity, + runtime: opts.json ? { ...runtime, log: () => undefined } : runtime, + }); } catch (error) { const code = error instanceof ClawAddMutationError ? error.code : "add_failed"; const message = (error as Error).message; diff --git a/src/cli/claws-cli.test.ts b/src/cli/claws-cli.test.ts index f2d8bfeee131..19d4222f142e 100644 --- a/src/cli/claws-cli.test.ts +++ b/src/cli/claws-cli.test.ts @@ -81,7 +81,14 @@ async function writePackage(): Promise<{ root: string; workspace: string }> { workspace: { bootstrapFiles: { "AGENTS.md": { source: "workspace/AGENTS.md" } }, }, - packages: [{ kind: "skill", source: "clawhub", ref: "@acme/demo-skill", version: "1.0.0" }], + packages: [ + { + kind: "skill", + source: "clawhub", + ref: "@acme/demo-skill", + version: "1.0.0", + }, + ], }), "utf8", ); @@ -257,7 +264,7 @@ describe("claws cli", () => { expect(mocks.applyClawAddPlan).toHaveBeenCalledWith( expect.objectContaining({ planIntegrity: plan.planIntegrity }), - { consentPlanIntegrity: plan.planIntegrity }, + expect.objectContaining({ consentPlanIntegrity: plan.planIntegrity }), ); expect(JSON.parse(mocks.logs[0] ?? "{}")).toMatchObject({ schemaVersion: "openclaw.clawAddResult.v1", @@ -297,7 +304,7 @@ describe("claws cli", () => { expect(mocks.applyClawAddPlan).toHaveBeenCalledWith( expect.objectContaining({ planIntegrity: plan.planIntegrity, blockers: [] }), - { consentPlanIntegrity: plan.planIntegrity }, + expect.objectContaining({ consentPlanIntegrity: plan.planIntegrity }), ); expect(mocks.runtime.exit).not.toHaveBeenCalled(); }); @@ -331,7 +338,7 @@ describe("claws cli", () => { expect(mocks.applyClawAddPlan).toHaveBeenCalledWith( expect.objectContaining({ planIntegrity: plan.planIntegrity, blockers: [] }), - { consentPlanIntegrity: plan.planIntegrity }, + expect.objectContaining({ consentPlanIntegrity: plan.planIntegrity }), ); expect(mocks.runtime.exit).not.toHaveBeenCalled(); }); diff --git a/src/cli/plugins-cli.uninstall.test.ts b/src/cli/plugins-cli.uninstall.test.ts index 631c502d66c1..f924dc003223 100644 --- a/src/cli/plugins-cli.uninstall.test.ts +++ b/src/cli/plugins-cli.uninstall.test.ts @@ -1,7 +1,11 @@ -// Plugins CLI uninstall tests cover plugin removal selection and uninstall output. import { installedPluginRoot } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +// Plugins CLI uninstall tests cover plugin removal selection and uninstall output. +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; +import { persistClawPackageRef } from "../claws/provenance.js"; +import type { ClawAddPlan } from "../claws/types.js"; import type { OpenClawConfig } from "../config/config.js"; +import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { applyPluginUninstallDirectoryRemoval, buildPluginDiagnosticsReport, @@ -24,6 +28,7 @@ import { const CLI_STATE_ROOT = "/tmp/openclaw-state"; const ALPHA_INSTALL_PATH = installedPluginRoot(CLI_STATE_ROOT, "alpha"); const ORIGINAL_OPENCLAW_NIX_MODE = process.env.OPENCLAW_NIX_MODE; +const tempDirs = useAutoCleanupTempDirTracker(afterEach); function expectRuntimeLogIncludes(fragment: string) { expect(runtimeLogs.join("\n")).toContain(fragment); @@ -53,6 +58,7 @@ describe("plugins cli uninstall", () => { }); afterEach(() => { + closeOpenClawStateDatabaseForTest(); if (ORIGINAL_OPENCLAW_NIX_MODE === undefined) { delete process.env.OPENCLAW_NIX_MODE; } else { @@ -210,6 +216,76 @@ describe("plugins cli uninstall", () => { }); }); + it("warns but proceeds when a shared plugin has an uncertain Claw reference", async () => { + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + process.env.OPENCLAW_STATE_DIR = tempDirs.make("openclaw-claw-plugin-ref-"); + closeOpenClawStateDatabaseForTest(); + try { + const installRecord = { + source: "clawhub" as const, + spec: "clawhub:@owner/audit@2.0.1", + clawhubPackage: "@owner/audit", + version: "2.0.1", + installPath: ALPHA_INSTALL_PATH, + }; + const baseConfig = { + plugins: { + entries: { alpha: { enabled: true } }, + installs: { alpha: installRecord }, + }, + } as OpenClawConfig; + loadConfig.mockReturnValue(baseConfig); + setInstalledPluginIndexInstallRecords({ alpha: installRecord }); + buildPluginSnapshotReport.mockReturnValue({ + plugins: [{ id: "alpha", name: "alpha" }], + diagnostics: [], + }); + planPluginUninstall.mockReturnValue({ + ok: true, + config: { plugins: { entries: {}, installs: {} } } as OpenClawConfig, + actions: { + entry: true, + install: true, + allowlist: false, + denylist: false, + loadPath: false, + memorySlot: false, + contextEngineSlot: false, + channelConfig: false, + directory: false, + }, + directoryRemoval: null, + }); + persistClawPackageRef( + { + agent: { finalId: "audit-agent" }, + claw: { name: "@owner/audit-claw" }, + } as ClawAddPlan, + { + kind: "plugin", + source: "clawhub", + ref: "@owner/audit", + version: "2.0.1", + integrity: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }, + { status: "failed" }, + ); + + await runPluginsCommand(["plugins", "uninstall", "alpha", "--force", "--keep-files"]); + + expectRuntimeLogIncludes('Warning: plugin "alpha" is referenced by Claw: @owner/audit-claw.'); + expectRuntimeLogIncludes("Uninstalling it may break those Claws"); + expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({}); + } finally { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + closeOpenClawStateDatabaseForTest(); + } + }); + it("exits cleanly when confirmation input closes before an answer", async () => { const baseConfig = { plugins: { diff --git a/src/cli/plugins-install-command.ts b/src/cli/plugins-install-command.ts index 5bb1bb54ec22..3f0d96f047c5 100644 --- a/src/cli/plugins-install-command.ts +++ b/src/cli/plugins-install-command.ts @@ -45,6 +45,8 @@ import { import { resolveCatalogOfficialExternalInstallPlan } from "../plugins/official-external-install-trust.js"; import { tracePluginLifecyclePhaseAsync } from "../plugins/plugin-lifecycle-trace.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; +import { markClawPackageIndependentlyOwned } from "../state/claw-package-adoption.js"; +import { withClawPackageLifecycleLease } from "../state/claw-package-lifecycle-lease.js"; import { resolveUserPath, shortenHomePath } from "../utils.js"; import { resolveClawHubRiskAcknowledgementCliOptions } from "./clawhub-risk-acknowledgement.js"; import { formatCliCommand } from "./command-format.js"; @@ -759,12 +761,15 @@ export async function runPluginInstallCommand(params: { raw: string; opts: InstallSafetyOverrides & { acknowledgeClawHubRisk?: boolean; + expectedIntegrity?: string; + expectedPluginId?: string; force?: boolean; link?: boolean; pin?: boolean; marketplace?: string; }; invalidateRuntimeCache?: boolean; + clawManaged?: boolean; runtime?: RuntimeEnv; }) { assertConfigWriteAllowedInCurrentMode(); @@ -1215,36 +1220,69 @@ export async function runPluginInstallCommand(params: { } if (clawhubSpec) { - const result = await installPluginFromClawHub({ - ...safetyOverrides, - ...resolveClawHubRiskAcknowledgementCliOptions({ - acknowledgeClawHubRisk: opts.acknowledgeClawHubRisk, - action: "installing", - }), - mode: installMode, - spec: raw, - extensionsDir, - logger: createPluginInstallLogger(runtime), - }); - if (!result.ok) { - if (!isClawHubBlockedCliFailure(result)) { - runtime.error(result.error); - } - return runtime.exit(1); - } - - await persistPluginInstall({ - snapshot, - pluginId: result.pluginId, - install: { - ...buildClawHubPluginInstallRecordFields(result.clawhub), + const installFromClawHub = async ( + installSnapshot = snapshot, + installSafetyOverrides = safetyOverrides, + ) => { + const result = await installPluginFromClawHub({ + ...installSafetyOverrides, + ...resolveClawHubRiskAcknowledgementCliOptions({ + acknowledgeClawHubRisk: opts.acknowledgeClawHubRisk, + action: "installing", + }), + mode: installMode, spec: raw, - installPath: result.targetDir, + ...(opts.expectedIntegrity ? { expectedIntegrity: opts.expectedIntegrity } : {}), + ...(opts.expectedPluginId ? { expectedPluginId: opts.expectedPluginId } : {}), + extensionsDir, + logger: createPluginInstallLogger(runtime), + }); + if (!result.ok) { + if (!isClawHubBlockedCliFailure(result)) { + runtime.error(result.error); + } + return runtime.exit(1); + } + + await persistPluginInstall({ + snapshot: installSnapshot, + pluginId: result.pluginId, + install: { + ...buildClawHubPluginInstallRecordFields(result.clawhub), + spec: raw, + installPath: result.targetDir, + }, + invalidateRuntimeCache, + runtime, + }); + if (!params.clawManaged && result.clawhub.version) { + markClawPackageIndependentlyOwned({ + kind: "plugin", + source: "clawhub", + ref: result.clawhub.clawhubPackage, + version: result.clawhub.version, + }); + } + }; + if (params.clawManaged) { + return await installFromClawHub(); + } + return await withClawPackageLifecycleLease( + { kind: "plugin", source: "clawhub", ref: clawhubSpec.name }, + async () => { + const leasedSnapshot = await loadConfigForInstall(request).catch((error: unknown) => { + runtime.error(formatErrorMessage(error)); + return null; + }); + if (!leasedSnapshot) { + return runtime.exit(1); + } + return await installFromClawHub( + leasedSnapshot, + resolveInstallSafetyOverrides({ ...opts, config: leasedSnapshot.config }), + ); }, - invalidateRuntimeCache, - runtime, - }); - return; + ); } const trustedNpmInstall = resolveOpenClawTrustedNpmPackageInstall(raw); diff --git a/src/cli/plugins-uninstall-command.ts b/src/cli/plugins-uninstall-command.ts index 28e5bd2901f3..55455e8d0f66 100644 --- a/src/cli/plugins-uninstall-command.ts +++ b/src/cli/plugins-uninstall-command.ts @@ -19,6 +19,8 @@ type PluginUninstallOptions = { force?: boolean; dryRun?: boolean; invalidateRuntimeCache?: boolean; + /** True when a Claw lifecycle caller already owns package coordination. */ + clawManaged?: boolean; }; function isPromptInputClosedError( @@ -147,6 +149,15 @@ export async function runPluginUninstallCommand( ); runtime.log(`Will remove: ${preview.length > 0 ? preview.join(", ") : "(nothing)"}`); + const { collectClawPluginUninstallWarnings } = + await import("../plugins/uninstall-claw-references.js"); + for (const warning of collectClawPluginUninstallWarnings({ + pluginId, + installRecord: cfg.plugins?.installs?.[pluginId], + })) { + runtime.log(theme.warn(warning)); + } + const nextConfig = withoutPluginInstallRecords(plan.config); if (opts.dryRun) { diff --git a/src/plugins/clawhub.test.ts b/src/plugins/clawhub.test.ts index ecc6405b20bd..dc68c4225328 100644 --- a/src/plugins/clawhub.test.ts +++ b/src/plugins/clawhub.test.ts @@ -984,6 +984,7 @@ describe("installPluginFromClawHub", () => { expectSuccessfulClawHubInstall(result, { clawhubChannel: "community" }); const success = expectInstallSuccess(result); expect(success.clawhub?.clawhubTrustDisposition).toBe("review-required"); + expect(success.warning).toContain("WARNING - ClawHub found security risks"); expect(success.clawhub?.clawhubTrustScanStatus).toBe("suspicious"); expect(success.clawhub?.clawhubTrustReasons).toEqual(["payload_strings"]); expect(success.clawhub?.clawhubTrustCheckedAt).toMatch( diff --git a/src/plugins/clawhub.ts b/src/plugins/clawhub.ts index b4b9f7a06b3c..04e5711c2b96 100644 --- a/src/plugins/clawhub.ts +++ b/src/plugins/clawhub.ts @@ -1219,6 +1219,7 @@ export async function installPluginFromClawHub( } & Extract & { clawhub: ClawHubPluginInstallRecordFields; packageName: string; + warning?: string; }) | ClawHubInstallFailure | Extract @@ -1485,6 +1486,7 @@ export async function installPluginFromClawHub( } return { ...installResult, + ...(trustResult?.warning ? { warning: trustResult.warning } : {}), packageName: canonicalPackageName, clawhub: { source: "clawhub", diff --git a/src/plugins/management-service.test.ts b/src/plugins/management-service.test.ts index 267eebfe46ce..43ec8263088f 100644 --- a/src/plugins/management-service.test.ts +++ b/src/plugins/management-service.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ applyUninstall: vi.fn(), + clawReferenceWarnings: vi.fn(), clawhubInstall: vi.fn(), commitRecords: vi.fn(), installRecords: vi.fn(), @@ -78,6 +79,10 @@ vi.mock("./install-record-commit.js", () => ({ commitPluginInstallRecordsWithConfig: (...args: unknown[]) => mocks.commitRecords(...args), })); +vi.mock("./uninstall-claw-references.js", () => ({ + collectClawPluginUninstallWarnings: (...args: unknown[]) => mocks.clawReferenceWarnings(...args), +})); + vi.mock("./official-external-plugin-catalog.js", async (importOriginal) => ({ ...(await importOriginal()), loadConfiguredHostedOfficialExternalPluginCatalogEntries: (...args: unknown[]) => @@ -221,6 +226,7 @@ describe("plugin management service", () => { mocks.applyUninstall.mockResolvedValue({ directoryRemoved: true, warnings: [] }); mocks.providerAuthChoices.mockReturnValue([]); mocks.recommendedInstalls.mockReturnValue([]); + mocks.clawReferenceWarnings.mockReturnValue([]); mocks.officialCatalog.mockResolvedValue({ source: "hosted", entries: [], @@ -1035,6 +1041,9 @@ describe("plugin management service", () => { }); mocks.commitRecords.mockResolvedValue(undefined); mocks.applyUninstall.mockResolvedValue({ directoryRemoved: true, warnings: [] }); + mocks.clawReferenceWarnings.mockReturnValue([ + 'Warning: plugin "diffs" is referenced by Claw: @acme/review.', + ]); mocks.refreshRegistry.mockResolvedValue(undefined); const result = await uninstallManagedPlugin({ pluginId: "diffs", env: {} }); @@ -1058,12 +1067,10 @@ describe("plugin management service", () => { )[0].nextConfig.plugins?.installs, ).toBeUndefined(); expect(mocks.applyUninstall).toHaveBeenCalledWith({ target: "/tmp/extensions/diffs" }); - expect(mocks.refreshRegistry).toHaveBeenCalledWith( - expect.objectContaining({ reason: "source-changed", installRecords: {} }), - ); expect(result).toMatchObject({ pluginId: "diffs", removed: ["config entry", "install record", "directory"], + warnings: ['Warning: plugin "diffs" is referenced by Claw: @acme/review.'], }); }); diff --git a/src/plugins/management-service.ts b/src/plugins/management-service.ts index a7d5d1e5766a..afc0adf9ed54 100644 --- a/src/plugins/management-service.ts +++ b/src/plugins/management-service.ts @@ -60,6 +60,7 @@ import { listRecommendedToolInstalls } from "./recommended-tool-installs.js"; import { refreshPluginRegistryAfterConfigMutation } from "./registry-refresh.js"; import { applySlotSelectionForPlugin } from "./slot-selection.js"; import { setPluginEnabledInConfig } from "./toggle-config.js"; +import { collectClawPluginUninstallWarnings } from "./uninstall-claw-references.js"; import { applyPluginUninstallDirectoryRemoval, formatUninstallActionLabels, @@ -1306,7 +1307,14 @@ export async function uninstallManagedPlugin(params: { writeOptions: snapshot.writeOptions, }); const directoryResult = await applyPluginUninstallDirectoryRemoval(plan.directoryRemoval); - const warnings = [...directoryResult.warnings]; + const warnings = [ + ...collectClawPluginUninstallWarnings({ + pluginId, + installRecord: installRecords[pluginId], + env, + }), + ...directoryResult.warnings, + ]; await refreshPluginRegistryAfterConfigMutation({ config: nextConfig, reason: "source-changed", diff --git a/src/plugins/plugin-install-preflight.test.ts b/src/plugins/plugin-install-preflight.test.ts new file mode 100644 index 000000000000..92039ecb38f2 --- /dev/null +++ b/src/plugins/plugin-install-preflight.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from "vitest"; +import { preflightPluginInstall } from "./plugin-install-preflight.js"; + +describe("preflightPluginInstall", () => { + it("reuses an exact installed version", async () => { + const result = await preflightPluginInstall({ + clawhubPackage: "@acme/audit", + 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" }, + }), + }); + expect(result).toMatchObject({ ok: true, action: "reuse", installedVersion: "1.2.3" }); + }); + + it("rejects a different installed version", async () => { + const result = await preflightPluginInstall({ + clawhubPackage: "@acme/audit", + rawSpec: "clawhub:@acme/audit@1.2.3", + expectedVersion: "1.2.3", + loadInstallRecords: vi.fn().mockResolvedValue({ + audit: { source: "clawhub", clawhubPackage: "@acme/audit", resolvedVersion: "1.1.0" }, + }), + }); + expect(result).toMatchObject({ + ok: false, + code: "plugin_version_conflict", + installedVersion: "1.1.0", + }); + }); +}); diff --git a/src/plugins/plugin-install-preflight.ts b/src/plugins/plugin-install-preflight.ts new file mode 100644 index 000000000000..2d1f46735418 --- /dev/null +++ b/src/plugins/plugin-install-preflight.ts @@ -0,0 +1,116 @@ +import { + resolvePluginInstallRequestContext, + type PluginInstallRequestContext, +} from "../cli/plugin-install-config-policy.js"; +import type { PluginInstallRecord } from "../config/types.plugins.js"; +import { parseClawHubPluginSpec } from "../infra/clawhub-spec.js"; +import { loadInstalledPluginIndexInstallRecords } from "./installed-plugin-index-records.js"; + +type PluginInstallPreflightResult = + | { ok: true; action: "install"; request: PluginInstallRequestContext } + | { + ok: true; + action: "reuse"; + request: PluginInstallRequestContext; + installedId: string; + installedVersion: string; + installedIntegrity?: string; + installedAt?: string; + } + | { + ok: false; + code: "plugin_version_conflict"; + request: PluginInstallRequestContext; + installedVersion: string; + expectedVersion: string; + } + | { ok: false; code: "invalid_plugin_spec"; error: string }; + +type InstalledClawHubPluginResolution = + | { status: "missing" } + | { status: "ambiguous"; pluginIds: string[] } + | { + status: "found"; + pluginId: string; + record: PluginInstallRecord; + installedVersion?: string; + }; + +/** Resolves one installed plugin by its stable ClawHub package identity. */ +export async function resolveInstalledClawHubPlugin(params: { + clawhubPackage: string; + loadInstallRecords?: typeof loadInstalledPluginIndexInstallRecords; +}): Promise { + const records = await (params.loadInstallRecords ?? loadInstalledPluginIndexInstallRecords)(); + const matches = Object.entries(records).filter( + ([, record]) => + (record.clawhubPackage ?? + parseClawHubPluginSpec(record.spec ?? "")?.name ?? + parseClawHubPluginSpec(record.resolvedSpec ?? "")?.name) === params.clawhubPackage, + ); + if (matches.length === 0) { + return { status: "missing" }; + } + if (matches.length > 1) { + return { status: "ambiguous", pluginIds: matches.map(([pluginId]) => pluginId).toSorted() }; + } + const match = matches[0]; + if (!match) { + return { status: "missing" }; + } + const [pluginId, record] = match; + return { + status: "found", + pluginId, + record, + installedVersion: record.resolvedVersion ?? record.version, + }; +} + +export async function preflightPluginInstall(params: { + clawhubPackage: string; + rawSpec: string; + expectedVersion: string; + marketplace?: string; + loadInstallRecords?: typeof loadInstalledPluginIndexInstallRecords; +}): Promise { + const resolved = resolvePluginInstallRequestContext({ + rawSpec: params.rawSpec, + ...(params.marketplace ? { marketplace: params.marketplace } : {}), + installKind: "plugin", + }); + if (!resolved.ok) { + return { ok: false, code: "invalid_plugin_spec", error: resolved.error }; + } + + const records = await (params.loadInstallRecords ?? loadInstalledPluginIndexInstallRecords)(); + const installedEntry = Object.entries(records).find( + ([, record]) => + (record.clawhubPackage ?? parseClawHubPluginSpec(record.spec ?? "")?.name) === + params.clawhubPackage, + ); + const installedId = installedEntry?.[0]; + const installed = installedEntry?.[1]; + const installedVersion = installed?.resolvedVersion ?? installed?.version; + if (!installedVersion || !installedId) { + return { ok: true, action: "install", request: resolved.request }; + } + if (installedVersion === params.expectedVersion) { + return { + ok: true, + action: "reuse", + request: resolved.request, + installedId, + installedVersion, + ...(installed?.integrity ? { installedIntegrity: installed.integrity } : {}), + ...(installed?.installedAt ? { installedAt: installed.installedAt } : {}), + }; + } + return { + ok: false, + code: "plugin_version_conflict", + request: resolved.request, + installedVersion, + expectedVersion: params.expectedVersion, + }; +} diff --git a/src/plugins/uninstall-claw-references.test.ts b/src/plugins/uninstall-claw-references.test.ts new file mode 100644 index 000000000000..82ab9b8b763d --- /dev/null +++ b/src/plugins/uninstall-claw-references.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const readClawPackageRefsMock = vi.hoisted(() => vi.fn()); + +vi.mock("../claws/provenance.js", async (importOriginal) => ({ + ...(await importOriginal()), + readClawPackageRefs: readClawPackageRefsMock, +})); + +const { collectClawPluginUninstallWarnings } = await import("./uninstall-claw-references.js"); + +const installRecord = { + source: "clawhub" as const, + clawhubPackage: "@owner/audit", + version: "2.0.1", +}; + +describe("collectClawPluginUninstallWarnings", () => { + beforeEach(() => { + readClawPackageRefsMock.mockReset(); + }); + + it("ignores a dependency that was conclusively rolled back", () => { + readClawPackageRefsMock.mockReturnValue([ + { + kind: "plugin", + source: "clawhub", + ref: "@owner/audit", + version: "2.0.1", + status: "rolled_back", + clawName: "@owner/audit-claw", + }, + ]); + + expect(collectClawPluginUninstallWarnings({ pluginId: "audit", installRecord })).toEqual([]); + }); + + it("keeps warning for an uncertain failed install", () => { + readClawPackageRefsMock.mockReturnValue([ + { + kind: "plugin", + source: "clawhub", + ref: "@owner/audit", + version: "2.0.1", + status: "failed", + clawName: "@owner/audit-claw", + }, + ]); + + expect(collectClawPluginUninstallWarnings({ pluginId: "audit", installRecord })).toContain( + 'Warning: plugin "audit" is referenced by Claw: @owner/audit-claw.', + ); + }); +}); diff --git a/src/plugins/uninstall-claw-references.ts b/src/plugins/uninstall-claw-references.ts new file mode 100644 index 000000000000..99133f6e9eb4 --- /dev/null +++ b/src/plugins/uninstall-claw-references.ts @@ -0,0 +1,60 @@ +import { readClawPackageRefs, type PersistedClawPackageRef } from "../claws/provenance.js"; +import type { PluginInstallRecord } from "../config/types.plugins.js"; +import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js"; + +function clawPackageRefMatchesPluginInstall( + ref: PersistedClawPackageRef, + pluginId: string, + record: PluginInstallRecord, +): boolean { + if (ref.kind !== "plugin" || ref.source !== "clawhub" || record.source !== "clawhub") { + return false; + } + const installedRef = + record.clawhubPackage ?? record.spec?.replace(/^clawhub:/i, "").replace(/@[^@]+$/, ""); + return (installedRef ?? pluginId) === ref.ref; +} + +/** Explain Claw dependents without blocking the operator-owned uninstall. */ +export function collectClawPluginUninstallWarnings(params: { + pluginId: string; + installRecord?: PluginInstallRecord; + env?: OpenClawStateDatabaseOptions["env"]; +}): string[] { + const installRecord = params.installRecord; + if (!installRecord || installRecord.source !== "clawhub") { + return []; + } + const refs = readClawPackageRefs({ + kind: "plugin", + source: "clawhub", + ...(params.env ? { env: params.env } : {}), + }).filter( + (ref) => + ref.status !== "rolled_back" && + clawPackageRefMatchesPluginInstall(ref, params.pluginId, installRecord), + ); + const clawIds = [...new Set(refs.map((ref) => ref.clawName))].toSorted(); + if (clawIds.length === 0) { + return []; + } + + const installedVersion = installRecord.resolvedVersion ?? installRecord.version; + const expectedVersions = [...new Set(refs.map((ref) => ref.version))].toSorted(); + const drifted = + installedVersion !== undefined && + expectedVersions.some((version) => version !== installedVersion); + + const warnings = [ + `Warning: plugin "${params.pluginId}" is referenced by Claw${clawIds.length === 1 ? "" : "s"}: ${clawIds.join(", ")}.`, + ]; + if (drifted) { + warnings.push( + `Installed version ${installedVersion} differs from the Claw reference${expectedVersions.length === 1 ? "" : "s"} ${expectedVersions.join(", ")}.`, + ); + } + warnings.push( + "Uninstalling it may break those Claws until the plugin is reinstalled or the Claws are updated.", + ); + return warnings; +} diff --git a/src/plugins/update-claw-lifecycle.ts b/src/plugins/update-claw-lifecycle.ts new file mode 100644 index 000000000000..79f415c869b3 --- /dev/null +++ b/src/plugins/update-claw-lifecycle.ts @@ -0,0 +1,96 @@ +import { parseClawHubPluginSpec } from "../infra/clawhub-spec.js"; +import { markClawPackageIndependentlyOwned } from "../state/claw-package-adoption.js"; +import { withClawPackageLifecycleLease } from "../state/claw-package-lifecycle-lease.js"; +import type { ClawHubRiskAcknowledgementRequest } from "./clawhub.js"; +import { installPluginFromNpmSpec } from "./install.js"; +import type { PluginUpdateChannelFallback, PluginUpdateOutcome } from "./update-source.js"; + +type ClawHubInstallRecord = { + source?: string; + clawhubPackage?: string; + spec?: string; + resolvedSpec?: string; +}; + +export function resolveRecordedClawHubPackage(record: ClawHubInstallRecord): string | undefined { + if (record.source !== "clawhub") { + return undefined; + } + return ( + record.clawhubPackage ?? + parseClawHubPluginSpec(record.spec ?? "")?.name ?? + parseClawHubPluginSpec(record.resolvedSpec ?? "")?.name + ); +} + +export function createTrackedNpmUpdateInstaller(onRun: () => void) { + return async (params: Parameters[0]) => { + onRun(); + return await installPluginFromNpmSpec(params); + }; +} + +export function resolveClawHubRiskAcknowledgementOptions(params: { + dryRun?: boolean; + acknowledgeClawHubRisk?: boolean; + onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise; +}) { + return { + ...(params.acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}), + ...(!params.dryRun && params.onClawHubRisk ? { onClawHubRisk: params.onClawHubRisk } : {}), + }; +} + +export function buildPluginUpdateVersionOutcome(params: { + pluginId: string; + currentVersion?: string; + nextVersion?: string; + channelFallbackSuffix: string; + channelFallback?: PluginUpdateChannelFallback; +}): PluginUpdateOutcome { + const currentLabel = params.currentVersion ?? "unknown"; + const nextLabel = params.nextVersion ?? "unknown"; + const unchanged = Boolean( + params.currentVersion && params.nextVersion && params.currentVersion === params.nextVersion, + ); + return { + pluginId: params.pluginId, + status: unchanged ? "unchanged" : "updated", + currentVersion: params.currentVersion, + nextVersion: params.nextVersion, + message: unchanged + ? `${params.pluginId} already at ${currentLabel}.${params.channelFallbackSuffix}` + : `Updated ${params.pluginId}: ${currentLabel} -> ${nextLabel}.${params.channelFallbackSuffix}`, + ...(params.channelFallback ? { channelFallback: params.channelFallback } : {}), + }; +} + +export async function runPluginUpdateWithClawHubLease(params: { + pluginId: string; + clawhubPackage?: string; + dryRun: boolean; + run: () => Promise; +}): Promise { + try { + if (!params.clawhubPackage || params.dryRun) { + return await params.run(); + } + return await withClawPackageLifecycleLease( + { kind: "plugin", source: "clawhub", ref: params.clawhubPackage }, + async () => { + markClawPackageIndependentlyOwned({ + kind: "plugin", + source: "clawhub", + ref: params.clawhubPackage!, + }); + return await params.run(); + }, + { required: true }, + ); + } catch (error) { + return { + kind: "exception", + message: `Failed to update ${params.pluginId}: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} diff --git a/src/plugins/update-installed.ts b/src/plugins/update-installed.ts index 6ed9834d2818..997e1544ad65 100644 --- a/src/plugins/update-installed.ts +++ b/src/plugins/update-installed.ts @@ -12,8 +12,7 @@ import { resolveBundledPluginSources } from "./bundled-sources.js"; import { buildClawHubPluginInstallRecordFields } from "./clawhub-install-records.js"; import type { ClawHubRiskAcknowledgementRequest } from "./clawhub.js"; import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; -import { installPluginFromNpmSpec, PLUGIN_INSTALL_ERROR_CODE } from "./install.js"; -import { resolvePluginInstallDir } from "./install.js"; +import { PLUGIN_INSTALL_ERROR_CODE, resolvePluginInstallDir } from "./install.js"; import { buildNpmResolutionInstallFields, recordPluginInstall, @@ -40,6 +39,13 @@ import { type MarketplacePluginUpdateSuccess, type NpmPluginUpdateSuccess, } from "./update-attempt.js"; +import { + buildPluginUpdateVersionOutcome, + createTrackedNpmUpdateInstaller, + resolveClawHubRiskAcknowledgementOptions, + resolveRecordedClawHubPackage, + runPluginUpdateWithClawHubLease, +} from "./update-claw-lifecycle.js"; import { disablePluginAfterUpdateFailure, hasRunnableInstalledNpmPayload, @@ -101,16 +107,10 @@ export async function updateNpmInstalledPlugins(params: { let next = params.config; let changed = false; let ranNpmInstaller = false; - const installNpmSpecForUpdate = async ( - installParams: Parameters[0], - ): Promise>> => { + const installNpmSpecForUpdate = createTrackedNpmUpdateInstaller(() => { ranNpmInstaller = true; - return await installPluginFromNpmSpec(installParams); - }; - const clawHubRiskAcknowledgementOptions = { - ...(params.acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}), - ...(!params.dryRun && params.onClawHubRisk ? { onClawHubRisk: params.onClawHubRisk } : {}), - }; + }); + const clawHubRiskAcknowledgementOptions = resolveClawHubRiskAcknowledgementOptions(params); const recordFailure = ( pluginId: string, @@ -294,7 +294,8 @@ export async function updateNpmInstalledPlugins(params: { continue; } - if (record.source === "clawhub" && !record.clawhubPackage && !officialClawHubSpec) { + const recordClawHubPackage = resolveRecordedClawHubPackage(record); + if (record.source === "clawhub" && !recordClawHubPackage && !officialClawHubSpec) { outcomes.push({ pluginId, status: "skipped", @@ -504,25 +505,32 @@ export async function updateNpmInstalledPlugins(params: { } } - const attempt = await runPluginUpdateAttempt({ + const runAttempt = () => + runPluginUpdateAttempt({ + pluginId, + record, + config: params.config, + dryRun: params.dryRun === true, + effectiveSpec, + extensionsDir, + timeoutMs: params.timeoutMs, + dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, + expectedIntegrity, + npmSpecs, + clawhubSpecs, + officialNpmFallbackSpecs, + trustedSourceLinkedOfficialInstall, + getFallbackExpectedIntegrity, + installNpmSpecForUpdate, + logger, + onIntegrityDrift: params.onIntegrityDrift, + clawHubRiskAcknowledgementOptions, + }); + const attempt = await runPluginUpdateWithClawHubLease({ pluginId, - record, - config: params.config, + clawhubPackage: recordClawHubPackage, dryRun: params.dryRun === true, - effectiveSpec, - extensionsDir, - timeoutMs: params.timeoutMs, - dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, - expectedIntegrity, - npmSpecs, - clawhubSpecs, - officialNpmFallbackSpecs, - trustedSourceLinkedOfficialInstall, - getFallbackExpectedIntegrity, - installNpmSpecForUpdate, - logger, - onIntegrityDrift: params.onIntegrityDrift, - clawHubRiskAcknowledgementOptions, + run: runAttempt, }); if (attempt.kind === "exception") { recordFailure(pluginId, attempt.message); @@ -692,27 +700,15 @@ export async function updateNpmInstalledPlugins(params: { } changed = true; - const currentLabel = currentVersion ?? "unknown"; - const nextLabel = nextVersion ?? "unknown"; - if (currentVersion && nextVersion && currentVersion === nextVersion) { - outcomes.push({ + outcomes.push( + buildPluginUpdateVersionOutcome({ pluginId, - status: "unchanged", - currentVersion: currentVersion ?? undefined, - nextVersion: nextVersion ?? undefined, - message: `${pluginId} already at ${currentLabel}.${channelFallbackSuffix}`, - ...(npmChannelFallback ? { channelFallback: npmChannelFallback } : {}), - }); - } else { - outcomes.push({ - pluginId, - status: "updated", - currentVersion: currentVersion ?? undefined, - nextVersion: nextVersion ?? undefined, - message: `Updated ${pluginId}: ${currentLabel} -> ${nextLabel}.${channelFallbackSuffix}`, - ...(npmChannelFallback ? { channelFallback: npmChannelFallback } : {}), - }); - } + currentVersion, + nextVersion, + channelFallbackSuffix, + channelFallback: npmChannelFallback, + }), + ); } if (ranNpmInstaller) { diff --git a/src/plugins/update-source.ts b/src/plugins/update-source.ts index c5ec544428d7..5c61aa976d8c 100644 --- a/src/plugins/update-source.ts +++ b/src/plugins/update-source.ts @@ -710,11 +710,18 @@ export function resolveClawHubUpdateSpecs(params: { fallbackSpec?: string; fallbackLabel?: string; } { - if (!params.officialSpecOverride && !params.record.clawhubPackage) { + const clawhubPackage = + params.record.clawhubPackage ?? + parseClawHubPluginSpec(params.record.spec ?? "")?.name ?? + parseClawHubPluginSpec(params.record.resolvedSpec ?? "")?.name; + if (!params.officialSpecOverride && !clawhubPackage) { return {}; } const recordSpec = - params.officialSpecOverride ?? params.record.spec ?? `clawhub:${params.record.clawhubPackage}`; + params.officialSpecOverride ?? + params.record.spec ?? + params.record.resolvedSpec ?? + `clawhub:${clawhubPackage}`; return resolveClawHubInstallSpecsForUpdateChannel({ spec: recordSpec, updateChannel: params.updateChannel, diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index 0bdf26fd6543..747891037532 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -50,6 +50,11 @@ const installPluginFromGitSpecMock = vi.fn(); const resolveBundledPluginSourcesMock = vi.fn(); const runCommandWithTimeoutMock = vi.fn(); const validatePackageExtensionEntriesForInstallMock = vi.fn(); +const markClawPackageIndependentlyOwnedMock = vi.fn(); +const withClawPackageLifecycleLeaseMock = vi.fn( + async (_artifact: unknown, operation: () => Promise, _options?: unknown) => + await operation(), +); const tempDirs: string[] = []; vi.mock("./install.js", () => ({ @@ -86,6 +91,19 @@ vi.mock("./clawhub.js", () => ({ installPluginFromClawHub: (...args: unknown[]) => installPluginFromClawHubMock(...args), })); +vi.mock("../state/claw-package-adoption.js", () => ({ + markClawPackageIndependentlyOwned: (...args: unknown[]) => + markClawPackageIndependentlyOwnedMock(...args), +})); + +vi.mock("../state/claw-package-lifecycle-lease.js", () => ({ + withClawPackageLifecycleLease: ( + artifact: unknown, + operation: () => Promise, + options?: unknown, + ) => withClawPackageLifecycleLeaseMock(artifact, operation, options), +})); + vi.mock("./bundled-sources.js", () => ({ resolveBundledPluginSources: (...args: unknown[]) => resolveBundledPluginSourcesMock(...args), })); @@ -538,6 +556,12 @@ describe("updateNpmInstalledPlugins", () => { resolveBundledPluginSourcesMock.mockReturnValue(new Map()); runCommandWithTimeoutMock.mockReset(); validatePackageExtensionEntriesForInstallMock.mockReset(); + markClawPackageIndependentlyOwnedMock.mockReset(); + withClawPackageLifecycleLeaseMock + .mockReset() + .mockImplementation( + async (_artifact: unknown, operation: () => Promise) => await operation(), + ); const installPath = createInstalledPackageDir({ name: "@martian-engineering/lossless-claw", version: "0.9.0", @@ -4051,27 +4075,41 @@ describe("updateNpmInstalledPlugins", () => { }, }); + const config = createClawHubInstallConfig({ + pluginId: "demo", + installPath: "/tmp/demo", + clawhubUrl: "https://clawhub.ai", + clawhubPackage: "demo", + clawhubFamily: "code-plugin", + clawhubChannel: "official", + }); + delete config.plugins?.installs?.demo?.clawhubPackage; + config.plugins!.installs!.demo!.resolvedSpec = "clawhub:demo@1.2.3"; + delete config.plugins?.installs?.demo?.spec; const result = await updateNpmInstalledPlugins({ - config: createClawHubInstallConfig({ - pluginId: "demo", - installPath: "/tmp/demo", - clawhubUrl: "https://clawhub.ai", - clawhubPackage: "demo", - clawhubFamily: "code-plugin", - clawhubChannel: "official", - }), + config, pluginIds: ["demo"], timeoutMs: 1_800_000, }); - expect(clawHubInstallCall()?.spec).toBe("clawhub:demo"); + expect(clawHubInstallCall()?.spec).toBe("clawhub:demo@1.2.3"); expect(clawHubInstallCall()?.baseUrl).toBe("https://clawhub.ai"); expect(clawHubInstallCall()?.expectedPluginId).toBe("demo"); expect(clawHubInstallCall()?.mode).toBe("update"); expect(clawHubInstallCall()?.timeoutMs).toBe(1_800_000); + expect(withClawPackageLifecycleLeaseMock).toHaveBeenCalledWith( + { kind: "plugin", source: "clawhub", ref: "demo" }, + expect.any(Function), + { required: true }, + ); + expect(markClawPackageIndependentlyOwnedMock).toHaveBeenCalledWith({ + kind: "plugin", + source: "clawhub", + ref: "demo", + }); expectRecordFields(result.config.plugins?.installs?.demo, { source: "clawhub", - spec: "clawhub:demo", + spec: "clawhub:demo@1.2.3", installPath: "/tmp/demo", version: "1.2.4", clawhubPackage: "demo", @@ -4090,6 +4128,30 @@ describe("updateNpmInstalledPlugins", () => { }); }); + it("records a busy ClawHub lifecycle lease as one plugin update failure", async () => { + withClawPackageLifecycleLeaseMock.mockRejectedValueOnce(new Error("package busy")); + const result = await updateNpmInstalledPlugins({ + config: createClawHubInstallConfig({ + pluginId: "demo", + installPath: "/tmp/demo", + clawhubUrl: "https://clawhub.ai", + clawhubPackage: "demo", + clawhubFamily: "code-plugin", + clawhubChannel: "official", + }), + pluginIds: ["demo"], + }); + + expect(result.outcomes).toContainEqual( + expect.objectContaining({ + pluginId: "demo", + status: "error", + message: expect.stringContaining("package busy"), + }), + ); + expect(installPluginFromClawHubMock).not.toHaveBeenCalled(); + }); + it("tries ClawHub beta for default ClawHub specs on beta channel without persisting the beta tag", async () => { installPluginFromClawHubMock.mockResolvedValue( createSuccessfulClawHubUpdateResult({ diff --git a/src/skills/lifecycle/clawhub.test.ts b/src/skills/lifecycle/clawhub.test.ts index 6f374e6a42e9..a35936abdfab 100644 --- a/src/skills/lifecycle/clawhub.test.ts +++ b/src/skills/lifecycle/clawhub.test.ts @@ -25,7 +25,8 @@ const evaluateSkillInstallPolicyMock = vi.fn(); const pathExistsMock = vi.fn(); const tempDirs = createTrackedTempDirs(); -vi.mock("../../infra/clawhub.js", () => ({ +vi.mock("../../infra/clawhub.js", async (importOriginal) => ({ + ...(await importOriginal()), fetchClawHubSkillDetail: fetchClawHubSkillDetailMock, fetchClawHubSkillInstallResolution: fetchClawHubSkillInstallResolutionMock, fetchClawHubSkillVerification: fetchClawHubSkillVerificationMock, @@ -61,6 +62,7 @@ vi.mock("../../infra/fs-safe.js", () => ({ const { installSkillFromClawHub, + preflightSkillFromClawHub, readVerifiedClawHubSkillSourceUrl, resolveClawHubSkillStatusLinkSync, resolveClawHubSkillVerificationTarget, @@ -324,6 +326,55 @@ describe("skills-clawhub", () => { }); }); + it("resolves an exact skill artifact without mutating the workspace", async () => { + const integrity = `sha256-${Buffer.from("a".repeat(64), "hex").toString("base64")}`; + downloadClawHubSkillArchiveMock.mockResolvedValueOnce({ + archivePath: "/tmp/agentreceipt.zip", + integrity, + sha256Hex: "a".repeat(64), + artifact: "archive", + cleanup: archiveCleanupMock, + }); + + const result = await preflightSkillFromClawHub({ + workspaceDir: "/tmp/workspace", + slug: "agentreceipt", + version: "1.0.0", + acknowledgeClawHubRisk: true, + }); + + expect(result).toEqual({ ok: true, action: "install", integrity }); + expect(withExtractedArchiveRootMock).not.toHaveBeenCalled(); + expect(installPackageDirMock).not.toHaveBeenCalled(); + expect(archiveCleanupMock).toHaveBeenCalledTimes(1); + }); + + it("rejects a downloaded skill whose bytes do not match the consented plan", async () => { + const observed = `sha256-${Buffer.from("b".repeat(64), "hex").toString("base64")}`; + const expected = `sha256-${Buffer.from("a".repeat(64), "hex").toString("base64")}`; + downloadClawHubSkillArchiveMock.mockResolvedValueOnce({ + archivePath: "/tmp/agentreceipt.zip", + integrity: observed, + sha256Hex: "b".repeat(64), + artifact: "archive", + cleanup: archiveCleanupMock, + }); + + const result = await installSkillFromClawHub({ + workspaceDir: "/tmp/workspace", + slug: "agentreceipt", + version: "1.0.0", + expectedIntegrity: `sha256:${"a".repeat(64)}`, + }); + + expect(result).toEqual({ + ok: false, + error: `ClawHub archive integrity mismatch: expected ${expected}, got ${observed}.`, + }); + expect(withExtractedArchiveRootMock).not.toHaveBeenCalled(); + expect(archiveCleanupMock).toHaveBeenCalledTimes(1); + }); + it("bypasses ClawHub trust checks for official skill install resolutions", async () => { fetchClawHubSkillInstallResolutionMock.mockResolvedValueOnce({ ok: true, diff --git a/src/skills/lifecycle/clawhub.ts b/src/skills/lifecycle/clawhub.ts index abf1fa66a49a..e11b99b537a8 100644 --- a/src/skills/lifecycle/clawhub.ts +++ b/src/skills/lifecycle/clawhub.ts @@ -16,6 +16,7 @@ import { fetchClawHubSkillInstallResolution, fetchClawHubSkillVerification, isDefaultClawHubBaseUrl, + normalizeClawHubSha256Integrity, reportClawHubSkillInstallTelemetry, resolveClawHubBaseUrl, searchClawHubSkills, @@ -235,6 +236,7 @@ type ClawHubInstallParams = { slug: string; ownerHandle?: string; version?: string; + expectedIntegrity?: string; baseUrl?: string; force?: boolean; forceInstall?: boolean; @@ -244,6 +246,36 @@ type ClawHubInstallParams = { config?: OpenClawConfig; }; +function normalizeExpectedArtifactIntegrity(expectedIntegrity: string): string; +function normalizeExpectedArtifactIntegrity(expectedIntegrity: undefined): undefined; +function normalizeExpectedArtifactIntegrity( + expectedIntegrity: string | undefined, +): string | undefined; +function normalizeExpectedArtifactIntegrity( + expectedIntegrity: string | undefined, +): string | undefined { + if (expectedIntegrity === undefined) { + return undefined; + } + const normalized = normalizeClawHubSha256Integrity(expectedIntegrity); + if (!normalized) { + throw new Error(`Invalid expected ClawHub archive integrity: ${expectedIntegrity}`); + } + return normalized; +} + +function assertDownloadedArtifactIntegrity( + archive: ClawHubDownloadResult, + expectedIntegrity: string | undefined, +): void { + const normalizedExpected = normalizeExpectedArtifactIntegrity(expectedIntegrity); + if (normalizedExpected && archive.integrity !== normalizedExpected) { + throw new Error( + `ClawHub archive integrity mismatch: expected ${normalizedExpected}, got ${archive.integrity}.`, + ); + } +} + type ClawHubOfficialFlagContainer = { channel?: unknown; official?: unknown; @@ -1310,6 +1342,7 @@ async function performClawHubSkillInstall( params: ClawHubInstallParams, ): Promise { try { + normalizeExpectedArtifactIntegrity(params.expectedIntegrity); const targetDir = resolveWorkspaceSkillInstallDir(params.workspaceDir, params.slug); const registry = resolveClawHubBaseUrl(params.baseUrl); const clawhubAuthority = isDefaultClawHubBaseUrl(params.baseUrl) ? "openclaw" : "third-party"; @@ -1415,6 +1448,7 @@ async function performClawHubSkillInstall( } } try { + assertDownloadedArtifactIntegrity(archive, params.expectedIntegrity); if (!params.version) { if (!latestResolution) { throw new Error(`Skill "${params.slug}" has no install resolution.`); @@ -1565,6 +1599,132 @@ async function installTrackedSkillFromClawHub( } } +type ClawHubSkillInstallPreflightResult = + | { ok: true; action: "install" | "reuse"; integrity: string; warning?: string } + | { ok: false; code: string; error: string }; + +async function preflightSkillOwnerState(params: { + workspaceDir: string; + requested: ClawHubSkillRef; + requestedLabel: string; + version: string; + integrity: string; +}): Promise { + const targetDir = resolveWorkspaceSkillInstallDir(params.workspaceDir, params.requested.slug); + if (!(await pathExists(targetDir))) { + return { ok: true, action: "install", integrity: params.integrity }; + } + const status = resolveClawHubSkillStatusLinkSync({ + workspaceDir: params.workspaceDir, + skillDir: targetDir, + skillKey: params.requested.slug, + }); + if ( + status?.status === "linked" && + status.installedVersion === params.version && + status.ownerHandle === params.requested.ownerHandle && + status.artifact?.integrity === params.integrity + ) { + return { ok: true, action: "reuse", integrity: params.integrity }; + } + return { + ok: false, + code: "skill_version_conflict", + error: `Skill ${params.requestedLabel}@${params.version} conflicts with the existing workspace skill at ${targetDir}.`, + }; +} + +export async function preflightSkillFromClawHub(params: { + workspaceDir: string; + slug: string; + version: string; + expectedIntegrity?: string; + baseUrl?: string; + acknowledgeClawHubRisk?: boolean; + onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise; + logger?: Logger; +}): Promise { + try { + const requested = parseRequestedClawHubSkillRef(params.slug); + const resolved = await resolveInstallVersion({ + slug: requested.slug, + ...(requested.ownerHandle ? { ownerHandle: requested.ownerHandle } : {}), + version: params.version, + baseUrl: params.baseUrl, + }); + if (resolved.version !== params.version) { + return { + ok: false, + code: "skill_version_resolution_mismatch", + error: `Skill ${params.slug}@${params.version} resolved to ${resolved.version}.`, + }; + } + const official = isDefaultOfficialClawHubSkillSource({ + baseUrl: params.baseUrl, + detail: resolved.detail, + }); + const trust = await ensureClawHubSkillTrustAcknowledged({ + workspaceDir: params.workspaceDir, + slug: requested.slug, + ...(requested.ownerHandle ? { ownerHandle: requested.ownerHandle } : {}), + version: resolved.version, + baseUrl: params.baseUrl, + acknowledgeClawHubRisk: params.acknowledgeClawHubRisk, + onClawHubRisk: params.onClawHubRisk, + logger: params.logger, + skipClawHubTrustCheck: official, + }); + if (!trust.ok) { + return { + ok: false, + code: trust.code ?? "skill_trust_required", + error: trust.error, + }; + } + + if (params.expectedIntegrity) { + const integrity = normalizeExpectedArtifactIntegrity(params.expectedIntegrity); + const owner = await preflightSkillOwnerState({ + workspaceDir: params.workspaceDir, + requested, + requestedLabel: params.slug, + version: resolved.version, + integrity, + }); + return owner.ok && trust.warning ? { ...owner, warning: trust.warning } : owner; + } + + const archive = await downloadClawHubSkillArchive({ + slug: requested.slug, + ...(requested.ownerHandle ? { ownerHandle: requested.ownerHandle } : {}), + version: resolved.version, + baseUrl: params.baseUrl, + }); + try { + const integrity = normalizeClawHubSha256Integrity(archive.integrity); + if (!integrity) { + return { + ok: false, + code: "skill_integrity_unavailable", + error: `Skill ${params.slug}@${params.version} did not resolve a valid artifact integrity.`, + }; + } + const owner = await preflightSkillOwnerState({ + workspaceDir: params.workspaceDir, + requested, + requestedLabel: params.slug, + version: resolved.version, + integrity, + }); + return owner.ok && trust.warning ? { ...owner, warning: trust.warning } : owner; + } finally { + await archive.cleanup().catch(() => undefined); + } + } catch (err) { + return { ok: false, code: "skill_preflight_failed", error: formatErrorMessage(err) }; + } +} + async function resolveTrackedUpdateTarget(params: { workspaceDir: string; slug: string; @@ -1595,6 +1755,7 @@ export async function installSkillFromClawHub(params: { workspaceDir: string; slug: string; version?: string; + expectedIntegrity?: string; baseUrl?: string; force?: boolean; forceInstall?: boolean; @@ -1602,6 +1763,8 @@ export async function installSkillFromClawHub(params: { onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise; logger?: Logger; config?: OpenClawConfig; + /** True when a Claw lifecycle caller already owns package coordination. */ + clawManaged?: boolean; }): Promise { return await installRequestedSkillFromClawHub(params); } diff --git a/src/state/claw-package-adoption.ts b/src/state/claw-package-adoption.ts new file mode 100644 index 000000000000..e77bedd085cd --- /dev/null +++ b/src/state/claw-package-adoption.ts @@ -0,0 +1,66 @@ +import { existsSync } from "node:fs"; +import { + runOpenClawStateWriteTransaction, + type OpenClawStateDatabaseOptions, +} from "./openclaw-state-db.js"; +import { resolveOpenClawStateSqlitePath } from "./openclaw-state-db.paths.js"; + +type ClawPackageAdoption = { + kind: "skill" | "plugin"; + source: "clawhub"; + ref: string; + version?: string; + workspace?: string; +}; + +/** Records an explicit non-Claw claim through the canonical package owner. */ +export function markClawPackageIndependentlyOwned( + artifact: ClawPackageAdoption, + options: OpenClawStateDatabaseOptions & { nowMs?: number } = {}, +): number { + const databasePath = options.path ?? resolveOpenClawStateSqlitePath(options.env ?? process.env); + if (!existsSync(databasePath)) { + return 0; + } + const nowMs = options.nowMs ?? Date.now(); + try { + return runOpenClawStateWriteTransaction(({ db }) => { + const workspaceScope = + artifact.kind === "skill" + ? `AND agent_id IN ( + SELECT agent_id FROM claw_installs WHERE workspace = @workspace + )` + : ""; + const versionScope = artifact.version ? "AND package_version = @package_version" : ""; + const statement = + db /* sqlite-allow-raw: record a current non-Claw package owner after direct install. */ + .prepare( + `UPDATE claw_package_refs + SET independent_owner = 1, updated_at_ms = @updated_at_ms + WHERE package_kind = @package_kind + AND package_source = @package_source + AND package_ref = @package_ref + ${versionScope} + AND independent_owner <> 1 + ${workspaceScope}`, + ); + const bindings: Record = { + package_kind: artifact.kind, + package_source: artifact.source, + package_ref: artifact.ref, + updated_at_ms: nowMs, + }; + if (artifact.version) { + bindings.package_version = artifact.version; + } + if (artifact.kind === "skill") { + bindings.workspace = artifact.workspace ?? ""; + } + const result = statement.run(bindings); + return Number(result.changes); + }, options); + } catch { + // The canonical install already succeeded. Removal also checks its newer owner timestamp. + return 0; + } +} diff --git a/src/state/claw-package-lifecycle-lease.ts b/src/state/claw-package-lifecycle-lease.ts new file mode 100644 index 000000000000..40f8fcac25e2 --- /dev/null +++ b/src/state/claw-package-lifecycle-lease.ts @@ -0,0 +1,219 @@ +import { randomUUID } from "node:crypto"; +import { resolve } from "node:path"; +import type { DatabaseSync } from "node:sqlite"; +import { formatErrorMessage } from "../infra/errors.js"; +import { + executeSqliteQuerySync, + executeSqliteQueryTakeFirstSync, + getNodeSqliteKysely, +} from "../infra/kysely-sync.js"; +import type { DB as OpenClawStateKyselyDatabase } from "./openclaw-state-db.generated.js"; +import { + runOpenClawStateWriteTransaction, + type OpenClawStateDatabaseOptions, +} from "./openclaw-state-db.js"; +import { resolveOpenClawStateSqlitePath } from "./openclaw-state-db.paths.js"; + +type ClawPackageLifecycleDatabase = Pick; + +type ClawPackageLifecycleArtifact = + | { kind: "plugin"; source: "clawhub"; ref: string } + | { kind: "skill"; source: "clawhub"; ref: string; workspace: string }; + +type ClawPackageLifecycleLease = { + heartbeat: (nowMs?: number) => void; + release: () => void; +}; + +export type MaintainedClawPackageLifecycleLease = { + assertCurrent: () => void; + release: () => void; +}; + +type ClawPackageLifecycleLeaseOptions = OpenClawStateDatabaseOptions & { + nowMs?: number; + owner?: string; + required?: boolean; +}; + +const LEASE_SCOPE = "claw-package-lifecycle"; +const LEASE_TTL_MS = 5 * 60_000; + +class ClawPackageLifecycleBusyError extends Error { + constructor(message: string) { + super(message); + this.name = "ClawPackageLifecycleBusyError"; + } +} + +function kyselyFor(db: DatabaseSync) { + return getNodeSqliteKysely(db); +} + +function packageLeaseKey(artifact: ClawPackageLifecycleArtifact): string { + if (artifact.kind === "skill") { + return `skill:${artifact.source}:workspace:${resolve(artifact.workspace)}`; + } + return `${artifact.kind}:${artifact.source}:${artifact.ref}`; +} + +/** Serializes shared package ownership and artifact mutation across processes. */ +export function acquireClawPackageLifecycleLease( + artifact: ClawPackageLifecycleArtifact, + options: ClawPackageLifecycleLeaseOptions = {}, +): ClawPackageLifecycleLease | null { + const env = options.env ?? process.env; + const databasePath = options.path ?? resolveOpenClawStateSqlitePath(env); + const nowMs = options.nowMs ?? Date.now(); + const expiresAt = nowMs + LEASE_TTL_MS; + const owner = options.owner ?? randomUUID(); + const leaseKey = packageLeaseKey(artifact); + let acquired = false; + + try { + runOpenClawStateWriteTransaction( + ({ db }) => { + const state = kyselyFor(db); + executeSqliteQuerySync( + db, + state + .deleteFrom("state_leases") + .where("scope", "=", LEASE_SCOPE) + .where("lease_key", "=", leaseKey) + .where("expires_at", "<=", nowMs), + ); + const existing = executeSqliteQueryTakeFirstSync( + db, + state + .selectFrom("state_leases") + .select("expires_at") + .where("scope", "=", LEASE_SCOPE) + .where("lease_key", "=", leaseKey), + ); + if (existing) { + throw new ClawPackageLifecycleBusyError( + `Package ${artifact.ref} is being changed by another OpenClaw lifecycle; retry after ${new Date(existing.expires_at ?? expiresAt).toISOString()}.`, + ); + } + executeSqliteQuerySync( + db, + state.insertInto("state_leases").values({ + scope: LEASE_SCOPE, + lease_key: leaseKey, + owner, + expires_at: expiresAt, + heartbeat_at: nowMs, + payload_json: JSON.stringify(artifact), + created_at: nowMs, + updated_at: nowMs, + }), + ); + acquired = true; + }, + { env, path: databasePath }, + ); + } catch (error) { + if (options.required || error instanceof ClawPackageLifecycleBusyError) { + throw error; + } + return null; + } + + if (!acquired) { + return null; + } + return { + heartbeat: (heartbeatNowMs = Date.now()) => { + const heartbeatExpiresAt = heartbeatNowMs + LEASE_TTL_MS; + runOpenClawStateWriteTransaction( + ({ db }) => { + const result = executeSqliteQuerySync( + db, + kyselyFor(db) + .updateTable("state_leases") + .set({ + expires_at: heartbeatExpiresAt, + heartbeat_at: heartbeatNowMs, + updated_at: heartbeatNowMs, + }) + .where("scope", "=", LEASE_SCOPE) + .where("lease_key", "=", leaseKey) + .where("owner", "=", owner) + .where("expires_at", ">", heartbeatNowMs), + ); + if (result.numAffectedRows !== 1n) { + throw new Error(`Package lifecycle lease was lost for ${artifact.ref}.`); + } + }, + { env, path: databasePath }, + ); + }, + release: () => { + runOpenClawStateWriteTransaction( + ({ db }) => { + executeSqliteQuerySync( + db, + kyselyFor(db) + .deleteFrom("state_leases") + .where("scope", "=", LEASE_SCOPE) + .where("lease_key", "=", leaseKey) + .where("owner", "=", owner), + ); + }, + { env, path: databasePath }, + ); + }, + }; +} + +/** Renews an acquired lease while an asynchronous package mutation is in flight. */ +export function maintainClawPackageLifecycleLease( + lease: ClawPackageLifecycleLease, +): MaintainedClawPackageLifecycleLease { + let heartbeatError: unknown; + const heartbeat = setInterval(() => { + try { + lease.heartbeat(); + } catch (error) { + heartbeatError ??= error; + } + }, LEASE_TTL_MS / 3); + heartbeat.unref(); + return { + assertCurrent: () => { + if (heartbeatError) { + throw heartbeatError instanceof Error + ? heartbeatError + : new Error(formatErrorMessage(heartbeatError)); + } + lease.heartbeat(); + }, + release: () => { + clearInterval(heartbeat); + lease.release(); + }, + }; +} + +export async function withClawPackageLifecycleLease( + artifact: ClawPackageLifecycleArtifact, + operation: () => Promise, + options: ClawPackageLifecycleLeaseOptions = {}, +): Promise { + const lease = acquireClawPackageLifecycleLease(artifact, options); + if (!lease) { + return await operation(); + } + const maintained = maintainClawPackageLifecycleLease(lease); + try { + const result = await operation(); + maintained.assertCurrent(); + return result; + } finally { + try { + maintained.release(); + } catch { + // Expiry recovers a lease whose cleanup cannot reach the shared database. + } + } +} diff --git a/src/state/openclaw-state-db-schema-additive.ts b/src/state/openclaw-state-db-schema-additive.ts index 91e6f8d36ccf..bcd830ddb492 100644 --- a/src/state/openclaw-state-db-schema-additive.ts +++ b/src/state/openclaw-state-db-schema-additive.ts @@ -92,6 +92,14 @@ function backfillLegacyManagedImageRoots(db: DatabaseSync): void { } export function ensureAdditiveStateColumns(db: DatabaseSync): void { + if (ensureColumn(db, "claw_package_refs", "updated_at_ms INTEGER NOT NULL DEFAULT 0")) { + db.exec("UPDATE claw_package_refs SET updated_at_ms = installed_at_ms;"); + } + ensureColumn( + db, + "claw_package_refs", + "package_integrity TEXT NOT NULL DEFAULT 'sha256:0000000000000000000000000000000000000000000000000000000000000000'", + ); const addedDiagnosticEventSequence = ensureColumn( db, "diagnostic_events", diff --git a/src/state/openclaw-state-db.generated.d.ts b/src/state/openclaw-state-db.generated.d.ts index e8a3b44d6f9a..2bae6868bbc6 100644 --- a/src/state/openclaw-state-db.generated.d.ts +++ b/src/state/openclaw-state-db.generated.d.ts @@ -277,6 +277,23 @@ export interface ClawInstalls { workspace: string; } +export interface ClawPackageRefs { + agent_id: string; + claw_name: string; + independent_owner: number; + installed_at_ms: number; + origin: string; + package_integrity: string; + package_kind: string; + package_ref: string; + package_source: string; + package_status: string; + package_version: string; + relationship: string; + schema_version: string; + updated_at_ms: number; +} + export interface ClawWorkspaceFiles { agent_id: string; content_digest: string; @@ -1390,6 +1407,7 @@ export interface DB { channel_pairing_allow_entries: ChannelPairingAllowEntries; channel_pairing_requests: ChannelPairingRequests; claw_installs: ClawInstalls; + claw_package_refs: ClawPackageRefs; claw_workspace_files: ClawWorkspaceFiles; clawhub_promotion_claims: ClawhubPromotionClaims; clawhub_promotions_feed_state: ClawhubPromotionsFeedState; diff --git a/src/state/openclaw-state-db.test.ts b/src/state/openclaw-state-db.test.ts index f738ea524dd0..991bb87e21dc 100644 --- a/src/state/openclaw-state-db.test.ts +++ b/src/state/openclaw-state-db.test.ts @@ -2291,6 +2291,50 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're expect(columns.map((column) => column.name)).toContain("startup_reason"); }); + it("adds and backfills Claw package update timestamps in existing state databases", () => { + const stateDir = createTempStateDir(); + const database = openOpenClawStateDatabase({ + env: { OPENCLAW_STATE_DIR: stateDir }, + }); + const databasePath = database.path; + database.db + .prepare( + "INSERT INTO claw_package_refs (" + + "agent_id, package_kind, package_source, package_ref, package_version, " + + "package_integrity, schema_version, claw_name, package_status, relationship, origin, independent_owner, installed_at_ms, updated_at_ms" + + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .run( + "incident", + "plugin", + "clawhub", + "@owner/audit", + "2.0.1", + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "openclaw.clawPackageRef.v1", + "incident-claw", + "complete", + "referenced", + "claw-introduced", + 0, + 1234, + 5678, + ); + closeOpenClawStateDatabaseForTest(); + + const { DatabaseSync } = requireNodeSqlite(); + const legacyDb = new DatabaseSync(databasePath); + legacyDb.exec("ALTER TABLE claw_package_refs DROP COLUMN updated_at_ms"); + legacyDb.close(); + + const reopened = openOpenClawStateDatabase({ + env: { OPENCLAW_STATE_DIR: stateDir }, + }); + expect( + reopened.db.prepare("SELECT installed_at_ms, updated_at_ms FROM claw_package_refs").get(), + ).toEqual({ installed_at_ms: 1234, updated_at_ms: 1234 }); + }); + it("adds worker bootstrap lifecycle columns to existing state databases", () => { const stateDir = createTempStateDir(); const database = openOpenClawStateDatabase({ diff --git a/src/state/openclaw-state-schema.generated.ts b/src/state/openclaw-state-schema.generated.ts index 87cba36ad83b..60fe5d23e304 100644 --- a/src/state/openclaw-state-schema.generated.ts +++ b/src/state/openclaw-state-schema.generated.ts @@ -1966,4 +1966,22 @@ CREATE TABLE IF NOT EXISTS claw_workspace_files ( created_at_ms INTEGER NOT NULL, updated_at_ms INTEGER NOT NULL, PRIMARY KEY (agent_id, target_path) +) STRICT; + +CREATE TABLE IF NOT EXISTS claw_package_refs ( + agent_id TEXT NOT NULL, + package_kind TEXT NOT NULL, + package_source TEXT NOT NULL, + package_ref TEXT NOT NULL, + package_version TEXT NOT NULL, + package_integrity TEXT NOT NULL, + schema_version TEXT NOT NULL, + claw_name TEXT NOT NULL, + package_status TEXT NOT NULL, + relationship TEXT NOT NULL CHECK (relationship IN ('managed', 'referenced')), + origin TEXT NOT NULL CHECK (origin IN ('claw-introduced', 'pre-existing')), + independent_owner INTEGER NOT NULL CHECK (independent_owner IN (0, 1)), + installed_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL, + PRIMARY KEY (agent_id, package_kind, package_source, package_ref, package_version) ) STRICT;\n`; diff --git a/src/state/openclaw-state-schema.sql b/src/state/openclaw-state-schema.sql index 25f42a725ffe..631ddd252846 100644 --- a/src/state/openclaw-state-schema.sql +++ b/src/state/openclaw-state-schema.sql @@ -1962,3 +1962,21 @@ CREATE TABLE IF NOT EXISTS claw_workspace_files ( updated_at_ms INTEGER NOT NULL, PRIMARY KEY (agent_id, target_path) ) STRICT; + +CREATE TABLE IF NOT EXISTS claw_package_refs ( + agent_id TEXT NOT NULL, + package_kind TEXT NOT NULL, + package_source TEXT NOT NULL, + package_ref TEXT NOT NULL, + package_version TEXT NOT NULL, + package_integrity TEXT NOT NULL, + schema_version TEXT NOT NULL, + claw_name TEXT NOT NULL, + package_status TEXT NOT NULL, + relationship TEXT NOT NULL CHECK (relationship IN ('managed', 'referenced')), + origin TEXT NOT NULL CHECK (origin IN ('claw-introduced', 'pre-existing')), + independent_owner INTEGER NOT NULL CHECK (independent_owner IN (0, 1)), + installed_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL, + PRIMARY KEY (agent_id, package_kind, package_source, package_ref, package_version) +) STRICT;