From 7d159fdc7700aeb597d414ea5a3e7aabf81e49f9 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Wed, 22 Jul 2026 17:42:42 -0700 Subject: [PATCH] Apply consented grouped Claw updates (#102982) * Apply consented grouped Claw updates * test(claws): cover update application * docs(claws): document consented updates * fix(claws): preserve uncertain update state * fix(claws): preserve inherited lifecycle guards * fix(claws): derive update package contracts * fix(claws): type update package lookup * test(claws): complete update capability fixtures * refactor(claws): share cron payload builder for updates * refactor(claws): extract update plan summary * fix(claws): activate cron after update dependencies * fix(claws): preserve state after uncertain cron update * fix(claws): persist partial update provenance * fix(claws): preserve update ownership metadata * test(claws): track update apply temp dirs * test(claws): align update cron mock result * fix(claws): update apply writes agent entries --------- Co-authored-by: Patrick Erichsen --- docs/cli/claws.md | 24 +- docs/docs_map.md | 2 +- src/claws/cron-update.test.ts | 260 ++++++++++ src/claws/cron-update.ts | 213 ++++++++ src/claws/cron.ts | 49 +- src/claws/lifecycle.ts | 2 +- src/claws/mcp-update.test.ts | 341 +++++++++++++ src/claws/mcp-update.ts | 214 ++++++++ src/claws/mcp.ts | 50 +- src/claws/package-update-provenance.ts | 108 ++++ src/claws/package-update.test.ts | 439 +++++++++++++++++ src/claws/package-update.ts | 259 ++++++++++ src/claws/packages.ts | 3 + src/claws/provenance.test.ts | 136 ++++- src/claws/provenance.ts | 124 ++++- src/claws/schema-conformance.test.ts | 36 ++ src/claws/schema-portability.ts | 34 +- src/claws/update-apply.test.ts | 657 +++++++++++++++++++++++++ src/claws/update-apply.ts | 550 +++++++++++++++++++++ src/claws/update-plan-summary.ts | 21 + src/claws/update-plan-types.ts | 1 + src/claws/update-plan.ts | 37 +- src/claws/workspace-update.test.ts | 123 +++++ src/claws/workspace-update.ts | 207 ++++++++ src/claws/workspace.ts | 80 ++- src/cli/claws-cli.runtime.ts | 161 +----- src/cli/claws-cli.test.ts | 103 +++- src/cli/claws-cli.ts | 10 +- src/cli/claws-update-cli.runtime.ts | 233 +++++++++ src/config/mcp-config.test.ts | 27 + src/config/mcp-config.ts | 15 + 31 files changed, 4274 insertions(+), 245 deletions(-) create mode 100644 src/claws/cron-update.test.ts create mode 100644 src/claws/cron-update.ts create mode 100644 src/claws/mcp-update.test.ts create mode 100644 src/claws/mcp-update.ts create mode 100644 src/claws/package-update-provenance.ts create mode 100644 src/claws/package-update.test.ts create mode 100644 src/claws/package-update.ts create mode 100644 src/claws/update-apply.test.ts create mode 100644 src/claws/update-apply.ts create mode 100644 src/claws/update-plan-summary.ts create mode 100644 src/claws/workspace-update.test.ts create mode 100644 src/claws/workspace-update.ts create mode 100644 src/cli/claws-update-cli.runtime.ts diff --git a/docs/cli/claws.md b/docs/cli/claws.md index be4bd28773c4..024f6b9cd1a5 100644 --- a/docs/cli/claws.md +++ b/docs/cli/claws.md @@ -197,7 +197,7 @@ This is not a reference count. Ordinary plugin, skill, and agent commands keep their existing behavior; Claws add provenance and guarded lifecycle operations on top. -## Preview an update +## Update an installed Claw By default, update uses the source recorded when the Claw was added. Use `--from` when that source moved or when testing another package directory: @@ -218,8 +218,24 @@ warning are included. Removing a package declaration releases this Claw's edge without uninstalling the artifact during update. The eventual exact `planIntegrity` confirmation binds that disclosed set as well as ordinary content changes. Hosts may use the same records for a separate dialog or an -aggregate multi-agent review. This stage is read-only: `claws update` requires -`--dry-run` and does not apply the plan. +aggregate multi-agent review. Apply the exact reviewed plan with explicit +consent: + +```bash +openclaw claws update incident-triage \ + --yes \ + --plan-integrity +``` + +OpenClaw rebuilds the plan and compare-and-swaps owned state before each +mutation. Removed package declarations release dependency edges without +uninstalling artifacts. Cron changes reread the live scheduler definition and +stop on operator drift. Package installers, source-config writers, and the Gateway scheduler +are not one transaction. If compensation cannot be proven after an external +mutation, OpenClaw reports error code `update_partial` with structured +`status: partial`, preserves uncertain provenance, +and stops. Inspect `claws status`, the affected resource, and `openclaw doctor`; +then preview again before retrying or removing anything. ## Remove an installed Claw @@ -273,7 +289,7 @@ agents, credentials, sessions, and unowned local state are excluded. | `claws inspect ` | Validate a package directory or JSON manifest. | | `claws add ` | Preview or create one new agent and workspace. | | `claws status [claw-or-agent]` | Report installed state, ownership, and drift. | -| `claws update ` | Preview changes from the recorded or given source. | +| `claws update ` | Preview or apply changes from the selected source. | | `claws remove ` | Preview or remove the agent and eligible resources. | | `claws export --out ` | Create a portable package from an installed agent. | diff --git a/docs/docs_map.md b/docs/docs_map.md index d2ad98f80f5b..28ff91b2121a 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -1375,7 +1375,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Create a grouped manifest - H2: Inspect and preview - H2: Inspect installed state - - H2: Preview an update + - H2: Update an installed Claw - H2: Remove an installed Claw - H2: Export an installed agent - H2: Command reference diff --git a/src/claws/cron-update.test.ts b/src/claws/cron-update.test.ts new file mode 100644 index 000000000000..522c53d04a71 --- /dev/null +++ b/src/claws/cron-update.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, it, vi } from "vitest"; +import { normalizeCronJobCreate } from "../cron/normalize.js"; +import { applyClawCronUpdate } from "./cron-update.js"; +import { + CLAW_CRON_REF_SCHEMA_VERSION, + clawCronGatewayInput, + type PersistedClawCronRef, +} from "./cron.js"; +import { CLAW_OUTPUT_STABILITY, type ClawCronJob, type ClawManifest } from "./types.js"; +import { CLAW_UPDATE_PLAN_SCHEMA_VERSION, type ClawUpdatePlan } from "./update-plan.js"; + +const oldDaily: ClawCronJob = { + id: "daily", + schedule: { cron: "0 9 * * *", timezone: "UTC" }, + session: "main", + message: "Old daily", +}; +const newDaily: ClawCronJob = { ...oldDaily, message: "New daily" }; +const legacy: ClawCronJob = { + id: "legacy", + schedule: { cron: "0 8 * * *", timezone: "UTC" }, + session: "isolated", + message: "Legacy", +}; +const weekly: ClawCronJob = { + id: "weekly", + schedule: { cron: "0 9 * * 1", timezone: "UTC" }, + session: "main", + message: "Weekly", +}; + +function ref(job: ClawCronJob, schedulerJobId: string): PersistedClawCronRef { + return { + schemaVersion: CLAW_CRON_REF_SCHEMA_VERSION, + agentId: "worker", + manifestId: job.id, + declarationKey: `claw:worker:${job.id}`, + schedulerJobId, + status: "complete", + job, + createdAtMs: 10, + updatedAtMs: 10, + }; +} + +function cronReadView(agentId: string, value: PersistedClawCronRef) { + const normalized = normalizeCronJobCreate(clawCronGatewayInput(agentId, value)); + if (!normalized || !value.schedulerJobId) { + throw new Error("expected complete cron provenance"); + } + return { + ...normalized, + id: value.schedulerJobId, + createdAtMs: 1, + updatedAtMs: 1, + state: {}, + }; +} + +function plan(actions: ClawUpdatePlan["actions"]): ClawUpdatePlan { + return { + schemaVersion: CLAW_UPDATE_PLAN_SCHEMA_VERSION, + stability: CLAW_OUTPUT_STABILITY, + dryRun: true, + mutationAllowed: false, + planIntegrity: "sha256:update-plan", + found: true, + agentId: "worker", + currentClaw: { name: "@acme/worker", version: "1.0.0", integrity: "sha256:old" }, + targetClaw: { name: "@acme/worker", version: "2.0.0", integrity: "sha256:new" }, + summary: { + totalActions: actions.length, + added: actions.filter((action) => action.action === "add").length, + changed: actions.filter((action) => action.action === "change").length, + removed: actions.filter((action) => action.action === "remove").length, + released: actions.filter((action) => action.action === "release").length, + unchanged: 0, + manual: 0, + blocked: 0, + capabilityChanges: 0, + capabilityEscalations: 0, + }, + actions, + capabilityChanges: [], + blockers: [], + diagnostics: [], + }; +} + +function manifest(): ClawManifest { + return { + schemaVersion: 1, + agent: { id: "worker" }, + workspace: { bootstrapFiles: {}, files: [] }, + packages: [], + mcpServers: {}, + cronJobs: [newDaily, weekly], + }; +} + +describe("applyClawCronUpdate", () => { + it("converges changes and reverses add, change, and remove operations", async () => { + const add = vi.fn(async (input: Record) => { + const key = input.declarationKey; + if (key === "claw:worker:daily") { + return { id: "scheduler-daily" }; + } + if (key === "claw:worker:legacy") { + return { id: "scheduler-legacy-restored" }; + } + return { id: "scheduler-weekly" }; + }); + const remove = vi.fn(async () => ({ ok: true })); + const upsertRef = vi.fn(); + const deleteRef = vi.fn(); + const refs = [ref(oldDaily, "scheduler-daily"), ref(legacy, "scheduler-legacy")]; + const execution = await applyClawCronUpdate( + plan([ + { + kind: "cronJob", + id: "daily", + action: "change", + target: "scheduler-daily", + blocked: false, + reason: "changed", + }, + { + kind: "cronJob", + id: "weekly", + action: "add", + target: "claw:worker:weekly", + blocked: false, + reason: "added", + }, + { + kind: "cronJob", + id: "legacy", + action: "remove", + target: "scheduler-legacy", + blocked: false, + reason: "removed", + }, + ]), + manifest(), + { + cronGateway: { + add, + get: async (id) => + cronReadView("worker", refs.find((item) => item.schedulerJobId === id)!), + remove, + }, + readRefs: () => refs, + upsertRef, + deleteRef, + nowMs: 20, + }, + ); + + expect(execution.appliedIds).toEqual(["daily", "weekly", "legacy"]); + expect(remove).toHaveBeenCalledWith("scheduler-legacy"); + expect(upsertRef).toHaveBeenCalledTimes(5); + expect(deleteRef).toHaveBeenCalledTimes(1); + + await execution.rollback(); + + expect(remove).toHaveBeenCalledWith("scheduler-weekly"); + expect(add).toHaveBeenCalledTimes(4); + expect(upsertRef).toHaveBeenCalledTimes(7); + expect(deleteRef).toHaveBeenCalledTimes(2); + }); + + it("removes a non-converged replacement and fails closed", async () => { + const remove = vi.fn(async () => ({ ok: true })); + await expect( + applyClawCronUpdate( + plan([ + { + kind: "cronJob", + id: "daily", + action: "change", + target: "scheduler-daily", + blocked: false, + reason: "changed", + }, + ]), + manifest(), + { + cronGateway: { + add: async () => ({ id: "unexpected-copy" }), + get: async () => cronReadView("worker", ref(oldDaily, "scheduler-daily")), + remove, + }, + readRefs: () => [ref(oldDaily, "scheduler-daily")], + upsertRef: vi.fn(), + }, + ), + ).rejects.toThrow("did not converge"); + expect(remove).toHaveBeenCalledWith("unexpected-copy"); + }); + + it("marks a thrown gateway mutation as uncertain", async () => { + await expect( + applyClawCronUpdate( + plan([ + { + kind: "cronJob", + id: "weekly", + action: "add", + target: "claw:worker:weekly", + blocked: false, + reason: "added", + }, + ]), + manifest(), + { + cronGateway: { + add: async () => { + throw new Error("connection lost"); + }, + get: vi.fn(), + remove: vi.fn(), + }, + readRefs: () => [], + upsertRef: vi.fn(), + }, + ), + ).rejects.toMatchObject({ partial: true }); + }); + + it("rejects a live cron definition changed after planning", async () => { + const remove = vi.fn(); + await expect( + applyClawCronUpdate( + plan([ + { + kind: "cronJob", + id: "legacy", + action: "remove", + target: "scheduler-legacy", + blocked: false, + reason: "removed", + }, + ]), + manifest(), + { + cronGateway: { + add: vi.fn(), + get: async () => ({ + ...cronReadView("worker", ref(legacy, "scheduler-legacy")), + payload: { kind: "agentTurn", message: "Operator edit" }, + }), + remove, + }, + readRefs: () => [ref(legacy, "scheduler-legacy")], + }, + ), + ).rejects.toThrow("changed after planning"); + expect(remove).not.toHaveBeenCalled(); + }); +}); diff --git a/src/claws/cron-update.ts b/src/claws/cron-update.ts new file mode 100644 index 000000000000..dd7d825e5f51 --- /dev/null +++ b/src/claws/cron-update.ts @@ -0,0 +1,213 @@ +import { createHash } from "node:crypto"; +import { stableStringify } from "../agents/stable-stringify.js"; +import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js"; +import { + CLAW_CRON_REF_SCHEMA_VERSION, + clawCronGatewayJobMatchesRef, + clawCronGatewayInput, + clawCronSchedulerJobFromResult, + deleteClawCronRef, + readClawCronRefs, + upsertClawCronRef, + type ClawCronGateway, + type PersistedClawCronRef, +} from "./cron.js"; +import type { ClawCronJob, ClawManifest } from "./types.js"; +import type { ClawUpdatePlan } from "./update-plan.js"; + +export type ClawCronUpdateExecution = { + appliedIds: string[]; + rollback: () => Promise; +}; + +export class ClawCronUpdateError extends Error { + constructor( + message: string, + readonly partial = false, + ) { + super(message); + this.name = "ClawCronUpdateError"; + } +} + +function digest(value: unknown): string { + return `sha256:${createHash("sha256").update(stableStringify(value)).digest("hex")}`; +} + +function targetRef(params: { + agentId: string; + job: ClawCronJob; + schedulerJobId?: string; + previous?: PersistedClawCronRef; + nowMs: number; +}): PersistedClawCronRef { + return { + schemaVersion: CLAW_CRON_REF_SCHEMA_VERSION, + agentId: params.agentId, + manifestId: params.job.id, + declarationKey: `claw:${params.agentId}:${params.job.id}`, + ...(params.schedulerJobId ? { schedulerJobId: params.schedulerJobId } : {}), + status: "pending", + job: params.job, + createdAtMs: params.previous?.createdAtMs ?? params.nowMs, + updatedAtMs: params.nowMs, + }; +} + +export async function applyClawCronUpdate( + updatePlan: ClawUpdatePlan, + targetManifest: ClawManifest, + options: OpenClawStateDatabaseOptions & { + cronGateway?: ClawCronGateway; + nowMs?: number; + readRefs?: typeof readClawCronRefs; + upsertRef?: typeof upsertClawCronRef; + deleteRef?: typeof deleteClawCronRef; + }, +): Promise { + const actions = updatePlan.actions.filter( + (action) => action.kind === "cronJob" && action.action !== "unchanged", + ); + if (actions.length === 0) { + return { appliedIds: [], rollback: async () => undefined }; + } + if (!options.cronGateway) { + throw new ClawCronUpdateError("Claw cron updates require the gateway cron API."); + } + if (!options.cronGateway.get) { + throw new ClawCronUpdateError("Claw cron updates require the gateway cron.get API."); + } + const gateway = options.cronGateway; + const readRefs = options.readRefs ?? readClawCronRefs; + const upsertRef = options.upsertRef ?? upsertClawCronRef; + const deleteRef = options.deleteRef ?? deleteClawCronRef; + const currentRefs = new Map( + readRefs(updatePlan.agentId, options).map((ref) => [ref.manifestId, ref]), + ); + const targetJobs = new Map(targetManifest.cronJobs.map((job) => [job.id, job])); + const undo: Array<() => Promise> = []; + const appliedIds: string[] = []; + const nowMs = options.nowMs ?? Date.now(); + + const add = async (ref: PersistedClawCronRef): Promise => { + let raw: unknown; + try { + raw = await gateway.add(clawCronGatewayInput(updatePlan.agentId, ref)); + } catch (error) { + throw new ClawCronUpdateError(error instanceof Error ? error.message : String(error), true); + } + const result = clawCronSchedulerJobFromResult(raw); + if (!result) { + throw new ClawCronUpdateError("cron.add returned no scheduler job id.", true); + } + return result.id; + }; + const rollback = async () => { + const failures: string[] = []; + for (const revert of undo.toReversed()) { + try { + await revert(); + } catch (error) { + failures.push(error instanceof Error ? error.message : String(error)); + } + } + if (failures.length > 0) { + throw new ClawCronUpdateError(failures.join("; ")); + } + }; + + try { + for (const action of actions) { + const previous = currentRefs.get(action.id); + if (previous && action.currentDigest && digest(previous.job) !== action.currentDigest) { + throw new ClawCronUpdateError( + `Cron declaration ${JSON.stringify(action.id)} changed after planning.`, + ); + } + if (previous?.schedulerJobId) { + const live = await gateway.get!(previous.schedulerJobId); + if (!clawCronGatewayJobMatchesRef(updatePlan.agentId, previous, live)) { + throw new ClawCronUpdateError( + `Cron declaration ${JSON.stringify(action.id)} changed after planning.`, + ); + } + } + if (action.action === "remove") { + if (!previous?.schedulerJobId || previous.status !== "complete") { + throw new ClawCronUpdateError( + `Cron declaration ${JSON.stringify(action.id)} is no longer safely removable.`, + ); + } + upsertRef({ ...previous, status: "pending", updatedAtMs: nowMs }, options); + try { + await gateway.remove(previous.schedulerJobId); + } catch (error) { + throw new ClawCronUpdateError( + error instanceof Error ? error.message : String(error), + true, + ); + } + undo.push(async () => { + const restoredId = await add(previous); + upsertRef({ ...previous, schedulerJobId: restoredId, updatedAtMs: nowMs }, options); + }); + deleteRef(updatePlan.agentId, action.id, options); + appliedIds.push(action.id); + continue; + } + + const job = targetJobs.get(action.id); + if (!job) { + throw new ClawCronUpdateError( + `Target cron declaration ${JSON.stringify(action.id)} is missing.`, + ); + } + const pending = targetRef({ agentId: updatePlan.agentId, job, previous, nowMs }); + upsertRef(pending, options); + const schedulerJobId = await add(pending); + if (action.action === "change") { + if (!previous?.schedulerJobId || schedulerJobId !== previous.schedulerJobId) { + try { + await gateway.remove(schedulerJobId); + if (previous) { + upsertRef(previous, options); + } + } catch (error) { + throw new ClawCronUpdateError( + `cron.add did not converge and cleanup failed: ${error instanceof Error ? error.message : String(error)}`, + true, + ); + } + throw new ClawCronUpdateError( + `cron.add did not converge declaration ${JSON.stringify(action.id)} on its owned scheduler job.`, + ); + } + undo.push(async () => { + const restoredId = await add(previous); + upsertRef({ ...previous, schedulerJobId: restoredId, updatedAtMs: nowMs }, options); + }); + } else { + undo.push(async () => { + await gateway.remove(schedulerJobId); + deleteRef(updatePlan.agentId, action.id, options); + }); + } + upsertRef({ ...pending, schedulerJobId, status: "complete" }, options); + appliedIds.push(action.id); + } + } catch (error) { + try { + await rollback(); + } catch (rollbackError) { + throw new ClawCronUpdateError( + `${error instanceof Error ? error.message : String(error)}; rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + true, + ); + } + throw new ClawCronUpdateError( + error instanceof Error ? error.message : String(error), + error instanceof ClawCronUpdateError && error.partial, + ); + } + return { appliedIds, rollback }; +} diff --git a/src/claws/cron.ts b/src/claws/cron.ts index 43bb3242a45e..ecfc23654f28 100644 --- a/src/claws/cron.ts +++ b/src/claws/cron.ts @@ -8,7 +8,7 @@ import { } from "../state/openclaw-state-db.js"; import type { ClawAddPlan, ClawCronJob } from "./types.js"; -const CLAW_CRON_REF_SCHEMA_VERSION = "openclaw.clawCronRef.v1" as const; +export const CLAW_CRON_REF_SCHEMA_VERSION = "openclaw.clawCronRef.v1" as const; export type PersistedClawCronRef = { schemaVersion: typeof CLAW_CRON_REF_SCHEMA_VERSION; @@ -167,7 +167,7 @@ function updateRef( return updated; } -function schedulerJobFromResult(value: unknown): { id: string } | undefined { +export function clawCronSchedulerJobFromResult(value: unknown): { id: string } | undefined { if (!value || typeof value !== "object") { return undefined; } @@ -204,7 +204,10 @@ function schedulerJobByDeclarationKey( return match ? { id: match.id as string } : undefined; } -function clawCronGatewayInput(agentId: string, ref: PersistedClawCronRef): Record { +export function clawCronGatewayInput( + agentId: string, + ref: PersistedClawCronRef, +): Record { const job = ref.job; return { name: job.name ?? job.id, @@ -313,7 +316,7 @@ export async function installClawCronJobs( pending.declarationKey, ); } - result ??= schedulerJobFromResult( + result ??= clawCronSchedulerJobFromResult( await options.gateway.add(clawCronGatewayInput(plan.agent.finalId, pending)), ); if (!result) { @@ -389,3 +392,41 @@ export function markClawCronRefRemoved( ); return ref ? updateRef(ref, { status: "removed" }, options) : undefined; } + +export function upsertClawCronRef( + ref: PersistedClawCronRef, + options: OpenClawStateDatabaseOptions = {}, +): void { + runOpenClawStateWriteTransaction(({ db }) => { + db /* sqlite-allow-raw: Claw cron lifecycle provenance write. */ + .prepare( + `INSERT INTO claw_cron_refs ( + agent_id, manifest_id, schema_version, declaration_key, scheduler_job_id, + status, job_json, error, created_at_ms, updated_at_ms + ) VALUES ( + @agent_id, @manifest_id, @schema_version, @declaration_key, @scheduler_job_id, + @status, @job_json, @error, @created_at_ms, @updated_at_ms + ) + ON CONFLICT(agent_id, manifest_id) DO UPDATE SET + schema_version = excluded.schema_version, + declaration_key = excluded.declaration_key, + scheduler_job_id = excluded.scheduler_job_id, + status = excluded.status, + job_json = excluded.job_json, + error = excluded.error, + updated_at_ms = excluded.updated_at_ms`, + ) + .run({ + agent_id: ref.agentId, + manifest_id: ref.manifestId, + schema_version: ref.schemaVersion, + declaration_key: ref.declarationKey, + scheduler_job_id: ref.schedulerJobId ?? null, + status: ref.status, + job_json: JSON.stringify(ref.job), + error: ref.error ?? null, + created_at_ms: ref.createdAtMs, + updated_at_ms: ref.updatedAtMs, + }); + }, options); +} diff --git a/src/claws/lifecycle.ts b/src/claws/lifecycle.ts index 7e83c7759716..b839ab0e4002 100644 --- a/src/claws/lifecycle.ts +++ b/src/claws/lifecycle.ts @@ -37,7 +37,7 @@ function capabilityChange( }; } -type ClawAddPlanContext = { +export type ClawAddPlanContext = { agentId?: string; workspace?: string; resumableWorkspace?: string; diff --git a/src/claws/mcp-update.test.ts b/src/claws/mcp-update.test.ts new file mode 100644 index 000000000000..305103ba8a22 --- /dev/null +++ b/src/claws/mcp-update.test.ts @@ -0,0 +1,341 @@ +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; +import { applyClawMcpUpdate } from "./mcp-update.js"; +import { + CLAW_MCP_REF_SCHEMA_VERSION, + digestClawMcpServer, + readClawMcpServerRefs, + upsertClawMcpServerRef, + type PersistedClawMcpServerRef, +} from "./mcp.js"; +import { CLAW_OUTPUT_STABILITY, type ClawManifest, type ClawMcpServer } from "./types.js"; +import { CLAW_UPDATE_PLAN_SCHEMA_VERSION, type ClawUpdatePlan } from "./update-plan.js"; + +const oldDocs: ClawMcpServer = { command: "uvx", args: ["docs@1"] }; +const newDocs: ClawMcpServer = { command: "uvx", args: ["docs@2"] }; +const legacy: ClawMcpServer = { command: "node", args: ["legacy.mjs"] }; +const remote: ClawMcpServer = { + url: "https://example.com/mcp", + transport: "streamable-http", + auth: "oauth", +}; + +afterEach(() => closeOpenClawStateDatabaseForTest()); +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +function ref(name: string, server: ClawMcpServer): PersistedClawMcpServerRef { + return { + schemaVersion: CLAW_MCP_REF_SCHEMA_VERSION, + agentId: "worker", + name, + configDigest: digestClawMcpServer(server), + relationship: "managed", + origin: "claw-introduced", + independentOwner: false, + status: "complete", + createdAtMs: 10, + updatedAtMs: 10, + }; +} + +function plan(actions: ClawUpdatePlan["actions"]): ClawUpdatePlan { + return { + schemaVersion: CLAW_UPDATE_PLAN_SCHEMA_VERSION, + stability: CLAW_OUTPUT_STABILITY, + dryRun: true, + mutationAllowed: false, + planIntegrity: "sha256:update-plan", + found: true, + agentId: "worker", + currentClaw: { name: "@acme/worker", version: "1.0.0", integrity: "sha256:old" }, + targetClaw: { name: "@acme/worker", version: "2.0.0", integrity: "sha256:new" }, + summary: { + totalActions: actions.length, + added: actions.filter((action) => action.action === "add").length, + changed: actions.filter((action) => action.action === "change").length, + removed: actions.filter((action) => action.action === "remove").length, + released: actions.filter((action) => action.action === "release").length, + unchanged: 0, + manual: 0, + blocked: 0, + capabilityChanges: 0, + capabilityEscalations: 0, + }, + actions, + capabilityChanges: [], + blockers: [], + diagnostics: [], + }; +} + +function manifest(): ClawManifest { + return { + schemaVersion: 1, + agent: { id: "worker" }, + workspace: { bootstrapFiles: {}, files: [] }, + packages: [], + mcpServers: { docs: newDocs, remote }, + cronJobs: [], + }; +} + +describe("applyClawMcpUpdate", () => { + it("applies add, change, and remove with CAS writes and reversible ownership", async () => { + const currentRefs = [ref("docs", oldDocs), ref("legacy", legacy)]; + const setServer = vi.fn(async () => ({ + ok: true as const, + path: "config", + config: {}, + mcpServers: {}, + })); + const unsetServer = vi.fn(async () => ({ + ok: true as const, + path: "config", + config: {}, + mcpServers: {}, + removed: true, + })); + const upsertRef = vi.fn(); + const deleteRef = vi.fn(); + const execution = await applyClawMcpUpdate( + plan([ + { + kind: "mcpServer", + id: "docs", + action: "change", + target: "mcp.servers.docs", + blocked: false, + reason: "changed", + }, + { + kind: "mcpServer", + id: "remote", + action: "add", + target: "mcp.servers.remote", + blocked: false, + reason: "added", + }, + { + kind: "mcpServer", + id: "legacy", + action: "remove", + target: "mcp.servers.legacy", + blocked: false, + reason: "removed", + }, + ]), + manifest(), + { + config: { + mcp: { servers: { docs: { command: "uvx", args: ["docs-resolved"] }, legacy } }, + } as OpenClawConfig, + sourceMcpServers: { docs: oldDocs, legacy }, + nowMs: 20, + readRefs: () => currentRefs, + planRemoval: () => ({ action: "remove" }), + setServer, + unsetServer, + upsertRef, + deleteRef, + }, + ); + + expect(execution.appliedNames).toEqual(["docs", "remote", "legacy"]); + expect(setServer).toHaveBeenNthCalledWith(1, { + name: "docs", + server: newDocs, + expectedServer: oldDocs, + recordIndependentOwner: false, + }); + expect(setServer).toHaveBeenNthCalledWith(2, { + name: "remote", + server: remote, + createOnly: true, + recordIndependentOwner: false, + }); + expect(unsetServer).toHaveBeenCalledWith({ name: "legacy", expectedServer: legacy }); + + await execution.rollback(); + + expect(setServer).toHaveBeenNthCalledWith(3, { + name: "legacy", + server: legacy, + createOnly: true, + recordIndependentOwner: false, + }); + expect(unsetServer).toHaveBeenNthCalledWith(2, { + name: "remote", + expectedServer: remote, + }); + expect(setServer).toHaveBeenNthCalledWith(4, { + name: "docs", + server: oldDocs, + expectedServer: newDocs, + recordIndependentOwner: false, + }); + expect(upsertRef).toHaveBeenCalledTimes(7); + expect(deleteRef).toHaveBeenCalledTimes(2); + }); + + it("releases ownership without removing shared or independently owned config", async () => { + const independent = { + ...ref("legacy", legacy), + relationship: "referenced" as const, + origin: "pre-existing" as const, + independentOwner: true, + }; + const unsetServer = vi.fn(); + const upsertRef = vi.fn(); + const deleteRef = vi.fn(); + const execution = await applyClawMcpUpdate( + plan([ + { + kind: "mcpServer", + id: "legacy", + action: "release", + target: "mcp.servers.legacy", + blocked: false, + reason: "shared config survives", + }, + ]), + manifest(), + { + config: { mcp: { servers: { legacy } } }, + sourceMcpServers: { legacy }, + readRefs: () => [independent], + planRemoval: () => ({ action: "release" }), + unsetServer, + upsertRef, + deleteRef, + }, + ); + + expect(unsetServer).not.toHaveBeenCalled(); + expect(deleteRef).toHaveBeenCalledWith("worker", "legacy", expect.any(Object)); + await execution.rollback(); + expect(upsertRef).toHaveBeenCalledWith(independent, expect.any(Object)); + }); + + it("rejects release when exact config becomes solely Claw-owned", async () => { + const previous = ref("legacy", legacy); + const deleteRef = vi.fn(); + await expect( + applyClawMcpUpdate( + plan([ + { + kind: "mcpServer", + id: "legacy", + action: "release", + target: "mcp.servers.legacy", + blocked: false, + reason: "shared at preview", + }, + ]), + manifest(), + { + config: { mcp: { servers: { legacy } } }, + sourceMcpServers: { legacy }, + readRefs: () => [previous], + planRemoval: () => ({ action: "remove" }), + deleteRef, + }, + ), + ).rejects.toThrow("no longer safely releasable"); + expect(deleteRef).not.toHaveBeenCalled(); + }); + + it("restores complete MCP ownership through a real release rollback", async () => { + const root = tempDirs.make("openclaw-mcp-release-"); + const stateOptions = { env: { OPENCLAW_STATE_DIR: join(root, "state") } }; + const independent = { + ...ref("legacy", legacy), + relationship: "referenced" as const, + origin: "pre-existing" as const, + independentOwner: true, + }; + upsertClawMcpServerRef(independent, stateOptions); + + const execution = await applyClawMcpUpdate( + plan([ + { + kind: "mcpServer", + id: "legacy", + action: "release", + target: "mcp.servers.legacy", + blocked: false, + reason: "release only", + }, + ]), + manifest(), + { + ...stateOptions, + config: { mcp: { servers: { legacy } } }, + sourceMcpServers: { legacy }, + }, + ); + expect(readClawMcpServerRefs("worker", stateOptions)).toEqual([]); + + await execution.rollback(); + expect(readClawMcpServerRefs("worker", stateOptions)).toEqual([independent]); + }); + + it("does not compensate a config write rejected before mutation", async () => { + const setServer = vi.fn(async () => ({ ok: false as const, path: "config", error: "changed" })); + const unsetServer = vi.fn(); + + await expect( + applyClawMcpUpdate( + plan([ + { + kind: "mcpServer", + id: "docs", + action: "change", + target: "mcp.servers.docs", + blocked: false, + reason: "changed", + }, + ]), + manifest(), + { + config: { mcp: { servers: { docs: oldDocs } } }, + sourceMcpServers: { docs: oldDocs }, + readRefs: () => [ref("docs", oldDocs)], + setServer, + unsetServer, + upsertRef: vi.fn(), + }, + ), + ).rejects.toThrow("changed"); + expect(setServer).toHaveBeenCalledTimes(1); + expect(unsetServer).not.toHaveBeenCalled(); + }); + + it("does not overwrite an unowned server that appears before apply", async () => { + const setServer = vi.fn(); + await expect( + applyClawMcpUpdate( + plan([ + { + kind: "mcpServer", + id: "remote", + action: "add", + target: "mcp.servers.remote", + blocked: false, + reason: "added", + }, + ]), + manifest(), + { + config: { mcp: { servers: { remote } } }, + sourceMcpServers: { remote }, + readRefs: () => [], + setServer, + }, + ), + ).rejects.toThrow("was not claimed"); + expect(setServer).not.toHaveBeenCalled(); + }); +}); diff --git a/src/claws/mcp-update.ts b/src/claws/mcp-update.ts new file mode 100644 index 000000000000..13b2801cf3f2 --- /dev/null +++ b/src/claws/mcp-update.ts @@ -0,0 +1,214 @@ +import { normalizeConfiguredMcpServers } from "../config/mcp-config-normalize.js"; +import { setConfiguredMcpServer, unsetConfiguredMcpServer } from "../config/mcp-config.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js"; +import { + CLAW_MCP_REF_SCHEMA_VERSION, + deleteClawMcpServerRef, + digestClawMcpServer, + planClawMcpServerRemoval, + readClawMcpServerRefs, + upsertClawMcpServerRef, + type PersistedClawMcpServerRef, +} from "./mcp.js"; +import type { ClawManifest } from "./types.js"; +import type { ClawUpdatePlan } from "./update-plan.js"; + +export type ClawMcpUpdateExecution = { + appliedNames: string[]; + rollback: () => Promise; +}; + +export class ClawMcpUpdateError extends Error { + constructor( + message: string, + readonly partial = false, + ) { + super(message); + this.name = "ClawMcpUpdateError"; + } +} + +export async function applyClawMcpUpdate( + updatePlan: ClawUpdatePlan, + targetManifest: ClawManifest, + options: OpenClawStateDatabaseOptions & { + config: OpenClawConfig; + sourceMcpServers: Record>; + nowMs?: number; + setServer?: typeof setConfiguredMcpServer; + unsetServer?: typeof unsetConfiguredMcpServer; + readRefs?: typeof readClawMcpServerRefs; + planRemoval?: ( + ref: PersistedClawMcpServerRef, + options: OpenClawStateDatabaseOptions, + ) => { action: "remove" | "release" }; + upsertRef?: typeof upsertClawMcpServerRef; + deleteRef?: typeof deleteClawMcpServerRef; + }, +): Promise { + const actions = updatePlan.actions.filter( + (action) => action.kind === "mcpServer" && action.action !== "unchanged", + ); + if (actions.length === 0) { + return { appliedNames: [], rollback: async () => undefined }; + } + const setServer = options.setServer ?? setConfiguredMcpServer; + const unsetServer = options.unsetServer ?? unsetConfiguredMcpServer; + const readRefs = options.readRefs ?? readClawMcpServerRefs; + const planRemoval = options.planRemoval ?? planClawMcpServerRemoval; + const upsertRef = options.upsertRef ?? upsertClawMcpServerRef; + const deleteRef = options.deleteRef ?? deleteClawMcpServerRef; + const currentRefs = new Map(readRefs(updatePlan.agentId, options).map((ref) => [ref.name, ref])); + const currentServers = normalizeConfiguredMcpServers(options.sourceMcpServers); + const undo: Array<() => Promise> = []; + const appliedNames: string[] = []; + const nowMs = options.nowMs ?? Date.now(); + let configMutationUncertain = false; + + const rollback = async () => { + const failures: string[] = []; + for (const revert of undo.toReversed()) { + try { + await revert(); + } catch (error) { + failures.push(error instanceof Error ? error.message : String(error)); + } + } + if (failures.length > 0) { + throw new ClawMcpUpdateError(failures.join("; ")); + } + }; + + try { + for (const action of actions) { + const name = action.id; + const previousRef = currentRefs.get(name); + const previousServer = currentServers[name]; + if (action.action === "add" && (previousServer || previousRef)) { + throw new ClawMcpUpdateError( + `MCP server ${JSON.stringify(name)} appeared after planning and was not claimed.`, + ); + } + if (previousServer && !previousRef) { + throw new ClawMcpUpdateError( + `MCP server ${JSON.stringify(name)} is not owned by this Claw.`, + ); + } + if (action.action === "release") { + if (!previousRef) { + throw new ClawMcpUpdateError(`MCP reference ${JSON.stringify(name)} disappeared.`); + } + const exactLiveConfig = + previousServer !== undefined && + digestClawMcpServer(previousServer) === previousRef.configDigest; + if (exactLiveConfig && planRemoval(previousRef, options).action !== "release") { + throw new ClawMcpUpdateError( + `MCP server ${JSON.stringify(name)} is no longer safely releasable.`, + ); + } + deleteRef(updatePlan.agentId, name, options); + undo.push(async () => upsertRef(previousRef, options)); + appliedNames.push(name); + continue; + } + if (action.action === "remove") { + if (!previousServer || !previousRef) { + throw new ClawMcpUpdateError(`MCP server ${JSON.stringify(name)} disappeared.`); + } + if (planRemoval(previousRef, options).action !== "remove") { + throw new ClawMcpUpdateError( + `MCP server ${JSON.stringify(name)} gained another owner after planning.`, + ); + } + upsertRef({ ...previousRef, status: "pending", updatedAtMs: nowMs }, options); + configMutationUncertain = true; + const removed = await unsetServer({ name, expectedServer: previousServer }); + configMutationUncertain = false; + if (!removed.ok) { + throw new Error(removed.error); + } + undo.push(async () => { + const restored = await setServer({ + name, + server: previousServer, + createOnly: true, + recordIndependentOwner: false, + }); + if (!restored.ok) { + throw new Error(restored.error); + } + upsertRef(previousRef, options); + }); + deleteRef(updatePlan.agentId, name, options); + appliedNames.push(name); + continue; + } + + const targetServer = targetManifest.mcpServers[name]; + if (!targetServer) { + throw new ClawMcpUpdateError(`Target MCP declaration ${JSON.stringify(name)} is missing.`); + } + const targetRef: PersistedClawMcpServerRef = { + schemaVersion: CLAW_MCP_REF_SCHEMA_VERSION, + agentId: updatePlan.agentId, + name, + configDigest: digestClawMcpServer(targetServer), + relationship: previousRef?.relationship ?? "managed", + origin: previousRef?.origin ?? "claw-introduced", + independentOwner: previousRef?.independentOwner ?? false, + status: "pending", + createdAtMs: previousRef?.createdAtMs ?? nowMs, + updatedAtMs: nowMs, + }; + upsertRef(targetRef, options); + configMutationUncertain = true; + const written = await setServer({ + name, + server: targetServer, + ...(previousServer ? { expectedServer: previousServer } : { createOnly: true }), + recordIndependentOwner: false, + }); + configMutationUncertain = false; + if (!written.ok) { + throw new Error(written.error); + } + undo.push(async () => { + if (previousServer && previousRef) { + const restored = await setServer({ + name, + server: previousServer, + expectedServer: targetServer, + recordIndependentOwner: false, + }); + if (!restored.ok) { + throw new Error(restored.error); + } + upsertRef(previousRef, options); + } else { + const removed = await unsetServer({ name, expectedServer: targetServer }); + if (!removed.ok) { + throw new Error(removed.error); + } + deleteRef(updatePlan.agentId, name, options); + } + }); + upsertRef({ ...targetRef, status: "complete" }, options); + appliedNames.push(name); + } + } catch (error) { + try { + await rollback(); + } catch (rollbackError) { + throw new ClawMcpUpdateError( + `${error instanceof Error ? error.message : String(error)}; rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + true, + ); + } + throw new ClawMcpUpdateError( + error instanceof Error ? error.message : String(error), + configMutationUncertain || (error instanceof ClawMcpUpdateError && error.partial), + ); + } + return { appliedNames, rollback }; +} diff --git a/src/claws/mcp.ts b/src/claws/mcp.ts index 3d2f162305fe..26544e4c3860 100644 --- a/src/claws/mcp.ts +++ b/src/claws/mcp.ts @@ -10,7 +10,7 @@ import { import type { ClawReferencedCleanup } from "./package-remove.js"; import type { ClawAddPlan, ClawMcpServer } from "./types.js"; -const CLAW_MCP_REF_SCHEMA_VERSION = "openclaw.clawMcpServerRef.v1" as const; +export const CLAW_MCP_REF_SCHEMA_VERSION = "openclaw.clawMcpServerRef.v1" as const; export type PersistedClawMcpServerRef = { schemaVersion: typeof CLAW_MCP_REF_SCHEMA_VERSION; @@ -173,7 +173,11 @@ function updateRef( export async function installClawMcpServers( plan: ClawAddPlan, options: OpenClawStateDatabaseOptions & { - setMcpServer?: typeof setConfiguredMcpServer; + setMcpServer?: (params: { + name: string; + server: ClawMcpServer; + createOnly?: boolean; + }) => ReturnType; listMcpServers?: typeof listConfiguredMcpServers; nowMs?: number; } = {}, @@ -424,3 +428,45 @@ export function deleteClawMcpServerRef( .run(agentId, name); }, options); } + +export function upsertClawMcpServerRef( + ref: PersistedClawMcpServerRef, + options: OpenClawStateDatabaseOptions = {}, +): void { + runOpenClawStateWriteTransaction(({ db }) => { + db /* sqlite-allow-raw: Claw MCP lifecycle provenance write. */ + .prepare( + `INSERT INTO claw_mcp_server_refs ( + agent_id, name, schema_version, config_digest, relationship, origin, + independent_owner, status, error, + created_at_ms, updated_at_ms + ) VALUES ( + @agent_id, @name, @schema_version, @config_digest, @relationship, @origin, + @independent_owner, @status, @error, + @created_at_ms, @updated_at_ms + ) + ON CONFLICT(agent_id, name) DO UPDATE SET + schema_version = excluded.schema_version, + config_digest = excluded.config_digest, + relationship = excluded.relationship, + origin = excluded.origin, + independent_owner = excluded.independent_owner, + status = excluded.status, + error = excluded.error, + updated_at_ms = excluded.updated_at_ms`, + ) + .run({ + agent_id: ref.agentId, + name: ref.name, + schema_version: ref.schemaVersion, + config_digest: ref.configDigest, + relationship: ref.relationship, + origin: ref.origin, + independent_owner: ref.independentOwner ? 1 : 0, + status: ref.status, + error: ref.error ?? null, + created_at_ms: ref.createdAtMs, + updated_at_ms: ref.updatedAtMs, + }); + }, options); +} diff --git a/src/claws/package-update-provenance.ts b/src/claws/package-update-provenance.ts new file mode 100644 index 000000000000..8701ae5e13ff --- /dev/null +++ b/src/claws/package-update-provenance.ts @@ -0,0 +1,108 @@ +import { createHash } from "node:crypto"; +import { stableStringify } from "../agents/stable-stringify.js"; +import { + runOpenClawStateWriteTransaction, + type OpenClawStateDatabaseOptions, +} from "../state/openclaw-state-db.js"; +import type { PersistedClawPackageRef } from "./provenance.js"; + +export function digestClawPackageRef(ref: PersistedClawPackageRef): string { + return `sha256:${createHash("sha256").update(stableStringify(ref)).digest("hex")}`; +} + +export function replaceClawPackageRefExpected( + expected: PersistedClawPackageRef | undefined, + replacement: PersistedClawPackageRef | undefined, + options: OpenClawStateDatabaseOptions = {}, +): void { + const identity = expected ?? replacement; + if (!identity) { + throw new Error("Package reference replacement requires an identity."); + } + runOpenClawStateWriteTransaction(({ db }) => { + if (expected) { + const result = db /* sqlite-allow-raw: Claw package provenance compare-and-swap delete. */ + .prepare( + `DELETE 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 + AND package_integrity = @package_integrity + AND schema_version = @schema_version + AND claw_name = @claw_name + AND package_status = @package_status + AND relationship = @relationship + AND origin = @origin + AND independent_owner = @independent_owner + AND installed_at_ms = @installed_at_ms + AND updated_at_ms = @updated_at_ms`, + ) + .run({ + agent_id: expected.agentId, + package_kind: expected.kind, + package_source: expected.source, + package_ref: expected.ref, + package_version: expected.version, + package_integrity: expected.integrity, + schema_version: expected.schemaVersion, + claw_name: expected.clawName, + package_status: expected.status, + relationship: expected.relationship, + origin: expected.origin, + independent_owner: expected.independentOwner ? 1 : 0, + installed_at_ms: expected.installedAtMs, + updated_at_ms: expected.updatedAtMs, + }); + if (Number(result.changes) !== 1) { + throw new Error( + `Package reference ${JSON.stringify(`${expected.kind}:${expected.ref}`)} changed after planning.`, + ); + } + } else { + const occupied = + db /* sqlite-allow-raw: Claw package provenance compare-and-swap occupancy check. */ + .prepare( + `SELECT 1 FROM claw_package_refs + WHERE agent_id = ? AND package_kind = ? AND package_source = ? AND package_ref = ?`, + ) + .get(identity.agentId, identity.kind, identity.source, identity.ref); + if (occupied) { + throw new Error( + `Package reference ${JSON.stringify(`${identity.kind}:${identity.ref}`)} appeared after planning.`, + ); + } + } + if (replacement) { + db /* sqlite-allow-raw: Claw package provenance compare-and-swap insert. */ + .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: replacement.agentId, + package_kind: replacement.kind, + package_source: replacement.source, + package_ref: replacement.ref, + package_version: replacement.version, + package_integrity: replacement.integrity, + schema_version: replacement.schemaVersion, + claw_name: replacement.clawName, + package_status: replacement.status, + relationship: replacement.relationship, + origin: replacement.origin, + independent_owner: replacement.independentOwner ? 1 : 0, + installed_at_ms: replacement.installedAtMs, + updated_at_ms: replacement.updatedAtMs, + }); + } + }, options); +} diff --git a/src/claws/package-update.test.ts b/src/claws/package-update.test.ts new file mode 100644 index 000000000000..7f4c1e59446e --- /dev/null +++ b/src/claws/package-update.test.ts @@ -0,0 +1,439 @@ +import { describe, expect, it, vi } from "vitest"; +import { digestClawPackageRef } from "./package-update-provenance.js"; +import { applyClawPackageUpdate } from "./package-update.js"; +import { installClawPackages } from "./packages.js"; +import { CLAW_PACKAGE_REF_SCHEMA_VERSION, type PersistedClawPackageRef } from "./provenance.js"; +import { CLAW_OUTPUT_STABILITY, type ClawAddPlan, type ClawManifest } from "./types.js"; +import { CLAW_UPDATE_PLAN_SCHEMA_VERSION, type ClawUpdatePlan } from "./update-plan.js"; + +function ref(kind: "skill" | "plugin", name: string, version: string): PersistedClawPackageRef { + return { + schemaVersion: CLAW_PACKAGE_REF_SCHEMA_VERSION, + agentId: "worker", + clawName: "@acme/worker", + kind, + source: "clawhub", + ref: name, + version, + integrity: `sha256:${name}-${version}`, + status: "complete", + relationship: kind === "skill" ? "managed" : "referenced", + origin: "claw-introduced", + independentOwner: false, + installedAtMs: 10, + updatedAtMs: 10, + }; +} + +function plan(actions: ClawUpdatePlan["actions"]): ClawUpdatePlan { + return { + schemaVersion: CLAW_UPDATE_PLAN_SCHEMA_VERSION, + stability: CLAW_OUTPUT_STABILITY, + dryRun: true, + mutationAllowed: false, + planIntegrity: "sha256:update-plan", + found: true, + agentId: "worker", + currentClaw: { name: "@acme/worker", version: "1.0.0", integrity: "sha256:old" }, + targetClaw: { name: "@acme/worker", version: "2.0.0", integrity: "sha256:new" }, + summary: { + totalActions: actions.length, + added: actions.filter((action) => action.action === "add").length, + changed: actions.filter((action) => action.action === "change").length, + removed: actions.filter((action) => action.action === "remove").length, + released: actions.filter((action) => action.action === "release").length, + unchanged: 0, + manual: 0, + blocked: 0, + capabilityChanges: 0, + capabilityEscalations: 0, + }, + actions, + capabilityChanges: [], + blockers: [], + diagnostics: [], + }; +} + +const manifest: ClawManifest = { + schemaVersion: 1, + agent: { id: "worker" }, + workspace: { bootstrapFiles: {}, files: [] }, + packages: [ + { + kind: "skill", + source: "clawhub", + ref: "triage", + version: "2.0.0", + }, + { + kind: "plugin", + source: "clawhub", + ref: "audit", + version: "1.0.0", + }, + ], + mcpServers: {}, + cronJobs: [], +}; + +const addPlan: ClawAddPlan = { + schemaVersion: "openclaw.clawAddPlan.v1", + stability: CLAW_OUTPUT_STABILITY, + dryRun: true, + mutationAllowed: false, + manifestSchemaVersion: 1, + planIntegrity: "sha256:add-plan", + claw: { + kind: "package", + name: "@acme/worker", + version: "2.0.0", + packageRoot: "/tmp/claw", + manifestPath: "/tmp/claw/openclaw.claw.json", + integrityKind: "artifact", + integrity: "sha256:new", + byteLength: 1, + }, + agent: { + requestedId: "worker", + finalId: "worker", + workspace: "/tmp/worker", + config: { id: "worker", workspace: "/tmp/worker" }, + }, + summary: { + totalActions: 2, + agentActions: 0, + workspaceActions: 0, + packageActions: 2, + mcpServerActions: 0, + cronJobActions: 0, + blockedActions: 0, + capabilityEscalations: 0, + }, + actions: manifest.packages.map((pkg) => ({ + kind: "package", + id: `${pkg.kind}:${pkg.ref}`, + action: "install", + target: `clawhub:${pkg.ref}@${pkg.version}`, + details: { + ...pkg, + integrity: `sha256:${pkg.ref}-${pkg.version}`, + ownerAction: "install", + ...(pkg.kind === "plugin" ? { installId: pkg.ref } : {}), + }, + blocked: false, + })), + capabilityChanges: [], + blockers: [], + diagnostics: [], + readiness: { ready: true, requirements: [] }, +}; + +describe("applyClawPackageUpdate", () => { + it("updates exact references but reports retained artifacts on rollback", async () => { + const oldSkill = ref("skill", "triage", "1.0.0"); + const legacy = ref("plugin", "legacy", "1.0.0"); + const installPackages = vi.fn( + async (current: ClawAddPlan, options: Parameters[1]) => { + const details = current.actions[0]?.details as { + kind: "skill" | "plugin"; + ref: string; + version: string; + integrity: string; + }; + options?.onExternalMutation?.({ ...details, source: "clawhub" }); + return [ref(details.kind, details.ref, details.version)]; + }, + ); + const replaceExpected = vi.fn(); + const execution = await applyClawPackageUpdate( + plan([ + { + kind: "package", + id: "skill:triage", + action: "change", + target: "clawhub:triage@2.0.0", + blocked: false, + reason: "changed", + currentDigest: digestClawPackageRef(oldSkill), + }, + { + kind: "package", + id: "plugin:audit", + action: "add", + target: "clawhub:audit@1.0.0", + blocked: false, + reason: "added", + }, + { + kind: "package", + id: "plugin:legacy", + action: "release", + target: "clawhub:legacy@1.0.0", + blocked: false, + reason: "removed", + currentDigest: digestClawPackageRef(legacy), + }, + ]), + manifest, + addPlan, + { + installPackages, + readRefs: () => [oldSkill, legacy], + replaceExpected, + }, + ); + + expect(execution.appliedIds).toEqual(["skill:triage", "plugin:audit", "plugin:legacy"]); + expect(installPackages).toHaveBeenCalledTimes(2); + expect(replaceExpected).toHaveBeenCalledWith( + oldSkill, + expect.objectContaining({ version: "2.0.0", status: "pending" }), + expect.any(Object), + ); + expect(replaceExpected).toHaveBeenCalledWith(legacy, undefined, expect.any(Object)); + + await expect(execution.rollback()).rejects.toMatchObject({ partial: true }); + expect(replaceExpected).toHaveBeenCalledWith(undefined, legacy, expect.any(Object)); + expect(replaceExpected).toHaveBeenCalledWith( + expect.objectContaining({ version: "2.0.0", status: "complete" }), + oldSkill, + expect.any(Object), + ); + }); + + it("reverses reference-only removal without uninstalling or reporting partial state", async () => { + const legacy = ref("plugin", "legacy", "1.0.0"); + const replaceExpected = vi.fn(); + const execution = await applyClawPackageUpdate( + plan([ + { + kind: "package", + id: "plugin:legacy", + action: "release", + target: "clawhub:legacy@1.0.0", + blocked: false, + reason: "removed", + currentDigest: digestClawPackageRef(legacy), + }, + ]), + { ...manifest, packages: [] }, + { ...addPlan, actions: [] }, + { readRefs: () => [legacy], replaceExpected }, + ); + + await expect(execution.rollback()).resolves.toBeUndefined(); + expect(replaceExpected).toHaveBeenNthCalledWith(1, legacy, undefined, expect.any(Object)); + expect(replaceExpected).toHaveBeenNthCalledWith(2, undefined, legacy, expect.any(Object)); + }); + + it("releases managed package provenance without uninstalling the artifact", async () => { + const oldSkill = ref("skill", "triage", "1.0.0"); + const replaceExpected = vi.fn(); + const execution = await applyClawPackageUpdate( + plan([ + { + kind: "package", + id: "skill:triage", + action: "remove", + target: "clawhub:triage@1.0.0", + blocked: false, + reason: "removed", + currentDigest: digestClawPackageRef(oldSkill), + }, + ]), + { ...manifest, packages: [] }, + { ...addPlan, actions: [] }, + { + readRefs: () => [oldSkill], + replaceExpected, + }, + ); + + expect(replaceExpected).toHaveBeenCalledWith(oldSkill, undefined, expect.any(Object)); + await expect(execution.rollback()).resolves.toBeUndefined(); + expect(replaceExpected).toHaveBeenCalledWith(undefined, oldSkill, expect.any(Object)); + }); + + it("does not replace a shared plugin pinned by another Claw", async () => { + const installPackages = vi.fn(); + const otherOwner = { ...ref("plugin", "audit", "0.9.0"), agentId: "other" }; + await expect( + applyClawPackageUpdate( + plan([ + { + kind: "package", + id: "plugin:audit", + action: "add", + target: "clawhub:audit@1.0.0", + blocked: false, + reason: "added", + }, + ]), + manifest, + addPlan, + { + installPackages, + readRefs: (options) => (options?.agentId ? [] : [otherOwner]), + }, + ), + ).rejects.toMatchObject({ partial: false }); + expect(installPackages).not.toHaveBeenCalled(); + }); + + it("rejects release when package provenance changed after planning", async () => { + const planned = ref("plugin", "legacy", "1.0.0"); + const observed = { ...planned, independentOwner: true }; + const replaceExpected = vi.fn(); + + await expect( + applyClawPackageUpdate( + plan([ + { + kind: "package", + id: "plugin:legacy", + action: "release", + target: "clawhub:legacy@1.0.0", + blocked: false, + reason: "released", + currentDigest: digestClawPackageRef(planned), + }, + ]), + { ...manifest, packages: [] }, + { ...addPlan, actions: [] }, + { readRefs: () => [observed], replaceExpected }, + ), + ).rejects.toMatchObject({ partial: false }); + expect(replaceExpected).not.toHaveBeenCalled(); + }); + + it("allows only the expected prior version conflict for an owned plugin upgrade", async () => { + const previous = ref("plugin", "audit", "0.9.0"); + const preflightPlugin = vi.fn(async () => ({ + ok: false as const, + code: "plugin_version_conflict" as const, + request: {} as never, + installedVersion: "0.9.0", + expectedVersion: "1.0.0", + })); + const installPackages = vi.fn( + async (_plan: ClawAddPlan, options: Parameters[1]) => { + expect(options).toBeDefined(); + const preflight = await options!.deps?.preflightPlugin?.({ + clawhubPackage: "audit", + rawSpec: "clawhub:audit@1.0.0", + expectedVersion: "1.0.0", + }); + expect(preflight).toMatchObject({ ok: true, action: "install" }); + return [ref("plugin", "audit", "1.0.0")]; + }, + ); + + await expect( + applyClawPackageUpdate( + plan([ + { + kind: "package", + id: "plugin:audit", + action: "change", + target: "clawhub:audit@1.0.0", + blocked: false, + reason: "owned upgrade", + }, + ]), + manifest, + addPlan, + { + installPackages, + readRefs: () => [previous], + replaceExpected: vi.fn(), + packageDeps: { preflightPlugin }, + }, + ), + ).resolves.toMatchObject({ appliedIds: ["plugin:audit"] }); + }); + + it("rejects an owned plugin upgrade when another owner appears before install", async () => { + const previous = ref("plugin", "audit", "0.9.0"); + const other = { ...previous, agentId: "other" }; + const preflightPlugin = vi.fn(async () => ({ + ok: false as const, + code: "plugin_version_conflict" as const, + request: {} as never, + installedVersion: "0.9.0", + expectedVersion: "1.0.0", + })); + const installPackages = vi.fn( + async (_plan: ClawAddPlan, options: Parameters[1]) => { + expect(options).toBeDefined(); + const preflight = await options!.deps?.preflightPlugin?.({ + clawhubPackage: "audit", + rawSpec: "clawhub:audit@1.0.0", + expectedVersion: "1.0.0", + }); + if (!preflight?.ok) { + throw new Error("plugin version conflict"); + } + return [ref("plugin", "audit", "1.0.0")]; + }, + ); + let reads = 0; + const readRefs = vi.fn((options?: { agentId?: string }) => { + reads += 1; + if (options?.agentId) { + return [previous]; + } + return reads >= 3 ? [previous, other] : [previous]; + }); + + await expect( + applyClawPackageUpdate( + plan([ + { + kind: "package", + id: "plugin:audit", + action: "change", + target: "clawhub:audit@1.0.0", + blocked: false, + reason: "owned upgrade", + }, + ]), + manifest, + addPlan, + { + installPackages, + readRefs, + replaceExpected: vi.fn(), + packageDeps: { preflightPlugin }, + }, + ), + ).rejects.toMatchObject({ partial: false }); + }); + + it("does not invoke an installer when package ownership changes after planning", async () => { + const oldSkill = ref("skill", "triage", "1.0.0"); + const installPackages = vi.fn(); + const replaceExpected = vi.fn(() => { + throw new Error('Package reference "skill:triage" changed after planning.'); + }); + + await expect( + applyClawPackageUpdate( + plan([ + { + kind: "package", + id: "skill:triage", + action: "change", + target: "clawhub:triage@2.0.0", + blocked: false, + reason: "changed", + }, + ]), + manifest, + addPlan, + { installPackages, readRefs: () => [oldSkill], replaceExpected }, + ), + ).rejects.toMatchObject({ partial: false }); + expect(installPackages).not.toHaveBeenCalled(); + }); +}); diff --git a/src/claws/package-update.ts b/src/claws/package-update.ts new file mode 100644 index 000000000000..10864b37b339 --- /dev/null +++ b/src/claws/package-update.ts @@ -0,0 +1,259 @@ +import { createHash } from "node:crypto"; +import { stableStringify } from "../agents/stable-stringify.js"; +import { preflightPluginInstall } from "../plugins/plugin-install-preflight.js"; +import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js"; +import { + digestClawPackageRef, + replaceClawPackageRefExpected, +} from "./package-update-provenance.js"; +import { installClawPackages } from "./packages.js"; +import { + CLAW_PACKAGE_REF_SCHEMA_VERSION, + readClawPackageRefs, + type PersistedClawPackageRef, +} from "./provenance.js"; +import type { ClawAddPlan, ClawManifest, ClawPackage } from "./types.js"; +import type { ClawUpdatePlan } from "./update-plan.js"; + +type PackageInstallerDeps = NonNullable< + NonNullable[1]>["deps"] +>; + +export type ClawPackageUpdateExecution = { + appliedIds: string[]; + rollback: () => Promise; +}; + +export class ClawPackageUpdateError extends Error { + constructor( + message: string, + readonly partial: boolean, + ) { + super(message); + this.name = "ClawPackageUpdateError"; + } +} + +function digest(value: unknown): string { + return `sha256:${createHash("sha256").update(stableStringify(value)).digest("hex")}`; +} + +function packageKey(value: Pick): string { + return `${value.kind}:${value.ref}`; +} + +export async function applyClawPackageUpdate( + updatePlan: ClawUpdatePlan, + targetManifest: ClawManifest, + targetAddPlan: ClawAddPlan, + options: OpenClawStateDatabaseOptions & { + installPackages?: typeof installClawPackages; + readRefs?: typeof readClawPackageRefs; + replaceExpected?: typeof replaceClawPackageRefExpected; + packageDeps?: PackageInstallerDeps; + nowMs?: number; + }, +): Promise { + const actions = updatePlan.actions.filter( + (action) => action.kind === "package" && action.action !== "unchanged", + ); + if (actions.length === 0) { + return { appliedIds: [], rollback: async () => undefined }; + } + const installPackages = options.installPackages ?? installClawPackages; + const readRefs = options.readRefs ?? readClawPackageRefs; + const replaceExpected = options.replaceExpected ?? replaceClawPackageRefExpected; + const currentRefs = new Map( + readRefs({ ...options, agentId: updatePlan.agentId }).map((ref) => [packageKey(ref), ref]), + ); + const allRefs = readRefs(options); + const targets = new Map(targetManifest.packages.map((pkg) => [packageKey(pkg), pkg])); + const undo: Array<() => Promise> = []; + const externalMutations: string[] = []; + const appliedIds: string[] = []; + + const rollback = async () => { + const failures: string[] = []; + for (const revert of undo.toReversed()) { + try { + await revert(); + } catch (error) { + failures.push(error instanceof Error ? error.message : String(error)); + } + } + if (externalMutations.length > 0) { + failures.push(`package artifacts may have been retained: ${externalMutations.join(", ")}`); + } + if (failures.length > 0) { + throw new ClawPackageUpdateError(failures.join("; "), externalMutations.length > 0); + } + }; + + try { + for (const action of actions) { + const previous = currentRefs.get(action.id); + if ( + previous && + action.currentDigest && + digestClawPackageRef(previous) !== action.currentDigest + ) { + throw new ClawPackageUpdateError( + `Package reference ${JSON.stringify(action.id)} changed after planning.`, + false, + ); + } + if (action.action === "release" || action.action === "remove") { + if (!previous) { + throw new ClawPackageUpdateError( + `Package reference ${JSON.stringify(action.id)} disappeared.`, + false, + ); + } + replaceExpected(previous, undefined, options); + undo.push(async () => replaceExpected(undefined, previous, options)); + appliedIds.push(action.id); + continue; + } + const target = targets.get(action.id); + const targetAction = targetAddPlan.actions.find( + (candidate) => candidate.kind === "package" && candidate.id === action.id, + ); + if (!target || !targetAction) { + throw new ClawPackageUpdateError( + `Target package action ${JSON.stringify(action.id)} is missing.`, + false, + ); + } + const targetIntegrity = targetAction.details?.integrity; + if (typeof targetIntegrity !== "string") { + throw new ClawPackageUpdateError( + `Target package action ${JSON.stringify(action.id)} has no resolved integrity.`, + false, + ); + } + if ( + target.kind === "plugin" && + allRefs.some( + (ref) => + ref.agentId !== updatePlan.agentId && + ref.kind === "plugin" && + ref.source === target.source && + ref.ref === target.ref && + ref.version !== target.version, + ) + ) { + throw new ClawPackageUpdateError( + `Plugin ${JSON.stringify(target.ref)} has another Claw owner pinned to a different version.`, + false, + ); + } + const nowMs = options.nowMs ?? Date.now(); + const reusesExistingArtifact = targetAction.details?.ownerAction === "reuse"; + let claimed: PersistedClawPackageRef = { + schemaVersion: CLAW_PACKAGE_REF_SCHEMA_VERSION, + agentId: updatePlan.agentId, + clawName: targetAddPlan.claw.name, + kind: target.kind, + source: target.source, + ref: target.ref, + version: target.version, + integrity: targetIntegrity, + status: "pending", + relationship: target.kind === "skill" ? "managed" : "referenced", + origin: reusesExistingArtifact ? "pre-existing" : "claw-introduced", + independentOwner: reusesExistingArtifact, + installedAtMs: nowMs, + updatedAtMs: nowMs, + }; + replaceExpected(previous, claimed, options); + undo.push(async () => replaceExpected(claimed, previous, options)); + const refs = await installPackages( + { ...targetAddPlan, actions: [targetAction] }, + { + ...options, + deps: { + ...options.packageDeps, + preflightPlugin: async (params) => { + const preflight = await ( + options.packageDeps?.preflightPlugin ?? preflightPluginInstall + )(params); + const conflictingOwner = readRefs(options).some( + (ref) => + ref.agentId !== updatePlan.agentId && + ref.kind === "plugin" && + ref.source === target.source && + ref.ref === target.ref && + ref.version !== target.version, + ); + return !preflight.ok && + preflight.code === "plugin_version_conflict" && + !conflictingOwner && + previous?.origin === "claw-introduced" && + !previous.independentOwner && + previous.version === preflight.installedVersion && + target.version === preflight.expectedVersion + ? { ok: true, action: "install", request: preflight.request } + : preflight; + }, + persistPackageRef: (_plan, _pkg, persistOptions) => { + const next = { + ...claimed, + status: persistOptions?.status ?? "complete", + relationship: persistOptions?.relationship ?? claimed.relationship, + origin: persistOptions?.origin ?? claimed.origin, + independentOwner: persistOptions?.independentOwner ?? claimed.independentOwner, + updatedAtMs: nowMs, + }; + replaceExpected(claimed, next, options); + claimed = next; + return next; + }, + completePackageRef: (ref, status) => { + const next = { ...ref, status, updatedAtMs: nowMs }; + replaceExpected(claimed, next, options); + claimed = next; + return next; + }, + }, + onExternalMutation: () => { + externalMutations.push(`${target.kind}:${target.ref}@${target.version}`); + }, + }, + ); + const installed = refs.find( + (ref) => packageKey(ref) === action.id && ref.version === target.version, + ); + if (!installed) { + throw new ClawPackageUpdateError( + `Package installer did not return exact ownership for ${JSON.stringify(action.id)}.`, + true, + ); + } + if (digest(installed) !== digest(claimed)) { + replaceExpected(claimed, installed, options); + claimed = installed; + } + appliedIds.push(action.id); + } + } catch (error) { + if (externalMutations.length > 0) { + throw new ClawPackageUpdateError( + `${error instanceof Error ? error.message : String(error)}; package artifact outcome requires reconciliation`, + true, + ); + } + try { + await rollback(); + } catch (rollbackError) { + throw new ClawPackageUpdateError( + `${error instanceof Error ? error.message : String(error)}; rollback incomplete: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + externalMutations.length > 0, + ); + } + throw new ClawPackageUpdateError( + error instanceof Error ? error.message : String(error), + error instanceof ClawPackageUpdateError ? error.partial : false, + ); + } + return { appliedIds, rollback }; +} diff --git a/src/claws/packages.ts b/src/claws/packages.ts index d1437cebd821..03ad5081a2ea 100644 --- a/src/claws/packages.ts +++ b/src/claws/packages.ts @@ -414,6 +414,9 @@ export async function installClawPackages( }); 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); await installPlugin({ raw: `clawhub:${pkg.ref}@${pkg.version}`, opts: { diff --git a/src/claws/provenance.test.ts b/src/claws/provenance.test.ts index 5fd20b98508e..4133efd83b75 100644 --- a/src/claws/provenance.test.ts +++ b/src/claws/provenance.test.ts @@ -11,12 +11,15 @@ import { import { applyClawAddPlan, ClawAddMutationError } from "./add.js"; import { ClawCronInstallError } from "./cron.js"; import { buildClawAddPlan } from "./lifecycle.js"; +import { replaceClawPackageRefExpected } from "./package-update-provenance.js"; import { - persistClawPackageRef, persistClawInstallRecord, - readClawPackageRefs, + persistClawPackageRef, readClawInstallRecord, + readClawPackageRefs, + updateClawInstallRecord, updateClawInstallRecordStatus, + updateClawPackageRefStatus, } from "./provenance.js"; import { parseClawManifest } from "./schema.js"; import type { ClawSourceIdentity } from "./types.js"; @@ -213,6 +216,135 @@ describe("Claw root install provenance", () => { ).toThrow("did not match the expected phase"); expect(readClawInstallRecord("worker", options)?.status).toBe("complete"); }); + + it("advances package identity while preserving install creation time", async () => { + const { root, plan } = await makePlan(); + const original = persistClawInstallRecord(plan, { env: stateEnv(root), nowMs: 1 }); + const target = { + ...plan, + claw: { ...plan.claw, version: "2.0.0", integrity: "sha256:target" }, + agent: { + ...plan.agent, + config: { ...plan.agent.config, name: "Worker v2" }, + }, + }; + + const updated = updateClawInstallRecord(target, { env: stateEnv(root), nowMs: 2 }); + + expect(updated).toMatchObject({ + claw: { version: "2.0.0", integrity: "sha256:target" }, + addedAtMs: 1, + updatedAtMs: 2, + status: "complete", + }); + expect(updated.agentConfigDigest).not.toBe(original.agentConfigDigest); + expect(readClawInstallRecord("worker", { env: stateEnv(root) })).toEqual(updated); + }); + + it("rejects an update when package provenance changed after planning", async () => { + const { root, plan } = await makePlan(); + const original = persistClawInstallRecord(plan, { env: stateEnv(root), nowMs: 1 }); + const target = { + ...plan, + claw: { ...plan.claw, version: "2.0.0", integrity: "sha256:target" }, + }; + + expect(() => + updateClawInstallRecord(target, { + env: stateEnv(root), + nowMs: 2, + expectedClaw: { version: "0.9.0", integrity: "sha256:stale" }, + }), + ).toThrow("changed"); + expect(readClawInstallRecord("worker", { env: stateEnv(root) })).toEqual(original); + }); + + it("records package references independently of shared package ownership", async () => { + const { root, plan } = await makePlan(); + const pkg = { + kind: "plugin" as const, + source: "clawhub" as const, + ref: "@acme/audit", + version: "2.3.4", + integrity: "sha256:audit-2.3.4", + }; + + const record = persistClawPackageRef(plan, pkg, { env: stateEnv(root), nowMs: 43 }); + + expect(record).toMatchObject({ + schemaVersion: "openclaw.clawPackageRef.v1", + agentId: "worker", + clawName: "@acme/worker", + ...pkg, + }); + expect( + readClawPackageRefs({ + env: stateEnv(root), + kind: "plugin", + source: "clawhub", + ref: "@acme/audit", + version: "2.3.4", + }), + ).toEqual([record]); + }); + + it("rejects a package claim when the persisted reference changed after planning", async () => { + const { root, plan } = await makePlan(); + const options = { env: stateEnv(root) }; + const pkg = { + kind: "plugin" as const, + source: "clawhub" as const, + ref: "@acme/audit", + version: "2.3.4", + integrity: "sha256:audit-2.3.4", + }; + const planned = persistClawPackageRef(plan, pkg, { ...options, nowMs: 43 }); + const current = updateClawPackageRefStatus(planned, "pending", options); + const claim = { ...planned, version: "3.0.0", status: "pending" as const }; + + expect(() => replaceClawPackageRefExpected(planned, claim, options)).toThrow( + "changed after planning", + ); + expect(readClawPackageRefs(options)).toEqual([current]); + }); + + it("replaces and restores package references with complete timestamps", async () => { + const { root, plan } = await makePlan(); + const options = { env: stateEnv(root) }; + const planned = persistClawPackageRef( + plan, + { + kind: "plugin", + source: "clawhub", + ref: "@acme/audit", + version: "2.3.4", + integrity: "sha256:audit-2.3.4", + }, + { ...options, nowMs: 43 }, + ); + const replacement = { + ...planned, + version: "3.0.0", + status: "pending" as const, + updatedAtMs: 44, + }; + + replaceClawPackageRefExpected(planned, replacement, options); + expect(readClawPackageRefs(options)).toEqual([replacement]); + + const restored = persistClawPackageRef( + plan, + { + kind: "plugin", + source: "clawhub", + ref: "@acme/audit", + version: "2.3.4", + integrity: "sha256:audit-2.3.4", + }, + { ...options, nowMs: 45 }, + ); + expect(readClawPackageRefs(options)).toEqual(expect.arrayContaining([replacement, restored])); + }); }); describe("applyClawAddPlan", () => { diff --git a/src/claws/provenance.ts b/src/claws/provenance.ts index a151903d6d68..517c5528833f 100644 --- a/src/claws/provenance.ts +++ b/src/claws/provenance.ts @@ -334,7 +334,7 @@ export function readClawInstallRecords( return rows.map(rowToInstall); } -const CLAW_PACKAGE_REF_SCHEMA_VERSION = "openclaw.clawPackageRef.v1" as const; +export 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"; @@ -373,6 +373,90 @@ type PackageRefRow = { updated_at_ms: number | bigint; }; +export function updateClawInstallRecord( + plan: ClawAddPlan, + options: OpenClawStateDatabaseOptions & { + nowMs?: number; + expectedClaw?: { version: string; integrity: string }; + status?: ClawInstallStatus; + } = {}, +): PersistedClawInstall { + const current = readClawInstallRecord(plan.agent.finalId, options); + if (!current) { + throw new Error( + `No Claw install record exists for agent ${JSON.stringify(plan.agent.finalId)}.`, + ); + } + const updatedAtMs = options.nowMs ?? Date.now(); + const status = options.status ?? "complete"; + const agentConfigDigest = digestAgentConfig(plan); + const ownedAgentPaths = plan.actions + .filter((action) => action.kind === "agent") + .map((action) => action.target); + runOpenClawStateWriteTransaction(({ db }) => { + const result = db /* sqlite-allow-raw: Claw install provenance compare-and-swap write. */ + .prepare( + `UPDATE claw_installs + SET source_kind = @source_kind, + claw_name = @claw_name, + claw_version = @claw_version, + package_root = @package_root, + manifest_path = @manifest_path, + integrity_kind = @integrity_kind, + integrity = @integrity, + source_byte_length = @source_byte_length, + manifest_schema_version = @manifest_schema_version, + plan_integrity = @plan_integrity, + workspace = @workspace, + agent_config_digest = @agent_config_digest, + agent_owned_paths_json = @agent_owned_paths_json, + status = @status, + updated_at_ms = @updated_at_ms + WHERE agent_id = @agent_id + AND claw_version = @expected_claw_version + AND integrity = @expected_integrity`, + ) + .run({ + agent_id: plan.agent.finalId, + source_kind: plan.claw.kind, + claw_name: plan.claw.name, + claw_version: plan.claw.version, + package_root: plan.claw.packageRoot, + manifest_path: plan.claw.manifestPath, + integrity_kind: plan.claw.integrityKind, + integrity: plan.claw.integrity, + source_byte_length: plan.claw.byteLength, + manifest_schema_version: plan.manifestSchemaVersion, + plan_integrity: plan.planIntegrity, + workspace: plan.agent.workspace, + agent_config_digest: agentConfigDigest, + agent_owned_paths_json: JSON.stringify(ownedAgentPaths), + status, + updated_at_ms: updatedAtMs, + expected_claw_version: options.expectedClaw?.version ?? current.claw.version, + expected_integrity: options.expectedClaw?.integrity ?? current.claw.integrity, + }); + if (Number(result.changes) !== 1) { + throw new Error( + `Claw install record changed for agent ${JSON.stringify(plan.agent.finalId)}.`, + ); + } + }, options); + return { + schemaVersion: CLAW_INSTALL_RECORD_SCHEMA_VERSION, + claw: plan.claw, + manifestSchemaVersion: plan.manifestSchemaVersion, + planIntegrity: plan.planIntegrity, + agentId: plan.agent.finalId, + workspace: plan.agent.workspace, + agentConfigDigest, + agentOwnedPaths: ownedAgentPaths, + status, + addedAtMs: current.addedAtMs, + updatedAtMs, + }; +} + function rowToPackageRef(row: PackageRefRow): PersistedClawPackageRef { return { schemaVersion: CLAW_PACKAGE_REF_SCHEMA_VERSION, @@ -490,8 +574,9 @@ export function persistClawPackageRef( return; } // sqlite-allow-raw: this Claw prototype state-table write is scoped to one owned row. - db.prepare( - `INSERT INTO claw_package_refs ( + db /* sqlite-allow-raw: Claw package provenance upsert. */ + .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, @@ -504,22 +589,23 @@ export function persistClawPackageRef( @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, - }); + ) + .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; } diff --git a/src/claws/schema-conformance.test.ts b/src/claws/schema-conformance.test.ts index 02a73446b970..a718bfbdb4f8 100644 --- a/src/claws/schema-conformance.test.ts +++ b/src/claws/schema-conformance.test.ts @@ -79,6 +79,42 @@ describe("portable Claw schema conformance", () => { expect.objectContaining({ path: "$.mcpServers.github.args" }), ); + const mixedPackages = parseClawManifest({ + ...baseManifest, + mcpServers: { + github: { + command: "npx", + args: ["--package", "safe@1.0.0", "--package", "unsafe@latest", "server"], + }, + }, + }); + expect(mixedPackages.ok).toBe(false); + expect(mixedPackages.diagnostics).toContainEqual( + expect.objectContaining({ path: "$.mcpServers.github.args" }), + ); + + const unpinnedCommandBeforeChildArgs = parseClawManifest({ + ...baseManifest, + mcpServers: { + github: { + command: "npx", + args: ["unsafe@latest", "--package", "safe@1.0.0"], + }, + }, + }); + expect(unpinnedCommandBeforeChildArgs.ok).toBe(false); + + const pinnedPackageWithChildArgs = parseClawManifest({ + ...baseManifest, + mcpServers: { + github: { + command: "npx", + args: ["--package", "safe@1.0.0", "server", "--package", "child-arg"], + }, + }, + }); + expect(pinnedPackageWithChildArgs.ok).toBe(true); + const dangerousEnv = parseClawManifest({ ...baseManifest, mcpServers: { github: { command: "node", env: { NODE_OPTIONS: "${NODE_OPTIONS}" } } }, diff --git a/src/claws/schema-portability.ts b/src/claws/schema-portability.ts index 087a2264f57a..cf023cb2a24f 100644 --- a/src/claws/schema-portability.ts +++ b/src/claws/schema-portability.ts @@ -94,7 +94,7 @@ export function isValidClawTimezone(value: string): boolean { } } -function packageManagerArtifact(command: string, args: string[]): string | undefined { +function packageManagerArtifacts(command: string, args: string[]): string[] | undefined { const executable = command .split(/[\\/]/) .at(-1) @@ -109,33 +109,45 @@ function packageManagerArtifact(command: string, args: string[]): string | undef } else if (executable !== "npx" && executable !== "pnpx" && executable !== "bunx") { return undefined; } + const selected: string[] = []; + let positional: string | undefined; for (let index = start; index < args.length; index += 1) { const value = args[index]; if (!value) { continue; } + if (value === "--") { + positional = args[index + 1] ?? ""; + break; + } if (value === "-p" || value === "--package") { - return args[index + 1]; + selected.push(args[index + 1] ?? ""); + index += 1; + continue; } if (value.startsWith("--package=")) { - return value.slice("--package=".length); + selected.push(value.slice("--package=".length)); + continue; } - if (!value.startsWith("-")) { - return value; + if (positional === undefined && !value.startsWith("-")) { + positional = value; + break; } } - return ""; + return selected.length > 0 ? selected : [positional ?? ""]; } export function isClawPackageManagerArtifactPinned( command: string, args: string[], ): boolean | undefined { - const artifact = packageManagerArtifact(command, args); - if (artifact === undefined) { + const artifacts = packageManagerArtifacts(command, args); + if (artifacts === undefined) { return undefined; } - const separator = artifact.lastIndexOf("@"); - const scopedSlash = artifact.startsWith("@") ? artifact.indexOf("/") : -1; - return separator > 0 && separator > scopedSlash && isExactSemVer(artifact.slice(separator + 1)); + return artifacts.every((artifact) => { + const separator = artifact.lastIndexOf("@"); + const scopedSlash = artifact.startsWith("@") ? artifact.indexOf("/") : -1; + return separator > 0 && separator > scopedSlash && isExactSemVer(artifact.slice(separator + 1)); + }); } diff --git a/src/claws/update-apply.test.ts b/src/claws/update-apply.test.ts new file mode 100644 index 000000000000..de9e3ae1a633 --- /dev/null +++ b/src/claws/update-apply.test.ts @@ -0,0 +1,657 @@ +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { ClawCronUpdateError } from "./cron-update.js"; +import { + persistClawInstallRecord, + readClawInstallRecord, + type PersistedClawInstall, +} from "./provenance.js"; +import type { ClawAddPlan, ClawManifest, ClawSourceIdentity } from "./types.js"; +import { applyClawUpdatePlan } from "./update-apply.js"; +import type { ClawUpdatePlan } from "./update-plan.js"; + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +const source: ClawSourceIdentity = { + kind: "package", + name: "@acme/worker", + version: "2.0.0", + packageRoot: "/tmp/target", + manifestPath: "/tmp/target/openclaw.claw.json", + integrityKind: "artifact", + integrity: "sha256:target", + byteLength: 1, +}; +const manifest: ClawManifest = { + schemaVersion: 1, + agent: { id: "worker", name: "Worker v2" }, + workspace: { bootstrapFiles: {}, files: [] }, + packages: [], + mcpServers: {}, + cronJobs: [], +}; +const install: PersistedClawInstall = { + schemaVersion: "openclaw.clawInstallRecord.v1", + claw: { ...source, version: "1.0.0", integrity: "sha256:current" }, + manifestSchemaVersion: 1, + planIntegrity: "sha256:current-add-plan", + agentId: "worker", + workspace: "/tmp/workspace-worker", + agentConfigDigest: "sha256:current-agent", + agentOwnedPaths: ['agents.entries["worker"]'], + status: "complete", + addedAtMs: 1, + updatedAtMs: 1, +}; +const addPlan: ClawAddPlan = { + schemaVersion: "openclaw.clawAddPlan.v1", + stability: "experimental", + dryRun: true, + mutationAllowed: false, + manifestSchemaVersion: 1, + planIntegrity: "sha256:target-add-plan", + claw: source, + agent: { + requestedId: "worker", + finalId: "worker", + workspace: "/tmp/workspace-worker", + config: { id: "worker", name: "Worker v2", workspace: "/tmp/workspace-worker" }, + }, + summary: { + totalActions: 1, + agentActions: 1, + workspaceActions: 0, + packageActions: 0, + mcpServerActions: 0, + cronJobActions: 0, + blockedActions: 0, + capabilityEscalations: 0, + }, + actions: [], + capabilityChanges: [], + blockers: [], + diagnostics: [], + readiness: { ready: true, requirements: [] }, +}; + +function plan(actions: ClawUpdatePlan["actions"]): ClawUpdatePlan { + return { + schemaVersion: "openclaw.clawUpdatePlan.v1", + stability: "experimental", + dryRun: true, + mutationAllowed: false, + planIntegrity: "sha256:update-plan", + found: true, + agentId: "worker", + currentClaw: { name: "@acme/worker", version: "1.0.0", integrity: "sha256:current" }, + targetClaw: { name: "@acme/worker", version: "2.0.0", integrity: "sha256:target" }, + summary: { + totalActions: actions.length, + added: 0, + changed: actions.filter((action) => action.action === "change").length, + removed: 0, + released: 0, + unchanged: actions.filter((action) => action.action === "unchanged").length, + manual: 0, + blocked: actions.filter((action) => action.blocked).length, + capabilityChanges: 0, + capabilityEscalations: 0, + }, + actions, + capabilityChanges: [], + blockers: [], + diagnostics: [], + }; +} + +function consent(updatePlan: ClawUpdatePlan) { + return { + sourceMcpServers: {}, + consentPlanIntegrity: updatePlan.planIntegrity, + }; +} + +describe("applyClawUpdatePlan", () => { + it("rejects consent that does not match the preview before rebuilding", async () => { + const updatePlan = plan([]); + const rebuildPlan = vi.fn(); + + await expect( + applyClawUpdatePlan( + updatePlan, + { targetManifest: manifest, targetSource: source }, + { + config: {}, + sourceMcpServers: {}, + consentPlanIntegrity: "sha256:different-plan", + rebuildPlan, + }, + ), + ).rejects.toMatchObject({ code: "plan_integrity_mismatch" }); + expect(rebuildPlan).not.toHaveBeenCalled(); + }); + + it("rejects a capability disclosure that changed after consent", async () => { + const updatePlan = plan([]); + const changed = { + ...updatePlan, + capabilityChanges: [ + { + kind: "mcpServer" as const, + id: "search", + path: "mcpServers.search", + action: "add" as const, + classification: "escalation" as const, + requiresDistinctConsent: true, + reason: "target adds an MCP execution surface", + effect: { transport: "stdio", command: "npx", args: ["search-server"] }, + desired: { summary: "stdio:npx search-server", digest: "sha256:capability" }, + }, + ], + }; + const readInstall = vi.fn(() => install); + + await expect( + applyClawUpdatePlan( + updatePlan, + { targetManifest: manifest, targetSource: source }, + { + config: {}, + ...consent(updatePlan), + rebuildPlan: vi.fn(async () => changed), + readInstall, + }, + ), + ).rejects.toMatchObject({ code: "update_changed" }); + expect(readInstall).not.toHaveBeenCalled(); + }); + + it("compare-writes the owned agent and advances root provenance", async () => { + const currentAgent = { id: "worker", name: "Worker" }; + const currentDigest = `sha256:${createHash("sha256").update(stableStringify(currentAgent)).digest("hex")}`; + const updatePlan = plan([ + { + kind: "agent", + id: "worker", + action: "change", + target: 'agents.entries["worker"]', + blocked: false, + reason: "target changed", + currentDigest, + desiredDigest: "sha256:target-agent", + }, + ]); + let config: OpenClawConfig = { agents: { entries: { worker: { name: "Worker" } } } }; + const persisted = { ...install, claw: source, updatedAtMs: 2 }; + const persistInstall = vi.fn(() => persisted); + + const result = await applyClawUpdatePlan( + updatePlan, + { targetManifest: manifest, targetSource: source }, + { + config, + ...consent(updatePlan), + rebuildPlan: vi.fn(async () => updatePlan), + buildAddPlan: vi.fn(async () => addPlan), + readInstall: vi.fn(() => install), + persistInstall, + commitConfig: async (transform) => { + config = transform(config); + }, + }, + ); + + expect(config.agents?.entries?.worker).toEqual({ + name: "Worker v2", + workspace: "/tmp/workspace-worker", + }); + expect(persistInstall).toHaveBeenCalledWith(addPlan, expect.any(Object)); + expect(result).toMatchObject({ + schemaVersion: "openclaw.clawUpdateResult.v1", + status: "complete", + agentId: "worker", + targetClaw: { version: "2.0.0" }, + }); + }); + + it("activates cron only after package and agent updates succeed", async () => { + const updatePlan = plan([ + { + kind: "agent", + id: "worker", + action: "change", + target: 'agents.entries["worker"]', + blocked: false, + reason: "restore agent", + }, + ]); + const order: string[] = []; + let config: OpenClawConfig = { agents: { entries: {} } }; + + await applyClawUpdatePlan( + updatePlan, + { targetManifest: manifest, targetSource: source }, + { + config, + ...consent(updatePlan), + rebuildPlan: vi.fn(async () => updatePlan), + buildAddPlan: vi.fn(async () => addPlan), + readInstall: vi.fn(() => install), + applyWorkspace: vi.fn(async () => { + order.push("workspace"); + return { appliedPaths: [], rollback: vi.fn(async () => undefined) }; + }), + applyMcp: vi.fn(async () => { + order.push("mcp"); + return { appliedNames: [], rollback: vi.fn(async () => undefined) }; + }), + applyPackage: vi.fn(async () => { + order.push("package"); + return { appliedIds: [], rollback: vi.fn(async () => undefined) }; + }), + commitConfig: async (transform) => { + order.push("agent"); + config = transform(config); + }, + applyCron: vi.fn(async () => { + order.push("cron"); + return { appliedIds: [], rollback: vi.fn(async () => undefined) }; + }), + persistInstall: vi.fn(() => { + order.push("provenance"); + return { ...install, claw: source }; + }), + }, + ); + + expect(order).toEqual(["workspace", "mcp", "package", "agent", "cron", "provenance"]); + }); + + it("preserves cron prerequisites when the gateway mutation outcome is uncertain", async () => { + const root = tempDirs.make("openclaw-claw-update-apply-"); + const env = { OPENCLAW_STATE_DIR: join(root, "state") }; + const currentAddPlan: ClawAddPlan = { + ...addPlan, + claw: install.claw, + planIntegrity: install.planIntegrity, + agent: { + ...addPlan.agent, + config: { id: "worker", name: "Worker", workspace: addPlan.agent.workspace }, + }, + }; + const currentRecord = persistClawInstallRecord(currentAddPlan, { env, nowMs: 1 }); + const updatePlan = plan([ + { + kind: "agent", + id: "worker", + action: "change", + target: 'agents.entries["worker"]', + blocked: false, + reason: "restore agent", + }, + { + kind: "cronJob", + id: "heartbeat", + action: "add", + target: "cronJobs.heartbeat", + blocked: false, + reason: "target adds cron job", + }, + ]); + let config: OpenClawConfig = { agents: { entries: {} } }; + const workspaceRollback = vi.fn(async () => undefined); + const mcpRollback = vi.fn(async () => undefined); + const packageRollback = vi.fn(async () => undefined); + + await expect( + applyClawUpdatePlan( + updatePlan, + { targetManifest: manifest, targetSource: source }, + { + config, + env, + ...consent(updatePlan), + rebuildPlan: vi.fn(async () => updatePlan), + buildAddPlan: vi.fn(async () => addPlan), + applyWorkspace: vi.fn(async () => ({ + appliedPaths: [], + rollback: workspaceRollback, + })), + applyMcp: vi.fn(async () => ({ appliedNames: [], rollback: mcpRollback })), + applyPackage: vi.fn(async () => ({ + appliedIds: [], + rollback: packageRollback, + })), + commitConfig: async (transform) => { + config = transform(config); + }, + applyCron: vi.fn(async () => { + throw new ClawCronUpdateError("cron transport closed", true); + }), + }, + ), + ).rejects.toMatchObject({ code: "update_partial" }); + + const partialRecord = readClawInstallRecord("worker", { env }); + expect(config.agents?.entries?.worker).toEqual({ + name: "Worker v2", + workspace: "/tmp/workspace-worker", + }); + expect(partialRecord).toMatchObject({ + claw: { version: "2.0.0", integrity: "sha256:target" }, + planIntegrity: addPlan.planIntegrity, + status: "partial", + }); + expect(partialRecord?.agentConfigDigest).not.toBe(currentRecord.agentConfigDigest); + expect(packageRollback).not.toHaveBeenCalled(); + expect(mcpRollback).not.toHaveBeenCalled(); + expect(workspaceRollback).not.toHaveBeenCalled(); + }); + + it("stops before agent mutation when a package update fails", async () => { + const targetPackage = { + kind: "skill" as const, + source: "clawhub" as const, + ref: "search", + version: "1.0.0", + }; + const packageDetails = { + ...targetPackage, + integrity: "sha256:search", + ownerAction: "install" as const, + }; + const desiredDigest = `sha256:${createHash("sha256") + .update( + stableStringify({ + package: targetPackage, + integrity: packageDetails.integrity, + installId: undefined, + riskWarning: undefined, + }), + ) + .digest("hex")}`; + const updatePlan = plan([ + { + kind: "package", + id: "skill:search", + action: "add", + target: "packages.skill:search", + blocked: false, + reason: "target adds package", + desiredDigest, + }, + ]); + const packageManifest = { ...manifest, packages: [targetPackage] }; + const packageAddPlan = { + ...addPlan, + actions: [ + { + kind: "package" as const, + id: "skill:search", + action: "install" as const, + target: "clawhub:search@1.0.0", + details: packageDetails, + blocked: false, + }, + ], + }; + const commitConfig = vi.fn(); + + await expect( + applyClawUpdatePlan( + updatePlan, + { targetManifest: packageManifest, targetSource: source }, + { + config: {}, + ...consent(updatePlan), + rebuildPlan: vi.fn(async () => updatePlan), + buildAddPlan: vi.fn(async () => packageAddPlan), + readInstall: vi.fn(() => install), + persistInstall: vi.fn(), + applyPackage: vi.fn(async () => { + throw new Error("installer unavailable"); + }), + commitConfig, + }, + ), + ).rejects.toMatchObject({ code: "package_update_failed" }); + expect(commitConfig).not.toHaveBeenCalled(); + }); + + it("preserves resolved plugin metadata when applying an owned version upgrade", async () => { + const packageRoot = tempDirs.make("openclaw-claw-plugin-update-"); + const targetSource = { + ...source, + packageRoot, + manifestPath: join(packageRoot, "openclaw.claw.json"), + }; + const targetPackage = { + kind: "plugin" as const, + source: "clawhub" as const, + ref: "github", + version: "2.0.0", + }; + const resolved = { + integrity: `sha256:${"a".repeat(64)}`, + installId: "github", + warning: "Review @acme/github before installation.", + }; + const desiredDigest = `sha256:${createHash("sha256") + .update( + stableStringify({ + package: targetPackage, + integrity: resolved.integrity, + installId: resolved.installId, + riskWarning: resolved.warning, + }), + ) + .digest("hex")}`; + const updatePlan = plan([ + { + kind: "package", + id: "plugin:github", + action: "change", + target: "packages.plugin:github", + blocked: false, + reason: "target changes package version", + desiredDigest, + }, + ]); + const packagePreflight = vi.fn(async () => ({ + ok: false as const, + code: "plugin_version_conflict", + message: "The Claw owns the installed previous version.", + installedVersion: "1.0.0", + ...resolved, + })); + const applyPackage = vi.fn(async () => ({ + appliedIds: ["plugin:github"], + rollback: vi.fn(async () => undefined), + })); + + await applyClawUpdatePlan( + updatePlan, + { targetManifest: { ...manifest, packages: [targetPackage] }, targetSource }, + { + config: {}, + ...consent(updatePlan), + rebuildPlan: vi.fn(async () => updatePlan), + packagePreflight, + readInstall: vi.fn(() => install), + persistInstall: vi.fn(() => ({ ...install, claw: source })), + applyWorkspace: vi.fn(async () => ({ + appliedPaths: [], + rollback: vi.fn(async () => undefined), + })), + applyMcp: vi.fn(async () => ({ + appliedNames: [], + rollback: vi.fn(async () => undefined), + })), + applyCron: vi.fn(async () => ({ + appliedIds: [], + rollback: vi.fn(async () => undefined), + })), + applyPackage, + }, + ); + + expect(packagePreflight).toHaveBeenCalledOnce(); + expect(applyPackage).toHaveBeenCalledOnce(); + }); + + it("rolls workspace and MCP changes back when root provenance cannot advance", async () => { + const updatePlan = plan([ + { + kind: "workspaceFile", + id: "SOUL.md", + action: "change", + target: "/tmp/workspace-worker/SOUL.md", + blocked: false, + reason: "target changed", + }, + ]); + const workspaceRollback = vi.fn(async () => undefined); + const mcpRollback = vi.fn(async () => undefined); + + await expect( + applyClawUpdatePlan( + updatePlan, + { targetManifest: manifest, targetSource: source }, + { + config: {}, + ...consent(updatePlan), + rebuildPlan: vi.fn(async () => updatePlan), + buildAddPlan: vi.fn(async () => addPlan), + readInstall: vi.fn(() => install), + applyWorkspace: vi.fn(async () => ({ + appliedPaths: ["SOUL.md"], + rollback: workspaceRollback, + })), + applyMcp: vi.fn(async () => ({ appliedNames: [], rollback: mcpRollback })), + persistInstall: vi.fn(() => { + throw new Error("provenance race"); + }), + }, + ), + ).rejects.toMatchObject({ code: "provenance_update_failed" }); + expect(mcpRollback).toHaveBeenCalledOnce(); + expect(workspaceRollback).toHaveBeenCalledOnce(); + }); + + it("restores the agent when the config commit throws after transforming state", async () => { + const currentAgent = { id: "worker", name: "Worker" }; + const currentDigest = `sha256:${createHash("sha256").update(stableStringify(currentAgent)).digest("hex")}`; + const updatePlan = plan([ + { + kind: "agent", + id: "worker", + action: "change", + target: 'agents.entries["worker"]', + blocked: false, + reason: "target changed", + currentDigest, + }, + ]); + let config: OpenClawConfig = { agents: { entries: { worker: { name: "Worker" } } } }; + let commits = 0; + + await expect( + applyClawUpdatePlan( + updatePlan, + { targetManifest: manifest, targetSource: source }, + { + config, + ...consent(updatePlan), + rebuildPlan: vi.fn(async () => updatePlan), + buildAddPlan: vi.fn(async () => addPlan), + readInstall: vi.fn(() => install), + commitConfig: async (transform) => { + config = transform(config); + commits += 1; + if (commits === 1) { + throw new Error("post-write failure"); + } + }, + }, + ), + ).rejects.toMatchObject({ code: "agent_update_failed" }); + expect(config.agents?.entries?.worker).toEqual({ name: "Worker" }); + expect(commits).toBe(2); + }); + + it("does not recreate an agent removed after planning", async () => { + const currentAgent = { id: "worker", name: "Worker" }; + const currentDigest = `sha256:${createHash("sha256").update(stableStringify(currentAgent)).digest("hex")}`; + const updatePlan = plan([ + { + kind: "agent", + id: "worker", + action: "change", + target: 'agents.entries["worker"]', + blocked: false, + reason: "target changed", + currentDigest, + }, + ]); + let config: OpenClawConfig = { agents: { entries: {} } }; + + await expect( + applyClawUpdatePlan( + updatePlan, + { targetManifest: manifest, targetSource: source }, + { + config, + ...consent(updatePlan), + rebuildPlan: vi.fn(async () => updatePlan), + buildAddPlan: vi.fn(async () => addPlan), + readInstall: vi.fn(() => install), + commitConfig: async (transform) => { + config = transform(config); + }, + }, + ), + ).rejects.toMatchObject({ code: "agent_changed" }); + expect(config.agents?.entries?.worker).toBeUndefined(); + }); + + it("rejects a stale or manually blocked plan", async () => { + const updatePlan = plan([]); + const changed = { ...updatePlan, targetClaw: { ...updatePlan.targetClaw!, version: "3.0.0" } }; + await expect( + applyClawUpdatePlan( + updatePlan, + { targetManifest: manifest, targetSource: source }, + { + config: {}, + ...consent(updatePlan), + rebuildPlan: vi.fn(async () => changed), + readInstall: vi.fn(() => install), + }, + ), + ).rejects.toMatchObject({ code: "update_changed" }); + + await expect( + applyClawUpdatePlan( + { + ...updatePlan, + actions: [ + { + kind: "agent", + id: "worker", + action: "manual", + target: "agent", + blocked: true, + reason: "drift", + }, + ], + }, + { targetManifest: manifest, targetSource: source }, + { config: {}, ...consent(updatePlan), readInstall: vi.fn(() => install) }, + ), + ).rejects.toMatchObject({ code: "update_blocked" }); + }); +}); +import { createHash } from "node:crypto"; +import { stableStringify } from "../agents/stable-stringify.js"; diff --git a/src/claws/update-apply.ts b/src/claws/update-apply.ts new file mode 100644 index 000000000000..473bdf8feba5 --- /dev/null +++ b/src/claws/update-apply.ts @@ -0,0 +1,550 @@ +import { createHash } from "node:crypto"; +import { listAgentEntries } from "../agents/agent-scope.js"; +import { stableStringify } from "../agents/stable-stringify.js"; +import { transformConfigFileWithRetry } from "../config/config.js"; +import type { AgentConfig } from "../config/types.agents.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js"; +import { + applyClawCronUpdate, + ClawCronUpdateError, + type ClawCronUpdateExecution, +} from "./cron-update.js"; +import type { ClawCronGateway } from "./cron.js"; +import { buildClawAddPlan, type ClawAddPlanContext } from "./lifecycle.js"; +import { + applyClawMcpUpdate, + ClawMcpUpdateError, + type ClawMcpUpdateExecution, +} from "./mcp-update.js"; +import { + applyClawPackageUpdate, + ClawPackageUpdateError, + type ClawPackageUpdateExecution, +} from "./package-update.js"; +import { + readClawInstallRecord, + updateClawInstallRecord, + updateClawInstallRecordStatus, + type PersistedClawInstall, +} from "./provenance.js"; +import { + CLAW_OUTPUT_STABILITY, + type ClawManifest, + type ClawPackage, + type ClawSourceIdentity, +} from "./types.js"; +import { buildClawUpdatePlan, type ClawUpdateAction, type ClawUpdatePlan } from "./update-plan.js"; +import { + applyClawWorkspaceUpdate, + ClawWorkspaceUpdateError, + type ClawWorkspaceUpdateExecution, +} from "./workspace-update.js"; + +export const CLAW_UPDATE_RESULT_SCHEMA_VERSION = "openclaw.clawUpdateResult.v1" as const; + +type ConfigCommit = (transform: (config: OpenClawConfig) => OpenClawConfig) => Promise; + +function digest(value: unknown): string { + return `sha256:${createHash("sha256").update(stableStringify(value)).digest("hex")}`; +} + +export class ClawUpdateMutationError extends Error { + constructor( + readonly code: string, + message: string, + ) { + super(message); + this.name = "ClawUpdateMutationError"; + } +} + +type ClawUpdateResult = { + schemaVersion: typeof CLAW_UPDATE_RESULT_SCHEMA_VERSION; + stability: typeof CLAW_OUTPUT_STABILITY; + dryRun: false; + mutationAllowed: true; + status: "complete"; + agentId: string; + previousClaw: NonNullable; + targetClaw: NonNullable; + appliedActions: ClawUpdateAction[]; + installRecord: PersistedClawInstall; +}; + +function comparablePlan(plan: ClawUpdatePlan): unknown { + return { + found: plan.found, + agentId: plan.agentId, + currentClaw: plan.currentClaw, + targetClaw: plan.targetClaw, + actions: plan.actions, + capabilityChanges: plan.capabilityChanges, + blockers: plan.blockers, + }; +} + +export async function applyClawUpdatePlan( + plan: ClawUpdatePlan, + params: { + targetManifest: ClawManifest; + targetSource: ClawSourceIdentity; + }, + options: OpenClawStateDatabaseOptions & { + config: OpenClawConfig; + sourceMcpServers: Record>; + consentPlanIntegrity: string | undefined; + packagePreflight?: ClawAddPlanContext["packagePreflight"]; + commitConfig?: ConfigCommit; + rebuildPlan?: typeof buildClawUpdatePlan; + buildAddPlan?: typeof buildClawAddPlan; + readInstall?: typeof readClawInstallRecord; + persistInstall?: typeof updateClawInstallRecord; + applyWorkspace?: typeof applyClawWorkspaceUpdate; + applyMcp?: typeof applyClawMcpUpdate; + applyCron?: typeof applyClawCronUpdate; + applyPackage?: typeof applyClawPackageUpdate; + cronGateway?: ClawCronGateway; + }, +): Promise { + if (options.consentPlanIntegrity !== plan.planIntegrity) { + throw new ClawUpdateMutationError( + "plan_integrity_mismatch", + "Consent does not match the current Claw update plan; run update --dry-run again.", + ); + } + if (!plan.found || plan.blockers.length > 0 || plan.actions.some((action) => action.blocked)) { + throw new ClawUpdateMutationError( + "update_blocked", + "The Claw update plan contains blockers or manual actions.", + ); + } + + const rebuildPlan = options.rebuildPlan ?? buildClawUpdatePlan; + const fresh = await rebuildPlan({ + agentId: plan.agentId, + targetManifest: params.targetManifest, + targetSource: params.targetSource, + config: options.config, + sourceMcpServers: options.sourceMcpServers, + stateOptions: options, + packagePreflight: options.packagePreflight, + }); + if ( + fresh.planIntegrity !== plan.planIntegrity || + stableStringify(comparablePlan(fresh)) !== stableStringify(comparablePlan(plan)) + ) { + throw new ClawUpdateMutationError( + "update_changed", + "Claw-owned state changed after update planning; build a new dry-run plan.", + ); + } + + const actionable = fresh.actions.filter((action) => action.action !== "unchanged"); + const unsupported = actionable.filter( + (action) => + action.kind !== "agent" && + action.kind !== "workspaceFile" && + action.kind !== "mcpServer" && + action.kind !== "cronJob" && + action.kind !== "package", + ); + if (unsupported.length > 0) { + throw new ClawUpdateMutationError( + "unsupported_update_actions", + `This update slice cannot yet apply: ${unsupported.map((action) => `${action.kind}:${action.id}`).join(", ")}.`, + ); + } + if (!fresh.currentClaw || !fresh.targetClaw) { + throw new ClawUpdateMutationError("update_invalid", "The Claw update plan lacks identity."); + } + + const buildAddPlan = options.buildAddPlan ?? buildClawAddPlan; + const readInstall = options.readInstall ?? readClawInstallRecord; + const currentInstall = readInstall(fresh.agentId, options); + if (!currentInstall) { + throw new ClawUpdateMutationError("update_changed", "The Claw install record disappeared."); + } + const partialMutation = (message: string): ClawUpdateMutationError => { + try { + updateClawInstallRecordStatus(fresh.agentId, "partial", options); + } catch { + // Preserve the owner failure; doctor can still reconcile subordinate pending records. + } + return new ClawUpdateMutationError("update_partial", message); + }; + const targetAddPlan = await buildAddPlan({ + manifest: params.targetManifest, + source: params.targetSource, + context: { + agentId: fresh.agentId, + workspace: currentInstall.workspace, + packagePreflight: async (pkg, workspace) => { + const preflight = options.packagePreflight + ? await options.packagePreflight(pkg, workspace) + : { + ok: false, + code: "package_install_unavailable", + message: "Package preflight is unavailable.", + }; + const action = fresh.actions.find( + (candidate) => candidate.kind === "package" && candidate.id === `${pkg.kind}:${pkg.ref}`, + ); + return !preflight.ok && + pkg.kind === "plugin" && + preflight.code === "plugin_version_conflict" && + action?.action === "change" + ? { + ok: true, + action: "install" as const, + ...(preflight.integrity ? { integrity: preflight.integrity } : {}), + ...(preflight.installId ? { installId: preflight.installId } : {}), + ...(preflight.warning ? { warning: preflight.warning } : {}), + } + : preflight; + }, + }, + }); + if ( + targetAddPlan.blockers.some( + (blocker) => blocker.code !== "agent_id_collision" && blocker.code !== "workspace_collision", + ) + ) { + throw new ClawUpdateMutationError( + "update_target_blocked", + "The target Claw cannot be safely materialized for update.", + ); + } + const targetPackages = new Map( + params.targetManifest.packages.map((pkg) => [`${pkg.kind}:${pkg.ref}`, pkg] as const), + ); + for (const action of fresh.actions.filter( + (candidate) => + candidate.kind === "package" && + candidate.action !== "release" && + candidate.action !== "remove", + )) { + const target = targetPackages.get(action.id); + const addAction = targetAddPlan.actions.find( + (candidate) => candidate.kind === "package" && candidate.id === action.id, + ); + const details = addAction?.details; + if ( + !target || + action.desiredDigest !== + digest({ + package: target, + integrity: details?.integrity, + installId: details?.installId, + riskWarning: details?.riskWarning, + }) + ) { + throw new ClawUpdateMutationError( + "update_changed", + `Resolved package ${JSON.stringify(action.id)} changed after update planning; build a new dry-run plan.`, + ); + } + } + + const applyWorkspace = options.applyWorkspace ?? applyClawWorkspaceUpdate; + let workspaceExecution: ClawWorkspaceUpdateExecution; + try { + workspaceExecution = await applyWorkspace(fresh, targetAddPlan, options); + } catch (error) { + if (error instanceof ClawWorkspaceUpdateError && error.partial) { + throw partialMutation(error.message); + } + throw new ClawUpdateMutationError( + "workspace_update_failed", + error instanceof Error ? error.message : String(error), + ); + } + + const applyMcp = options.applyMcp ?? applyClawMcpUpdate; + let mcpExecution: ClawMcpUpdateExecution; + try { + mcpExecution = await applyMcp(fresh, params.targetManifest, options); + } catch (error) { + const partial = error instanceof ClawMcpUpdateError && error.partial; + try { + await workspaceExecution.rollback(); + } catch (rollbackError) { + throw partialMutation( + `${error instanceof Error ? error.message : String(error)}; workspace rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + ); + } + if (partial) { + throw partialMutation(`${error.message}; MCP config write outcome is uncertain`); + } + throw new ClawUpdateMutationError( + "mcp_update_failed", + error instanceof Error ? error.message : String(error), + ); + } + + const applyPackage = options.applyPackage ?? applyClawPackageUpdate; + let packageExecution: ClawPackageUpdateExecution; + try { + packageExecution = await applyPackage(fresh, params.targetManifest, targetAddPlan, options); + } catch (error) { + const rollbackFailures: string[] = []; + try { + await mcpExecution.rollback(); + } catch (rollbackError) { + rollbackFailures.push( + `MCP rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + ); + } + try { + await workspaceExecution.rollback(); + } catch (rollbackError) { + rollbackFailures.push( + `workspace rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + ); + } + if (error instanceof ClawPackageUpdateError && error.partial) { + rollbackFailures.unshift("package artifact rollback is unavailable"); + } + if (rollbackFailures.length > 0) { + throw partialMutation( + `${error instanceof Error ? error.message : String(error)}; ${rollbackFailures.join("; ")}`, + ); + } + throw new ClawUpdateMutationError( + "package_update_failed", + error instanceof Error ? error.message : String(error), + ); + } + + const agentAction = fresh.actions.find((action) => action.kind === "agent"); + const commit: ConfigCommit = + options.commitConfig ?? + (async (transform) => { + await transformConfigFileWithRetry({ + afterWrite: { mode: "auto" }, + transform: (config) => ({ nextConfig: transform(config) }), + }); + }); + let previousAgent: AgentConfig | undefined; + let agentChanged = false; + const rollbackAgent = async (): Promise => { + if (!agentChanged) { + return; + } + await commit((config) => { + const current = listAgentEntries(config).find((agent) => agent.id === fresh.agentId); + const targetDigest = `sha256:${createHash("sha256").update(stableStringify(targetAddPlan.agent.config)).digest("hex")}`; + const liveDigest = current + ? `sha256:${createHash("sha256").update(stableStringify(current)).digest("hex")}` + : undefined; + if (liveDigest !== targetDigest) { + throw new Error("The agent changed before rollback."); + } + const nextEntries = { ...config.agents?.entries }; + if (previousAgent) { + const { id: _id, ...previousEntry } = previousAgent; + nextEntries[fresh.agentId] = previousEntry; + } else { + delete nextEntries[fresh.agentId]; + } + return { ...config, agents: { ...config.agents, entries: nextEntries } }; + }); + agentChanged = false; + }; + if (agentAction?.action === "change") { + try { + await commit((config) => { + const current = listAgentEntries(config).find((agent) => agent.id === fresh.agentId); + previousAgent = current; + if (agentAction.currentDigest !== undefined) { + if (!current) { + throw new ClawUpdateMutationError( + "agent_changed", + "The owned agent entry disappeared during update.", + ); + } + const liveDigest = `sha256:${createHash("sha256").update(stableStringify(current)).digest("hex")}`; + if (liveDigest !== agentAction.currentDigest) { + throw new ClawUpdateMutationError( + "agent_changed", + "The owned agent entry changed during update.", + ); + } + } + const nextEntries = { ...config.agents?.entries }; + const { id: _id, ...targetEntry } = targetAddPlan.agent.config; + nextEntries[fresh.agentId] = targetEntry; + agentChanged = true; + return { ...config, agents: { ...config.agents, entries: nextEntries } }; + }); + } catch (error) { + const rollbackFailures: string[] = []; + try { + await rollbackAgent(); + } catch (rollbackError) { + rollbackFailures.push( + `agent rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + ); + } + try { + await packageExecution.rollback(); + } catch (rollbackError) { + rollbackFailures.push( + `package rollback incomplete: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + ); + } + try { + await mcpExecution.rollback(); + } catch (rollbackError) { + rollbackFailures.push( + `MCP rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + ); + } + try { + await workspaceExecution.rollback(); + } catch (rollbackError) { + rollbackFailures.push( + `workspace rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + ); + } + if (rollbackFailures.length > 0) { + throw partialMutation( + `${error instanceof Error ? error.message : String(error)}; ${rollbackFailures.join("; ")}`, + ); + } + if (error instanceof ClawUpdateMutationError) { + throw error; + } + throw new ClawUpdateMutationError( + "agent_update_failed", + error instanceof Error ? error.message : String(error), + ); + } + } + + const persistInstall = options.persistInstall ?? updateClawInstallRecord; + const applyCron = options.applyCron ?? applyClawCronUpdate; + let cronExecution: ClawCronUpdateExecution; + try { + cronExecution = await applyCron(fresh, params.targetManifest, options); + } catch (error) { + if (error instanceof ClawCronUpdateError && error.partial) { + try { + persistInstall(targetAddPlan, { + ...options, + expectedClaw: fresh.currentClaw, + status: "partial", + }); + } catch (persistError) { + throw partialMutation( + `${error.message}; cron gateway mutation outcome is uncertain; provenance update failed: ${persistError instanceof Error ? persistError.message : String(persistError)}`, + ); + } + throw partialMutation(`${error.message}; cron gateway mutation outcome is uncertain`); + } + const rollbackFailures: string[] = []; + try { + await rollbackAgent(); + } catch (rollbackError) { + rollbackFailures.push( + `agent rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + ); + } + try { + await packageExecution.rollback(); + } catch (rollbackError) { + rollbackFailures.push( + `package rollback incomplete: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + ); + } + try { + await mcpExecution.rollback(); + } catch (rollbackError) { + rollbackFailures.push( + `MCP rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + ); + } + try { + await workspaceExecution.rollback(); + } catch (rollbackError) { + rollbackFailures.push( + `workspace rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + ); + } + if (rollbackFailures.length > 0) { + throw partialMutation( + `${error instanceof Error ? error.message : String(error)}; ${rollbackFailures.join("; ")}`, + ); + } + throw new ClawUpdateMutationError( + "cron_update_failed", + error instanceof Error ? error.message : String(error), + ); + } + + let installRecord: PersistedClawInstall; + try { + installRecord = persistInstall(targetAddPlan, { + ...options, + expectedClaw: fresh.currentClaw, + }); + } catch (error) { + const rollbackFailures: string[] = []; + try { + await rollbackAgent(); + } catch (rollbackError) { + rollbackFailures.push( + `agent rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + ); + } + try { + await packageExecution.rollback(); + } catch (rollbackError) { + rollbackFailures.push( + `package rollback incomplete: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + ); + } + try { + await cronExecution.rollback(); + } catch (rollbackError) { + rollbackFailures.push( + `cron rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + ); + } + try { + await mcpExecution.rollback(); + } catch (rollbackError) { + rollbackFailures.push( + `MCP rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + ); + } + try { + await workspaceExecution.rollback(); + } catch (rollbackError) { + rollbackFailures.push( + `workspace rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + ); + } + if (rollbackFailures.length > 0) { + throw partialMutation( + `${error instanceof Error ? error.message : String(error)}; ${rollbackFailures.join("; ")}`, + ); + } + throw new ClawUpdateMutationError( + "provenance_update_failed", + error instanceof Error ? error.message : String(error), + ); + } + return { + schemaVersion: CLAW_UPDATE_RESULT_SCHEMA_VERSION, + stability: CLAW_OUTPUT_STABILITY, + dryRun: false, + mutationAllowed: true, + status: "complete", + agentId: fresh.agentId, + previousClaw: fresh.currentClaw, + targetClaw: fresh.targetClaw, + appliedActions: actionable, + installRecord, + }; +} diff --git a/src/claws/update-plan-summary.ts b/src/claws/update-plan-summary.ts new file mode 100644 index 000000000000..f3494eeebcc7 --- /dev/null +++ b/src/claws/update-plan-summary.ts @@ -0,0 +1,21 @@ +import type { ClawUpdateCapabilityChange } from "./update-capability-changes.js"; +import type { ClawUpdateAction, ClawUpdatePlan } from "./update-plan-types.js"; + +export function summarizeClawUpdatePlan( + actions: ClawUpdateAction[], + capabilityChanges: ClawUpdateCapabilityChange[], +): ClawUpdatePlan["summary"] { + return { + totalActions: actions.length, + added: actions.filter((action) => action.action === "add").length, + changed: actions.filter((action) => action.action === "change").length, + removed: actions.filter((action) => action.action === "remove").length, + released: actions.filter((action) => action.action === "release").length, + unchanged: actions.filter((action) => action.action === "unchanged").length, + manual: actions.filter((action) => action.action === "manual").length, + blocked: actions.filter((action) => action.blocked).length, + capabilityChanges: capabilityChanges.length, + capabilityEscalations: capabilityChanges.filter((change) => change.requiresDistinctConsent) + .length, + }; +} diff --git a/src/claws/update-plan-types.ts b/src/claws/update-plan-types.ts index 0b1bf36b5e5f..1e0c06e35ada 100644 --- a/src/claws/update-plan-types.ts +++ b/src/claws/update-plan-types.ts @@ -11,6 +11,7 @@ export type ClawUpdateAction = { blocked: boolean; reason: string; currentDigest?: string; + currentPresent?: boolean; desiredDigest?: string; }; diff --git a/src/claws/update-plan.ts b/src/claws/update-plan.ts index bfd92418eef9..70c9aab08045 100644 --- a/src/claws/update-plan.ts +++ b/src/claws/update-plan.ts @@ -13,6 +13,7 @@ import { readClawStatus } from "./lifecycle-state.js"; import { buildClawAddPlan } from "./lifecycle.js"; import { digestClawMcpServer, readClawMcpServerRefsByName } from "./mcp.js"; import type { PackageRemovalDeps } from "./package-remove.js"; +import { digestClawPackageRef } from "./package-update-provenance.js"; import { readClawPackageRefs } from "./provenance.js"; import { CLAW_OUTPUT_STABILITY, @@ -29,13 +30,18 @@ import { type ClawUpdateCapabilityChange, } from "./update-capability-changes.js"; import { makeEmptyClawUpdatePlan } from "./update-plan-empty.js"; +import { summarizeClawUpdatePlan } from "./update-plan-summary.js"; import { CLAW_UPDATE_PLAN_SCHEMA_VERSION, type ClawUpdateAction, type ClawUpdatePlan, } from "./update-plan-types.js"; -export { CLAW_UPDATE_PLAN_SCHEMA_VERSION, type ClawUpdatePlan } from "./update-plan-types.js"; +export { + CLAW_UPDATE_PLAN_SCHEMA_VERSION, + type ClawUpdateAction, + type ClawUpdatePlan, +} from "./update-plan-types.js"; function digest(value: unknown): string { return `sha256:${createHash("sha256").update(stableStringify(value)).digest("hex")}`; @@ -45,25 +51,6 @@ function diagnostic(code: string, path: string, message: string): ClawDiagnostic return { level: "error", code, phase: "plan", path, message }; } -function summarize( - actions: ClawUpdateAction[], - capabilityChanges: ClawUpdateCapabilityChange[], -): ClawUpdatePlan["summary"] { - return { - totalActions: actions.length, - added: actions.filter((action) => action.action === "add").length, - changed: actions.filter((action) => action.action === "change").length, - removed: actions.filter((action) => action.action === "remove").length, - released: actions.filter((action) => action.action === "release").length, - unchanged: actions.filter((action) => action.action === "unchanged").length, - manual: actions.filter((action) => action.action === "manual").length, - blocked: actions.filter((action) => action.blocked).length, - capabilityChanges: capabilityChanges.length, - capabilityEscalations: capabilityChanges.filter((change) => change.requiresDistinctConsent) - .length, - }; -} - function manualState(state: string): boolean { return state === "modified" || state === "unsafe" || state === "pending" || state === "failed"; } @@ -254,7 +241,7 @@ export async function buildClawUpdatePlan(params: { kind: "agent", id: agentId, action: agentAction, - target: `agents.list.${agentId}`, + target: `agents.entries[${JSON.stringify(agentId)}]`, blocked: agentAction === "manual", reason: agentAction === "manual" @@ -361,6 +348,7 @@ export async function buildClawUpdatePlan(params: { ? "Managed workspace content already matches the target source." : "Target source changes or restores managed workspace content.", ...(current ? { currentDigest: current.contentDigest } : {}), + ...(current ? { currentPresent: current.state !== "missing" } : {}), desiredDigest: target.digest, }); } @@ -382,6 +370,7 @@ export async function buildClawUpdatePlan(params: { ? "Target removes this file, but local drift must be preserved manually." : "Target manifest removes this managed workspace file.", currentDigest: current.contentDigest, + currentPresent: current.state !== "missing", }); } @@ -457,7 +446,7 @@ export async function buildClawUpdatePlan(params: { : action === "unchanged" ? "Recorded package reference already matches the exact target version." : "Target manifest changes the exact package version.", - ...(current ? { currentDigest: digest(current) } : {}), + ...(current ? { currentDigest: digestClawPackageRef(current) } : {}), desiredDigest: digest({ package: target, integrity: preflight?.integrity, @@ -501,7 +490,7 @@ export async function buildClawUpdatePlan(params: { reason: manual ? `Target removes this package, but current lifecycle state is ${current.state}.` : "Target manifest releases this package dependency while preserving the artifact.", - currentDigest: digest(current), + currentDigest: digestClawPackageRef(current), }); const capabilityChange = packageCapabilityChange({ pkg: current, @@ -697,7 +686,7 @@ export async function buildClawUpdatePlan(params: { version: params.targetSource.version, integrity: params.targetSource.integrity, }, - summary: summarize(actions, capabilityChanges), + summary: summarizeClawUpdatePlan(actions, capabilityChanges), actions, capabilityChanges, blockers, diff --git a/src/claws/workspace-update.test.ts b/src/claws/workspace-update.test.ts new file mode 100644 index 000000000000..2f319419c836 --- /dev/null +++ b/src/claws/workspace-update.test.ts @@ -0,0 +1,123 @@ +import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; +import { applyClawAddPlan } from "./add.js"; +import { buildClawAddPlan } from "./lifecycle.js"; +import { parseClawManifest } from "./schema.js"; +import type { ClawSourceIdentity } from "./types.js"; +import { buildClawUpdatePlan } from "./update-plan.js"; +import { applyClawWorkspaceUpdate } from "./workspace-update.js"; +import { readClawWorkspaceFiles } from "./workspace.js"; + +afterEach(() => closeOpenClawStateDatabaseForTest()); +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +describe("applyClawWorkspaceUpdate", () => { + it("applies add/change/remove actions and can roll them back with provenance", async () => { + const root = tempDirs.make("openclaw-claw-workspace-update-"); + const currentRoot = join(root, "current"); + const targetRoot = join(root, "target"); + await mkdir(currentRoot); + await mkdir(targetRoot); + await writeFile(join(currentRoot, "SOUL.md"), "current soul\n", "utf8"); + await writeFile(join(currentRoot, "OLD.md"), "old\n", "utf8"); + await writeFile(join(targetRoot, "SOUL.md"), "target soul\n", "utf8"); + await writeFile(join(targetRoot, "NEW.md"), "new\n", "utf8"); + + const currentParsed = parseClawManifest({ + schemaVersion: 1, + agent: { id: "worker" }, + workspace: { + bootstrapFiles: { "SOUL.md": { source: "SOUL.md" } }, + files: [{ source: "OLD.md", path: "OLD.md" }], + }, + }); + const targetParsed = parseClawManifest({ + schemaVersion: 1, + agent: { id: "worker" }, + workspace: { + bootstrapFiles: { "SOUL.md": { source: "SOUL.md" } }, + files: [{ source: "NEW.md", path: "NEW.md" }], + }, + }); + if (!currentParsed.ok || !targetParsed.ok) { + throw new Error("fixture manifest invalid"); + } + const currentSource: ClawSourceIdentity = { + kind: "package", + name: "@acme/worker", + version: "1.0.0", + packageRoot: currentRoot, + manifestPath: join(currentRoot, "openclaw.claw.json"), + integrityKind: "artifact", + integrity: "sha256:current", + byteLength: 1, + }; + const targetSource: ClawSourceIdentity = { + ...currentSource, + version: "2.0.0", + packageRoot: targetRoot, + manifestPath: join(targetRoot, "openclaw.claw.json"), + integrity: "sha256:target", + }; + const workspace = join(root, "workspace"); + const env = { OPENCLAW_STATE_DIR: join(root, "state") }; + const currentAddPlan = await buildClawAddPlan({ + manifest: currentParsed.manifest, + source: currentSource, + context: { workspace }, + }); + let config: OpenClawConfig = {}; + await applyClawAddPlan(currentAddPlan, { + env, + consentPlanIntegrity: currentAddPlan.planIntegrity, + commitConfig: async (transform) => { + config = transform(config); + }, + }); + const updatePlan = await buildClawUpdatePlan({ + agentId: "worker", + targetManifest: targetParsed.manifest, + targetSource, + config, + sourceMcpServers: {}, + stateOptions: { env }, + }); + const targetAddPlan = await buildClawAddPlan({ + manifest: targetParsed.manifest, + source: targetSource, + context: { agentId: "worker", workspace }, + }); + + const execution = await applyClawWorkspaceUpdate(updatePlan, targetAddPlan, { + env, + nowMs: 20, + }); + + await expect(readFile(join(workspace, "SOUL.md"), "utf8")).resolves.toBe("target soul\n"); + await expect(readFile(join(workspace, "NEW.md"), "utf8")).resolves.toBe("new\n"); + await expect(access(join(workspace, "OLD.md"))).rejects.toThrow(); + expect(readClawWorkspaceFiles("worker", { env }).map((record) => record.path)).toEqual([ + "NEW.md", + "SOUL.md", + ]); + + await execution.rollback(); + + await expect(readFile(join(workspace, "SOUL.md"), "utf8")).resolves.toBe("current soul\n"); + await expect(readFile(join(workspace, "OLD.md"), "utf8")).resolves.toBe("old\n"); + await expect(access(join(workspace, "NEW.md"))).rejects.toThrow(); + expect(readClawWorkspaceFiles("worker", { env }).map((record) => record.path)).toEqual([ + "OLD.md", + "SOUL.md", + ]); + + await rm(join(workspace, "OLD.md")); + await expect( + applyClawWorkspaceUpdate(updatePlan, targetAddPlan, { env, nowMs: 30 }), + ).rejects.toThrow("disappeared after planning"); + }); +}); diff --git a/src/claws/workspace-update.ts b/src/claws/workspace-update.ts new file mode 100644 index 000000000000..f61d040d8bfd --- /dev/null +++ b/src/claws/workspace-update.ts @@ -0,0 +1,207 @@ +import { createHash } from "node:crypto"; +import { relative, resolve, sep } from "node:path"; +import { root as fsSafeRoot } from "../infra/fs-safe.js"; +import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js"; +import type { ClawAddPlan } from "./types.js"; +import type { ClawUpdatePlan } from "./update-plan.js"; +import { + CLAW_WORKSPACE_FILE_RECORD_SCHEMA_VERSION, + deleteClawWorkspaceFileRecord, + readClawWorkspaceFiles, + upsertClawWorkspaceFile, + type PersistedClawWorkspaceFile, +} from "./workspace.js"; + +const MAX_UPDATE_FILE_BYTES = 1024 * 1024; + +export type ClawWorkspaceUpdateExecution = { + appliedPaths: string[]; + rollback: () => Promise; +}; + +export class ClawWorkspaceUpdateError extends Error { + constructor( + message: string, + readonly partial = false, + ) { + super(message); + this.name = "ClawWorkspaceUpdateError"; + } +} + +function digest(content: Uint8Array): string { + return `sha256:${createHash("sha256").update(content).digest("hex")}`; +} + +function relativeWithin(root: string, target: string): string { + const value = relative(root, target); + if (!value || value === ".." || value.startsWith(`..${sep}`)) { + throw new ClawWorkspaceUpdateError(`Path ${JSON.stringify(target)} escapes its owned root.`); + } + return value; +} + +export async function applyClawWorkspaceUpdate( + updatePlan: ClawUpdatePlan, + targetAddPlan: ClawAddPlan, + options: OpenClawStateDatabaseOptions & { nowMs?: number } = {}, +): Promise { + const actions = updatePlan.actions.filter( + (action) => action.kind === "workspaceFile" && action.action !== "unchanged", + ); + if (actions.length === 0) { + return { appliedPaths: [], rollback: async () => undefined }; + } + const workspaceRoot = resolve(targetAddPlan.agent.workspace); + const packageRoot = resolve(targetAddPlan.claw.packageRoot); + const workspace = await fsSafeRoot(workspaceRoot, { + hardlinks: "reject", + maxBytes: MAX_UPDATE_FILE_BYTES, + symlinks: "reject", + }); + const source = await fsSafeRoot(packageRoot, { + hardlinks: "reject", + maxBytes: MAX_UPDATE_FILE_BYTES, + symlinks: "reject", + }); + const currentRefs = new Map( + readClawWorkspaceFiles(updatePlan.agentId, options).map((record) => [record.path, record]), + ); + const targetActions = new Map( + targetAddPlan.actions + .filter((action) => action.kind === "workspaceFile") + .map((action) => [action.id, action]), + ); + const undo: Array<() => Promise> = []; + const appliedPaths: string[] = []; + + const rollback = async () => { + const failures: string[] = []; + for (const revert of undo.toReversed()) { + try { + await revert(); + } catch (error) { + failures.push(error instanceof Error ? error.message : String(error)); + } + } + if (failures.length > 0) { + throw new ClawWorkspaceUpdateError(failures.join("; "), true); + } + }; + + try { + for (const action of actions) { + const path = action.id; + const previousRef = currentRefs.get(path); + const existed = await workspace.exists(path); + const previousContent = existed + ? await workspace.readBytes(path, { maxBytes: MAX_UPDATE_FILE_BYTES }) + : undefined; + if (action.currentPresent === true && !existed) { + throw new ClawWorkspaceUpdateError( + `Workspace file ${JSON.stringify(path)} disappeared after planning.`, + ); + } + if (action.currentPresent === false && existed) { + throw new ClawWorkspaceUpdateError( + `Workspace file ${JSON.stringify(path)} appeared after planning.`, + ); + } + if ( + previousContent && + action.currentDigest && + digest(previousContent) !== action.currentDigest + ) { + throw new ClawWorkspaceUpdateError( + `Workspace file ${JSON.stringify(path)} changed after planning.`, + ); + } + if (action.action === "add" && existed) { + throw new ClawWorkspaceUpdateError( + `Workspace destination ${JSON.stringify(path)} appeared after planning.`, + ); + } + + if (action.action === "remove") { + undo.push(async () => { + if (await workspace.exists(path)) { + throw new Error(`Workspace file ${JSON.stringify(path)} appeared before rollback.`); + } + if (previousContent) { + await workspace.write(path, previousContent, { mkdir: true, overwrite: true }); + } + if (previousRef) { + upsertClawWorkspaceFile(previousRef, options); + } + }); + if (existed) { + await workspace.remove(path); + } + deleteClawWorkspaceFileRecord(updatePlan.agentId, path, options); + appliedPaths.push(path); + continue; + } + + const target = targetActions.get(path); + if (!target?.source || !target.digest) { + throw new ClawWorkspaceUpdateError( + `Target workspace action ${JSON.stringify(path)} lacks source provenance.`, + ); + } + const sourceRelative = relativeWithin(packageRoot, resolve(target.source)); + const content = await source.readBytes(sourceRelative, { maxBytes: MAX_UPDATE_FILE_BYTES }); + if (digest(content) !== target.digest || target.digest !== action.desiredDigest) { + throw new ClawWorkspaceUpdateError( + `Workspace source for ${JSON.stringify(path)} changed after planning.`, + ); + } + const nowMs = options.nowMs ?? Date.now(); + const record: PersistedClawWorkspaceFile = { + schemaVersion: CLAW_WORKSPACE_FILE_RECORD_SCHEMA_VERSION, + agentId: updatePlan.agentId, + workspace: workspace.rootReal, + path, + sourcePath: resolve(target.source), + contentDigest: target.digest, + status: "complete", + createdAtMs: previousRef?.createdAtMs ?? nowMs, + updatedAtMs: nowMs, + }; + undo.push(async () => { + if (!(await workspace.exists(path))) { + throw new Error(`Workspace file ${JSON.stringify(path)} disappeared before rollback.`); + } + const currentContent = await workspace.readBytes(path, { + maxBytes: MAX_UPDATE_FILE_BYTES, + }); + if (digest(currentContent) !== target.digest) { + throw new Error(`Workspace file ${JSON.stringify(path)} changed before rollback.`); + } + if (previousContent) { + await workspace.write(path, previousContent, { mkdir: true, overwrite: true }); + } else if (await workspace.exists(path)) { + await workspace.remove(path); + } + if (previousRef) { + upsertClawWorkspaceFile(previousRef, options); + } else { + deleteClawWorkspaceFileRecord(updatePlan.agentId, path, options); + } + }); + await workspace.write(path, content, { mkdir: true, overwrite: existed }); + upsertClawWorkspaceFile(record, options); + appliedPaths.push(path); + } + } catch (error) { + try { + await rollback(); + } catch (rollbackError) { + throw new ClawWorkspaceUpdateError( + `${error instanceof Error ? error.message : String(error)}; rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + true, + ); + } + throw error; + } + return { appliedPaths, rollback }; +} diff --git a/src/claws/workspace.ts b/src/claws/workspace.ts index 598111dad1c4..ca1c4298c178 100644 --- a/src/claws/workspace.ts +++ b/src/claws/workspace.ts @@ -10,7 +10,8 @@ import { } from "../state/openclaw-state-db.js"; import type { ClawAddPlan, ClawAddPlanAction, ClawDiagnostic } from "./types.js"; -const CLAW_WORKSPACE_FILE_RECORD_SCHEMA_VERSION = "openclaw.clawWorkspaceFileRecord.v1" as const; +export const CLAW_WORKSPACE_FILE_RECORD_SCHEMA_VERSION = + "openclaw.clawWorkspaceFileRecord.v1" as const; const MAX_CLAW_WORKSPACE_FILE_BYTES = 1024 * 1024; @@ -90,25 +91,27 @@ function persistWorkspaceFile( ): void { runOpenClawStateWriteTransaction(({ db }) => { // sqlite-allow-raw: this Claw prototype state-table write is scoped to one owned row. - db.prepare( - `INSERT INTO claw_workspace_files ( + db /* sqlite-allow-raw: Claw workspace-file provenance write. */ + .prepare( + `INSERT INTO claw_workspace_files ( agent_id, target_path, schema_version, workspace, source_path, content_digest, status, created_at_ms, updated_at_ms ) VALUES ( @agent_id, @target_path, @schema_version, @workspace, @source_path, @content_digest, @status, @created_at_ms, @updated_at_ms )`, - ).run({ - agent_id: record.agentId, - target_path: record.path, - schema_version: record.schemaVersion, - workspace: record.workspace, - source_path: record.sourcePath, - content_digest: record.contentDigest, - status: record.status, - created_at_ms: record.createdAtMs, - updated_at_ms: record.updatedAtMs, - }); + ) + .run({ + agent_id: record.agentId, + target_path: record.path, + schema_version: record.schemaVersion, + workspace: record.workspace, + source_path: record.sourcePath, + content_digest: record.contentDigest, + status: record.status, + created_at_ms: record.createdAtMs, + updated_at_ms: record.updatedAtMs, + }); }, options); } @@ -206,6 +209,55 @@ function updateWorkspaceFileStatus( }, options); } +export function upsertClawWorkspaceFile( + record: PersistedClawWorkspaceFile, + options: OpenClawStateDatabaseOptions = {}, +): void { + runOpenClawStateWriteTransaction(({ db }) => { + db /* sqlite-allow-raw: Claw workspace-file provenance write. */ + .prepare( + `INSERT INTO claw_workspace_files ( + agent_id, target_path, schema_version, workspace, source_path, + content_digest, status, created_at_ms, updated_at_ms + ) VALUES ( + @agent_id, @target_path, @schema_version, @workspace, @source_path, + @content_digest, @status, @created_at_ms, @updated_at_ms + ) + ON CONFLICT(agent_id, target_path) DO UPDATE SET + schema_version = excluded.schema_version, + workspace = excluded.workspace, + source_path = excluded.source_path, + content_digest = excluded.content_digest, + status = excluded.status, + created_at_ms = excluded.created_at_ms, + updated_at_ms = excluded.updated_at_ms`, + ) + .run({ + agent_id: record.agentId, + target_path: record.path, + schema_version: record.schemaVersion, + workspace: record.workspace, + source_path: record.sourcePath, + content_digest: record.contentDigest, + status: record.status, + created_at_ms: record.createdAtMs, + updated_at_ms: record.updatedAtMs, + }); + }, options); +} + +export function deleteClawWorkspaceFileRecord( + agentId: string, + path: string, + options: OpenClawStateDatabaseOptions = {}, +): void { + runOpenClawStateWriteTransaction(({ db }) => { + db /* sqlite-allow-raw: Claw workspace-file provenance write. */ + .prepare("DELETE FROM claw_workspace_files WHERE agent_id = ? AND target_path = ?") + .run(agentId, path); + }, options); +} + function workspaceFileActions(plan: ClawAddPlan): ClawAddPlanAction[] { return plan.actions.filter((action) => action.kind === "workspaceFile"); } diff --git a/src/cli/claws-cli.runtime.ts b/src/cli/claws-cli.runtime.ts index 82b255db5238..08b2b48f574c 100644 --- a/src/cli/claws-cli.runtime.ts +++ b/src/cli/claws-cli.runtime.ts @@ -34,7 +34,6 @@ import { CLAW_OUTPUT_STABILITY, type ClawAddPlan, } from "../claws/types.js"; -import { buildClawUpdatePlan, CLAW_UPDATE_PLAN_SCHEMA_VERSION } from "../claws/update-plan.js"; // Runtime handlers for experimental local Claws commands. import { getRuntimeConfig } from "../config/config.js"; import { listConfiguredMcpServers } from "../config/mcp-config.js"; @@ -45,15 +44,12 @@ import { } from "../cron/store.js"; import { redactSensitiveText } from "../logging/redact.js"; import { defaultRuntime, writeRuntimeJson, type RuntimeEnv } from "../runtime.js"; -import { openExistingOpenClawStateDatabaseReadOnly } from "../state/openclaw-state-db.js"; -import { logClawUpdatePlanSummary } from "./claws-cli-update-output.js"; import type { ClawsAddOptions, ClawsExportOptions, ClawsInspectOptions, ClawsRemoveOptions, ClawsStatusOptions, - ClawsUpdateOptions, } from "./claws-cli.js"; import { callGatewayFromCli } from "./gateway-rpc.js"; @@ -406,162 +402,7 @@ export async function runClawsStatusCommand( } } -export async function runClawsUpdateCommand( - target: string, - opts: ClawsUpdateOptions, - runtime: RuntimeEnv = defaultRuntime, -): Promise { - assertExperimentalClawsEnabled(); - if (!opts.dryRun) { - const message = - "Claw update is read-only in this implementation slice; pass --dry-run to preview changes."; - if (opts.json) { - writeRuntimeJson(runtime, { - schemaVersion: CLAW_UPDATE_PLAN_SCHEMA_VERSION, - stability: CLAW_OUTPUT_STABILITY, - ok: false, - error: { code: "update_preview_required", message }, - }); - } else { - runtime.error(message); - } - runtime.exit(1); - return; - } - - const listedMcpServers = await listConfiguredMcpServers(); - if (!listedMcpServers.ok) { - if (opts.json) { - writeRuntimeJson(runtime, { - schemaVersion: CLAW_UPDATE_PLAN_SCHEMA_VERSION, - stability: CLAW_OUTPUT_STABILITY, - dryRun: true, - mutationAllowed: false, - valid: false, - diagnostics: [ - { - level: "error", - code: "mcp_config_unavailable", - phase: "plan", - path: "$.mcpServers", - message: listedMcpServers.error, - }, - ], - }); - } else { - runtime.error(listedMcpServers.error); - } - runtime.exit(1); - return; - } - - let source = opts.from; - if (!source) { - const database = openExistingOpenClawStateDatabaseReadOnly(); - let status: Awaited> | { records: never[] } = { - records: [], - }; - if (database) { - try { - const hasClawInstalls = - database.db /* sqlite-allow-raw: read-only Claw install table-existence probe. */ - .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'claw_installs'") - .get(); - if (hasClawInstalls) { - status = await readClawStatus(target, { - database, - readOnly: true, - sourceMcpServers: listedMcpServers.mcpServers, - }); - } - } finally { - database.walMaintenance.close(); - } - } - if (status.records.length !== 1) { - const message = - status.records.length === 0 - ? `No installed Claw agent matches ${JSON.stringify(target)}.` - : `Claw name ${JSON.stringify(target)} matches multiple agents; use an agent id.`; - if (opts.json) { - writeRuntimeJson(runtime, { - schemaVersion: CLAW_UPDATE_PLAN_SCHEMA_VERSION, - stability: CLAW_OUTPUT_STABILITY, - dryRun: true, - mutationAllowed: false, - valid: false, - diagnostics: [ - { - level: "error", - code: status.records.length === 0 ? "claw_not_found" : "claw_ambiguous", - phase: "plan", - path: "$", - message, - }, - ], - }); - } else { - runtime.error(message); - } - runtime.exit(1); - return; - } - const recorded = status.records[0]!.install.claw; - source = recorded.kind === "package" ? recorded.packageRoot : recorded.manifestPath; - } - - const loaded = await readClawManifestFile(source); - if (!loaded.ok) { - const diagnostics = opts.from - ? loaded.diagnostics - : [ - ...loaded.diagnostics, - { - level: "error" as const, - code: "recorded_source_unavailable", - phase: "plan" as const, - path: "$", - message: "The recorded Claw source is unavailable; pass --from to override it.", - }, - ]; - if (opts.json) { - writeRuntimeJson(runtime, { - schemaVersion: CLAW_UPDATE_PLAN_SCHEMA_VERSION, - stability: CLAW_OUTPUT_STABILITY, - dryRun: true, - mutationAllowed: false, - valid: false, - diagnostics, - }); - } else { - runtime.error(formatDiagnostics(diagnostics)); - } - runtime.exit(1); - return; - } - - const plan = await buildClawUpdatePlan({ - agentId: target, - targetManifest: loaded.manifest, - targetSource: loaded.source, - config: getRuntimeConfig(), - sourceMcpServers: listedMcpServers.mcpServers, - packagePreflight: preflightClawPackage, - diagnostics: loaded.diagnostics, - }); - if (opts.json) { - writeRuntimeJson(runtime, plan); - } else { - logExperimentalWarning(runtime); - runtime.log( - `Claw update plan: ${plan.currentClaw?.name ?? target} ${plan.currentClaw?.version ?? "unknown"} -> ${plan.targetClaw?.version ?? "unknown"}`, - ); - logClawUpdatePlanSummary(plan, runtime); - } - if (plan.blockers.length > 0 || plan.actions.some((action) => action.blocked)) { - runtime.exit(1); - } -} +export { runClawsUpdateCommand } from "./claws-update-cli.runtime.js"; export async function runClawsRemoveCommand( target: string, diff --git a/src/cli/claws-cli.test.ts b/src/cli/claws-cli.test.ts index 6c4956af4277..793e4d70b3ec 100644 --- a/src/cli/claws-cli.test.ts +++ b/src/cli/claws-cli.test.ts @@ -34,6 +34,7 @@ const mocks = vi.hoisted(() => { readClawStatus: vi.fn(), buildClawRemovePlan: vi.fn(), applyClawRemovePlan: vi.fn(), + applyClawUpdatePlan: vi.fn(), buildClawUpdatePlan: vi.fn(), exportClawAgent: vi.fn(), }; @@ -88,8 +89,14 @@ vi.mock("../claws/update-plan.js", async () => ({ buildClawUpdatePlan: mocks.buildClawUpdatePlan, })); +vi.mock("../claws/update-apply.js", async () => ({ + ...(await vi.importActual("../claws/update-apply.js")), + applyClawUpdatePlan: mocks.applyClawUpdatePlan, +})); + const { registerClawsCli } = await import("./claws-cli.js"); const { runClawsAddCommand } = await import("./claws-cli.runtime.js"); +const { ClawUpdateMutationError } = await import("../claws/update-apply.js"); const tempDirs = useAutoCleanupTempDirTracker(afterEach); const minimalManifest = { schemaVersion: 1, agent: { id: "demo-agent", name: "Demo Agent" } }; @@ -213,7 +220,7 @@ describe("claws cli", () => { kind: "agent", id: "demo-agent", action: "remove", - target: "agents.list[demo-agent]", + target: 'agents.entries["demo-agent"]', blocked: false, }, ], @@ -273,6 +280,19 @@ describe("claws cli", () => { blockers: [], diagnostics: [], }); + mocks.applyClawUpdatePlan.mockReset(); + mocks.applyClawUpdatePlan.mockResolvedValue({ + schemaVersion: "openclaw.clawUpdateResult.v1", + stability: "experimental", + dryRun: false, + mutationAllowed: true, + status: "complete", + agentId: "demo-agent", + previousClaw: { name: "@acme/demo-agent", version: "1.0.0", integrity: "sha256:old" }, + targetClaw: { name: "@acme/demo-agent", version: "1.2.3", integrity: "sha256:new" }, + appliedActions: [], + installRecord: { agentId: "demo-agent" }, + }); mocks.exportClawAgent.mockReset(); mocks.exportClawAgent.mockResolvedValue({ schemaVersion: "openclaw.clawExportResult.v1", @@ -849,7 +869,86 @@ describe("claws cli", () => { expect(mocks.buildClawUpdatePlan).not.toHaveBeenCalled(); expect(JSON.parse(mocks.logs[0] ?? "{}")).toMatchObject({ schemaVersion: "openclaw.clawUpdatePlan.v1", - error: { code: "update_preview_required" }, + error: { code: "consent_required" }, + }); + expect(mocks.runtime.exit).toHaveBeenCalledWith(1); + }); + + it("requires exact plan integrity with update consent", async () => { + const { root } = await writePackage(); + + await runCli(["claws", "update", "demo-agent", "--from", root, "--yes", "--json"]); + + expect(mocks.buildClawUpdatePlan).not.toHaveBeenCalled(); + expect(JSON.parse(mocks.logs[0] ?? "{}")).toMatchObject({ + error: { code: "consent_required" }, + }); + expect(mocks.runtime.exit).toHaveBeenCalledWith(1); + }); + + it("applies a supported update only after explicit consent", async () => { + const { root } = await writePackage(); + + await runCli([ + "claws", + "update", + "demo-agent", + "--from", + root, + "--yes", + "--plan-integrity", + "sha256:update-plan", + "--json", + ]); + + expect(mocks.applyClawUpdatePlan).toHaveBeenCalledWith( + expect.objectContaining({ agentId: "demo-agent" }), + expect.objectContaining({ + targetManifest: expect.objectContaining({ + agent: { id: "demo-agent", name: "Demo Agent" }, + }), + }), + expect.objectContaining({ + config: {}, + sourceMcpServers: {}, + consentPlanIntegrity: "sha256:update-plan", + packagePreflight: expect.any(Function), + cronGateway: expect.objectContaining({ + add: expect.any(Function), + get: expect.any(Function), + remove: expect.any(Function), + }), + }), + ); + expect(JSON.parse(mocks.logs[0] ?? "{}")).toMatchObject({ + schemaVersion: "openclaw.clawUpdateResult.v1", + status: "complete", + agentId: "demo-agent", + }); + }); + + it("reports uncertain update mutations as partial JSON", async () => { + const { root } = await writePackage(); + mocks.applyClawUpdatePlan.mockRejectedValueOnce( + new ClawUpdateMutationError("update_partial", "artifact outcome requires reconciliation"), + ); + + await runCli([ + "claws", + "update", + "demo-agent", + "--from", + root, + "--yes", + "--plan-integrity", + "sha256:update-plan", + "--json", + ]); + + expect(JSON.parse(mocks.logs[0] ?? "{}")).toMatchObject({ + schemaVersion: "openclaw.clawUpdateResult.v1", + status: "partial", + error: { code: "update_partial" }, }); expect(mocks.runtime.exit).toHaveBeenCalledWith(1); }); diff --git a/src/cli/claws-cli.ts b/src/cli/claws-cli.ts index 6c7dd27c37d9..5f543a49eadc 100644 --- a/src/cli/claws-cli.ts +++ b/src/cli/claws-cli.ts @@ -17,7 +17,13 @@ export type ClawsAddOptions = { }; export type ClawsStatusOptions = { json?: boolean }; -export type ClawsUpdateOptions = { from?: string; dryRun?: boolean; json?: boolean }; +export type ClawsUpdateOptions = { + from?: string; + dryRun?: boolean; + yes?: boolean; + planIntegrity?: string; + json?: boolean; +}; export type ClawsRemoveOptions = { dryRun?: boolean; yes?: boolean; @@ -80,6 +86,8 @@ export function registerClawsCli(program: Command) { .argument("", "Installed package name or final agent id") .option("--from ", "Override the target source recorded at Claw add time") .option("--dry-run", "Preview update actions without mutating state", false) + .option("--yes", "Confirm the exact supported update plan", false) + .option("--plan-integrity ", "Bind consent to an exact update plan") .option("--json", "Print JSON", false) .action(async (target: string, opts: ClawsUpdateOptions) => { const { runClawsUpdateCommand } = await import("./claws-cli.runtime.js"); diff --git a/src/cli/claws-update-cli.runtime.ts b/src/cli/claws-update-cli.runtime.ts new file mode 100644 index 000000000000..3065deb7bd48 --- /dev/null +++ b/src/cli/claws-update-cli.runtime.ts @@ -0,0 +1,233 @@ +import { assertExperimentalClawsEnabled } from "../claws/experimental.js"; +import { readClawStatus } from "../claws/lifecycle-state.js"; +import { preflightClawPackage } from "../claws/packages.js"; +import { readClawManifestFile } from "../claws/reader.js"; +import { CLAW_OUTPUT_STABILITY } from "../claws/types.js"; +import { + applyClawUpdatePlan, + CLAW_UPDATE_RESULT_SCHEMA_VERSION, + ClawUpdateMutationError, +} from "../claws/update-apply.js"; +import { buildClawUpdatePlan, CLAW_UPDATE_PLAN_SCHEMA_VERSION } from "../claws/update-plan.js"; +import { listConfiguredMcpServers } from "../config/mcp-config.js"; +import { defaultRuntime, writeRuntimeJson, type RuntimeEnv } from "../runtime.js"; +import { openExistingOpenClawStateDatabaseReadOnly } from "../state/openclaw-state-db.js"; +import { logClawUpdatePlanSummary } from "./claws-cli-update-output.js"; +import type { ClawsUpdateOptions } from "./claws-cli.js"; +import { callGatewayFromCli } from "./gateway-rpc.js"; + +type DiagnosticLike = { level: string; code: string; path: string; message: string }; + +function formatDiagnostics(diagnostics: DiagnosticLike[]): string { + return diagnostics + .map( + (diagnostic) => + `${diagnostic.level.toUpperCase()} ${diagnostic.code} ${diagnostic.path}: ${diagnostic.message}`, + ) + .join("\n"); +} + +function logExperimentalWarning(runtime: RuntimeEnv): void { + runtime.log("Experimental: Claws contracts may change while RFC 0016 is under review."); +} + +export async function runClawsUpdateCommand( + target: string, + opts: ClawsUpdateOptions, + runtime: RuntimeEnv = defaultRuntime, +): Promise { + assertExperimentalClawsEnabled(); + if (!opts.dryRun && (!opts.yes || !opts.planIntegrity)) { + const message = + "Claw update requires explicit consent; pass --dry-run to preview or --yes with --plan-integrity to apply supported actions."; + if (opts.json) { + writeRuntimeJson(runtime, { + schemaVersion: CLAW_UPDATE_PLAN_SCHEMA_VERSION, + stability: CLAW_OUTPUT_STABILITY, + ok: false, + error: { code: "consent_required", message }, + }); + } else { + runtime.error(message); + } + runtime.exit(1); + return; + } + + const listedMcpServers = await listConfiguredMcpServers(); + if (!listedMcpServers.ok) { + if (opts.json) { + writeRuntimeJson(runtime, { + schemaVersion: CLAW_UPDATE_PLAN_SCHEMA_VERSION, + stability: CLAW_OUTPUT_STABILITY, + dryRun: true, + mutationAllowed: false, + valid: false, + diagnostics: [ + { + level: "error", + code: "mcp_config_unavailable", + phase: "plan", + path: "$.mcpServers", + message: listedMcpServers.error, + }, + ], + }); + } else { + runtime.error(listedMcpServers.error); + } + runtime.exit(1); + return; + } + const config = listedMcpServers.config; + + let source = opts.from; + if (!source) { + const database = openExistingOpenClawStateDatabaseReadOnly(); + let status: Awaited> | { records: never[] } = { + records: [], + }; + if (database) { + try { + const hasClawInstalls = + database.db /* sqlite-allow-raw: read-only Claw install table-existence probe. */ + .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'claw_installs'") + .get(); + if (hasClawInstalls) { + status = await readClawStatus(target, { + database, + readOnly: true, + sourceMcpServers: listedMcpServers.mcpServers, + }); + } + } finally { + database.walMaintenance.close(); + } + } + if (status.records.length !== 1) { + const message = + status.records.length === 0 + ? `No installed Claw agent matches ${JSON.stringify(target)}.` + : `Claw name ${JSON.stringify(target)} matches multiple agents; use an agent id.`; + if (opts.json) { + writeRuntimeJson(runtime, { + schemaVersion: CLAW_UPDATE_PLAN_SCHEMA_VERSION, + stability: CLAW_OUTPUT_STABILITY, + dryRun: true, + mutationAllowed: false, + valid: false, + diagnostics: [ + { + level: "error", + code: status.records.length === 0 ? "claw_not_found" : "claw_ambiguous", + phase: "plan", + path: "$", + message, + }, + ], + }); + } else { + runtime.error(message); + } + runtime.exit(1); + return; + } + const recorded = status.records[0]!.install.claw; + source = recorded.kind === "package" ? recorded.packageRoot : recorded.manifestPath; + } + + const loaded = await readClawManifestFile(source); + if (!loaded.ok) { + const diagnostics = opts.from + ? loaded.diagnostics + : [ + ...loaded.diagnostics, + { + level: "error" as const, + code: "recorded_source_unavailable", + phase: "plan" as const, + path: "$", + message: "The recorded Claw source is unavailable; pass --from to override it.", + }, + ]; + if (opts.json) { + writeRuntimeJson(runtime, { + schemaVersion: CLAW_UPDATE_PLAN_SCHEMA_VERSION, + stability: CLAW_OUTPUT_STABILITY, + dryRun: true, + mutationAllowed: false, + valid: false, + diagnostics, + }); + } else { + runtime.error(formatDiagnostics(diagnostics)); + } + runtime.exit(1); + return; + } + + const plan = await buildClawUpdatePlan({ + agentId: target, + targetManifest: loaded.manifest, + targetSource: loaded.source, + config, + sourceMcpServers: listedMcpServers.mcpServers, + packagePreflight: preflightClawPackage, + diagnostics: loaded.diagnostics, + }); + if (opts.dryRun || plan.blockers.length > 0 || plan.actions.some((action) => action.blocked)) { + if (opts.json) { + writeRuntimeJson(runtime, plan); + } else { + logExperimentalWarning(runtime); + runtime.log( + `Claw update plan: ${plan.currentClaw?.name ?? target} ${plan.currentClaw?.version ?? "unknown"} -> ${plan.targetClaw?.version ?? "unknown"}`, + ); + runtime.log(`Plan integrity: ${plan.planIntegrity}`); + logClawUpdatePlanSummary(plan, runtime); + } + if (plan.blockers.length > 0 || plan.actions.some((action) => action.blocked)) { + runtime.exit(1); + } + return; + } + + try { + const result = await applyClawUpdatePlan( + plan, + { targetManifest: loaded.manifest, targetSource: loaded.source }, + { + config, + sourceMcpServers: listedMcpServers.mcpServers, + consentPlanIntegrity: opts.planIntegrity, + packagePreflight: preflightClawPackage, + cronGateway: { + add: async (input) => await callGatewayFromCli("cron.add", {}, input), + get: async (id) => await callGatewayFromCli("cron.get", {}, { id }), + remove: async (id) => await callGatewayFromCli("cron.remove", {}, { id }), + }, + }, + ); + if (opts.json) { + writeRuntimeJson(runtime, result); + return; + } + logExperimentalWarning(runtime); + runtime.log(`Updated agent: ${result.agentId}`); + runtime.log(`Claw version: ${result.previousClaw.version} -> ${result.targetClaw.version}`); + } catch (error) { + const code = error instanceof ClawUpdateMutationError ? error.code : "update_failed"; + const message = error instanceof Error ? error.message : String(error); + if (opts.json) { + writeRuntimeJson(runtime, { + schemaVersion: CLAW_UPDATE_RESULT_SCHEMA_VERSION, + stability: CLAW_OUTPUT_STABILITY, + status: code === "update_partial" ? "partial" : "failed", + error: { code, message }, + }); + } else { + runtime.error(message); + } + runtime.exit(1); + } +} diff --git a/src/config/mcp-config.test.ts b/src/config/mcp-config.test.ts index 349e0273c6f5..f256585ca52f 100644 --- a/src/config/mcp-config.test.ts +++ b/src/config/mcp-config.test.ts @@ -136,6 +136,33 @@ describe("config mcp config", () => { ); }); + it("only replaces a server that still matches the expected config", async () => { + await withMcpConfigHome( + { mcp: { servers: { docs: { command: "node", args: ["current.mjs"] } } } }, + async () => { + const stale = await setConfiguredMcpServer({ + name: "docs", + server: { command: "uvx", args: ["docs@2"] }, + expectedServer: { command: "node", args: ["stale.mjs"] }, + }); + expect(stale).toMatchObject({ ok: false, error: expect.stringContaining("changed") }); + + const replaced = await setConfiguredMcpServer({ + name: "docs", + server: { command: "uvx", args: ["docs@2"] }, + expectedServer: { command: "node", args: ["current.mjs"] }, + }); + expect(replaced.ok).toBe(true); + + const loaded = await listConfiguredMcpServers(); + expect(loaded.ok && loaded.mcpServers.docs).toEqual({ + command: "uvx", + args: ["docs@2"], + }); + }, + ); + }); + it("does not remove a server that changed after ownership inspection", async () => { await withMcpConfigHome( { mcp: { servers: { docs: { command: "node", args: ["changed.mjs"] } } } }, diff --git a/src/config/mcp-config.ts b/src/config/mcp-config.ts index 2ff837cc347c..b83d00019e5e 100644 --- a/src/config/mcp-config.ts +++ b/src/config/mcp-config.ts @@ -231,6 +231,7 @@ export async function setConfiguredMcpServer(params: { server: unknown; createOnly?: boolean; recordIndependentOwner?: boolean; + expectedServer?: Record; }): Promise { const name = params.name.trim(); if (!name) { @@ -251,6 +252,20 @@ export async function setConfiguredMcpServer(params: { error: `MCP server ${JSON.stringify(name)} already exists.`, }; } + const existingServer = loaded.mcpServers[name]; + if ( + params.expectedServer && + (!Object.hasOwn(loaded.mcpServers, name) || + !existingServer || + stableStringify(canonicalizeConfiguredMcpServer(existingServer)) !== + stableStringify(canonicalizeConfiguredMcpServer(params.expectedServer))) + ) { + return { + ok: false, + path: loaded.path, + error: `MCP server ${JSON.stringify(name)} changed and was not updated.`, + }; + } const argvRestored = restoreMcpServerArgvSentinels({ incoming: params.server,