diff --git a/docs/cli/claws.md b/docs/cli/claws.md index fb99101cd950..b4f63043f4de 100644 --- a/docs/cli/claws.md +++ b/docs/cli/claws.md @@ -185,14 +185,28 @@ Use `--force-referenced` only after reviewing the displayed dependents, independent owners, and pre-existing origin. It allows selected cleanup despite those conflicts; it does not skip plan-integrity consent. +## Export an installed agent + +Export creates a new package directory and fails if the destination exists or +managed state has drifted: + +```bash +openclaw claws export incident-triage --out ./incident-triage-export --json +``` + +The result contains `package.json`, `openclaw.claw.json`, and managed workspace +sidecars. It is a portable Claw package, not a whole-instance backup: unrelated +agents, credentials, sessions, and unowned local state are excluded. + ## Command reference -| Command | Purpose | -| ------------------------------ | --------------------------------------------------- | -| `claws inspect ` | Validate a package directory or JSON manifest. | -| `claws add ` | Preview or create one new agent and workspace. | -| `claws status [claw-or-agent]` | Report installed state, ownership, and drift. | -| `claws remove ` | Preview or remove the agent and eligible resources. | +| Command | Purpose | +| ----------------------------------- | --------------------------------------------------- | +| `claws inspect ` | Validate a package directory or JSON manifest. | +| `claws add ` | Preview or create one new agent and workspace. | +| `claws status [claw-or-agent]` | Report installed state, ownership, and drift. | +| `claws remove ` | Preview or remove the agent and eligible resources. | +| `claws export --out ` | Create a portable package from an installed agent. | Use `--json` for experimental machine-readable output. diff --git a/docs/docs_map.md b/docs/docs_map.md index 6c756a26b1bf..6e0bde3b7eaf 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -1376,6 +1376,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Inspect and preview - H2: Inspect installed state - H2: Remove an installed Claw + - H2: Export an installed agent - H2: Command reference - H2: See also diff --git a/src/claws/export.test.ts b/src/claws/export.test.ts new file mode 100644 index 000000000000..97cf4bafb67a --- /dev/null +++ b/src/claws/export.test.ts @@ -0,0 +1,292 @@ +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { afterEach, describe, expect, it, vi } 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 { applyClawAddPlan } from "./add.js"; +import { exportClawAgent } from "./export.js"; +import { buildClawAddPlan } from "./lifecycle.js"; +import { persistClawPackageRef, updateClawInstallRecordStatus } from "./provenance.js"; +import { parseClawManifest } from "./schema.js"; +import type { ClawSourceIdentity } from "./types.js"; + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +afterEach(() => { + closeOpenClawStateDatabaseForTest(); + vi.unstubAllEnvs(); +}); + +async function installedFixture( + options: { + avatar?: string; + extraWorkspaceFiles?: string[]; + withPackage?: boolean; + } = {}, +) { + const root = tempDirs.make("openclaw-claw-export-"); + await mkdir(join(root, "source", "reference"), { recursive: true }); + const content = (label: string) => `managed ${label}\n`; + await writeFile(join(root, "source", "SOUL.md"), content("soul")); + await writeFile(join(root, "source", "reference", "policy.md"), content("policy")); + for (const path of options.extraWorkspaceFiles ?? []) { + await mkdir(join(root, "source", dirname(path)), { recursive: true }); + await writeFile(join(root, "source", path), content(path)); + } + const parsed = parseClawManifest({ + schemaVersion: 1, + agent: { + id: "worker", + name: "Worker", + ...(options.avatar ? { identity: { avatar: options.avatar } } : {}), + tools: { deny: ["exec"] }, + }, + workspace: { + bootstrapFiles: { "SOUL.md": { source: "source/SOUL.md" } }, + files: [ + { source: "source/reference/policy.md", path: "reference/policy.md" }, + ...(options.extraWorkspaceFiles ?? []).map((path) => ({ source: `source/${path}`, path })), + ], + }, + }); + if (!parsed.ok) { + throw new Error(JSON.stringify(parsed.diagnostics)); + } + const source: ClawSourceIdentity = { + kind: "package", + name: "@acme/worker", + version: "1.2.3", + packageRoot: root, + manifestPath: join(root, "openclaw.claw.json"), + integrityKind: "artifact", + integrity: "sha256:manifest", + byteLength: 100, + }; + const plan = await buildClawAddPlan({ + manifest: parsed.manifest, + source, + context: { workspace: join(root, "workspace-worker") }, + }); + let config: OpenClawConfig = {}; + await applyClawAddPlan(plan, { + consentPlanIntegrity: plan.planIntegrity, + env: { OPENCLAW_STATE_DIR: join(root, "state") }, + commitConfig: async (transform) => { + config = transform(config); + }, + }); + if (options.withPackage) { + persistClawPackageRef( + plan, + { + kind: "skill", + source: "clawhub", + ref: "@acme/triage", + version: "2.0.0", + integrity: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }, + { env: { OPENCLAW_STATE_DIR: join(root, "state") } }, + ); + } + return { + root, + plan, + config, + env: { OPENCLAW_STATE_DIR: join(root, "state") }, + packageDeps: { + planSkill: async () => ({ + ok: true as const, + plan: { + workspaceDir: plan.agent.workspace, + slug: "@acme/triage", + version: "2.0.0", + installedAt: 0, + targetDir: join(plan.agent.workspace, "skills", "@acme", "triage"), + skillFilePath: join(plan.agent.workspace, "skills", "@acme", "triage", "SKILL.md"), + skillFileSha256: "a".repeat(64), + fileTreeSha256: `sha256:${"a".repeat(64)}`, + }, + }), + }, + }; +} + +describe("exportClawAgent", () => { + it("writes a grouped package from one installed agent", async () => { + const fixture = await installedFixture({ withPackage: true }); + const out = join(fixture.root, "exported"); + + const result = await exportClawAgent("worker", out, { + env: fixture.env, + config: fixture.config, + packageDeps: fixture.packageDeps, + }); + + expect(result).toMatchObject({ + schemaVersion: "openclaw.clawExportResult.v1", + stability: "experimental", + agentId: "worker", + manifest: { + schemaVersion: 1, + agent: { id: "worker", name: "Worker", tools: { deny: ["exec"] } }, + workspace: { + bootstrapFiles: { "SOUL.md": { source: "workspace/SOUL.md" } }, + files: [{ source: "workspace/reference/policy.md", path: "reference/policy.md" }], + }, + packages: [ + { + kind: "skill", + source: "clawhub", + ref: "@acme/triage", + version: "2.0.0", + }, + ], + mcpServers: {}, + cronJobs: [], + }, + }); + const packageJson = JSON.parse(await readFile(join(out, "package.json"), "utf8")); + expect(packageJson).toMatchObject({ + name: "openclaw-claw-worker", + openclaw: { claw: "openclaw.claw.json" }, + }); + expect(packageJson.version).toMatch(/^0\.0\.0-export\.[0-9a-f]{64}$/); + await expect(readFile(join(out, "workspace", "SOUL.md"), "utf8")).resolves.toBe( + "managed soul\n", + ); + }); + + it("rejects modified managed content instead of silently creating a snapshot", async () => { + const fixture = await installedFixture(); + await writeFile(join(fixture.plan.agent.workspace, "SOUL.md"), "operator revision\n", "utf8"); + const out = join(fixture.root, "exported-edited"); + + await expect( + exportClawAgent("worker", out, { + env: fixture.env, + config: fixture.config, + packageDeps: fixture.packageDeps, + }), + ).rejects.toMatchObject({ code: "workspace_files_drifted" }); + }); + + it("rejects a partial install rather than exporting an incomplete snapshot", async () => { + const fixture = await installedFixture(); + updateClawInstallRecordStatus("worker", "partial", { env: fixture.env }); + + await expect( + exportClawAgent("worker", join(fixture.root, "exported-partial"), { + env: fixture.env, + config: fixture.config, + packageDeps: fixture.packageDeps, + }), + ).rejects.toMatchObject({ code: "install_incomplete" }); + }); + + it("rejects agent configuration drift", async () => { + const fixture = await installedFixture(); + const agent = fixture.config.agents!.entries!.worker!; + agent.name = "Locally changed worker"; + + await expect( + exportClawAgent("worker", join(fixture.root, "exported-agent-drift"), { + env: fixture.env, + config: fixture.config, + }), + ).rejects.toMatchObject({ code: "agent_drifted" }); + }); + + it("rejects missing or drifted package dependencies", async () => { + const fixture = await installedFixture({ withPackage: true }); + + await expect( + exportClawAgent("worker", join(fixture.root, "exported-package-drift"), { + env: fixture.env, + config: fixture.config, + packageDeps: { + planSkill: async () => ({ ok: false as const, code: "missing", error: "missing" }), + }, + }), + ).rejects.toMatchObject({ code: "packages_drifted" }); + }); + + it("packages a safe workspace-relative avatar as a sidecar", async () => { + const fixture = await installedFixture({ + avatar: "avatars/worker.png", + extraWorkspaceFiles: ["avatars/worker.png"], + }); + const avatarPath = join(fixture.plan.agent.workspace, "avatars", "worker.png"); + const out = join(fixture.root, "exported-avatar"); + + const result = await exportClawAgent("worker", out, { + env: fixture.env, + config: fixture.config, + }); + + expect(result.manifest.agent.identity?.avatar).toBe("avatars/worker.png"); + expect(result.manifest.workspace.files).toContainEqual({ + source: "workspace/avatars/worker.png", + path: "avatars/worker.png", + }); + await expect(readFile(join(out, "workspace", "avatars", "worker.png"), "utf8")).resolves.toBe( + "managed avatars/worker.png\n", + ); + await expect(readFile(avatarPath, "utf8")).resolves.toBe("managed avatars/worker.png\n"); + }); + + it("rejects an agent whose effective workspace changed after installation", async () => { + const fixture = await installedFixture(); + const movedWorkspace = join(fixture.root, "moved-workspace"); + await mkdir(movedWorkspace); + const agent = fixture.config.agents!.entries!.worker!; + agent.workspace = movedWorkspace; + + await expect( + exportClawAgent("worker", join(fixture.root, "exported-moved-workspace"), { + env: fixture.env, + config: fixture.config, + }), + ).rejects.toMatchObject({ code: "workspace_changed" }); + }); + + it("expands a home-relative output directory", async () => { + const fixture = await installedFixture(); + vi.stubEnv("HOME", fixture.root); + + const result = await exportClawAgent("worker", "~/exported-home", { + env: fixture.env, + config: fixture.config, + }); + + expect(result.outputDirectory).toBe(join(fixture.root, "exported-home")); + await expect( + readFile(join(result.outputDirectory, "openclaw.claw.json"), "utf8"), + ).resolves.toContain('"schemaVersion": 1'); + }); + + it("fails closed when a managed file is unavailable", async () => { + const fixture = await installedFixture(); + await writeFile(join(fixture.plan.agent.workspace, "SOUL.md"), "still available\n", "utf8"); + await rm(join(fixture.plan.agent.workspace, "reference", "policy.md")); + + await expect( + exportClawAgent("worker", join(fixture.root, "exported-missing"), { + env: fixture.env, + config: fixture.config, + }), + ).rejects.toMatchObject({ code: "workspace_files_drifted" }); + }); + + it("never writes into an existing output directory", async () => { + const fixture = await installedFixture(); + const out = join(fixture.root, "existing"); + await mkdir(out); + await writeFile(join(out, "operator.txt"), "keep\n", "utf8"); + + await expect( + exportClawAgent("worker", out, { env: fixture.env, config: fixture.config }), + ).rejects.toMatchObject({ code: "output_collision" }); + await expect(readFile(join(out, "operator.txt"), "utf8")).resolves.toBe("keep\n"); + }); +}); diff --git a/src/claws/export.ts b/src/claws/export.ts new file mode 100644 index 000000000000..11f033471fab --- /dev/null +++ b/src/claws/export.ts @@ -0,0 +1,359 @@ +import { createHash } from "node:crypto"; +import { closeSync } from "node:fs"; +import { mkdir, realpath, rm } from "node:fs/promises"; +import { dirname, relative, resolve, sep } from "node:path"; +import { listAgentEntries, resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; +import { openLocalAgentAvatarFile } from "../agents/identity-avatar-file.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { readFileDescriptorBoundedSync } from "../infra/boundary-file-read.js"; +import { root as fsSafeRoot } from "../infra/fs-safe.js"; +import { AVATAR_MAX_BYTES, isAvatarDataUrl, isAvatarHttpUrl } from "../shared/avatar-policy.js"; +import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js"; +import { resolveUserPath } from "../utils.js"; +import { readClawStatus } from "./lifecycle-state.js"; +import type { PackageRemovalDeps } from "./package-remove.js"; +import { isPortableClawAvatar } from "./schema-portability.js"; +import { parseClawManifest } from "./schema.js"; +import { MAX_MANAGED_WORKSPACE_BYTES } from "./source-limits.js"; +import { + CLAW_BOOTSTRAP_FILE_NAMES, + CLAW_OUTPUT_STABILITY, + CLAW_SCHEMA_VERSION, + type ClawManifest, +} from "./types.js"; + +export const CLAW_EXPORT_RESULT_SCHEMA_VERSION = "openclaw.clawExportResult.v1" as const; +const MAX_EXPORT_FILE_BYTES = 1024 * 1024; + +type AgentConfig = NonNullable["list"]>[number]; +type ClawAgent = ClawManifest["agent"]; +type ClawBootstrapFileName = keyof ClawManifest["workspace"]["bootstrapFiles"]; + +type ClawExportResult = { + schemaVersion: typeof CLAW_EXPORT_RESULT_SCHEMA_VERSION; + stability: typeof CLAW_OUTPUT_STABILITY; + agentId: string; + outputDirectory: string; + manifest: ClawManifest; + filesWritten: string[]; +}; + +export class ClawExportError extends Error { + constructor( + readonly code: string, + message: string, + ) { + super(message); + this.name = "ClawExportError"; + } +} + +function portableAgent(agent: AgentConfig, avatar: string | undefined): ClawAgent { + const identity = { + ...(agent.identity?.name ? { name: agent.identity.name } : {}), + ...(agent.identity?.theme ? { theme: agent.identity.theme } : {}), + ...(agent.identity?.emoji ? { emoji: agent.identity.emoji } : {}), + ...(avatar ? { avatar } : {}), + }; + const tools = { + ...(agent.tools?.allow?.length ? { allow: agent.tools.allow } : {}), + ...(agent.tools?.deny?.length ? { deny: agent.tools.deny } : {}), + }; + return { + id: agent.id, + ...(agent.name ? { name: agent.name } : {}), + ...(agent.description ? { description: agent.description } : {}), + ...(Object.keys(identity).length > 0 ? { identity } : {}), + ...(agent.groupChat?.mentionPatterns?.length + ? { groupChat: { mentionPatterns: agent.groupChat.mentionPatterns } } + : {}), + ...(agent.sandbox + ? { + sandbox: { + ...(agent.sandbox.mode ? { mode: agent.sandbox.mode } : {}), + ...(agent.sandbox.scope ? { scope: agent.sandbox.scope } : {}), + ...(agent.sandbox.workspaceAccess + ? { workspaceAccess: agent.sandbox.workspaceAccess } + : {}), + }, + } + : {}), + ...(Object.keys(tools).length > 0 ? { tools } : {}), + ...(agent.heartbeat + ? { + heartbeat: { + ...(agent.heartbeat.every ? { every: agent.heartbeat.every } : {}), + ...(agent.heartbeat.activeHours + ? { + activeHours: { + ...(agent.heartbeat.activeHours.start + ? { start: agent.heartbeat.activeHours.start } + : {}), + ...(agent.heartbeat.activeHours.end + ? { end: agent.heartbeat.activeHours.end } + : {}), + ...(agent.heartbeat.activeHours.timezone + ? { timezone: agent.heartbeat.activeHours.timezone } + : {}), + }, + } + : {}), + ...(agent.heartbeat.lightContext !== undefined + ? { lightContext: agent.heartbeat.lightContext } + : {}), + ...(agent.heartbeat.isolatedSession !== undefined + ? { isolatedSession: agent.heartbeat.isolatedSession } + : {}), + ...(agent.heartbeat.timeoutSeconds !== undefined + ? { timeoutSeconds: agent.heartbeat.timeoutSeconds } + : {}), + }, + } + : {}), + ...(agent.humanDelay + ? { + humanDelay: { + ...(agent.humanDelay.mode ? { mode: agent.humanDelay.mode } : {}), + ...(agent.humanDelay.minMs !== undefined ? { minMs: agent.humanDelay.minMs } : {}), + ...(agent.humanDelay.maxMs !== undefined ? { maxMs: agent.humanDelay.maxMs } : {}), + }, + } + : {}), + }; +} + +function normalizedRelativePath(value: string): string { + return value.split(sep).join("/"); +} + +function comparePortableText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function isClawBootstrapFileName(value: string): value is ClawBootstrapFileName { + return (CLAW_BOOTSTRAP_FILE_NAMES as readonly string[]).includes(value); +} + +function readPortableAvatar(params: { + config: OpenClawConfig; + agent: AgentConfig; + workspace: string; +}): { source?: string; sidecar?: { path: string; content: Buffer } } { + const source = params.agent.identity?.avatar?.trim(); + if (!source) { + return {}; + } + if (isAvatarHttpUrl(source)) { + return {}; + } + if (isAvatarDataUrl(source)) { + return isPortableClawAvatar(source) ? { source } : {}; + } + const opened = openLocalAgentAvatarFile({ + cfg: params.config, + agentId: params.agent.id, + source, + }); + if (!opened.ok) { + return {}; + } + try { + const content = readFileDescriptorBoundedSync(opened.file.fd, AVATAR_MAX_BYTES); + const path = normalizedRelativePath(relative(params.workspace, opened.file.path)); + return { source: path, sidecar: { path, content } }; + } catch { + return {}; + } finally { + closeSync(opened.file.fd); + } +} + +function derivativePackageVersion(manifest: ClawManifest, contents: ExportContent[]): string { + const hash = createHash("sha256").update(JSON.stringify(manifest)); + for (const file of contents.toSorted((left, right) => + comparePortableText(left.path, right.path), + )) { + hash.update(file.path).update("\0").update(file.content).update("\0"); + } + return `0.0.0-export.${hash.digest("hex")}`; +} + +type ExportContent = { path: string; content: Buffer }; + +export async function exportClawAgent( + agentId: string, + outputDirectory: string, + options: OpenClawStateDatabaseOptions & { + config: OpenClawConfig; + packageDeps?: PackageRemovalDeps; + }, +): Promise { + const status = await readClawStatus(agentId, options); + const record = status.records.find((candidate) => candidate.install.agentId === agentId); + if (!record) { + throw new ClawExportError( + "claw_not_found", + `No installed Claw agent matches ${JSON.stringify(agentId)}.`, + ); + } + if (record.install.status !== "complete") { + throw new ClawExportError( + "install_incomplete", + `Installed Claw agent ${JSON.stringify(agentId)} is in ${JSON.stringify(record.install.status)} state; finish or repair it before export.`, + ); + } + const agent = listAgentEntries(options.config).find((candidate) => candidate.id === agentId); + if (!agent) { + throw new ClawExportError( + "agent_missing", + `Installed Claw agent ${JSON.stringify(agentId)} is missing from config.`, + ); + } + const currentWorkspace = await realpath( + resolve(resolveAgentWorkspaceDir(options.config, agentId)), + ).catch(() => resolve(resolveAgentWorkspaceDir(options.config, agentId))); + if (currentWorkspace !== record.install.workspace) { + throw new ClawExportError( + "workspace_changed", + `Agent ${JSON.stringify(agentId)} now resolves to workspace ${JSON.stringify(currentWorkspace)} instead of its recorded Claw workspace ${JSON.stringify(record.install.workspace)}.`, + ); + } + if (record.agentState !== "present") { + throw new ClawExportError( + "agent_drifted", + `Agent ${JSON.stringify(agentId)} no longer matches its recorded Claw configuration.`, + ); + } + const driftedFiles = record.workspaceFiles.filter((file) => file.state !== "unchanged"); + if (driftedFiles.length > 0) { + throw new ClawExportError( + "workspace_files_drifted", + `Cannot export drifted managed files: ${driftedFiles.map((file) => `${file.path} (${file.state})`).join(", ")}.`, + ); + } + const driftedPackages = record.packages.filter((pkg) => pkg.state !== "present"); + if (driftedPackages.length > 0) { + throw new ClawExportError( + "packages_drifted", + `Cannot export drifted packages: ${driftedPackages.map((pkg) => `${pkg.kind}:${pkg.ref}@${pkg.version} (${pkg.state})`).join(", ")}.`, + ); + } + + const workspace = await fsSafeRoot(record.install.workspace, { + hardlinks: "reject", + maxBytes: MAX_EXPORT_FILE_BYTES, + symlinks: "reject", + }); + const contents: ExportContent[] = await Promise.all( + record.workspaceFiles.map(async (file) => ({ + path: normalizedRelativePath(file.path), + content: await workspace.readBytes(file.path, { maxBytes: MAX_EXPORT_FILE_BYTES }), + })), + ); + const avatar = readPortableAvatar({ + config: options.config, + agent, + workspace: record.install.workspace, + }); + const managedPaths = new Set(contents.map((file) => file.path)); + if (avatar.sidecar && !managedPaths.has(avatar.sidecar.path)) { + contents.push(avatar.sidecar); + } + const aggregateBytes = contents.reduce((total, file) => total + file.content.byteLength, 0); + if (aggregateBytes > MAX_MANAGED_WORKSPACE_BYTES) { + throw new ClawExportError( + "workspace_files_oversized", + `Exported workspace content exceeds ${MAX_MANAGED_WORKSPACE_BYTES} aggregate bytes.`, + ); + } + const bootstrapFiles: ClawManifest["workspace"]["bootstrapFiles"] = {}; + const files: ClawManifest["workspace"]["files"] = []; + for (const file of contents) { + const source = `workspace/${file.path}`; + if (isClawBootstrapFileName(file.path)) { + bootstrapFiles[file.path] = { source }; + } else { + files.push({ source, path: file.path }); + } + } + const manifest: ClawManifest = { + schemaVersion: CLAW_SCHEMA_VERSION, + agent: portableAgent(agent, avatar.source), + workspace: { bootstrapFiles, files }, + packages: record.packages + .map((pkg) => ({ + kind: pkg.kind, + source: pkg.source, + ref: pkg.ref, + version: pkg.version, + })) + .toSorted((left, right) => { + const leftIdentity = `${left.kind}:${left.ref}:${left.version}`; + const rightIdentity = `${right.kind}:${right.ref}:${right.version}`; + return comparePortableText(leftIdentity, rightIdentity); + }), + mcpServers: {}, + cronJobs: [], + }; + const parsed = parseClawManifest(manifest); + if (!parsed.ok) { + throw new ClawExportError( + "export_manifest_invalid", + parsed.diagnostics.map((diagnostic) => diagnostic.message).join("; "), + ); + } + + const target = resolve(resolveUserPath(outputDirectory)); + await mkdir(dirname(target), { recursive: true }); + try { + await mkdir(target); + } catch (error) { + throw new ClawExportError( + "output_collision", + `Export directory ${JSON.stringify(target)} must not already exist: ${(error as Error).message}`, + ); + } + const filesWritten: string[] = []; + try { + const output = await fsSafeRoot(target, { + hardlinks: "reject", + maxBytes: MAX_EXPORT_FILE_BYTES, + symlinks: "reject", + }); + for (const file of contents) { + const path = `workspace/${file.path}`; + await output.write(path, file.content, { mkdir: true, overwrite: false }); + filesWritten.push(path); + } + const packageJson = { + name: `openclaw-claw-${record.install.agentId}`, + version: derivativePackageVersion(manifest, contents), + type: "module", + openclaw: { claw: "openclaw.claw.json" }, + }; + await output.write("package.json", Buffer.from(`${JSON.stringify(packageJson, null, 2)}\n`), { + overwrite: false, + }); + filesWritten.push("package.json"); + await output.write( + "openclaw.claw.json", + Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`), + { overwrite: false }, + ); + filesWritten.push("openclaw.claw.json"); + } catch (error) { + await rm(target, { recursive: true, force: true }).catch(() => undefined); + throw new ClawExportError( + "export_write_failed", + error instanceof Error ? error.message : String(error), + ); + } + return { + schemaVersion: CLAW_EXPORT_RESULT_SCHEMA_VERSION, + stability: CLAW_OUTPUT_STABILITY, + agentId, + outputDirectory: target, + manifest, + filesWritten, + }; +} diff --git a/src/claws/lifecycle.e2e.test.ts b/src/claws/lifecycle.e2e.test.ts index 263a0bbb95d1..be7ef8c2775d 100644 --- a/src/claws/lifecycle.e2e.test.ts +++ b/src/claws/lifecycle.e2e.test.ts @@ -269,6 +269,86 @@ describe("claws lifecycle cli e2e", () => { expect(config.agents).toEqual({ entries: { main: { default: true } } }); }); + it("exports an installed agent as a self-contained grouped package", async () => { + const source = "src/claws/fixtures/workspace-agent.claw.json"; + const addPreview = await runOpenClaw(["claws", "add", source, "--dry-run", "--json"]); + const addPlan = parseJson(addPreview.stdout) as { planIntegrity: string }; + const added = await runOpenClaw( + ["claws", "add", source, "--yes", "--plan-integrity", addPlan.planIntegrity, "--json"], + { stateDir: addPreview.stateDir }, + ); + const outputDirectory = join(added.stateDir, "exported-claw"); + const exported = await runOpenClaw( + ["claws", "export", "workspace-agent", "--out", outputDirectory, "--json"], + { stateDir: added.stateDir }, + ); + expect(parseJson(exported.stdout)).toMatchObject({ + schemaVersion: "openclaw.clawExportResult.v1", + stability: "experimental", + agentId: "workspace-agent", + outputDirectory, + manifest: { + schemaVersion: 1, + agent: { id: "workspace-agent" }, + workspace: { + bootstrapFiles: { + "SOUL.md": { source: "workspace/SOUL.md" }, + "HEARTBEAT.md": { source: "workspace/HEARTBEAT.md" }, + }, + files: [ + { + source: "workspace/reference/policy.md", + path: "reference/policy.md", + }, + ], + }, + }, + }); + expect(JSON.parse(await readFile(join(outputDirectory, "package.json"), "utf8"))).toMatchObject( + { + name: "openclaw-claw-workspace-agent", + version: expect.stringMatching(/^0\.0\.0-export\.[0-9a-f]{64}$/), + type: "module", + }, + ); + const inspected = await runOpenClaw(["claws", "inspect", outputDirectory, "--json"]); + expect(parseJson(inspected.stdout)).toMatchObject({ + valid: true, + source: { kind: "package" }, + manifest: { agent: { id: "workspace-agent" } }, + }); + const roundTripPreview = await runOpenClaw([ + "claws", + "add", + outputDirectory, + "--dry-run", + "--json", + ]); + const roundTripPlan = parseJson(roundTripPreview.stdout) as { planIntegrity: string }; + const roundTrip = await runOpenClaw( + [ + "claws", + "add", + outputDirectory, + "--yes", + "--plan-integrity", + roundTripPlan.planIntegrity, + "--json", + ], + { stateDir: roundTripPreview.stateDir }, + ); + expect(parseJson(roundTrip.stdout)).toMatchObject({ + status: "complete", + claw: { kind: "package" }, + agent: { finalId: "workspace-agent" }, + workspaceFiles: [ + expect.objectContaining({ path: "SOUL.md" }), + expect.objectContaining({ path: "HEARTBEAT.md" }), + expect.objectContaining({ path: "reference/policy.md" }), + ], + }); + }); + it("blocks mutation when declared components need later lifecycle slices", async () => { const root = tempDirs.make("openclaw-claws-deferred-components-"); const deferredManifestPath = join(root, "deferred.claw.json"); diff --git a/src/cli/claws-cli.runtime.ts b/src/cli/claws-cli.runtime.ts index b2ab34515e8f..1987817e7f3c 100644 --- a/src/cli/claws-cli.runtime.ts +++ b/src/cli/claws-cli.runtime.ts @@ -10,6 +10,11 @@ import { ClawAddMutationError, } from "../claws/add.js"; import { assertExperimentalClawsEnabled } from "../claws/experimental.js"; +import { + CLAW_EXPORT_RESULT_SCHEMA_VERSION, + ClawExportError, + exportClawAgent, +} from "../claws/export.js"; import { applyClawRemovePlan, buildClawRemovePlan, @@ -38,6 +43,7 @@ import { redactSensitiveText } from "../logging/redact.js"; import { defaultRuntime, writeRuntimeJson, type RuntimeEnv } from "../runtime.js"; import type { ClawsAddOptions, + ClawsExportOptions, ClawsInspectOptions, ClawsRemoveOptions, ClawsStatusOptions, @@ -454,3 +460,39 @@ export async function runClawsRemoveCommand( runtime.exit(1); } } + +export async function runClawsExportCommand( + agentId: string, + opts: ClawsExportOptions, + runtime: RuntimeEnv = defaultRuntime, +): Promise { + assertExperimentalClawsEnabled(); + try { + const result = await exportClawAgent(agentId, opts.out, { config: getRuntimeConfig() }); + if (opts.json) { + writeRuntimeJson(runtime, result); + return; + } + logExperimentalWarning(runtime); + runtime.log(`Exported agent: ${result.agentId}`); + runtime.log(`Package directory: ${result.outputDirectory}`); + runtime.log( + `Workspace files: ${result.manifest.workspace.files.length + Object.keys(result.manifest.workspace.bootstrapFiles).length}`, + ); + runtime.log(`Packages: ${result.manifest.packages.length}`); + } catch (error) { + const code = error instanceof ClawExportError ? error.code : "export_failed"; + const message = error instanceof Error ? error.message : String(error); + if (opts.json) { + writeRuntimeJson(runtime, { + schemaVersion: CLAW_EXPORT_RESULT_SCHEMA_VERSION, + stability: CLAW_OUTPUT_STABILITY, + status: "failed", + error: { code, message }, + }); + } else { + runtime.error(message); + } + runtime.exit(1); + } +} diff --git a/src/cli/claws-cli.test.ts b/src/cli/claws-cli.test.ts index 34f139ad1558..80750191a5b8 100644 --- a/src/cli/claws-cli.test.ts +++ b/src/cli/claws-cli.test.ts @@ -30,6 +30,7 @@ const mocks = vi.hoisted(() => { readClawStatus: vi.fn(), buildClawRemovePlan: vi.fn(), applyClawRemovePlan: vi.fn(), + exportClawAgent: vi.fn(), }; }); @@ -60,6 +61,11 @@ vi.mock("../claws/lifecycle-state.js", async () => ({ applyClawRemovePlan: mocks.applyClawRemovePlan, })); +vi.mock("../claws/export.js", async () => ({ + ...(await vi.importActual("../claws/export.js")), + exportClawAgent: mocks.exportClawAgent, +})); + const { registerClawsCli } = await import("./claws-cli.js"); const tempDirs = useAutoCleanupTempDirTracker(afterEach); @@ -185,6 +191,22 @@ describe("claws cli", () => { packages: [], packageRefsReleased: 1, }); + mocks.exportClawAgent.mockReset(); + mocks.exportClawAgent.mockResolvedValue({ + schemaVersion: "openclaw.clawExportResult.v1", + stability: "experimental", + agentId: "demo-agent", + outputDirectory: "/tmp/exported", + manifest: { + schemaVersion: 1, + agent: { id: "demo-agent" }, + workspace: { bootstrapFiles: {}, files: [] }, + packages: [], + mcpServers: {}, + cronJobs: [], + }, + filesWritten: ["package.json", "openclaw.claw.json"], + }); }); afterEach(() => { @@ -210,6 +232,7 @@ describe("claws cli", () => { "add", "status", "remove", + "export", ]); }); @@ -658,4 +681,17 @@ describe("claws cli", () => { error: { code: "consent_required" }, }); }); + + it("exports one installed agent to a new package directory", async () => { + await runCli(["claws", "export", "demo-agent", "--out", "/tmp/exported", "--json"]); + + expect(mocks.exportClawAgent).toHaveBeenCalledWith("demo-agent", "/tmp/exported", { + config: {}, + }); + expect(JSON.parse(mocks.logs[0] ?? "{}")).toMatchObject({ + schemaVersion: "openclaw.clawExportResult.v1", + stability: "experimental", + agentId: "demo-agent", + }); + }); }); diff --git a/src/cli/claws-cli.ts b/src/cli/claws-cli.ts index 4df2adc3b5e5..3a6717b84112 100644 --- a/src/cli/claws-cli.ts +++ b/src/cli/claws-cli.ts @@ -26,6 +26,7 @@ export type ClawsRemoveOptions = { forceReferenced?: boolean; json?: boolean; }; +export type ClawsExportOptions = { out: string; json?: boolean }; function collectOption(value: string, previous: string[]): string[] { return [...previous, value]; @@ -101,5 +102,16 @@ export function registerClawsCli(program: Command) { await runClawsRemoveCommand(target, opts); }); + claws + .command("export") + .description("Export portable state for one installed Claw agent") + .argument("", "Final id of the installed Claw agent") + .requiredOption("--out ", "New package directory to create") + .option("--json", "Print JSON", false) + .action(async (agent: string, opts: ClawsExportOptions) => { + const { runClawsExportCommand } = await import("./claws-cli.runtime.js"); + await runClawsExportCommand(agent, opts); + }); + applyParentDefaultHelpAction(claws); }