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:
Gio Della-Libera
2026-07-22 13:44:09 -07:00
committed by GitHub
parent 6bf03cd674
commit f004d76a0e
14 changed files with 3079 additions and 5 deletions
+26 -1
View File
@@ -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. |
+1
View File
@@ -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
+4 -2
View File
@@ -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
View File
@@ -303,7 +303,7 @@ export function readClawMcpServerRefs(
return rows.map(rowToRef);
}
function readClawMcpServerRefsByName(
export function readClawMcpServerRefsByName(
name: string,
options: OpenClawStateDatabaseOptions = {},
): PersistedClawMcpServerRef[] {
+349
View File
@@ -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);
}
});
});
+493
View File
@@ -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),
}),
};
}
+48
View File
@@ -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) };
}
+43
View File
@@ -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[];
};
+972
View File
@@ -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")}`,
);
});
});
+712
View File
@@ -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();
}
}
}
+38
View File
@@ -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"),
);
}
}
+161
View File
@@ -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,
+217
View File
@@ -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
View File
@@ -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")