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:
Gio Della-Libera
2026-08-08 12:32:26 -07:00
committed by GitHub
parent fe908cf309
commit a3094582ff
6 changed files with 299 additions and 19 deletions
+13
View File
@@ -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,
+195
View File
@@ -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
View File
@@ -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),
+2
View File
@@ -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);
+3 -5
View File
@@ -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",
+2 -1
View File
@@ -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");