mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
Plan grouped Claw agent updates (#102959)
* Plan grouped Claw agent updates * test(claws): cover update planning * docs(claws): document update preview * fix(claws): bind update capability effects * fix(claws): redact update capability effects * fix(claws): bind capability previews to exact changes --------- Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
This commit is contained in:
+26
-1
@@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "Add, inspect, and remove experimental Claw agent packages"
|
||||
summary: "Add, inspect, update, and remove experimental Claw agent packages"
|
||||
read_when:
|
||||
- You want to validate a grouped Claw manifest
|
||||
- You want to preview or add one agent from a Claw
|
||||
@@ -197,6 +197,30 @@ 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
|
||||
|
||||
By default, update uses the source recorded when the Claw was added. Use
|
||||
`--from` when that source moved or when testing another package directory:
|
||||
|
||||
```bash
|
||||
openclaw claws update incident-triage --dry-run --json
|
||||
openclaw claws update incident-triage \
|
||||
--from ./incident-triage-next \
|
||||
--dry-run --json
|
||||
```
|
||||
|
||||
The plan compares current provenance and live state with the target manifest.
|
||||
It reports agent, workspace, package, MCP, cron, and ownership changes,
|
||||
including capability escalations and blockers. Capability escalations have
|
||||
separate machine-readable records and `!` lines with exact redacted effects in
|
||||
human output. Resolved package integrity, install identity, and any trust
|
||||
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.
|
||||
|
||||
## Remove an installed Claw
|
||||
|
||||
Preview removal before selecting cleanup:
|
||||
@@ -249,6 +273,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 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,6 +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: Remove an installed Claw
|
||||
- H2: Export an installed agent
|
||||
- H2: Command reference
|
||||
|
||||
@@ -396,8 +396,10 @@ describe("claws lifecycle cli e2e", () => {
|
||||
expect(result.code).toBe(1);
|
||||
expect(parseJson(result.stdout)).toMatchObject({
|
||||
schemaVersion: "openclaw.clawAddResult.v1",
|
||||
status: "failed",
|
||||
error: { code: "unsupported_components" },
|
||||
status: "partial",
|
||||
configCommitted: true,
|
||||
error: { code: "cron_install_failed" },
|
||||
installRecord: { status: "config_committed" },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+1
-1
@@ -303,7 +303,7 @@ export function readClawMcpServerRefs(
|
||||
return rows.map(rowToRef);
|
||||
}
|
||||
|
||||
function readClawMcpServerRefsByName(
|
||||
export function readClawMcpServerRefsByName(
|
||||
name: string,
|
||||
options: OpenClawStateDatabaseOptions = {},
|
||||
): PersistedClawMcpServerRef[] {
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
cronCapabilityChange,
|
||||
mcpCapabilityChange,
|
||||
pushResolvedAgentCapabilityChanges,
|
||||
} from "./update-capability-changes.js";
|
||||
|
||||
type Changes = Parameters<typeof pushResolvedAgentCapabilityChanges>[0]["changes"];
|
||||
|
||||
function collectChanges(params: {
|
||||
currentAgent: Parameters<typeof pushResolvedAgentCapabilityChanges>[0]["desiredAgent"];
|
||||
desiredAgent: Parameters<typeof pushResolvedAgentCapabilityChanges>[0]["desiredAgent"];
|
||||
defaults?: NonNullable<
|
||||
Parameters<typeof pushResolvedAgentCapabilityChanges>[0]["config"]["agents"]
|
||||
>["defaults"];
|
||||
}): Changes {
|
||||
const changes: Changes = [];
|
||||
pushResolvedAgentCapabilityChanges({
|
||||
changes,
|
||||
agentId: params.currentAgent.id,
|
||||
config: {
|
||||
agents: {
|
||||
defaults: params.defaults,
|
||||
list: [params.currentAgent],
|
||||
},
|
||||
},
|
||||
desiredAgent: params.desiredAgent,
|
||||
});
|
||||
return changes;
|
||||
}
|
||||
|
||||
describe("pushResolvedAgentCapabilityChanges", () => {
|
||||
it("classifies effective sandbox and heartbeat changes", () => {
|
||||
const changes = collectChanges({
|
||||
currentAgent: { id: "worker", sandbox: { mode: "all" }, heartbeat: { every: "1h" } },
|
||||
desiredAgent: { id: "worker", sandbox: { mode: "off" }, heartbeat: { every: "5m" } },
|
||||
});
|
||||
expect(changes).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: "agent.sandbox.mode",
|
||||
classification: "escalation",
|
||||
requiresDistinctConsent: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
path: "agent.heartbeat.every",
|
||||
classification: "escalation",
|
||||
requiresDistinctConsent: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
const inherited = collectChanges({
|
||||
currentAgent: { id: "worker", sandbox: { mode: "all" }, heartbeat: { every: "1h" } },
|
||||
desiredAgent: { id: "worker" },
|
||||
defaults: { sandbox: { mode: "off" }, heartbeat: { every: "5m" } },
|
||||
});
|
||||
expect(inherited).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: "agent.sandbox.mode",
|
||||
classification: "escalation",
|
||||
requiresDistinctConsent: true,
|
||||
desired: expect.objectContaining({ summary: "off" }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
path: "agent.heartbeat.every",
|
||||
classification: "escalation",
|
||||
requiresDistinctConsent: true,
|
||||
current: expect.objectContaining({ summary: "1h" }),
|
||||
desired: expect.objectContaining({ summary: "5m" }),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves the implicit heartbeat interval", () => {
|
||||
const changes: Changes = [];
|
||||
pushResolvedAgentCapabilityChanges({
|
||||
changes,
|
||||
agentId: "main",
|
||||
config: {
|
||||
agents: { list: [{ id: "main", heartbeat: { every: "1h" } }] },
|
||||
},
|
||||
desiredAgent: { id: "main" },
|
||||
});
|
||||
expect(changes).toContainEqual(
|
||||
expect.objectContaining({
|
||||
path: "agent.heartbeat.every",
|
||||
classification: "escalation",
|
||||
requiresDistinctConsent: true,
|
||||
current: expect.objectContaining({ summary: "1h" }),
|
||||
desired: expect.objectContaining({ summary: "30m" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves implicit default-agent heartbeat resolution", () => {
|
||||
const changes: Changes = [];
|
||||
pushResolvedAgentCapabilityChanges({
|
||||
changes,
|
||||
agentId: "worker",
|
||||
config: { agents: { list: [{ id: "worker" }, { id: "other" }] } },
|
||||
desiredAgent: { id: "worker" },
|
||||
});
|
||||
expect(changes.filter((change) => change.path.startsWith("agent.heartbeat."))).toEqual([]);
|
||||
});
|
||||
|
||||
it("classifies heartbeat activity increases and reductions directionally", () => {
|
||||
const moreFrequent = collectChanges({
|
||||
currentAgent: { id: "worker", heartbeat: { every: "1h" } },
|
||||
desiredAgent: { id: "worker", heartbeat: { every: "5m" } },
|
||||
});
|
||||
expect(moreFrequent).toContainEqual(
|
||||
expect.objectContaining({
|
||||
path: "agent.heartbeat.every",
|
||||
classification: "escalation",
|
||||
requiresDistinctConsent: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const lessFrequent = collectChanges({
|
||||
currentAgent: {
|
||||
id: "worker",
|
||||
heartbeat: { every: "5m", isolatedSession: false, timeoutSeconds: 60 },
|
||||
},
|
||||
desiredAgent: {
|
||||
id: "worker",
|
||||
heartbeat: { every: "1h", isolatedSession: true, timeoutSeconds: 30 },
|
||||
},
|
||||
});
|
||||
expect(lessFrequent).toEqual(
|
||||
expect.arrayContaining(
|
||||
["every", "isolatedSession", "timeoutSeconds"].map((field) =>
|
||||
expect.objectContaining({
|
||||
path: `agent.heartbeat.${field}`,
|
||||
classification: "reduction",
|
||||
requiresDistinctConsent: false,
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const disabled = collectChanges({
|
||||
currentAgent: { id: "worker", heartbeat: { every: "5m" } },
|
||||
desiredAgent: { id: "worker", heartbeat: { every: "0m" } },
|
||||
});
|
||||
expect(disabled).toContainEqual(
|
||||
expect.objectContaining({
|
||||
path: "agent.heartbeat.every",
|
||||
classification: "reduction",
|
||||
requiresDistinctConsent: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("ranks sandbox mode and sharing scope", () => {
|
||||
const changes = collectChanges({
|
||||
currentAgent: { id: "worker", sandbox: { mode: "off", scope: "shared" } },
|
||||
desiredAgent: { id: "worker", sandbox: { mode: "all", scope: "session" } },
|
||||
});
|
||||
expect(changes).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: "agent.sandbox.mode",
|
||||
classification: "reduction",
|
||||
requiresDistinctConsent: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
path: "agent.sandbox.scope",
|
||||
classification: "reduction",
|
||||
requiresDistinctConsent: false,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
const widened = collectChanges({
|
||||
currentAgent: { id: "worker", sandbox: { scope: "session" } },
|
||||
desiredAgent: { id: "worker", sandbox: { scope: "shared" } },
|
||||
});
|
||||
expect(widened).toContainEqual(
|
||||
expect.objectContaining({
|
||||
path: "agent.sandbox.scope",
|
||||
classification: "escalation",
|
||||
requiresDistinctConsent: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("classifies tool restrictions by effective set membership", () => {
|
||||
const substituted = collectChanges({
|
||||
currentAgent: { id: "worker", tools: { deny: ["exec"] } },
|
||||
desiredAgent: { id: "worker", tools: { deny: ["read", "write"] } },
|
||||
});
|
||||
expect(substituted).toContainEqual(
|
||||
expect.objectContaining({
|
||||
path: "agent.tools.deny",
|
||||
classification: "escalation",
|
||||
requiresDistinctConsent: true,
|
||||
}),
|
||||
);
|
||||
|
||||
for (const field of ["allow", "deny"] as const) {
|
||||
const added = collectChanges({
|
||||
currentAgent: { id: "worker" },
|
||||
desiredAgent: { id: "worker", tools: { [field]: ["exec"] } },
|
||||
});
|
||||
expect(added).toContainEqual(
|
||||
expect.objectContaining({
|
||||
path: `agent.tools.${field}`,
|
||||
classification: "reduction",
|
||||
requiresDistinctConsent: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const removed = collectChanges({
|
||||
currentAgent: { id: "worker", tools: { [field]: ["exec"] } },
|
||||
desiredAgent: { id: "worker" },
|
||||
});
|
||||
expect(removed).toContainEqual(
|
||||
expect.objectContaining({
|
||||
path: `agent.tools.${field}`,
|
||||
classification: "escalation",
|
||||
requiresDistinctConsent: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("treats tool policies on a restored missing agent as escalations", () => {
|
||||
for (const field of ["allow", "deny"] as const) {
|
||||
const changes: Changes = [];
|
||||
pushResolvedAgentCapabilityChanges({
|
||||
changes,
|
||||
agentId: "worker",
|
||||
config: { agents: { list: [] } },
|
||||
desiredAgent: { id: "worker", tools: { [field]: ["exec"] } },
|
||||
});
|
||||
expect(changes).toContainEqual(
|
||||
expect.objectContaining({
|
||||
path: `agent.tools.${field}`,
|
||||
classification: "escalation",
|
||||
requiresDistinctConsent: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("treats inherited capabilities on a restored missing agent as escalations", () => {
|
||||
const changes: Changes = [];
|
||||
pushResolvedAgentCapabilityChanges({
|
||||
changes,
|
||||
agentId: "worker",
|
||||
config: {
|
||||
agents: {
|
||||
defaults: { sandbox: { mode: "all" }, heartbeat: { every: "1h" } },
|
||||
list: [],
|
||||
},
|
||||
},
|
||||
desiredAgent: { id: "worker" },
|
||||
});
|
||||
expect(changes).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: "agent.sandbox.mode",
|
||||
classification: "escalation",
|
||||
requiresDistinctConsent: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
path: "agent.heartbeat.every",
|
||||
classification: "escalation",
|
||||
requiresDistinctConsent: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not derive redacted capability digests from private details or payloads", () => {
|
||||
const firstMcp = mcpCapabilityChange({
|
||||
id: "search",
|
||||
action: "change",
|
||||
desired: { url: "https://first.example", auth: { scheme: "first" } },
|
||||
});
|
||||
const secondMcp = mcpCapabilityChange({
|
||||
id: "search",
|
||||
action: "change",
|
||||
desired: { url: "https://second.example", auth: { scheme: "second" } },
|
||||
});
|
||||
expect(firstMcp?.desired?.summary).toBe(secondMcp?.desired?.summary);
|
||||
expect(firstMcp?.desired?.digest).not.toBe(secondMcp?.desired?.digest);
|
||||
expect(firstMcp?.effect).toEqual({ connection: "remote-server", authConfigured: true });
|
||||
expect(JSON.stringify(firstMcp)).not.toContain("first.example");
|
||||
expect(JSON.stringify(firstMcp)).not.toContain('"scheme":"first"');
|
||||
|
||||
const firstCron = cronCapabilityChange({
|
||||
id: "report",
|
||||
action: "change",
|
||||
desired: { schedule: { cron: "0 9 * * *" }, session: "isolated", message: "first" },
|
||||
});
|
||||
const secondCron = cronCapabilityChange({
|
||||
id: "report",
|
||||
action: "change",
|
||||
desired: { schedule: { cron: "0 9 * * *" }, session: "isolated", message: "second" },
|
||||
});
|
||||
expect(firstCron?.desired?.summary).toBe(secondCron?.desired?.summary);
|
||||
expect(firstCron?.desired?.digest).not.toBe(secondCron?.desired?.digest);
|
||||
expect(firstCron?.effect).toEqual({
|
||||
schedule: "cron",
|
||||
timezoneConfigured: false,
|
||||
session: "isolated",
|
||||
deliveryConfigured: false,
|
||||
payloadWithheld: true,
|
||||
});
|
||||
expect(JSON.stringify(firstCron)).not.toContain('"message":"first"');
|
||||
});
|
||||
|
||||
it("describes MCP execution shape without exposing private configuration", () => {
|
||||
const change = mcpCapabilityChange({
|
||||
id: "private",
|
||||
action: "add",
|
||||
desired: {
|
||||
command: "private-command",
|
||||
args: ["--token", "secret-argument"],
|
||||
env: { PRIVATE_TOKEN: "secret-env" },
|
||||
auth: { token: "secret-auth" },
|
||||
toolFilter: { allow: ["secret-tool"] },
|
||||
},
|
||||
});
|
||||
expect(change?.effect).toEqual({
|
||||
connection: "local-process",
|
||||
commandConfigured: true,
|
||||
argumentCount: 2,
|
||||
authConfigured: true,
|
||||
toolFilterConfigured: true,
|
||||
envEntryCount: 1,
|
||||
});
|
||||
const serialized = JSON.stringify(change);
|
||||
for (const privateValue of [
|
||||
"private-command",
|
||||
"secret-argument",
|
||||
"PRIVATE_TOKEN",
|
||||
"secret-env",
|
||||
"secret-auth",
|
||||
"secret-tool",
|
||||
]) {
|
||||
expect(serialized).not.toContain(privateValue);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,493 @@
|
||||
// Builds field-level capability change summaries for Claw update previews.
|
||||
import { createHash } from "node:crypto";
|
||||
import { resolveSandboxConfigForAgent } from "../agents/sandbox/config.js";
|
||||
import { stableStringify } from "../agents/stable-stringify.js";
|
||||
import { parseDurationMs } from "../cli/parse-duration.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { resolveHeartbeatSummaryForAgent } from "../infra/heartbeat-summary.js";
|
||||
|
||||
type ClawUpdateCapabilityValue = {
|
||||
summary: string;
|
||||
digest: string;
|
||||
};
|
||||
|
||||
export type ClawUpdateCapabilityChange = {
|
||||
kind: "agent" | "package" | "mcpServer" | "cronJob";
|
||||
id: string;
|
||||
path: string;
|
||||
action: "add" | "change" | "remove" | "release" | "unchanged" | "manual";
|
||||
classification: "escalation" | "reduction" | "neutral";
|
||||
requiresDistinctConsent: boolean;
|
||||
reason: string;
|
||||
effect: Record<string, unknown>;
|
||||
current?: ClawUpdateCapabilityValue;
|
||||
desired?: ClawUpdateCapabilityValue;
|
||||
};
|
||||
|
||||
function capabilityValue(
|
||||
summary: string,
|
||||
digestSource: unknown = summary,
|
||||
): ClawUpdateCapabilityValue {
|
||||
return {
|
||||
summary,
|
||||
digest: `sha256:${createHash("sha256").update(stableStringify(digestSource)).digest("hex")}`,
|
||||
};
|
||||
}
|
||||
|
||||
function getPath(value: unknown, path: readonly string[]): unknown {
|
||||
let current = value;
|
||||
for (const segment of path) {
|
||||
if (!current || typeof current !== "object" || !Object.hasOwn(current, segment)) {
|
||||
return undefined;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[segment];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function sameValue(left: unknown, right: unknown): boolean {
|
||||
return stableStringify(left) === stableStringify(right);
|
||||
}
|
||||
|
||||
function summarizeAgentCapability(value: unknown): string {
|
||||
return typeof value === "string" || typeof value === "number" || typeof value === "boolean"
|
||||
? String(value)
|
||||
: stableStringify(value);
|
||||
}
|
||||
|
||||
function rankedValue(value: unknown, rank: Record<string, number>): number {
|
||||
return typeof value === "string" ? (rank[value] ?? 0) : 0;
|
||||
}
|
||||
|
||||
function compareRankedCapability(
|
||||
current: unknown,
|
||||
desired: unknown,
|
||||
rank: Record<string, number>,
|
||||
): ClawUpdateCapabilityChange["classification"] {
|
||||
const currentRank = rankedValue(current, rank);
|
||||
const desiredRank = rankedValue(desired, rank);
|
||||
return desiredRank > currentRank
|
||||
? "escalation"
|
||||
: desiredRank < currentRank
|
||||
? "reduction"
|
||||
: "neutral";
|
||||
}
|
||||
|
||||
function classifyHeartbeatEvery(
|
||||
current: unknown,
|
||||
desired: unknown,
|
||||
): ClawUpdateCapabilityChange["classification"] {
|
||||
const toInterval = (value: unknown): number | undefined => {
|
||||
if (value === "disabled") {
|
||||
return 0;
|
||||
}
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return Math.max(0, parseDurationMs(value, { defaultUnit: "m" }));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
const currentMs = toInterval(current);
|
||||
const desiredMs = toInterval(desired);
|
||||
if (currentMs === undefined || desiredMs === undefined || currentMs === desiredMs) {
|
||||
return "neutral";
|
||||
}
|
||||
if (currentMs === 0) {
|
||||
return "escalation";
|
||||
}
|
||||
if (desiredMs === 0) {
|
||||
return "reduction";
|
||||
}
|
||||
return desiredMs < currentMs ? "escalation" : "reduction";
|
||||
}
|
||||
|
||||
function classifyAgentCapability(
|
||||
path: string,
|
||||
current: unknown,
|
||||
desired: unknown,
|
||||
currentAgentExists: boolean,
|
||||
): ClawUpdateCapabilityChange["classification"] {
|
||||
if (path === "tools.allow" || path === "tools.deny") {
|
||||
if (!currentAgentExists && desired !== undefined) {
|
||||
return "escalation";
|
||||
}
|
||||
if (desired === undefined) {
|
||||
return "escalation";
|
||||
}
|
||||
if (current === undefined) {
|
||||
return "reduction";
|
||||
}
|
||||
}
|
||||
if (desired === undefined) {
|
||||
return "reduction";
|
||||
}
|
||||
if (current === undefined) {
|
||||
return "escalation";
|
||||
}
|
||||
if (path === "sandbox.workspaceAccess") {
|
||||
const rank = { none: 0, ro: 1, rw: 2 } as Record<string, number>;
|
||||
return compareRankedCapability(current, desired, rank);
|
||||
}
|
||||
if (path === "sandbox.mode") {
|
||||
const rank = { all: 0, "non-main": 1, off: 2 } as Record<string, number>;
|
||||
return compareRankedCapability(current, desired, rank);
|
||||
}
|
||||
if (path === "sandbox.scope") {
|
||||
const rank = { session: 0, agent: 1, shared: 2 } as Record<string, number>;
|
||||
return compareRankedCapability(current, desired, rank);
|
||||
}
|
||||
if (path === "heartbeat.every") {
|
||||
return classifyHeartbeatEvery(current, desired);
|
||||
}
|
||||
if (path === "heartbeat.isolatedSession" || path === "heartbeat.skipWhenBusy") {
|
||||
return desired === true ? "reduction" : "escalation";
|
||||
}
|
||||
if (path === "heartbeat.timeoutSeconds") {
|
||||
return typeof current === "number" && typeof desired === "number" && desired < current
|
||||
? "reduction"
|
||||
: "escalation";
|
||||
}
|
||||
if (path === "tools.deny") {
|
||||
if (!Array.isArray(current) || !Array.isArray(desired)) {
|
||||
return "escalation";
|
||||
}
|
||||
const desiredTools = new Set(
|
||||
desired.filter((value): value is string => typeof value === "string"),
|
||||
);
|
||||
if (current.some((value) => typeof value === "string" && !desiredTools.has(value))) {
|
||||
return "escalation";
|
||||
}
|
||||
const currentTools = new Set(
|
||||
current.filter((value): value is string => typeof value === "string"),
|
||||
);
|
||||
return desired.some((value) => typeof value === "string" && !currentTools.has(value))
|
||||
? "reduction"
|
||||
: "neutral";
|
||||
}
|
||||
if (path === "tools.allow" && Array.isArray(current) && Array.isArray(desired)) {
|
||||
const currentTools = new Set(
|
||||
current.filter((value): value is string => typeof value === "string"),
|
||||
);
|
||||
return desired.some((value) => typeof value === "string" && !currentTools.has(value))
|
||||
? "escalation"
|
||||
: "reduction";
|
||||
}
|
||||
return path.startsWith("sandbox.") || path === "tools.allow" || path.startsWith("heartbeat.")
|
||||
? "escalation"
|
||||
: "neutral";
|
||||
}
|
||||
|
||||
function pushAgentCapabilityChanges(params: {
|
||||
changes: ClawUpdateCapabilityChange[];
|
||||
agentId: string;
|
||||
currentAgent: unknown;
|
||||
desiredAgent: unknown;
|
||||
currentSandbox?: unknown;
|
||||
desiredSandbox?: unknown;
|
||||
currentHeartbeat?: unknown;
|
||||
desiredHeartbeat?: unknown;
|
||||
}): void {
|
||||
const fields = [
|
||||
["sandbox", "mode"],
|
||||
["sandbox", "scope"],
|
||||
["sandbox", "workspaceAccess"],
|
||||
["tools", "allow"],
|
||||
["tools", "deny"],
|
||||
["heartbeat", "every"],
|
||||
["heartbeat", "activeHours"],
|
||||
["heartbeat", "isolatedSession"],
|
||||
["heartbeat", "skipWhenBusy"],
|
||||
["heartbeat", "timeoutSeconds"],
|
||||
] as const;
|
||||
for (const field of fields) {
|
||||
const sandboxField = field[0] === "sandbox" ? field.slice(1) : undefined;
|
||||
const heartbeatField = field[0] === "heartbeat" ? field.slice(1) : undefined;
|
||||
const current = sandboxField
|
||||
? getPath(params.currentSandbox, sandboxField)
|
||||
: heartbeatField
|
||||
? getPath(params.currentHeartbeat, heartbeatField)
|
||||
: getPath(params.currentAgent, field);
|
||||
const desired = sandboxField
|
||||
? getPath(params.desiredSandbox, sandboxField)
|
||||
: heartbeatField
|
||||
? getPath(params.desiredHeartbeat, heartbeatField)
|
||||
: getPath(params.desiredAgent, field);
|
||||
if (sameValue(current, desired)) {
|
||||
continue;
|
||||
}
|
||||
const path = field.join(".");
|
||||
const classification = classifyAgentCapability(
|
||||
path,
|
||||
current,
|
||||
desired,
|
||||
params.currentAgent !== undefined,
|
||||
);
|
||||
params.changes.push({
|
||||
kind: "agent",
|
||||
id: params.agentId,
|
||||
path: `agent.${path}`,
|
||||
action: "change",
|
||||
classification,
|
||||
requiresDistinctConsent: classification === "escalation",
|
||||
reason: `Agent capability field ${path} changes in the target manifest.`,
|
||||
effect: { path, current, desired },
|
||||
...(current === undefined
|
||||
? {}
|
||||
: { current: capabilityValue(summarizeAgentCapability(current)) }),
|
||||
...(desired === undefined
|
||||
? {}
|
||||
: { desired: capabilityValue(summarizeAgentCapability(desired)) }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
type AgentConfig = NonNullable<NonNullable<OpenClawConfig["agents"]>["list"]>[number];
|
||||
|
||||
function resolveHeartbeat(config: OpenClawConfig, agentId: string): unknown {
|
||||
const defaults = config.agents?.defaults?.heartbeat;
|
||||
const overrides = config.agents?.list?.find((agent) => agent.id === agentId)?.heartbeat;
|
||||
return {
|
||||
...defaults,
|
||||
...overrides,
|
||||
every: resolveHeartbeatSummaryForAgent(config, agentId).every,
|
||||
};
|
||||
}
|
||||
|
||||
export function pushResolvedAgentCapabilityChanges(params: {
|
||||
changes: ClawUpdateCapabilityChange[];
|
||||
agentId: string;
|
||||
config: OpenClawConfig;
|
||||
desiredAgent: AgentConfig;
|
||||
}): void {
|
||||
const currentAgents = params.config.agents?.list ?? [];
|
||||
const currentIndex = currentAgents.findIndex((agent) => agent.id === params.agentId);
|
||||
const currentAgent = currentIndex === -1 ? undefined : currentAgents[currentIndex];
|
||||
const desiredAgents = [...currentAgents];
|
||||
if (currentIndex === -1) {
|
||||
desiredAgents.push(params.desiredAgent);
|
||||
} else {
|
||||
desiredAgents[currentIndex] = params.desiredAgent;
|
||||
}
|
||||
const desiredConfig: OpenClawConfig = {
|
||||
...params.config,
|
||||
agents: {
|
||||
...params.config.agents,
|
||||
list: desiredAgents,
|
||||
},
|
||||
};
|
||||
pushAgentCapabilityChanges({
|
||||
changes: params.changes,
|
||||
agentId: params.agentId,
|
||||
currentAgent,
|
||||
desiredAgent: params.desiredAgent,
|
||||
currentSandbox: currentAgent
|
||||
? resolveSandboxConfigForAgent(params.config, params.agentId)
|
||||
: undefined,
|
||||
desiredSandbox: resolveSandboxConfigForAgent(desiredConfig, params.agentId),
|
||||
currentHeartbeat: currentAgent ? resolveHeartbeat(params.config, params.agentId) : undefined,
|
||||
desiredHeartbeat: resolveHeartbeat(desiredConfig, params.agentId),
|
||||
});
|
||||
}
|
||||
|
||||
export function packageCapabilityChange(params: {
|
||||
pkg: { kind: string; ref: string; version: string };
|
||||
action: ClawUpdateCapabilityChange["action"];
|
||||
currentVersion?: string;
|
||||
desiredVersion?: string;
|
||||
integrity?: string;
|
||||
installId?: string;
|
||||
riskWarning?: string;
|
||||
}): ClawUpdateCapabilityChange | undefined {
|
||||
if (params.pkg.kind !== "plugin" || params.action === "unchanged") {
|
||||
return undefined;
|
||||
}
|
||||
const reduction = params.desiredVersion === undefined;
|
||||
return {
|
||||
kind: "package",
|
||||
id: `plugin:${params.pkg.ref}`,
|
||||
path: `packages.plugin.${params.pkg.ref}`,
|
||||
action: params.action,
|
||||
classification: reduction ? "reduction" : "escalation",
|
||||
requiresDistinctConsent: !reduction,
|
||||
reason: reduction
|
||||
? "Target manifest removes or releases plugin executable code."
|
||||
: "Target manifest adds or changes plugin executable code.",
|
||||
effect: {
|
||||
kind: params.pkg.kind,
|
||||
ref: params.pkg.ref,
|
||||
...(params.desiredVersion ? { version: params.desiredVersion } : {}),
|
||||
...(params.integrity ? { integrity: params.integrity } : {}),
|
||||
...(params.installId ? { installId: params.installId } : {}),
|
||||
...(params.riskWarning ? { riskWarning: params.riskWarning } : {}),
|
||||
},
|
||||
...(params.currentVersion
|
||||
? {
|
||||
current: capabilityValue(`version ${params.currentVersion}`),
|
||||
}
|
||||
: {}),
|
||||
...(params.desiredVersion
|
||||
? {
|
||||
desired: capabilityValue(`version ${params.desiredVersion}`),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeMcpCapability(server: unknown): string {
|
||||
if (!server || typeof server !== "object") {
|
||||
return "not configured";
|
||||
}
|
||||
const value = server as Record<string, unknown>;
|
||||
const summary: string[] = [];
|
||||
if (typeof value.command === "string") {
|
||||
summary.push(`local process (${Array.isArray(value.args) ? value.args.length : 0} args)`);
|
||||
} else if (typeof value.url === "string") {
|
||||
summary.push("remote server");
|
||||
} else {
|
||||
summary.push("configured server");
|
||||
}
|
||||
if (value.auth !== undefined) {
|
||||
summary.push("auth configured");
|
||||
}
|
||||
if (value.toolFilter !== undefined) {
|
||||
summary.push("tool filter configured");
|
||||
}
|
||||
if (value.env && typeof value.env === "object") {
|
||||
summary.push(`${Object.keys(value.env).length} env entries`);
|
||||
}
|
||||
return summary.join("; ");
|
||||
}
|
||||
|
||||
function summarizeMcpCapabilityEffect(server: unknown): Record<string, unknown> {
|
||||
if (!server || typeof server !== "object") {
|
||||
return { configured: false };
|
||||
}
|
||||
const value = server as Record<string, unknown>;
|
||||
return {
|
||||
connection:
|
||||
typeof value.command === "string"
|
||||
? "local-process"
|
||||
: typeof value.url === "string"
|
||||
? "remote-server"
|
||||
: "configured-server",
|
||||
...(typeof value.transport === "string" ? { transport: value.transport } : {}),
|
||||
...(typeof value.command === "string"
|
||||
? {
|
||||
commandConfigured: true,
|
||||
argumentCount: Array.isArray(value.args) ? value.args.length : 0,
|
||||
}
|
||||
: {}),
|
||||
...(value.auth !== undefined ? { authConfigured: true } : {}),
|
||||
...(value.toolFilter !== undefined ? { toolFilterConfigured: true } : {}),
|
||||
...(value.env && typeof value.env === "object"
|
||||
? { envEntryCount: Object.keys(value.env).length }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function mcpCapabilityChange(params: {
|
||||
id: string;
|
||||
action: ClawUpdateCapabilityChange["action"];
|
||||
current?: unknown;
|
||||
desired?: unknown;
|
||||
}): ClawUpdateCapabilityChange | undefined {
|
||||
if (params.action === "unchanged") {
|
||||
return undefined;
|
||||
}
|
||||
const reduction = params.desired === undefined;
|
||||
return {
|
||||
kind: "mcpServer",
|
||||
id: params.id,
|
||||
path: `mcpServers.${params.id}`,
|
||||
action: params.action,
|
||||
classification: reduction ? "reduction" : "escalation",
|
||||
requiresDistinctConsent: !reduction,
|
||||
reason: reduction
|
||||
? "Target manifest removes or releases an MCP tool surface."
|
||||
: "Target manifest adds, restores, or changes an MCP tool surface.",
|
||||
effect:
|
||||
params.desired === undefined
|
||||
? { removed: true }
|
||||
: summarizeMcpCapabilityEffect(params.desired),
|
||||
...(params.current === undefined
|
||||
? {}
|
||||
: {
|
||||
current: capabilityValue(summarizeMcpCapability(params.current), params.current),
|
||||
}),
|
||||
...(params.desired === undefined
|
||||
? {}
|
||||
: {
|
||||
desired: capabilityValue(summarizeMcpCapability(params.desired), params.desired),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeCronCapability(cron: unknown): string {
|
||||
if (!cron || typeof cron !== "object") {
|
||||
return "not configured";
|
||||
}
|
||||
const value = cron as Record<string, unknown>;
|
||||
const schedule = value.schedule as Record<string, unknown> | undefined;
|
||||
const scheduleKind = schedule
|
||||
? (Object.keys(schedule).find((key) => key !== "timezone") ?? "configured")
|
||||
: "configured";
|
||||
return `schedule ${scheduleKind}; session ${typeof value.session === "string" ? value.session : "default"}; payload withheld`;
|
||||
}
|
||||
|
||||
function summarizeCronCapabilityEffect(cron: unknown): Record<string, unknown> {
|
||||
if (!cron || typeof cron !== "object") {
|
||||
return { configured: false };
|
||||
}
|
||||
const value = cron as Record<string, unknown>;
|
||||
const schedule = value.schedule as Record<string, unknown> | undefined;
|
||||
return {
|
||||
schedule:
|
||||
schedule && typeof schedule === "object"
|
||||
? (Object.keys(schedule).find((key) => key !== "timezone") ?? "configured")
|
||||
: "configured",
|
||||
timezoneConfigured: typeof schedule?.timezone === "string",
|
||||
session: typeof value.session === "string" ? value.session : "default",
|
||||
deliveryConfigured: value.delivery !== undefined,
|
||||
payloadWithheld: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function cronCapabilityChange(params: {
|
||||
id: string;
|
||||
action: ClawUpdateCapabilityChange["action"];
|
||||
current?: unknown;
|
||||
desired?: unknown;
|
||||
}): ClawUpdateCapabilityChange | undefined {
|
||||
if (params.action === "unchanged") {
|
||||
return undefined;
|
||||
}
|
||||
const reduction = params.desired === undefined;
|
||||
return {
|
||||
kind: "cronJob",
|
||||
id: params.id,
|
||||
path: `cronJobs.${params.id}`,
|
||||
action: params.action,
|
||||
classification: reduction ? "reduction" : "escalation",
|
||||
requiresDistinctConsent: !reduction,
|
||||
reason: reduction
|
||||
? "Target manifest removes a scheduled automation."
|
||||
: "Target manifest adds, restores, or changes a scheduled automation.",
|
||||
effect:
|
||||
params.desired === undefined
|
||||
? { removed: true }
|
||||
: summarizeCronCapabilityEffect(params.desired),
|
||||
...(params.current === undefined
|
||||
? {}
|
||||
: {
|
||||
current: capabilityValue(summarizeCronCapability(params.current), params.current),
|
||||
}),
|
||||
...(params.desired === undefined
|
||||
? {}
|
||||
: {
|
||||
desired: capabilityValue(summarizeCronCapability(params.desired), params.desired),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { CLAW_OUTPUT_STABILITY, type ClawDiagnostic, type ClawSourceIdentity } from "./types.js";
|
||||
import { CLAW_UPDATE_PLAN_SCHEMA_VERSION, type ClawUpdatePlan } from "./update-plan-types.js";
|
||||
|
||||
export function makeEmptyClawUpdatePlan(params: {
|
||||
agentId: string;
|
||||
source?: ClawSourceIdentity;
|
||||
currentClaw?: ClawUpdatePlan["currentClaw"];
|
||||
found?: boolean;
|
||||
blockers: ClawDiagnostic[];
|
||||
diagnostics?: ClawDiagnostic[];
|
||||
digest: (value: unknown) => string;
|
||||
}): ClawUpdatePlan {
|
||||
const plan: Omit<ClawUpdatePlan, "planIntegrity"> = {
|
||||
schemaVersion: CLAW_UPDATE_PLAN_SCHEMA_VERSION,
|
||||
stability: CLAW_OUTPUT_STABILITY,
|
||||
dryRun: true,
|
||||
mutationAllowed: false,
|
||||
found: params.found ?? false,
|
||||
agentId: params.agentId,
|
||||
...(params.currentClaw ? { currentClaw: params.currentClaw } : {}),
|
||||
...(params.source
|
||||
? {
|
||||
targetClaw: {
|
||||
name: params.source.name,
|
||||
version: params.source.version,
|
||||
integrity: params.source.integrity,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
summary: {
|
||||
totalActions: 0,
|
||||
added: 0,
|
||||
changed: 0,
|
||||
removed: 0,
|
||||
released: 0,
|
||||
unchanged: 0,
|
||||
manual: 0,
|
||||
blocked: 0,
|
||||
capabilityChanges: 0,
|
||||
capabilityEscalations: 0,
|
||||
},
|
||||
actions: [],
|
||||
capabilityChanges: [],
|
||||
blockers: params.blockers,
|
||||
diagnostics: params.diagnostics ?? [],
|
||||
};
|
||||
return { ...plan, planIntegrity: params.digest(plan) };
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { CLAW_OUTPUT_STABILITY, ClawDiagnostic, ClawSourceIdentity } from "./types.js";
|
||||
import type { ClawUpdateCapabilityChange } from "./update-capability-changes.js";
|
||||
|
||||
export const CLAW_UPDATE_PLAN_SCHEMA_VERSION = "openclaw.clawUpdatePlan.v1" as const;
|
||||
|
||||
export type ClawUpdateAction = {
|
||||
kind: "agent" | "workspaceFile" | "package" | "mcpServer" | "cronJob";
|
||||
id: string;
|
||||
action: "add" | "change" | "remove" | "release" | "unchanged" | "manual";
|
||||
target: string;
|
||||
blocked: boolean;
|
||||
reason: string;
|
||||
currentDigest?: string;
|
||||
desiredDigest?: string;
|
||||
};
|
||||
|
||||
export type ClawUpdatePlan = {
|
||||
schemaVersion: typeof CLAW_UPDATE_PLAN_SCHEMA_VERSION;
|
||||
stability: typeof CLAW_OUTPUT_STABILITY;
|
||||
dryRun: true;
|
||||
mutationAllowed: false;
|
||||
planIntegrity: string;
|
||||
found: boolean;
|
||||
agentId: string;
|
||||
currentClaw?: { name: string; version: string; integrity: string };
|
||||
targetClaw?: Pick<ClawSourceIdentity, "name" | "version" | "integrity">;
|
||||
summary: {
|
||||
totalActions: number;
|
||||
added: number;
|
||||
changed: number;
|
||||
removed: number;
|
||||
released: number;
|
||||
unchanged: number;
|
||||
manual: number;
|
||||
blocked: number;
|
||||
capabilityChanges: number;
|
||||
capabilityEscalations: number;
|
||||
};
|
||||
actions: ClawUpdateAction[];
|
||||
capabilityChanges: ClawUpdateCapabilityChange[];
|
||||
blockers: ClawDiagnostic[];
|
||||
diagnostics: ClawDiagnostic[];
|
||||
};
|
||||
@@ -0,0 +1,972 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, rm, stat, 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 { stableStringify } from "../agents/stable-stringify.js";
|
||||
import type { McpServerConfig } from "../config/types.mcp.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import {
|
||||
closeOpenClawStateDatabaseForTest,
|
||||
openOpenClawStateDatabase,
|
||||
} from "../state/openclaw-state-db.js";
|
||||
import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js";
|
||||
import { applyClawAddPlan } from "./add.js";
|
||||
import { buildClawAddPlan } from "./lifecycle.js";
|
||||
import { installClawMcpServers } from "./mcp.js";
|
||||
import { persistClawPackageRef } from "./provenance.js";
|
||||
import { parseClawManifest } from "./schema.js";
|
||||
import type { ClawPackage, ClawSourceIdentity, ResolvedClawPackage } from "./types.js";
|
||||
import { buildClawUpdatePlan } from "./update-plan.js";
|
||||
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
|
||||
afterEach(() => closeOpenClawStateDatabaseForTest());
|
||||
|
||||
const packagePreflight = async (pkg: { kind: "skill" | "plugin"; ref: string }) => ({
|
||||
ok: true as const,
|
||||
action: "install" as const,
|
||||
integrity: `sha256:${"a".repeat(64)}`,
|
||||
...(pkg.kind === "plugin" ? { installId: pkg.ref } : {}),
|
||||
});
|
||||
|
||||
async function fixture() {
|
||||
const root = tempDirs.make("openclaw-claw-update-");
|
||||
await writeFile(join(root, "SOUL.md"), "base soul\n", "utf8");
|
||||
await writeFile(join(root, "OLD.md"), "old\n", "utf8");
|
||||
const raw = {
|
||||
schemaVersion: 1,
|
||||
agent: { id: "worker", name: "Worker" },
|
||||
workspace: {
|
||||
bootstrapFiles: { "SOUL.md": { source: "SOUL.md" } },
|
||||
files: [{ source: "OLD.md", path: "OLD.md" }],
|
||||
},
|
||||
packages: [
|
||||
{
|
||||
kind: "skill",
|
||||
source: "clawhub",
|
||||
ref: "triage",
|
||||
version: "1.0.0",
|
||||
},
|
||||
{
|
||||
kind: "plugin",
|
||||
source: "clawhub",
|
||||
ref: "obsolete",
|
||||
version: "1.0.0",
|
||||
},
|
||||
],
|
||||
mcpServers: { docs: { command: "uvx", args: ["docs-mcp"] } },
|
||||
cronJobs: [
|
||||
{
|
||||
id: "daily",
|
||||
schedule: { cron: "0 9 * * *", timezone: "UTC" },
|
||||
session: "isolated",
|
||||
message: "Base report",
|
||||
},
|
||||
],
|
||||
};
|
||||
const parsed = parseClawManifest(raw);
|
||||
if (!parsed.ok) {
|
||||
throw new Error(JSON.stringify(parsed.diagnostics));
|
||||
}
|
||||
const source: ClawSourceIdentity = {
|
||||
kind: "package",
|
||||
name: "@acme/worker",
|
||||
version: "1.0.0",
|
||||
packageRoot: root,
|
||||
manifestPath: join(root, "openclaw.claw.json"),
|
||||
integrityKind: "artifact",
|
||||
integrity: "sha256:base",
|
||||
byteLength: 100,
|
||||
};
|
||||
const env = { OPENCLAW_STATE_DIR: join(root, "state") };
|
||||
const addPlan = await buildClawAddPlan({
|
||||
manifest: parsed.manifest,
|
||||
source,
|
||||
context: { workspace: join(root, "workspace-worker"), packagePreflight },
|
||||
});
|
||||
if (addPlan.blockers.length > 0) {
|
||||
throw new Error(JSON.stringify(addPlan.blockers));
|
||||
}
|
||||
let config: OpenClawConfig = {};
|
||||
await applyClawAddPlan(addPlan, {
|
||||
consentPlanIntegrity: addPlan.planIntegrity,
|
||||
env,
|
||||
commitConfig: async (transform) => {
|
||||
config = transform(config);
|
||||
},
|
||||
installPackages: async (plan, options) =>
|
||||
plan.actions
|
||||
.filter((action) => action.kind === "package")
|
||||
.map((action) =>
|
||||
persistClawPackageRef(plan, action.details as ResolvedClawPackage, options),
|
||||
),
|
||||
installMcpServers: async (plan, options) =>
|
||||
await installClawMcpServers(plan, {
|
||||
...options,
|
||||
setMcpServer: async ({ name, server }) => {
|
||||
const servers = { ...config.mcp?.servers, [name]: server as McpServerConfig };
|
||||
config.mcp = { ...config.mcp, servers };
|
||||
return { ok: true, path: "config", config, mcpServers: servers };
|
||||
},
|
||||
listMcpServers: async () => ({ ok: true, path: "config", config, mcpServers: {} }),
|
||||
}),
|
||||
cronGateway: { add: async () => ({ id: "scheduler-daily" }) },
|
||||
});
|
||||
return { root, env, config, manifest: parsed.manifest, source, addPlan };
|
||||
}
|
||||
|
||||
function targetSource(root: string, version: string, integrity: string): ClawSourceIdentity {
|
||||
return {
|
||||
kind: "package",
|
||||
name: "@acme/worker",
|
||||
version,
|
||||
packageRoot: root,
|
||||
manifestPath: join(root, "openclaw.claw.json"),
|
||||
integrityKind: "artifact",
|
||||
integrity,
|
||||
byteLength: 100,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildClawUpdatePlan", () => {
|
||||
it("plans missing package restoration without mutating state", async () => {
|
||||
const current = await fixture();
|
||||
const beforeConfig = structuredClone(current.config);
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
const databasePath = resolveOpenClawStateSqlitePath(current.env);
|
||||
const beforeBytes = await readFile(databasePath);
|
||||
const beforeStat = await stat(databasePath);
|
||||
|
||||
const plan = await buildClawUpdatePlan({
|
||||
agentId: "worker",
|
||||
targetManifest: current.manifest,
|
||||
targetSource: current.source,
|
||||
config: current.config,
|
||||
sourceMcpServers: current.config.mcp?.servers ?? {},
|
||||
stateOptions: { env: current.env },
|
||||
packagePreflight,
|
||||
});
|
||||
|
||||
expect(plan).toMatchObject({
|
||||
schemaVersion: "openclaw.clawUpdatePlan.v1",
|
||||
stability: "experimental",
|
||||
dryRun: true,
|
||||
mutationAllowed: false,
|
||||
planIntegrity: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
|
||||
found: true,
|
||||
summary: {
|
||||
totalActions: 7,
|
||||
unchanged: 5,
|
||||
added: 0,
|
||||
changed: 2,
|
||||
removed: 0,
|
||||
released: 0,
|
||||
},
|
||||
blockers: [],
|
||||
});
|
||||
expect(current.config).toEqual(beforeConfig);
|
||||
expect(await readFile(databasePath)).toEqual(beforeBytes);
|
||||
expect((await stat(databasePath)).mtimeMs).toBe(beforeStat.mtimeMs);
|
||||
});
|
||||
|
||||
it("resolves an unambiguous installed package name to its final local agent id", async () => {
|
||||
const current = await fixture();
|
||||
|
||||
const plan = await buildClawUpdatePlan({
|
||||
agentId: "@acme/worker",
|
||||
targetManifest: current.manifest,
|
||||
targetSource: current.source,
|
||||
config: current.config,
|
||||
sourceMcpServers: current.config.mcp?.servers ?? {},
|
||||
stateOptions: { env: current.env },
|
||||
packagePreflight,
|
||||
});
|
||||
|
||||
expect(plan).toMatchObject({ found: true, agentId: "worker", blockers: [] });
|
||||
expect(plan.actions).toContainEqual(
|
||||
expect.objectContaining({ kind: "agent", id: "worker", action: "unchanged" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("plans restoration when the owned agent entry is missing", async () => {
|
||||
const current = await fixture();
|
||||
current.config.agents = { ...current.config.agents, entries: {} };
|
||||
|
||||
const plan = await buildClawUpdatePlan({
|
||||
agentId: "worker",
|
||||
targetManifest: current.manifest,
|
||||
targetSource: current.source,
|
||||
config: current.config,
|
||||
sourceMcpServers: current.config.mcp?.servers ?? {},
|
||||
stateOptions: { env: current.env },
|
||||
packagePreflight,
|
||||
});
|
||||
|
||||
const agentAction = plan.actions.find(
|
||||
(action) => action.kind === "agent" && action.id === "worker",
|
||||
);
|
||||
expect(agentAction).toMatchObject({ action: "change", blocked: false });
|
||||
expect(agentAction).not.toHaveProperty("currentDigest");
|
||||
});
|
||||
|
||||
it("plans workspace restoration when the owned workspace directory is missing", async () => {
|
||||
const current = await fixture();
|
||||
await rm(join(current.root, "workspace-worker"), { recursive: true, force: true });
|
||||
|
||||
const plan = await buildClawUpdatePlan({
|
||||
agentId: "worker",
|
||||
targetManifest: current.manifest,
|
||||
targetSource: current.source,
|
||||
config: current.config,
|
||||
sourceMcpServers: current.config.mcp?.servers ?? {},
|
||||
stateOptions: { env: current.env },
|
||||
packagePreflight,
|
||||
});
|
||||
|
||||
expect(plan.actions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "workspaceFile", id: "SOUL.md", action: "change" }),
|
||||
expect.objectContaining({ kind: "workspaceFile", id: "OLD.md", action: "change" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("plans grouped add, change, and removal actions", async () => {
|
||||
const current = await fixture();
|
||||
await writeFile(join(current.root, "SOUL-v2.md"), "new soul\n", "utf8");
|
||||
await writeFile(join(current.root, "NEW.md"), "new\n", "utf8");
|
||||
const raw = {
|
||||
schemaVersion: 1,
|
||||
agent: {
|
||||
id: "requested-id",
|
||||
name: "Worker v2",
|
||||
sandbox: { mode: "all", scope: "agent", workspaceAccess: "rw" },
|
||||
tools: { allow: ["web.fetch"] },
|
||||
},
|
||||
workspace: {
|
||||
bootstrapFiles: { "SOUL.md": { source: "SOUL-v2.md" } },
|
||||
files: [{ source: "NEW.md", path: "NEW.md" }],
|
||||
},
|
||||
packages: [
|
||||
{
|
||||
kind: "skill",
|
||||
source: "clawhub",
|
||||
ref: "triage",
|
||||
version: "2.0.0",
|
||||
},
|
||||
{
|
||||
kind: "plugin",
|
||||
source: "clawhub",
|
||||
ref: "new-plugin",
|
||||
version: "1.0.0",
|
||||
},
|
||||
],
|
||||
mcpServers: {
|
||||
docs: { command: "uvx", args: ["docs-mcp-v2"] },
|
||||
search: {
|
||||
url: "https://mcp.example.com/search",
|
||||
transport: "streamable-http",
|
||||
auth: "oauth",
|
||||
},
|
||||
},
|
||||
cronJobs: [
|
||||
{
|
||||
id: "daily",
|
||||
schedule: { cron: "0 10 * * *", timezone: "UTC" },
|
||||
session: "isolated",
|
||||
message: "Updated report",
|
||||
},
|
||||
{
|
||||
id: "weekly",
|
||||
schedule: { cron: "0 9 * * 1", timezone: "UTC" },
|
||||
session: "isolated",
|
||||
message: "Weekly report",
|
||||
},
|
||||
],
|
||||
};
|
||||
const parsed = parseClawManifest(raw);
|
||||
if (!parsed.ok) {
|
||||
throw new Error(JSON.stringify(parsed.diagnostics));
|
||||
}
|
||||
|
||||
const plan = await buildClawUpdatePlan({
|
||||
agentId: "worker",
|
||||
targetManifest: parsed.manifest,
|
||||
targetSource: targetSource(current.root, "2.0.0", "sha256:target"),
|
||||
config: current.config,
|
||||
sourceMcpServers: current.config.mcp?.servers ?? {},
|
||||
stateOptions: { env: current.env },
|
||||
packagePreflight,
|
||||
});
|
||||
|
||||
expect(plan.summary).toMatchObject({
|
||||
totalActions: 11,
|
||||
added: 4,
|
||||
changed: 5,
|
||||
removed: 1,
|
||||
released: 0,
|
||||
unchanged: 0,
|
||||
manual: 1,
|
||||
blocked: 1,
|
||||
capabilityEscalations: expect.any(Number),
|
||||
});
|
||||
expect(plan.summary.capabilityEscalations).toBeGreaterThan(0);
|
||||
expect(plan.capabilityChanges).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "agent",
|
||||
path: "agent.sandbox.mode",
|
||||
desired: expect.objectContaining({ digest: expect.any(String) }),
|
||||
effect: expect.objectContaining({ path: "sandbox.mode" }),
|
||||
requiresDistinctConsent: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "agent",
|
||||
path: "agent.tools.allow",
|
||||
desired: expect.objectContaining({ digest: expect.any(String) }),
|
||||
effect: expect.objectContaining({ path: "tools.allow" }),
|
||||
requiresDistinctConsent: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "package",
|
||||
id: "plugin:new-plugin",
|
||||
effect: expect.objectContaining({
|
||||
version: "1.0.0",
|
||||
integrity: `sha256:${"a".repeat(64)}`,
|
||||
installId: "new-plugin",
|
||||
}),
|
||||
requiresDistinctConsent: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "mcpServer",
|
||||
id: "search",
|
||||
effect: expect.objectContaining({
|
||||
connection: "remote-server",
|
||||
transport: "streamable-http",
|
||||
authConfigured: true,
|
||||
}),
|
||||
desired: expect.objectContaining({
|
||||
summary: "remote server; auth configured",
|
||||
digest: expect.any(String),
|
||||
}),
|
||||
requiresDistinctConsent: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "cronJob",
|
||||
id: "daily",
|
||||
effect: expect.objectContaining({
|
||||
schedule: "cron",
|
||||
payloadWithheld: true,
|
||||
}),
|
||||
requiresDistinctConsent: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(plan.actions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "agent", action: "change", id: "worker" }),
|
||||
expect.objectContaining({ kind: "workspaceFile", action: "remove", id: "OLD.md" }),
|
||||
expect.objectContaining({ kind: "package", action: "change", id: "skill:triage" }),
|
||||
expect.objectContaining({ kind: "mcpServer", action: "add", id: "search" }),
|
||||
expect.objectContaining({ kind: "cronJob", action: "change", id: "daily" }),
|
||||
]),
|
||||
);
|
||||
const serializedPlan = JSON.stringify(plan);
|
||||
expect(serializedPlan).not.toContain("mcp.example.com");
|
||||
expect(serializedPlan).not.toContain("docs-mcp-v2");
|
||||
expect(serializedPlan).not.toContain("Updated report");
|
||||
expect(serializedPlan).toContain("remote server; auth configured");
|
||||
expect(serializedPlan).toContain("payload withheld");
|
||||
});
|
||||
|
||||
it.each(["modified", "ambiguous"] as const)(
|
||||
"blocks removal when an installed plugin is %s",
|
||||
async (state) => {
|
||||
const current = await fixture();
|
||||
const parsed = parseClawManifest({
|
||||
...current.manifest,
|
||||
packages: current.manifest.packages.filter((pkg) => pkg.ref !== "obsolete"),
|
||||
});
|
||||
if (!parsed.ok) {
|
||||
throw new Error(JSON.stringify(parsed.diagnostics));
|
||||
}
|
||||
|
||||
const plan = await buildClawUpdatePlan({
|
||||
agentId: "worker",
|
||||
targetManifest: parsed.manifest,
|
||||
targetSource: targetSource(current.root, "2.0.0", "sha256:target"),
|
||||
config: current.config,
|
||||
sourceMcpServers: current.config.mcp?.servers ?? {},
|
||||
stateOptions: {
|
||||
env: current.env,
|
||||
packageDeps: {
|
||||
resolvePlugin: async () =>
|
||||
state === "ambiguous"
|
||||
? { status: "ambiguous", pluginIds: ["obsolete-a", "obsolete-b"] }
|
||||
: {
|
||||
status: "found",
|
||||
pluginId: "obsolete-runtime",
|
||||
record: {
|
||||
source: "clawhub",
|
||||
integrity: `sha256:${"b".repeat(64)}`,
|
||||
installedAt: "2000-01-01T00:00:00.000Z",
|
||||
},
|
||||
installedVersion: "1.0.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
packagePreflight,
|
||||
});
|
||||
|
||||
expect(plan.actions).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "package",
|
||||
id: "plugin:obsolete",
|
||||
action: "manual",
|
||||
blocked: true,
|
||||
reason: expect.stringContaining(state),
|
||||
}),
|
||||
);
|
||||
const capabilityChange = plan.capabilityChanges.find(
|
||||
(change) => change.kind === "package" && change.id === "plugin:obsolete",
|
||||
);
|
||||
expect(capabilityChange).toMatchObject({
|
||||
action: "manual",
|
||||
classification: "reduction",
|
||||
requiresDistinctConsent: false,
|
||||
current: expect.objectContaining({ summary: "version 1.0.0" }),
|
||||
});
|
||||
expect(capabilityChange).not.toHaveProperty("desired");
|
||||
},
|
||||
);
|
||||
|
||||
it("marks operator drift and unresolved ownership as manual", async () => {
|
||||
const current = await fixture();
|
||||
await writeFile(join(current.root, "workspace-worker", "SOUL.md"), "operator edit\n", "utf8");
|
||||
current.config.mcp!.servers!.docs = { command: "node", args: ["operator.mjs"] };
|
||||
openOpenClawStateDatabase({ env: current.env })
|
||||
.db.prepare("UPDATE claw_cron_refs SET status = 'pending' WHERE agent_id = 'worker'")
|
||||
.run();
|
||||
|
||||
const plan = await buildClawUpdatePlan({
|
||||
agentId: "worker",
|
||||
targetManifest: current.manifest,
|
||||
targetSource: current.source,
|
||||
config: current.config,
|
||||
sourceMcpServers: current.config.mcp?.servers ?? {},
|
||||
stateOptions: { env: current.env },
|
||||
packagePreflight,
|
||||
});
|
||||
|
||||
expect(plan.actions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "workspaceFile", id: "SOUL.md", action: "manual" }),
|
||||
expect.objectContaining({ kind: "mcpServer", id: "docs", action: "manual" }),
|
||||
expect.objectContaining({ kind: "cronJob", id: "daily", action: "manual" }),
|
||||
]),
|
||||
);
|
||||
expect(plan.summary.manual).toBe(3);
|
||||
expect(plan.summary.blocked).toBe(3);
|
||||
expect(plan.actions.filter((action) => action.action === "manual")).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ blocked: true })]),
|
||||
);
|
||||
});
|
||||
|
||||
it("classifies blocked MCP and cron removals as capability reductions", async () => {
|
||||
const current = await fixture();
|
||||
const database = openOpenClawStateDatabase({ env: current.env }).db;
|
||||
database
|
||||
.prepare("UPDATE claw_mcp_server_refs SET status = 'pending' WHERE agent_id = 'worker'")
|
||||
.run();
|
||||
database
|
||||
.prepare("UPDATE claw_cron_refs SET status = 'pending' WHERE agent_id = 'worker'")
|
||||
.run();
|
||||
const parsed = parseClawManifest({
|
||||
...current.manifest,
|
||||
mcpServers: {},
|
||||
cronJobs: [],
|
||||
});
|
||||
if (!parsed.ok) {
|
||||
throw new Error(JSON.stringify(parsed.diagnostics));
|
||||
}
|
||||
|
||||
const plan = await buildClawUpdatePlan({
|
||||
agentId: "worker",
|
||||
targetManifest: parsed.manifest,
|
||||
targetSource: targetSource(current.root, "2.0.0", "sha256:target"),
|
||||
config: current.config,
|
||||
sourceMcpServers: current.config.mcp?.servers ?? {},
|
||||
stateOptions: { env: current.env },
|
||||
packagePreflight,
|
||||
});
|
||||
|
||||
for (const [kind, id] of [
|
||||
["mcpServer", "docs"],
|
||||
["cronJob", "daily"],
|
||||
] as const) {
|
||||
const capabilityChange = plan.capabilityChanges.find(
|
||||
(change) => change.kind === kind && change.id === id,
|
||||
);
|
||||
expect(capabilityChange).toMatchObject({
|
||||
action: "manual",
|
||||
classification: "reduction",
|
||||
requiresDistinctConsent: false,
|
||||
});
|
||||
expect(capabilityChange).not.toHaveProperty("desired");
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks unowned workspace, MCP, and incompatible shared plugin claims", async () => {
|
||||
const current = await fixture();
|
||||
await writeFile(join(current.root, "NOTES-source.md"), "managed notes\n", "utf8");
|
||||
await writeFile(join(current.root, "workspace-worker", "NOTES.md"), "operator notes\n", "utf8");
|
||||
current.config.mcp = {
|
||||
...current.config.mcp,
|
||||
servers: {
|
||||
...current.config.mcp?.servers,
|
||||
search: { command: "node", args: ["operator-search.mjs"] },
|
||||
},
|
||||
};
|
||||
persistClawPackageRef(
|
||||
{
|
||||
...current.addPlan,
|
||||
agent: { ...current.addPlan.agent, finalId: "other-agent" },
|
||||
},
|
||||
{
|
||||
kind: "plugin",
|
||||
source: "clawhub",
|
||||
ref: "audit",
|
||||
version: "0.9.0",
|
||||
integrity: "sha256:7777777777777777777777777777777777777777777777777777777777777777",
|
||||
},
|
||||
{ env: current.env },
|
||||
);
|
||||
const parsed = parseClawManifest({
|
||||
...current.manifest,
|
||||
workspace: {
|
||||
...current.manifest.workspace,
|
||||
files: [
|
||||
...current.manifest.workspace.files,
|
||||
{ source: "NOTES-source.md", path: "NOTES.md" },
|
||||
],
|
||||
},
|
||||
packages: [
|
||||
...current.manifest.packages,
|
||||
{
|
||||
kind: "plugin",
|
||||
source: "clawhub",
|
||||
ref: "audit",
|
||||
version: "1.0.0",
|
||||
},
|
||||
],
|
||||
mcpServers: {
|
||||
...current.manifest.mcpServers,
|
||||
search: { command: "uvx", args: ["search-mcp"] },
|
||||
},
|
||||
});
|
||||
if (!parsed.ok) {
|
||||
throw new Error(JSON.stringify(parsed.diagnostics));
|
||||
}
|
||||
|
||||
const plan = await buildClawUpdatePlan({
|
||||
agentId: "worker",
|
||||
targetManifest: parsed.manifest,
|
||||
targetSource: targetSource(current.root, "2.0.0", "sha256:target"),
|
||||
config: current.config,
|
||||
sourceMcpServers: current.config.mcp?.servers ?? {},
|
||||
stateOptions: { env: current.env },
|
||||
packagePreflight,
|
||||
});
|
||||
|
||||
expect(plan.actions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "workspaceFile",
|
||||
id: "NOTES.md",
|
||||
action: "manual",
|
||||
blocked: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "mcpServer",
|
||||
id: "search",
|
||||
action: "manual",
|
||||
blocked: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "package",
|
||||
id: "plugin:audit",
|
||||
action: "manual",
|
||||
blocked: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(plan.summary.blocked).toBe(3);
|
||||
});
|
||||
|
||||
it("blocks incomplete packages and independently owned MCP changes", async () => {
|
||||
const current = await fixture();
|
||||
const database = openOpenClawStateDatabase({ env: current.env }).db;
|
||||
database
|
||||
.prepare(
|
||||
"UPDATE claw_package_refs SET package_status = 'pending' WHERE agent_id = 'worker' AND package_ref = 'triage'",
|
||||
)
|
||||
.run();
|
||||
database
|
||||
.prepare(
|
||||
"UPDATE claw_mcp_server_refs SET relationship = 'referenced', origin = 'pre-existing', independent_owner = 1 WHERE agent_id = 'worker' AND name = 'docs'",
|
||||
)
|
||||
.run();
|
||||
const parsed = parseClawManifest({
|
||||
...current.manifest,
|
||||
mcpServers: { docs: { command: "uvx", args: ["docs-mcp-v2"] } },
|
||||
});
|
||||
if (!parsed.ok) {
|
||||
throw new Error(JSON.stringify(parsed.diagnostics));
|
||||
}
|
||||
|
||||
const plan = await buildClawUpdatePlan({
|
||||
agentId: "worker",
|
||||
targetManifest: parsed.manifest,
|
||||
targetSource: targetSource(current.root, "2.0.0", "sha256:target"),
|
||||
config: current.config,
|
||||
sourceMcpServers: current.config.mcp?.servers ?? {},
|
||||
stateOptions: { env: current.env },
|
||||
packagePreflight,
|
||||
});
|
||||
|
||||
expect(plan.actions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "package",
|
||||
id: "skill:triage",
|
||||
action: "manual",
|
||||
blocked: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "mcpServer",
|
||||
id: "docs",
|
||||
action: "manual",
|
||||
blocked: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks restoring independently owned packages and MCP configuration", async () => {
|
||||
const current = await fixture();
|
||||
const database = openOpenClawStateDatabase({ env: current.env }).db;
|
||||
database
|
||||
.prepare(
|
||||
"UPDATE claw_package_refs SET relationship = 'referenced', origin = 'pre-existing', independent_owner = 1 WHERE agent_id = 'worker' AND package_ref = 'triage'",
|
||||
)
|
||||
.run();
|
||||
database
|
||||
.prepare(
|
||||
"UPDATE claw_mcp_server_refs SET relationship = 'referenced', origin = 'pre-existing', independent_owner = 1 WHERE agent_id = 'worker' AND name = 'docs'",
|
||||
)
|
||||
.run();
|
||||
delete current.config.mcp!.servers!.docs;
|
||||
|
||||
const plan = await buildClawUpdatePlan({
|
||||
agentId: "worker",
|
||||
targetManifest: current.manifest,
|
||||
targetSource: targetSource(current.root, "2.0.0", "sha256:target"),
|
||||
config: current.config,
|
||||
sourceMcpServers: current.config.mcp?.servers ?? {},
|
||||
stateOptions: { env: current.env },
|
||||
packagePreflight,
|
||||
});
|
||||
|
||||
expect(plan.actions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "package",
|
||||
id: "skill:triage",
|
||||
action: "manual",
|
||||
blocked: true,
|
||||
reason: expect.stringContaining("independently owned"),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "mcpServer",
|
||||
id: "docs",
|
||||
action: "manual",
|
||||
blocked: true,
|
||||
reason: expect.stringContaining("independently owned"),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("releases a removed package declaration without uninstalling its artifact", async () => {
|
||||
const current = await fixture();
|
||||
const parsed = parseClawManifest({
|
||||
...current.manifest,
|
||||
packages: current.manifest.packages.filter((pkg) => pkg.ref !== "triage"),
|
||||
});
|
||||
if (!parsed.ok) {
|
||||
throw new Error(JSON.stringify(parsed.diagnostics));
|
||||
}
|
||||
|
||||
const plan = await buildClawUpdatePlan({
|
||||
agentId: "worker",
|
||||
targetManifest: parsed.manifest,
|
||||
targetSource: targetSource(current.root, "2.0.0", "sha256:target"),
|
||||
config: current.config,
|
||||
sourceMcpServers: current.config.mcp?.servers ?? {},
|
||||
stateOptions: {
|
||||
env: current.env,
|
||||
packageDeps: {
|
||||
planSkill: async () => ({
|
||||
ok: true as const,
|
||||
plan: {
|
||||
workspaceDir: current.addPlan.agent.workspace,
|
||||
slug: "triage",
|
||||
version: "1.0.0",
|
||||
installedAt: 0,
|
||||
targetDir: join(current.addPlan.agent.workspace, "skills", "triage"),
|
||||
skillFilePath: join(current.addPlan.agent.workspace, "skills", "triage", "SKILL.md"),
|
||||
skillFileSha256: "a".repeat(64),
|
||||
fileTreeSha256: `sha256:${"a".repeat(64)}`,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
packagePreflight,
|
||||
});
|
||||
|
||||
expect(plan.actions).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "package",
|
||||
id: "skill:triage",
|
||||
action: "release",
|
||||
blocked: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses update package semantics instead of add-time conflicts", async () => {
|
||||
const current = await fixture();
|
||||
const parsed = parseClawManifest({
|
||||
...current.manifest,
|
||||
packages: [
|
||||
current.manifest.packages[0],
|
||||
{ ...current.manifest.packages[1], version: "2.0.0" },
|
||||
{
|
||||
kind: "plugin",
|
||||
source: "clawhub",
|
||||
ref: "new-plugin",
|
||||
version: "1.0.0",
|
||||
},
|
||||
],
|
||||
});
|
||||
if (!parsed.ok) {
|
||||
throw new Error(JSON.stringify(parsed.diagnostics));
|
||||
}
|
||||
const unavailablePreflight = async (pkg: ClawPackage) =>
|
||||
pkg.ref === "obsolete"
|
||||
? {
|
||||
ok: false as const,
|
||||
code: "plugin_version_conflict",
|
||||
installedVersion: "1.0.0",
|
||||
message: `Installed ${pkg.ref} has the previous owned version.`,
|
||||
}
|
||||
: {
|
||||
ok: false as const,
|
||||
code:
|
||||
pkg.kind === "skill"
|
||||
? "skill_package_preflight_unavailable"
|
||||
: "plugin_version_conflict",
|
||||
message: `Cannot add ${pkg.ref}.`,
|
||||
};
|
||||
|
||||
const plan = await buildClawUpdatePlan({
|
||||
agentId: "worker",
|
||||
targetManifest: parsed.manifest,
|
||||
targetSource: targetSource(current.root, "2.0.0", "sha256:target"),
|
||||
config: current.config,
|
||||
sourceMcpServers: current.config.mcp?.servers ?? {},
|
||||
stateOptions: {
|
||||
env: current.env,
|
||||
packageDeps: {
|
||||
resolvePlugin: async () => ({
|
||||
status: "found",
|
||||
pluginId: "obsolete-runtime",
|
||||
record: {
|
||||
source: "clawhub",
|
||||
integrity: `sha256:${"a".repeat(64)}`,
|
||||
installedAt: "2000-01-01T00:00:00.000Z",
|
||||
},
|
||||
installedVersion: "1.0.0",
|
||||
}),
|
||||
},
|
||||
},
|
||||
packagePreflight: unavailablePreflight,
|
||||
});
|
||||
|
||||
expect(plan.actions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "package",
|
||||
id: "skill:triage",
|
||||
action: "manual",
|
||||
blocked: true,
|
||||
}),
|
||||
expect.objectContaining({ kind: "package", id: "plugin:obsolete", action: "change" }),
|
||||
expect.objectContaining({
|
||||
kind: "package",
|
||||
id: "plugin:new-plugin",
|
||||
action: "manual",
|
||||
blocked: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(plan.blockers).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
code: "skill_package_preflight_unavailable",
|
||||
path: "$.packages[0]",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
code: "plugin_version_conflict",
|
||||
path: "$.packages[2]",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks changing an MCP declaration shared by another Claw", async () => {
|
||||
const current = await fixture();
|
||||
openOpenClawStateDatabase({ env: current.env })
|
||||
.db.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
|
||||
) SELECT
|
||||
'other-agent', name, schema_version, config_digest, 'referenced', origin,
|
||||
independent_owner, status, error,
|
||||
created_at_ms, updated_at_ms
|
||||
FROM claw_mcp_server_refs
|
||||
WHERE agent_id = 'worker' AND name = 'docs'`,
|
||||
)
|
||||
.run();
|
||||
const parsed = parseClawManifest({
|
||||
...current.manifest,
|
||||
mcpServers: { docs: { command: "uvx", args: ["docs-mcp-v2"] } },
|
||||
});
|
||||
if (!parsed.ok) {
|
||||
throw new Error(JSON.stringify(parsed.diagnostics));
|
||||
}
|
||||
|
||||
const plan = await buildClawUpdatePlan({
|
||||
agentId: "worker",
|
||||
targetManifest: parsed.manifest,
|
||||
targetSource: targetSource(current.root, "2.0.0", "sha256:target"),
|
||||
config: current.config,
|
||||
sourceMcpServers: current.config.mcp?.servers ?? {},
|
||||
stateOptions: { env: current.env },
|
||||
packagePreflight,
|
||||
});
|
||||
|
||||
expect(plan.actions).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "mcpServer",
|
||||
id: "docs",
|
||||
action: "manual",
|
||||
blocked: true,
|
||||
reason: expect.stringContaining("Another Claw shares"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("releases shared and independently owned MCP declarations without removing config", async () => {
|
||||
const current = await fixture();
|
||||
const database = openOpenClawStateDatabase({ env: current.env }).db;
|
||||
database
|
||||
.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
|
||||
) SELECT
|
||||
'other-agent', name, schema_version, config_digest, 'referenced', origin,
|
||||
independent_owner, status, error,
|
||||
created_at_ms, updated_at_ms
|
||||
FROM claw_mcp_server_refs
|
||||
WHERE agent_id = 'worker' AND name = 'docs'`,
|
||||
)
|
||||
.run();
|
||||
current.config.mcp!.servers!.docs = { command: "node", args: ["operator-docs.mjs"] };
|
||||
const parsed = parseClawManifest({ ...current.manifest, mcpServers: {} });
|
||||
if (!parsed.ok) {
|
||||
throw new Error(JSON.stringify(parsed.diagnostics));
|
||||
}
|
||||
|
||||
const shared = await buildClawUpdatePlan({
|
||||
agentId: "worker",
|
||||
targetManifest: parsed.manifest,
|
||||
targetSource: targetSource(current.root, "2.0.0", "sha256:target"),
|
||||
config: current.config,
|
||||
sourceMcpServers: current.config.mcp?.servers ?? {},
|
||||
stateOptions: { env: current.env },
|
||||
packagePreflight,
|
||||
});
|
||||
expect(shared.actions).toContainEqual(
|
||||
expect.objectContaining({ kind: "mcpServer", id: "docs", action: "release", blocked: false }),
|
||||
);
|
||||
expect(shared.summary.released).toBe(1);
|
||||
|
||||
database
|
||||
.prepare("DELETE FROM claw_mcp_server_refs WHERE agent_id = 'other-agent' AND name = 'docs'")
|
||||
.run();
|
||||
database
|
||||
.prepare(
|
||||
"UPDATE claw_mcp_server_refs SET relationship = 'referenced', origin = 'pre-existing', independent_owner = 1 WHERE agent_id = 'worker' AND name = 'docs'",
|
||||
)
|
||||
.run();
|
||||
const independent = await buildClawUpdatePlan({
|
||||
agentId: "worker",
|
||||
targetManifest: parsed.manifest,
|
||||
targetSource: targetSource(current.root, "2.0.0", "sha256:target"),
|
||||
config: current.config,
|
||||
sourceMcpServers: current.config.mcp?.servers ?? {},
|
||||
stateOptions: { env: current.env },
|
||||
packagePreflight,
|
||||
});
|
||||
expect(independent.actions).toContainEqual(
|
||||
expect.objectContaining({ kind: "mcpServer", id: "docs", action: "release", blocked: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed for missing agents and mismatched package identity", async () => {
|
||||
const current = await fixture();
|
||||
const missing = await buildClawUpdatePlan({
|
||||
agentId: "missing",
|
||||
targetManifest: current.manifest,
|
||||
targetSource: current.source,
|
||||
config: current.config,
|
||||
sourceMcpServers: current.config.mcp?.servers ?? {},
|
||||
stateOptions: { env: current.env },
|
||||
packagePreflight,
|
||||
});
|
||||
expect(missing.blockers).toContainEqual(expect.objectContaining({ code: "claw_not_found" }));
|
||||
|
||||
const mismatch = await buildClawUpdatePlan({
|
||||
agentId: "worker",
|
||||
targetManifest: current.manifest,
|
||||
targetSource: { ...current.source, name: "@other/worker" },
|
||||
config: current.config,
|
||||
sourceMcpServers: current.config.mcp?.servers ?? {},
|
||||
stateOptions: { env: current.env },
|
||||
packagePreflight,
|
||||
});
|
||||
expect(mismatch.blockers).toContainEqual(
|
||||
expect.objectContaining({ code: "claw_identity_mismatch" }),
|
||||
);
|
||||
const { planIntegrity, ...authenticatedPlan } = mismatch;
|
||||
expect(planIntegrity).toBe(
|
||||
`sha256:${createHash("sha256").update(stableStringify(authenticatedPlan)).digest("hex")}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,712 @@
|
||||
// Builds read-only, agent-centric Claw update plans from grouped manifests and ownership state.
|
||||
import { createHash } from "node:crypto";
|
||||
import { lstat } from "node:fs/promises";
|
||||
import { stableStringify } from "../agents/stable-stringify.js";
|
||||
import { normalizeConfiguredMcpServers } from "../config/mcp-config-normalize.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { root as fsSafeRoot } from "../infra/fs-safe.js";
|
||||
import {
|
||||
openExistingOpenClawStateDatabaseReadOnly,
|
||||
type OpenClawStateDatabaseOptions,
|
||||
} from "../state/openclaw-state-db.js";
|
||||
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 { readClawPackageRefs } from "./provenance.js";
|
||||
import {
|
||||
CLAW_OUTPUT_STABILITY,
|
||||
type ClawDiagnostic,
|
||||
type ClawManifest,
|
||||
type ClawPackage,
|
||||
type ClawSourceIdentity,
|
||||
} from "./types.js";
|
||||
import {
|
||||
cronCapabilityChange,
|
||||
mcpCapabilityChange,
|
||||
packageCapabilityChange,
|
||||
pushResolvedAgentCapabilityChanges,
|
||||
type ClawUpdateCapabilityChange,
|
||||
} from "./update-capability-changes.js";
|
||||
import { makeEmptyClawUpdatePlan } from "./update-plan-empty.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";
|
||||
|
||||
function digest(value: unknown): string {
|
||||
return `sha256:${createHash("sha256").update(stableStringify(value)).digest("hex")}`;
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
export async function buildClawUpdatePlan(params: {
|
||||
agentId: string;
|
||||
targetManifest: ClawManifest;
|
||||
targetSource: ClawSourceIdentity;
|
||||
config: OpenClawConfig;
|
||||
sourceMcpServers: Record<string, Record<string, unknown>>;
|
||||
stateOptions?: OpenClawStateDatabaseOptions & { packageDeps?: PackageRemovalDeps };
|
||||
packagePreflight?: (
|
||||
pkg: ClawPackage,
|
||||
workspaceDir: string,
|
||||
) => Promise<{
|
||||
ok: boolean;
|
||||
action?: "install" | "reuse";
|
||||
code?: string;
|
||||
message?: string;
|
||||
installedVersion?: string;
|
||||
integrity?: string;
|
||||
installId?: string;
|
||||
warning?: string;
|
||||
}>;
|
||||
diagnostics?: ClawDiagnostic[];
|
||||
}): Promise<ClawUpdatePlan> {
|
||||
const ownsDatabase = !params.stateOptions?.database;
|
||||
const database =
|
||||
params.stateOptions?.database ?? openExistingOpenClawStateDatabaseReadOnly(params.stateOptions);
|
||||
if (!database) {
|
||||
return makeEmptyClawUpdatePlan({
|
||||
agentId: params.agentId,
|
||||
source: params.targetSource,
|
||||
blockers: [
|
||||
diagnostic(
|
||||
"claw_not_found",
|
||||
"$",
|
||||
`No installed Claw agent matches ${JSON.stringify(params.agentId)}.`,
|
||||
),
|
||||
],
|
||||
diagnostics: params.diagnostics,
|
||||
digest,
|
||||
});
|
||||
}
|
||||
if (
|
||||
!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 (ownsDatabase) {
|
||||
database.walMaintenance.close();
|
||||
}
|
||||
return makeEmptyClawUpdatePlan({
|
||||
agentId: params.agentId,
|
||||
source: params.targetSource,
|
||||
blockers: [
|
||||
diagnostic(
|
||||
"claw_not_found",
|
||||
"$",
|
||||
`No installed Claw agent matches ${JSON.stringify(params.agentId)}.`,
|
||||
),
|
||||
],
|
||||
diagnostics: params.diagnostics,
|
||||
digest,
|
||||
});
|
||||
}
|
||||
const readOnlyStateOptions: OpenClawStateDatabaseOptions & {
|
||||
packageDeps?: PackageRemovalDeps;
|
||||
} = {
|
||||
...params.stateOptions,
|
||||
database,
|
||||
readOnly: true,
|
||||
};
|
||||
try {
|
||||
const status = await readClawStatus(params.agentId, {
|
||||
...readOnlyStateOptions,
|
||||
config: params.config,
|
||||
sourceMcpServers: params.sourceMcpServers,
|
||||
});
|
||||
if (status.records.length === 0) {
|
||||
return makeEmptyClawUpdatePlan({
|
||||
agentId: params.agentId,
|
||||
source: params.targetSource,
|
||||
blockers: [
|
||||
diagnostic(
|
||||
"claw_not_found",
|
||||
"$",
|
||||
`No installed Claw agent matches ${JSON.stringify(params.agentId)}.`,
|
||||
),
|
||||
],
|
||||
diagnostics: params.diagnostics,
|
||||
digest,
|
||||
});
|
||||
}
|
||||
if (status.records.length > 1) {
|
||||
return makeEmptyClawUpdatePlan({
|
||||
agentId: params.agentId,
|
||||
source: params.targetSource,
|
||||
found: true,
|
||||
blockers: [
|
||||
diagnostic(
|
||||
"claw_ambiguous",
|
||||
"$",
|
||||
`Claw name ${JSON.stringify(params.agentId)} matches multiple agents; use an agent id.`,
|
||||
),
|
||||
],
|
||||
diagnostics: params.diagnostics,
|
||||
digest,
|
||||
});
|
||||
}
|
||||
const record = status.records[0]!;
|
||||
const agentId = record.install.agentId;
|
||||
if (record.install.claw.name !== params.targetSource.name) {
|
||||
return makeEmptyClawUpdatePlan({
|
||||
agentId,
|
||||
source: params.targetSource,
|
||||
found: true,
|
||||
currentClaw: {
|
||||
name: record.install.claw.name,
|
||||
version: record.install.claw.version,
|
||||
integrity: record.install.claw.integrity,
|
||||
},
|
||||
blockers: [
|
||||
diagnostic(
|
||||
"claw_identity_mismatch",
|
||||
"$.name",
|
||||
`Target package ${JSON.stringify(params.targetSource.name)} does not match installed Claw ${JSON.stringify(record.install.claw.name)}.`,
|
||||
),
|
||||
],
|
||||
diagnostics: params.diagnostics,
|
||||
digest,
|
||||
});
|
||||
}
|
||||
|
||||
const packageKey = (value: { kind: string; ref: string }) => `${value.kind}:${value.ref}`;
|
||||
const packagePreflights = new Map<
|
||||
string,
|
||||
{
|
||||
ok: boolean;
|
||||
action?: "install" | "reuse";
|
||||
code?: string;
|
||||
message?: string;
|
||||
installedVersion?: string;
|
||||
integrity?: string;
|
||||
installId?: string;
|
||||
warning?: string;
|
||||
}
|
||||
>();
|
||||
const targetPlan = await buildClawAddPlan({
|
||||
manifest: params.targetManifest,
|
||||
source: params.targetSource,
|
||||
diagnostics: params.diagnostics,
|
||||
context: {
|
||||
agentId,
|
||||
workspace: record.install.workspace,
|
||||
packagePreflight: async (pkg) => {
|
||||
const result = params.packagePreflight
|
||||
? await params.packagePreflight(pkg, record.install.workspace)
|
||||
: {
|
||||
ok: false,
|
||||
code: "package_install_unavailable",
|
||||
message: "Package preflight is unavailable.",
|
||||
};
|
||||
packagePreflights.set(packageKey(pkg), result);
|
||||
return result;
|
||||
},
|
||||
},
|
||||
});
|
||||
const blockers = targetPlan.blockers.filter(
|
||||
(entry) =>
|
||||
entry.code !== "workspace_collision" &&
|
||||
entry.code !== "agent_id_collision" &&
|
||||
!entry.path.startsWith("$.packages"),
|
||||
);
|
||||
const actions: ClawUpdateAction[] = [];
|
||||
const capabilityChanges: ClawUpdateCapabilityChange[] = [];
|
||||
|
||||
const desiredAgentDigest = digest(targetPlan.agent.config);
|
||||
const agentAction =
|
||||
record.agentState === "modified"
|
||||
? "manual"
|
||||
: record.agentState === "missing"
|
||||
? "change"
|
||||
: record.install.agentConfigDigest === desiredAgentDigest
|
||||
? "unchanged"
|
||||
: "change";
|
||||
actions.push({
|
||||
kind: "agent",
|
||||
id: agentId,
|
||||
action: agentAction,
|
||||
target: `agents.list.${agentId}`,
|
||||
blocked: agentAction === "manual",
|
||||
reason:
|
||||
agentAction === "manual"
|
||||
? "Live agent config changed after installation and must be reconciled manually."
|
||||
: record.agentState === "missing"
|
||||
? "Owned agent config is missing and would be restored from the target manifest."
|
||||
: agentAction === "unchanged"
|
||||
? "Owned agent config already matches the target manifest."
|
||||
: "Target manifest changes owned agent config.",
|
||||
...(record.agentState === "missing"
|
||||
? {}
|
||||
: { currentDigest: record.install.agentConfigDigest }),
|
||||
desiredDigest: desiredAgentDigest,
|
||||
});
|
||||
pushResolvedAgentCapabilityChanges({
|
||||
changes: capabilityChanges,
|
||||
agentId,
|
||||
config: params.config,
|
||||
desiredAgent: targetPlan.agent.config,
|
||||
});
|
||||
|
||||
const targetFiles = new Map(
|
||||
targetPlan.actions
|
||||
.filter((action) => action.kind === "workspaceFile")
|
||||
.map((action) => [action.id, action] as const),
|
||||
);
|
||||
const currentFiles = new Map(record.workspaceFiles.map((file) => [file.path, file] as const));
|
||||
let workspace: Awaited<ReturnType<typeof fsSafeRoot>> | undefined;
|
||||
let workspaceState: "present" | "missing" | "unsafe" = "present";
|
||||
try {
|
||||
const workspaceStat = await lstat(record.install.workspace);
|
||||
if (!workspaceStat.isDirectory() || workspaceStat.isSymbolicLink()) {
|
||||
workspaceState = "unsafe";
|
||||
} else {
|
||||
workspace = await fsSafeRoot(record.install.workspace, {
|
||||
hardlinks: "reject",
|
||||
symlinks: "reject",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
workspaceState =
|
||||
error && typeof error === "object" && "code" in error && error.code === "ENOENT"
|
||||
? "missing"
|
||||
: "unsafe";
|
||||
}
|
||||
for (const [path, target] of targetFiles) {
|
||||
const current = currentFiles.get(path);
|
||||
if (!target.digest) {
|
||||
actions.push({
|
||||
kind: "workspaceFile",
|
||||
id: path,
|
||||
action: "manual",
|
||||
target: `${record.install.workspace}:${path}`,
|
||||
blocked: true,
|
||||
reason: target.reason ?? "Target workspace source could not be verified.",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let unownedDestination: "absent" | "occupied" | "unsafe" =
|
||||
workspaceState === "unsafe" ? "unsafe" : "absent";
|
||||
if (!current) {
|
||||
if (workspace) {
|
||||
try {
|
||||
unownedDestination = (await workspace.exists(path)) ? "occupied" : "absent";
|
||||
} catch {
|
||||
unownedDestination = "unsafe";
|
||||
}
|
||||
}
|
||||
}
|
||||
const currentFileRequiresManual =
|
||||
current !== undefined &&
|
||||
manualState(current.state) &&
|
||||
!(workspaceState === "missing" && current.state === "unsafe");
|
||||
const action =
|
||||
workspaceState === "unsafe"
|
||||
? "manual"
|
||||
: !current && unownedDestination !== "absent"
|
||||
? "manual"
|
||||
: !current
|
||||
? "add"
|
||||
: currentFileRequiresManual
|
||||
? "manual"
|
||||
: current.contentDigest === target.digest && current.state === "unchanged"
|
||||
? "unchanged"
|
||||
: "change";
|
||||
actions.push({
|
||||
kind: "workspaceFile",
|
||||
id: path,
|
||||
action,
|
||||
target: `${record.install.workspace}:${path}`,
|
||||
blocked: action === "manual",
|
||||
reason:
|
||||
unownedDestination === "occupied"
|
||||
? "Workspace path already exists without Claw ownership and must be preserved."
|
||||
: unownedDestination === "unsafe"
|
||||
? "Workspace path is unsafe to inspect and cannot be claimed automatically."
|
||||
: workspaceState === "missing" && current
|
||||
? "Owned workspace is missing and this file would be restored."
|
||||
: action === "add"
|
||||
? "Target manifest adds a managed workspace file."
|
||||
: action === "manual"
|
||||
? "Local workspace content changed or became unsafe and must be reconciled manually."
|
||||
: action === "unchanged"
|
||||
? "Managed workspace content already matches the target source."
|
||||
: "Target source changes or restores managed workspace content.",
|
||||
...(current ? { currentDigest: current.contentDigest } : {}),
|
||||
desiredDigest: target.digest,
|
||||
});
|
||||
}
|
||||
for (const current of record.workspaceFiles) {
|
||||
if (targetFiles.has(current.path)) {
|
||||
continue;
|
||||
}
|
||||
const manual =
|
||||
workspaceState === "unsafe" ||
|
||||
(manualState(current.state) &&
|
||||
!(workspaceState === "missing" && current.state === "unsafe"));
|
||||
actions.push({
|
||||
kind: "workspaceFile",
|
||||
id: current.path,
|
||||
action: manual ? "manual" : "remove",
|
||||
target: `${current.workspace}:${current.path}`,
|
||||
blocked: manual,
|
||||
reason: manual
|
||||
? "Target removes this file, but local drift must be preserved manually."
|
||||
: "Target manifest removes this managed workspace file.",
|
||||
currentDigest: current.contentDigest,
|
||||
});
|
||||
}
|
||||
|
||||
const allPackages = readClawPackageRefs(readOnlyStateOptions);
|
||||
const currentPackages = new Map(record.packages.map((pkg) => [packageKey(pkg), pkg] as const));
|
||||
const targetPackages = new Map(
|
||||
params.targetManifest.packages.map((pkg) => [packageKey(pkg), pkg] as const),
|
||||
);
|
||||
for (const [key, target] of targetPackages) {
|
||||
const current = currentPackages.get(key);
|
||||
const preflight = packagePreflights.get(key);
|
||||
const requiresPackageMutation =
|
||||
!current ||
|
||||
(current.origin === "claw-introduced" &&
|
||||
!current.independentOwner &&
|
||||
(current.state === "missing" || current.version !== target.version));
|
||||
const expectedOwnedPluginUpgradeConflict =
|
||||
target.kind === "plugin" &&
|
||||
current?.state === "present" &&
|
||||
current.origin === "claw-introduced" &&
|
||||
!current.independentOwner &&
|
||||
current.version !== target.version &&
|
||||
preflight?.code === "plugin_version_conflict" &&
|
||||
preflight.installedVersion === current.version;
|
||||
const failedPackageMutationPreflight =
|
||||
requiresPackageMutation && !preflight?.ok && !expectedOwnedPluginUpgradeConflict;
|
||||
const conflictingPluginPin =
|
||||
target.kind === "plugin" &&
|
||||
allPackages.some(
|
||||
(candidate) =>
|
||||
candidate.agentId !== agentId &&
|
||||
candidate.kind === target.kind &&
|
||||
candidate.source === target.source &&
|
||||
candidate.ref === target.ref &&
|
||||
candidate.version !== target.version,
|
||||
);
|
||||
const unresolvedCurrent =
|
||||
current && ["modified", "ambiguous", "incomplete"].includes(current.state);
|
||||
const independentlyOwnedMutation =
|
||||
current &&
|
||||
(current.origin === "pre-existing" || current.independentOwner) &&
|
||||
(current.state === "missing" || current.version !== target.version);
|
||||
const action =
|
||||
conflictingPluginPin ||
|
||||
unresolvedCurrent ||
|
||||
independentlyOwnedMutation ||
|
||||
failedPackageMutationPreflight
|
||||
? "manual"
|
||||
: !current
|
||||
? "add"
|
||||
: current.state === "missing"
|
||||
? "change"
|
||||
: current.version === target.version
|
||||
? "unchanged"
|
||||
: "change";
|
||||
actions.push({
|
||||
kind: "package",
|
||||
id: key,
|
||||
action,
|
||||
target: `${target.source}:${target.ref}@${target.version}`,
|
||||
blocked: action === "manual",
|
||||
reason:
|
||||
action === "manual"
|
||||
? conflictingPluginPin
|
||||
? "Another Claw pins an incompatible version of this shared plugin."
|
||||
: independentlyOwnedMutation
|
||||
? "Package is independently owned and cannot be restored or changed by this Claw."
|
||||
: failedPackageMutationPreflight
|
||||
? (preflight?.message ?? "Package preflight failed.")
|
||||
: `Current package lifecycle state is ${current?.state ?? "unknown"} and must be reconciled manually.`
|
||||
: action === "add"
|
||||
? "Target manifest adds a package reference."
|
||||
: action === "unchanged"
|
||||
? "Recorded package reference already matches the exact target version."
|
||||
: "Target manifest changes the exact package version.",
|
||||
...(current ? { currentDigest: digest(current) } : {}),
|
||||
desiredDigest: digest({
|
||||
package: target,
|
||||
integrity: preflight?.integrity,
|
||||
installId: preflight?.installId,
|
||||
riskWarning: preflight?.warning,
|
||||
}),
|
||||
});
|
||||
const capabilityChange = packageCapabilityChange({
|
||||
pkg: target,
|
||||
action,
|
||||
currentVersion: current?.version,
|
||||
desiredVersion: target.version,
|
||||
integrity: preflight?.integrity,
|
||||
installId: preflight?.installId,
|
||||
riskWarning: preflight?.warning,
|
||||
});
|
||||
if (capabilityChange) {
|
||||
capabilityChanges.push(capabilityChange);
|
||||
}
|
||||
if (failedPackageMutationPreflight) {
|
||||
const index = params.targetManifest.packages.findIndex((pkg) => packageKey(pkg) === key);
|
||||
blockers.push(
|
||||
diagnostic(
|
||||
preflight?.code ?? "package_install_unavailable",
|
||||
`$.packages[${index}]`,
|
||||
preflight?.message ?? "Package preflight failed.",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const [key, current] of currentPackages) {
|
||||
if (!targetPackages.has(key)) {
|
||||
const manual = current.state !== "present";
|
||||
const action = manual ? "manual" : "release";
|
||||
actions.push({
|
||||
kind: "package",
|
||||
id: key,
|
||||
action,
|
||||
target: `${current.source}:${current.ref}@${current.version}`,
|
||||
blocked: manual,
|
||||
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),
|
||||
});
|
||||
const capabilityChange = packageCapabilityChange({
|
||||
pkg: current,
|
||||
action,
|
||||
currentVersion: current.version,
|
||||
});
|
||||
if (capabilityChange) {
|
||||
capabilityChanges.push(capabilityChange);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const configuredMcpServers = normalizeConfiguredMcpServers(params.sourceMcpServers);
|
||||
const currentMcp = new Map(record.mcpServers.map((server) => [server.name, server] as const));
|
||||
for (const [name, target] of Object.entries(params.targetManifest.mcpServers)) {
|
||||
const current = currentMcp.get(name);
|
||||
const desiredDigest = digestClawMcpServer(target);
|
||||
const unownedLiveServer = !current && Object.hasOwn(configuredMcpServers, name);
|
||||
const sharedWithOtherClaws =
|
||||
current &&
|
||||
readClawMcpServerRefsByName(name, readOnlyStateOptions).some(
|
||||
(candidate) => candidate.agentId !== agentId,
|
||||
);
|
||||
const independentlyOwnedMutation =
|
||||
current !== undefined &&
|
||||
(current.origin === "pre-existing" || current.independentOwner) &&
|
||||
(current.configDigest !== desiredDigest || current.state !== "present");
|
||||
const sharedChange = sharedWithOtherClaws && current?.configDigest !== desiredDigest;
|
||||
const action =
|
||||
unownedLiveServer || independentlyOwnedMutation || sharedChange
|
||||
? "manual"
|
||||
: !current
|
||||
? "add"
|
||||
: manualState(current.state)
|
||||
? "manual"
|
||||
: current.configDigest === desiredDigest && current.state === "present"
|
||||
? "unchanged"
|
||||
: "change";
|
||||
actions.push({
|
||||
kind: "mcpServer",
|
||||
id: name,
|
||||
action,
|
||||
target: `mcp.servers.${name}`,
|
||||
blocked: action === "manual",
|
||||
reason: unownedLiveServer
|
||||
? "MCP server name already exists without this Claw's ownership."
|
||||
: independentlyOwnedMutation
|
||||
? "MCP server is independently owned and cannot be restored or changed by this Claw."
|
||||
: sharedChange
|
||||
? "Another Claw shares this MCP declaration and blocks changing global config."
|
||||
: action === "manual"
|
||||
? "MCP ownership is unresolved or live config drifted and must be reconciled manually."
|
||||
: action === "unchanged"
|
||||
? "Owned MCP config digest already matches the target declaration."
|
||||
: `Target manifest ${action === "add" ? "adds" : "changes or restores"} this MCP declaration.`,
|
||||
...(current ? { currentDigest: current.configDigest } : {}),
|
||||
desiredDigest,
|
||||
});
|
||||
const capabilityChange = mcpCapabilityChange({
|
||||
id: name,
|
||||
action,
|
||||
current: current ? configuredMcpServers[name] : undefined,
|
||||
desired: target,
|
||||
});
|
||||
if (capabilityChange) {
|
||||
capabilityChanges.push(capabilityChange);
|
||||
}
|
||||
}
|
||||
for (const current of record.mcpServers) {
|
||||
if (Object.hasOwn(params.targetManifest.mcpServers, current.name)) {
|
||||
continue;
|
||||
}
|
||||
const manual = current.state === "pending" || current.state === "failed";
|
||||
const sharedOrIndependent =
|
||||
current.relationship === "referenced" ||
|
||||
current.origin === "pre-existing" ||
|
||||
current.independentOwner ||
|
||||
readClawMcpServerRefsByName(current.name, readOnlyStateOptions).some(
|
||||
(candidate) => candidate.agentId !== agentId,
|
||||
);
|
||||
const ownerAction =
|
||||
current.state === "present" && !sharedOrIndependent ? "remove" : "release";
|
||||
const action = manual ? "manual" : ownerAction;
|
||||
actions.push({
|
||||
kind: "mcpServer",
|
||||
id: current.name,
|
||||
action,
|
||||
target: `mcp.servers.${current.name}`,
|
||||
blocked: manual,
|
||||
reason: manual
|
||||
? "Target removes this MCP declaration, but ownership is incomplete."
|
||||
: ownerAction === "release"
|
||||
? "Target manifest releases this Claw's reference while preserving shared or independently owned MCP config."
|
||||
: "Target manifest removes this solely owned MCP declaration.",
|
||||
currentDigest: current.configDigest,
|
||||
});
|
||||
const capabilityChange = mcpCapabilityChange({
|
||||
id: current.name,
|
||||
action,
|
||||
current: configuredMcpServers[current.name],
|
||||
});
|
||||
if (capabilityChange) {
|
||||
capabilityChanges.push(capabilityChange);
|
||||
}
|
||||
}
|
||||
|
||||
const currentCron = new Map(record.cronJobs.map((cron) => [cron.manifestId, cron] as const));
|
||||
for (const target of params.targetManifest.cronJobs) {
|
||||
const current = currentCron.get(target.id);
|
||||
const desiredDigest = digest(target);
|
||||
const unresolved = current && (current.status !== "complete" || !current.schedulerJobId);
|
||||
const action = !current
|
||||
? "add"
|
||||
: unresolved
|
||||
? "manual"
|
||||
: digest(current.job) === desiredDigest
|
||||
? "unchanged"
|
||||
: "change";
|
||||
actions.push({
|
||||
kind: "cronJob",
|
||||
id: target.id,
|
||||
action,
|
||||
target: current?.schedulerJobId ?? `claw:${agentId}:${target.id}`,
|
||||
blocked: action === "manual",
|
||||
reason:
|
||||
action === "manual"
|
||||
? "Cron ownership is unresolved and must be reconciled with the gateway."
|
||||
: action === "unchanged"
|
||||
? "Recorded cron declaration already matches the target manifest."
|
||||
: `Target manifest ${action === "add" ? "adds" : "changes"} this cron declaration.`,
|
||||
...(current ? { currentDigest: digest(current.job) } : {}),
|
||||
desiredDigest,
|
||||
});
|
||||
const capabilityChange = cronCapabilityChange({
|
||||
id: target.id,
|
||||
action,
|
||||
current: current?.job,
|
||||
desired: target,
|
||||
});
|
||||
if (capabilityChange) {
|
||||
capabilityChanges.push(capabilityChange);
|
||||
}
|
||||
}
|
||||
for (const current of record.cronJobs) {
|
||||
if (params.targetManifest.cronJobs.some((cron) => cron.id === current.manifestId)) {
|
||||
continue;
|
||||
}
|
||||
const manual = current.status !== "complete" || !current.schedulerJobId;
|
||||
const action = manual ? "manual" : "remove";
|
||||
actions.push({
|
||||
kind: "cronJob",
|
||||
id: current.manifestId,
|
||||
action,
|
||||
target: current.schedulerJobId ?? current.declarationKey,
|
||||
blocked: manual,
|
||||
reason: manual
|
||||
? "Target removes this cron declaration, but scheduler ownership is unresolved."
|
||||
: "Target manifest removes this owned cron declaration.",
|
||||
currentDigest: digest(current.job),
|
||||
});
|
||||
const capabilityChange = cronCapabilityChange({
|
||||
id: current.manifestId,
|
||||
action,
|
||||
current: current.job,
|
||||
});
|
||||
if (capabilityChange) {
|
||||
capabilityChanges.push(capabilityChange);
|
||||
}
|
||||
}
|
||||
|
||||
actions.sort((left, right) =>
|
||||
`${left.kind}:${left.id}`.localeCompare(`${right.kind}:${right.id}`),
|
||||
);
|
||||
capabilityChanges.sort((left, right) =>
|
||||
`${left.kind}:${left.id}:${left.path}`.localeCompare(
|
||||
`${right.kind}:${right.id}:${right.path}`,
|
||||
),
|
||||
);
|
||||
const plan: Omit<ClawUpdatePlan, "planIntegrity"> = {
|
||||
schemaVersion: CLAW_UPDATE_PLAN_SCHEMA_VERSION,
|
||||
stability: CLAW_OUTPUT_STABILITY,
|
||||
dryRun: true,
|
||||
mutationAllowed: false,
|
||||
found: true,
|
||||
agentId,
|
||||
currentClaw: {
|
||||
name: record.install.claw.name,
|
||||
version: record.install.claw.version,
|
||||
integrity: record.install.claw.integrity,
|
||||
},
|
||||
targetClaw: {
|
||||
name: params.targetSource.name,
|
||||
version: params.targetSource.version,
|
||||
integrity: params.targetSource.integrity,
|
||||
},
|
||||
summary: summarize(actions, capabilityChanges),
|
||||
actions,
|
||||
capabilityChanges,
|
||||
blockers,
|
||||
diagnostics: params.diagnostics ?? [],
|
||||
};
|
||||
return { ...plan, planIntegrity: digest(plan) };
|
||||
} finally {
|
||||
if (ownsDatabase) {
|
||||
database.walMaintenance.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ClawUpdatePlan } from "../claws/update-plan.js";
|
||||
import { redactSensitiveText } from "../logging/redact.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
|
||||
export function logClawUpdatePlanSummary(plan: ClawUpdatePlan, runtime: RuntimeEnv): void {
|
||||
runtime.log(`Agent: ${plan.agentId}`);
|
||||
runtime.log(`Update actions: ${plan.summary.totalActions}`);
|
||||
runtime.log(
|
||||
`Add: ${plan.summary.added}; change: ${plan.summary.changed}; remove: ${plan.summary.removed}; release: ${plan.summary.released}; unchanged: ${plan.summary.unchanged}; manual: ${plan.summary.manual}`,
|
||||
);
|
||||
runtime.log(
|
||||
`Capability changes: ${plan.summary.capabilityChanges}; escalations requiring explicit review: ${plan.summary.capabilityEscalations}`,
|
||||
);
|
||||
runtime.log(`Plan integrity: ${plan.planIntegrity}`);
|
||||
if (plan.summary.capabilityEscalations > 0) {
|
||||
runtime.log(
|
||||
"Capability consent: the exact plan-integrity token binds every ! change disclosed below.",
|
||||
);
|
||||
}
|
||||
for (const change of plan.capabilityChanges) {
|
||||
const current = change.current?.summary ?? "unset";
|
||||
const desired = change.desired?.summary ?? "unset";
|
||||
runtime.log(
|
||||
` ${change.requiresDistinctConsent ? "!" : "-"} ${change.path}: ${current} -> ${desired} (${change.action})`,
|
||||
);
|
||||
runtime.log(redactSensitiveText(` effect: ${JSON.stringify(change.effect)}`));
|
||||
}
|
||||
if (plan.blockers.length > 0) {
|
||||
runtime.error(
|
||||
plan.blockers
|
||||
.map(
|
||||
(diagnostic) =>
|
||||
`${diagnostic.level.toUpperCase()} ${diagnostic.code} ${diagnostic.path}: ${diagnostic.message}`,
|
||||
)
|
||||
.join("\n"),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ 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";
|
||||
@@ -44,12 +45,15 @@ 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";
|
||||
|
||||
@@ -402,6 +406,163 @@ 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 async function runClawsRemoveCommand(
|
||||
target: string,
|
||||
opts: ClawsRemoveOptions,
|
||||
|
||||
@@ -27,10 +27,14 @@ const mocks = vi.hoisted(() => {
|
||||
runtime,
|
||||
loadConfig: vi.fn<() => Record<string, unknown>>(() => ({})),
|
||||
listConfiguredMcpServers: vi.fn(),
|
||||
closeReadOnlyDatabase: vi.fn(),
|
||||
stateTableGet: vi.fn(),
|
||||
openExistingOpenClawStateDatabaseReadOnly: vi.fn(),
|
||||
applyClawAddPlan: vi.fn(),
|
||||
readClawStatus: vi.fn(),
|
||||
buildClawRemovePlan: vi.fn(),
|
||||
applyClawRemovePlan: vi.fn(),
|
||||
buildClawUpdatePlan: vi.fn(),
|
||||
exportClawAgent: vi.fn(),
|
||||
};
|
||||
});
|
||||
@@ -53,6 +57,13 @@ vi.mock("../config/mcp-config.js", async () => ({
|
||||
listConfiguredMcpServers: mocks.listConfiguredMcpServers,
|
||||
}));
|
||||
|
||||
vi.mock("../state/openclaw-state-db.js", async () => ({
|
||||
...(await vi.importActual<typeof import("../state/openclaw-state-db.js")>(
|
||||
"../state/openclaw-state-db.js",
|
||||
)),
|
||||
openExistingOpenClawStateDatabaseReadOnly: mocks.openExistingOpenClawStateDatabaseReadOnly,
|
||||
}));
|
||||
|
||||
vi.mock("../claws/add.js", async () => ({
|
||||
...(await vi.importActual<typeof import("../claws/add.js")>("../claws/add.js")),
|
||||
applyClawAddPlan: mocks.applyClawAddPlan,
|
||||
@@ -72,6 +83,11 @@ vi.mock("../claws/export.js", async () => ({
|
||||
exportClawAgent: mocks.exportClawAgent,
|
||||
}));
|
||||
|
||||
vi.mock("../claws/update-plan.js", async () => ({
|
||||
...(await vi.importActual<typeof import("../claws/update-plan.js")>("../claws/update-plan.js")),
|
||||
buildClawUpdatePlan: mocks.buildClawUpdatePlan,
|
||||
}));
|
||||
|
||||
const { registerClawsCli } = await import("./claws-cli.js");
|
||||
const { runClawsAddCommand } = await import("./claws-cli.runtime.js");
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
@@ -155,6 +171,15 @@ describe("claws cli", () => {
|
||||
config: {},
|
||||
mcpServers: {},
|
||||
});
|
||||
mocks.closeReadOnlyDatabase.mockReset();
|
||||
mocks.stateTableGet.mockReset();
|
||||
mocks.stateTableGet.mockReturnValue({ 1: 1 });
|
||||
mocks.openExistingOpenClawStateDatabaseReadOnly.mockReset();
|
||||
mocks.openExistingOpenClawStateDatabaseReadOnly.mockReturnValue({
|
||||
db: { prepare: () => ({ get: mocks.stateTableGet }) },
|
||||
path: "state.sqlite",
|
||||
walMaintenance: { checkpoint: () => false, close: mocks.closeReadOnlyDatabase },
|
||||
});
|
||||
mocks.applyClawAddPlan.mockReset();
|
||||
mocks.applyClawAddPlan.mockImplementation(async (plan) => ({
|
||||
schemaVersion: "openclaw.clawAddResult.v1",
|
||||
@@ -207,6 +232,47 @@ describe("claws cli", () => {
|
||||
cronJobs: [],
|
||||
packageRefsReleased: 1,
|
||||
});
|
||||
mocks.buildClawUpdatePlan.mockReset();
|
||||
mocks.buildClawUpdatePlan.mockResolvedValue({
|
||||
schemaVersion: "openclaw.clawUpdatePlan.v1",
|
||||
stability: "experimental",
|
||||
dryRun: true,
|
||||
mutationAllowed: false,
|
||||
planIntegrity: "sha256:update-plan",
|
||||
found: true,
|
||||
agentId: "demo-agent",
|
||||
currentClaw: { name: "@acme/demo-agent", version: "1.0.0", integrity: "sha256:old" },
|
||||
targetClaw: { name: "@acme/demo-agent", version: "1.2.3", integrity: "sha256:new" },
|
||||
summary: {
|
||||
totalActions: 1,
|
||||
added: 0,
|
||||
changed: 1,
|
||||
removed: 0,
|
||||
released: 0,
|
||||
unchanged: 0,
|
||||
manual: 0,
|
||||
blocked: 0,
|
||||
capabilityChanges: 1,
|
||||
capabilityEscalations: 1,
|
||||
},
|
||||
actions: [],
|
||||
capabilityChanges: [
|
||||
{
|
||||
kind: "agent",
|
||||
id: "demo-agent",
|
||||
path: "agent.sandbox.mode",
|
||||
action: "change",
|
||||
classification: "escalation",
|
||||
requiresDistinctConsent: true,
|
||||
reason: "Agent capability field sandbox.mode changes in the target manifest.",
|
||||
effect: { path: "sandbox.mode", current: "non-main", desired: "all" },
|
||||
current: { summary: "non-main", digest: "sha256:current" },
|
||||
desired: { summary: "all", digest: "sha256:desired" },
|
||||
},
|
||||
],
|
||||
blockers: [],
|
||||
diagnostics: [],
|
||||
});
|
||||
mocks.exportClawAgent.mockReset();
|
||||
mocks.exportClawAgent.mockResolvedValue({
|
||||
schemaVersion: "openclaw.clawExportResult.v1",
|
||||
@@ -247,6 +313,7 @@ describe("claws cli", () => {
|
||||
"inspect",
|
||||
"add",
|
||||
"status",
|
||||
"update",
|
||||
"remove",
|
||||
"export",
|
||||
]);
|
||||
@@ -637,6 +704,156 @@ describe("claws cli", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("prints a read-only grouped update plan", async () => {
|
||||
const { root } = await writePackage();
|
||||
|
||||
await runCli(["claws", "update", "demo-agent", "--from", root, "--dry-run", "--json"]);
|
||||
|
||||
expect(mocks.buildClawUpdatePlan).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentId: "demo-agent",
|
||||
targetManifest: expect.objectContaining({
|
||||
agent: { id: "demo-agent", name: "Demo Agent" },
|
||||
}),
|
||||
targetSource: expect.objectContaining({ name: "@acme/demo-agent", version: "1.2.3" }),
|
||||
config: {},
|
||||
sourceMcpServers: {},
|
||||
}),
|
||||
);
|
||||
expect(JSON.parse(mocks.logs[0] ?? "{}")).toMatchObject({
|
||||
schemaVersion: "openclaw.clawUpdatePlan.v1",
|
||||
dryRun: true,
|
||||
mutationAllowed: false,
|
||||
agentId: "demo-agent",
|
||||
});
|
||||
});
|
||||
|
||||
it("prints capability escalation details in human update previews", async () => {
|
||||
const { root } = await writePackage();
|
||||
|
||||
await runCli(["claws", "update", "demo-agent", "--from", root, "--dry-run"]);
|
||||
|
||||
const output = mocks.logs.join("\n");
|
||||
expect(output).toContain("Capability changes: 1; escalations requiring explicit review: 1");
|
||||
expect(output).toContain("Plan integrity: sha256:update-plan");
|
||||
expect(output).toContain(
|
||||
"Capability consent: the exact plan-integrity token binds every ! change disclosed below.",
|
||||
);
|
||||
expect(output).toContain("! agent.sandbox.mode: non-main -> all (change)");
|
||||
expect(output).toContain(
|
||||
'effect: {"path":"sandbox.mode","current":"non-main","desired":"all"}',
|
||||
);
|
||||
});
|
||||
|
||||
it("returns failure when an update plan contains blocked actions", async () => {
|
||||
const { root } = await writePackage();
|
||||
mocks.buildClawUpdatePlan.mockResolvedValueOnce({
|
||||
schemaVersion: "openclaw.clawUpdatePlan.v1",
|
||||
stability: "experimental",
|
||||
dryRun: true,
|
||||
mutationAllowed: false,
|
||||
planIntegrity: "sha256:blocked-plan",
|
||||
found: true,
|
||||
agentId: "demo-agent",
|
||||
summary: {
|
||||
totalActions: 1,
|
||||
added: 0,
|
||||
changed: 0,
|
||||
removed: 0,
|
||||
released: 0,
|
||||
unchanged: 0,
|
||||
manual: 1,
|
||||
blocked: 1,
|
||||
capabilityChanges: 0,
|
||||
capabilityEscalations: 0,
|
||||
},
|
||||
capabilityChanges: [],
|
||||
actions: [
|
||||
{
|
||||
kind: "workspaceFile",
|
||||
id: "SOUL.md",
|
||||
action: "manual",
|
||||
target: "workspace:SOUL.md",
|
||||
blocked: true,
|
||||
reason: "Local content changed.",
|
||||
},
|
||||
],
|
||||
blockers: [],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
await runCli(["claws", "update", "demo-agent", "--from", root, "--dry-run", "--json"]);
|
||||
|
||||
expect(mocks.runtime.exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("uses the source recorded by the installed Claw when --from is omitted", async () => {
|
||||
const { root } = await writePackage();
|
||||
mocks.readClawStatus.mockResolvedValue({
|
||||
schemaVersion: "openclaw.clawStatus.v1",
|
||||
records: [
|
||||
{
|
||||
install: {
|
||||
agentId: "demo-agent",
|
||||
claw: {
|
||||
kind: "package",
|
||||
name: "@acme/demo-agent",
|
||||
version: "1.0.0",
|
||||
packageRoot: root,
|
||||
manifestPath: join(root, "openclaw.claw.json"),
|
||||
integrity: "sha256:old",
|
||||
},
|
||||
},
|
||||
workspaceFiles: [],
|
||||
packages: [],
|
||||
mcpServers: [],
|
||||
cronJobs: [],
|
||||
},
|
||||
],
|
||||
summary: { claws: 1 },
|
||||
});
|
||||
|
||||
await runCli(["claws", "update", "demo-agent", "--dry-run", "--json"]);
|
||||
|
||||
expect(mocks.readClawStatus).toHaveBeenCalledWith(
|
||||
"demo-agent",
|
||||
expect.objectContaining({ readOnly: true, sourceMcpServers: {} }),
|
||||
);
|
||||
expect(mocks.closeReadOnlyDatabase).toHaveBeenCalled();
|
||||
expect(mocks.buildClawUpdatePlan).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentId: "demo-agent",
|
||||
targetSource: expect.objectContaining({ name: "@acme/demo-agent", version: "1.2.3" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns not found for a supported state database without Claws tables", async () => {
|
||||
mocks.stateTableGet.mockReturnValue(undefined);
|
||||
|
||||
await runCli(["claws", "update", "demo-agent", "--dry-run", "--json"]);
|
||||
|
||||
expect(mocks.readClawStatus).not.toHaveBeenCalled();
|
||||
expect(mocks.closeReadOnlyDatabase).toHaveBeenCalled();
|
||||
expect(JSON.parse(mocks.logs[0] ?? "{}")).toMatchObject({
|
||||
diagnostics: [expect.objectContaining({ code: "claw_not_found", phase: "plan" })],
|
||||
});
|
||||
expect(mocks.runtime.exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("fails closed when update is invoked without dry-run", async () => {
|
||||
const { root } = await writePackage();
|
||||
|
||||
await runCli(["claws", "update", "demo-agent", "--from", root, "--json"]);
|
||||
|
||||
expect(mocks.buildClawUpdatePlan).not.toHaveBeenCalled();
|
||||
expect(JSON.parse(mocks.logs[0] ?? "{}")).toMatchObject({
|
||||
schemaVersion: "openclaw.clawUpdatePlan.v1",
|
||||
error: { code: "update_preview_required" },
|
||||
});
|
||||
expect(mocks.runtime.exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("applies remove only after explicit consent", async () => {
|
||||
await runCli([
|
||||
"claws",
|
||||
|
||||
+14
-1
@@ -17,6 +17,7 @@ export type ClawsAddOptions = {
|
||||
};
|
||||
|
||||
export type ClawsStatusOptions = { json?: boolean };
|
||||
export type ClawsUpdateOptions = { from?: string; dryRun?: boolean; json?: boolean };
|
||||
export type ClawsRemoveOptions = {
|
||||
dryRun?: boolean;
|
||||
yes?: boolean;
|
||||
@@ -36,7 +37,7 @@ export function registerClawsCli(program: Command) {
|
||||
if (!isExperimentalClawsEnabled()) {
|
||||
return;
|
||||
}
|
||||
const claws = program.command("claws").description("Inspect and add experimental OpenClaw Claws");
|
||||
const claws = program.command("claws").description("Manage experimental OpenClaw Claws");
|
||||
|
||||
claws
|
||||
.command("inspect")
|
||||
@@ -73,6 +74,18 @@ export function registerClawsCli(program: Command) {
|
||||
await runClawsStatusCommand(target, opts);
|
||||
});
|
||||
|
||||
claws
|
||||
.command("update")
|
||||
.description("Plan changes to one installed Claw agent")
|
||||
.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("--json", "Print JSON", false)
|
||||
.action(async (target: string, opts: ClawsUpdateOptions) => {
|
||||
const { runClawsUpdateCommand } = await import("./claws-cli.runtime.js");
|
||||
await runClawsUpdateCommand(target, opts);
|
||||
});
|
||||
|
||||
claws
|
||||
.command("remove")
|
||||
.description("Plan or remove one Claw-created agent and owned state")
|
||||
|
||||
Reference in New Issue
Block a user