mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 12:31:32 +00:00
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:
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user