mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
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:
parent
5c4e9aba92
commit
98f7c8d8cb
40 changed files with 3484 additions and 106 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
163
src/claws/lifecycle-adopted-removal.ts
Normal file
163
src/claws/lifecycle-adopted-removal.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
|
|
@ -93,6 +93,7 @@ export function synthesizeOrphanInstall(params: {
|
|||
agentId: params.agentId,
|
||||
workspace: params.workspace ?? "",
|
||||
agentConfigDigest: "sha256:missing",
|
||||
agentOrigin: "created",
|
||||
agentOwnedPaths: [],
|
||||
status: "partial",
|
||||
addedAtMs: updatedAtMs,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
43
src/claws/lifecycle-remove-state-blockers.ts
Normal file
43
src/claws/lifecycle-remove-state-blockers.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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" ||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
10
src/claws/migrate-errors.ts
Normal file
10
src/claws/migrate-errors.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export class ClawMigrationError extends Error {
|
||||
constructor(
|
||||
readonly code: string,
|
||||
message: string,
|
||||
readonly path = "$",
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ClawMigrationError";
|
||||
}
|
||||
}
|
||||
227
src/claws/migrate-package.ts
Normal file
227
src/claws/migrate-package.ts
Normal 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 });
|
||||
}
|
||||
80
src/claws/migrate-safety.ts
Normal file
80
src/claws/migrate-safety.ts
Normal 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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
319
src/claws/migrate-validation.ts
Normal file
319
src/claws/migrate-validation.ts
Normal 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 } : {}),
|
||||
};
|
||||
}
|
||||
73
src/claws/migrate-workspace-files.ts
Normal file
73
src/claws/migrate-workspace-files.ts
Normal 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
385
src/claws/migrate.test.ts
Normal 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
603
src/claws/migrate.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
200
src/claws/provenance-adopted.ts
Normal file
200
src/claws/provenance-adopted.ts
Normal 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);
|
||||
}
|
||||
70
src/claws/provenance-agent-origin.ts
Normal file
70
src/claws/provenance-agent-origin.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
21
src/claws/provenance-secondary-references.ts
Normal file
21
src/claws/provenance-secondary-references.ts
Normal 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),
|
||||
);
|
||||
});
|
||||
}
|
||||
26
src/claws/provenance-types.ts
Normal file
26
src/claws/provenance-types.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
141
src/claws/update-apply.adopted.test.ts
Normal file
141
src/claws/update-apply.adopted.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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" }) },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
133
src/cli/claws-migrate-cli.runtime.test.ts
Normal file
133
src/cli/claws-migrate-cli.runtime.test.ts
Normal 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();
|
||||
},
|
||||
);
|
||||
});
|
||||
216
src/cli/claws-migrate-cli.runtime.ts
Normal file
216
src/cli/claws-migrate-cli.runtime.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue