feat: migrate existing agents to local Claws (#162329)

* feat: migrate existing agents to local Claws

* fix(claws): keep migration config and provenance clean

* fix(claws): avoid duplicate migration helper export

* test(config): cover runtime snapshot MCP results

* fix(claws): reject Basic authorization during migration

* fix(claws): preserve adopted authority across lifecycle operations

* fix(claws): classify adopted ownership writes as CLI operations

* test(claws): exercise adopted consent through tool execution
This commit is contained in:
Patrick Erichsen 2026-10-01 06:52:35 -07:00 • committed by GitHub
parent 5c4e9aba92
commit 98f7c8d8cb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 3484 additions and 106 deletions

View file

@ -1785,7 +1785,7 @@ src/claws/package-update.ts 1
src/claws/packages.ts 1
src/claws/project-build.ts 4
src/claws/project.ts 2
src/claws/provenance.ts 6
src/claws/provenance.ts 5
src/claws/reader.ts 3
src/claws/schema.ts 2
src/claws/update-capability-changes.ts 7

View file

@ -9,10 +9,11 @@ title: "Claws"
# `openclaw claws`
A Claw is a versioned setup for one new OpenClaw agent. It can describe the
A Claw is a versioned setup for one OpenClaw agent. It can describe the
agent's portable identity, workspace files, skills, plugins, MCP servers, and
cron jobs. Harness-specific agent settings may be carried in a conventional
package profile. A Claw does not replace or modify an existing agent.
package profile. Adding a Claw creates a separate agent; `claws migrate` can
enroll an existing agent without replacing it or moving its workspace.
Claws are experimental. Their schema, command output, and lifecycle may change.
Enable the command surface explicitly:
@ -416,6 +417,52 @@ 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.
## Migrate an existing agent
`claws migrate` enrolls one already configured local agent without creating a
second agent or moving its workspace. It creates a local package under the
OpenClaw state directory, previews the exact profile and existing files that
will become Claw-managed, lists the generated package files, and asks for
confirmation:
```bash
openclaw claws migrate research-agent
```
For automation, inspect the read-only plan and apply only that exact plan:
```bash
openclaw claws migrate research-agent --dry-run --json
openclaw claws migrate research-agent \
--yes \
--plan-integrity <SHA256_FROM_DRY_RUN> \
--json
```
Migration supports Claw v1 agent identity and OpenClaw profile settings, plus
the existing `AGENTS.md`, `SOUL.md`, `IDENTITY.md`, `TOOLS.md`, and
`HEARTBEAT.md` prompt files. It fails closed when a setting cannot be
represented faithfully, workspace ownership is ambiguous, a selected file is
unsafe, or likely secret material is detected. Selected files are recorded
with their existing content digests and are not rewritten. `BOOTSTRAP.md`,
credentials, sessions, transcripts, databases, and every other workspace entry
remain local and outside Claw ownership.
Inherited model, subagent allowlist/delegation, heartbeat schedule, sandbox
mode/scope/workspace access, and human-delay defaults are copied into the
generated profile. Host ownership pointers such as `heartbeat.agentId` remain in
OpenClaw config. Other inherited agent defaults that Claw v1 cannot carry,
including provider params, skills, model policy/catalog, or unsupported
heartbeat/sandbox fields and custom compaction settings, block migration with
their setting paths in the diagnostic. An empty compaction placeholder or the
effective `safeguard` default materialized by OpenClaw has no effect beyond the
runtime default and is ignored.
`claws status` and `claws update` use the generated package after migration.
Removing an adopted Claw releases its ownership records while retaining the
pre-existing agent, workspace, local package, credentials, databases, sessions,
and transcripts.
## Update an installed Claw
By default, update uses the source recorded when the Claw was added. Use

View file

@ -235,6 +235,12 @@ const exceptionModules = new Set([
"src/state/openclaw-agent-db-lease.ts",
"src/infra/gateway-boot-lifecycle.ts",
]);
const cliModules = new Map([
[
"src/claws/provenance-adopted.ts",
"Only claws migrate/remove CLI one-shots call these writers via migrate.ts and lifecycle-adopted-removal.ts; no Gateway caller",
],
]);
function classify(file) {
const evidence = reviewed.get(file);
@ -244,6 +250,10 @@ function classify(file) {
if (/\.worker\.[cm]?[jt]s$/.test(file) || workerModules.has(file)) {
return { tier: "W", priority: 99, evidence: "Worker implementation; keep SQL in this owner" };
}
const cliEvidence = cliModules.get(file);
if (cliEvidence) {
return { tier: "T3", priority: 99, evidence: cliEvidence };
}
if (/^(?:scripts\/|src\/(?:cli|commands|tui)\/)/.test(file)) {
return {
tier: "T3",

View file

@ -231,6 +231,8 @@ describe("configured MCP read-only results", () => {
"mcpServers",
"ok",
"path",
"runtimeConfig",
"sourceConfigBeforeMigrations",
]);
const missing = await unsetConfiguredMcpServer({ name: "missing" });
expect(missing).toMatchObject({ ok: true, removed: false });

View file

@ -66,7 +66,10 @@ export class ClawExportError extends Error {
}
}
function portableAgent(agent: AgentConfig, avatar: string | undefined): ClawManifest["agent"] {
export function portableAgent(
agent: AgentConfig,
avatar: string | undefined,
): ClawManifest["agent"] {
const identity = {
...(agent.identity?.name ? { name: agent.identity.name } : {}),
...(agent.identity?.theme ? { theme: agent.identity.theme } : {}),
@ -81,7 +84,7 @@ function portableAgent(agent: AgentConfig, avatar: string | undefined): ClawMani
};
}
function portableOpenClawProfile(
export function portableOpenClawProfile(
agent: AgentConfig,
extensions: ClawOpenClawExtension[],
): ClawOpenClawProfile | undefined {

View file

@ -0,0 +1,163 @@
import { withAgentDeletion } from "../agents/agent-lifecycle-registry.js";
import { digestClawValue } from "./digest.js";
import { ClawRemoveError } from "./lifecycle-delete-support.js";
import {
CLAW_REMOVE_PLAN_SCHEMA_VERSION,
CLAW_REMOVE_RESULT_SCHEMA_VERSION,
type ClawRemoveApplyOptions,
type ClawRemovePlan,
type ClawRemovePlanAction,
type ClawRemoveResult,
} from "./lifecycle-remove-contract.js";
import { readClawStatus, type ClawStatusRecord } from "./lifecycle-status.js";
import { releaseAdoptedClawInstallRecord } from "./provenance.js";
import { CLAW_OUTPUT_STABILITY } from "./types.js";
export function buildClawAdoptedRemovePlan(
target: string,
record: ClawStatusRecord,
blockers: ClawRemovePlan["blockers"],
): ClawRemovePlan {
const adoptedBlockers = [...blockers];
if (record.install.status !== "complete") {
adoptedBlockers.push({
code: "adopted_install_incomplete",
message: `Adopted Claw ownership is ${record.install.status}; reconcile it before removal.`,
});
}
if (record.packages.length > 0 || record.mcpServers.length > 0 || record.cronJobs.length > 0) {
adoptedBlockers.push({
code: "adopted_managed_resources_present",
message:
"This adopted Claw has managed packages, MCP servers, or cron jobs. Removal will not touch the pre-existing agent; reconcile those Claw resources before releasing ownership.",
});
}
const actions: ClawRemovePlanAction[] = [
{
kind: "agent",
id: record.install.agentId,
action: "retain",
target: `agents.entries[${JSON.stringify(record.install.agentId)}]`,
blocked: false,
reason: "The agent existed before Claw migration and remains configured.",
},
{
kind: "workspace",
id: record.install.agentId,
action: "retain",
target: record.install.workspace,
blocked: false,
reason: "The workspace existed before Claw migration and remains in place.",
},
{
kind: "agentState",
id: record.install.agentId,
action: "retain",
target: "agent runtime state, credentials, and databases",
blocked: false,
},
{
kind: "sessionIndex",
id: record.install.agentId,
action: "retain",
target: `session store entries for agent:${record.install.agentId}`,
blocked: false,
},
{
kind: "sessionTranscripts",
id: record.install.agentId,
action: "retain",
target: "session transcripts",
blocked: false,
},
...record.workspaceFiles.map((file) => ({
kind: "workspaceFile" as const,
id: file.path,
action: "retain" as const,
target: file.path,
blocked: false,
reason: "The file predated migration; only its Claw ownership record is released.",
})),
{
kind: "installRecord",
id: record.install.agentId,
action: "release",
target: `claw_installs:${record.install.agentId}`,
blocked: false,
details: { expectedPlanIntegrity: record.install.planIntegrity },
},
];
const planIdentity = {
target,
agentId: record.install.agentId,
actions,
blockers: adoptedBlockers,
};
return {
schemaVersion: CLAW_REMOVE_PLAN_SCHEMA_VERSION,
stability: CLAW_OUTPUT_STABILITY,
dryRun: true,
mutationAllowed: false,
planIntegrity: digestClawValue(planIdentity),
target,
agentId: record.install.agentId,
actions,
blockers: adoptedBlockers,
};
}
export async function applyClawAdoptedRemovePlan(
plan: ClawRemovePlan,
options: ClawRemoveApplyOptions,
): Promise<ClawRemoveResult> {
const agentId = plan.agentId;
if (!agentId) {
throw new ClawRemoveError("remove_blocked", "The adopted Claw remove plan has no agent id.");
}
return await withAgentDeletion(
agentId,
async () => {
const lockedStatus = await readClawStatus(agentId, options);
const record = lockedStatus.records[0];
if (
!record ||
record.install.agentOrigin !== "adopted" ||
record.install.status !== "complete" ||
record.packages.length > 0 ||
record.mcpServers.length > 0 ||
record.cronJobs.length > 0
) {
throw new ClawRemoveError(
"remove_blocked",
"Adopted Claw ownership now includes incomplete or managed secondary resources; review remove --dry-run and reconcile them first.",
);
}
if (
buildClawAdoptedRemovePlan(plan.target, record, []).planIntegrity !== plan.planIntegrity
) {
throw new ClawRemoveError(
"remove_changed",
"Claw-owned state changed while waiting to release adopted ownership; review a fresh remove --dry-run plan.",
);
}
releaseAdoptedClawInstallRecord(agentId, record.install.planIntegrity, options);
return {
schemaVersion: CLAW_REMOVE_RESULT_SCHEMA_VERSION,
stability: CLAW_OUTPUT_STABILITY,
dryRun: false,
status: "complete",
agentId,
agentRemoved: false,
workspaceFiles: [],
packages: [],
mcpServers: [],
cronJobs: [],
packageRefsReleased: 0,
warnings: [
"Released Claw ownership. The pre-existing agent, workspace, credentials, databases, sessions, transcripts, and generated local package were retained.",
],
};
},
options,
);
}

View file

@ -93,6 +93,7 @@ export function synthesizeOrphanInstall(params: {
agentId: params.agentId,
workspace: params.workspace ?? "",
agentConfigDigest: "sha256:missing",
agentOrigin: "created",
agentOwnedPaths: [],
status: "partial",
addedAtMs: updatedAtMs,

View file

@ -273,7 +273,14 @@ describe("Claw exec approvals removal", () => {
config: {},
mcpServers: { docs: sourceMcpServer },
}),
listMcpServers: async () => ({ ok: true, path: "fixture", config: {}, mcpServers: {} }),
listMcpServers: async () => ({
ok: true,
path: "fixture",
config: {},
mcpServers: {},
runtimeConfig: {},
sourceConfigBeforeMigrations: {},
}),
});
config = { ...config, mcp: { servers: { docs: sourceMcpServer } } };
await writeOpenClawConfig(home, config);

View file

@ -0,0 +1,43 @@
import { clawBootstrapStateBlocksRemove } from "./lifecycle-bootstrap-removal.js";
import type { ClawStatusRecord } from "./lifecycle-status.js";
export function clawRemoveStateBlockers(record?: ClawStatusRecord) {
const blockers: Array<{ code: string; message: string }> = [];
if (record?.agentState === "modified") {
blockers.push({
code: "agent_modified",
message: `Agent ${JSON.stringify(record.install.agentId)} changed after add.`,
});
}
for (const file of record?.workspaceFiles ?? []) {
if (file.state === "unsafe") {
blockers.push({
code: "workspace_file_unsafe",
message: `${file.path}: ${file.message ?? "unsafe file"}`,
});
}
}
if (record && clawBootstrapStateBlocksRemove(record)) {
blockers.push({
code: "bootstrap_cleanup_uncertain",
message: `BOOTSTRAP.md has ${record.bootstrap.state} ownership state and must be reconciled before removal.`,
});
}
for (const server of record?.mcpServers ?? []) {
if (server.state === "pending") {
blockers.push({
code: "mcp_cleanup_uncertain",
message: `MCP server ${JSON.stringify(server.name)} has ${server.state} ownership state and must be reconciled before removal.`,
});
}
}
for (const cron of record?.cronJobs ?? []) {
if (cron.status !== "removed" && (cron.status !== "complete" || !cron.schedulerJobId)) {
blockers.push({
code: "cron_cleanup_uncertain",
message: `Cron declaration ${JSON.stringify(cron.manifestId)} has ${cron.status} ownership state and must be reconciled before removal.`,
});
}
}
return blockers;
}

View file

@ -26,6 +26,7 @@ import { withClawAgentConfigRemoval } from "./lifecycle-config-removal.js";
import { quiescentClawMonitorGateway } from "./lifecycle-remove.test-support.js";
import { applyClawRemovePlan, buildClawRemovePlan, readClawStatus } from "./lifecycle-state.js";
import { createClawRemoveTestFixtures } from "./lifecycle-state.test-helpers.js";
import { digestClawMcpServer, readClawMcpServerRefs, upsertClawMcpServerRef } from "./mcp.js";
import {
persistClawInstallRecord,
persistClawPackageRef,
@ -123,6 +124,53 @@ describe("Claw status and remove", () => {
);
});
it("does not reconcile pending MCP provenance before rejecting remove without Gateway", async () => {
const current = await addFixture();
const server = { command: "docs-mcp", args: [] };
const mcpOptions = {
listMcpServers: async () => ({
ok: true as const,
path: "config",
config: {},
mcpServers: { docs: server },
runtimeConfig: current.getConfig(),
sourceConfigBeforeMigrations: current.getConfig(),
}),
};
const ref = {
schemaVersion: "openclaw.clawMcpServerRef.v1" as const,
agentId: "worker",
name: "docs",
configDigest: digestClawMcpServer(server),
relationship: "managed" as const,
origin: "claw-introduced" as const,
independentOwner: false,
status: "complete" as const,
createdAtMs: 1,
updatedAtMs: 1,
};
upsertClawMcpServerRef(ref, { env: current.env });
const config = current.getConfig();
const plan = await buildClawRemovePlan("worker", {
env: current.env,
config,
...mcpOptions,
});
upsertClawMcpServerRef({ ...ref, status: "pending" }, { env: current.env });
await expect(
applyClawRemovePlan(plan, {
env: current.env,
config,
consentPlanIntegrity: plan.planIntegrity,
...mcpOptions,
}),
).rejects.toMatchObject({ code: "monitor_gateway_required" });
expect(readClawMcpServerRefs("worker", { env: current.env })).toMatchObject([
{ name: "docs", status: "pending" },
]);
});
it("rejects cleanup when an expected-missing agent id was recreated", async () => {
await expect(
withClawAgentConfigRemoval(

View file

@ -10,6 +10,10 @@ import {
import { getRuntimeConfig } from "../config/config.js";
import { clawCronGatewayJobMatchesRef, deleteClawCronRef, markClawCronRefRemoved } from "./cron.js";
import { digestClawValue } from "./digest.js";
import {
applyClawAdoptedRemovePlan,
buildClawAdoptedRemovePlan,
} from "./lifecycle-adopted-removal.js";
import {
clawBootstrapStateBlocksRemove,
planClawBootstrapRemoval,
@ -39,6 +43,7 @@ import {
type ClawRemovePlan,
type ClawRemovePlanAction,
} from "./lifecycle-remove-contract.js";
import { clawRemoveStateBlockers } from "./lifecycle-remove-state-blockers.js";
import { readClawStatus } from "./lifecycle-status.js";
import { clawMcpRemovalSelector, planClawMcpServerRemoval } from "./mcp.js";
import { clawMonitorSnapshotSchema } from "./monitor-cleanup-contract.js";
@ -72,42 +77,10 @@ export async function buildClawRemovePlan(
});
}
const record = status.records.length === 1 ? status.records[0] : undefined;
if (record?.agentState === "modified") {
blockers.push({
code: "agent_modified",
message: `Agent ${JSON.stringify(record.install.agentId)} changed after add.`,
});
}
for (const file of record?.workspaceFiles ?? []) {
if (file.state === "unsafe") {
blockers.push({
code: "workspace_file_unsafe",
message: `${file.path}: ${file.message ?? "unsafe file"}`,
});
}
}
if (record && clawBootstrapStateBlocksRemove(record)) {
blockers.push({
code: "bootstrap_cleanup_uncertain",
message: `BOOTSTRAP.md has ${record.bootstrap.state} ownership state and must be reconciled before removal.`,
});
}
for (const server of record?.mcpServers ?? []) {
if (server.state === "pending") {
blockers.push({
code: "mcp_cleanup_uncertain",
message: `MCP server ${JSON.stringify(server.name)} has ${server.state} ownership state and must be reconciled before removal.`,
});
}
}
for (const cron of record?.cronJobs ?? []) {
if (cron.status !== "removed" && (cron.status !== "complete" || !cron.schedulerJobId)) {
blockers.push({
code: "cron_cleanup_uncertain",
message: `Cron declaration ${JSON.stringify(cron.manifestId)} has ${cron.status} ownership state and must be reconciled before removal.`,
});
}
if (record?.install.agentOrigin === "adopted") {
return buildClawAdoptedRemovePlan(target, record, blockers);
}
blockers.push(...clawRemoveStateBlockers(record));
const actions: ClawRemovePlanAction[] = [];
if (record) {
const packageCleanup = filterReferencedCleanup(options.referencedCleanup, "package");
@ -430,6 +403,11 @@ export async function applyClawRemovePlan(
if (plan.blockers.length > 0 || !plan.agentId) {
throw new ClawRemoveError("remove_blocked", "The Claw remove plan contains blockers.");
}
if (
plan.actions.some((action) => action.kind === "installRecord" && action.action === "release")
) {
return await applyClawAdoptedRemovePlan(plan, options);
}
const monitorGateway = options.monitorGateway;
if (!monitorGateway) {
throw new ClawRemoveError(
@ -442,6 +420,8 @@ export async function applyClawRemovePlan(
throw new ClawRemoveError("remove_changed", "Claw-owned state changed after remove planning.");
}
const agentId = plan.agentId;
const current = await readClawStatus(plan.agentId, options);
const record = current.records[0];
const plannedAgentAction = plan.actions.find(
(action) => action.kind === "agent" && action.id === agentId,
);
@ -449,8 +429,6 @@ export async function applyClawRemovePlan(
if (typeof expectedRemovalSurfaceDigest !== "string") {
throw new ClawRemoveError("remove_changed", "Claw remove plan is missing config state.");
}
const current = await readClawStatus(plan.agentId, options);
const record = current.records[0];
if (
!record ||
record.agentState === "modified" ||

View file

@ -1,4 +1,7 @@
import { realpath } from "node:fs/promises";
import { resolve } from "node:path";
import { stableStringify } from "@openclaw/normalization-core";
import { resolveAgentWorkspaceDir } from "../agents/agent-scope-config.js";
import { listAgentEntries } from "../agents/agent-scope.js";
import { getRuntimeConfig } from "../config/config.js";
import { normalizeConfiguredMcpServers } from "../config/mcp-config-normalize.js";
@ -26,6 +29,11 @@ import {
reconcileClawMcpServerRefs,
type PersistedClawMcpServerRef,
} from "./mcp.js";
import {
normalizeWorkspaceConfig,
resolveMigrationAgentSettings,
withAuthoredAgentRoster,
} from "./migrate-validation.js";
import {
inspectClawPackage,
type ClawPackageInspection,
@ -234,7 +242,26 @@ export async function readClawStatus(
const records: ClawStatusRecord[] = [];
const packagePreflight = options.packagePreflight;
for (const install of installs) {
const agent = listAgentEntries(config).find((candidate) => candidate.id === install.agentId);
const lifecycleConfig =
install.agentOrigin === "adopted" && listedMcp?.ok
? withAuthoredAgentRoster(config, listedMcp.sourceConfigBeforeMigrations)
: config;
const agent = listAgentEntries(lifecycleConfig).find(
(candidate) => candidate.id === install.agentId,
);
let comparableAgent = agent;
if (agent?.id && install.agentOrigin === "adopted") {
try {
comparableAgent = normalizeWorkspaceConfig(
resolveMigrationAgentSettings(lifecycleConfig, agent),
await realpath(resolveAgentWorkspaceDir(config, install.agentId, options.env)).catch(() =>
resolve(resolveAgentWorkspaceDir(config, install.agentId, options.env)),
),
);
} catch {
comparableAgent = undefined;
}
}
const packageRefs = allPackageRefs.filter(
(packageRef) => packageRef.agentId === install.agentId,
);
@ -253,7 +280,7 @@ export async function readClawStatus(
...(installAgentIds.has(install.agentId) ? {} : { orphaned: true }),
agentState: !agent
? "missing"
: digestClawValue(agent) === install.agentConfigDigest
: digestClawValue(comparableAgent) === install.agentConfigDigest
? "present"
: "modified",
bootstrapState: bootstrap.state,

View file

@ -1,6 +1,6 @@
// E2E coverage for experimental grouped Claw inspection and add planning.
import { execFile } from "node:child_process";
import { readFile, realpath } from "node:fs/promises";
import { mkdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
@ -67,6 +67,226 @@ function parseJson(stdout: string): unknown {
describe("claws lifecycle cli e2e", () => {
const manifestPath = "src/claws/fixtures/incident-response.claw.json";
it("migrates an existing agent in place and releases only Claw ownership on remove", async () => {
const stateDir = tempDirs.make("openclaw-claws-migrate-e2e-");
const workspace = join(stateDir, "workspace");
const agentDir = join(stateDir, "agents", "main", "agent");
const sessionsDir = join(stateDir, "agents", "main", "sessions");
await Promise.all([
mkdir(workspace, { recursive: true }),
mkdir(agentDir, { recursive: true }),
mkdir(sessionsDir, { recursive: true }),
]);
const originalFiles = new Map([
[join(workspace, "AGENTS.md"), Buffer.from("# Existing instructions\n", "utf8")],
[join(workspace, "SOUL.md"), Buffer.from("Keep the existing voice.\n", "utf8")],
[join(workspace, "BOOTSTRAP.md"), Buffer.from("First-run state stays local.\n", "utf8")],
[join(workspace, "unrelated.txt"), Buffer.from("Leave me unmanaged.\n", "utf8")],
[join(agentDir, "auth-profiles.json"), Buffer.from('{"sentinel":"auth"}\n', "utf8")],
[join(sessionsDir, "session.jsonl"), Buffer.from('{"sentinel":"transcript"}\n', "utf8")],
[join(workspace, "memory.sqlite"), Buffer.from("existing database bytes\n", "utf8")],
]);
for (const [path, content] of originalFiles) {
await writeFile(path, content);
}
const configPath = join(stateDir, "openclaw.json");
const config = {
gateway: { mode: "local", controlUi: { enabled: false } },
agents: {
defaults: {
model: { primary: "provider/default", fallbacks: ["provider/fallback"] },
heartbeat: { agentId: "main" },
systemAgent: { agentId: "main" },
},
entries: { main: { name: "Existing agent", workspace } },
},
};
const configBytes = Buffer.from(`${JSON.stringify(config, null, 2)}\n`, "utf8");
await writeFile(configPath, configBytes);
const beforeStats = new Map(
await Promise.all(
[...originalFiles.keys()].map(async (path) => [path, await stat(path)] as const),
),
);
const preview = await runOpenClaw(["claws", "migrate", "main", "--dry-run", "--json"], {
stateDir,
});
const plan = parseJson(preview.stdout) as {
planIntegrity: string;
packageRoot: string;
workspaceFiles: Array<{ path: string }>;
openClawProfile?: { agent: { model?: { primary?: string; fallbacks?: string[] } } };
retained: string[];
};
expect(plan).toMatchObject({
schemaVersion: "openclaw.clawMigrationPlan.v1",
mutationAllowed: false,
agentId: "main",
workspace,
workspaceFiles: [
{ path: join(workspace, "AGENTS.md") },
{ path: join(workspace, "SOUL.md") },
],
retained: expect.arrayContaining([
"BOOTSTRAP.md (one-time workspace seed)",
"credentials and auth state",
"session indexes and transcripts",
"agent databases and runtime state",
"all other workspace files and directories",
]),
});
expect(plan.openClawProfile?.agent.model).toEqual({
primary: "provider/default",
fallbacks: ["provider/fallback"],
});
expect(plan.packageRoot).toBe(join(stateDir, "claws", "local", "main"));
const migrated = await runOpenClaw(
["claws", "migrate", "main", "--yes", "--plan-integrity", plan.planIntegrity, "--json"],
{ stateDir },
);
expect(parseJson(migrated.stdout)).toMatchObject({
schemaVersion: "openclaw.clawMigrationResult.v1",
status: "complete",
agentId: "main",
workspace,
packageRoot: plan.packageRoot,
});
expect(await readFile(configPath)).toEqual(configBytes);
const status = await runOpenClaw(["claws", "status", "main", "--json"], { stateDir });
expect(await readFile(configPath)).toEqual(configBytes);
expect(parseJson(status.stdout)).toMatchObject({
summary: { claws: 1, driftedFiles: 0 },
records: [
{
install: { agentId: "main", agentOrigin: "adopted" },
agentState: "present",
workspaceFiles: [
{ path: "AGENTS.md", state: "unchanged" },
{ path: "SOUL.md", state: "unchanged" },
],
},
],
});
const inspected = await runOpenClaw(["claws", "inspect", plan.packageRoot, "--json"], {
stateDir,
});
expect(parseJson(inspected.stdout)).toMatchObject({
valid: true,
source: { kind: "package" },
manifest: { agent: { id: "main", name: "Existing agent" } },
});
// Exercise updates after a package stops pinning an inherited value. The
// live agent still gets this model from agents.defaults, so its effective
// settings and adopted ownership digest remain stable.
await rm(join(plan.packageRoot, "profiles", "openclaw.yml"));
const statusAfterPackageMutation = await runOpenClaw(["claws", "status", "main", "--json"], {
stateDir,
});
expect(parseJson(statusAfterPackageMutation.stdout)).toMatchObject({
records: [{ install: { agentOrigin: "adopted" }, agentState: "present" }],
});
const update = await runOpenClaw(["claws", "update", "main", "--dry-run", "--json"], {
stateDir,
});
const updatePlan = parseJson(update.stdout) as { planIntegrity: string };
expect(updatePlan).toMatchObject({
schemaVersion: "openclaw.clawUpdatePlan.v1",
blockers: [],
actions: expect.arrayContaining([
expect.objectContaining({ kind: "agent", action: "unchanged" }),
]),
});
const updated = await runOpenClaw(
["claws", "update", "main", "--yes", "--plan-integrity", updatePlan.planIntegrity, "--json"],
{ stateDir },
);
expect(parseJson(updated.stdout)).toMatchObject({
schemaVersion: "openclaw.clawUpdateResult.v1",
status: "complete",
agentId: "main",
});
const statusAfterUpdate = await runOpenClaw(["claws", "status", "main", "--json"], {
stateDir,
});
expect(parseJson(statusAfterUpdate.stdout)).toMatchObject({
records: [{ install: { agentOrigin: "adopted" }, agentState: "present" }],
});
const configWithoutAgent = {
...config,
agents: { ...config.agents, entries: {} },
};
await writeFile(configPath, `${JSON.stringify(configWithoutAgent, null, 2)}\n`, "utf8");
const statusBeforeRestore = await runOpenClaw(["claws", "status", "main", "--json"], {
stateDir,
});
expect(parseJson(statusBeforeRestore.stdout)).toMatchObject({
records: [{ install: { agentOrigin: "adopted" }, agentState: "missing" }],
});
const restorePreview = await runOpenClaw(["claws", "update", "main", "--dry-run", "--json"], {
stateDir,
});
const restorePlan = parseJson(restorePreview.stdout) as {
planIntegrity: string;
actions: Array<{ kind: string; action: string }>;
};
expect(restorePlan.actions).toEqual(
expect.arrayContaining([expect.objectContaining({ kind: "agent", action: "change" })]),
);
const restored = await runOpenClaw(
["claws", "update", "main", "--yes", "--plan-integrity", restorePlan.planIntegrity, "--json"],
{ stateDir },
);
expect(parseJson(restored.stdout)).toMatchObject({ status: "complete", agentId: "main" });
const statusAfterRestore = await runOpenClaw(["claws", "status", "main", "--json"], {
stateDir,
});
expect(parseJson(statusAfterRestore.stdout)).toMatchObject({
records: [{ install: { agentOrigin: "adopted" }, agentState: "present" }],
});
const configBytesAfterRestore = await readFile(configPath);
const removePreview = await runOpenClaw(["claws", "remove", "main", "--dry-run", "--json"], {
stateDir,
});
const removePlan = parseJson(removePreview.stdout) as {
planIntegrity: string;
actions: unknown[];
};
expect(removePlan.actions).toEqual(
expect.arrayContaining([
expect.objectContaining({ kind: "agent", action: "retain" }),
expect.objectContaining({ kind: "workspace", action: "retain", target: workspace }),
expect.objectContaining({ kind: "sessionTranscripts", action: "retain" }),
expect.objectContaining({ kind: "installRecord", action: "release" }),
]),
);
const removed = await runOpenClaw(
["claws", "remove", "main", "--yes", "--plan-integrity", removePlan.planIntegrity, "--json"],
{ stateDir },
);
expect(parseJson(removed.stdout)).toMatchObject({
status: "complete",
agentId: "main",
agentRemoved: false,
});
expect(await readFile(configPath)).toEqual(configBytesAfterRestore);
for (const [path, content] of originalFiles) {
expect(await readFile(path)).toEqual(content);
const after = await stat(path);
expect(after.ino).toBe(beforeStats.get(path)?.ino);
expect(after.mtimeMs).toBe(beforeStats.get(path)?.mtimeMs);
}
const afterStatus = await runOpenClaw(["claws", "status", "main", "--json"], {
expectFailure: true,
stateDir,
});
expect(afterStatus.code).toBe(1);
expect(parseJson(afterStatus.stdout)).toMatchObject({ summary: { claws: 0 } });
});
it("inspects a grouped development manifest", async () => {
const inspect = parseJson(
(await runOpenClaw(["claws", "inspect", manifestPath, "--json"])).stdout,

View file

@ -60,7 +60,7 @@ async function fixture(agentId = "worker", root?: string) {
}
function listedMcpServers(mcpServers: Record<string, Record<string, unknown>> = {}) {
return { ok: true as const, path: "config", config: {}, mcpServers };
return { ok: true as const, path: "config", config: {}, mcpServers, runtimeConfig: {} };
}
describe("installClawMcpServers", () => {

View file

@ -0,0 +1,10 @@
export class ClawMigrationError extends Error {
constructor(
readonly code: string,
message: string,
readonly path = "$",
) {
super(message);
this.name = "ClawMigrationError";
}
}

View file

@ -0,0 +1,227 @@
import { createHash } from "node:crypto";
import { lstat, mkdir, mkdtemp, realpath, rm, rmdir, unlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, relative, resolve, sep } from "node:path";
import { assertNoSymlinkParents } from "@openclaw/fs-safe/advanced";
import { stringify as stringifyYaml } from "yaml";
import type { AgentConfig } from "../config/types.agents.js";
import { root as fsSafeRoot } from "../infra/fs-safe.js";
import { portableAgent, portableOpenClawProfile } from "./export.js";
import { ClawMigrationError } from "./migrate-errors.js";
import { MAX_MANAGED_FILE_BYTES } from "./source-limits.js";
import { CLAW_BOOTSTRAP_FILE_NAMES } from "./types.js";
import type { ClawManifest, ClawOpenClawProfile } from "./types.js";
export type CapturedWorkspaceFile = {
name: (typeof CLAW_BOOTSTRAP_FILE_NAMES)[number];
content: Buffer;
digest: string;
};
export function lstatMigrationPathIfExists(path: string) {
return lstat(path).catch((error: unknown) => {
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") {
return undefined;
}
throw error;
});
}
export function packageIdentityDigest(files: Map<string, Buffer>): {
integrity: string;
byteLength: number;
} {
const hash = createHash("sha256");
let byteLength = 0;
for (const [path, content] of [...files.entries()].toSorted(([left], [right]) =>
left.localeCompare(right),
)) {
const pathBytes = Buffer.from(path, "utf8");
hash.update(`${pathBytes.byteLength}:${pathBytes.toString("utf8")}:${content.byteLength}:`);
hash.update(content);
byteLength += content.byteLength;
}
return { integrity: `sha256:${hash.digest("hex")}`, byteLength };
}
export function generatedPackage(
agentId: string,
params: {
agent: AgentConfig;
avatar?: string;
files: CapturedWorkspaceFile[];
},
): {
manifest: ClawManifest;
profile?: ClawOpenClawProfile;
body?: Buffer;
packageFiles: Map<string, Buffer>;
} {
const bootstrapFiles: ClawManifest["workspace"]["bootstrapFiles"] = {};
const workspaceFiles: ClawManifest["workspace"]["files"] = [];
const packageFiles = new Map<string, Buffer>();
let body: Buffer | undefined;
for (const file of params.files) {
if (file.name === "SOUL.md" && file.content.toString("utf8").trim().length > 0) {
body = file.content;
continue;
}
bootstrapFiles[file.name] = {
source: `workspace/${file.name}`,
};
packageFiles.set(`workspace/${file.name}`, file.content);
}
const agent = portableAgent(params.agent, params.avatar);
const profile = portableOpenClawProfile(params.agent, []);
const manifest: ClawManifest = {
schemaVersion: 1,
agent,
workspace: { bootstrapFiles, files: workspaceFiles },
packages: [],
mcpServers: {},
cronJobs: [],
};
const clawMarkdownFrontmatter = ["---", stringifyYaml(manifest).trimEnd(), "---", ""].join("\n");
packageFiles.set(
"CLAW.md",
body
? Buffer.concat([Buffer.from(clawMarkdownFrontmatter, "utf8"), body])
: Buffer.from(`${clawMarkdownFrontmatter}\n`, "utf8"),
);
packageFiles.set(
"package.json",
Buffer.from(
`${JSON.stringify(
{
name: `openclaw-agent-${agentId}-local`,
version: "1.0.0",
type: "module",
openclaw: { claw: "CLAW.md" },
},
null,
2,
)}\n`,
"utf8",
),
);
if (profile) {
packageFiles.set("profiles/openclaw.yml", Buffer.from(stringifyYaml(profile), "utf8"));
}
return { manifest, profile, body, packageFiles };
}
export async function createGeneratedPackage(
root: string,
packageFiles: Map<string, Buffer>,
): Promise<void> {
const parent = dirname(root);
let existingAncestor = parent;
while (!(await lstatMigrationPathIfExists(existingAncestor))) {
const next = dirname(existingAncestor);
if (next === existingAncestor) {
throw new ClawMigrationError(
"package_parent_unavailable",
`Could not find an existing parent for generated package ${JSON.stringify(root)}.`,
"$.packageRoot",
);
}
existingAncestor = next;
}
const ancestorInfo = await lstat(existingAncestor);
if (!ancestorInfo.isDirectory() || ancestorInfo.isSymbolicLink()) {
throw new ClawMigrationError(
"package_parent_unsafe",
`Generated package parent ${JSON.stringify(existingAncestor)} must be a real directory.`,
"$.packageRoot",
);
}
const realAncestor = await realpath(existingAncestor);
await assertNoSymlinkParents({
rootDir: realAncestor,
targetPath: parent,
allowMissing: true,
messagePrefix: "Generated Claw package parent",
});
await mkdir(parent, { recursive: true });
const parentInfo = await lstat(parent);
const realParent = await realpath(parent);
if (!parentInfo.isDirectory() || parentInfo.isSymbolicLink() || realParent !== parent) {
throw new ClawMigrationError(
"package_parent_unsafe",
`Generated package parent ${JSON.stringify(parent)} changed or resolves through a symlink; inspect the state directory before retrying.`,
"$.packageRoot",
);
}
await assertNoSymlinkParents({
rootDir: realParent,
targetPath: root,
allowMissing: true,
messagePrefix: "Generated Claw package",
});
await mkdir(root, { recursive: false });
try {
for (const [path, content] of packageFiles) {
const target = resolve(root, path);
const child = relative(root, target);
if (child === ".." || child.startsWith(`..${sep}`)) {
throw new Error("Generated package path escaped its destination.");
}
await mkdir(dirname(target), { recursive: true });
await writeFile(target, content, { flag: "wx" });
}
} catch (error) {
await removeGeneratedPackageIfUnchanged(root, packageFiles);
throw error;
}
}
export async function removeGeneratedPackageIfUnchanged(
root: string,
packageFiles: Map<string, Buffer>,
): Promise<void> {
for (const [path, expected] of [...packageFiles.entries()].toReversed()) {
const target = resolve(root, path);
const info = await lstatMigrationPathIfExists(target);
if (!info || !info.isFile() || info.isSymbolicLink() || info.nlink !== 1) {
continue;
}
const actual = await fsSafeRoot(dirname(target))
.then((parent) =>
parent.read(target.slice(dirname(target).length + 1), {
hardlinks: "reject",
maxBytes: MAX_MANAGED_FILE_BYTES,
symlinks: "reject",
}),
)
.catch(() => undefined);
if (actual && sha256(actual.buffer) === sha256(expected)) {
await unlink(target).catch(() => undefined);
}
}
for (const directory of [resolve(root, "profiles"), resolve(root, "workspace"), root]) {
await rmdir(directory).catch(() => undefined);
}
}
function sha256(value: Uint8Array): string {
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
}
export async function createPackagePreview(packageFiles: Map<string, Buffer>): Promise<string> {
const root = await mkdtemp(resolve(tmpdir(), "openclaw-claws-migrate-"));
try {
for (const [path, content] of packageFiles) {
const target = resolve(root, path);
await mkdir(dirname(target), { recursive: true });
await writeFile(target, content, { flag: "wx" });
}
return root;
} catch (error) {
await rm(root, { recursive: true, force: true });
throw error;
}
}
export async function removePackagePreview(root: string): Promise<void> {
await rm(root, { recursive: true, force: true });
}

View file

@ -0,0 +1,80 @@
import { resolve, sep } from "node:path";
import { resolveAgentWorkspaceDir } from "../agents/agent-scope-config.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveIdentityPathViaExistingAncestorSync } from "../infra/boundary-path.js";
import { ClawMigrationError } from "./migrate-errors.js";
export function clawMigrationPathsOverlap(left: string, right: string): boolean {
const a = resolve(left);
const b = resolve(right);
return a === b || a.startsWith(`${b}${sep}`) || b.startsWith(`${a}${sep}`);
}
export function assertPackageDestinationOutsideWorkspaces(params: {
agentId: string;
packageRoot: string;
workspace: string;
configuredAgents: Array<{ id: string }>;
installs: Array<{ agentId: string; workspace: string }>;
config: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): void {
if (clawMigrationPathsOverlap(params.packageRoot, params.workspace)) {
throw new ClawMigrationError(
"package_destination_overlaps_workspace",
`Generated package destination ${JSON.stringify(params.packageRoot)} overlaps the existing workspace ${JSON.stringify(params.workspace)}. Choose a workspace outside the local Claw package directory before migrating.`,
"$.packageRoot",
);
}
for (const other of params.configuredAgents) {
if (other.id === params.agentId) {
continue;
}
const otherWorkspace = resolveIdentityPathViaExistingAncestorSync(
resolveAgentWorkspaceDir(params.config, other.id, params.env),
);
if (clawMigrationPathsOverlap(params.packageRoot, otherWorkspace)) {
throw new ClawMigrationError(
"package_destination_owned_by_agent",
`Generated package destination ${JSON.stringify(params.packageRoot)} overlaps agent ${JSON.stringify(other.id)} workspace ${JSON.stringify(otherWorkspace)}. Move that workspace before migrating.`,
"$.packageRoot",
);
}
}
const installOverlap = params.installs.find(
(record) =>
record.agentId !== params.agentId &&
clawMigrationPathsOverlap(
params.packageRoot,
resolveIdentityPathViaExistingAncestorSync(record.workspace),
),
);
if (installOverlap) {
throw new ClawMigrationError(
"package_destination_owned_by_claw",
`Generated package destination ${JSON.stringify(params.packageRoot)} overlaps Claw agent ${JSON.stringify(installOverlap.agentId)} workspace ${JSON.stringify(installOverlap.workspace)}. Move that workspace before migrating.`,
"$.packageRoot",
);
}
}
export async function assertWorkspaceSnapshotUnchanged(
workspace: string,
expectedFiles: Array<{ path: string; contentDigest: string }>,
readCurrentFiles: (workspace: string) => Promise<Array<{ name: string; digest: string }>>,
): Promise<void> {
const currentFiles = await readCurrentFiles(workspace);
const expected = new Map(expectedFiles.map((file) => [file.path, file.contentDigest]));
const changed = currentFiles.find((file) => expected.get(file.name) !== file.digest);
const missing = expectedFiles.find(
(file) => !currentFiles.some((current) => current.name === file.path),
);
if (changed || missing || currentFiles.length !== expectedFiles.length) {
const name = changed?.name ?? missing?.path ?? "selected prompt files";
throw new ClawMigrationError(
"workspace_file_changed_after_consent",
`Existing workspace file ${JSON.stringify(name)} changed after consent. Rerun migrate to review the current workspace.`,
`$.workspace.${name}`,
);
}
}

View file

@ -0,0 +1,319 @@
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import {
DEFAULT_SUBAGENT_ARCHIVE_AFTER_MINUTES,
DEFAULT_SUBAGENT_MAX_CONCURRENT,
} from "../config/agent-limits.js";
import type { AgentConfig } from "../config/types.agents.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { isAvatarDataUrl } from "../shared/avatar-policy.js";
import { ClawMigrationError } from "./migrate-errors.js";
import { isPortableClawAvatar } from "./schema-portability.js";
export function containsPotentialSecret(value: string): boolean {
return (
/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/u.test(value) ||
/\b(?:sk-(?:proj-|ant-|live-)?[A-Za-z0-9_-]{20,}|gh[pousr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{20,})\b/u.test(
value,
) ||
/\bAKIA[0-9A-Z]{16}\b/u.test(value) ||
/\bBearer\s+[A-Za-z0-9._~+/-]{20,}={0,}/iu.test(value) ||
/\b(?:proxy-)?authorization["']?\s*[:=]\s*["']?Basic\s+[A-Za-z0-9+/]{4,}={0,2}/iu.test(value) ||
/\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\b/u.test(value) ||
/(?:^|[^A-Za-z0-9])(?:[A-Za-z0-9]+[_-])*?(?:api[_-]?key|access[_-]?token|client[_-]?secret|password|secret|token)(?:[_-][A-Za-z0-9]+)*\s*[:=]\s*["']?(?!\$\{|\{\{|<|YOUR_|REPLACE_|EXAMPLE)([A-Za-z0-9/+_=-]{16,})/iu.test(
value,
)
);
}
export function inspectValueForSecret(value: unknown): boolean {
if (typeof value === "string") {
return containsPotentialSecret(value);
}
if (Array.isArray(value)) {
return value.some(inspectValueForSecret);
}
if (value && typeof value === "object") {
return Object.values(value).some(inspectValueForSecret);
}
return false;
}
export function normalizeWorkspaceConfig(
agent: AgentConfig,
workspace: string,
): AgentConfig & { workspace: string } {
return {
...agent,
...(typeof agent.model === "string" ? { model: { primary: agent.model } } : {}),
id: agent.id,
workspace,
};
}
/** Keeps source roster ownership when runtime migration materializes an implicit main agent. */
export function withAuthoredAgentRoster(
config: OpenClawConfig,
source: OpenClawConfig | undefined,
): OpenClawConfig {
const sourceAgents = source?.agents;
if (!sourceAgents) {
return config;
}
const agents = { ...config.agents };
if (Object.hasOwn(sourceAgents, "entries") && sourceAgents.entries !== undefined) {
agents.entries = structuredClone(sourceAgents.entries);
delete agents.list;
} else if (Object.hasOwn(sourceAgents, "list") && sourceAgents.list !== undefined) {
agents.list = structuredClone(sourceAgents.list);
delete agents.entries;
} else {
return config;
}
if (sourceAgents.ownership !== undefined) {
agents.ownership = sourceAgents.ownership;
}
return { ...config, agents };
}
export function validateAgentConfigKeys(agent: AgentConfig): void {
const representable = new Set([
"id",
"name",
"description",
"identity",
"model",
"subagents",
"groupChat",
"sandbox",
"tools",
"memory",
"heartbeat",
"humanDelay",
"workspace",
]);
const unsupported = Object.keys(agent).filter((key) => !representable.has(key));
if (unsupported.length > 0) {
throw new ClawMigrationError(
"agent_setting_unsupported",
`Agent settings cannot be represented by Claw v1: ${unsupported.toSorted().join(", ")}. Remove or move those settings before migrating.`,
"$.agent",
);
}
const avatar = agent.identity?.avatar?.trim();
if (avatar && (!isAvatarDataUrl(avatar) || !isPortableClawAvatar(avatar))) {
throw new ClawMigrationError(
"agent_avatar_unsupported",
"This migration can represent an embedded portable image avatar, but not a URL or local avatar path. Keep the avatar unmanaged or convert it to a supported image data URL before migrating.",
"$.agent.identity.avatar",
);
}
}
type ModelConfig = NonNullable<NonNullable<OpenClawConfig["agents"]>["defaults"]>["model"];
function modelPrimary(model: ModelConfig): string | undefined {
if (typeof model === "string") {
return model;
}
return model?.primary;
}
function modelFallbacks(model: ModelConfig): string[] {
return model && typeof model === "object" && Array.isArray(model.fallbacks)
? model.fallbacks
: [];
}
function record(value: unknown): Record<string, unknown> | undefined {
return isRecord(value) ? value : undefined;
}
function unsupportedFields(value: unknown, fields: readonly string[], prefix: string): string[] {
const source = record(value);
if (!source) {
return [];
}
return Object.keys(source)
.filter((field) => !fields.includes(field))
.map((field) => `${prefix}.${field}`);
}
function unsupportedSubagentDefaultFields(value: unknown): string[] {
const source = record(value);
if (!source) {
return [];
}
return Object.keys(source)
.filter((field) => {
if (field === "allowAgents" || field === "delegationMode") {
return false;
}
if (field === "maxConcurrent" && source.maxConcurrent === DEFAULT_SUBAGENT_MAX_CONCURRENT) {
return false;
}
if (
field === "archiveAfterMinutes" &&
source.archiveAfterMinutes === DEFAULT_SUBAGENT_ARCHIVE_AFTER_MINUTES
) {
return false;
}
return true;
})
.map((field) => `agents.defaults.subagents.${field}`);
}
export function resolveMigrationAgentSettings(
config: OpenClawConfig,
agent: AgentConfig,
): AgentConfig {
const defaults = config.agents?.defaults;
if (!defaults) {
return agent;
}
const hostDefaults = new Set([
"workspace",
"modelSelectionScope",
"systemAgent",
"authInheritance",
"sessionStore",
"maxConcurrent",
]);
const portableDefaults = new Set(["model", "subagents", "heartbeat", "sandbox", "humanDelay"]);
const unsupportedDefaults = Object.keys(defaults).flatMap((key) => {
const compaction = record(defaults.compaction);
// Config materialization injects this effective default even when the user
// has no compaction settings. It has the same behavior as an unset value.
if (
key === "compaction" &&
(Object.keys(compaction ?? {}).length === 0 ||
(Object.keys(compaction ?? {}).length === 1 && compaction?.mode === "safeguard"))
) {
return [];
}
if (hostDefaults.has(key) || portableDefaults.has(key)) {
return [];
}
return [`agents.defaults.${key}`];
});
unsupportedDefaults.push(
...unsupportedSubagentDefaultFields(defaults.subagents),
...unsupportedFields(
defaults.heartbeat,
["agentId", "every", "activeHours", "lightContext", "isolatedSession", "timeoutSeconds"],
"agents.defaults.heartbeat",
),
...unsupportedFields(
record(defaults.heartbeat)?.activeHours,
["start", "end", "timezone"],
"agents.defaults.heartbeat.activeHours",
),
...unsupportedFields(
defaults.sandbox,
["mode", "scope", "workspaceAccess"],
"agents.defaults.sandbox",
),
...unsupportedFields(
defaults.humanDelay,
["mode", "minMs", "maxMs"],
"agents.defaults.humanDelay",
),
);
if (unsupportedDefaults.length > 0) {
throw new ClawMigrationError(
"agent_default_setting_unsupported",
`Inherited agent settings cannot be represented by Claw v1: ${unsupportedDefaults.toSorted().join(", ")}. Keep this agent unmanaged or remove those defaults before migrating.`,
"$.agents.defaults",
);
}
const inheritedSubagents = {
...agent.subagents,
...(agent.subagents?.allowAgents === undefined && defaults.subagents?.allowAgents !== undefined
? { allowAgents: defaults.subagents.allowAgents }
: {}),
...(agent.subagents?.delegationMode === undefined &&
defaults.subagents?.delegationMode !== undefined
? { delegationMode: defaults.subagents.delegationMode }
: {}),
};
const inheritedHeartbeat = {
...agent.heartbeat,
...(agent.heartbeat?.every === undefined && defaults.heartbeat?.every !== undefined
? { every: defaults.heartbeat.every }
: {}),
...(agent.heartbeat?.activeHours === undefined && defaults.heartbeat?.activeHours !== undefined
? { activeHours: defaults.heartbeat.activeHours }
: {}),
...(agent.heartbeat?.lightContext === undefined &&
defaults.heartbeat?.lightContext !== undefined
? { lightContext: defaults.heartbeat.lightContext }
: {}),
...(agent.heartbeat?.isolatedSession === undefined &&
defaults.heartbeat?.isolatedSession !== undefined
? { isolatedSession: defaults.heartbeat.isolatedSession }
: {}),
...(agent.heartbeat?.timeoutSeconds === undefined &&
defaults.heartbeat?.timeoutSeconds !== undefined
? { timeoutSeconds: defaults.heartbeat.timeoutSeconds }
: {}),
};
const inheritedSandbox = {
...agent.sandbox,
...(agent.sandbox?.mode === undefined && defaults.sandbox?.mode !== undefined
? { mode: defaults.sandbox.mode }
: {}),
...(agent.sandbox?.scope === undefined && defaults.sandbox?.scope !== undefined
? { scope: defaults.sandbox.scope }
: {}),
...(agent.sandbox?.workspaceAccess === undefined &&
defaults.sandbox?.workspaceAccess !== undefined
? { workspaceAccess: defaults.sandbox.workspaceAccess }
: {}),
};
const inheritedHumanDelay = {
...agent.humanDelay,
...(agent.humanDelay?.mode === undefined && defaults.humanDelay?.mode !== undefined
? { mode: defaults.humanDelay.mode }
: {}),
...(agent.humanDelay?.minMs === undefined && defaults.humanDelay?.minMs !== undefined
? { minMs: defaults.humanDelay.minMs }
: {}),
...(agent.humanDelay?.maxMs === undefined && defaults.humanDelay?.maxMs !== undefined
? { maxMs: defaults.humanDelay.maxMs }
: {}),
};
const effectiveHeartbeat =
Object.keys(inheritedHeartbeat).length > 0 ? inheritedHeartbeat : undefined;
const defaultModel = defaults.model;
if (defaultModel === undefined) {
return {
...agent,
...(Object.keys(inheritedSubagents).length > 0 ? { subagents: inheritedSubagents } : {}),
...(effectiveHeartbeat ? { heartbeat: effectiveHeartbeat } : {}),
...(Object.keys(inheritedSandbox).length > 0 ? { sandbox: inheritedSandbox } : {}),
...(Object.keys(inheritedHumanDelay).length > 0 ? { humanDelay: inheritedHumanDelay } : {}),
};
}
const primary = modelPrimary(agent.model) ?? modelPrimary(defaultModel);
const hasAgentFallbacks = typeof agent.model === "object" && Array.isArray(agent.model.fallbacks);
const hasDefaultFallbacks =
typeof defaultModel === "object" && Array.isArray(defaultModel.fallbacks);
const fallbacks = hasAgentFallbacks ? modelFallbacks(agent.model) : modelFallbacks(defaultModel);
const effectiveModel =
typeof agent.model === "string" && !hasDefaultFallbacks
? agent.model
: {
...(typeof agent.model === "object" ? agent.model : {}),
...(primary ? { primary } : {}),
...(hasAgentFallbacks || hasDefaultFallbacks ? { fallbacks } : {}),
};
return {
...agent,
model: effectiveModel,
...(Object.keys(inheritedSubagents).length > 0 ? { subagents: inheritedSubagents } : {}),
...(effectiveHeartbeat ? { heartbeat: effectiveHeartbeat } : {}),
...(Object.keys(inheritedSandbox).length > 0 ? { sandbox: inheritedSandbox } : {}),
...(Object.keys(inheritedHumanDelay).length > 0 ? { humanDelay: inheritedHumanDelay } : {}),
};
}

View file

@ -0,0 +1,73 @@
import { createHash } from "node:crypto";
import { resolve } from "node:path";
import { root as fsSafeRoot } from "../infra/fs-safe.js";
import { ClawMigrationError } from "./migrate-errors.js";
import { lstatMigrationPathIfExists } from "./migrate-package.js";
import type { CapturedWorkspaceFile } from "./migrate-package.js";
import { containsPotentialSecret } from "./migrate-validation.js";
import { MAX_MANAGED_FILE_BYTES, MAX_MANAGED_WORKSPACE_BYTES } from "./source-limits.js";
import { CLAW_BOOTSTRAP_FILE_NAMES } from "./types.js";
const PROMPT_FILE_NAMES = [...CLAW_BOOTSTRAP_FILE_NAMES];
function sha256(value: Uint8Array): string {
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
}
export async function readSelectedWorkspaceFiles(
workspace: string,
): Promise<CapturedWorkspaceFile[]> {
const root = await fsSafeRoot(workspace, {
hardlinks: "reject",
maxBytes: MAX_MANAGED_FILE_BYTES,
symlinks: "reject",
});
const captured: CapturedWorkspaceFile[] = [];
let totalBytes = 0;
for (const name of PROMPT_FILE_NAMES) {
const info = await lstatMigrationPathIfExists(resolve(workspace, name));
if (!info) {
continue;
}
if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1) {
throw new ClawMigrationError(
"workspace_file_unsafe",
`${name} must be a regular, non-symlinked, non-hardlinked file.`,
`$.workspace.${name}`,
);
}
const read = await root.read(name, {
hardlinks: "reject",
maxBytes: MAX_MANAGED_FILE_BYTES,
nonBlockingRead: true,
symlinks: "reject",
});
let text: string;
try {
text = new TextDecoder("utf-8", { fatal: true }).decode(read.buffer);
} catch {
throw new ClawMigrationError(
"workspace_file_encoding_unsupported",
`${name} is not valid UTF-8 text and cannot be represented as an agent prompt file.`,
`$.workspace.${name}`,
);
}
if (containsPotentialSecret(text)) {
throw new ClawMigrationError(
"workspace_file_secret_detected",
`Potential secret material was found in ${name}. Remove it or leave this file unmanaged; the matching text was not displayed.`,
`$.workspace.${name}`,
);
}
totalBytes += read.buffer.byteLength;
if (totalBytes > MAX_MANAGED_WORKSPACE_BYTES) {
throw new ClawMigrationError(
"workspace_files_too_large",
`Selected prompt files exceed the ${MAX_MANAGED_WORKSPACE_BYTES}-byte Claw workspace limit.`,
"$.workspace",
);
}
captured.push({ name, content: read.buffer, digest: sha256(read.buffer) });
}
return captured;
}

385
src/claws/migrate.test.ts Normal file
View file

@ -0,0 +1,385 @@
import { access, mkdir, symlink, 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 {
DEFAULT_SUBAGENT_ARCHIVE_AFTER_MINUTES,
DEFAULT_SUBAGENT_MAX_CONCURRENT,
} from "../config/agent-limits.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js";
import { applyClawMigrationPlan, buildClawMigrationPlan, ClawMigrationError } from "./migrate.js";
import {
persistClawMigrationOwnership,
persistClawPackageRef,
readClawInstallRecord,
} from "./provenance.js";
import { CLAW_WORKSPACE_FILE_RECORD_SCHEMA_VERSION, upsertClawWorkspaceFile } from "./workspace.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
afterEach(() => closeOpenClawStateDatabaseForTest());
async function fixture(agent: Record<string, unknown> = {}) {
const root = tempDirs.make("openclaw-claw-migrate-");
const workspace = join(root, "workspace");
await mkdir(workspace, { recursive: true });
await writeFile(join(workspace, "AGENTS.md"), "# Existing agent\n", "utf8");
await writeFile(join(workspace, "SOUL.md"), "Use the current voice.\n", "utf8");
await writeFile(join(workspace, "unrelated.json"), '{"keep":true}\n', "utf8");
const env = {
...process.env,
HOME: root,
OPENCLAW_HOME: root,
OPENCLAW_STATE_DIR: join(root, "state"),
};
const config = {
agents: {
entries: {
worker: { workspace, ...agent },
},
},
} as unknown as OpenClawConfig;
return { root, workspace, env, config };
}
describe("Claw migration planning", () => {
it("builds a stable read-only plan without creating a package or state database", async () => {
const { workspace, env, config } = await fixture({
name: "Existing worker",
heartbeat: { every: "30m" },
});
const first = await buildClawMigrationPlan({
agentId: "worker",
config,
options: { env },
});
const second = await buildClawMigrationPlan({
agentId: "worker",
config,
options: { env },
});
expect(first.plan).toMatchObject({
schemaVersion: "openclaw.clawMigrationPlan.v1",
dryRun: true,
mutationAllowed: false,
agentId: "worker",
workspace,
workspaceFiles: [
{ path: join(workspace, "AGENTS.md") },
{ path: join(workspace, "SOUL.md") },
],
generatedPackageFiles: [
{ path: "CLAW.md" },
{ path: "package.json" },
{ path: "profiles/openclaw.yml" },
{ path: "workspace/AGENTS.md" },
],
});
expect(first.plan.planIntegrity).toBe(second.plan.planIntegrity);
await expect(access(first.plan.packageRoot)).rejects.toMatchObject({ code: "ENOENT" });
await expect(access(resolveOpenClawStateSqlitePath(env))).rejects.toMatchObject({
code: "ENOENT",
});
});
it("changes the plan when a selected workspace file changes", async () => {
const { workspace, env, config } = await fixture();
const plan = await buildClawMigrationPlan({ agentId: "worker", config, options: { env } });
await writeFile(join(workspace, "AGENTS.md"), "# Updated agent\n", "utf8");
const changed = await buildClawMigrationPlan({ agentId: "worker", config, options: { env } });
expect(changed.plan.planIntegrity).not.toBe(plan.plan.planIntegrity);
});
it("fails closed when a selected prompt file contains likely secret material", async () => {
const { workspace, env, config } = await fixture();
await writeFile(join(workspace, "TOOLS.md"), "api_key = abcdef0123456789abcdef\n", "utf8");
await expect(
buildClawMigrationPlan({ agentId: "worker", config, options: { env } }),
).rejects.toMatchObject({
code: "workspace_file_secret_detected",
path: "$.workspace.TOOLS.md",
});
});
it("fails closed when a selected prompt file is a symbolic link", async () => {
const { root, workspace, env, config } = await fixture();
const outside = join(root, "outside.md");
await writeFile(outside, "# Outside file\n", "utf8");
await symlink(outside, join(workspace, "IDENTITY.md"));
await expect(
buildClawMigrationPlan({ agentId: "worker", config, options: { env } }),
).rejects.toMatchObject({
code: "workspace_file_unsafe",
path: "$.workspace.IDENTITY.md",
});
});
it("reports unsupported settings and ambiguous workspace ownership", async () => {
const { workspace, env, config } = await fixture({ skills: ["local-only"] });
await expect(
buildClawMigrationPlan({ agentId: "worker", config, options: { env } }),
).rejects.toMatchObject({ code: "agent_setting_unsupported" });
const ambiguous = {
agents: {
entries: {
worker: { workspace },
child: { workspace: join(workspace, "child") },
},
},
} as unknown as OpenClawConfig;
await expect(
buildClawMigrationPlan({ agentId: "worker", config: ambiguous, options: { env } }),
).rejects.toMatchObject({ code: "workspace_ownership_ambiguous" });
});
it("resolves symlink aliases when checking another agent's workspace ownership", async () => {
const { root, workspace, env } = await fixture();
const child = join(workspace, "child");
const alias = join(root, "workspace-alias");
await mkdir(child);
await symlink(child, alias);
const config = {
agents: {
entries: {
worker: { workspace },
child: { workspace: alias },
},
},
} as unknown as OpenClawConfig;
await expect(
buildClawMigrationPlan({ agentId: "worker", config, options: { env } }),
).rejects.toMatchObject({ code: "workspace_ownership_ambiguous" });
});
it("does not place the generated package inside another agent's workspace", async () => {
const { workspace, env } = await fixture();
const stateWorkspace = join(env.OPENCLAW_STATE_DIR, "claws");
await mkdir(stateWorkspace, { recursive: true });
const config = {
agents: {
entries: {
worker: { workspace },
other: { workspace: stateWorkspace },
},
},
} as unknown as OpenClawConfig;
await expect(
buildClawMigrationPlan({ agentId: "worker", config, options: { env } }),
).rejects.toMatchObject({ code: "package_destination_owned_by_agent" });
});
it("rejects orphan secondary Claw refs during planning and the ownership transaction", async () => {
const { env, config } = await fixture();
const migration = await buildClawMigrationPlan({
agentId: "worker",
config,
options: { env },
});
persistClawPackageRef(
migration.addPlan,
{
kind: "plugin",
source: "clawhub",
ref: "audit",
version: "1.0.0",
integrity: `sha256:${"a".repeat(64)}`,
},
{ env },
);
await expect(
buildClawMigrationPlan({ agentId: "worker", config, options: { env } }),
).rejects.toMatchObject({ code: "secondary_resources_unclaimed" });
expect(() =>
persistClawMigrationOwnership(migration.addPlan, migration.ownershipFiles, { env }),
).toThrow(/unclaimed Claw resource references/u);
expect(readClawInstallRecord("worker", { env })).toBeUndefined();
});
it("rejects orphan workspace ownership rows during planning and the ownership transaction", async () => {
const { workspace, env, config } = await fixture();
const migration = await buildClawMigrationPlan({
agentId: "worker",
config,
options: { env },
});
upsertClawWorkspaceFile(
{
schemaVersion: CLAW_WORKSPACE_FILE_RECORD_SCHEMA_VERSION,
agentId: "worker",
workspace,
path: join(workspace, "unrelated.json"),
sourcePath: "workspace/unrelated.json",
contentDigest: `sha256:${"b".repeat(64)}`,
status: "complete",
createdAtMs: 1,
updatedAtMs: 1,
},
{ env },
);
expect(() =>
persistClawMigrationOwnership(migration.addPlan, migration.ownershipFiles, { env }),
).toThrow(/unclaimed Claw workspace-file ownership record/u);
expect(readClawInstallRecord("worker", { env })).toBeUndefined();
await expect(
buildClawMigrationPlan({ agentId: "worker", config, options: { env } }),
).rejects.toMatchObject({ code: "workspace_ownership_unclaimed" });
});
it("captures a representable inherited default model in the generated package", async () => {
const { workspace, env } = await fixture();
const config = {
agents: {
defaults: {
model: { primary: "provider/default", fallbacks: ["provider/fallback"] },
compaction: { mode: "safeguard" },
subagents: {
allowAgents: ["researcher"],
delegationMode: "prefer",
maxConcurrent: DEFAULT_SUBAGENT_MAX_CONCURRENT,
archiveAfterMinutes: DEFAULT_SUBAGENT_ARCHIVE_AFTER_MINUTES,
},
heartbeat: { agentId: "worker", every: "45m" },
sandbox: { mode: "non-main", scope: "agent", workspaceAccess: "rw" },
humanDelay: { mode: "custom", minMs: 100, maxMs: 300 },
},
entries: { worker: { workspace } },
},
} as unknown as OpenClawConfig;
const migration = await buildClawMigrationPlan({
agentId: "worker",
config,
options: { env },
});
expect(migration.profile?.agent.model).toEqual({
primary: "provider/default",
fallbacks: ["provider/fallback"],
});
expect(migration.addPlan.agent.config.model).toEqual(migration.profile?.agent.model);
expect(migration.profile?.agent).toMatchObject({
subagents: { allowAgents: ["researcher"], delegationMode: "prefer" },
heartbeat: { every: "45m" },
sandbox: { mode: "non-main", scope: "agent", workspaceAccess: "rw" },
humanDelay: { mode: "custom", minMs: 100, maxMs: 300 },
});
expect(migration.profile?.agent.heartbeat).not.toHaveProperty("agentId");
});
it("migrates a representable string model setting", async () => {
const { env, config } = await fixture({ model: "provider/model" });
const migration = await buildClawMigrationPlan({
agentId: "worker",
config,
options: { env },
});
expect(migration.profile?.agent.model).toEqual({ primary: "provider/model" });
});
it("fails closed for inherited agent defaults Claw v1 cannot represent", async () => {
const { workspace, env } = await fixture();
const config = {
agents: {
defaults: {
compaction: { mode: "default" },
params: { temperature: 0.2 },
skills: ["local-only"],
},
entries: { worker: { workspace } },
},
} as unknown as OpenClawConfig;
await expect(
buildClawMigrationPlan({ agentId: "worker", config, options: { env } }),
).rejects.toMatchObject({
code: "agent_default_setting_unsupported",
message: expect.stringContaining("agents.defaults.compaction"),
});
await expect(
buildClawMigrationPlan({ agentId: "worker", config, options: { env } }),
).rejects.toMatchObject({
code: "agent_default_setting_unsupported",
message: expect.stringContaining("agents.defaults.params"),
});
await expect(
buildClawMigrationPlan({ agentId: "worker", config, options: { env } }),
).rejects.toMatchObject({
message: expect.stringContaining("agents.defaults.skills"),
});
});
it("rejects non-default inherited subagent limits that Claw v1 cannot preserve", async () => {
const { workspace, env } = await fixture();
const config = {
agents: {
defaults: { subagents: { maxConcurrent: 3, archiveAfterMinutes: 90 } },
entries: { worker: { workspace } },
},
} as unknown as OpenClawConfig;
await expect(
buildClawMigrationPlan({ agentId: "worker", config, options: { env } }),
).rejects.toMatchObject({
code: "agent_default_setting_unsupported",
message: expect.stringContaining("agents.defaults.subagents.archiveAfterMinutes"),
});
});
it("rejects selected workspace changes after consent and cleans the generated package", async () => {
const { workspace, env, config } = await fixture();
const migration = await buildClawMigrationPlan({
agentId: "worker",
config,
options: { env },
});
await writeFile(join(workspace, "AGENTS.md"), "# Changed after consent\n", "utf8");
await expect(
applyClawMigrationPlan({ migration, config, options: { env } }),
).rejects.toMatchObject({ code: "workspace_file_changed_after_consent" });
await expect(access(migration.plan.packageRoot)).rejects.toMatchObject({ code: "ENOENT" });
await expect(access(resolveOpenClawStateSqlitePath(env))).rejects.toMatchObject({
code: "ENOENT",
});
});
it("does not disclose secret values in diagnostics", async () => {
const { workspace, env, config } = await fixture();
const secret = "ghp_0123456789abcdefghijklmnopqrstuv";
await writeFile(join(workspace, "HEARTBEAT.md"), `token=${secret}\n`, "utf8");
let failure: unknown;
try {
await buildClawMigrationPlan({ agentId: "worker", config, options: { env } });
} catch (error) {
failure = error;
}
expect(failure).toBeInstanceOf(ClawMigrationError);
expect(String(failure)).not.toContain(secret);
});
it.each([
"Authorization: Bearer bearer-token-value-that-must-not-leak",
`Authorization: Basic ${Buffer.from("synthetic-user:synthetic-password").toString("base64")}`,
"session JWT eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signaturepayloadvalue",
"AWS_SECRET_ACCESS_KEY=0123456789abcdef0123456789abcdef01234567",
"GITHUB_TOKEN=ghp_0123456789abcdefghijklmnopqrstuv",
])("rejects common workspace credential formats", async (secret) => {
const { workspace, env, config } = await fixture();
await writeFile(join(workspace, "HEARTBEAT.md"), `${secret}\n`, "utf8");
let failure: unknown;
try {
await buildClawMigrationPlan({ agentId: "worker", config, options: { env } });
} catch (error) {
failure = error;
}
expect(failure).toBeInstanceOf(ClawMigrationError);
expect(String(failure)).not.toContain(secret);
});
});

603
src/claws/migrate.ts Normal file
View file

@ -0,0 +1,603 @@
import { createHash } from "node:crypto";
import { lstat, realpath } from "node:fs/promises";
import { relative, resolve, sep } from "node:path";
import { listAgentEntries, resolveAgentWorkspaceDir } from "../agents/agent-scope-config.js";
import { resolveStateDir } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveIdentityPathViaExistingAncestorSync } from "../infra/boundary-path.js";
import { isAvatarDataUrl } from "../shared/avatar-policy.js";
import { tableExists } from "../state/openclaw-state-db-schema-helpers.js";
import { openExistingOpenClawStateDatabaseReadOnly } from "../state/openclaw-state-db.js";
import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js";
import { digestClawValue } from "./digest.js";
import { buildClawAddPlan } from "./lifecycle.js";
import { ClawMigrationError } from "./migrate-errors.js";
import {
createGeneratedPackage,
createPackagePreview,
generatedPackage,
lstatMigrationPathIfExists,
packageIdentityDigest,
removeGeneratedPackageIfUnchanged,
removePackagePreview,
} from "./migrate-package.js";
import {
assertPackageDestinationOutsideWorkspaces,
assertWorkspaceSnapshotUnchanged,
clawMigrationPathsOverlap,
} from "./migrate-safety.js";
import {
inspectValueForSecret,
normalizeWorkspaceConfig,
resolveMigrationAgentSettings,
validateAgentConfigKeys,
} from "./migrate-validation.js";
import { readSelectedWorkspaceFiles } from "./migrate-workspace-files.js";
import { readClawSecondaryReferenceTables } from "./provenance-secondary-references.js";
import {
persistClawMigrationOwnership,
readClawInstallRecordFromDatabase,
readClawInstallRecords,
} from "./provenance.js";
import { readClawManifestFile } from "./reader.js";
import { isPortableClawAvatar } from "./schema-portability.js";
import type { ClawManifest, ClawOpenClawProfile } from "./types.js";
import {
CLAW_WORKSPACE_FILE_RECORD_SCHEMA_VERSION,
type PersistedClawWorkspaceFile,
readAllClawWorkspaceFiles,
readClawWorkspaceFiles,
} from "./workspace.js";
export const CLAW_MIGRATION_PLAN_SCHEMA_VERSION = "openclaw.clawMigrationPlan.v1" as const;
const CLAW_MIGRATION_RESULT_SCHEMA_VERSION = "openclaw.clawMigrationResult.v1" as const;
const AGENT_ID_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/;
const MIGRATION_RETAINED_PATHS = [
"BOOTSTRAP.md (one-time workspace seed)",
"credentials and auth state",
"session indexes and transcripts",
"agent databases and runtime state",
"all other workspace files and directories",
];
type ClawMigrationPlan = {
schemaVersion: typeof CLAW_MIGRATION_PLAN_SCHEMA_VERSION;
stability: "experimental";
dryRun: true;
mutationAllowed: false;
agentId: string;
workspace: string;
packageRoot: string;
packageName: string;
agent: ClawManifest["agent"];
openClawProfile?: ClawOpenClawProfile;
generatedPackageFiles: Array<{ path: string; byteLength: number; digest: string }>;
workspaceFiles: Array<{ path: string; byteLength: number; digest: string }>;
retained: string[];
planIntegrity: string;
blockers: Array<{ code: string; path: string; message: string }>;
};
export type ClawMigrationResult = {
schemaVersion: typeof CLAW_MIGRATION_RESULT_SCHEMA_VERSION;
stability: "experimental";
dryRun: false;
status: "complete";
agentId: string;
workspace: string;
packageRoot: string;
planIntegrity: string;
};
export { ClawMigrationError } from "./migrate-errors.js";
type BuiltMigration = {
plan: ClawMigrationPlan;
addPlan: Awaited<ReturnType<typeof buildClawAddPlan>>;
manifest: ClawManifest;
profile?: ClawOpenClawProfile;
clawMarkdownBody?: Buffer;
packageFiles: Map<string, Buffer>;
ownershipFiles: PersistedClawWorkspaceFile[];
};
function sha256(value: Uint8Array): string {
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
}
async function readOwnership(options: OpenClawStateDatabaseOptions, agentId: string) {
const database = await openExistingOpenClawStateDatabaseReadOnly(options);
if (!database) {
return {
install: undefined,
workspaceFiles: [],
installs: [],
allWorkspaceFiles: [],
secondaryReferences: [],
};
}
try {
const db = database.db;
const readOptions = { ...options, database, readOnly: true };
const hasInstallTable = tableExists(db, "claw_installs");
const hasWorkspaceFileTable = tableExists(db, "claw_workspace_files");
return {
install: hasInstallTable ? readClawInstallRecordFromDatabase(db, agentId) : undefined,
workspaceFiles: hasWorkspaceFileTable ? readClawWorkspaceFiles(agentId, readOptions) : [],
installs: hasInstallTable ? readClawInstallRecords(readOptions) : [],
allWorkspaceFiles: hasWorkspaceFileTable ? readAllClawWorkspaceFiles(readOptions) : [],
secondaryReferences: readClawSecondaryReferenceTables(db, agentId),
};
} finally {
database.walMaintenance.close();
}
}
async function canonicalExistingWorkspace(path: string): Promise<string> {
const absolute = resolve(path);
const stat = await lstat(absolute).catch(() => undefined);
if (!stat || !stat.isDirectory() || stat.isSymbolicLink()) {
throw new ClawMigrationError(
"workspace_unavailable",
`Workspace ${JSON.stringify(absolute)} must already exist as a local directory and cannot be a symlink.`,
"$.workspace",
);
}
return await realpath(absolute);
}
function sanitizeAgentPreview(agent: ClawManifest["agent"]): ClawManifest["agent"] {
const avatar = agent.identity?.avatar;
if (!avatar?.startsWith("data:image/")) {
return agent;
}
return {
...agent,
identity: {
...agent.identity,
avatar: `image data URL (${Buffer.byteLength(avatar, "utf8")} bytes; ${sha256(Buffer.from(avatar))})`,
},
};
}
function buildPlanIntegrity(
addPlan: Awaited<ReturnType<typeof buildClawAddPlan>>,
packageFiles: Map<string, Buffer>,
) {
return digestClawValue({
schemaVersion: CLAW_MIGRATION_PLAN_SCHEMA_VERSION,
addPlan,
packageFiles: [...packageFiles.entries()]
.map(([path, content]) => ({ path, digest: sha256(content), byteLength: content.byteLength }))
.toSorted((left, right) => left.path.localeCompare(right.path)),
retained: MIGRATION_RETAINED_PATHS,
});
}
export async function buildClawMigrationPlan(params: {
agentId: string;
config: OpenClawConfig;
options?: OpenClawStateDatabaseOptions;
}): Promise<BuiltMigration> {
const options = params.options ?? {};
const agentId = params.agentId;
if (!AGENT_ID_PATTERN.test(agentId)) {
throw new ClawMigrationError(
"invalid_agent_id",
`Agent id ${JSON.stringify(agentId)} is not a valid Claw agent id.`,
"$.agent.id",
);
}
const agents = listAgentEntries(params.config).filter((entry) => entry.id === agentId);
if (agents.length !== 1) {
throw new ClawMigrationError(
agents.length === 0 ? "agent_not_found" : "agent_ambiguous",
agents.length === 0
? `No configured local agent matches ${JSON.stringify(agentId)}.`
: `More than one configured agent resolves to ${JSON.stringify(agentId)}; resolve the duplicate ownership before migrating.`,
"$.agent.id",
);
}
const agent = agents[0]!;
validateAgentConfigKeys(agent);
const migrationAgent = resolveMigrationAgentSettings(params.config, agent);
const ownership = await readOwnership(options, agentId);
if (ownership.install) {
throw new ClawMigrationError(
"agent_already_managed",
`Agent ${JSON.stringify(agentId)} already has Claw ownership. Use claws status/update/remove instead of migrating it again.`,
);
}
if (ownership.secondaryReferences.length > 0) {
throw new ClawMigrationError(
"secondary_resources_unclaimed",
`Agent ${JSON.stringify(agentId)} has unclaimed Claw resource references in ${ownership.secondaryReferences.join(", ")}. Reconcile those resources before migrating.`,
"$.agent.id",
);
}
if (ownership.workspaceFiles.length > 0) {
throw new ClawMigrationError(
"workspace_ownership_unclaimed",
`Agent ${JSON.stringify(agentId)} has Claw workspace-file ownership records without an install record. Reconcile those records before migrating.`,
"$.workspace",
);
}
const configuredWorkspace = resolveAgentWorkspaceDir(params.config, agentId, options.env);
const workspace = await canonicalExistingWorkspace(configuredWorkspace);
const packageRoot = resolveIdentityPathViaExistingAncestorSync(
resolve(resolveStateDir(options.env), "claws", "local", agentId),
);
if (await lstatMigrationPathIfExists(packageRoot)) {
throw new ClawMigrationError(
"package_destination_exists",
`Local Claw package destination ${JSON.stringify(packageRoot)} already exists. Move or inspect it before migrating.`,
"$.packageRoot",
);
}
const configuredAgents = listAgentEntries(params.config);
assertPackageDestinationOutsideWorkspaces({
agentId,
packageRoot,
workspace,
configuredAgents,
installs: ownership.installs,
config: params.config,
env: options.env,
});
for (const other of configuredAgents) {
if (other.id === agentId) {
continue;
}
const otherWorkspace = resolveIdentityPathViaExistingAncestorSync(
resolveAgentWorkspaceDir(params.config, other.id, options.env),
);
if (clawMigrationPathsOverlap(workspace, otherWorkspace)) {
throw new ClawMigrationError(
"workspace_ownership_ambiguous",
`Workspace ${JSON.stringify(workspace)} overlaps agent ${JSON.stringify(other.id)} at ${JSON.stringify(otherWorkspace)}. Resolve workspace ownership before migrating.`,
"$.workspace",
);
}
}
const installOverlap = ownership.installs.find(
(record) =>
record.agentId !== agentId &&
clawMigrationPathsOverlap(
workspace,
resolveIdentityPathViaExistingAncestorSync(record.workspace),
),
);
if (installOverlap) {
throw new ClawMigrationError(
"workspace_owned_by_claw",
`Workspace ${JSON.stringify(workspace)} is already tracked by Claw agent ${JSON.stringify(installOverlap.agentId)}.`,
"$.workspace",
);
}
const selectedFiles = await readSelectedWorkspaceFiles(workspace);
const selectedPaths = new Set<string>(selectedFiles.map((file) => file.name));
const conflictingFile = ownership.allWorkspaceFiles.find(
(file) =>
file.agentId !== agentId &&
resolveIdentityPathViaExistingAncestorSync(file.workspace) === workspace &&
selectedPaths.has(file.path),
);
if (conflictingFile) {
throw new ClawMigrationError(
"workspace_file_already_owned",
`${conflictingFile.path} is already tracked by Claw agent ${JSON.stringify(conflictingFile.agentId)}.`,
`$.workspace.${conflictingFile.path}`,
);
}
const avatar = agent.identity?.avatar?.trim();
const portableAvatar =
avatar && isAvatarDataUrl(avatar) && isPortableClawAvatar(avatar) ? avatar : undefined;
const projected = generatedPackage(agentId, {
agent: migrationAgent,
avatar: portableAvatar,
files: selectedFiles,
});
if (inspectValueForSecret({ agent: projected.manifest.agent, profile: projected.profile })) {
throw new ClawMigrationError(
"agent_setting_secret_detected",
"Potential secret material was found in a Claw v1 agent setting. The matching value was not displayed; remove it or keep the setting unmanaged.",
"$.agent",
);
}
const packagePreview = await createPackagePreview(projected.packageFiles);
let loaded: Extract<Awaited<ReturnType<typeof readClawManifestFile>>, { ok: true }>;
try {
const read = await readClawManifestFile(packagePreview);
if (!read.ok) {
throw new ClawMigrationError(
"generated_package_invalid",
read.diagnostics.map((diagnostic) => diagnostic.message).join("; "),
read.diagnostics[0]?.path,
);
}
loaded = read;
const existingWorkspacePaths = configuredAgents
.filter((entry) => entry.id !== agentId)
.map((entry) => resolveAgentWorkspaceDir(params.config, entry.id, options.env));
const packageIdentity = packageIdentityDigest(projected.packageFiles);
const source = {
...loaded.source,
integrity: packageIdentity.integrity,
byteLength: packageIdentity.byteLength,
};
const addPlan = await buildClawAddPlan({
manifest: loaded.manifest,
clawMarkdownBody: loaded.clawMarkdownBody,
openClawProfile: loaded.openClawProfile,
source,
context: {
config: params.config,
agentId,
workspace,
existingAgentIds: configuredAgents
.filter((entry) => entry.id !== agentId)
.map((entry) => entry.id),
existingWorkspacePaths,
resumableWorkspace: workspace,
sourceReferenceRoot: packageRoot,
},
});
if (addPlan.blockers.length > 0) {
const first = addPlan.blockers[0]!;
throw new ClawMigrationError(first.code, first.message, first.path);
}
const comparablePlanAgent = normalizeWorkspaceConfig(migrationAgent, workspace);
if (digestClawValue(comparablePlanAgent) !== digestClawValue(addPlan.agent.config)) {
throw new ClawMigrationError(
"agent_settings_not_faithful",
"The generated Claw v1 manifest would not reproduce the configured agent settings exactly. Review unsupported fields and defaults before migrating.",
"$.agent",
);
}
for (const fileAction of addPlan.actions.filter((action) => action.kind === "workspaceFile")) {
const name = fileAction.id;
const captured = selectedFiles.find((file) => file.name === name);
if (!captured || fileAction.digest !== captured.digest) {
throw new ClawMigrationError(
"workspace_file_projection_changed",
`The Claw package would not preserve the exact bytes of ${JSON.stringify(name)}.`,
`$.workspace.${name}`,
);
}
}
addPlan.actions = addPlan.actions.map((action) => ({
...action,
action:
action.kind === "workspaceFile" || action.kind === "agent" || action.kind === "workspace"
? "reuse"
: action.action,
...(action.kind === "agent"
? { details: { ...action.details, expectedState: "present" } }
: {}),
...(action.kind === "workspace"
? { details: { ...action.details, expectedState: "present" } }
: {}),
...(action.kind === "workspaceFile"
? { details: { ...action.details, expectedState: "present-matching" } }
: {}),
}));
const planIntegrity = buildPlanIntegrity(addPlan, projected.packageFiles);
addPlan.planIntegrity = planIntegrity;
const plan: ClawMigrationPlan = {
schemaVersion: CLAW_MIGRATION_PLAN_SCHEMA_VERSION,
stability: "experimental",
dryRun: true,
mutationAllowed: false,
agentId,
workspace,
packageRoot,
packageName: source.name,
agent: projected.manifest.agent,
...(projected.profile ? { openClawProfile: projected.profile } : {}),
generatedPackageFiles: [...projected.packageFiles.entries()]
.map(([path, content]) => ({
path,
byteLength: content.byteLength,
digest: sha256(content),
}))
.toSorted((left, right) => left.path.localeCompare(right.path)),
workspaceFiles: selectedFiles.map(({ name, content, digest }) => ({
path: resolve(workspace, name),
byteLength: content.byteLength,
digest,
})),
retained: [...MIGRATION_RETAINED_PATHS],
planIntegrity,
blockers: [],
};
const ownershipFiles = addPlan.actions
.filter((action) => action.kind === "workspaceFile")
.map((action) => {
const sourcePath = action.source
? relative(plan.packageRoot, action.source).replaceAll(sep, "/")
: "";
const path = relative(workspace, action.target).replaceAll(sep, "/");
if (!sourcePath || path.startsWith("../") || sourcePath.startsWith("../")) {
throw new ClawMigrationError(
"migration_path_invalid",
`Could not bind ${JSON.stringify(action.id)} to the generated local package.`,
`$.workspace.${action.id}`,
);
}
return {
schemaVersion: CLAW_WORKSPACE_FILE_RECORD_SCHEMA_VERSION,
agentId,
workspace,
path,
sourcePath,
contentDigest: action.digest!,
status: "complete" as const,
createdAtMs: 0,
updatedAtMs: 0,
};
});
return {
plan: {
...plan,
agent: sanitizeAgentPreview(plan.agent),
},
addPlan,
manifest: loaded.manifest,
...(loaded.openClawProfile ? { profile: loaded.openClawProfile } : {}),
...(loaded.clawMarkdownBody ? { clawMarkdownBody: loaded.clawMarkdownBody } : {}),
packageFiles: projected.packageFiles,
ownershipFiles,
};
} finally {
await removePackagePreview(packagePreview);
}
}
export async function applyClawMigrationPlan(params: {
migration: BuiltMigration;
config: OpenClawConfig;
options?: OpenClawStateDatabaseOptions;
assertCurrentConfig?: () => Promise<void>;
}): Promise<ClawMigrationResult> {
const options = params.options ?? {};
const root = params.migration.plan.packageRoot;
const currentWorkspace = await canonicalExistingWorkspace(
resolveAgentWorkspaceDir(params.config, params.migration.plan.agentId, options.env),
);
if (currentWorkspace !== params.migration.plan.workspace) {
throw new ClawMigrationError(
"migration_changed",
"The configured workspace changed after consent. Rerun migrate and review the new plan.",
);
}
const currentOwnership = await readOwnership(options, params.migration.plan.agentId);
if (currentOwnership.secondaryReferences.length > 0) {
throw new ClawMigrationError(
"secondary_resources_unclaimed",
`Agent ${JSON.stringify(params.migration.plan.agentId)} now has unclaimed Claw resource references in ${currentOwnership.secondaryReferences.join(", ")}; rerun migrate after reconciling them.`,
"$.agent.id",
);
}
assertPackageDestinationOutsideWorkspaces({
agentId: params.migration.plan.agentId,
packageRoot: root,
workspace: currentWorkspace,
configuredAgents: listAgentEntries(params.config),
installs: currentOwnership.installs,
config: params.config,
env: options.env,
});
if (await lstatMigrationPathIfExists(root)) {
throw new ClawMigrationError(
"package_destination_exists",
`Generated package destination ${JSON.stringify(root)} appeared after planning; rerun migrate to review a fresh plan.`,
"$.packageRoot",
);
}
await params.assertCurrentConfig?.();
await createGeneratedPackage(root, params.migration.packageFiles);
try {
await assertWorkspaceSnapshotUnchanged(
currentWorkspace,
params.migration.ownershipFiles,
readSelectedWorkspaceFiles,
);
const read = await readClawManifestFile(root);
if (!read.ok) {
throw new ClawMigrationError(
"generated_package_invalid",
read.diagnostics.map((diagnostic) => diagnostic.message).join("; "),
);
}
const identity = packageIdentityDigest(params.migration.packageFiles);
const source = {
...read.source,
integrity: identity.integrity,
byteLength: identity.byteLength,
};
const finalPlan = await buildClawAddPlan({
manifest: read.manifest,
clawMarkdownBody: read.clawMarkdownBody,
openClawProfile: read.openClawProfile,
source,
context: {
config: params.config,
agentId: params.migration.plan.agentId,
workspace: params.migration.plan.workspace,
existingAgentIds: listAgentEntries(params.config)
.filter((entry) => entry.id !== params.migration.plan.agentId)
.map((entry) => entry.id),
existingWorkspacePaths: listAgentEntries(params.config)
.filter((entry) => entry.id !== params.migration.plan.agentId)
.map((entry) => resolveAgentWorkspaceDir(params.config, entry.id, options.env)),
resumableWorkspace: params.migration.plan.workspace,
sourceReferenceRoot: root,
},
});
for (const fileAction of finalPlan.actions.filter(
(action) => action.kind === "workspaceFile",
)) {
const expected = params.migration.ownershipFiles.find((file) => file.path === fileAction.id);
if (!expected || fileAction.digest !== expected.contentDigest) {
throw new ClawMigrationError(
"workspace_file_projection_changed",
`The existing ${JSON.stringify(fileAction.id)} changed after consent. Rerun migrate to review the current file.`,
`$.workspace.${fileAction.id}`,
);
}
}
finalPlan.actions = finalPlan.actions.map((action) => ({
...action,
action:
action.kind === "workspaceFile" || action.kind === "agent" || action.kind === "workspace"
? "reuse"
: action.action,
...(action.kind === "agent"
? { details: { ...action.details, expectedState: "present" } }
: {}),
...(action.kind === "workspace"
? { details: { ...action.details, expectedState: "present" } }
: {}),
...(action.kind === "workspaceFile"
? { details: { ...action.details, expectedState: "present-matching" } }
: {}),
}));
const finalIntegrity = buildPlanIntegrity(finalPlan, params.migration.packageFiles);
if (finalIntegrity !== params.migration.plan.planIntegrity) {
throw new ClawMigrationError(
"migration_changed",
"The agent, workspace files, or local ownership changed after consent. The generated package was removed; rerun migrate and review the new plan.",
);
}
// Consent uses a path-independent package digest. Persist the reader's actual
// source identity so a later update of this package resolves to the same source.
finalPlan.claw = read.source;
finalPlan.planIntegrity = finalIntegrity;
await assertWorkspaceSnapshotUnchanged(
currentWorkspace,
params.migration.ownershipFiles,
readSelectedWorkspaceFiles,
);
const finalOwnershipFiles = params.migration.ownershipFiles.map((file) => ({
...file,
createdAtMs: Date.now(),
updatedAtMs: Date.now(),
}));
await params.assertCurrentConfig?.();
persistClawMigrationOwnership(finalPlan, finalOwnershipFiles, options);
return {
schemaVersion: CLAW_MIGRATION_RESULT_SCHEMA_VERSION,
stability: "experimental",
dryRun: false,
status: "complete",
agentId: params.migration.plan.agentId,
workspace: params.migration.plan.workspace,
packageRoot: root,
planIntegrity: finalIntegrity,
};
} catch (error) {
await removeGeneratedPackageIfUnchanged(root, params.migration.packageFiles);
throw error;
}
}

View file

@ -0,0 +1,200 @@
import type { DatabaseSync } from "node:sqlite";
import {
executeSqliteQuerySync,
executeSqliteQueryTakeFirstSync,
getNodeSqliteKysely,
} from "../infra/kysely-sync.js";
import type { DB } from "../state/openclaw-state-db.generated.js";
import {
runOpenClawStateWriteTransaction,
type OpenClawStateDatabaseOptions,
} from "../state/openclaw-state-db.js";
import { digestClawValue } from "./digest.js";
import {
CLAW_INSTALL_RECORD_ADOPTED_SCHEMA_VERSION,
encodeClawAgentOwnership,
} from "./provenance-agent-origin.js";
import {
cacheClawInstallSchemaVersion,
deleteCachedClawInstallSchemaVersion,
} from "./provenance-runtime-read.js";
import { readClawSecondaryReferenceTables } from "./provenance-secondary-references.js";
import type { PersistedClawInstall } from "./provenance-types.js";
import type { ClawAddPlan } from "./types.js";
import type { PersistedClawWorkspaceFile } from "./workspace.js";
type ClawAdoptedDatabase = Pick<DB, "claw_installs" | "claw_workspace_files">;
function agentOwnedPaths(plan: ClawAddPlan): string[] {
return plan.actions.filter((action) => action.kind === "agent").map((action) => action.target);
}
/** Atomically records a migration's adopted agent and already-present workspace files. */
export function persistClawMigrationOwnershipWithInstallRecordReader(
plan: ClawAddPlan,
workspaceFiles: PersistedClawWorkspaceFile[],
readInstallRecord: (db: DatabaseSync, agentId: string) => PersistedClawInstall | undefined,
options: OpenClawStateDatabaseOptions & { nowMs?: number } = {},
): PersistedClawInstall {
const nowMs = options.nowMs ?? Date.now();
const agentConfigDigest = digestClawValue(plan.agent.config);
const ownedPaths = agentOwnedPaths(plan);
const ownership = encodeClawAgentOwnership(ownedPaths, "adopted");
const record = runOpenClawStateWriteTransaction(({ db }) => {
if (readInstallRecord(db, plan.agent.finalId)) {
throw new Error(
`Agent ${JSON.stringify(plan.agent.finalId)} already has Claw ownership; inspect claws status before migrating.`,
);
}
const secondaryReferences = readClawSecondaryReferenceTables(db, plan.agent.finalId);
if (secondaryReferences.length > 0) {
throw new Error(
`Agent ${JSON.stringify(plan.agent.finalId)} has unclaimed Claw resource references in ${secondaryReferences.join(", ")}; reconcile them before migration.`,
);
}
const existingWorkspaceOwnership = executeSqliteQueryTakeFirstSync(
db,
getNodeSqliteKysely<ClawAdoptedDatabase>(db)
.selectFrom("claw_workspace_files")
.select("target_path")
.where("agent_id", "=", plan.agent.finalId)
.limit(1),
);
if (existingWorkspaceOwnership) {
throw new Error(
`Agent ${JSON.stringify(plan.agent.finalId)} has an unclaimed Claw workspace-file ownership record for ${JSON.stringify(existingWorkspaceOwnership.target_path)}; reconcile it before migration.`,
);
}
for (const file of workspaceFiles) {
if (
file.agentId !== plan.agent.finalId ||
file.workspace !== plan.agent.workspace ||
file.status !== "complete"
) {
throw new Error("Migration workspace ownership does not match its consented agent plan.");
}
const collision = executeSqliteQueryTakeFirstSync(
db,
getNodeSqliteKysely<ClawAdoptedDatabase>(db)
.selectFrom("claw_workspace_files")
.select("agent_id")
.where("workspace", "=", file.workspace)
.where("target_path", "=", file.path)
.limit(1),
);
if (collision) {
throw new Error(
`Workspace path ${JSON.stringify(file.path)} is already tracked by Claw agent ${JSON.stringify(collision.agent_id)}.`,
);
}
}
const state = getNodeSqliteKysely<ClawAdoptedDatabase>(db);
executeSqliteQuerySync(
db,
state.insertInto("claw_installs").values({
agent_id: plan.agent.finalId,
schema_version: ownership.schemaVersion,
source_kind: plan.claw.kind,
claw_name: plan.claw.name,
claw_version: plan.claw.version,
package_root: plan.claw.packageRoot,
manifest_path: plan.claw.manifestPath,
integrity_kind: plan.claw.integrityKind,
integrity: plan.claw.integrity,
source_byte_length: plan.claw.byteLength,
manifest_schema_version: plan.manifestSchemaVersion,
plan_integrity: plan.planIntegrity,
workspace: plan.agent.workspace,
agent_config_digest: agentConfigDigest,
agent_owned_paths_json: ownership.agentOwnedPathsJson,
bootstrap_source_path: null,
bootstrap_content_digest: null,
status: "complete",
added_at_ms: nowMs,
updated_at_ms: nowMs,
}),
);
for (const file of workspaceFiles) {
executeSqliteQuerySync(
db,
state.insertInto("claw_workspace_files").values({
schema_version: file.schemaVersion,
agent_id: file.agentId,
workspace: file.workspace,
target_path: file.path,
source_path: file.sourcePath,
content_digest: file.contentDigest,
status: file.status,
created_at_ms: file.createdAtMs,
updated_at_ms: file.updatedAtMs,
}),
);
}
return {
schemaVersion: CLAW_INSTALL_RECORD_ADOPTED_SCHEMA_VERSION,
claw: plan.claw,
manifestSchemaVersion: plan.manifestSchemaVersion,
planIntegrity: plan.planIntegrity,
agentId: plan.agent.finalId,
workspace: plan.agent.workspace,
agentConfigDigest,
agentOrigin: "adopted" as const,
agentOwnedPaths: ownedPaths,
status: "complete" as const,
addedAtMs: nowMs,
updatedAtMs: nowMs,
};
}, options);
cacheClawInstallSchemaVersion(
plan.agent.finalId,
record.schemaVersion,
record.agentConfigDigest,
options,
);
return record;
}
/** Releases adopted ownership metadata without changing the pre-existing agent or files. */
export function releaseAdoptedClawInstallRecordWithInstallRecordReader(
agentId: string,
expectedPlanIntegrity: string,
readInstallRecord: (db: DatabaseSync, agentId: string) => PersistedClawInstall | undefined,
options: OpenClawStateDatabaseOptions = {},
): void {
runOpenClawStateWriteTransaction(({ db }) => {
const record = readInstallRecord(db, agentId);
if (!record) {
throw new Error(`No Claw install record exists for agent ${JSON.stringify(agentId)}.`);
}
if (
record.agentOrigin !== "adopted" ||
record.planIntegrity !== expectedPlanIntegrity ||
record.status !== "complete"
) {
throw new Error(`Adopted Claw ownership changed for agent ${JSON.stringify(agentId)}.`);
}
const secondaryReferences = readClawSecondaryReferenceTables(db, agentId);
if (secondaryReferences.length > 0) {
throw new Error(
`Adopted Claw ownership for agent ${JSON.stringify(agentId)} now includes secondary resources in ${secondaryReferences.join(", ")}; reconcile them before releasing ownership.`,
);
}
const state = getNodeSqliteKysely<ClawAdoptedDatabase>(db);
executeSqliteQuerySync(
db,
state.deleteFrom("claw_workspace_files").where("agent_id", "=", agentId),
);
const removed = executeSqliteQuerySync(
db,
state
.deleteFrom("claw_installs")
.where("agent_id", "=", agentId)
.where("schema_version", "=", record.schemaVersion)
.where("plan_integrity", "=", expectedPlanIntegrity),
);
if (removed.numAffectedRows !== 1n) {
throw new Error(`Adopted Claw ownership changed for agent ${JSON.stringify(agentId)}.`);
}
}, options);
deleteCachedClawInstallSchemaVersion(agentId, options);
}

View file

@ -0,0 +1,70 @@
/** Versioned ownership payload for agent state that predates Claw enrollment. */
export const CLAW_INSTALL_RECORD_ADOPTED_SCHEMA_VERSION = "openclaw.clawInstallRecord.v3" as const;
export type ClawAgentOrigin = "created" | "adopted";
type AdoptedAgentPaths = {
origin: "adopted";
paths: string[];
};
function isAdoptedAgentPaths(value: unknown): value is AdoptedAgentPaths {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
if (!("origin" in value) || !("paths" in value)) {
return false;
}
return (
value.origin === "adopted" &&
Array.isArray(value.paths) &&
value.paths.every((path) => typeof path === "string")
);
}
export function decodeClawAgentOwnership(
value: string,
schemaVersion: string,
): {
origin: ClawAgentOrigin;
paths: string[];
} {
const parsed: unknown = JSON.parse(value);
if (schemaVersion === CLAW_INSTALL_RECORD_ADOPTED_SCHEMA_VERSION) {
if (!isAdoptedAgentPaths(parsed)) {
throw new Error("Adopted Claw install record has invalid agent ownership data.");
}
return { origin: "adopted", paths: [...parsed.paths] };
}
if (
schemaVersion !== "openclaw.clawInstallRecord.v1" &&
schemaVersion !== "openclaw.clawInstallRecord.v2"
) {
throw new Error(`Unsupported Claw install record schema ${JSON.stringify(schemaVersion)}.`);
}
if (!Array.isArray(parsed) || !parsed.every((path) => typeof path === "string")) {
throw new Error("Created Claw install record has invalid agent ownership data.");
}
return { origin: "created", paths: [...parsed] };
}
export function encodeClawAgentOwnership(
paths: string[],
origin: ClawAgentOrigin,
): {
schemaVersion:
| "openclaw.clawInstallRecord.v2"
| typeof CLAW_INSTALL_RECORD_ADOPTED_SCHEMA_VERSION;
agentOwnedPathsJson: string;
} {
return origin === "adopted"
? {
schemaVersion: CLAW_INSTALL_RECORD_ADOPTED_SCHEMA_VERSION,
agentOwnedPathsJson: JSON.stringify({ origin, paths }),
}
: {
schemaVersion: "openclaw.clawInstallRecord.v2",
agentOwnedPathsJson: JSON.stringify(paths),
};
}

View file

@ -1,16 +1,19 @@
import type { DatabaseSync } from "node:sqlite";
import { stableStringify } from "@openclaw/normalization-core";
import { CLAW_INSTALL_RECORD_ADOPTED_SCHEMA_VERSION } from "./provenance-agent-origin.js";
const LEGACY_CLAW_INSTALL_RECORD_SCHEMA_VERSION = "openclaw.clawInstallRecord.v1" as const;
export const CLAW_INSTALL_RECORD_SCHEMA_VERSION = "openclaw.clawInstallRecord.v2" as const;
type ClawInstallRecordSchemaVersion =
| typeof LEGACY_CLAW_INSTALL_RECORD_SCHEMA_VERSION
| typeof CLAW_INSTALL_RECORD_SCHEMA_VERSION;
| typeof CLAW_INSTALL_RECORD_SCHEMA_VERSION
| typeof CLAW_INSTALL_RECORD_ADOPTED_SCHEMA_VERSION;
export function parseClawInstallRecordSchemaVersion(value: string): ClawInstallRecordSchemaVersion {
if (
value === LEGACY_CLAW_INSTALL_RECORD_SCHEMA_VERSION ||
value === CLAW_INSTALL_RECORD_SCHEMA_VERSION
value === CLAW_INSTALL_RECORD_SCHEMA_VERSION ||
value === CLAW_INSTALL_RECORD_ADOPTED_SCHEMA_VERSION
) {
return value;
}

View file

@ -0,0 +1,21 @@
import type { DatabaseSync } from "node:sqlite";
import { tableExists } from "../state/openclaw-state-db-schema-helpers.js";
const CLAW_SECONDARY_REFERENCE_TABLES = [
"claw_package_refs",
"claw_mcp_server_refs",
"claw_cron_refs",
] as const;
export function readClawSecondaryReferenceTables(db: DatabaseSync, agentId: string): string[] {
return CLAW_SECONDARY_REFERENCE_TABLES.filter((table) => {
if (!tableExists(db, table)) {
return false;
}
return Boolean(
db /* sqlite-allow-raw: read-only point check for secondary Claw ownership before migration. */
.prepare(`SELECT 1 FROM ${table} WHERE agent_id = ? LIMIT 1`)
.get(agentId),
);
});
}

View file

@ -0,0 +1,26 @@
import type { ClawAgentOrigin } from "./provenance-agent-origin.js";
import type { parseClawInstallRecordSchemaVersion } from "./provenance-schema-version.js";
import type { ClawAddPlan } from "./types.js";
export type ClawInstallStatus =
| "pending"
| "workspace_ready"
| "config_committed"
| "complete"
| "partial";
export type PersistedClawInstall = {
schemaVersion: ReturnType<typeof parseClawInstallRecordSchemaVersion>;
claw: ClawAddPlan["claw"];
manifestSchemaVersion: ClawAddPlan["manifestSchemaVersion"];
planIntegrity: string;
agentId: string;
workspace: string;
agentConfigDigest: string;
agentOrigin: ClawAgentOrigin;
agentOwnedPaths: string[];
bootstrap?: { sourcePath: string; contentDigest: string };
status: ClawInstallStatus;
addedAtMs: number;
updatedAtMs: number;
};

View file

@ -21,6 +21,15 @@ import {
type PackageRefRow,
type PersistedClawPackageRef,
} from "./package-extension-provenance.js";
import {
persistClawMigrationOwnershipWithInstallRecordReader,
releaseAdoptedClawInstallRecordWithInstallRecordReader,
} from "./provenance-adopted.js";
import {
decodeClawAgentOwnership,
encodeClawAgentOwnership,
type ClawAgentOrigin,
} from "./provenance-agent-origin.js";
import {
clawBootstrapProvenanceFromRow,
selectClawBootstrapProvenanceColumns,
@ -31,35 +40,19 @@ import {
deleteCachedClawInstallSchemaVersion,
} from "./provenance-runtime-read.js";
import * as installRecordSchema from "./provenance-schema-version.js";
import type { ClawInstallStatus, PersistedClawInstall } from "./provenance-types.js";
import type { ClawAddPlan, ClawPackage, ResolvedClawPackage } from "./types.js";
import type { PersistedClawWorkspaceFile } from "./workspace.js";
export {
CLAW_PACKAGE_REF_SCHEMA_VERSION,
type PersistedClawPackageRef,
} from "./package-extension-provenance.js";
export type { ClawInstallStatus, PersistedClawInstall } from "./provenance-types.js";
type ClawProvenanceDatabase = Pick<DB, "claw_installs" | "claw_package_refs">;
export type ClawInstallStatus =
| "pending"
| "workspace_ready"
| "config_committed"
| "complete"
| "partial";
export type PersistedClawInstall = {
schemaVersion: ReturnType<typeof installRecordSchema.parseClawInstallRecordSchemaVersion>;
claw: ClawAddPlan["claw"];
manifestSchemaVersion: ClawAddPlan["manifestSchemaVersion"];
planIntegrity: string;
agentId: string;
workspace: string;
agentConfigDigest: string;
agentOwnedPaths: string[];
bootstrap?: { sourcePath: string; contentDigest: string };
status: ClawInstallStatus;
addedAtMs: number;
updatedAtMs: number;
};
type ClawProvenanceDatabase = Pick<
DB,
"claw_installs" | "claw_package_refs" | "claw_workspace_files"
>;
type ClawInstallRow = {
schema_version: string;
@ -85,6 +78,7 @@ type ClawInstallRow = {
};
function rowToRecord(row: ClawInstallRow): PersistedClawInstall {
const ownership = decodeClawAgentOwnership(row.agent_owned_paths_json, row.schema_version);
return {
schemaVersion: installRecordSchema.parseClawInstallRecordSchemaVersion(row.schema_version),
claw: {
@ -104,7 +98,8 @@ function rowToRecord(row: ClawInstallRow): PersistedClawInstall {
agentId: row.agent_id,
workspace: row.workspace,
agentConfigDigest: row.agent_config_digest,
agentOwnedPaths: JSON.parse(row.agent_owned_paths_json) as string[],
agentOrigin: ownership.origin,
agentOwnedPaths: ownership.paths,
...clawBootstrapProvenanceFromRow(row),
status: row.status,
addedAtMs: sqliteNumber(row.added_at_ms),
@ -171,6 +166,32 @@ export function readClawInstallRecordFromDatabase(
return row ? rowToRecord(row) : undefined;
}
export function persistClawMigrationOwnership(
plan: ClawAddPlan,
workspaceFiles: PersistedClawWorkspaceFile[],
options: OpenClawStateDatabaseOptions & { nowMs?: number } = {},
): PersistedClawInstall {
return persistClawMigrationOwnershipWithInstallRecordReader(
plan,
workspaceFiles,
readClawInstallRecordFromDatabase,
options,
);
}
export function releaseAdoptedClawInstallRecord(
agentId: string,
expectedPlanIntegrity: string,
options: OpenClawStateDatabaseOptions = {},
): void {
releaseAdoptedClawInstallRecordWithInstallRecordReader(
agentId,
expectedPlanIntegrity,
readClawInstallRecordFromDatabase,
options,
);
}
export function readClawInstallRecord(
agentId: string,
options: OpenClawStateDatabaseOptions = {},
@ -187,12 +208,14 @@ export function persistClawInstallRecord(
expectedExistingRecord?: PersistedClawInstall;
expectedExistingPlan?: ClawAddPlan;
deferLegacyPlanUpgrade?: boolean;
agentOrigin?: ClawAgentOrigin;
} = {},
): PersistedClawInstall {
const nowMs = options.nowMs ?? Date.now();
const status = options.status ?? "complete";
const agentConfigDigest = digestClawValue(plan.agent.config);
const ownedPaths = agentOwnedPaths(plan);
const ownership = encodeClawAgentOwnership(ownedPaths, options.agentOrigin ?? "created");
const bootstrap = bootstrapProvenance(plan);
const persistedRecord = runOpenClawStateWriteTransaction(({ db }) => {
const existing = selectClawInstallRow(db, plan.agent.finalId);
@ -229,7 +252,7 @@ export function persistClawInstallRecord(
.insertInto("claw_installs")
.values({
agent_id: plan.agent.finalId,
schema_version: installRecordSchema.CLAW_INSTALL_RECORD_SCHEMA_VERSION,
schema_version: ownership.schemaVersion,
source_kind: plan.claw.kind,
claw_name: plan.claw.name,
claw_version: plan.claw.version,
@ -242,7 +265,7 @@ export function persistClawInstallRecord(
plan_integrity: plan.planIntegrity,
workspace: plan.agent.workspace,
agent_config_digest: agentConfigDigest,
agent_owned_paths_json: JSON.stringify(ownedPaths),
agent_owned_paths_json: ownership.agentOwnedPathsJson,
bootstrap_source_path: bootstrap?.sourcePath ?? null,
bootstrap_content_digest: bootstrap?.contentDigest ?? null,
status,
@ -251,13 +274,14 @@ export function persistClawInstallRecord(
}),
);
return {
schemaVersion: installRecordSchema.CLAW_INSTALL_RECORD_SCHEMA_VERSION,
schemaVersion: ownership.schemaVersion,
claw: plan.claw,
manifestSchemaVersion: plan.manifestSchemaVersion,
planIntegrity: plan.planIntegrity,
agentId: plan.agent.finalId,
workspace: plan.agent.workspace,
agentConfigDigest,
agentOrigin: options.agentOrigin ?? "created",
agentOwnedPaths: ownedPaths,
...(bootstrap ? { bootstrap } : {}),
status,
@ -347,6 +371,7 @@ export function updateClawInstallRecord(
nowMs?: number;
expectedClaw?: { version: string; integrity: string };
status?: ClawInstallStatus;
agentConfigDigest?: string;
} = {},
): PersistedClawInstall {
const current = readClawInstallRecord(plan.agent.finalId, options);
@ -357,18 +382,19 @@ export function updateClawInstallRecord(
}
const updatedAtMs = options.nowMs ?? Date.now();
const status = options.status ?? "complete";
const agentConfigDigest = digestClawValue(plan.agent.config);
const agentConfigDigest = options.agentConfigDigest ?? digestClawValue(plan.agent.config);
const ownedAgentPaths = plan.actions
.filter((action) => action.kind === "agent")
.map((action) => action.target);
const bootstrap = bootstrapProvenance(plan) ?? current.bootstrap;
const ownership = encodeClawAgentOwnership(ownedAgentPaths, current.agentOrigin);
runOpenClawStateWriteTransaction(({ db }) => {
const result = executeSqliteQuerySync(
db,
getNodeSqliteKysely<ClawProvenanceDatabase>(db)
.updateTable("claw_installs")
.set({
schema_version: installRecordSchema.CLAW_INSTALL_RECORD_SCHEMA_VERSION,
schema_version: ownership.schemaVersion,
source_kind: plan.claw.kind,
claw_name: plan.claw.name,
claw_version: plan.claw.version,
@ -381,7 +407,7 @@ export function updateClawInstallRecord(
plan_integrity: plan.planIntegrity,
workspace: plan.agent.workspace,
agent_config_digest: agentConfigDigest,
agent_owned_paths_json: JSON.stringify(ownedAgentPaths),
agent_owned_paths_json: ownership.agentOwnedPathsJson,
bootstrap_source_path: bootstrap?.sourcePath ?? null,
bootstrap_content_digest: bootstrap?.contentDigest ?? null,
status,
@ -398,13 +424,14 @@ export function updateClawInstallRecord(
}
}, options);
const record = {
schemaVersion: installRecordSchema.CLAW_INSTALL_RECORD_SCHEMA_VERSION,
schemaVersion: ownership.schemaVersion,
claw: plan.claw,
manifestSchemaVersion: plan.manifestSchemaVersion,
planIntegrity: plan.planIntegrity,
agentId: plan.agent.finalId,
workspace: plan.agent.workspace,
agentConfigDigest,
agentOrigin: current.agentOrigin,
agentOwnedPaths: ownedAgentPaths,
...(bootstrap ? { bootstrap } : {}),
status,

View file

@ -1,14 +1,39 @@
import { listAgentEntries } from "../agents/agent-scope.js";
import { realpathSync } from "node:fs";
import { listAgentEntries, resolveAgentWorkspaceDir } from "../agents/agent-scope-config.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { digestClawValue } from "./digest.js";
import { normalizeWorkspaceConfig, resolveMigrationAgentSettings } from "./migrate-validation.js";
export type ClawToolPolicyCandidate = { agentId: string; agentConfigDigest: string; tools: object };
export type ClawToolPolicyCandidate = {
agentId: string;
agentConfigDigest: string;
adoptedAgentConfigDigest: (env?: NodeJS.ProcessEnv) => string;
tools: object;
};
export function collectClawToolPolicyCandidates(config: OpenClawConfig): ClawToolPolicyCandidate[] {
return listAgentEntries(config).flatMap((agent) => {
const tools = agent.tools;
return tools && (tools.profile || tools.allow?.length)
? [{ agentId: agent.id, agentConfigDigest: digestClawValue(agent), tools }]
: [];
if (!tools || (!tools.profile && !tools.allow?.length)) {
return [];
}
let adoptedDigest: string | undefined;
return [
{
agentId: agent.id,
agentConfigDigest: digestClawValue(agent),
// Adoption binds effective settings and the canonical workspace without
// rewriting the authored config. Resolve only for known adopted owners,
// once per prepared candidate, rather than on each tool-policy lookup.
adoptedAgentConfigDigest: (env) =>
(adoptedDigest ??= digestClawValue(
normalizeWorkspaceConfig(
resolveMigrationAgentSettings(config, agent),
realpathSync(resolveAgentWorkspaceDir(config, agent.id, env)),
),
)),
tools,
},
];
});
}

View file

@ -3,16 +3,25 @@ import { dirname, join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import { createExecTool } from "../agents/bash-tools.exec-run.js";
import { resolveConversationCapabilityProfile } from "../agents/conversation-capability-profile.js";
import {
buildConversationToolPolicyPipelineSteps,
resolveConversationToolPolicies,
} from "../agents/conversation-tool-policy-pipeline.js";
import { createReadTool } from "../agents/sessions/tools/read.js";
import { applyToolPolicyPipeline } from "../agents/tool-policy-pipeline.js";
import {
createToolSearchCatalogRef,
registerHeadlessToolSearchCatalog,
} from "../agents/tool-search-catalog.js";
import { resolveToolSearchConfig } from "../agents/tool-search-config.js";
import { ToolSearchRuntime } from "../agents/tool-search-runtime.js";
import {
clearRuntimeConfigSnapshot,
setRuntimeConfigSnapshot,
} from "../config/runtime-snapshot.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import * as sqliteSnapshot from "../infra/sqlite-snapshot-source.js";
import { withArtifactPreservingStateReads } from "../state/openclaw-state-db-readonly.js";
import {
@ -21,8 +30,10 @@ import {
openOpenClawStateDatabase,
} from "../state/openclaw-state-db.js";
import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js";
import { applyClawMigrationPlan, buildClawMigrationPlan } from "./migrate.js";
import { persistClawInstallRecord } from "./provenance.js";
import { makeProvenancePlan, stateEnv } from "./provenance.test-helpers.js";
import { prepareCapturedClawToolPolicyConsent } from "./tool-policy-runtime.js";
import type { ClawOpenClawProfile } from "./types.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
@ -47,7 +58,167 @@ function makeToolConsentPlan(
);
}
async function migrateToolConsentAgent() {
const root = tempDirs.make("openclaw-adopted-claw-tool-consent-");
const env = stateEnv(root);
vi.stubEnv("OPENCLAW_STATE_DIR", env.OPENCLAW_STATE_DIR);
const workspace = join(root, "workspace");
mkdirSync(workspace);
writeFileSync(join(workspace, "AGENTS.md"), "Use the existing workspace.\n");
const config = {
agents: {
defaults: {
workspace: root,
model: "openai/gpt-4.1",
sandbox: { mode: "all" as const },
},
entries: {
worker: { workspace, tools: { profile: "full" as const, allow: ["read"] } },
},
},
};
const migration = await buildClawMigrationPlan({ agentId: "worker", config, options: { env } });
await applyClawMigrationPlan({ migration, config, options: { env } });
return { root, config, env };
}
describe("Claw tool policy consent provenance", () => {
it("runs an adopted agent with frozen tools and inherited settings after restart", async () => {
const { config, env } = await migrateToolConsentAgent();
const workspace = config.agents.entries.worker.workspace;
const read = createReadTool(workspace);
const exec = createExecTool({ cwd: workspace, host: "gateway", security: "full", ask: "off" });
const executeRead = vi.spyOn(read, "execute");
const executeExec = vi.spyOn(exec, "execute");
const marker = join(workspace, "forbidden-exec-marker");
const execInput = { command: "touch forbidden-exec-marker" };
const prepareDispatcher = (activeConfig: OpenClawConfig) => {
const capabilityProfile = resolveConversationCapabilityProfile({
agentId: "worker",
config: activeConfig,
});
const policies = resolveConversationToolPolicies({ capabilityProfile });
const filtered = applyToolPolicyPipeline({
tools: [read, exec, { ...read, name: "future_tool" }],
toolMeta: (tool) => (tool.name === "future_tool" ? { pluginId: "read" } : undefined),
warn: () => {},
steps: buildConversationToolPolicyPipelineSteps({
capabilityProfile,
policies,
includeRuntimeToolPolicy: true,
}),
});
expect(filtered.map((tool) => tool.name)).toEqual(["read"]);
const catalogRef = createToolSearchCatalogRef();
registerHeadlessToolSearchCatalog({ catalogRef, tools: filtered });
return new ToolSearchRuntime({ catalogRef }, resolveToolSearchConfig(), {
validateInput: true,
});
};
closeOpenClawStateDatabase();
setRuntimeConfigSnapshot(config);
const captured = structuredClone(config);
prepareCapturedClawToolPolicyConsent(captured, { env });
const dispatcher = prepareDispatcher(captured);
const readResult = await dispatcher.call("read", { path: "AGENTS.md" });
expect(readResult.result.content).toContainEqual(
expect.objectContaining({
type: "text",
text: expect.stringContaining("Use the existing workspace."),
}),
);
expect(executeRead).toHaveBeenCalledOnce();
await expect(dispatcher.call("exec", execInput)).rejects.toThrow("Unknown tool");
expect(executeExec).not.toHaveBeenCalled();
expect(existsSync(marker)).toBe(false);
const changedConfigs: OpenClawConfig[] = [
{
agents: {
...config.agents,
defaults: { ...config.agents.defaults, model: "openai/gpt-4.1-mini" },
},
},
{
agents: {
...config.agents,
defaults: { ...config.agents.defaults, sandbox: { mode: "off" } },
},
},
{
agents: {
...config.agents,
defaults: { ...config.agents.defaults, compaction: { mode: "default" } },
},
},
{
agents: {
...config.agents,
entries: {
worker: {
...config.agents.entries.worker,
tools: { profile: "full", allow: ["read", "exec"] },
},
},
},
},
];
for (const changed of changedConfigs) {
setRuntimeConfigSnapshot(changed);
await expect(
(async () => prepareDispatcher(changed).call("exec", execInput))(),
).rejects.toThrow("Cannot verify the installed tool authority");
}
setRuntimeConfigSnapshot(config);
openOpenClawStateDatabase({ env });
closeOpenClawStateDatabase();
await expect((async () => prepareDispatcher(config).call("exec", execInput))()).rejects.toThrow(
"Cannot verify the installed tool authority",
);
expect(executeRead).toHaveBeenCalledOnce();
expect(executeExec).not.toHaveBeenCalled();
expect(existsSync(marker)).toBe(false);
});
it("keeps mixed legacy, created, and adopted consent isolated after restart", async () => {
const { root, config, env } = await migrateToolConsentAgent();
mkdirSync(join(root, "created"));
mkdirSync(join(root, "legacy"));
const { plan: created } = await makeToolConsentPlan(
join(root, "created"),
undefined,
"created",
);
const { plan: legacy } = await makeToolConsentPlan(join(root, "legacy"), undefined, "legacy");
persistClawInstallRecord(created, { env });
persistClawInstallRecord(legacy, { env });
openOpenClawStateDatabase({ env })
.db
/* sqlite-allow-raw: test-only downgrade verifies mixed stored consent versions. */
.prepare("UPDATE claw_installs SET schema_version = ? WHERE agent_id = ?")
.run("openclaw.clawInstallRecord.v1", "legacy");
closeOpenClawStateDatabase();
const mixedConfig = {
agents: {
...config.agents,
entries: {
...config.agents.entries,
created: created.agent.config,
legacy: legacy.agent.config,
},
},
};
setRuntimeConfigSnapshot(mixedConfig);
for (const agentId of ["worker", "created"]) {
expect(() =>
resolveConversationCapabilityProfile({ agentId, config: mixedConfig }),
).not.toThrow();
}
expect(() =>
resolveConversationCapabilityProfile({ agentId: "legacy", config: mixedConfig }),
).toThrow("legacy dynamic tool policy");
});
it("refreshes runtime consent without copying the live database on each catalog generation", async () => {
const root = tempDirs.make("openclaw-claw-runtime-consent-");
const env = stateEnv(root);

View file

@ -4,6 +4,7 @@ import {
} from "../config/runtime-snapshot.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js";
import { CLAW_INSTALL_RECORD_ADOPTED_SCHEMA_VERSION } from "./provenance-agent-origin.js";
import {
initializeCachedClawInstallSchemaVersions,
prepareClawInstallSchemaVersions,
@ -74,21 +75,28 @@ function applyPreparedClawToolPolicyConsent(
});
continue;
}
if (
schemaVersionRead.schemaVersion === CLAW_INSTALL_RECORD_SCHEMA_VERSION &&
schemaVersionRead.agentConfigDigest !== candidate.agentConfigDigest
) {
const adopted = schemaVersionRead.schemaVersion === CLAW_INSTALL_RECORD_ADOPTED_SCHEMA_VERSION;
const current =
adopted || schemaVersionRead.schemaVersion === CLAW_INSTALL_RECORD_SCHEMA_VERSION;
try {
if (
current &&
schemaVersionRead.agentConfigDigest !==
(adopted
? candidate.adoptedAgentConfigDigest(stateOptions.env)
: candidate.agentConfigDigest)
) {
throw new Error("Claw agent configuration does not match its consent provenance.");
}
} catch (error) {
preparedClawToolPolicies.set(candidate.tools, {
kind: "state-error",
error: new Error("Claw agent configuration does not match its consent provenance."),
error,
});
continue;
}
preparedClawToolPolicies.set(candidate.tools, {
kind:
schemaVersionRead.schemaVersion === CLAW_INSTALL_RECORD_SCHEMA_VERSION
? "current"
: "legacy",
kind: current ? "current" : "legacy",
});
}
}
@ -132,6 +140,7 @@ async function prepareClawToolPolicyConsentAsync(
return () => {
replaceClawToolPolicyCandidates(collectClawToolPolicyCandidates(config), {
path: preparedSchemaVersions.path,
env: context.env,
});
preparedSchemaVersions.publish();
applyPreparedClawToolPolicyConsent();

View file

@ -0,0 +1,141 @@
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import { readClawStatus } from "./lifecycle-status.js";
import { applyClawMigrationPlan, buildClawMigrationPlan } from "./migrate.js";
import { readClawInstallRecord } from "./provenance.js";
import { applyClawUpdatePlan } from "./update-apply.js";
import { buildClawUpdatePlan } from "./update-plan.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
afterEach(closeOpenClawStateDatabaseForTest);
async function fixture() {
const root = tempDirs.make("openclaw-adopted-update-");
const workspace = join(root, "workspace");
await mkdir(workspace);
const env = { OPENCLAW_STATE_DIR: join(root, "state") };
const config: OpenClawConfig = {
agents: {
defaults: { model: "provider/inherited" },
entries: { worker: { name: "Worker", workspace: `${workspace}/.` } },
},
};
const migration = await buildClawMigrationPlan({
agentId: "worker",
config,
options: { env },
});
await applyClawMigrationPlan({ migration, config, options: { env } });
const installed = readClawInstallRecord("worker", { env });
const target = {
targetManifest: {
...migration.manifest,
agent: { ...migration.manifest.agent, name: "Worker v2" },
},
// The updated package continues to inherit the host model.
targetOpenClawProfile: { schemaVersion: 1 as const, agent: {} },
targetSource: { ...migration.addPlan.claw, version: "2.0.0", integrity: "sha256:updated" },
};
const plan = await buildClawUpdatePlan({
agentId: "worker",
...target,
config,
sourceMcpServers: {},
stateOptions: { env },
});
expect(plan.blockers).toEqual([]);
expect(plan.actions).toContainEqual(
expect.objectContaining({ kind: "agent", action: "change", blocked: false }),
);
return { config, env, installed, plan, target, workspace: migration.plan.workspace };
}
describe("updating an adopted agent", () => {
it("updates a present agent with inherited settings and keeps status consistent", async () => {
const current = await fixture();
let config = current.config;
await expect(
applyClawUpdatePlan(current.plan, current.target, {
config,
env: current.env,
sourceMcpServers: {},
consentPlanIntegrity: current.plan.planIntegrity,
commitConfig: async (transform) => {
config = transform(config);
},
}),
).resolves.toMatchObject({ status: "complete", installRecord: { agentOrigin: "adopted" } });
expect(config.agents?.entries?.worker).toEqual({
name: "Worker v2",
workspace: current.workspace,
});
expect(config.agents?.defaults).toEqual(current.config.agents?.defaults);
await expect(
readClawStatus("worker", { config, env: current.env, sourceMcpServers: {} }),
).resolves.toMatchObject({ records: [{ agentState: "present" }] });
});
it("restores the original authored entry when a later update step fails", async () => {
const current = await fixture();
let config = current.config;
let reachedCron = false;
await expect(
applyClawUpdatePlan(current.plan, current.target, {
config,
env: current.env,
sourceMcpServers: {},
consentPlanIntegrity: current.plan.planIntegrity,
commitConfig: async (transform) => {
config = transform(config);
},
applyCron: async () => {
reachedCron = true;
expect(config.agents?.entries?.worker?.name).toBe("Worker v2");
throw new Error("cron unavailable");
},
}),
).rejects.toMatchObject({ code: "cron_update_failed" });
expect(reachedCron).toBe(true);
expect(config).toEqual(current.config);
expect(readClawInstallRecord("worker", { env: current.env })).toEqual(current.installed);
await expect(
readClawStatus("worker", { config, env: current.env, sourceMcpServers: {} }),
).resolves.toMatchObject({ records: [{ agentState: "present" }] });
});
it("preserves a change to inherited settings made before rollback", async () => {
const current = await fixture();
let config = current.config;
await expect(
applyClawUpdatePlan(current.plan, current.target, {
config,
env: current.env,
sourceMcpServers: {},
consentPlanIntegrity: current.plan.planIntegrity,
commitConfig: async (transform) => {
config = transform(config);
},
applyCron: async () => {
config = {
...config,
agents: { ...config.agents, defaults: { model: "provider/operator-change" } },
};
throw new Error("cron unavailable");
},
}),
).rejects.toMatchObject({ code: "update_partial" });
expect(config.agents?.defaults?.model).toBe("provider/operator-change");
expect(config.agents?.entries?.worker?.name).toBe("Worker v2");
expect(readClawInstallRecord("worker", { env: current.env })?.status).toBe("partial");
});
});

View file

@ -28,6 +28,7 @@ export const install: PersistedClawInstall = {
agentId: "worker",
workspace: "/tmp/workspace-worker",
agentConfigDigest: "sha256:current-agent",
agentOrigin: "created",
agentOwnedPaths: ['agents.entries["worker"]'],
status: "complete",
addedAtMs: 1,

View file

@ -1,4 +1,7 @@
import { realpathSync } from "node:fs";
import { resolve } from "node:path";
import { coerceErrorMessage, stableStringify } from "@openclaw/normalization-core";
import { resolveAgentWorkspaceDir } from "../agents/agent-scope-config.js";
import { listAgentEntries } from "../agents/agent-scope.js";
import { transformConfigFileWithRetry } from "../config/config.js";
import type { AgentConfig } from "../config/types.agents.js";
@ -20,6 +23,7 @@ import {
ClawMcpUpdateError,
type ClawMcpUpdateExecution,
} from "./mcp-update.js";
import { normalizeWorkspaceConfig, resolveMigrationAgentSettings } from "./migrate-validation.js";
import {
applyClawPackageUpdate,
ClawPackageUpdateError,
@ -175,6 +179,14 @@ export async function applyClawUpdatePlan(
if (!currentInstall) {
throw new ClawUpdateMutationError("update_changed", "The Claw install record disappeared.");
}
const adoptedAgentConfigDigest =
currentInstall.agentOrigin === "adopted"
? fresh.actions.find((action) => action.kind === "agent")?.desiredDigest
: undefined;
const installPersistenceOptions = {
...options,
...(adoptedAgentConfigDigest ? { agentConfigDigest: adoptedAgentConfigDigest } : {}),
};
const partialMutation = (
message: string,
errorOptions?: ErrorOptions,
@ -439,14 +451,34 @@ export async function applyClawUpdatePlan(
});
let previousAgent: AgentConfig | undefined;
let agentChanged = false;
const liveAgentDigest = (config: OpenClawConfig, agent: AgentConfig | undefined) => {
if (!agent || currentInstall.agentOrigin !== "adopted") {
return agent ? digest(agent) : undefined;
}
let workspace = resolveAgentWorkspaceDir(config, fresh.agentId, options.env);
try {
workspace = realpathSync(workspace);
} catch {
workspace = resolve(workspace);
}
try {
// Adopted ownership records effective settings, including inherited defaults
// and the canonical workspace, while rollback retains the authored entry.
return digest(
normalizeWorkspaceConfig(resolveMigrationAgentSettings(config, agent), workspace),
);
} catch {
return undefined;
}
};
const rollbackAgent = async (): Promise<void> => {
if (!agentChanged) {
return;
}
await commit((config) => {
const current = listAgentEntries(config).find((agent) => agent.id === fresh.agentId);
const targetDigest = digest(targetAddPlan.agent.config);
const liveDigest = current ? digest(current) : undefined;
const targetDigest = adoptedAgentConfigDigest ?? digest(targetAddPlan.agent.config);
const liveDigest = liveAgentDigest(config, current);
if (liveDigest !== targetDigest) {
throw new Error("The agent changed before rollback.");
}
@ -473,7 +505,7 @@ export async function applyClawUpdatePlan(
"The owned agent entry disappeared during update.",
);
}
const liveDigest = digest(current);
const liveDigest = liveAgentDigest(config, current);
if (liveDigest !== agentAction.currentDigest) {
throw new ClawUpdateMutationError(
"agent_changed",
@ -511,7 +543,7 @@ export async function applyClawUpdatePlan(
if (error instanceof ClawCronUpdateError && error.partial) {
try {
persistInstall(targetAddPlan, {
...options,
...installPersistenceOptions,
expectedClaw: fresh.currentClaw,
status: "partial",
});
@ -535,7 +567,7 @@ export async function applyClawUpdatePlan(
let installRecord: PersistedClawInstall;
try {
installRecord = persistInstall(targetAddPlan, {
...options,
...installPersistenceOptions,
expectedClaw: fresh.currentClaw,
});
} catch (error) {

View file

@ -98,7 +98,14 @@ export async function createUpdatePlanFixture(
config.mcp = { ...config.mcp, servers };
return { ok: true, path: "config", config, mcpServers: servers };
},
listMcpServers: async () => ({ ok: true, path: "config", config, mcpServers: {} }),
listMcpServers: async () => ({
ok: true,
path: "config",
config,
mcpServers: {},
runtimeConfig: config,
sourceConfigBeforeMigrations: config,
}),
}),
cronGateway: { add: async () => ({ id: "scheduler-daily" }) },
});

View file

@ -20,6 +20,7 @@ import { digestClawValue as digest } from "./digest.js";
import { readClawStatus } from "./lifecycle-state.js";
import { buildClawAddPlan } from "./lifecycle.js";
import { digestClawMcpServer, readClawMcpServerRefsByName } from "./mcp.js";
import { normalizeWorkspaceConfig, resolveMigrationAgentSettings } from "./migrate-validation.js";
import type { PackageRemovalDeps } from "./package-remove.js";
import { digestClawPackageRef } from "./package-update-provenance.js";
import { readClawPackageRefs } from "./provenance.js";
@ -185,9 +186,22 @@ export async function buildClawUpdatePlan(params: {
const actions: ClawUpdateAction[] = [];
const capabilityChanges: ClawUpdateCapabilityChange[] = [];
const desiredAgentDigest = digest(targetPlan.agent.config);
let desiredAgentDigest = digest(targetPlan.agent.config);
let adoptedSettingsUnsupported = false;
if (record.install.agentOrigin === "adopted") {
try {
desiredAgentDigest = digest(
normalizeWorkspaceConfig(
resolveMigrationAgentSettings(params.config, targetPlan.agent.config),
record.install.workspace,
),
);
} catch {
adoptedSettingsUnsupported = true;
}
}
const agentAction =
record.agentState === "modified"
record.agentState === "modified" || adoptedSettingsUnsupported
? "manual"
: record.agentState === "missing"
? "change"
@ -202,7 +216,9 @@ export async function buildClawUpdatePlan(params: {
blocked: agentAction === "manual",
reason:
agentAction === "manual"
? "Live agent config changed after installation and must be reconciled manually."
? adoptedSettingsUnsupported
? "Current inherited agent defaults cannot be represented by the installed Claw v1 package. Reconcile those settings manually."
: "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"

View file

@ -21,6 +21,12 @@ export type ClawsAddOptions = ClawsDevOptions & {
yes?: boolean;
planIntegrity?: string;
};
export type ClawsMigrateOptions = {
dryRun?: boolean;
yes?: boolean;
planIntegrity?: string;
json?: boolean;
};
export type ClawsStatusOptions = { json?: boolean };
export type ClawsUpdateOptions = Omit<ClawsAddOptions, "agentId" | "workspace"> & {
@ -119,6 +125,19 @@ export function registerClawsCli(program: Command) {
await runClawsStatusCommand(target, opts);
});
claws
.command("migrate")
.description("Enroll one existing local agent as a Claw without replacing its workspace")
.argument("<agent-id>", "Existing configured agent id")
.option("--dry-run", "Preview migration without creating a package or ownership record", false)
.option("--yes", "Apply after confirming the exact migration plan", false)
.option("--plan-integrity <digest>", "Bind automation consent to an exact dry-run plan")
.option("--json", "Print JSON", false)
.action(async (agentId: string, opts: ClawsMigrateOptions) => {
const { runClawsMigrateCommand } = await import("./claws-migrate-cli.runtime.js");
await runClawsMigrateCommand(agentId, opts);
});
claws
.command("update")
.description("Plan changes to one installed Claw agent")

View file

@ -0,0 +1,133 @@
import { access, mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import { readClawInstallRecord } from "../claws/provenance.js";
import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../config/config.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js";
const mocks = vi.hoisted(() => ({
confirm: vi.fn(),
isCancel: vi.fn((value: unknown) => value === "cancelled"),
}));
vi.mock("@clack/prompts", () => ({
confirm: mocks.confirm,
isCancel: mocks.isCancel,
}));
// This suite exercises config freshness and the real migration writes; lease
// worker admission is covered by the lifecycle integration suite.
vi.mock("../agents/agent-lifecycle-registry.js", () => ({
withAgentDeletion: async (_agentId: string, run: () => Promise<unknown>) => await run(),
}));
const { runClawsMigrateCommand } = await import("./claws-migrate-cli.runtime.js");
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
afterEach(() => {
clearRuntimeConfigSnapshot();
closeOpenClawStateDatabaseForTest();
});
async function fixture() {
const root = tempDirs.make("openclaw-claws-migrate-cli-");
const workspace = join(root, "workspace");
const stateDir = join(root, "state");
const configPath = join(root, "openclaw.json");
const env = {
...process.env,
HOME: root,
OPENCLAW_HOME: root,
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_CONFIG_PATH: configPath,
};
for (const key of [
"HOME",
"OPENCLAW_HOME",
"OPENCLAW_STATE_DIR",
"OPENCLAW_CONFIG_PATH",
] as const) {
vi.stubEnv(key, env[key]);
}
await mkdir(workspace);
await writeFile(join(workspace, "AGENTS.md"), "Keep this agent as-is.\n", "utf8");
const config: OpenClawConfig = { agents: { entries: { worker: { workspace } } } };
await writeFile(configPath, JSON.stringify(config));
setRuntimeConfigSnapshot(config);
const runtime = {
log: vi.fn(),
error: vi.fn(),
writeJson: vi.fn(),
writeStdout: vi.fn(),
exit: vi.fn(),
};
return { root, workspace, stateDir, configPath, env, config, runtime };
}
describe("claws migrate interactive consent", () => {
beforeEach(() => {
vi.stubEnv("OPENCLAW_EXPERIMENTAL_CLAWS", "1");
mocks.confirm.mockReset();
mocks.isCancel.mockClear();
});
it("defaults to no and leaves the existing agent untouched when cancelled", async () => {
const { stateDir, env, runtime } = await fixture();
mocks.confirm.mockResolvedValue(false);
await runClawsMigrateCommand("worker", {}, runtime);
expect(mocks.confirm).toHaveBeenCalledWith(
expect.objectContaining({
initialValue: false,
message: expect.stringContaining('"worker"'),
}),
);
expect(runtime.log).toHaveBeenCalledWith(
expect.stringContaining("Generated local Claw package files:"),
);
expect(runtime.log).toHaveBeenCalledWith(
"Migration cancelled; no Claw ownership was recorded.",
);
await expect(access(resolveOpenClawStateSqlitePath(env))).rejects.toMatchObject({
code: "ENOENT",
});
await expect(access(join(stateDir, "claws", "local", "worker"))).rejects.toMatchObject({
code: "ENOENT",
});
});
it.each(["removed", "reassigned"] as const)(
"rejects an agent %s on disk while interactive consent waits, before enrollment",
async (change) => {
const { root, stateDir, configPath, config, env, runtime } = await fixture();
const reassignedWorkspace = join(root, "reassigned");
await mkdir(reassignedWorkspace);
mocks.confirm.mockImplementation(async () => {
const current: OpenClawConfig = {
...config,
agents: {
entries: change === "removed" ? {} : { worker: { workspace: reassignedWorkspace } },
},
};
await writeFile(configPath, JSON.stringify(current));
return true;
});
await runClawsMigrateCommand("worker", {}, runtime);
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(runtime.error).toHaveBeenCalledWith(
expect.stringMatching(
change === "removed" ? /No configured local agent/ : /changed after consent/,
),
);
await expect(access(join(stateDir, "claws", "local", "worker"))).rejects.toMatchObject({
code: "ENOENT",
});
expect(readClawInstallRecord("worker", { env })).toBeUndefined();
},
);
});

View file

@ -0,0 +1,216 @@
import { realpath } from "node:fs/promises";
import { withAgentDeletion } from "../agents/agent-lifecycle-registry.js";
import { digestClawValue } from "../claws/digest.js";
import { assertExperimentalClawsEnabled } from "../claws/experimental.js";
import { withAuthoredAgentRoster } from "../claws/migrate-validation.js";
import {
applyClawMigrationPlan,
buildClawMigrationPlan,
ClawMigrationError,
CLAW_MIGRATION_PLAN_SCHEMA_VERSION,
} from "../claws/migrate.js";
import { CLAW_OUTPUT_STABILITY } from "../claws/types.js";
import { readConfigFileSnapshot } from "../config/config.js";
import { withConfigSourceLocks } from "../config/write-lock.js";
import { defaultRuntime, writeRuntimeJson, type RuntimeEnv } from "../runtime.js";
import { emitClawFailure, logClawExperimentalWarning } from "./claws-cli-output.js";
import type { ClawsMigrateOptions } from "./claws-cli.js";
async function readMigrationConfig() {
const snapshot = await readConfigFileSnapshot({ observe: false, isolateEnv: true });
if (!snapshot.exists || !snapshot.valid) {
throw new ClawMigrationError(
"migration_config_unavailable",
"Migration requires an existing valid local configuration. Repair the config before retrying.",
);
}
return {
config: withAuthoredAgentRoster(snapshot.runtimeConfig, snapshot.sourceConfigBeforeMigrations),
sources: [
...new Set([snapshot.path, await realpath(snapshot.path), ...(snapshot.includedPaths ?? [])]),
].toSorted(),
};
}
function logMigrationPlan(
plan: Awaited<ReturnType<typeof buildClawMigrationPlan>>["plan"],
runtime: RuntimeEnv,
): void {
logClawExperimentalWarning(runtime);
runtime.log(`Existing agent: ${plan.agentId}`);
runtime.log(`Workspace: ${plan.workspace}`);
runtime.log(`Local Claw package: ${plan.packageRoot}`);
runtime.log(`Portable identity: ${JSON.stringify(plan.agent)}`);
if (plan.openClawProfile) {
runtime.log(`OpenClaw profile: ${JSON.stringify(plan.openClawProfile.agent)}`);
}
runtime.log("Generated local Claw package files:");
for (const file of plan.generatedPackageFiles) {
runtime.log(` ${file.path} (${file.byteLength} bytes, ${file.digest})`);
}
if (plan.workspaceFiles.length === 0) {
runtime.log("Existing prompt files becoming Claw-managed: none");
} else {
runtime.log("Existing prompt files becoming Claw-managed (contents will not be rewritten):");
for (const file of plan.workspaceFiles) {
runtime.log(` ${file.path} (${file.byteLength} bytes, ${file.digest})`);
}
}
runtime.log("Retained outside Claw ownership:");
for (const item of plan.retained) {
runtime.log(` ${item}`);
}
runtime.log(`Plan integrity: ${plan.planIntegrity}`);
}
function emitMigrationFailure(
runtime: RuntimeEnv,
json: boolean | undefined,
code: string,
message: string,
path = "$",
): void {
emitClawFailure(runtime, json, message, {
schemaVersion: CLAW_MIGRATION_PLAN_SCHEMA_VERSION,
stability: CLAW_OUTPUT_STABILITY,
ok: false,
mutationAllowed: false,
error: { code, message },
blockers: [{ code, path, message }],
});
}
export async function runClawsMigrateCommand(
agentId: string,
opts: ClawsMigrateOptions,
runtime: RuntimeEnv = defaultRuntime,
): Promise<void> {
assertExperimentalClawsEnabled();
if (!opts.dryRun && opts.yes && !opts.planIntegrity) {
emitMigrationFailure(
runtime,
opts.json,
"plan_integrity_required",
"Automated Claw migration requires --yes with --plan-integrity from the exact dry-run plan.",
);
return;
}
if (!opts.dryRun && !opts.yes && opts.json) {
emitMigrationFailure(
runtime,
true,
"consent_required",
"JSON migration requires --dry-run or --yes with --plan-integrity; interactive consent is available in human-readable mode.",
);
return;
}
let migration: Awaited<ReturnType<typeof buildClawMigrationPlan>>;
let previewConfig: Awaited<ReturnType<typeof readMigrationConfig>>;
try {
previewConfig = await readMigrationConfig();
migration = await buildClawMigrationPlan({
agentId,
config: previewConfig.config,
options: { env: process.env },
});
} catch (error) {
const code = error instanceof ClawMigrationError ? error.code : "migration_plan_failed";
const message = error instanceof Error ? error.message : String(error);
const path = error instanceof ClawMigrationError ? error.path : "$";
emitMigrationFailure(runtime, opts.json, code, message, path);
return;
}
if (opts.dryRun) {
if (opts.json) {
writeRuntimeJson(runtime, migration.plan);
} else {
logMigrationPlan(migration.plan, runtime);
}
return;
}
if (opts.yes && opts.planIntegrity !== migration.plan.planIntegrity) {
emitMigrationFailure(
runtime,
opts.json,
"plan_integrity_mismatch",
"Consent does not match the current migration plan. Run claws migrate with --dry-run and use its exact plan-integrity value.",
);
return;
}
if (!opts.yes) {
logMigrationPlan(migration.plan, runtime);
const { confirm, isCancel } = await import("@clack/prompts");
const confirmed = await confirm({
message: `Enroll existing agent ${JSON.stringify(agentId)} as a Claw?`,
initialValue: false,
});
if (isCancel(confirmed) || !confirmed) {
runtime.log("Migration cancelled; no Claw ownership was recorded.");
return;
}
}
try {
const result = await withAgentDeletion(
agentId,
async () =>
await withConfigSourceLocks(
previewConfig.sources,
async (assertCurrent) => {
const changed = () =>
new ClawMigrationError(
"migration_changed",
"The agent, workspace files, or ownership changed after consent. Review a fresh dry-run plan before retrying.",
);
const latest = await readMigrationConfig();
assertCurrent();
if (digestClawValue(latest.sources) !== digestClawValue(previewConfig.sources)) {
throw changed();
}
const current = await buildClawMigrationPlan({
agentId,
config: latest.config,
options: { env: process.env },
});
if (current.plan.planIntegrity !== migration.plan.planIntegrity) {
throw changed();
}
const expectedConfig = digestClawValue(latest);
return await applyClawMigrationPlan({
migration: current,
config: latest.config,
options: { env: process.env },
assertCurrentConfig: async () => {
assertCurrent();
const live = await readMigrationConfig();
assertCurrent();
if (digestClawValue(live) !== expectedConfig) {
throw changed();
}
},
});
},
process.env,
),
{ env: process.env },
);
if (opts.json) {
writeRuntimeJson(runtime, result);
return;
}
logClawExperimentalWarning(runtime);
runtime.log(`Migrated agent: ${result.agentId}`);
runtime.log(`Workspace: ${result.workspace}`);
runtime.log(`Local Claw package: ${result.packageRoot}`);
runtime.log(`Plan integrity: ${result.planIntegrity}`);
} catch (error) {
const code = error instanceof ClawMigrationError ? error.code : "migration_failed";
const message = error instanceof Error ? error.message : String(error);
const path = error instanceof ClawMigrationError ? error.path : "$";
emitMigrationFailure(runtime, opts.json, code, message, path);
}
}

View file

@ -1,5 +1,6 @@
import { assertExperimentalClawsEnabled } from "../claws/experimental.js";
import { readClawStatus } from "../claws/lifecycle-state.js";
import { withAuthoredAgentRoster } from "../claws/migrate-validation.js";
import { preflightClawPackage } from "../claws/packages.js";
import { readClawManifestFile } from "../claws/reader.js";
import { CLAW_OUTPUT_STABILITY } from "../claws/types.js";
@ -61,8 +62,10 @@ export async function runClawsUpdateCommand(
});
return;
}
const config = listedMcpServers.config;
const config = withAuthoredAgentRoster(
listedMcpServers.runtimeConfig ?? listedMcpServers.config,
listedMcpServers.sourceConfigBeforeMigrations,
);
let source = opts.from;
if (!source) {
const database = await openExistingOpenClawStateDatabaseReadOnly();

View file

@ -29,8 +29,16 @@ type ConfigMcpSuccess = {
config: OpenClawConfig;
mcpServers: ConfigMcpServers;
};
type ConfigMcpReadSuccess = ConfigMcpSuccess & {
runtimeConfig: Awaited<ReturnType<typeof readSourceConfigSnapshot>>["runtimeConfig"];
sourceConfigBeforeMigrations?: Awaited<
ReturnType<typeof readSourceConfigSnapshot>
>["sourceConfigBeforeMigrations"];
};
type ConfigMcpFailure = { ok: false; path: string; error: string };
type ConfigMcpReadResult = (ConfigMcpSuccess & { ok: true; baseHash?: string }) | ConfigMcpFailure;
type ConfigMcpReadResult =
| (ConfigMcpReadSuccess & { ok: true; baseHash?: string })
| ConfigMcpFailure;
type ConfigMcpWriteResult =
| (ConfigMcpSuccess & { ok: true; removed?: boolean; updated?: boolean })
| ConfigMcpFailure;
@ -112,6 +120,10 @@ function resolveConfiguredMcpServers(
path: snapshot.path,
config: structuredClone(sourceConfig),
mcpServers: normalizeConfiguredMcpServers(sourceConfig.mcp?.servers),
runtimeConfig: snapshot.runtimeConfig,
...(snapshot.sourceConfigBeforeMigrations
? { sourceConfigBeforeMigrations: snapshot.sourceConfigBeforeMigrations }
: {}),
baseHash: snapshot.hash,
};
}