diff --git a/docs/cli/claws.md b/docs/cli/claws.md index 7f2fc73b6564..be4bd28773c4 100644 --- a/docs/cli/claws.md +++ b/docs/cli/claws.md @@ -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 ` | Validate a package directory or JSON manifest. | | `claws add ` | Preview or create one new agent and workspace. | | `claws status [claw-or-agent]` | Report installed state, ownership, and drift. | +| `claws update ` | Preview changes from the recorded or given source. | | `claws remove ` | Preview or remove the agent and eligible resources. | | `claws export --out ` | Create a portable package from an installed agent. | diff --git a/docs/docs_map.md b/docs/docs_map.md index 01ca6ca232e5..bdb16a1fa11e 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -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 diff --git a/src/claws/lifecycle.e2e.test.ts b/src/claws/lifecycle.e2e.test.ts index be7ef8c2775d..b1549a3b92d6 100644 --- a/src/claws/lifecycle.e2e.test.ts +++ b/src/claws/lifecycle.e2e.test.ts @@ -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" }, }); }); diff --git a/src/claws/mcp.ts b/src/claws/mcp.ts index 50095b3db40c..3d2f162305fe 100644 --- a/src/claws/mcp.ts +++ b/src/claws/mcp.ts @@ -303,7 +303,7 @@ export function readClawMcpServerRefs( return rows.map(rowToRef); } -function readClawMcpServerRefsByName( +export function readClawMcpServerRefsByName( name: string, options: OpenClawStateDatabaseOptions = {}, ): PersistedClawMcpServerRef[] { diff --git a/src/claws/update-capability-changes.test.ts b/src/claws/update-capability-changes.test.ts new file mode 100644 index 000000000000..c0f87dfbe48b --- /dev/null +++ b/src/claws/update-capability-changes.test.ts @@ -0,0 +1,349 @@ +import { describe, expect, it } from "vitest"; +import { + cronCapabilityChange, + mcpCapabilityChange, + pushResolvedAgentCapabilityChanges, +} from "./update-capability-changes.js"; + +type Changes = Parameters[0]["changes"]; + +function collectChanges(params: { + currentAgent: Parameters[0]["desiredAgent"]; + desiredAgent: Parameters[0]["desiredAgent"]; + defaults?: NonNullable< + Parameters[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); + } + }); +}); diff --git a/src/claws/update-capability-changes.ts b/src/claws/update-capability-changes.ts new file mode 100644 index 000000000000..2f0f8f8a6cdd --- /dev/null +++ b/src/claws/update-capability-changes.ts @@ -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; + 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)[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): number { + return typeof value === "string" ? (rank[value] ?? 0) : 0; +} + +function compareRankedCapability( + current: unknown, + desired: unknown, + rank: Record, +): 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; + return compareRankedCapability(current, desired, rank); + } + if (path === "sandbox.mode") { + const rank = { all: 0, "non-main": 1, off: 2 } as Record; + return compareRankedCapability(current, desired, rank); + } + if (path === "sandbox.scope") { + const rank = { session: 0, agent: 1, shared: 2 } as Record; + 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["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; + 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 { + if (!server || typeof server !== "object") { + return { configured: false }; + } + const value = server as Record; + 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; + const schedule = value.schedule as Record | 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 { + if (!cron || typeof cron !== "object") { + return { configured: false }; + } + const value = cron as Record; + const schedule = value.schedule as Record | 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), + }), + }; +} diff --git a/src/claws/update-plan-empty.ts b/src/claws/update-plan-empty.ts new file mode 100644 index 000000000000..423bd62cf467 --- /dev/null +++ b/src/claws/update-plan-empty.ts @@ -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 = { + 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) }; +} diff --git a/src/claws/update-plan-types.ts b/src/claws/update-plan-types.ts new file mode 100644 index 000000000000..0b1bf36b5e5f --- /dev/null +++ b/src/claws/update-plan-types.ts @@ -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; + 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[]; +}; diff --git a/src/claws/update-plan.test.ts b/src/claws/update-plan.test.ts new file mode 100644 index 000000000000..24d44d41dcfc --- /dev/null +++ b/src/claws/update-plan.test.ts @@ -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")}`, + ); + }); +}); diff --git a/src/claws/update-plan.ts b/src/claws/update-plan.ts new file mode 100644 index 000000000000..bfd92418eef9 --- /dev/null +++ b/src/claws/update-plan.ts @@ -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>; + 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 { + 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> | 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 = { + 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(); + } + } +} diff --git a/src/cli/claws-cli-update-output.ts b/src/cli/claws-cli-update-output.ts new file mode 100644 index 000000000000..987723e8cbc2 --- /dev/null +++ b/src/cli/claws-cli-update-output.ts @@ -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"), + ); + } +} diff --git a/src/cli/claws-cli.runtime.ts b/src/cli/claws-cli.runtime.ts index 6ccf68d5861d..82b255db5238 100644 --- a/src/cli/claws-cli.runtime.ts +++ b/src/cli/claws-cli.runtime.ts @@ -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 { + assertExperimentalClawsEnabled(); + if (!opts.dryRun) { + const message = + "Claw update is read-only in this implementation slice; pass --dry-run to preview changes."; + if (opts.json) { + writeRuntimeJson(runtime, { + schemaVersion: CLAW_UPDATE_PLAN_SCHEMA_VERSION, + stability: CLAW_OUTPUT_STABILITY, + ok: false, + error: { code: "update_preview_required", message }, + }); + } else { + runtime.error(message); + } + runtime.exit(1); + return; + } + + const listedMcpServers = await listConfiguredMcpServers(); + if (!listedMcpServers.ok) { + if (opts.json) { + writeRuntimeJson(runtime, { + schemaVersion: CLAW_UPDATE_PLAN_SCHEMA_VERSION, + stability: CLAW_OUTPUT_STABILITY, + dryRun: true, + mutationAllowed: false, + valid: false, + diagnostics: [ + { + level: "error", + code: "mcp_config_unavailable", + phase: "plan", + path: "$.mcpServers", + message: listedMcpServers.error, + }, + ], + }); + } else { + runtime.error(listedMcpServers.error); + } + runtime.exit(1); + return; + } + + let source = opts.from; + if (!source) { + const database = openExistingOpenClawStateDatabaseReadOnly(); + let status: Awaited> | { records: never[] } = { + records: [], + }; + if (database) { + try { + const hasClawInstalls = + database.db /* sqlite-allow-raw: read-only Claw install table-existence probe. */ + .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'claw_installs'") + .get(); + if (hasClawInstalls) { + status = await readClawStatus(target, { + database, + readOnly: true, + sourceMcpServers: listedMcpServers.mcpServers, + }); + } + } finally { + database.walMaintenance.close(); + } + } + if (status.records.length !== 1) { + const message = + status.records.length === 0 + ? `No installed Claw agent matches ${JSON.stringify(target)}.` + : `Claw name ${JSON.stringify(target)} matches multiple agents; use an agent id.`; + if (opts.json) { + writeRuntimeJson(runtime, { + schemaVersion: CLAW_UPDATE_PLAN_SCHEMA_VERSION, + stability: CLAW_OUTPUT_STABILITY, + dryRun: true, + mutationAllowed: false, + valid: false, + diagnostics: [ + { + level: "error", + code: status.records.length === 0 ? "claw_not_found" : "claw_ambiguous", + phase: "plan", + path: "$", + message, + }, + ], + }); + } else { + runtime.error(message); + } + runtime.exit(1); + return; + } + const recorded = status.records[0]!.install.claw; + source = recorded.kind === "package" ? recorded.packageRoot : recorded.manifestPath; + } + + const loaded = await readClawManifestFile(source); + if (!loaded.ok) { + const diagnostics = opts.from + ? loaded.diagnostics + : [ + ...loaded.diagnostics, + { + level: "error" as const, + code: "recorded_source_unavailable", + phase: "plan" as const, + path: "$", + message: "The recorded Claw source is unavailable; pass --from to override it.", + }, + ]; + if (opts.json) { + writeRuntimeJson(runtime, { + schemaVersion: CLAW_UPDATE_PLAN_SCHEMA_VERSION, + stability: CLAW_OUTPUT_STABILITY, + dryRun: true, + mutationAllowed: false, + valid: false, + diagnostics, + }); + } else { + runtime.error(formatDiagnostics(diagnostics)); + } + runtime.exit(1); + return; + } + + const plan = await buildClawUpdatePlan({ + agentId: target, + targetManifest: loaded.manifest, + targetSource: loaded.source, + config: getRuntimeConfig(), + sourceMcpServers: listedMcpServers.mcpServers, + packagePreflight: preflightClawPackage, + diagnostics: loaded.diagnostics, + }); + if (opts.json) { + writeRuntimeJson(runtime, plan); + } else { + logExperimentalWarning(runtime); + runtime.log( + `Claw update plan: ${plan.currentClaw?.name ?? target} ${plan.currentClaw?.version ?? "unknown"} -> ${plan.targetClaw?.version ?? "unknown"}`, + ); + logClawUpdatePlanSummary(plan, runtime); + } + if (plan.blockers.length > 0 || plan.actions.some((action) => action.blocked)) { + runtime.exit(1); + } +} + export async function runClawsRemoveCommand( target: string, opts: ClawsRemoveOptions, diff --git a/src/cli/claws-cli.test.ts b/src/cli/claws-cli.test.ts index a222f221b838..6c4956af4277 100644 --- a/src/cli/claws-cli.test.ts +++ b/src/cli/claws-cli.test.ts @@ -27,10 +27,14 @@ const mocks = vi.hoisted(() => { runtime, loadConfig: vi.fn<() => Record>(() => ({})), 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( + "../state/openclaw-state-db.js", + )), + openExistingOpenClawStateDatabaseReadOnly: mocks.openExistingOpenClawStateDatabaseReadOnly, +})); + vi.mock("../claws/add.js", async () => ({ ...(await vi.importActual("../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("../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", diff --git a/src/cli/claws-cli.ts b/src/cli/claws-cli.ts index 7a7c0382f3fb..6c7dd27c37d9 100644 --- a/src/cli/claws-cli.ts +++ b/src/cli/claws-cli.ts @@ -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("", "Installed package name or final agent id") + .option("--from ", "Override the target source recorded at Claw add time") + .option("--dry-run", "Preview update actions without mutating state", false) + .option("--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")