feat(claws): add local project author lifecycle (#117037)

* feat(claws): add local project lifecycle

* fix(claws): harden local project lifecycle

* fix(claws): harden project source selection

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3cdcdb00-ade8-4e61-85a7-8151b35f216a

* fix(claws): preserve project archive paths

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: db3a73db-01ff-428e-a451-b7b710cd5085

* fix(claws): harden author lifecycle

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: db3a73db-01ff-428e-a451-b7b710cd5085

* chore(claws): normalize authoring test mode

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: db3a73db-01ff-428e-a451-b7b710cd5085

* fix(claws): normalize archive path spellings

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: db3a73db-01ff-428e-a451-b7b710cd5085

* fix(claws): exclude repository-local sources

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: db3a73db-01ff-428e-a451-b7b710cd5085

* fix(claws): satisfy CI contracts

Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com

Copilot-Session: db3a73db-01ff-428e-a451-b7b710cd5085

* chore: record CI fix attribution

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: db3a73db-01ff-428e-a451-b7b710cd5085

* fix(claws): reject excluded manifest links

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: db3a73db-01ff-428e-a451-b7b710cd5085

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3cdcdb00-ade8-4e61-85a7-8151b35f216a
Copilot-Session: db3a73db-01ff-428e-a451-b7b710cd5085
This commit is contained in:
Gio Della-Libera
2026-08-09 03:30:21 -07:00
committed by GitHub
parent 8362f74c99
commit 7422222788
22 changed files with 2006 additions and 17 deletions
+33
View File
@@ -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
+17
View File
@@ -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-");
+26 -6
View File
@@ -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<string>;
existingMcpServers?: Record<string, Record<string, unknown>>;
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,
+260
View File
@@ -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<Buffer> {
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<void>;
}> {
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<ClawBuildResult> {
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<string, Buffer | string>();
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 });
}
}
+545
View File
@@ -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<void> {
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<ClawProjectError>);
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<ClawProjectError>);
});
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<ClawProjectError>);
});
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<ClawProjectError>);
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);
});
});
+455
View File
@@ -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<ClawReadResult, { ok: true }>;
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<boolean> {
return lstat(path)
.then((entry) => entry.isFile())
.catch(() => false);
}
async function isConfinedManifestFile(root: string): Promise<boolean> {
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<string> {
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<boolean> {
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<string>): Promise<string[]> {
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<void> => {
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<ClawProjectValidationResult> {
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<string, unknown>)
: undefined;
const openclaw =
record?.openclaw && typeof record.openclaw === "object" && !Array.isArray(record.openclaw)
? (record.openclaw as Record<string, unknown>)
: 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<string, string>();
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,
};
}
+17
View File
@@ -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<ClawReadResult
...(profile.profile ? { openClawProfile: profile.profile } : {}),
source,
snapshot: {
manifest: snapshot.manifest,
...(snapshot.openClawProfile ? { openClawProfile: snapshot.openClawProfile } : {}),
workspaceSources: snapshot.workspaceSources,
...(snapshot.packageBootstrap ? { packageBootstrap: snapshot.packageBootstrap } : {}),
},
+5 -3
View File
@@ -62,9 +62,11 @@ const clawHubPackageName = nonEmptyString.refine(
);
const portableEnvKey = /^[A-Za-z_][A-Za-z0-9_]*$/;
const packageRelativePath = nonEmptyString.refine(isSafeClawRelativePath, {
message: "Path must be package-relative and must not contain traversal segments.",
});
const packageRelativePath = nonEmptyString
.refine(isSafeClawRelativePath, {
message: "Path must be package-relative and must not contain traversal segments.",
})
.transform((value) => value.replaceAll("\\", "/"));
const identitySchema = z
.object({
+11
View File
@@ -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;
};
@@ -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<string[]> {
const snapshot: string[] = [];
const walk = async (directory: string, relativeDirectory: string): Promise<void> => {
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<void> {
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,
);
});
+131
View File
@@ -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<typeof import("../runtime.js")>("../runtime.js")),
defaultRuntime: mocks.runtime,
writeRuntimeJson: (runtime: typeof mocks.runtime, value: unknown) => runtime.writeJson(value),
}));
vi.mock("../config/config.js", async () => ({
...(await vi.importActual<typeof import("../config/config.js")>("../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<Record<string, unknown>>;
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<Record<string, unknown>>;
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<Record<string, unknown>>;
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 };
};
+291
View File
@@ -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<ReturnType<typeof buildClawProject>>;
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<PreparedDev> {
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<void> {
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<void> {
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<void> {
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<void> {
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);
}
}
+3 -8
View File
@@ -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 () => {
+54
View File
@@ -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 <name>", "Set the package name")
.option("--agent-id <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 <id>", "Preview with an unused local agent id")
.option("--workspace <path>", "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 <artifact>", "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")
+7
View File
@@ -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" },
+11
View File
@@ -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"],
@@ -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
+1
View File
@@ -0,0 +1 @@
Ask the user what outcome matters most before beginning work.
+12
View File
@@ -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.
+8
View File
@@ -0,0 +1,8 @@
{
"name": "golden-claw",
"version": "1.0.0",
"type": "module",
"openclaw": {
"claw": "CLAW.md"
}
}
+4
View File
@@ -0,0 +1,4 @@
schemaVersion: 1
agent:
tools:
profile: minimal
+3
View File
@@ -0,0 +1,3 @@
# Working guide
Prefer concise, evidence-backed answers.