Apply consented grouped Claw updates (#102982)

* Apply consented grouped Claw updates

* test(claws): cover update application

* docs(claws): document consented updates

* fix(claws): preserve uncertain update state

* fix(claws): preserve inherited lifecycle guards

* fix(claws): derive update package contracts

* fix(claws): type update package lookup

* test(claws): complete update capability fixtures

* refactor(claws): share cron payload builder for updates

* refactor(claws): extract update plan summary

* fix(claws): activate cron after update dependencies

* fix(claws): preserve state after uncertain cron update

* fix(claws): persist partial update provenance

* fix(claws): preserve update ownership metadata

* test(claws): track update apply temp dirs

* test(claws): align update cron mock result

* fix(claws): update apply writes agent entries

---------

Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
This commit is contained in:
Gio Della-Libera
2026-07-22 17:42:42 -07:00
committed by GitHub
parent 1a7a1808f7
commit 7d159fdc77
31 changed files with 4274 additions and 245 deletions
+1 -160
View File
@@ -34,7 +34,6 @@ import {
CLAW_OUTPUT_STABILITY,
type ClawAddPlan,
} from "../claws/types.js";
import { buildClawUpdatePlan, CLAW_UPDATE_PLAN_SCHEMA_VERSION } from "../claws/update-plan.js";
// Runtime handlers for experimental local Claws commands.
import { getRuntimeConfig } from "../config/config.js";
import { listConfiguredMcpServers } from "../config/mcp-config.js";
@@ -45,15 +44,12 @@ import {
} from "../cron/store.js";
import { redactSensitiveText } from "../logging/redact.js";
import { defaultRuntime, writeRuntimeJson, type RuntimeEnv } from "../runtime.js";
import { openExistingOpenClawStateDatabaseReadOnly } from "../state/openclaw-state-db.js";
import { logClawUpdatePlanSummary } from "./claws-cli-update-output.js";
import type {
ClawsAddOptions,
ClawsExportOptions,
ClawsInspectOptions,
ClawsRemoveOptions,
ClawsStatusOptions,
ClawsUpdateOptions,
} from "./claws-cli.js";
import { callGatewayFromCli } from "./gateway-rpc.js";
@@ -406,162 +402,7 @@ export async function runClawsStatusCommand(
}
}
export async function runClawsUpdateCommand(
target: string,
opts: ClawsUpdateOptions,
runtime: RuntimeEnv = defaultRuntime,
): Promise<void> {
assertExperimentalClawsEnabled();
if (!opts.dryRun) {
const message =
"Claw update is read-only in this implementation slice; pass --dry-run to preview changes.";
if (opts.json) {
writeRuntimeJson(runtime, {
schemaVersion: CLAW_UPDATE_PLAN_SCHEMA_VERSION,
stability: CLAW_OUTPUT_STABILITY,
ok: false,
error: { code: "update_preview_required", message },
});
} else {
runtime.error(message);
}
runtime.exit(1);
return;
}
const listedMcpServers = await listConfiguredMcpServers();
if (!listedMcpServers.ok) {
if (opts.json) {
writeRuntimeJson(runtime, {
schemaVersion: CLAW_UPDATE_PLAN_SCHEMA_VERSION,
stability: CLAW_OUTPUT_STABILITY,
dryRun: true,
mutationAllowed: false,
valid: false,
diagnostics: [
{
level: "error",
code: "mcp_config_unavailable",
phase: "plan",
path: "$.mcpServers",
message: listedMcpServers.error,
},
],
});
} else {
runtime.error(listedMcpServers.error);
}
runtime.exit(1);
return;
}
let source = opts.from;
if (!source) {
const database = openExistingOpenClawStateDatabaseReadOnly();
let status: Awaited<ReturnType<typeof readClawStatus>> | { records: never[] } = {
records: [],
};
if (database) {
try {
const hasClawInstalls =
database.db /* sqlite-allow-raw: read-only Claw install table-existence probe. */
.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'claw_installs'")
.get();
if (hasClawInstalls) {
status = await readClawStatus(target, {
database,
readOnly: true,
sourceMcpServers: listedMcpServers.mcpServers,
});
}
} finally {
database.walMaintenance.close();
}
}
if (status.records.length !== 1) {
const message =
status.records.length === 0
? `No installed Claw agent matches ${JSON.stringify(target)}.`
: `Claw name ${JSON.stringify(target)} matches multiple agents; use an agent id.`;
if (opts.json) {
writeRuntimeJson(runtime, {
schemaVersion: CLAW_UPDATE_PLAN_SCHEMA_VERSION,
stability: CLAW_OUTPUT_STABILITY,
dryRun: true,
mutationAllowed: false,
valid: false,
diagnostics: [
{
level: "error",
code: status.records.length === 0 ? "claw_not_found" : "claw_ambiguous",
phase: "plan",
path: "$",
message,
},
],
});
} else {
runtime.error(message);
}
runtime.exit(1);
return;
}
const recorded = status.records[0]!.install.claw;
source = recorded.kind === "package" ? recorded.packageRoot : recorded.manifestPath;
}
const loaded = await readClawManifestFile(source);
if (!loaded.ok) {
const diagnostics = opts.from
? loaded.diagnostics
: [
...loaded.diagnostics,
{
level: "error" as const,
code: "recorded_source_unavailable",
phase: "plan" as const,
path: "$",
message: "The recorded Claw source is unavailable; pass --from to override it.",
},
];
if (opts.json) {
writeRuntimeJson(runtime, {
schemaVersion: CLAW_UPDATE_PLAN_SCHEMA_VERSION,
stability: CLAW_OUTPUT_STABILITY,
dryRun: true,
mutationAllowed: false,
valid: false,
diagnostics,
});
} else {
runtime.error(formatDiagnostics(diagnostics));
}
runtime.exit(1);
return;
}
const plan = await buildClawUpdatePlan({
agentId: target,
targetManifest: loaded.manifest,
targetSource: loaded.source,
config: getRuntimeConfig(),
sourceMcpServers: listedMcpServers.mcpServers,
packagePreflight: preflightClawPackage,
diagnostics: loaded.diagnostics,
});
if (opts.json) {
writeRuntimeJson(runtime, plan);
} else {
logExperimentalWarning(runtime);
runtime.log(
`Claw update plan: ${plan.currentClaw?.name ?? target} ${plan.currentClaw?.version ?? "unknown"} -> ${plan.targetClaw?.version ?? "unknown"}`,
);
logClawUpdatePlanSummary(plan, runtime);
}
if (plan.blockers.length > 0 || plan.actions.some((action) => action.blocked)) {
runtime.exit(1);
}
}
export { runClawsUpdateCommand } from "./claws-update-cli.runtime.js";
export async function runClawsRemoveCommand(
target: string,
+101 -2
View File
@@ -34,6 +34,7 @@ const mocks = vi.hoisted(() => {
readClawStatus: vi.fn(),
buildClawRemovePlan: vi.fn(),
applyClawRemovePlan: vi.fn(),
applyClawUpdatePlan: vi.fn(),
buildClawUpdatePlan: vi.fn(),
exportClawAgent: vi.fn(),
};
@@ -88,8 +89,14 @@ vi.mock("../claws/update-plan.js", async () => ({
buildClawUpdatePlan: mocks.buildClawUpdatePlan,
}));
vi.mock("../claws/update-apply.js", async () => ({
...(await vi.importActual<typeof import("../claws/update-apply.js")>("../claws/update-apply.js")),
applyClawUpdatePlan: mocks.applyClawUpdatePlan,
}));
const { registerClawsCli } = await import("./claws-cli.js");
const { runClawsAddCommand } = await import("./claws-cli.runtime.js");
const { ClawUpdateMutationError } = await import("../claws/update-apply.js");
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
const minimalManifest = { schemaVersion: 1, agent: { id: "demo-agent", name: "Demo Agent" } };
@@ -213,7 +220,7 @@ describe("claws cli", () => {
kind: "agent",
id: "demo-agent",
action: "remove",
target: "agents.list[demo-agent]",
target: 'agents.entries["demo-agent"]',
blocked: false,
},
],
@@ -273,6 +280,19 @@ describe("claws cli", () => {
blockers: [],
diagnostics: [],
});
mocks.applyClawUpdatePlan.mockReset();
mocks.applyClawUpdatePlan.mockResolvedValue({
schemaVersion: "openclaw.clawUpdateResult.v1",
stability: "experimental",
dryRun: false,
mutationAllowed: true,
status: "complete",
agentId: "demo-agent",
previousClaw: { name: "@acme/demo-agent", version: "1.0.0", integrity: "sha256:old" },
targetClaw: { name: "@acme/demo-agent", version: "1.2.3", integrity: "sha256:new" },
appliedActions: [],
installRecord: { agentId: "demo-agent" },
});
mocks.exportClawAgent.mockReset();
mocks.exportClawAgent.mockResolvedValue({
schemaVersion: "openclaw.clawExportResult.v1",
@@ -849,7 +869,86 @@ describe("claws cli", () => {
expect(mocks.buildClawUpdatePlan).not.toHaveBeenCalled();
expect(JSON.parse(mocks.logs[0] ?? "{}")).toMatchObject({
schemaVersion: "openclaw.clawUpdatePlan.v1",
error: { code: "update_preview_required" },
error: { code: "consent_required" },
});
expect(mocks.runtime.exit).toHaveBeenCalledWith(1);
});
it("requires exact plan integrity with update consent", async () => {
const { root } = await writePackage();
await runCli(["claws", "update", "demo-agent", "--from", root, "--yes", "--json"]);
expect(mocks.buildClawUpdatePlan).not.toHaveBeenCalled();
expect(JSON.parse(mocks.logs[0] ?? "{}")).toMatchObject({
error: { code: "consent_required" },
});
expect(mocks.runtime.exit).toHaveBeenCalledWith(1);
});
it("applies a supported update only after explicit consent", async () => {
const { root } = await writePackage();
await runCli([
"claws",
"update",
"demo-agent",
"--from",
root,
"--yes",
"--plan-integrity",
"sha256:update-plan",
"--json",
]);
expect(mocks.applyClawUpdatePlan).toHaveBeenCalledWith(
expect.objectContaining({ agentId: "demo-agent" }),
expect.objectContaining({
targetManifest: expect.objectContaining({
agent: { id: "demo-agent", name: "Demo Agent" },
}),
}),
expect.objectContaining({
config: {},
sourceMcpServers: {},
consentPlanIntegrity: "sha256:update-plan",
packagePreflight: expect.any(Function),
cronGateway: expect.objectContaining({
add: expect.any(Function),
get: expect.any(Function),
remove: expect.any(Function),
}),
}),
);
expect(JSON.parse(mocks.logs[0] ?? "{}")).toMatchObject({
schemaVersion: "openclaw.clawUpdateResult.v1",
status: "complete",
agentId: "demo-agent",
});
});
it("reports uncertain update mutations as partial JSON", async () => {
const { root } = await writePackage();
mocks.applyClawUpdatePlan.mockRejectedValueOnce(
new ClawUpdateMutationError("update_partial", "artifact outcome requires reconciliation"),
);
await runCli([
"claws",
"update",
"demo-agent",
"--from",
root,
"--yes",
"--plan-integrity",
"sha256:update-plan",
"--json",
]);
expect(JSON.parse(mocks.logs[0] ?? "{}")).toMatchObject({
schemaVersion: "openclaw.clawUpdateResult.v1",
status: "partial",
error: { code: "update_partial" },
});
expect(mocks.runtime.exit).toHaveBeenCalledWith(1);
});
+9 -1
View File
@@ -17,7 +17,13 @@ export type ClawsAddOptions = {
};
export type ClawsStatusOptions = { json?: boolean };
export type ClawsUpdateOptions = { from?: string; dryRun?: boolean; json?: boolean };
export type ClawsUpdateOptions = {
from?: string;
dryRun?: boolean;
yes?: boolean;
planIntegrity?: string;
json?: boolean;
};
export type ClawsRemoveOptions = {
dryRun?: boolean;
yes?: boolean;
@@ -80,6 +86,8 @@ export function registerClawsCli(program: Command) {
.argument("<claw-or-agent>", "Installed package name or final agent id")
.option("--from <source>", "Override the target source recorded at Claw add time")
.option("--dry-run", "Preview update actions without mutating state", false)
.option("--yes", "Confirm the exact supported update plan", false)
.option("--plan-integrity <digest>", "Bind consent to an exact update plan")
.option("--json", "Print JSON", false)
.action(async (target: string, opts: ClawsUpdateOptions) => {
const { runClawsUpdateCommand } = await import("./claws-cli.runtime.js");
+233
View File
@@ -0,0 +1,233 @@
import { assertExperimentalClawsEnabled } from "../claws/experimental.js";
import { readClawStatus } from "../claws/lifecycle-state.js";
import { preflightClawPackage } from "../claws/packages.js";
import { readClawManifestFile } from "../claws/reader.js";
import { CLAW_OUTPUT_STABILITY } from "../claws/types.js";
import {
applyClawUpdatePlan,
CLAW_UPDATE_RESULT_SCHEMA_VERSION,
ClawUpdateMutationError,
} from "../claws/update-apply.js";
import { buildClawUpdatePlan, CLAW_UPDATE_PLAN_SCHEMA_VERSION } from "../claws/update-plan.js";
import { listConfiguredMcpServers } from "../config/mcp-config.js";
import { defaultRuntime, writeRuntimeJson, type RuntimeEnv } from "../runtime.js";
import { openExistingOpenClawStateDatabaseReadOnly } from "../state/openclaw-state-db.js";
import { logClawUpdatePlanSummary } from "./claws-cli-update-output.js";
import type { ClawsUpdateOptions } from "./claws-cli.js";
import { callGatewayFromCli } from "./gateway-rpc.js";
type DiagnosticLike = { level: string; code: string; path: string; message: string };
function formatDiagnostics(diagnostics: DiagnosticLike[]): string {
return diagnostics
.map(
(diagnostic) =>
`${diagnostic.level.toUpperCase()} ${diagnostic.code} ${diagnostic.path}: ${diagnostic.message}`,
)
.join("\n");
}
function logExperimentalWarning(runtime: RuntimeEnv): void {
runtime.log("Experimental: Claws contracts may change while RFC 0016 is under review.");
}
export async function runClawsUpdateCommand(
target: string,
opts: ClawsUpdateOptions,
runtime: RuntimeEnv = defaultRuntime,
): Promise<void> {
assertExperimentalClawsEnabled();
if (!opts.dryRun && (!opts.yes || !opts.planIntegrity)) {
const message =
"Claw update requires explicit consent; pass --dry-run to preview or --yes with --plan-integrity to apply supported actions.";
if (opts.json) {
writeRuntimeJson(runtime, {
schemaVersion: CLAW_UPDATE_PLAN_SCHEMA_VERSION,
stability: CLAW_OUTPUT_STABILITY,
ok: false,
error: { code: "consent_required", message },
});
} else {
runtime.error(message);
}
runtime.exit(1);
return;
}
const listedMcpServers = await listConfiguredMcpServers();
if (!listedMcpServers.ok) {
if (opts.json) {
writeRuntimeJson(runtime, {
schemaVersion: CLAW_UPDATE_PLAN_SCHEMA_VERSION,
stability: CLAW_OUTPUT_STABILITY,
dryRun: true,
mutationAllowed: false,
valid: false,
diagnostics: [
{
level: "error",
code: "mcp_config_unavailable",
phase: "plan",
path: "$.mcpServers",
message: listedMcpServers.error,
},
],
});
} else {
runtime.error(listedMcpServers.error);
}
runtime.exit(1);
return;
}
const config = listedMcpServers.config;
let source = opts.from;
if (!source) {
const database = openExistingOpenClawStateDatabaseReadOnly();
let status: Awaited<ReturnType<typeof readClawStatus>> | { records: never[] } = {
records: [],
};
if (database) {
try {
const hasClawInstalls =
database.db /* sqlite-allow-raw: read-only Claw install table-existence probe. */
.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'claw_installs'")
.get();
if (hasClawInstalls) {
status = await readClawStatus(target, {
database,
readOnly: true,
sourceMcpServers: listedMcpServers.mcpServers,
});
}
} finally {
database.walMaintenance.close();
}
}
if (status.records.length !== 1) {
const message =
status.records.length === 0
? `No installed Claw agent matches ${JSON.stringify(target)}.`
: `Claw name ${JSON.stringify(target)} matches multiple agents; use an agent id.`;
if (opts.json) {
writeRuntimeJson(runtime, {
schemaVersion: CLAW_UPDATE_PLAN_SCHEMA_VERSION,
stability: CLAW_OUTPUT_STABILITY,
dryRun: true,
mutationAllowed: false,
valid: false,
diagnostics: [
{
level: "error",
code: status.records.length === 0 ? "claw_not_found" : "claw_ambiguous",
phase: "plan",
path: "$",
message,
},
],
});
} else {
runtime.error(message);
}
runtime.exit(1);
return;
}
const recorded = status.records[0]!.install.claw;
source = recorded.kind === "package" ? recorded.packageRoot : recorded.manifestPath;
}
const loaded = await readClawManifestFile(source);
if (!loaded.ok) {
const diagnostics = opts.from
? loaded.diagnostics
: [
...loaded.diagnostics,
{
level: "error" as const,
code: "recorded_source_unavailable",
phase: "plan" as const,
path: "$",
message: "The recorded Claw source is unavailable; pass --from to override it.",
},
];
if (opts.json) {
writeRuntimeJson(runtime, {
schemaVersion: CLAW_UPDATE_PLAN_SCHEMA_VERSION,
stability: CLAW_OUTPUT_STABILITY,
dryRun: true,
mutationAllowed: false,
valid: false,
diagnostics,
});
} else {
runtime.error(formatDiagnostics(diagnostics));
}
runtime.exit(1);
return;
}
const plan = await buildClawUpdatePlan({
agentId: target,
targetManifest: loaded.manifest,
targetSource: loaded.source,
config,
sourceMcpServers: listedMcpServers.mcpServers,
packagePreflight: preflightClawPackage,
diagnostics: loaded.diagnostics,
});
if (opts.dryRun || plan.blockers.length > 0 || plan.actions.some((action) => action.blocked)) {
if (opts.json) {
writeRuntimeJson(runtime, plan);
} else {
logExperimentalWarning(runtime);
runtime.log(
`Claw update plan: ${plan.currentClaw?.name ?? target} ${plan.currentClaw?.version ?? "unknown"} -> ${plan.targetClaw?.version ?? "unknown"}`,
);
runtime.log(`Plan integrity: ${plan.planIntegrity}`);
logClawUpdatePlanSummary(plan, runtime);
}
if (plan.blockers.length > 0 || plan.actions.some((action) => action.blocked)) {
runtime.exit(1);
}
return;
}
try {
const result = await applyClawUpdatePlan(
plan,
{ targetManifest: loaded.manifest, targetSource: loaded.source },
{
config,
sourceMcpServers: listedMcpServers.mcpServers,
consentPlanIntegrity: opts.planIntegrity,
packagePreflight: preflightClawPackage,
cronGateway: {
add: async (input) => await callGatewayFromCli("cron.add", {}, input),
get: async (id) => await callGatewayFromCli("cron.get", {}, { id }),
remove: async (id) => await callGatewayFromCli("cron.remove", {}, { id }),
},
},
);
if (opts.json) {
writeRuntimeJson(runtime, result);
return;
}
logExperimentalWarning(runtime);
runtime.log(`Updated agent: ${result.agentId}`);
runtime.log(`Claw version: ${result.previousClaw.version} -> ${result.targetClaw.version}`);
} catch (error) {
const code = error instanceof ClawUpdateMutationError ? error.code : "update_failed";
const message = error instanceof Error ? error.message : String(error);
if (opts.json) {
writeRuntimeJson(runtime, {
schemaVersion: CLAW_UPDATE_RESULT_SCHEMA_VERSION,
stability: CLAW_OUTPUT_STABILITY,
status: code === "update_partial" ? "partial" : "failed",
error: { code, message },
});
} else {
runtime.error(message);
}
runtime.exit(1);
}
}