mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
@@ -0,0 +1 @@
|
||||
Ask the user what outcome matters most before beginning work.
|
||||
+12
@@ -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
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "golden-claw",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"openclaw": {
|
||||
"claw": "CLAW.md"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
schemaVersion: 1
|
||||
agent:
|
||||
tools:
|
||||
profile: minimal
|
||||
@@ -0,0 +1,3 @@
|
||||
# Working guide
|
||||
|
||||
Prefer concise, evidence-backed answers.
|
||||
Reference in New Issue
Block a user