diff --git a/docs/cli/claws.md b/docs/cli/claws.md index 024f6b9cd1a5..712affa060b2 100644 --- a/docs/cli/claws.md +++ b/docs/cli/claws.md @@ -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 ` | Validate a package directory or JSON manifest. | +| `claws inspect ` | Validate a package directory or grouped manifest. | | `claws add ` | Preview or create one new agent and workspace. | | `claws status [claw-or-agent]` | Report installed state, ownership, and drift. | | `claws update ` | Preview or apply changes from the selected source. | diff --git a/docs/docs_map.md b/docs/docs_map.md index 847b28a5f678..5c83f63b2c11 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -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 diff --git a/src/claws/export.test.ts b/src/claws/export.test.ts index ddd83245c8c2..d56a795872be 100644 --- a/src/claws/export.test.ts +++ b/src/claws/export.test.ts @@ -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 () => { diff --git a/src/claws/export.ts b/src/claws/export.ts index 26c3386200f5..eafb72c7eb97 100644 --- a/src/claws/export.ts +++ b/src/claws/export.ts @@ -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["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( diff --git a/src/claws/reader.ts b/src/claws/reader.ts index b5df3401d46e..ce5e4bf646ea 100644 --- a/src/claws/reader.ts +++ b/src/claws/reader.ts @@ -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 & { 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 { 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"); - }); });