diff --git a/docs/cli/claws.md b/docs/cli/claws.md index 613c4e7edbaf..d671d91e2f7f 100644 --- a/docs/cli/claws.md +++ b/docs/cli/claws.md @@ -257,6 +257,39 @@ values. A collision-free declaration becomes managed, while an exact existing or shared declaration is referenced. Preview, provenance, status, export, and removal follow the same ownership policy as other Claw resources. +## Author locally + +Create a minimal project, validate its publishable inputs, preview its complete +OpenClaw add plan offline, and build an immutable package artifact: + +```bash +openclaw claws create ./incident-triage +openclaw claws validate ./incident-triage +openclaw claws dev ./incident-triage +openclaw claws build ./incident-triage --out ./incident-triage-1.0.0.tgz +``` + +`create` writes only `package.json` and `CLAW.md` and refuses to merge into a +nonempty directory. Project validation requires `openclaw.claw` to point to +the root `CLAW.md`, rejects package scripts and lifecycle hooks, discovers a +single unambiguous project root, and reports files excluded from the package. + +`dev` validates and builds the same artifact that would be published, then +runs that artifact through the canonical add planner. It does not install +packages, contact ClawHub, start an agent turn, enable schedules, deliver +messages, or modify OpenClaw state. Dependencies that require online preflight +appear as blockers instead of weakening that boundary. Use `--agent-id` or +`--workspace` to preview collision-free local destinations. + +`build` writes a deterministic npm-compatible `.tgz` with a `package/` root. +Only package metadata, `CLAW.md`, optional `BOOTSTRAP.md`, the OpenClaw profile, +and sources selected by the manifest are included. Tests, caches, ambient or +unselected credentials, unselected files, prior artifacts, and source-control +state remain outside the package. Selected source bytes are package content, so +authors must not select secret-bearing files. Build refuses to overwrite an +existing artifact, reports its SHA-256 integrity, and re-opens it through the +canonical Claw reader before success. + ## Inspect and preview Validate the source without planning local changes. For OpenClaw profile diff --git a/src/agents/workspace.test.ts b/src/agents/workspace.test.ts index 32bea7988661..2f6f8a39da42 100644 --- a/src/agents/workspace.test.ts +++ b/src/agents/workspace.test.ts @@ -151,6 +151,23 @@ function expectCronAllowedBootstrapNames(files: WorkspaceBootstrapFile[]) { } describe("ensureAgentWorkspace", () => { + it("registers workspace aliases in the selected state database", async () => { + const root = testState!.root; + const workspace = path.join(root, "custom-db-workspace"); + const workspaceAlias = path.join(root, "custom-db-workspace-alias"); + const databasePath = path.join(root, "custom-state.sqlite"); + const options = { path: databasePath }; + const seededAt = "2026-07-31T12:00:00.000Z"; + await fs.mkdir(workspace); + await fs.symlink(workspace, workspaceAlias, process.platform === "win32" ? "junction" : "dir"); + mergeWorkspaceSetupState(workspace, { bootstrapSeededAt: seededAt }, Date.now(), options); + + expect(readWorkspaceStateSnapshot(workspaceAlias, options).setup).toEqual({ + version: 1, + bootstrapSeededAt: seededAt, + }); + }); + it("creates BOOTSTRAP.md and records a seeded marker for brand new workspaces", async () => { const tempDir = await makeTempWorkspace("openclaw-workspace-"); diff --git a/src/claws/lifecycle.ts b/src/claws/lifecycle.ts index a34846ebae44..e096c0d829cd 100644 --- a/src/claws/lifecycle.ts +++ b/src/claws/lifecycle.ts @@ -2,7 +2,7 @@ import { createHash } from "node:crypto"; import { lstat, realpath } from "node:fs/promises"; import { homedir } from "node:os"; -import { resolve } from "node:path"; +import { relative, resolve } from "node:path"; import { stableStringify } from "@openclaw/normalization-core"; import { resolvePathViaExistingAncestorSync } from "../infra/boundary-path.js"; import { assertNoSymlinkParents } from "../infra/fs-safe-advanced.js"; @@ -51,8 +51,13 @@ export type ClawAddPlanContext = { existingMcpServerNames?: Iterable; existingMcpServers?: Record>; packagePreflight?: ClawPackagePreflight; + sourceReferenceRoot?: string; }; +function sourceReferencePath(root: string, path: string): string { + return `${root.replace(/\/+$/u, "")}/${path.replaceAll("\\", "/")}`; +} + function canonicalWorkspacePath(value: string): string { return resolvePathViaExistingAncestorSync(resolve(resolveUserPath(value))); } @@ -201,6 +206,18 @@ export async function buildClawAddPlan(params: { ); const manifestPath = resolvePathViaExistingAncestorSync(resolve(params.source.manifestPath)); const source = { ...params.source, packageRoot, manifestPath }; + const planSource = context.sourceReferenceRoot + ? { + ...source, + packageRoot: context.sourceReferenceRoot, + manifestPath: sourceReferencePath( + context.sourceReferenceRoot, + relative(packageRoot, manifestPath), + ), + } + : source; + const planSourcePath = (path: string, fallback: string): string => + context.sourceReferenceRoot ? sourceReferencePath(context.sourceReferenceRoot, path) : fallback; const sourceRoot = await fsSafeRoot(packageRoot); const blockers: ClawDiagnostic[] = []; const actions: ClawAddPlanAction[] = []; @@ -301,7 +318,7 @@ export async function buildClawAddPlan(params: { id: "BOOTSTRAP.md", action: "write", target: resolve(workspace, "BOOTSTRAP.md"), - source: params.packageBootstrap.realPath, + source: planSourcePath(params.packageBootstrap.sourcePath, params.packageBootstrap.realPath), digest: params.packageBootstrap.digest, details: { sourcePath: params.packageBootstrap.sourcePath, @@ -336,6 +353,9 @@ export async function buildClawAddPlan(params: { if (!action) { throw new Error("Claw workspace source inspection did not produce an action"); } + if (action.source) { + action.source = planSourcePath(fileParams.sourcePath, action.source); + } action.blocked ||= workspaceBlocked; if (workspaceBlocked) { action.reason = `Workspace ${JSON.stringify(workspace)} already exists.`; @@ -362,7 +382,7 @@ export async function buildClawAddPlan(params: { id: "SOUL.md", action: "write", target: resolve(workspace, "SOUL.md"), - source: source.manifestPath, + source: planSource.manifestPath, sourceKind: "clawMarkdownBody", blocked: true, reason: diagnostic.message, @@ -378,7 +398,7 @@ export async function buildClawAddPlan(params: { id: "SOUL.md", action: "write", target: resolve(workspace, "SOUL.md"), - source: source.manifestPath, + source: planSource.manifestPath, sourceKind: "clawMarkdownBody", details: { expectedState: "absent" }, blocked: false, @@ -443,7 +463,7 @@ export async function buildClawAddPlan(params: { maxBytes: MAX_MANAGED_FILE_BYTES, symlinks: "reject", }); - pending.action.source = read.realPath; + pending.action.source = planSourcePath(pending.sourcePath, read.realPath); pending.action.digest = `sha256:${createHash("sha256").update(read.buffer).digest("hex")}`; } catch (error) { const code = workspaceSourceErrorCode(error); @@ -664,7 +684,7 @@ export async function buildClawAddPlan(params: { dryRun: true, mutationAllowed: false, planIntegrity, - claw: source, + claw: planSource, agent: { requestedId: params.manifest.agent.id, finalId, diff --git a/src/claws/project-build.ts b/src/claws/project-build.ts new file mode 100644 index 000000000000..b74c2efdf3ab --- /dev/null +++ b/src/claws/project-build.ts @@ -0,0 +1,260 @@ +import { createHash } from "node:crypto"; +import { + chmod, + link, + lstat, + mkdir, + mkdtemp, + readFile, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import * as tar from "tar"; +import { root as fsSafeRoot } from "../infra/fs-safe.js"; +import { + CLAW_PROJECT_RESULT_SCHEMA_VERSION, + ClawProjectError, + validateClawProject, +} from "./project.js"; +import { readClawManifestFile } from "./reader.js"; +import { isSafeClawRelativePath } from "./schema-portability.js"; +import { MAX_MANAGED_FILE_BYTES } from "./source-limits.js"; + +export const CLAW_BUILD_RESULT_SCHEMA_VERSION = "openclaw.clawBuild.v1" as const; + +type ClawBuildResult = { + schemaVersion: typeof CLAW_BUILD_RESULT_SCHEMA_VERSION; + projectSchemaVersion: typeof CLAW_PROJECT_RESULT_SCHEMA_VERSION; + artifact: string; + integrity: string; + byteLength: number; + files: string[]; + excludedPaths: string[]; + claw: { name: string; version: string }; +}; + +async function writeStagedFile(stagingRoot: string, path: string, content: Buffer | string) { + const target = resolve(stagingRoot, path); + const targetRelative = relative(stagingRoot, target); + if ( + !isSafeClawRelativePath(path) || + targetRelative === ".." || + targetRelative.startsWith(`..${sep}`) || + isAbsolute(targetRelative) + ) { + throw new ClawProjectError( + "unsafe_build_path", + `Cannot package unsafe path ${JSON.stringify(path)}.`, + ); + } + await mkdir(dirname(target), { recursive: true, mode: 0o755 }); + await writeFile(target, content, { flag: "wx", mode: 0o644 }); + await chmod(target, 0o644); +} + +async function readSelectedProjectFile(projectRoot: string, path: string): Promise { + const sourceRoot = await fsSafeRoot(projectRoot); + const read = await sourceRoot.read(path, { + hardlinks: "reject", + maxBytes: MAX_MANAGED_FILE_BYTES, + nonBlockingRead: true, + symlinks: path === "CLAW.md" ? "follow-within-root" : "reject", + }); + return read.buffer; +} + +function assertValidatedBytes( + path: string, + bytes: Buffer, + expected: { byteLength: number; digest: string }, +): void { + const digest = `sha256:${createHash("sha256").update(bytes).digest("hex")}`; + if (bytes.byteLength !== expected.byteLength || digest !== expected.digest) { + throw new ClawProjectError( + "project_changed_during_build", + `Claw project input ${JSON.stringify(path)} changed after validation; retry the build from a stable snapshot.`, + ); + } +} + +export async function extractBuiltClawArtifact(artifact: string): Promise<{ + temporaryDirectory: string; + packageRoot: string; + dispose: () => Promise; +}> { + const temporaryDirectory = await mkdtemp(join(tmpdir(), "openclaw-claw-artifact-")); + try { + await tar.x({ cwd: temporaryDirectory, file: resolve(artifact), strict: true }); + const packageRoot = join(temporaryDirectory, "package"); + const packageStat = await lstat(packageRoot); + if (!packageStat.isDirectory()) { + throw new Error("artifact does not contain a package directory"); + } + return { + temporaryDirectory, + packageRoot, + dispose: () => rm(temporaryDirectory, { recursive: true, force: true }), + }; + } catch (error) { + await rm(temporaryDirectory, { recursive: true, force: true }); + throw new ClawProjectError( + "artifact_verification_failed", + `Could not extract built Claw artifact: ${(error as Error).message}`, + ); + } +} + +export async function buildClawProject( + projectPath: string, + outputPath: string, +): Promise { + const project = await validateClawProject(projectPath); + if (!project.ok) { + throw new ClawProjectError( + "project_invalid", + project.diagnostics.map((item) => `${item.code}: ${item.message}`).join("\n"), + ); + } + + const artifact = resolve(outputPath); + if (!artifact.toLowerCase().endsWith(".tgz")) { + throw new ClawProjectError("invalid_artifact_path", "Claw build output must end in .tgz."); + } + if (await lstat(artifact).catch(() => undefined)) { + throw new ClawProjectError( + "artifact_exists", + `Refusing to overwrite existing artifact ${JSON.stringify(artifact)}.`, + ); + } + const outputParent = await stat(dirname(artifact)).catch(() => undefined); + if (!outputParent?.isDirectory()) { + throw new ClawProjectError( + "artifact_parent_missing", + `Artifact parent directory ${JSON.stringify(dirname(artifact))} does not exist.`, + ); + } + + const temporaryDirectory = await mkdtemp(join(dirname(artifact), ".openclaw-claw-build-")); + const stagingRoot = join(temporaryDirectory, "staging"); + const temporaryArtifact = join(temporaryDirectory, "claw.tgz"); + try { + await mkdir(stagingRoot, { mode: 0o755 }); + const files = new Map(); + files.set("package.json", `${JSON.stringify(project.packageJson, null, 2)}\n`); + const clawMarkdown = await readSelectedProjectFile(project.root, "CLAW.md"); + assertValidatedBytes("CLAW.md", clawMarkdown, project.claw.snapshot.manifest); + files.set("CLAW.md", clawMarkdown); + if (project.claw.packageBootstrap) { + const bootstrap = await readSelectedProjectFile(project.root, "BOOTSTRAP.md"); + assertValidatedBytes("BOOTSTRAP.md", bootstrap, project.claw.packageBootstrap); + files.set("BOOTSTRAP.md", bootstrap); + } + if (project.claw.openClawProfile) { + const profileSnapshot = project.claw.snapshot.openClawProfile; + if (!profileSnapshot) { + throw new ClawProjectError( + "project_invalid", + "Validated OpenClaw profile is missing its source snapshot.", + ); + } + const profile = await readSelectedProjectFile(project.root, profileSnapshot.sourcePath); + assertValidatedBytes(profileSnapshot.sourcePath, profile, profileSnapshot); + files.set(profileSnapshot.sourcePath, profile); + } + for (const source of project.claw.snapshot.workspaceSources) { + const bytes = await readSelectedProjectFile(project.root, source.sourcePath); + assertValidatedBytes(source.sourcePath, bytes, source); + files.set(source.sourcePath, bytes); + } + + const fileNames = [...files.keys()].toSorted((left, right) => + Buffer.compare(Buffer.from(left), Buffer.from(right)), + ); + for (const fileName of fileNames) { + await writeStagedFile(stagingRoot, fileName, files.get(fileName) as Buffer | string); + } + const tarInputNames = fileNames.map((fileName) => + fileName.startsWith("@") ? `./${fileName}` : fileName, + ); + + await tar.c( + { + cwd: stagingRoot, + file: temporaryArtifact, + gzip: { level: 9, portable: true }, + mtime: new Date(0), + portable: true, + prefix: "package", + }, + tarInputNames, + ); + + const archiveEntries: Array<{ path: string; type: string }> = []; + await tar.t({ + file: temporaryArtifact, + onentry: (entry) => archiveEntries.push({ path: entry.path, type: entry.type }), + }); + const expectedEntries = fileNames.map((path) => ({ path: `package/${path}`, type: "File" })); + if (JSON.stringify(archiveEntries) !== JSON.stringify(expectedEntries)) { + throw new ClawProjectError( + "artifact_contents_mismatch", + "Built artifact contents differ from the validated project selection.", + ); + } + + const packed = await readFile(temporaryArtifact); + const integrity = `sha256:${createHash("sha256").update(packed).digest("hex")}`; + const extracted = await extractBuiltClawArtifact(temporaryArtifact); + try { + const reread = await readClawManifestFile(extracted.packageRoot); + if (!reread.ok) { + throw new ClawProjectError( + "artifact_verification_failed", + reread.diagnostics.map((item) => `${item.code}: ${item.message}`).join("\n"), + ); + } + if ( + reread.source.name !== project.packageJson.name || + reread.source.version !== project.packageJson.version + ) { + throw new ClawProjectError( + "artifact_identity_mismatch", + "Built artifact identity differs from the validated project.", + ); + } + } finally { + await extracted.dispose(); + } + + try { + await link(temporaryArtifact, artifact); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "EEXIST") { + throw new ClawProjectError( + "artifact_exists", + `Refusing to overwrite existing artifact ${JSON.stringify(artifact)}.`, + ); + } + throw new ClawProjectError( + "artifact_atomic_publish_failed", + `Could not atomically publish artifact ${JSON.stringify(artifact)}: ${(error as Error).message}`, + ); + } + return { + schemaVersion: CLAW_BUILD_RESULT_SCHEMA_VERSION, + projectSchemaVersion: CLAW_PROJECT_RESULT_SCHEMA_VERSION, + artifact, + integrity, + byteLength: packed.byteLength, + files: fileNames, + excludedPaths: project.excludedPaths, + claw: { name: project.packageJson.name, version: project.packageJson.version }, + }; + } finally { + await rm(temporaryDirectory, { recursive: true, force: true }); + } +} diff --git a/src/claws/project.test.ts b/src/claws/project.test.ts new file mode 100644 index 000000000000..2d7ff0428a78 --- /dev/null +++ b/src/claws/project.test.ts @@ -0,0 +1,545 @@ +import { spawnSync } from "node:child_process"; +import { lstat, mkdir, readFile, rename, symlink, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import * as tar from "tar"; +import { afterEach, describe, expect, it } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; +import { buildClawProject } from "./project-build.js"; +import { ClawProjectError, createClawProject, validateClawProject } from "./project.js"; + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +async function writeRichProject(root: string): Promise { + await mkdir(join(root, "workspace"), { recursive: true }); + await mkdir(join(root, "profiles"), { recursive: true }); + await writeFile( + join(root, "package.json"), + `${JSON.stringify({ + name: "demo-claw", + version: "1.2.3", + openclaw: { claw: "CLAW.md" }, + })}\n`, + ); + await writeFile( + join(root, "CLAW.md"), + [ + "---", + "schemaVersion: 1", + "agent:", + " id: demo-claw", + "workspace:", + " files:", + " - source: workspace/reference.md", + " path: reference.md", + "---", + "You are the demo Claw.", + "", + ].join("\n"), + ); + await writeFile(join(root, "workspace", "reference.md"), "# Reference\n"); + await writeFile(join(root, "BOOTSTRAP.md"), "Interview the user before starting.\n"); + await writeFile(join(root, "profiles", "openclaw.yml"), "schemaVersion: 1\nagent: {}\n"); + await writeFile(join(root, "not-packed.txt"), "local scratch\n"); +} + +describe("Claw projects", () => { + it("matches the cross-platform golden artifact digest", async () => { + const output = join(tempDirs.make("openclaw-claw-golden-"), "golden.tgz"); + const result = await buildClawProject( + join(process.cwd(), "test", "fixtures", "claws", "project-v1"), + output, + ); + + expect(result.integrity).toBe( + "sha256:f7377ae66679a8d1088ac2d259b8567d19f584dbc4357949d3d4e0cc09d05874", + ); + }); + + it("matches the golden artifact digest under a restrictive umask", () => { + const output = join(tempDirs.make("openclaw-claw-umask-"), "golden.tgz"); + const project = join(process.cwd(), "test", "fixtures", "claws", "project-v1"); + const script = [ + "process.umask(0o077);", + 'const { buildClawProject } = await import("./src/claws/project-build.ts");', + `const result = await buildClawProject(${JSON.stringify(project)}, ${JSON.stringify(output)});`, + "process.stdout.write(result.integrity);", + ].join("\n"); + + const result = spawnSync( + process.execPath, + ["--import", "tsx", "--input-type=module", "--eval", script], + { + cwd: process.cwd(), + encoding: "utf8", + env: { + ...process.env, + NODE_DISABLE_COMPILE_CACHE: "1", + NODE_OPTIONS: undefined, + VITEST: undefined, + VITEST_POOL_ID: undefined, + VITEST_WORKER_ID: undefined, + }, + timeout: 60_000, + }, + ); + + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toBe( + "sha256:f7377ae66679a8d1088ac2d259b8567d19f584dbc4357949d3d4e0cc09d05874", + ); + }); + + it("creates a minimal project that validates through the canonical reader", async () => { + const root = join(tempDirs.make("openclaw-claw-create-"), "research-assistant"); + + const created = await createClawProject(root); + const validated = await validateClawProject(root); + + expect(created.packageJson).toEqual({ + name: "research-assistant", + version: "0.1.0", + openclaw: { claw: "CLAW.md" }, + }); + expect(validated.ok).toBe(true); + if (validated.ok) { + expect(validated.claw.manifest.agent.id).toBe("research-assistant"); + expect(validated.claw.clawMarkdownBody?.toString()).toContain("purpose-built OpenClaw agent"); + } + }); + + it("keeps one concurrent creator's completed project", async () => { + const root = join(tempDirs.make("openclaw-claw-create-race-"), "shared"); + await mkdir(root); + + const results = await Promise.allSettled([createClawProject(root), createClawProject(root)]); + + expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1); + expect(results.filter((result) => result.status === "rejected")).toHaveLength(1); + await expect(validateClawProject(root)).resolves.toMatchObject({ ok: true }); + }); + + it("refuses occupied targets and package lifecycle scripts", async () => { + const occupied = tempDirs.make("openclaw-claw-occupied-"); + await writeFile(join(occupied, "keep.txt"), "keep\n"); + await expect(createClawProject(occupied)).rejects.toMatchObject({ + code: "project_target_not_empty", + } satisfies Partial); + + const project = tempDirs.make("openclaw-claw-scripts-"); + await writeRichProject(project); + await writeFile( + join(project, "package.json"), + JSON.stringify({ + name: "demo-claw", + version: "1.2.3", + scripts: { postinstall: "echo unsafe" }, + openclaw: { claw: "CLAW.md" }, + }), + ); + const result = await validateClawProject(project); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.diagnostics.map((item) => item.code)).toContain("project_scripts_forbidden"); + } + }); + + it("rejects package.json as a managed workspace source", async () => { + const project = tempDirs.make("openclaw-claw-package-source-"); + const output = join(tempDirs.make("openclaw-claw-package-source-output-"), "claw.tgz"); + await writeRichProject(project); + const manifest = await readFile(join(project, "CLAW.md"), "utf8"); + await writeFile( + join(project, "CLAW.md"), + manifest.replace( + " - source: workspace/reference.md\n path: reference.md", + " - source: package.json\n path: metadata.json", + ), + ); + + await expect(validateClawProject(project)).resolves.toMatchObject({ + ok: false, + diagnostics: [expect.objectContaining({ code: "project_invalid" })], + }); + await expect(buildClawProject(project, output)).rejects.toMatchObject({ + code: "project_invalid", + } satisfies Partial); + }); + + it("builds byte-identical artifacts containing only declared project inputs", async () => { + const project = tempDirs.make("openclaw-claw-build-"); + const output = tempDirs.make("openclaw-claw-output-"); + await writeRichProject(project); + const firstPath = join(output, "first.tgz"); + const secondPath = join(output, "second.tgz"); + + const first = await buildClawProject(project, firstPath); + const second = await buildClawProject(project, secondPath); + const validation = await validateClawProject(join(project, "workspace", "reference.md")); + const entries: string[] = []; + await tar.t({ file: firstPath, onentry: (entry) => entries.push(entry.path) }); + + expect(await readFile(firstPath)).toEqual(await readFile(secondPath)); + expect(first.integrity).toBe(second.integrity); + expect(first.excludedPaths).toEqual(["not-packed.txt"]); + expect(validation).toMatchObject({ ok: true, excludedPaths: ["not-packed.txt"] }); + expect(entries).toEqual([ + "package/BOOTSTRAP.md", + "package/CLAW.md", + "package/package.json", + "package/profiles/openclaw.yml", + "package/workspace/reference.md", + ]); + expect(entries).not.toContain("package/not-packed.txt"); + }); + + it("preserves the canonical metadata-selected OpenClaw profile path", async () => { + const project = tempDirs.make("openclaw-claw-custom-profile-"); + const output = join(tempDirs.make("openclaw-claw-custom-profile-output-"), "claw.tgz"); + await writeRichProject(project); + await rename( + join(project, "profiles", "openclaw.yml"), + join(project, "profiles", "custom.yaml"), + ); + const manifest = await readFile(join(project, "CLAW.md"), "utf8"); + await writeFile( + join(project, "CLAW.md"), + manifest.replace( + "agent:\n id: demo-claw", + "agent:\n id: demo-claw\nmetadata:\n openclaw.config: profiles/custom.yaml", + ), + ); + + const validation = await validateClawProject(project); + const result = await buildClawProject(project, output); + const entries: string[] = []; + await tar.t({ file: output, onentry: (entry) => entries.push(entry.path) }); + + expect(validation).toMatchObject({ ok: true }); + if (validation.ok) { + expect(validation.claw.snapshot.openClawProfile?.sourcePath).toBe("profiles/custom.yaml"); + expect(validation.excludedPaths).not.toContain("profiles/custom.yaml"); + } + expect(result.files).toContain("profiles/custom.yaml"); + expect(entries).toContain("package/profiles/custom.yaml"); + expect(entries).not.toContain("package/profiles/openclaw.yml"); + }); + + it("packages a leading-at workspace source as an ordinary file", async () => { + const project = tempDirs.make("openclaw-claw-leading-at-"); + const output = join(tempDirs.make("openclaw-claw-leading-at-output-"), "claw.tgz"); + await writeRichProject(project); + const manifest = await readFile(join(project, "CLAW.md"), "utf8"); + await writeFile( + join(project, "CLAW.md"), + manifest.replace("workspace/reference.md", '"@notes.md"'), + ); + await writeFile(join(project, "@notes.md"), "# Notes\n"); + + const result = await buildClawProject(project, output); + const entries: string[] = []; + await tar.t({ file: output, onentry: (entry) => entries.push(entry.path) }); + + expect(result.files).toContain("@notes.md"); + expect(entries).toContain("package/@notes.md"); + }); + + it("packages a valid source whose filename begins with two dots", async () => { + const project = tempDirs.make("openclaw-claw-leading-dots-"); + const output = join(tempDirs.make("openclaw-claw-leading-dots-output-"), "claw.tgz"); + await writeRichProject(project); + const manifest = await readFile(join(project, "CLAW.md"), "utf8"); + await writeFile( + join(project, "CLAW.md"), + manifest.replace("workspace/reference.md", "..notes.md"), + ); + await writeFile(join(project, "..notes.md"), "# Notes\n"); + + const result = await buildClawProject(project, output); + const entries: string[] = []; + await tar.t({ file: output, onentry: (entry) => entries.push(entry.path) }); + + expect(result.files).toContain("..notes.md"); + expect(entries).toContain("package/..notes.md"); + }); + + it("normalizes accepted backslash source separators in the built package", async () => { + const project = tempDirs.make("openclaw-claw-backslash-source-"); + const output = join(tempDirs.make("openclaw-claw-backslash-source-output-"), "claw.tgz"); + await writeRichProject(project); + const manifest = await readFile(join(project, "CLAW.md"), "utf8"); + await writeFile( + join(project, "CLAW.md"), + manifest.replace( + " - source: workspace/reference.md", + String.raw` - source: 'workspace\reference.md'`, + ), + ); + + const validation = await validateClawProject(project); + const result = await buildClawProject(project, output); + const entries: string[] = []; + await tar.t({ file: output, onentry: (entry) => entries.push(entry.path) }); + + expect(validation).toMatchObject({ ok: true }); + expect(result.files).toContain("workspace/reference.md"); + expect(entries).toContain("package/workspace/reference.md"); + }); + + it("preserves long workspace source paths deterministically", async () => { + const project = tempDirs.make("openclaw-claw-long-path-"); + const output = tempDirs.make("openclaw-claw-long-path-output-"); + await writeRichProject(project); + const longName = `${"a".repeat(140)}.md`; + const longSource = `workspace/${longName}`; + const manifest = await readFile(join(project, "CLAW.md"), "utf8"); + await writeFile( + join(project, "CLAW.md"), + manifest.replace("workspace/reference.md", longSource), + ); + await writeFile(join(project, longSource), "# Long path\n"); + + const firstPath = join(output, "first.tgz"); + const secondPath = join(output, "second.tgz"); + const first = await buildClawProject(project, firstPath); + const second = await buildClawProject(project, secondPath); + const entries: string[] = []; + await tar.t({ file: firstPath, onentry: (entry) => entries.push(entry.path) }); + + expect(await readFile(firstPath)).toEqual(await readFile(secondPath)); + expect(first.integrity).toBe(second.integrity); + expect(first.files).toContain(longSource); + expect(entries).toContain(`package/${longSource}`); + }); + + it.runIf(process.platform === "win32")( + "does not report a differently cased selected file as excluded", + async () => { + const project = tempDirs.make("openclaw-claw-selected-case-"); + await writeRichProject(project); + const temporaryManifest = join(project, "manifest.tmp"); + await rename(join(project, "CLAW.md"), temporaryManifest); + await rename(temporaryManifest, join(project, "claw.md")); + + const result = await validateClawProject(project); + + expect(result).toMatchObject({ ok: true }); + if (result.ok) { + expect(result.excludedPaths).not.toContain("claw.md"); + } + }, + ); + + it("dereferences only a confined CLAW.md symlink into the artifact", async () => { + const project = tempDirs.make("openclaw-claw-manifest-link-"); + const output = join(tempDirs.make("openclaw-claw-manifest-link-output-"), "linked.tgz"); + const unpacked = tempDirs.make("openclaw-claw-manifest-link-unpacked-"); + await writeRichProject(project); + await mkdir(join(project, "manifest")); + await rename(join(project, "CLAW.md"), join(project, "manifest", "source.md")); + await symlink("manifest/source.md", join(project, "CLAW.md"), "file"); + + await expect(validateClawProject(project)).resolves.toMatchObject({ ok: true }); + await buildClawProject(project, output); + await tar.x({ cwd: unpacked, file: output, strict: true }); + + expect((await lstat(join(unpacked, "package", "CLAW.md"))).isFile()).toBe(true); + expect(await readFile(join(unpacked, "package", "CLAW.md"), "utf8")).toContain( + "You are the demo Claw.", + ); + }); + + it.each([".git/CLAW.md", "node_modules/example/CLAW.md"])( + "rejects a CLAW.md symlink into excluded tree %s", + async (targetPath) => { + const project = tempDirs.make("openclaw-claw-manifest-excluded-link-"); + await writeRichProject(project); + await mkdir(dirname(join(project, targetPath)), { recursive: true }); + await rename(join(project, "CLAW.md"), join(project, targetPath)); + await symlink(targetPath, join(project, "CLAW.md"), "file"); + + await expect(validateClawProject(project)).resolves.toMatchObject({ + ok: false, + diagnostics: [expect.objectContaining({ code: "project_not_found" })], + }); + }, + ); + + it("rejects a CLAW.md symlink that escapes the project", async () => { + const project = tempDirs.make("openclaw-claw-manifest-escape-"); + const outside = tempDirs.make("openclaw-claw-manifest-outside-"); + await writeRichProject(project); + await rename(join(project, "CLAW.md"), join(outside, "CLAW.md")); + await symlink(join(outside, "CLAW.md"), join(project, "CLAW.md"), "file"); + + await expect(validateClawProject(project)).resolves.toMatchObject({ + ok: false, + diagnostics: [expect.objectContaining({ code: "project_not_found" })], + }); + }); + + it.each([ + ".git/config", + "node_modules/example/secret.md", + "workspace/.git/config", + "workspace/node_modules/example/secret.md", + ])("rejects an explicitly selected source from %s", async (sourcePath) => { + const project = tempDirs.make("openclaw-claw-excluded-source-"); + const output = join(tempDirs.make("openclaw-claw-excluded-source-output-"), "claw.tgz"); + await writeRichProject(project); + await mkdir(dirname(join(project, sourcePath)), { recursive: true }); + await writeFile(join(project, sourcePath), "sensitive local state\n"); + const manifest = await readFile(join(project, "CLAW.md"), "utf8"); + await writeFile( + join(project, "CLAW.md"), + manifest.replace("workspace/reference.md", sourcePath), + ); + + await expect(validateClawProject(project)).resolves.toMatchObject({ + ok: false, + diagnostics: [expect.objectContaining({ code: "project_excluded_source" })], + }); + await expect(buildClawProject(project, output)).rejects.toMatchObject({ + code: "project_invalid", + } satisfies Partial); + }); + + it("rejects a custom profile selected from an excluded tree", async () => { + const project = tempDirs.make("openclaw-claw-excluded-profile-"); + await writeRichProject(project); + await rename( + join(project, "profiles", "openclaw.yml"), + join(project, "profiles", "unused.yml"), + ); + await mkdir(join(project, ".git"), { recursive: true }); + await writeFile(join(project, ".git", "profile.yaml"), "schemaVersion: 1\nagent: {}\n"); + const manifest = await readFile(join(project, "CLAW.md"), "utf8"); + await writeFile( + join(project, "CLAW.md"), + manifest.replace( + "agent:\n id: demo-claw", + "agent:\n id: demo-claw\nmetadata:\n openclaw.config: .git/profile.yaml", + ), + ); + + await expect(validateClawProject(project)).resolves.toMatchObject({ + ok: false, + diagnostics: [expect.objectContaining({ code: "project_excluded_source" })], + }); + }); + + it.runIf(process.platform !== "win32")( + "rejects a workspace source that portably collides with CLAW.md", + async () => { + const project = tempDirs.make("openclaw-claw-manifest-case-collision-"); + await writeRichProject(project); + const manifest = await readFile(join(project, "CLAW.md"), "utf8"); + await writeFile( + join(project, "CLAW.md"), + manifest.replace("workspace/reference.md", "claw.md"), + ); + await writeFile(join(project, "claw.md"), "# Conflicting source\n"); + + await expect(validateClawProject(project)).resolves.toMatchObject({ + ok: false, + diagnostics: [expect.objectContaining({ code: "project_path_collision" })], + }); + }, + ); + + it.runIf(process.platform !== "win32")( + "rejects a workspace source that portably collides with a custom profile", + async () => { + const project = tempDirs.make("openclaw-claw-profile-case-collision-"); + await writeRichProject(project); + await rename( + join(project, "profiles", "openclaw.yml"), + join(project, "profiles", "custom.yaml"), + ); + await writeFile(join(project, "profiles", "CUSTOM.yaml"), "schemaVersion: 1\nagent: {}\n"); + const manifest = await readFile(join(project, "CLAW.md"), "utf8"); + await writeFile( + join(project, "CLAW.md"), + manifest + .replace( + "agent:\n id: demo-claw", + "agent:\n id: demo-claw\nmetadata:\n openclaw.config: profiles/custom.yaml", + ) + .replace("workspace/reference.md", "profiles/CUSTOM.yaml"), + ); + + await expect(validateClawProject(project)).resolves.toMatchObject({ + ok: false, + diagnostics: [expect.objectContaining({ code: "project_path_collision" })], + }); + }, + ); + + it.runIf(process.platform !== "win32")( + "rejects Unicode-normalization collisions between workspace sources", + async () => { + const project = tempDirs.make("openclaw-claw-unicode-collision-"); + await writeRichProject(project); + const composed = "workspace/caf\u00e9.md"; + const decomposed = "workspace/cafe\u0301.md"; + const manifest = await readFile(join(project, "CLAW.md"), "utf8"); + await writeFile( + join(project, "CLAW.md"), + manifest.replace( + " - source: workspace/reference.md\n path: reference.md", + [ + ` - source: ${JSON.stringify(composed)}`, + " path: composed.md", + ` - source: ${JSON.stringify(decomposed)}`, + " path: decomposed.md", + ].join("\n"), + ), + ); + await writeFile(join(project, composed), "# Composed\n"); + await writeFile(join(project, decomposed), "# Decomposed\n"); + + await expect(validateClawProject(project)).resolves.toMatchObject({ + ok: false, + diagnostics: [expect.objectContaining({ code: "project_path_collision" })], + }); + }, + ); + + it("preserves an existing build destination", async () => { + const project = tempDirs.make("openclaw-claw-build-existing-"); + const output = join(tempDirs.make("openclaw-claw-output-existing-"), "existing.tgz"); + await writeRichProject(project); + await writeFile(output, "keep this artifact\n"); + + await expect(buildClawProject(project, output)).rejects.toMatchObject({ + code: "artifact_exists", + } satisfies Partial); + expect(await readFile(output, "utf8")).toBe("keep this artifact\n"); + }); + + it("rejects ambiguous nested project discovery", async () => { + const outer = tempDirs.make("openclaw-claw-nested-"); + const inner = join(outer, "examples", "nested"); + await writeRichProject(outer); + await writeRichProject(inner); + + const result = await validateClawProject(join(inner, "CLAW.md")); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.diagnostics.map((item) => item.code)).toContain("ambiguous_project_root"); + } + }); + + it("changes the artifact digest when a declared input changes", async () => { + const project = tempDirs.make("openclaw-claw-build-change-"); + const output = tempDirs.make("openclaw-claw-output-change-"); + await writeRichProject(project); + + const first = await buildClawProject(project, join(output, "first.tgz")); + await writeFile(join(project, "workspace", "reference.md"), "# Changed reference\n"); + const second = await buildClawProject(project, join(output, "second.tgz")); + + expect(first.integrity).not.toBe(second.integrity); + }); +}); diff --git a/src/claws/project.ts b/src/claws/project.ts new file mode 100644 index 000000000000..a81feb37e409 --- /dev/null +++ b/src/claws/project.ts @@ -0,0 +1,455 @@ +import { lstat, mkdir, readdir, realpath, rmdir, unlink, writeFile } from "node:fs/promises"; +import { basename, dirname, isAbsolute, parse, relative, resolve, sep } from "node:path"; +import { root as fsSafeRoot } from "../infra/fs-safe.js"; +import { readClawManifestFile } from "./reader.js"; +import { isCanonicalClawHubPackageName, portableClawPathKey } from "./schema-portability.js"; +import type { ClawDiagnostic, ClawReadResult } from "./types.js"; + +export const CLAW_PROJECT_RESULT_SCHEMA_VERSION = "openclaw.clawProject.v1" as const; + +const MAX_PACKAGE_JSON_BYTES = 256 * 1024; +const MAX_PROJECT_ENTRIES = 4096; +const AGENT_ID_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/; + +type ClawProjectPackageJson = { + name: string; + version: string; + type?: string; + openclaw: { claw: "CLAW.md" }; +}; + +type ClawProjectValidationResult = + | { + ok: true; + root: string; + packageJson: ClawProjectPackageJson; + claw: Extract; + excludedPaths: string[]; + diagnostics: ClawDiagnostic[]; + } + | { ok: false; root: string; diagnostics: ClawDiagnostic[] }; + +export class ClawProjectError extends Error { + constructor( + readonly code: string, + message: string, + ) { + super(message); + this.name = "ClawProjectError"; + } +} + +function diagnostic(code: string, path: string, message: string): ClawDiagnostic { + return { level: "error", code, phase: "policy", path, message }; +} + +function defaultSlug(value: string): string { + const slug = value + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/^[^a-z]+/, "") + .replace(/-+$/g, "") + .slice(0, 64); + return slug || "my-claw"; +} + +function displayName(agentId: string): string { + return agentId + .split(/[-_]+/) + .filter(Boolean) + .map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`) + .join(" "); +} + +async function pathState(path: string): Promise<"missing" | "empty-directory" | "occupied"> { + const entry = await lstat(path).catch(() => undefined); + if (!entry) { + return "missing"; + } + if (!entry.isDirectory()) { + return "occupied"; + } + return (await readdir(path)).length === 0 ? "empty-directory" : "occupied"; +} + +async function isFile(path: string): Promise { + return lstat(path) + .then((entry) => entry.isFile()) + .catch(() => false); +} + +async function isConfinedManifestFile(root: string): Promise { + const manifestPath = resolve(root, "CLAW.md"); + const entry = await lstat(manifestPath).catch(() => undefined); + if (entry?.isFile()) { + return true; + } + if (!entry?.isSymbolicLink()) { + return false; + } + const [rootReal, targetReal] = await Promise.all([ + realpath(root).catch(() => undefined), + realpath(manifestPath).catch(() => undefined), + ]); + if (!rootReal || !targetReal) { + return false; + } + const targetRelative = relative(rootReal, targetReal); + if ( + targetRelative === "" || + targetRelative === ".." || + targetRelative.startsWith(`..${sep}`) || + isAbsolute(targetRelative) || + isExcludedProjectSource(targetRelative) + ) { + return false; + } + return lstat(targetReal) + .then((target) => target.isFile()) + .catch(() => false); +} + +async function discoverClawProjectRoot(projectPath: string): Promise { + const input = resolve(projectPath); + const inputStat = await lstat(input).catch(() => undefined); + if (!inputStat) { + throw new ClawProjectError( + "project_not_found", + `Could not resolve Claw project path ${JSON.stringify(input)}.`, + ); + } + let current = inputStat.isDirectory() ? input : dirname(input); + const roots: string[] = []; + const filesystemRoot = parse(current).root; + while (true) { + if ( + (await isFile(resolve(current, "package.json"))) && + (await isConfinedManifestFile(current)) + ) { + roots.push(await realpath(current)); + } + if (current === filesystemRoot) { + break; + } + current = dirname(current); + } + if (roots.length === 0) { + throw new ClawProjectError( + "project_not_found", + `No Claw project containing package.json and CLAW.md was found from ${JSON.stringify(input)}.`, + ); + } + if (roots.length > 1) { + throw new ClawProjectError( + "ambiguous_project_root", + `Multiple Claw project roots contain ${JSON.stringify(input)}: ${roots.join(", ")}.`, + ); + } + return roots[0] as string; +} + +function projectPathKey(value: string, caseInsensitive: boolean): string { + const normalized = value.normalize("NFC"); + return caseInsensitive ? normalized.toLowerCase() : normalized; +} + +function isExcludedProjectSource(value: string): boolean { + return portableClawPathKey(value) + .split("/") + .some((segment) => segment === ".git" || segment === "node_modules"); +} + +async function isCaseInsensitiveProjectRoot(root: string): Promise { + const [canonical, folded] = await Promise.all([ + lstat(resolve(root, "CLAW.md")).catch(() => undefined), + lstat(resolve(root, "claw.md")).catch(() => undefined), + ]); + return Boolean( + canonical && folded && canonical.dev === folded.dev && canonical.ino === folded.ino, + ); +} + +async function collectExcludedPaths(root: string, selectedPaths: Set): Promise { + const excluded: string[] = []; + const caseInsensitive = await isCaseInsensitiveProjectRoot(root); + const selectedPathKeys = new Set( + [...selectedPaths].map((path) => projectPathKey(path, caseInsensitive)), + ); + let entryCount = 0; + const visit = async (directory: string): Promise => { + const entries = await readdir(resolve(root, directory), { withFileTypes: true }); + for (const entry of entries) { + entryCount += 1; + if (entryCount > MAX_PROJECT_ENTRIES) { + throw new ClawProjectError( + "project_too_many_entries", + `Claw projects may contain at most ${MAX_PROJECT_ENTRIES} entries outside excluded dependency and source-control trees.`, + ); + } + const path = directory ? `${directory}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + if (entry.name === ".git" || entry.name === "node_modules") { + excluded.push(`${path}/`); + } else { + await visit(path); + } + } else if (!selectedPathKeys.has(projectPathKey(path, caseInsensitive))) { + excluded.push(path); + } + } + }; + await visit(""); + return excluded.toSorted((left, right) => Buffer.compare(Buffer.from(left), Buffer.from(right))); +} + +export async function createClawProject( + projectPath: string, + options: { name?: string; agentId?: string } = {}, +): Promise<{ root: string; packageJson: ClawProjectPackageJson; filesWritten: string[] }> { + const root = resolve(projectPath); + const initialState = await pathState(root); + if (initialState === "occupied") { + throw new ClawProjectError( + "project_target_not_empty", + `Claw project target ${JSON.stringify(root)} must be absent or empty.`, + ); + } + + const agentId = options.agentId ?? defaultSlug(basename(root)); + if (!AGENT_ID_PATTERN.test(agentId)) { + throw new ClawProjectError( + "invalid_agent_id", + `Agent id ${JSON.stringify(agentId)} must match ${AGENT_ID_PATTERN}.`, + ); + } + const name = options.name ?? agentId; + if (!isCanonicalClawHubPackageName(name)) { + throw new ClawProjectError( + "invalid_package_name", + `Package name ${JSON.stringify(name)} must be a canonical ClawHub package name.`, + ); + } + + const packageJson: ClawProjectPackageJson = { + name, + version: "0.1.0", + openclaw: { claw: "CLAW.md" }, + }; + const clawMarkdown = [ + "---", + "schemaVersion: 1", + "agent:", + ` id: ${JSON.stringify(agentId)}`, + ` name: ${JSON.stringify(displayName(agentId))}`, + "---", + `You are ${displayName(agentId)}, a purpose-built OpenClaw agent.`, + "", + ].join("\n"); + + const packageJsonPath = resolve(root, "package.json"); + const clawMarkdownPath = resolve(root, "CLAW.md"); + const createdPaths: string[] = []; + await mkdir(root, { recursive: true }); + try { + await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, { + encoding: "utf8", + flag: "wx", + }); + createdPaths.push(packageJsonPath); + await writeFile(clawMarkdownPath, clawMarkdown, { encoding: "utf8", flag: "wx" }); + createdPaths.push(clawMarkdownPath); + } catch (error) { + await Promise.allSettled(createdPaths.map((path) => unlink(path))); + if (initialState === "missing") { + await rmdir(root).catch(() => undefined); + } + throw error; + } + return { root, packageJson, filesWritten: ["package.json", "CLAW.md"] }; +} + +export async function validateClawProject( + projectPath: string, +): Promise { + let root: string; + try { + root = await discoverClawProjectRoot(projectPath); + } catch (error) { + return { + ok: false, + root: resolve(projectPath), + diagnostics: [ + diagnostic( + error instanceof ClawProjectError ? error.code : "project_discovery_failed", + "$", + error instanceof Error ? error.message : String(error), + ), + ], + }; + } + let packageValue: unknown; + try { + const sourceRoot = await fsSafeRoot(root); + const read = await sourceRoot.read("package.json", { + hardlinks: "reject", + maxBytes: MAX_PACKAGE_JSON_BYTES, + nonBlockingRead: true, + symlinks: "reject", + }); + packageValue = JSON.parse(read.buffer.toString("utf8")); + } catch (error) { + return { + ok: false, + root, + diagnostics: [ + diagnostic( + "invalid_project_package", + "package.json", + `Could not read a safe project package.json: ${(error as Error).message}`, + ), + ], + }; + } + + const record = + packageValue && typeof packageValue === "object" && !Array.isArray(packageValue) + ? (packageValue as Record) + : undefined; + const openclaw = + record?.openclaw && typeof record.openclaw === "object" && !Array.isArray(record.openclaw) + ? (record.openclaw as Record) + : undefined; + const scripts = record?.scripts; + const diagnostics: ClawDiagnostic[] = []; + if (openclaw?.claw !== "CLAW.md") { + diagnostics.push( + diagnostic( + "project_manifest_must_be_claw_markdown", + "package.json.openclaw.claw", + 'A Claw project must set openclaw.claw to "CLAW.md".', + ), + ); + } + if ( + scripts !== undefined && + (typeof scripts !== "object" || + scripts === null || + Array.isArray(scripts) || + Object.keys(scripts).length > 0) + ) { + diagnostics.push( + diagnostic( + "project_scripts_forbidden", + "package.json.scripts", + "Claw projects cannot declare package scripts or lifecycle hooks.", + ), + ); + } + if (diagnostics.length > 0) { + return { ok: false, root, diagnostics }; + } + + const claw = await readClawManifestFile(root); + if (!claw.ok) { + return { ok: false, root, diagnostics: claw.diagnostics }; + } + const excludedSource = [ + ...(claw.snapshot.openClawProfile + ? [ + { + path: claw.snapshot.openClawProfile.sourcePath, + diagnosticPath: "$.metadata.openclaw.config", + }, + ] + : []), + ...claw.snapshot.workspaceSources.map((source) => ({ + path: source.sourcePath, + diagnosticPath: "$.workspace", + })), + ].find((source) => isExcludedProjectSource(source.path)); + if (excludedSource) { + return { + ok: false, + root, + diagnostics: [ + diagnostic( + "project_excluded_source", + excludedSource.diagnosticPath, + `Selected project source ${JSON.stringify(excludedSource.path)} cannot come from .git or node_modules.`, + ), + ], + }; + } + const reservedPackageSource = claw.snapshot.workspaceSources.find( + (source) => source.sourcePath.normalize("NFC").toLowerCase() === "package.json", + ); + if (reservedPackageSource) { + return { + ok: false, + root, + diagnostics: [ + diagnostic( + "project_invalid", + "$.workspace.files", + `Workspace source ${JSON.stringify(reservedPackageSource.sourcePath)} collides with generated package metadata.`, + ), + ], + }; + } + const selectedPathList = [ + "package.json", + "CLAW.md", + ...(claw.packageBootstrap ? ["BOOTSTRAP.md"] : []), + ...(claw.snapshot.openClawProfile ? [claw.snapshot.openClawProfile.sourcePath] : []), + ...claw.snapshot.workspaceSources.map((source) => source.sourcePath), + ]; + const portableSelectedPaths = new Map(); + for (const path of selectedPathList) { + const key = portableClawPathKey(path); + const existing = portableSelectedPaths.get(key); + if (existing && existing !== path) { + return { + ok: false, + root, + diagnostics: [ + diagnostic( + "project_path_collision", + "$", + `Selected project paths ${JSON.stringify(existing)} and ${JSON.stringify(path)} collide on portable filesystems.`, + ), + ], + }; + } + portableSelectedPaths.set(key, path); + } + const selectedPaths = new Set(selectedPathList); + let excludedPaths: string[]; + try { + excludedPaths = await collectExcludedPaths(root, selectedPaths); + } catch (error) { + return { + ok: false, + root, + diagnostics: [ + diagnostic( + error instanceof ClawProjectError ? error.code : "project_enumeration_failed", + "$", + error instanceof Error ? error.message : String(error), + ), + ], + }; + } + return { + ok: true, + root, + packageJson: { + name: claw.source.name, + version: claw.source.version, + ...(typeof record?.type === "string" ? { type: record.type } : {}), + openclaw: { claw: "CLAW.md" }, + }, + claw, + excludedPaths, + diagnostics: claw.diagnostics, + }; +} diff --git a/src/claws/reader.ts b/src/claws/reader.ts index 67764cf5f7d5..7f25463b371d 100644 --- a/src/claws/reader.ts +++ b/src/claws/reader.ts @@ -101,6 +101,8 @@ async function buildDevelopmentSnapshot(params: { ok: true; integrity: string; byteLength: number; + manifest: { byteLength: number; digest: string }; + openClawProfile?: { sourcePath: string; byteLength: number; digest: string }; workspaceSources: ClawWorkspaceSourceSnapshot[]; packageBootstrap?: ClawWorkspaceSourceSnapshot; } @@ -112,6 +114,17 @@ async function buildDevelopmentSnapshot(params: { updateSnapshotHash(hash, label, bytes); byteLength += bytes.byteLength; }; + const snapshotFile = (bytes: Buffer) => ({ + byteLength: bytes.byteLength, + digest: `sha256:${createHash("sha256").update(bytes).digest("hex")}`, + }); + const manifest = snapshotFile(params.manifestRaw); + const openClawProfile = params.openClawProfile + ? { + sourcePath: params.openClawProfile.path.replaceAll("\\", "/"), + ...snapshotFile(params.openClawProfile.raw), + } + : undefined; add("canonical-source", Buffer.from(params.source.manifestPath, "utf8")); add("manifest", params.manifestRaw); if (params.openClawProfile) { @@ -272,6 +285,8 @@ async function buildDevelopmentSnapshot(params: { ok: true, integrity: `sha256:${hash.digest("hex")}`, byteLength, + manifest, + ...(openClawProfile ? { openClawProfile } : {}), workspaceSources, ...(packageBootstrap ? { packageBootstrap } : {}), }; @@ -650,6 +665,8 @@ export async function readClawManifestFile(path: string): Promise value.replaceAll("\\", "/")); const identitySchema = z .object({ diff --git a/src/claws/types.ts b/src/claws/types.ts index 7117aa203cf9..9b65ab6cfb44 100644 --- a/src/claws/types.ts +++ b/src/claws/types.ts @@ -218,7 +218,18 @@ export type ClawWorkspaceSourceSnapshot = { digest: string; }; +type ClawSourceFileSnapshot = { + byteLength: number; + digest: string; +}; + +type ClawProfileSourceSnapshot = ClawSourceFileSnapshot & { + sourcePath: string; +}; + type ClawSourceSnapshot = { + manifest: ClawSourceFileSnapshot; + openClawProfile?: ClawProfileSourceSnapshot; workspaceSources: ClawWorkspaceSourceSnapshot[]; packageBootstrap?: ClawWorkspaceSourceSnapshot; }; diff --git a/src/cli/claws-authoring-state.process.test.ts b/src/cli/claws-authoring-state.process.test.ts new file mode 100644 index 000000000000..7ea6bccb7c69 --- /dev/null +++ b/src/cli/claws-authoring-state.process.test.ts @@ -0,0 +1,111 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +async function snapshotTree(root: string): Promise { + const snapshot: string[] = []; + const walk = async (directory: string, relativeDirectory: string): Promise => { + const entries = await fs.readdir(directory, { withFileTypes: true }); + entries.sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + const relativePath = path.posix.join(relativeDirectory, entry.name); + const absolutePath = path.join(directory, entry.name); + if (entry.isDirectory()) { + snapshot.push(`d ${relativePath}`); + await walk(absolutePath, relativePath); + } else { + snapshot.push(`f ${relativePath} ${(await fs.readFile(absolutePath)).toString("base64")}`); + } + } + }; + await walk(root, ""); + return snapshot; +} + +async function writeProject(root: string): Promise { + await fs.mkdir(root, { recursive: true }); + await fs.writeFile( + path.join(root, "package.json"), + `${JSON.stringify({ + name: "authoring-state-test", + version: "1.0.0", + openclaw: { claw: "CLAW.md" }, + })}\n`, + ); + await fs.writeFile( + path.join(root, "CLAW.md"), + ["---", "schemaVersion: 1", "agent:", " id: authoring-state-test", "---", ""].join("\n"), + ); +} + +function runClaws(root: string, args: string[]) { + return spawnSync( + process.execPath, + ["--import", "tsx", path.resolve("src", "entry.ts"), "claws", ...args], + { + cwd: path.resolve("."), + encoding: "utf8", + env: { + ...process.env, + HOME: root, + USERPROFILE: root, + NODE_DISABLE_COMPILE_CACHE: "1", + NODE_ENV: undefined, + NODE_OPTIONS: undefined, + NO_COLOR: "1", + OPENCLAW_CONFIG_PATH: path.join(root, "config", "openclaw.json"), + OPENCLAW_EXPERIMENTAL_CLAWS: "1", + OPENCLAW_HIDE_BANNER: "1", + OPENCLAW_HOME: root, + OPENCLAW_NO_RESPAWN: "1", + OPENCLAW_STATE_DIR: path.join(root, "state"), + VITEST: undefined, + VITEST_POOL_ID: undefined, + VITEST_WORKER_ID: undefined, + }, + maxBuffer: 4 * 1024 * 1024, + timeout: 60_000, + }, + ); +} + +describe("Claw authoring process state", () => { + it.each(["create", "validate", "build", "dev"] as const)( + "leaves migration-pending operator state unchanged for claws %s", + async (command) => { + const root = tempDirs.make(`openclaw-claws-${command}-state-`); + const external = tempDirs.make(`openclaw-claws-${command}-external-`); + const project = path.join(external, command === "create" ? "created-project" : "project"); + const output = path.join(external, `${command}.tgz`); + const workspace = path.join(external, `${command}-workspace`); + await fs.mkdir(path.join(root, "config"), { recursive: true }); + await fs.mkdir(path.join(root, "state", "tasks"), { recursive: true }); + await fs.writeFile(path.join(root, "config", "openclaw.json"), "{}\n"); + await fs.writeFile(path.join(root, "state", "tasks", "runs.sqlite"), "legacy state\n"); + if (command !== "create") { + await writeProject(project); + } + const before = await snapshotTree(root); + const args = + command === "create" + ? ["create", project, "--json"] + : command === "validate" + ? ["validate", project, "--json"] + : command === "build" + ? ["build", project, "--out", output, "--json"] + : ["dev", project, "--workspace", workspace, "--json"]; + + const result = runClaws(root, args); + + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toBeTruthy(); + expect(await snapshotTree(root)).toEqual(before); + }, + 120_000, + ); +}); diff --git a/src/cli/claws-cli.project.test.ts b/src/cli/claws-cli.project.test.ts new file mode 100644 index 000000000000..d66ff2d5e262 --- /dev/null +++ b/src/cli/claws-cli.project.test.ts @@ -0,0 +1,131 @@ +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; + +const mocks = vi.hoisted(() => { + const payloads: unknown[] = []; + return { + payloads, + runtime: { + log: vi.fn(), + error: vi.fn(), + writeJson: vi.fn((value: unknown) => payloads.push(value)), + writeStdout: vi.fn(), + exit: vi.fn((code: number) => { + throw new Error(`__exit__:${code}`); + }), + }, + }; +}); + +vi.mock("../runtime.js", async () => ({ + ...(await vi.importActual("../runtime.js")), + defaultRuntime: mocks.runtime, + writeRuntimeJson: (runtime: typeof mocks.runtime, value: unknown) => runtime.writeJson(value), +})); + +vi.mock("../config/config.js", async () => ({ + ...(await vi.importActual("../config/config.js")), + readConfigFileSnapshot: async () => ({ + exists: true, + valid: true, + issues: [], + warnings: [], + legacyIssues: [], + path: "/tmp/openclaw.json", + raw: {}, + sourceConfig: {}, + resolved: {}, + }), +})); + +const { runClawsBuildCommand, runClawsCreateCommand, runClawsDevCommand, runClawsValidateCommand } = + await import("./claws-cli.project.js"); +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +describe("Claw project CLI", () => { + beforeEach(() => { + vi.stubEnv("OPENCLAW_EXPERIMENTAL_CLAWS", "1"); + mocks.payloads.length = 0; + }); + + it("runs create, validate, build, and offline dev against the built artifact", async () => { + const root = join(tempDirs.make("openclaw-claw-author-"), "author-flow"); + const artifact = join(tempDirs.make("openclaw-claw-author-output-"), "author-flow.tgz"); + + await runClawsCreateCommand(root, { json: true }); + await runClawsValidateCommand(root, { json: true }); + await runClawsBuildCommand(root, { out: artifact, json: true }); + await runClawsDevCommand(root, { + agentId: "author-flow-preview", + workspace: join(root, "preview-workspace"), + json: true, + }); + + const payloads = mocks.payloads as Array>; + expect(payloads.map((payload) => payload.schemaVersion)).toEqual([ + "openclaw.clawProject.v1", + "openclaw.clawProject.v1", + "openclaw.clawBuild.v1", + "openclaw.clawDev.v1", + ]); + expect(payloads[1]).toMatchObject({ excludedPaths: [] }); + expect(payloads[2]).toMatchObject({ excludedPaths: [] }); + const dev = payloads[3] as { mutationAllowed: boolean; offline: boolean; plan: ClawPlan }; + expect(dev).toMatchObject({ mutationAllowed: false, offline: true }); + expect(dev.plan).toMatchObject({ mutationAllowed: false, blockers: [] }); + expect(dev.plan.claw).toMatchObject({ + integrityKind: "artifact", + integrity: (payloads[2] as { integrity: string }).integrity, + }); + expect(dev.plan.claw.packageRoot).toBe( + `claw-artifact:${(payloads[2] as { integrity: string }).integrity}`, + ); + }); + + it("emits stable dev plans without deleted extraction paths", async () => { + const root = join(tempDirs.make("openclaw-claw-dev-stable-"), "stable-dev"); + await runClawsCreateCommand(root, { json: true }); + const options = { + agentId: "stable-dev-preview", + workspace: join(root, "preview-workspace"), + json: true, + }; + + await runClawsDevCommand(root, options); + await runClawsDevCommand(root, options); + + const payloads = mocks.payloads as Array>; + const first = payloads[1] as { plan: ClawPlan }; + const second = payloads[2] as { plan: ClawPlan }; + expect(first.plan).toEqual(second.plan); + expect(first.plan.planIntegrity).toBe(second.plan.planIntegrity); + expect(JSON.stringify(first.plan)).not.toContain("openclaw-claw-artifact-"); + expect(first.plan.claw.packageRoot).toMatch(/^claw-artifact:sha256:/u); + }); + + it("uses command-specific schemas for build and dev failures", async () => { + const missing = join(tempDirs.make("openclaw-claw-errors-"), "missing"); + + await expect( + runClawsBuildCommand(missing, { + out: join(tempDirs.make("openclaw-claw-error-output-"), "missing.tgz"), + json: true, + }), + ).rejects.toThrow("__exit__:1"); + await expect(runClawsDevCommand(missing, { json: true })).rejects.toThrow("__exit__:1"); + + const payloads = mocks.payloads as Array>; + expect(payloads).toMatchObject([ + { schemaVersion: "openclaw.clawBuild.v1", ok: false }, + { schemaVersion: "openclaw.clawDev.v1", ok: false }, + ]); + }); +}); + +type ClawPlan = { + mutationAllowed: boolean; + planIntegrity: string; + blockers: unknown[]; + claw: { integrityKind: string; integrity: string; packageRoot: string }; +}; diff --git a/src/cli/claws-cli.project.ts b/src/cli/claws-cli.project.ts new file mode 100644 index 000000000000..05634d52680b --- /dev/null +++ b/src/cli/claws-cli.project.ts @@ -0,0 +1,291 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { listAgentIds, resolveAgentWorkspaceDir } from "../agents/agent-scope-config.js"; +import { assertExperimentalClawsEnabled } from "../claws/experimental.js"; +import { buildClawAddPlan } from "../claws/lifecycle.js"; +import { + CLAW_BUILD_RESULT_SCHEMA_VERSION, + buildClawProject, + extractBuiltClawArtifact, +} from "../claws/project-build.js"; +import { + CLAW_PROJECT_RESULT_SCHEMA_VERSION, + ClawProjectError, + createClawProject, + validateClawProject, +} from "../claws/project.js"; +import { readClawManifestFile } from "../claws/reader.js"; +import { CLAW_OUTPUT_STABILITY, type ClawAddPlan, type ClawDiagnostic } from "../claws/types.js"; +import { readConfigFileSnapshot } from "../config/config.js"; +import { normalizeConfiguredMcpServers } from "../config/mcp-config-normalize.js"; +import { defaultRuntime, writeRuntimeJson, type RuntimeEnv } from "../runtime.js"; +import type { + ClawsBuildOptions, + ClawsCreateOptions, + ClawsDevOptions, + ClawsValidateOptions, +} from "./claws-cli.js"; + +type PreparedDev = { + build: Awaited>; + plan: ClawAddPlan; +}; + +const CLAW_DEV_RESULT_SCHEMA_VERSION = "openclaw.clawDev.v1" as const; + +function formatDiagnostics(diagnostics: ClawDiagnostic[]): string { + return diagnostics + .map((item) => `${item.level.toUpperCase()} ${item.code} ${item.path}: ${item.message}`) + .join("\n"); +} + +function logExperimentalWarning(runtime: RuntimeEnv): void { + runtime.log("Experimental: Claws contracts may change while RFC 0016 is under review."); +} + +function reportProjectError( + error: unknown, + fallbackCode: string, + schemaVersion: + | typeof CLAW_PROJECT_RESULT_SCHEMA_VERSION + | typeof CLAW_BUILD_RESULT_SCHEMA_VERSION + | typeof CLAW_DEV_RESULT_SCHEMA_VERSION, + json: boolean | undefined, + runtime: RuntimeEnv, +): void { + const code = error instanceof ClawProjectError ? error.code : fallbackCode; + const message = error instanceof Error ? error.message : String(error); + if (json) { + writeRuntimeJson(runtime, { + schemaVersion, + stability: CLAW_OUTPUT_STABILITY, + ok: false, + error: { code, message }, + }); + } else { + runtime.error(message); + } + runtime.exit(1); +} + +function logDevPlanSummary(plan: ClawAddPlan, runtime: RuntimeEnv): void { + runtime.log(`Agent: ${plan.agent.finalId}`); + runtime.log(`Workspace: ${plan.agent.workspace}`); + runtime.log(`Actions: ${plan.summary.totalActions}`); + runtime.log(`Capability escalations: ${plan.capabilityChanges.length}`); + runtime.log(`Blocked actions: ${plan.summary.blockedActions}`); +} + +async function prepareDev(projectPath: string, opts: ClawsDevOptions): Promise { + const temporaryDirectory = await mkdtemp(join(tmpdir(), "openclaw-claw-dev-")); + try { + const build = await buildClawProject(projectPath, join(temporaryDirectory, "claw.tgz")); + const extracted = await extractBuiltClawArtifact(build.artifact); + try { + const result = await readClawManifestFile(extracted.packageRoot); + if (!result.ok) { + throw new ClawProjectError( + "artifact_verification_failed", + formatDiagnostics(result.diagnostics), + ); + } + const configSnapshot = await readConfigFileSnapshot({ + observe: false, + skipPluginValidation: true, + }); + if (!configSnapshot.valid) { + throw new ClawProjectError( + "config_unavailable", + "OpenClaw config is invalid; fix it before previewing a Claw project.", + ); + } + const config = configSnapshot.resolved; + const existingMcpServers = normalizeConfiguredMcpServers(config.mcp?.servers); + const existingAgentIds = listAgentIds(config); + const plan = await buildClawAddPlan({ + manifest: result.manifest, + clawMarkdownBody: result.clawMarkdownBody, + packageBootstrap: result.packageBootstrap, + openClawProfile: result.openClawProfile, + source: { + ...result.source, + integrityKind: "artifact", + integrity: build.integrity, + byteLength: build.byteLength, + }, + diagnostics: result.diagnostics, + context: { + ...(opts.agentId ? { agentId: opts.agentId } : {}), + ...(opts.workspace ? { workspace: opts.workspace } : {}), + existingAgentIds, + existingWorkspacePaths: existingAgentIds.map((agentId) => + resolveAgentWorkspaceDir(config, agentId), + ), + existingMcpServers, + sourceReferenceRoot: `claw-artifact:${build.integrity}`, + }, + }); + return { build, plan }; + } finally { + await extracted.dispose(); + } + } finally { + await rm(temporaryDirectory, { recursive: true, force: true }); + } +} + +export async function runClawsCreateCommand( + projectPath: string, + opts: ClawsCreateOptions, + runtime: RuntimeEnv = defaultRuntime, +): Promise { + assertExperimentalClawsEnabled(); + try { + const result = await createClawProject(projectPath, { + ...(opts.name ? { name: opts.name } : {}), + ...(opts.agentId ? { agentId: opts.agentId } : {}), + }); + if (opts.json) { + writeRuntimeJson(runtime, { + schemaVersion: CLAW_PROJECT_RESULT_SCHEMA_VERSION, + stability: CLAW_OUTPUT_STABILITY, + ok: true, + ...result, + }); + return; + } + logExperimentalWarning(runtime); + runtime.log(`Created Claw project: ${result.root}`); + runtime.log(`Package: ${result.packageJson.name}@${result.packageJson.version}`); + } catch (error) { + reportProjectError( + error, + "project_create_failed", + CLAW_PROJECT_RESULT_SCHEMA_VERSION, + opts.json, + runtime, + ); + } +} + +export async function runClawsValidateCommand( + projectPath: string, + opts: ClawsValidateOptions, + runtime: RuntimeEnv = defaultRuntime, +): Promise { + assertExperimentalClawsEnabled(); + const result = await validateClawProject(projectPath); + if (!result.ok) { + if (opts.json) { + writeRuntimeJson(runtime, { + schemaVersion: CLAW_PROJECT_RESULT_SCHEMA_VERSION, + stability: CLAW_OUTPUT_STABILITY, + ok: false, + root: result.root, + diagnostics: result.diagnostics, + }); + } else { + runtime.error(formatDiagnostics(result.diagnostics)); + } + runtime.exit(1); + return; + } + if (opts.json) { + writeRuntimeJson(runtime, { + schemaVersion: CLAW_PROJECT_RESULT_SCHEMA_VERSION, + stability: CLAW_OUTPUT_STABILITY, + ok: true, + root: result.root, + source: result.claw.source, + manifest: result.claw.manifest, + ...(result.claw.openClawProfile ? { openClawProfile: result.claw.openClawProfile } : {}), + excludedPaths: result.excludedPaths, + diagnostics: result.diagnostics, + }); + return; + } + logExperimentalWarning(runtime); + runtime.log(`Valid Claw project: ${result.root}`); + runtime.log(`Package: ${result.packageJson.name}@${result.packageJson.version}`); + for (const path of result.excludedPaths) { + runtime.log(`Excluded: ${path}`); + } +} + +export async function runClawsBuildCommand( + projectPath: string, + opts: ClawsBuildOptions, + runtime: RuntimeEnv = defaultRuntime, +): Promise { + assertExperimentalClawsEnabled(); + try { + const result = await buildClawProject(projectPath, opts.out); + if (opts.json) { + writeRuntimeJson(runtime, { ...result, stability: CLAW_OUTPUT_STABILITY, ok: true }); + return; + } + logExperimentalWarning(runtime); + runtime.log(`Built Claw: ${result.claw.name}@${result.claw.version}`); + runtime.log(`Artifact: ${result.artifact}`); + runtime.log(`Integrity: ${result.integrity}`); + runtime.log(`Excluded project paths: ${result.excludedPaths.length}`); + } catch (error) { + reportProjectError( + error, + "project_build_failed", + CLAW_BUILD_RESULT_SCHEMA_VERSION, + opts.json, + runtime, + ); + } +} + +export async function runClawsDevCommand( + projectPath: string, + opts: ClawsDevOptions, + runtime: RuntimeEnv = defaultRuntime, +): Promise { + assertExperimentalClawsEnabled(); + let prepared: PreparedDev; + try { + prepared = await prepareDev(projectPath, opts); + } catch (error) { + reportProjectError( + error, + "project_dev_failed", + CLAW_DEV_RESULT_SCHEMA_VERSION, + opts.json, + runtime, + ); + return; + } + const { build, plan } = prepared; + if (opts.json) { + writeRuntimeJson(runtime, { + schemaVersion: CLAW_DEV_RESULT_SCHEMA_VERSION, + stability: CLAW_OUTPUT_STABILITY, + offline: true, + mutationAllowed: false, + build: { + integrity: build.integrity, + byteLength: build.byteLength, + files: build.files, + excludedPaths: build.excludedPaths, + claw: build.claw, + }, + plan, + }); + } else { + logExperimentalWarning(runtime); + runtime.log(`Claw dev preview: ${build.claw.name}@${build.claw.version}`); + runtime.log(`Artifact integrity: ${build.integrity}`); + logDevPlanSummary(plan, runtime); + if (plan.blockers.length > 0) { + runtime.error(formatDiagnostics(plan.blockers)); + } + } + if (plan.blockers.length > 0) { + runtime.exit(1); + } +} diff --git a/src/cli/claws-cli.test.ts b/src/cli/claws-cli.test.ts index a568d5694b32..90265f94bca3 100644 --- a/src/cli/claws-cli.test.ts +++ b/src/cli/claws-cli.test.ts @@ -321,14 +321,9 @@ describe("claws cli", () => { registerClawsCli(program); const claws = program.commands.find((command) => command.name() === "claws"); - expect(claws?.commands.map((command) => command.name())).toEqual([ - "inspect", - "add", - "status", - "update", - "remove", - "export", - ]); + expect(claws?.commands.map((command) => command.name())).toEqual( + expect.arrayContaining(["inspect", "add", "status", "update", "remove", "export"]), + ); }); it("accepts an already-applied Gateway config revision", async () => { diff --git a/src/cli/claws-cli.ts b/src/cli/claws-cli.ts index 503ec6d8a2b8..86474f43dba1 100644 --- a/src/cli/claws-cli.ts +++ b/src/cli/claws-cli.ts @@ -7,6 +7,15 @@ export type ClawsInspectOptions = { json?: boolean; }; +export type ClawsCreateOptions = { + name?: string; + agentId?: string; + json?: boolean; +}; +export type ClawsValidateOptions = { json?: boolean }; +export type ClawsBuildOptions = { out: string; json?: boolean }; +export type ClawsDevOptions = { agentId?: string; workspace?: string; json?: boolean }; + export type ClawsAddOptions = { dryRun?: boolean; yes?: boolean; @@ -45,6 +54,51 @@ export function registerClawsCli(program: Command) { } const claws = program.command("claws").description("Manage experimental OpenClaw Claws"); + claws + .command("create") + .description("Create a minimal local Claw project") + .argument("[path]", "New project directory", ".") + .option("--name ", "Set the package name") + .option("--agent-id ", "Set the portable agent id") + .option("--json", "Print JSON", false) + .action(async (path: string, opts: ClawsCreateOptions) => { + const { runClawsCreateCommand } = await import("./claws-cli.project.js"); + await runClawsCreateCommand(path, opts); + }); + + claws + .command("validate") + .description("Validate a local Claw project") + .argument("[path]", "Project directory", ".") + .option("--json", "Print JSON", false) + .action(async (path: string, opts: ClawsValidateOptions) => { + const { runClawsValidateCommand } = await import("./claws-cli.project.js"); + await runClawsValidateCommand(path, opts); + }); + + claws + .command("dev") + .description("Build and preview a local Claw without network or mutation") + .argument("[path]", "Project directory", ".") + .option("--agent-id ", "Preview with an unused local agent id") + .option("--workspace ", "Preview with a new workspace path") + .option("--json", "Print JSON", false) + .action(async (path: string, opts: ClawsDevOptions) => { + const { runClawsDevCommand } = await import("./claws-cli.project.js"); + await runClawsDevCommand(path, opts); + }); + + claws + .command("build") + .description("Build a deterministic Claw package artifact") + .argument("[path]", "Project directory", ".") + .requiredOption("--out ", "New .tgz artifact to create") + .option("--json", "Print JSON", false) + .action(async (path: string, opts: ClawsBuildOptions) => { + const { runClawsBuildCommand } = await import("./claws-cli.project.js"); + await runClawsBuildCommand(path, opts); + }); + claws .command("inspect") .description("Validate a Claw package or local development manifest") diff --git a/src/cli/command-catalog.ts b/src/cli/command-catalog.ts index fec4a23d91fe..80913698b971 100644 --- a/src/cli/command-catalog.ts +++ b/src/cli/command-catalog.ts @@ -173,6 +173,13 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [ exact: true, policy: { configGuard: "skip", loadPlugins: "never", networkProxy: "bypass" }, }, + ...["create", "validate", "build", "dev"].map( + (subcommand): CliCommandCatalogEntry => ({ + commandPath: ["claws", subcommand], + exact: true, + policy: { configGuard: "skip", loadPlugins: "never", networkProxy: "bypass" }, + }), + ), { commandPath: ["migrate"], policy: { configGuard: "skip", loadPlugins: "never", networkProxy: "bypass" }, diff --git a/src/cli/command-startup-policy.test.ts b/src/cli/command-startup-policy.test.ts index 37777ae326d6..bc4f834cfd2b 100644 --- a/src/cli/command-startup-policy.test.ts +++ b/src/cli/command-startup-policy.test.ts @@ -75,6 +75,17 @@ describe("command-startup-policy", () => { } }); + it("skips operator-state startup for local Claw authoring commands only", () => { + for (const subcommand of ["create", "validate", "build", "dev"]) { + const commandPath = ["claws", subcommand]; + expect(resolvePolicy({ commandPath }).skipConfigGuard, commandPath.join(" ")).toBe(true); + } + for (const subcommand of ["add", "update", "remove"]) { + const commandPath = ["claws", subcommand]; + expect(resolvePolicy({ commandPath }).skipConfigGuard, commandPath.join(" ")).toBe(false); + } + }); + it("skips the config guard for exact root update dry-runs", () => { for (const argv of [ ["node", "openclaw", "update", "--dry-run"], diff --git a/src/cli/program/root-command-descriptions.test.ts b/src/cli/program/root-command-descriptions.test.ts index 9464b5b960ec..770c8594bbca 100644 --- a/src/cli/program/root-command-descriptions.test.ts +++ b/src/cli/program/root-command-descriptions.test.ts @@ -312,6 +312,7 @@ describe("root command descriptions", () => { }); it("keeps startup policy catalog paths registered or explicitly reserved", async () => { + vi.stubEnv("OPENCLAW_EXPERIMENTAL_CLAWS", "1"); const program = await registerAllBuiltInCommands(); // Private QA is a lazy source-checkout command. Its root placeholder proves diff --git a/test/fixtures/claws/project-v1/BOOTSTRAP.md b/test/fixtures/claws/project-v1/BOOTSTRAP.md new file mode 100644 index 000000000000..418b3257eff2 --- /dev/null +++ b/test/fixtures/claws/project-v1/BOOTSTRAP.md @@ -0,0 +1 @@ +Ask the user what outcome matters most before beginning work. diff --git a/test/fixtures/claws/project-v1/CLAW.md b/test/fixtures/claws/project-v1/CLAW.md new file mode 100644 index 000000000000..01ba16b012db --- /dev/null +++ b/test/fixtures/claws/project-v1/CLAW.md @@ -0,0 +1,12 @@ +--- +schemaVersion: 1 +agent: + id: golden-claw + name: Golden Claw +workspace: + files: + - source: references/guide.md + path: references/guide.md +--- + +You are the deterministic Claw project fixture. diff --git a/test/fixtures/claws/project-v1/package.json b/test/fixtures/claws/project-v1/package.json new file mode 100644 index 000000000000..d37e8bc41323 --- /dev/null +++ b/test/fixtures/claws/project-v1/package.json @@ -0,0 +1,8 @@ +{ + "name": "golden-claw", + "version": "1.0.0", + "type": "module", + "openclaw": { + "claw": "CLAW.md" + } +} diff --git a/test/fixtures/claws/project-v1/profiles/openclaw.yml b/test/fixtures/claws/project-v1/profiles/openclaw.yml new file mode 100644 index 000000000000..3254f20d34b2 --- /dev/null +++ b/test/fixtures/claws/project-v1/profiles/openclaw.yml @@ -0,0 +1,4 @@ +schemaVersion: 1 +agent: + tools: + profile: minimal diff --git a/test/fixtures/claws/project-v1/references/guide.md b/test/fixtures/claws/project-v1/references/guide.md new file mode 100644 index 000000000000..34b51f3c3e4c --- /dev/null +++ b/test/fixtures/claws/project-v1/references/guide.md @@ -0,0 +1,3 @@ +# Working guide + +Prefer concise, evidence-backed answers.