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 <patrick.a.erichsen@gmail.com>
This commit is contained in:
Gio Della-Libera
2026-07-22 17:42:42 -07:00
committed by GitHub
parent 1a7a1808f7
commit 7d159fdc77
31 changed files with 4274 additions and 245 deletions

View File

@@ -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 <SHA256_FROM_DRY_RUN>
```
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 <source>` | Validate a package directory or JSON manifest. |
| `claws add <source>` | Preview or create one new agent and workspace. |
| `claws status [claw-or-agent]` | Report installed state, ownership, and drift. |
| `claws update <claw-or-agent>` | Preview changes from the recorded or given source. |
| `claws update <claw-or-agent>` | Preview or apply changes from the selected source. |
| `claws remove <claw-or-agent>` | Preview or remove the agent and eligible resources. |
| `claws export <agent> --out <path>` | Create a portable package from an installed agent. |

View File

@@ -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

View File

@@ -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<string, unknown>) => {
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();
});
});

213
src/claws/cron-update.ts Normal file
View File

@@ -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<void>;
};
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<ClawCronUpdateExecution> {
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<void>> = [];
const appliedIds: string[] = [];
const nowMs = options.nowMs ?? Date.now();
const add = async (ref: PersistedClawCronRef): Promise<string> => {
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 };
}

View File

@@ -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<string, unknown> {
export function clawCronGatewayInput(
agentId: string,
ref: PersistedClawCronRef,
): Record<string, unknown> {
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);
}

View File

@@ -37,7 +37,7 @@ function capabilityChange(
};
}
type ClawAddPlanContext = {
export type ClawAddPlanContext = {
agentId?: string;
workspace?: string;
resumableWorkspace?: string;

View File

@@ -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();
});
});

214
src/claws/mcp-update.ts Normal file
View File

@@ -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<void>;
};
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<string, Record<string, unknown>>;
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<ClawMcpUpdateExecution> {
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<void>> = [];
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 };
}

View File

@@ -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<typeof setConfiguredMcpServer>;
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);
}

View File

@@ -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);
}

View File

@@ -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<typeof installClawPackages>[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<typeof installClawPackages>[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<typeof installClawPackages>[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();
});
});

259
src/claws/package-update.ts Normal file
View File

@@ -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<Parameters<typeof installClawPackages>[1]>["deps"]
>;
export type ClawPackageUpdateExecution = {
appliedIds: string[];
rollback: () => Promise<void>;
};
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<ClawPackage, "kind" | "ref">): 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<ClawPackageUpdateExecution> {
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<void>> = [];
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 };
}

View File

@@ -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: {

View File

@@ -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", () => {

View File

@@ -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;
}

View File

@@ -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}" } } },

View File

@@ -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));
});
}

View File

@@ -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";

550
src/claws/update-apply.ts Normal file
View File

@@ -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<void>;
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<ClawUpdatePlan["currentClaw"]>;
targetClaw: NonNullable<ClawUpdatePlan["targetClaw"]>;
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<string, Record<string, unknown>>;
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<ClawUpdateResult> {
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<string, ClawPackage>(
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<void> => {
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,
};
}

View File

@@ -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,
};
}

View File

@@ -11,6 +11,7 @@ export type ClawUpdateAction = {
blocked: boolean;
reason: string;
currentDigest?: string;
currentPresent?: boolean;
desiredDigest?: string;
};

View File

@@ -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,

View File

@@ -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");
});
});

View File

@@ -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<void>;
};
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<ClawWorkspaceUpdateExecution> {
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<void>> = [];
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 };
}

View File

@@ -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");
}

View File

@@ -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<void> {
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<ReturnType<typeof readClawStatus>> | { 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,

View File

@@ -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<typeof import("../claws/update-apply.js")>("../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);
});

View File

@@ -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("<claw-or-agent>", "Installed package name or final agent id")
.option("--from <source>", "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 <digest>", "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");

View File

@@ -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<void> {
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<ReturnType<typeof readClawStatus>> | { 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);
}
}

View File

@@ -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"] } } } },

View File

@@ -231,6 +231,7 @@ export async function setConfiguredMcpServer(params: {
server: unknown;
createOnly?: boolean;
recordIndependentOwner?: boolean;
expectedServer?: Record<string, unknown>;
}): Promise<ConfigMcpWriteResult> {
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,