mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
Export installed agents as grouped Claw packages (#102306)
* Export installed agents as grouped Claw packages * test(claws): cover exact agent export * docs(claws): document agent export * fix(claws): use current bounded file reader --------- Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
This commit is contained in:
+20
-6
@@ -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 <source>` | Validate a package directory or JSON manifest. |
|
||||
| `claws add <source>` | Preview or create one new agent and workspace. |
|
||||
| `claws status [claw-or-agent]` | Report installed state, ownership, and drift. |
|
||||
| `claws remove <claw-or-agent>` | Preview or remove the agent and eligible resources. |
|
||||
| Command | Purpose |
|
||||
| ----------------------------------- | --------------------------------------------------- |
|
||||
| `claws inspect <source>` | Validate a package directory or JSON manifest. |
|
||||
| `claws add <source>` | Preview or create one new agent and workspace. |
|
||||
| `claws status [claw-or-agent]` | Report installed state, ownership, and drift. |
|
||||
| `claws remove <claw-or-agent>` | Preview or remove the agent and eligible resources. |
|
||||
| `claws export <agent> --out <path>` | Create a portable package from an installed agent. |
|
||||
|
||||
Use `--json` for experimental machine-readable output.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<NonNullable<OpenClawConfig["agents"]>["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<ClawExportResult> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<typeof import("../claws/export.js")>("../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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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("<agent>", "Final id of the installed Claw agent")
|
||||
.requiredOption("--out <path>", "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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user