diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index b7f17cdd303f..76f895fde3d6 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -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 diff --git a/docs/cli/claws.md b/docs/cli/claws.md index 794f3daaba2a..88d4d2960aa2 100644 --- a/docs/cli/claws.md +++ b/docs/cli/claws.md @@ -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 \ + --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 diff --git a/scripts/database-worker-inventory.mjs b/scripts/database-worker-inventory.mjs index f3c8984c0595..aa1131a51c1d 100644 --- a/scripts/database-worker-inventory.mjs +++ b/scripts/database-worker-inventory.mjs @@ -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", diff --git a/src/agents/mcp-config-mutation.test.ts b/src/agents/mcp-config-mutation.test.ts index a63c374a5bf8..3c16062821bf 100644 --- a/src/agents/mcp-config-mutation.test.ts +++ b/src/agents/mcp-config-mutation.test.ts @@ -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 }); diff --git a/src/claws/export.ts b/src/claws/export.ts index 1c92cae6248d..7b16f2c6946a 100644 --- a/src/claws/export.ts +++ b/src/claws/export.ts @@ -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 { diff --git a/src/claws/lifecycle-adopted-removal.ts b/src/claws/lifecycle-adopted-removal.ts new file mode 100644 index 000000000000..1cbe3557dceb --- /dev/null +++ b/src/claws/lifecycle-adopted-removal.ts @@ -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 { + 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, + ); +} diff --git a/src/claws/lifecycle-delete-support.ts b/src/claws/lifecycle-delete-support.ts index 52d01e6c034f..074eaea1707b 100644 --- a/src/claws/lifecycle-delete-support.ts +++ b/src/claws/lifecycle-delete-support.ts @@ -93,6 +93,7 @@ export function synthesizeOrphanInstall(params: { agentId: params.agentId, workspace: params.workspace ?? "", agentConfigDigest: "sha256:missing", + agentOrigin: "created", agentOwnedPaths: [], status: "partial", addedAtMs: updatedAtMs, diff --git a/src/claws/lifecycle-remove-approvals.test.ts b/src/claws/lifecycle-remove-approvals.test.ts index 30d7d8865935..0077c20c76a2 100644 --- a/src/claws/lifecycle-remove-approvals.test.ts +++ b/src/claws/lifecycle-remove-approvals.test.ts @@ -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); diff --git a/src/claws/lifecycle-remove-state-blockers.ts b/src/claws/lifecycle-remove-state-blockers.ts new file mode 100644 index 000000000000..e6dcbd44f44f --- /dev/null +++ b/src/claws/lifecycle-remove-state-blockers.ts @@ -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; +} diff --git a/src/claws/lifecycle-state.test.ts b/src/claws/lifecycle-state.test.ts index 9c1321231001..f609ec94309f 100644 --- a/src/claws/lifecycle-state.test.ts +++ b/src/claws/lifecycle-state.test.ts @@ -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( diff --git a/src/claws/lifecycle-state.ts b/src/claws/lifecycle-state.ts index 6e8483b0cb4d..39e7412badff 100644 --- a/src/claws/lifecycle-state.ts +++ b/src/claws/lifecycle-state.ts @@ -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" || diff --git a/src/claws/lifecycle-status.ts b/src/claws/lifecycle-status.ts index 075e5549b3b3..c97bc29affe4 100644 --- a/src/claws/lifecycle-status.ts +++ b/src/claws/lifecycle-status.ts @@ -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, diff --git a/src/claws/lifecycle.e2e.test.ts b/src/claws/lifecycle.e2e.test.ts index abec46274980..e33d237ee30b 100644 --- a/src/claws/lifecycle.e2e.test.ts +++ b/src/claws/lifecycle.e2e.test.ts @@ -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, diff --git a/src/claws/mcp.test.ts b/src/claws/mcp.test.ts index da766dcf15d6..74a32303920f 100644 --- a/src/claws/mcp.test.ts +++ b/src/claws/mcp.test.ts @@ -60,7 +60,7 @@ async function fixture(agentId = "worker", root?: string) { } function listedMcpServers(mcpServers: Record> = {}) { - return { ok: true as const, path: "config", config: {}, mcpServers }; + return { ok: true as const, path: "config", config: {}, mcpServers, runtimeConfig: {} }; } describe("installClawMcpServers", () => { diff --git a/src/claws/migrate-errors.ts b/src/claws/migrate-errors.ts new file mode 100644 index 000000000000..017ecae77614 --- /dev/null +++ b/src/claws/migrate-errors.ts @@ -0,0 +1,10 @@ +export class ClawMigrationError extends Error { + constructor( + readonly code: string, + message: string, + readonly path = "$", + ) { + super(message); + this.name = "ClawMigrationError"; + } +} diff --git a/src/claws/migrate-package.ts b/src/claws/migrate-package.ts new file mode 100644 index 000000000000..0f1a4585f5ed --- /dev/null +++ b/src/claws/migrate-package.ts @@ -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): { + 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; +} { + const bootstrapFiles: ClawManifest["workspace"]["bootstrapFiles"] = {}; + const workspaceFiles: ClawManifest["workspace"]["files"] = []; + const packageFiles = new Map(); + 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, +): Promise { + 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, +): Promise { + 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): Promise { + 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 { + await rm(root, { recursive: true, force: true }); +} diff --git a/src/claws/migrate-safety.ts b/src/claws/migrate-safety.ts new file mode 100644 index 000000000000..418ec43ff6c3 --- /dev/null +++ b/src/claws/migrate-safety.ts @@ -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>, +): Promise { + 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}`, + ); + } +} diff --git a/src/claws/migrate-validation.ts b/src/claws/migrate-validation.ts new file mode 100644 index 000000000000..111141df06b0 --- /dev/null +++ b/src/claws/migrate-validation.ts @@ -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["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 | 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 } : {}), + }; +} diff --git a/src/claws/migrate-workspace-files.ts b/src/claws/migrate-workspace-files.ts new file mode 100644 index 000000000000..b6f2b2b8a59e --- /dev/null +++ b/src/claws/migrate-workspace-files.ts @@ -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 { + 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; +} diff --git a/src/claws/migrate.test.ts b/src/claws/migrate.test.ts new file mode 100644 index 000000000000..a4b175fce820 --- /dev/null +++ b/src/claws/migrate.test.ts @@ -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 = {}) { + 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); + }); +}); diff --git a/src/claws/migrate.ts b/src/claws/migrate.ts new file mode 100644 index 000000000000..e7afb5d0e98f --- /dev/null +++ b/src/claws/migrate.ts @@ -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>; + manifest: ClawManifest; + profile?: ClawOpenClawProfile; + clawMarkdownBody?: Buffer; + packageFiles: Map; + 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 { + 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>, + packageFiles: Map, +) { + 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 { + 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(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>, { 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; +}): Promise { + 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; + } +} diff --git a/src/claws/provenance-adopted.ts b/src/claws/provenance-adopted.ts new file mode 100644 index 000000000000..7f06a038f6e7 --- /dev/null +++ b/src/claws/provenance-adopted.ts @@ -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; + +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(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(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(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(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); +} diff --git a/src/claws/provenance-agent-origin.ts b/src/claws/provenance-agent-origin.ts new file mode 100644 index 000000000000..5b346450cf30 --- /dev/null +++ b/src/claws/provenance-agent-origin.ts @@ -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), + }; +} diff --git a/src/claws/provenance-schema-version.ts b/src/claws/provenance-schema-version.ts index 3e24c56f3f0a..71672d51945e 100644 --- a/src/claws/provenance-schema-version.ts +++ b/src/claws/provenance-schema-version.ts @@ -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; } diff --git a/src/claws/provenance-secondary-references.ts b/src/claws/provenance-secondary-references.ts new file mode 100644 index 000000000000..8d6bbb4bedba --- /dev/null +++ b/src/claws/provenance-secondary-references.ts @@ -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), + ); + }); +} diff --git a/src/claws/provenance-types.ts b/src/claws/provenance-types.ts new file mode 100644 index 000000000000..ea01b1d2e9f6 --- /dev/null +++ b/src/claws/provenance-types.ts @@ -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; + 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; +}; diff --git a/src/claws/provenance.ts b/src/claws/provenance.ts index c586e549cc0b..11d4bade4380 100644 --- a/src/claws/provenance.ts +++ b/src/claws/provenance.ts @@ -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; - -export type ClawInstallStatus = - | "pending" - | "workspace_ready" - | "config_committed" - | "complete" - | "partial"; - -export type PersistedClawInstall = { - schemaVersion: ReturnType; - 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(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, diff --git a/src/claws/tool-policy-candidates.ts b/src/claws/tool-policy-candidates.ts index 833a9b149989..c3ca3950cc9c 100644 --- a/src/claws/tool-policy-candidates.ts +++ b/src/claws/tool-policy-candidates.ts @@ -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, + }, + ]; }); } diff --git a/src/claws/tool-policy-runtime.integration.test.ts b/src/claws/tool-policy-runtime.integration.test.ts index 8b7f5e9056e2..6e7266c534ae 100644 --- a/src/claws/tool-policy-runtime.integration.test.ts +++ b/src/claws/tool-policy-runtime.integration.test.ts @@ -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); diff --git a/src/claws/tool-policy-runtime.ts b/src/claws/tool-policy-runtime.ts index af25c85ff795..1a64e286e4ac 100644 --- a/src/claws/tool-policy-runtime.ts +++ b/src/claws/tool-policy-runtime.ts @@ -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(); diff --git a/src/claws/update-apply.adopted.test.ts b/src/claws/update-apply.adopted.test.ts new file mode 100644 index 000000000000..30540be814e0 --- /dev/null +++ b/src/claws/update-apply.adopted.test.ts @@ -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"); + }); +}); diff --git a/src/claws/update-apply.test-helpers.ts b/src/claws/update-apply.test-helpers.ts index 66e28c4e045c..90e1ca7de07b 100644 --- a/src/claws/update-apply.test-helpers.ts +++ b/src/claws/update-apply.test-helpers.ts @@ -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, diff --git a/src/claws/update-apply.ts b/src/claws/update-apply.ts index 9909c992baa4..0852e6ec680d 100644 --- a/src/claws/update-apply.ts +++ b/src/claws/update-apply.ts @@ -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 => { 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) { diff --git a/src/claws/update-plan.test-helpers.ts b/src/claws/update-plan.test-helpers.ts index 0e77d9f83676..34ace237f3b5 100644 --- a/src/claws/update-plan.test-helpers.ts +++ b/src/claws/update-plan.test-helpers.ts @@ -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" }) }, }); diff --git a/src/claws/update-plan.ts b/src/claws/update-plan.ts index 8f695ec48ddd..fba6d5c4c8cd 100644 --- a/src/claws/update-plan.ts +++ b/src/claws/update-plan.ts @@ -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" diff --git a/src/cli/claws-cli.ts b/src/cli/claws-cli.ts index 97227521e706..86e5b9d75fcc 100644 --- a/src/cli/claws-cli.ts +++ b/src/cli/claws-cli.ts @@ -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 & { @@ -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("", "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 ", "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") diff --git a/src/cli/claws-migrate-cli.runtime.test.ts b/src/cli/claws-migrate-cli.runtime.test.ts new file mode 100644 index 000000000000..4fb4dc205dd5 --- /dev/null +++ b/src/cli/claws-migrate-cli.runtime.test.ts @@ -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) => 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(); + }, + ); +}); diff --git a/src/cli/claws-migrate-cli.runtime.ts b/src/cli/claws-migrate-cli.runtime.ts new file mode 100644 index 000000000000..649d1b040458 --- /dev/null +++ b/src/cli/claws-migrate-cli.runtime.ts @@ -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>["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 { + 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>; + let previewConfig: Awaited>; + 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); + } +} diff --git a/src/cli/claws-update-cli.runtime.ts b/src/cli/claws-update-cli.runtime.ts index 3f758f2c6f03..b12e423ce5ef 100644 --- a/src/cli/claws-update-cli.runtime.ts +++ b/src/cli/claws-update-cli.runtime.ts @@ -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(); diff --git a/src/config/mcp-config.ts b/src/config/mcp-config.ts index 35e3ebd54fdf..0851db082762 100644 --- a/src/config/mcp-config.ts +++ b/src/config/mcp-config.ts @@ -29,8 +29,16 @@ type ConfigMcpSuccess = { config: OpenClawConfig; mcpServers: ConfigMcpServers; }; +type ConfigMcpReadSuccess = ConfigMcpSuccess & { + runtimeConfig: Awaited>["runtimeConfig"]; + sourceConfigBeforeMigrations?: Awaited< + ReturnType + >["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, }; }