From a3094582ffc5473df9a48be4486f3f240e14484f Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Sat, 8 Aug 2026 12:32:26 -0700 Subject: [PATCH] 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 --- docs/cli/claws.md | 13 +++ src/claws/export.test.ts | 195 +++++++++++++++++++++++++++++++++++ src/claws/export.ts | 97 ++++++++++++++--- src/cli/claws-cli.runtime.ts | 2 + src/cli/claws-cli.test.ts | 8 +- src/cli/claws-cli.ts | 3 +- 6 files changed, 299 insertions(+), 19 deletions(-) diff --git a/docs/cli/claws.md b/docs/cli/claws.md index 60fdbf93e51f..613c4e7edbaf 100644 --- a/docs/cli/claws.md +++ b/docs/cli/claws.md @@ -405,6 +405,19 @@ managed state has drifted: openclaw claws export incident-triage --out ./incident-triage-export --json ``` +Use `--bootstrap ` 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 ` 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, diff --git a/src/claws/export.test.ts b/src/claws/export.test.ts index 66ce5537d542..01bc21398d43 100644 --- a/src/claws/export.test.ts +++ b/src/claws/export.test.ts @@ -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) | undefined, +})); + +vi.mock("./lifecycle-state.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readClawStatus: async (...args: Parameters) => { + 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); diff --git a/src/claws/export.ts b/src/claws/export.ts index 3a613ef6310f..c35a04369307 100644 --- a/src/claws/export.ts +++ b/src/claws/export.ts @@ -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(["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 { + 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): ClawMcpServer { const common = { ...(server.toolFilter && typeof server.toolFilter === "object" @@ -268,6 +303,7 @@ export async function exportClawAgent( packageDeps?: PackageRemovalDeps; packagePreflight?: ClawPackagePreflight; sourceMcpServers?: Record>; + bootstrapPath?: string; }, ): Promise { 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), diff --git a/src/cli/claws-cli.runtime.ts b/src/cli/claws-cli.runtime.ts index d1ab8adccfd0..c94fd07f6bb5 100644 --- a/src/cli/claws-cli.runtime.ts +++ b/src/cli/claws-cli.runtime.ts @@ -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); diff --git a/src/cli/claws-cli.test.ts b/src/cli/claws-cli.test.ts index 0b333558335a..a568d5694b32 100644 --- a/src/cli/claws-cli.test.ts +++ b/src/cli/claws-cli.test.ts @@ -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", diff --git a/src/cli/claws-cli.ts b/src/cli/claws-cli.ts index 5f543a49eadc..503ec6d8a2b8 100644 --- a/src/cli/claws-cli.ts +++ b/src/cli/claws-cli.ts @@ -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("", "Final id of the installed Claw agent") .requiredOption("--out ", "New package directory to create") + .option("--bootstrap ", "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");