mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 09:51:33 +00:00
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:
@@ -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. |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
260
src/claws/cron-update.test.ts
Normal file
260
src/claws/cron-update.test.ts
Normal 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
213
src/claws/cron-update.ts
Normal 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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ function capabilityChange(
|
||||
};
|
||||
}
|
||||
|
||||
type ClawAddPlanContext = {
|
||||
export type ClawAddPlanContext = {
|
||||
agentId?: string;
|
||||
workspace?: string;
|
||||
resumableWorkspace?: string;
|
||||
|
||||
341
src/claws/mcp-update.test.ts
Normal file
341
src/claws/mcp-update.test.ts
Normal 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
214
src/claws/mcp-update.ts
Normal 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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
108
src/claws/package-update-provenance.ts
Normal file
108
src/claws/package-update-provenance.ts
Normal 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);
|
||||
}
|
||||
439
src/claws/package-update.test.ts
Normal file
439
src/claws/package-update.test.ts
Normal 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
259
src/claws/package-update.ts
Normal 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 };
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}" } } },
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
657
src/claws/update-apply.test.ts
Normal file
657
src/claws/update-apply.test.ts
Normal 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
550
src/claws/update-apply.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
21
src/claws/update-plan-summary.ts
Normal file
21
src/claws/update-plan-summary.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export type ClawUpdateAction = {
|
||||
blocked: boolean;
|
||||
reason: string;
|
||||
currentDigest?: string;
|
||||
currentPresent?: boolean;
|
||||
desiredDigest?: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
123
src/claws/workspace-update.test.ts
Normal file
123
src/claws/workspace-update.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
207
src/claws/workspace-update.ts
Normal file
207
src/claws/workspace-update.ts
Normal 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 };
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
233
src/cli/claws-update-cli.runtime.ts
Normal file
233
src/cli/claws-update-cli.runtime.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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"] } } } },
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user