Add CLAW.md manifest support (#111391)

* Add CLAW.md manifest support

* test(claws): cover CLAW.md manifests

* docs(claws): document CLAW.md packages

* fix(claws): harden claw markdown parsing

* fix(claws): bound claw document reads
This commit is contained in:
Gio Della-Libera
2026-07-22 18:37:08 -07:00
committed by GitHub
parent dcaed5999d
commit 9636f2aa2d
6 changed files with 387 additions and 49 deletions

View File

@@ -1,7 +1,7 @@
---
summary: "Add, inspect, update, and remove experimental Claw agent packages"
summary: "Create, add, update, and remove experimental Claw agent packages"
read_when:
- You want to validate a grouped Claw manifest
- You are authoring or validating a CLAW.md manifest
- You want to preview or add one agent from a Claw
- You need to inspect Claw ownership, drift, or cleanup behavior
title: "Claws"
@@ -20,29 +20,51 @@ Enable the command surface explicitly:
export OPENCLAW_EXPERIMENTAL_CLAWS=1
```
The current CLI reads a local package directory or grouped JSON manifest.
The current CLI reads a local package directory, `CLAW.md`, or grouped JSON manifest.
Publishing, searching, and installing whole Claws through ClawHub are a
separate registry track and are not part of this command surface yet.
## Create a grouped manifest
## Create a Claw package
Start with a version 1 JSON manifest:
A package contains `package.json`, a `CLAW.md` manifest, and any workspace
sidecars referenced by the manifest:
```json
{
"schemaVersion": 1,
"agent": {
"id": "incident-triage",
"name": "Incident triage",
"tools": { "deny": ["exec"] }
},
"workspace": { "bootstrapFiles": {} },
"packages": [],
"mcpServers": {},
"cronJobs": []
"name": "@acme/incident-triage-claw",
"version": "1.0.0",
"type": "module",
"openclaw": { "claw": "CLAW.md" }
}
```
`CLAW.md` starts with YAML frontmatter. Its Markdown body describes the Claw
for people and is not part of the agent configuration:
```md
---
schemaVersion: 1
agent:
id: incident-triage
name: Incident triage
tools:
deny: [exec]
workspace:
bootstrapFiles: {}
packages: []
mcpServers: {}
cronJobs: []
---
# Incident triage
Creates one agent for reviewing and routing incidents.
```
The same strict version 1 schema continues to accept grouped JSON manifests.
The remaining schema fragments on this page use JSON, with equivalent keys
available in `CLAW.md` frontmatter.
Package and workspace paths must remain inside the package root. Manifests are
limited to 1 MiB, package metadata to 256 KiB, and workspace sources enforce
separate per-file and aggregate limits. Workspace sources also reject symlinked
@@ -169,8 +191,8 @@ defaults collide with local state.
Adding a Claw creates the new agent and workspace configuration, writes declared
workspace files, installs or reuses declared skill and plugin artifacts, and
records provenance. Existing files are not overwritten, and retries fail closed
when owned content drifted. Later Claws stages add other declared resources.
records package, MCP, and cron provenance. Existing files are not overwritten,
and retries fail closed when owned content drifted.
## Inspect installed state
@@ -278,7 +300,7 @@ managed state has drifted:
openclaw claws export incident-triage --out ./incident-triage-export --json
```
The result contains `package.json`, `openclaw.claw.json`, and managed workspace
The result contains `package.json`, canonical `CLAW.md`, and managed workspace
sidecars. It is a portable Claw package, not a whole-instance backup: unrelated
agents, credentials, sessions, and unowned local state are excluded.
@@ -286,7 +308,7 @@ agents, credentials, sessions, and unowned local state are excluded.
| Command | Purpose |
| ----------------------------------- | --------------------------------------------------- |
| `claws inspect <source>` | Validate a package directory or JSON manifest. |
| `claws inspect <source>` | Validate a package directory or grouped manifest. |
| `claws add <source>` | Preview or create one new agent and workspace. |
| `claws status [claw-or-agent]` | Report installed state, ownership, and drift. |
| `claws update <claw-or-agent>` | Preview or apply changes from the selected source. |

View File

@@ -1372,7 +1372,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- Route: /cli/claws
- Headings:
- H1: openclaw claws
- H2: Create a grouped manifest
- H2: Create a Claw package
- H2: Inspect and preview
- H2: Inspect installed state
- H2: Update an installed Claw

View File

@@ -204,10 +204,10 @@ describe("exportClawAgent", () => {
const packageJson = JSON.parse(await readFile(join(out, "package.json"), "utf8"));
expect(packageJson).toMatchObject({
name: "openclaw-claw-worker",
openclaw: { claw: "openclaw.claw.json" },
openclaw: { claw: "CLAW.md" },
});
expect(packageJson.version).toMatch(/^0\.0\.0-export\.[0-9a-f]{64}$/);
await expect(readFile(join(out, "openclaw.claw.json"), "utf8")).resolves.not.toContain(
await expect(readFile(join(out, "CLAW.md"), "utf8")).resolves.not.toContain(
"resolved-secret-must-not-be-exported",
);
await expect(readFile(join(out, "workspace", "SOUL.md"), "utf8")).resolves.toBe(
@@ -321,9 +321,9 @@ describe("exportClawAgent", () => {
});
expect(result.outputDirectory).toBe(join(fixture.root, "exported-home"));
await expect(
readFile(join(result.outputDirectory, "openclaw.claw.json"), "utf8"),
).resolves.toContain('"schemaVersion": 1');
await expect(readFile(join(result.outputDirectory, "CLAW.md"), "utf8")).resolves.toContain(
"schemaVersion: 1",
);
});
it("fails closed when a managed file is unavailable", async () => {

View File

@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
import { closeSync } from "node:fs";
import { mkdir, realpath, rm } from "node:fs/promises";
import { dirname, relative, resolve, sep } from "node:path";
import { stringify as stringifyYaml } from "yaml";
import { listAgentEntries, resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
import { openLocalAgentAvatarFile } from "../agents/identity-avatar-file.js";
import { normalizeConfiguredMcpServers } from "../config/mcp-config-normalize.js";
@@ -28,8 +29,7 @@ export const CLAW_EXPORT_RESULT_SCHEMA_VERSION = "openclaw.clawExportResult.v1"
const MAX_EXPORT_FILE_BYTES = 1024 * 1024;
type AgentConfig = NonNullable<NonNullable<OpenClawConfig["agents"]>["list"]>[number];
type ClawAgent = ClawManifest["agent"];
type ClawBootstrapFileName = keyof ClawManifest["workspace"]["bootstrapFiles"];
type ClawBootstrapFileName = (typeof CLAW_BOOTSTRAP_FILE_NAMES)[number];
type ClawExportResult = {
schemaVersion: typeof CLAW_EXPORT_RESULT_SCHEMA_VERSION;
@@ -50,7 +50,7 @@ export class ClawExportError extends Error {
}
}
function portableAgent(agent: AgentConfig, avatar: string | undefined): ClawAgent {
function portableAgent(agent: AgentConfig, avatar: string | undefined): ClawManifest["agent"] {
const identity = {
...(agent.identity?.name ? { name: agent.identity.name } : {}),
...(agent.identity?.theme ? { theme: agent.identity.theme } : {}),
@@ -135,7 +135,6 @@ function comparePortableText(left: string, right: string): number {
function isClawBootstrapFileName(value: string): value is ClawBootstrapFileName {
return (CLAW_BOOTSTRAP_FILE_NAMES as readonly string[]).includes(value);
}
function readPortableAvatar(params: {
config: OpenClawConfig;
agent: AgentConfig;
@@ -395,18 +394,21 @@ export async function exportClawAgent(
name: `openclaw-claw-${record.install.agentId}`,
version: derivativePackageVersion(manifest, contents),
type: "module",
openclaw: { claw: "openclaw.claw.json" },
openclaw: { claw: "CLAW.md" },
};
await output.write("package.json", Buffer.from(`${JSON.stringify(packageJson, null, 2)}\n`), {
overwrite: false,
});
filesWritten.push("package.json");
await output.write(
"openclaw.claw.json",
Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`),
"CLAW.md",
Buffer.from(
`---\n${stringifyYaml(manifest)}---\n\n# ${manifest.agent.name ?? manifest.agent.id}\n\n` +
"This Claw creates one configured OpenClaw agent and workspace.\n",
),
{ overwrite: false },
);
filesWritten.push("openclaw.claw.json");
filesWritten.push("CLAW.md");
} catch (error) {
await rm(target, { recursive: true, force: true }).catch(() => undefined);
throw new ClawExportError(

View File

@@ -2,6 +2,7 @@
import { createHash } from "node:crypto";
import { realpath, stat } from "node:fs/promises";
import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
import { isScalar, parseDocument, visit } from "yaml";
import { assertNoSymlinkParents } from "../infra/fs-safe-advanced.js";
import { FsSafeError, root as fsSafeRoot, type OpenResult } from "../infra/fs-safe.js";
import { isCanonicalClawHubPackageName, isExactSemVer } from "./schema-portability.js";
@@ -23,8 +24,11 @@ type PackageJson = {
type ResolvedClawSource = Omit<ClawSourceIdentity, "integrity" | "integrityKind" | "byteLength"> & {
packageJsonRaw?: Buffer;
manifestFormatPath: string;
};
const CLAW_MARKDOWN_FILENAME = "CLAW.md";
const MAX_CLAW_MANIFEST_BYTES = 1024 * 1024;
const MAX_CLAW_PACKAGE_JSON_BYTES = 256 * 1024;
@@ -271,6 +275,125 @@ async function readJson(
}
}
function parseClawMarkdown(
raw: string,
path: string,
): { ok: true; value: unknown } | { ok: false; diagnostics: ClawDiagnostic[] } {
const markdown = raw.startsWith("\uFEFF") ? raw.slice(1) : raw;
const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
if (!match) {
return {
ok: false,
diagnostics: [
fileDiagnostic(
"missing_claw_frontmatter",
`${path} must start with a YAML frontmatter block delimited by --- lines.`,
),
],
};
}
const frontmatter = match[1] ?? "";
const document = parseDocument(frontmatter, { prettyErrors: false, uniqueKeys: true });
if (document.errors.length > 0) {
return {
ok: false,
diagnostics: document.errors.map((error) =>
fileDiagnostic("invalid_claw_frontmatter", `Could not parse ${path}: ${error.message}`),
),
};
}
let unsupportedFeature: string | undefined;
visit(document, {
Alias() {
unsupportedFeature ??= "aliases";
},
Node(_key, node) {
if (node.anchor) {
unsupportedFeature ??= "anchors";
} else if (node.tag) {
unsupportedFeature ??= "explicit tags";
}
},
Pair(_key, pair) {
if (isScalar(pair.key) && pair.key.value === "<<") {
unsupportedFeature ??= "merge keys";
}
},
});
if (unsupportedFeature) {
return {
ok: false,
diagnostics: [
fileDiagnostic(
"unsupported_claw_yaml_feature",
`${path} uses ${unsupportedFeature}; CLAW.md frontmatter must map directly to JSON data.`,
),
],
};
}
try {
return { ok: true, value: document.toJSON() };
} catch (error) {
return {
ok: false,
diagnostics: [
fileDiagnostic(
"invalid_claw_frontmatter",
`Could not parse ${path}: ${(error as Error).message}`,
),
],
};
}
}
function parseClawManifestDocument(
raw: string,
path: string,
): { ok: true; value: unknown } | { ok: false; diagnostics: ClawDiagnostic[] } {
if (basename(path).toLowerCase() === CLAW_MARKDOWN_FILENAME.toLowerCase()) {
return parseClawMarkdown(raw, path);
}
try {
return { ok: true, value: JSON.parse(raw) };
} catch (error) {
return {
ok: false,
diagnostics: [
fileDiagnostic("invalid_json", `Could not parse ${path}: ${(error as Error).message}`),
],
};
}
}
async function readClawDocument(
path: string,
code: string,
manifestFormatPath = path,
): Promise<
{ ok: true; raw: Buffer; value: unknown } | { ok: false; diagnostics: ClawDiagnostic[] }
> {
let raw: Buffer;
try {
raw = await readBoundedFile(path, MAX_CLAW_MANIFEST_BYTES);
} catch (error) {
const tooLarge =
error instanceof RangeError || (error instanceof FsSafeError && error.code === "too-large");
return {
ok: false,
diagnostics: [
fileDiagnostic(
tooLarge ? `${code}_too_large` : code,
tooLarge
? `${path} exceeds ${MAX_CLAW_MANIFEST_BYTES} bytes.`
: `Could not read ${path}: ${(error as Error).message}`,
),
],
};
}
const parsed = parseClawManifestDocument(raw.toString("utf8"), manifestFormatPath);
return parsed.ok ? { ...parsed, raw } : parsed;
}
async function resolvePackageSource(
packageRoot: string,
): Promise<
@@ -312,9 +435,8 @@ async function resolvePackageSource(
],
};
}
const manifestPath = await realpath(resolve(packageRootReal, packageJson.openclaw.claw)).catch(
() => undefined,
);
const declaredManifestPath = resolve(packageRootReal, packageJson.openclaw.claw);
const manifestPath = await realpath(declaredManifestPath).catch(() => undefined);
if (!manifestPath || !isContained(packageRootReal, manifestPath)) {
return {
ok: false,
@@ -335,6 +457,7 @@ async function resolvePackageSource(
packageRoot: packageRootReal,
manifestPath,
packageJsonRaw: packageJsonResult.raw,
manifestFormatPath: declaredManifestPath,
},
};
}
@@ -374,6 +497,7 @@ async function resolveSource(
version: "0.0.0-development",
packageRoot,
manifestPath,
manifestFormatPath: inputPath,
},
};
}
@@ -383,10 +507,10 @@ export async function readClawManifestFile(path: string): Promise<ClawReadResult
if (!sourceResult.ok) {
return sourceResult;
}
const manifestResult = await readJson(
const manifestResult = await readClawDocument(
sourceResult.source.manifestPath,
"read_failed",
MAX_CLAW_MANIFEST_BYTES,
sourceResult.source.manifestFormatPath,
);
if (!manifestResult.ok) {
return manifestResult;

View File

@@ -332,6 +332,167 @@ describe("readClawManifestFile", () => {
expect(result.source.byteLength).toBeGreaterThan(0);
});
it("reads a human-authored CLAW.md package through the same manifest schema", async () => {
const root = tempDirs.make("openclaw-claw-markdown-");
await writeFile(
join(root, "package.json"),
JSON.stringify({
name: "@acme/github-triage",
version: "3.2.1",
openclaw: { claw: "CLAW.md" },
}),
"utf8",
);
await writeFile(
join(root, "CLAW.md"),
[
"---",
"schemaVersion: 1",
"agent:",
" id: triage",
"workspace: {}",
"packages: []",
"mcpServers: {}",
"cronJobs: []",
"---",
"",
"# GitHub Triage",
].join("\n"),
"utf8",
);
const result = await readClawManifestFile(root);
expect(result).toMatchObject({
ok: true,
manifest: { schemaVersion: 1, agent: { id: "triage" } },
source: { kind: "package", name: "@acme/github-triage", version: "3.2.1" },
});
if (!result.ok) {
throw new Error("expected package to parse");
}
expect(result.source).not.toHaveProperty("manifestFormatPath");
});
it("accepts a UTF-8 BOM and includes its bytes in snapshot integrity", async () => {
const root = tempDirs.make("openclaw-claw-markdown-bom-");
await writeFile(
join(root, "package.json"),
JSON.stringify({
name: "@acme/github-triage",
version: "3.2.1",
openclaw: { claw: "CLAW.md" },
}),
"utf8",
);
const raw =
"\uFEFF" +
[
"---",
"schemaVersion: 1",
"agent:",
" id: triage",
"workspace: {}",
"packages: []",
"mcpServers: {}",
"cronJobs: []",
"---",
].join("\n");
await writeFile(join(root, "CLAW.md"), raw, "utf8");
const result = await readClawManifestFile(root);
expect(result.ok).toBe(true);
if (!result.ok) {
throw new Error("expected BOM-prefixed CLAW.md to parse");
}
expect(result.manifest.agent.id).toBe("triage");
expect(result.source).toMatchObject({
integrityKind: "development-snapshot",
byteLength: expect.any(Number),
});
await writeFile(join(root, "CLAW.md"), raw.slice(1), "utf8");
const withoutBom = await readClawManifestFile(root);
expect(withoutBom.ok).toBe(true);
if (!withoutBom.ok) {
throw new Error("expected CLAW.md without BOM to parse");
}
expect(withoutBom.source.integrity).not.toBe(result.source.integrity);
});
it("rejects CLAW.md without YAML frontmatter", async () => {
const root = tempDirs.make("openclaw-claw-markdown-invalid-");
const path = join(root, "CLAW.md");
await writeFile(path, "# Missing manifest\n", "utf8");
const result = await readClawManifestFile(path);
expect(result.ok).toBe(false);
expect(result.diagnostics).toContainEqual(
expect.objectContaining({ code: "missing_claw_frontmatter" }),
);
});
it.each([
["anchor", "agent: &agent { id: triage }"],
["alias", "agent: { id: &id triage, name: *id }"],
["merge key", "agent: { <<: { id: triage } }"],
["explicit tag", "agent: { id: !!str triage }"],
])("rejects CLAW.md YAML %s", async (_label, declaration) => {
const root = tempDirs.make("openclaw-claw-markdown-alias-");
const path = join(root, "CLAW.md");
await writeFile(
path,
[
"---",
"schemaVersion: 1",
declaration,
"workspace: {}",
"packages: []",
"mcpServers: {}",
"cronJobs: []",
"---",
].join("\n"),
"utf8",
);
const result = await readClawManifestFile(path);
expect(result.ok).toBe(false);
expect(result.diagnostics).toContainEqual(
expect.objectContaining({ code: "unsupported_claw_yaml_feature" }),
);
});
it("hashes original CLAW.md bytes rather than decoded text", async () => {
const root = tempDirs.make("openclaw-claw-markdown-original-bytes-");
const path = join(root, "CLAW.md");
const frontmatter = Buffer.from(
[
"---",
"schemaVersion: 1",
"agent: { id: triage }",
"workspace: {}",
"packages: []",
"mcpServers: {}",
"cronJobs: []",
"---",
"",
].join("\n"),
);
await writeFile(path, Buffer.concat([frontmatter, Buffer.from([0x80])]));
const first = await readClawManifestFile(path);
await writeFile(path, Buffer.concat([frontmatter, Buffer.from([0x81])]));
const second = await readClawManifestFile(path);
expect(first.ok && second.ok).toBe(true);
if (!first.ok || !second.ok) {
throw new Error("expected CLAW.md bodies to parse");
}
expect(second.source.integrity).not.toBe(first.source.integrity);
});
it("synthesizes explicit development identity for a standalone manifest", async () => {
const root = tempDirs.make("openclaw-claw-development-");
const path = join(root, "demo.claw.json");
@@ -431,6 +592,46 @@ describe("readClawManifestFile", () => {
);
});
it.runIf(process.platform !== "win32")(
"uses the declared CLAW.md path when it is an in-package symlink",
async () => {
const root = tempDirs.make("openclaw-claw-markdown-link-");
await writeFile(
join(root, "package.json"),
JSON.stringify({
name: "@acme/github-triage",
version: "3.2.1",
openclaw: { claw: "CLAW.md" },
}),
"utf8",
);
await writeFile(
join(root, "manifest.json"),
[
"---",
"schemaVersion: 1",
"agent:",
" id: triage",
"workspace: {}",
"packages: []",
"mcpServers: {}",
"cronJobs: []",
"---",
].join("\n"),
"utf8",
);
await symlink("manifest.json", join(root, "CLAW.md"));
const result = await readClawManifestFile(root);
expect(result).toMatchObject({
ok: true,
manifest: { agent: { id: "triage" } },
source: { manifestPath: await realpath(join(root, "manifest.json")) },
});
},
);
it("rejects package manifests that escape the package root", async () => {
const parent = tempDirs.make("openclaw-claw-escape-");
const root = join(parent, "package");
@@ -736,7 +937,6 @@ describe("buildClawAddPlan", () => {
expect(workspaceFileActions.every((action) => action.blocked)).toBe(true);
expect(workspaceFileActions.every((action) => !Object.hasOwn(action, "digest"))).toBe(true);
});
it("binds plan integrity to the source and planned mutations", async () => {
const { source, workspace } = await createPlanSource();
const first = await buildClawAddPlan({
@@ -767,14 +967,4 @@ describe("buildClawAddPlan", () => {
expect(changed.planIntegrity).not.toBe(first.planIntegrity);
expect(changedCapability.planIntegrity).not.toBe(first.planIntegrity);
});
it("rejects current-session cron jobs before planning", () => {
const result = parseClawManifest({
...baseManifest,
cronJobs: [{ ...baseManifest.cronJobs[0], session: "current" }],
});
expect(result.ok).toBe(false);
expect(result.diagnostics[0]?.path).toBe("$.cronJobs[0].session");
});
});