mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat(claws): export reviewed native bootstrap (#115371)
* feat(claws): export reviewed native bootstrap Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3cdcdb00-ade8-4e61-85a7-8151b35f216a * fix(claws): fail export when the package bootstrap drifted Export re-emitted BOOTSTRAP.md only while the seeded copy was still pending, so an agent whose bootstrap had been edited, flagged unsafe, or become unreadable exported a package with no bootstrap at all. That is the same class of silent loss the managed workspace files already guard against, so treat it the same way: drifted bootstrap state now fails with `bootstrap_drifted` unless the author supplies a reviewed `--bootstrap` replacement. A consumed bootstrap stays a completed lifecycle state and still exports without BOOTSTRAP.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3cdcdb00-ade8-4e61-85a7-8151b35f216a * fix(claws): bind pending bootstrap export bytes * fix(claws): preserve current export ownership limits --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3cdcdb00-ade8-4e61-85a7-8151b35f216a
This commit is contained in:
@@ -405,6 +405,19 @@ managed state has drifted:
|
||||
openclaw claws export incident-triage --out ./incident-triage-export --json
|
||||
```
|
||||
|
||||
Use `--bootstrap <path>` to attach an explicitly reviewed Markdown file as the
|
||||
package-root `BOOTSTRAP.md`. Export re-emits an unchanged, still-pending package
|
||||
bootstrap automatically. A package bootstrap that drifted in the workspace
|
||||
(edited, unsafe, or unreadable) fails the export with `bootstrap_drifted`, the
|
||||
same way managed workspace files fail with `workspace_files_drifted`; pass
|
||||
`--bootstrap <path>` with a reviewed replacement to export anyway. A bootstrap
|
||||
the agent already consumed is a completed lifecycle state, so export omits
|
||||
`BOOTSTRAP.md` instead of failing. The exporter validates the completed package
|
||||
and removes the new output directory if validation fails. Bootstrap is
|
||||
package-authored prompt content: do not include credentials, tokens, private
|
||||
answers, or machine-specific paths. Export does not infer questions, render
|
||||
personal-data templates, persist answers, or add a separate setup lifecycle.
|
||||
|
||||
The result contains `package.json`, canonical `CLAW.md`, and managed workspace
|
||||
sidecars. Managed `SOUL.md` content is emitted as the `CLAW.md` body when it is
|
||||
non-empty UTF-8 and the combined document fits the manifest limit. Otherwise,
|
||||
|
||||
@@ -17,10 +17,27 @@ import { readClawManifestFile } from "./reader.js";
|
||||
import { parseClawManifest } from "./schema.js";
|
||||
import type { ClawOpenClawProfile, ClawSourceIdentity } from "./types.js";
|
||||
|
||||
const lifecycleStateTestControl = vi.hoisted(() => ({
|
||||
afterRead: undefined as (() => Promise<void>) | undefined,
|
||||
}));
|
||||
|
||||
vi.mock("./lifecycle-state.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./lifecycle-state.js")>();
|
||||
return {
|
||||
...actual,
|
||||
readClawStatus: async (...args: Parameters<typeof actual.readClawStatus>) => {
|
||||
const status = await actual.readClawStatus(...args);
|
||||
await lifecycleStateTestControl.afterRead?.();
|
||||
return status;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
|
||||
afterEach(() => {
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
lifecycleStateTestControl.afterRead = undefined;
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
@@ -358,6 +375,82 @@ describe("exportClawAgent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("attaches an explicit reviewed package bootstrap and re-reads the export", async () => {
|
||||
const fixture = await installedFixture();
|
||||
const bootstrapPath = join(fixture.root, "reviewed-bootstrap.md");
|
||||
const out = join(fixture.root, "exported-with-bootstrap");
|
||||
await writeFile(bootstrapPath, "# First run\n\nAsk for the operator's timezone.\n", "utf8");
|
||||
|
||||
const result = await exportClawAgent("worker", out, {
|
||||
env: fixture.env,
|
||||
config: fixture.config,
|
||||
sourceMcpServers: fixture.sourceMcpServers,
|
||||
bootstrapPath,
|
||||
});
|
||||
|
||||
expect(result.filesWritten).toContain("BOOTSTRAP.md");
|
||||
await expect(readFile(join(out, "BOOTSTRAP.md"), "utf8")).resolves.toBe(
|
||||
"# First run\n\nAsk for the operator's timezone.\n",
|
||||
);
|
||||
const exported = await readClawManifestFile(out);
|
||||
expect(exported.ok).toBe(true);
|
||||
if (!exported.ok) {
|
||||
throw new Error(JSON.stringify(exported.diagnostics));
|
||||
}
|
||||
expect(exported.packageBootstrap).toMatchObject({
|
||||
sourcePath: "BOOTSTRAP.md",
|
||||
byteLength: 46,
|
||||
});
|
||||
|
||||
const originalPackage = JSON.parse(await readFile(join(out, "package.json"), "utf8")) as {
|
||||
version: string;
|
||||
};
|
||||
await writeFile(bootstrapPath, "# First run\n\nAsk for the operator's locale.\n", "utf8");
|
||||
const changedOut = join(fixture.root, "exported-with-changed-bootstrap");
|
||||
await exportClawAgent("worker", changedOut, {
|
||||
env: fixture.env,
|
||||
config: fixture.config,
|
||||
sourceMcpServers: fixture.sourceMcpServers,
|
||||
bootstrapPath,
|
||||
});
|
||||
const changedPackage = JSON.parse(await readFile(join(changedOut, "package.json"), "utf8")) as {
|
||||
version: string;
|
||||
};
|
||||
expect(changedPackage.version).not.toBe(originalPackage.version);
|
||||
});
|
||||
|
||||
it("rejects empty bootstrap authoring without leaving an export target", async () => {
|
||||
const fixture = await installedFixture();
|
||||
const bootstrapPath = join(fixture.root, "empty-bootstrap.md");
|
||||
const out = join(fixture.root, "exported-empty-bootstrap");
|
||||
await writeFile(bootstrapPath, " \n", "utf8");
|
||||
|
||||
await expect(
|
||||
exportClawAgent("worker", out, {
|
||||
env: fixture.env,
|
||||
config: fixture.config,
|
||||
sourceMcpServers: fixture.sourceMcpServers,
|
||||
bootstrapPath,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "bootstrap_empty" });
|
||||
await expect(readFile(join(out, "CLAW.md"), "utf8")).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("rejects non-UTF-8 bootstrap authoring", async () => {
|
||||
const fixture = await installedFixture();
|
||||
const bootstrapPath = join(fixture.root, "binary-bootstrap.md");
|
||||
await writeFile(bootstrapPath, Buffer.from([0xff]));
|
||||
|
||||
await expect(
|
||||
exportClawAgent("worker", join(fixture.root, "exported-binary-bootstrap"), {
|
||||
env: fixture.env,
|
||||
config: fixture.config,
|
||||
sourceMcpServers: fixture.sourceMcpServers,
|
||||
bootstrapPath,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "bootstrap_invalid" });
|
||||
});
|
||||
|
||||
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");
|
||||
@@ -421,6 +514,25 @@ describe("exportClawAgent", () => {
|
||||
await expect(stat(out)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("rejects a pending package bootstrap that changes after status inspection", async () => {
|
||||
const fixture = await installedFixture({ packageBootstrap: true });
|
||||
const bootstrapPath = join(fixture.plan.agent.workspace, "BOOTSTRAP.md");
|
||||
const out = join(fixture.root, "exported-raced-bootstrap");
|
||||
lifecycleStateTestControl.afterRead = async () => {
|
||||
await writeFile(bootstrapPath, "# Changed after inspection\n", "utf8");
|
||||
};
|
||||
|
||||
await expect(
|
||||
exportClawAgent("worker", out, {
|
||||
env: fixture.env,
|
||||
config: fixture.config,
|
||||
packageDeps: fixture.packageDeps,
|
||||
sourceMcpServers: fixture.sourceMcpServers,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "bootstrap_drifted" });
|
||||
await expect(stat(out)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("does not export native bootstrap content without package ownership", async () => {
|
||||
const fixture = await installedFixture();
|
||||
await writeFile(
|
||||
@@ -446,6 +558,89 @@ describe("exportClawAgent", () => {
|
||||
expect(exported.packageBootstrap).toBeUndefined();
|
||||
});
|
||||
|
||||
it("refuses to export a locally modified package bootstrap", async () => {
|
||||
const fixture = await installedFixture({ packageBootstrap: true });
|
||||
await writeFile(
|
||||
join(fixture.plan.agent.workspace, "BOOTSTRAP.md"),
|
||||
"# Edited onboarding\n",
|
||||
"utf8",
|
||||
);
|
||||
const out = join(fixture.root, "exported-modified-bootstrap");
|
||||
|
||||
await expect(
|
||||
exportClawAgent("worker", out, {
|
||||
env: fixture.env,
|
||||
config: fixture.config,
|
||||
packageDeps: fixture.packageDeps,
|
||||
sourceMcpServers: fixture.sourceMcpServers,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "bootstrap_drifted" });
|
||||
await expect(stat(out)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("still exports a consumed package bootstrap without a package BOOTSTRAP.md", async () => {
|
||||
const fixture = await installedFixture({ packageBootstrap: true });
|
||||
await rm(join(fixture.plan.agent.workspace, "BOOTSTRAP.md"));
|
||||
const out = join(fixture.root, "exported-consumed-bootstrap");
|
||||
|
||||
const result = await exportClawAgent("worker", out, {
|
||||
env: fixture.env,
|
||||
config: fixture.config,
|
||||
packageDeps: fixture.packageDeps,
|
||||
sourceMcpServers: fixture.sourceMcpServers,
|
||||
});
|
||||
|
||||
expect(result.filesWritten).not.toContain("BOOTSTRAP.md");
|
||||
});
|
||||
|
||||
it("exports a drifted package bootstrap when a reviewed replacement is supplied", async () => {
|
||||
const fixture = await installedFixture({ packageBootstrap: true });
|
||||
await writeFile(
|
||||
join(fixture.plan.agent.workspace, "BOOTSTRAP.md"),
|
||||
"# Edited onboarding\n",
|
||||
"utf8",
|
||||
);
|
||||
const bootstrapPath = join(fixture.root, "reviewed-replacement.md");
|
||||
await writeFile(bootstrapPath, "# First run\n\nAsk for the operator's timezone.\n", "utf8");
|
||||
const out = join(fixture.root, "exported-replaced-bootstrap");
|
||||
|
||||
const result = await exportClawAgent("worker", out, {
|
||||
env: fixture.env,
|
||||
config: fixture.config,
|
||||
packageDeps: fixture.packageDeps,
|
||||
sourceMcpServers: fixture.sourceMcpServers,
|
||||
bootstrapPath,
|
||||
});
|
||||
|
||||
expect(result.filesWritten).toContain("BOOTSTRAP.md");
|
||||
await expect(readFile(join(out, "BOOTSTRAP.md"), "utf8")).resolves.toContain(
|
||||
"operator's timezone",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not reread a pending package bootstrap when an explicit replacement is supplied", async () => {
|
||||
const fixture = await installedFixture({ packageBootstrap: true });
|
||||
const bootstrapPath = join(fixture.root, "reviewed-race-replacement.md");
|
||||
await writeFile(bootstrapPath, "# First run\n\nUse the reviewed replacement.\n", "utf8");
|
||||
lifecycleStateTestControl.afterRead = async () => {
|
||||
await rm(join(fixture.plan.agent.workspace, "BOOTSTRAP.md"));
|
||||
};
|
||||
const out = join(fixture.root, "exported-race-replacement");
|
||||
|
||||
const result = await exportClawAgent("worker", out, {
|
||||
env: fixture.env,
|
||||
config: fixture.config,
|
||||
packageDeps: fixture.packageDeps,
|
||||
sourceMcpServers: fixture.sourceMcpServers,
|
||||
bootstrapPath,
|
||||
});
|
||||
|
||||
expect(result.filesWritten).toContain("BOOTSTRAP.md");
|
||||
await expect(readFile(join(out, "BOOTSTRAP.md"), "utf8")).resolves.toContain(
|
||||
"reviewed replacement",
|
||||
);
|
||||
});
|
||||
|
||||
it("exports a large pending package bootstrap within the native size limit", async () => {
|
||||
const content = Buffer.from("# First run\n\n" + "x".repeat(1024 * 1024 + 32));
|
||||
expect(content.byteLength).toBeLessThanOrEqual(MAX_WORKSPACE_BOOTSTRAP_FILE_BYTES);
|
||||
|
||||
+84
-13
@@ -1,7 +1,7 @@
|
||||
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 { basename, dirname, relative, resolve, sep } from "node:path";
|
||||
import { stringify as stringifyYaml } from "yaml";
|
||||
import { listAgentEntries, resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
|
||||
import { openLocalAgentAvatarFile } from "../agents/identity-avatar-file.js";
|
||||
@@ -9,12 +9,13 @@ import { MAX_WORKSPACE_BOOTSTRAP_FILE_BYTES } from "../agents/workspace-bootstra
|
||||
import { normalizeConfiguredMcpServers } from "../config/mcp-config-normalize.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 { FsSafeError, 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 { readClawManifestFile } from "./reader.js";
|
||||
import { isPortableClawAvatar } from "./schema-portability.js";
|
||||
import { parseClawManifest, parseClawOpenClawProfile } from "./schema.js";
|
||||
import { MAX_CLAW_MANIFEST_BYTES, MAX_MANAGED_WORKSPACE_BYTES } from "./source-limits.js";
|
||||
@@ -53,6 +54,8 @@ type ClawExportResult = {
|
||||
filesWritten: string[];
|
||||
};
|
||||
|
||||
const DRIFTED_BOOTSTRAP_STATES = new Set<string>(["modified", "unsafe", "unknown"]);
|
||||
|
||||
export class ClawExportError extends Error {
|
||||
constructor(
|
||||
readonly code: string,
|
||||
@@ -227,6 +230,38 @@ function derivativePackageVersion(manifest: ClawManifest, contents: ExportConten
|
||||
|
||||
type ExportContent = { path: string; content: Buffer };
|
||||
|
||||
async function readAuthorBootstrap(path: string): Promise<Buffer> {
|
||||
const resolvedPath = resolve(resolveUserPath(path));
|
||||
try {
|
||||
const sourceRoot = await fsSafeRoot(dirname(resolvedPath));
|
||||
const read = await sourceRoot.read(basename(resolvedPath), {
|
||||
hardlinks: "reject",
|
||||
maxBytes: MAX_WORKSPACE_BOOTSTRAP_FILE_BYTES,
|
||||
nonBlockingRead: true,
|
||||
symlinks: "reject",
|
||||
});
|
||||
const text = new TextDecoder("utf-8", { fatal: true }).decode(read.buffer);
|
||||
if (text.trim().length === 0) {
|
||||
throw new ClawExportError(
|
||||
"bootstrap_empty",
|
||||
"Export BOOTSTRAP.md must contain reviewed first-run instructions.",
|
||||
);
|
||||
}
|
||||
return read.buffer;
|
||||
} catch (error) {
|
||||
if (error instanceof ClawExportError) {
|
||||
throw error;
|
||||
}
|
||||
const tooLarge = error instanceof FsSafeError && error.code === "too-large";
|
||||
throw new ClawExportError(
|
||||
tooLarge ? "bootstrap_oversized" : "bootstrap_invalid",
|
||||
tooLarge
|
||||
? `Export BOOTSTRAP.md exceeds ${MAX_WORKSPACE_BOOTSTRAP_FILE_BYTES} bytes.`
|
||||
: `Could not read a safe UTF-8 BOOTSTRAP.md from ${JSON.stringify(resolvedPath)}: ${(error as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function portableMcpServer(server: Record<string, unknown>): ClawMcpServer {
|
||||
const common = {
|
||||
...(server.toolFilter && typeof server.toolFilter === "object"
|
||||
@@ -268,6 +303,7 @@ export async function exportClawAgent(
|
||||
packageDeps?: PackageRemovalDeps;
|
||||
packagePreflight?: ClawPackagePreflight;
|
||||
sourceMcpServers?: Record<string, Record<string, unknown>>;
|
||||
bootstrapPath?: string;
|
||||
},
|
||||
): Promise<ClawExportResult> {
|
||||
const status = await readClawStatus(agentId, options);
|
||||
@@ -325,6 +361,19 @@ export async function exportClawAgent(
|
||||
`Cannot export drifted packages: ${driftedPackages.map((pkg) => `${pkg.kind}:${pkg.ref}@${pkg.version} (${pkg.extensionCompatibility?.state ?? pkg.state})`).join(", ")}.`,
|
||||
);
|
||||
}
|
||||
// A drifted package bootstrap is managed state like any other: exporting it
|
||||
// silently would publish a package with no BOOTSTRAP.md at all. An explicitly
|
||||
// reviewed --bootstrap replacement is the supported way through.
|
||||
if (
|
||||
record.install.bootstrap &&
|
||||
!options.bootstrapPath &&
|
||||
DRIFTED_BOOTSTRAP_STATES.has(record.bootstrapState)
|
||||
) {
|
||||
throw new ClawExportError(
|
||||
"bootstrap_drifted",
|
||||
`Cannot export the package bootstrap ${JSON.stringify(record.bootstrap.path)} in ${JSON.stringify(record.bootstrapState)} state; restore the seeded file or pass a reviewed --bootstrap replacement.`,
|
||||
);
|
||||
}
|
||||
const unresolvedCronJobs = record.cronJobs.filter(
|
||||
(cron) => cron.status !== "complete" || !cron.schedulerJobId,
|
||||
);
|
||||
@@ -346,6 +395,10 @@ export async function exportClawAgent(
|
||||
);
|
||||
}
|
||||
|
||||
const authorBootstrap = options.bootstrapPath
|
||||
? await readAuthorBootstrap(options.bootstrapPath)
|
||||
: undefined;
|
||||
|
||||
const workspace = await fsSafeRoot(record.install.workspace, {
|
||||
hardlinks: "reject",
|
||||
maxBytes: MAX_EXPORT_FILE_BYTES,
|
||||
@@ -372,18 +425,28 @@ export async function exportClawAgent(
|
||||
contents.push(avatar.sidecar);
|
||||
}
|
||||
let pendingPackageBootstrap: Buffer | undefined;
|
||||
if (record.install.bootstrap && record.bootstrapState === "pending") {
|
||||
pendingPackageBootstrap = await workspace.readBytes("BOOTSTRAP.md", {
|
||||
maxBytes: MAX_WORKSPACE_BOOTSTRAP_FILE_BYTES,
|
||||
});
|
||||
const contentDigest = `sha256:${createHash("sha256").update(pendingPackageBootstrap).digest("hex")}`;
|
||||
if (!authorBootstrap && record.install.bootstrap && record.bootstrapState === "pending") {
|
||||
try {
|
||||
pendingPackageBootstrap = await workspace.readBytes("BOOTSTRAP.md", {
|
||||
maxBytes: MAX_WORKSPACE_BOOTSTRAP_FILE_BYTES,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new ClawExportError(
|
||||
"bootstrap_drifted",
|
||||
`Cannot export the package bootstrap because BOOTSTRAP.md changed after inspection: ${(error as Error).message}`,
|
||||
);
|
||||
}
|
||||
const contentDigest = `sha256:${createHash("sha256")
|
||||
.update(pendingPackageBootstrap)
|
||||
.digest("hex")}`;
|
||||
if (contentDigest !== record.install.bootstrap.contentDigest) {
|
||||
throw new ClawExportError(
|
||||
"bootstrap_drifted",
|
||||
"Cannot export BOOTSTRAP.md because it changed during ownership inspection.",
|
||||
"Cannot export the package bootstrap because BOOTSTRAP.md changed after inspection.",
|
||||
);
|
||||
}
|
||||
}
|
||||
const exportedBootstrap = authorBootstrap ?? pendingPackageBootstrap;
|
||||
const bootstrapFiles: ClawManifest["workspace"]["bootstrapFiles"] = {};
|
||||
const files: ClawManifest["workspace"]["files"] = [];
|
||||
for (const file of contents) {
|
||||
@@ -516,9 +579,7 @@ export async function exportClawAgent(
|
||||
...contents,
|
||||
...(clawMarkdownBody ? [{ path: "CLAW.md#body", content: clawMarkdownBody }] : []),
|
||||
...(openClawProfileRaw ? [{ path: openClawProfilePath, content: openClawProfileRaw }] : []),
|
||||
...(pendingPackageBootstrap
|
||||
? [{ path: "BOOTSTRAP.md", content: pendingPackageBootstrap }]
|
||||
: []),
|
||||
...(exportedBootstrap ? [{ path: "BOOTSTRAP.md", content: exportedBootstrap }] : []),
|
||||
]),
|
||||
type: "module",
|
||||
openclaw: { claw: "CLAW.md" },
|
||||
@@ -529,12 +590,22 @@ export async function exportClawAgent(
|
||||
filesWritten.push("package.json");
|
||||
await output.write("CLAW.md", clawMarkdownRaw, { overwrite: false });
|
||||
filesWritten.push("CLAW.md");
|
||||
if (pendingPackageBootstrap) {
|
||||
await output.write("BOOTSTRAP.md", pendingPackageBootstrap, { overwrite: false });
|
||||
if (exportedBootstrap) {
|
||||
await output.write("BOOTSTRAP.md", exportedBootstrap, { overwrite: false });
|
||||
filesWritten.push("BOOTSTRAP.md");
|
||||
}
|
||||
const reread = await readClawManifestFile(target);
|
||||
if (!reread.ok) {
|
||||
throw new ClawExportError(
|
||||
"export_package_invalid",
|
||||
reread.diagnostics.map((diagnostic) => diagnostic.message).join("; "),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
await rm(target, { recursive: true, force: true }).catch(() => undefined);
|
||||
if (error instanceof ClawExportError) {
|
||||
throw error;
|
||||
}
|
||||
throw new ClawExportError(
|
||||
"export_write_failed",
|
||||
error instanceof Error ? error.message : String(error),
|
||||
|
||||
@@ -609,6 +609,7 @@ export async function runClawsExportCommand(
|
||||
const result = await exportClawAgent(agentId, opts.out, {
|
||||
config: getRuntimeConfig(),
|
||||
sourceMcpServers: listedMcpServers.mcpServers,
|
||||
...(opts.bootstrap ? { bootstrapPath: opts.bootstrap } : {}),
|
||||
});
|
||||
if (opts.json) {
|
||||
writeRuntimeJson(runtime, result);
|
||||
@@ -621,6 +622,7 @@ export async function runClawsExportCommand(
|
||||
`Workspace files: ${result.manifest.workspace.files.length + Object.keys(result.manifest.workspace.bootstrapFiles).length}`,
|
||||
);
|
||||
runtime.log(`Packages: ${result.manifest.packages.length}`);
|
||||
runtime.log(`Bootstrap: ${result.filesWritten.includes("BOOTSTRAP.md") ? "included" : "none"}`);
|
||||
} catch (error) {
|
||||
const code = error instanceof ClawExportError ? error.code : "export_failed";
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
@@ -1085,12 +1085,10 @@ describe("claws cli", () => {
|
||||
});
|
||||
|
||||
it("exports one installed agent to a new package directory", async () => {
|
||||
await runCli(["claws", "export", "demo-agent", "--out", "/tmp/exported", "--json"]);
|
||||
await runCli(["claws", "export", "demo-agent", "--out", "/e", "--bootstrap", "/b", "--json"]);
|
||||
|
||||
expect(mocks.exportClawAgent).toHaveBeenCalledWith("demo-agent", "/tmp/exported", {
|
||||
config: {},
|
||||
sourceMcpServers: {},
|
||||
});
|
||||
expect(mocks.exportClawAgent.mock.calls[0]?.slice(0, 2)).toEqual(["demo-agent", "/e"]);
|
||||
expect(mocks.exportClawAgent.mock.calls[0]?.[2]).toMatchObject({ bootstrapPath: "/b" });
|
||||
expect(JSON.parse(mocks.logs[0] ?? "{}")).toMatchObject({
|
||||
schemaVersion: "openclaw.clawExportResult.v1",
|
||||
stability: "experimental",
|
||||
|
||||
@@ -33,7 +33,7 @@ export type ClawsRemoveOptions = {
|
||||
forceReferenced?: boolean;
|
||||
json?: boolean;
|
||||
};
|
||||
export type ClawsExportOptions = { out: string; json?: boolean };
|
||||
export type ClawsExportOptions = { out: string; bootstrap?: string; json?: boolean };
|
||||
|
||||
function collectOption(value: string, previous: string[]): string[] {
|
||||
return [...previous, value];
|
||||
@@ -128,6 +128,7 @@ export function registerClawsCli(program: Command) {
|
||||
.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("--bootstrap <path>", "Reviewed Markdown file to export as package BOOTSTRAP.md")
|
||||
.option("--json", "Print JSON", false)
|
||||
.action(async (agent: string, opts: ClawsExportOptions) => {
|
||||
const { runClawsExportCommand } = await import("./claws-cli.runtime.js");
|
||||
|
||||
Reference in New Issue
Block a user