Add grouped Claw schema and read-only add plan (#101328)

* Add grouped Claw schema and read-only add plan

* test(claws): cover grouped schema and preview

* docs(claws): document experimental preview

* fix(claws): harden preview consent

* fix(claws): satisfy tool filter lint

* fix(claws): bind plans to validated sources
This commit is contained in:
Gio Della-Libera
2026-07-21 14:50:36 -07:00
committed by GitHub
parent b36342ee6f
commit 138d2a5627
25 changed files with 3462 additions and 12 deletions
+4
View File
@@ -43,6 +43,10 @@
"source": "ClawHub",
"target": "ClawHub"
},
{
"source": "Claws",
"target": "Claws"
},
{
"source": "ClawRouter (managed multi-provider routing)",
"target": "ClawRouter(托管式多提供商路由)"
+87
View File
@@ -0,0 +1,87 @@
---
summary: "Validate and preview experimental Claw agent packages"
read_when:
- You want to validate a grouped Claw manifest
- You want to preview adding one agent from a Claw
title: "Claws"
---
# `openclaw claws`
A Claw is a versioned setup for one new OpenClaw agent. It can describe the
agent configuration, workspace files, skills, plugins, MCP servers, and cron
jobs that agent needs. A Claw does not replace or modify an existing agent.
Claws are experimental. Their schema, command output, and lifecycle may change.
Enable the command surface explicitly:
```bash
export OPENCLAW_EXPERIMENTAL_CLAWS=1
```
The current CLI reads a local package directory 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
Start with a version 1 JSON manifest:
```json
{
"schemaVersion": 1,
"agent": {
"id": "incident-triage",
"name": "Incident triage",
"tools": { "deny": ["exec"] }
},
"workspace": { "bootstrapFiles": {} },
"packages": [],
"mcpServers": {},
"cronJobs": []
}
```
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
parents.
## Inspect and preview
Validate the source without planning local changes:
```bash
openclaw claws inspect ./incident-triage.claw.json
```
Preview all proposed lifecycle actions:
```bash
openclaw claws add ./incident-triage.claw.json --dry-run --json
```
The plan reports the derived agent and workspace, every proposed action,
prerequisites, blockers, and distinct capability escalations. Capability records
show the exact package, MCP, scheduled-work, sandbox, tool, or heartbeat effect
and are included in plan integrity. Use `--agent-id` or
`--workspace` to preview alternatives when package defaults collide with local
state.
This initial experimental command is read-only. `claws add` requires
`--dry-run` and does not create the agent or mutate OpenClaw state.
## Command reference
| Command | Purpose |
| ------------------------ | ---------------------------------------------- |
| `claws inspect <source>` | Validate a package directory or JSON manifest. |
| `claws add <source>` | Preview adding one new agent and workspace. |
Use `--json` for experimental machine-readable output.
## See also
- [Agents](/cli/agents)
- [Skills](/tools/skills)
- [Plugins](/tools/plugin)
+1
View File
@@ -1801,6 +1801,7 @@
"pages": [
"cli/agent",
"cli/agents",
"cli/claws",
"cli/audit",
"cli/hooks",
"cli/infer",
+10
View File
@@ -1364,6 +1364,16 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: Migration
- H2: Related
## cli/claws.md
- Route: /cli/claws
- Headings:
- H1: openclaw claws
- H2: Create a grouped manifest
- H2: Inspect and preview
- H2: Command reference
- H2: See also
## cli/commitments.md
- Route: /cli/commitments
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { assertExperimentalClawsEnabled, isExperimentalClawsEnabled } from "./experimental.js";
describe("experimental Claws gate", () => {
it("is disabled unless explicitly enabled", () => {
expect(isExperimentalClawsEnabled({})).toBe(false);
expect(isExperimentalClawsEnabled({ OPENCLAW_EXPERIMENTAL_CLAWS: "0" })).toBe(false);
expect(isExperimentalClawsEnabled({ OPENCLAW_EXPERIMENTAL_CLAWS: "false" })).toBe(false);
});
it("accepts explicit process opt-ins", () => {
expect(isExperimentalClawsEnabled({ OPENCLAW_EXPERIMENTAL_CLAWS: "1" })).toBe(true);
expect(isExperimentalClawsEnabled({ OPENCLAW_EXPERIMENTAL_CLAWS: "TRUE" })).toBe(true);
});
it("rejects direct handler access when disabled", () => {
expect(() => assertExperimentalClawsEnabled({})).toThrow("OPENCLAW_EXPERIMENTAL_CLAWS=1");
});
});
+15
View File
@@ -0,0 +1,15 @@
const EXPERIMENTAL_CLAWS_ENV = "OPENCLAW_EXPERIMENTAL_CLAWS";
export function isExperimentalClawsEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
const value = env[EXPERIMENTAL_CLAWS_ENV]?.trim().toLowerCase();
return value === "1" || value === "true";
}
export function assertExperimentalClawsEnabled(env: NodeJS.ProcessEnv = process.env): void {
if (isExperimentalClawsEnabled(env)) {
return;
}
throw new Error(
`Claws are experimental and disabled. Set ${EXPERIMENTAL_CLAWS_ENV}=1 for this process to enable the unstable CLI.`,
);
}
@@ -0,0 +1,76 @@
{
"schemaVersion": 1,
"agent": {
"id": "incident-response",
"name": "Incident Response",
"description": "Triages incidents and prepares response updates.",
"identity": {
"name": "Incident Response",
"emoji": "siren"
},
"groupChat": {
"mentionPatterns": ["@incident-response"]
},
"sandbox": {
"mode": "all",
"scope": "agent",
"workspaceAccess": "rw"
},
"tools": {
"allow": ["read", "write", "web_fetch"],
"deny": ["exec", "browser"]
},
"heartbeat": {
"every": "30m",
"lightContext": true,
"isolatedSession": true,
"skipWhenBusy": true,
"timeoutSeconds": 120
}
},
"workspace": {
"bootstrapFiles": {
"SOUL.md": { "source": "workspace/SOUL.md" },
"HEARTBEAT.md": { "source": "workspace/HEARTBEAT.md" }
}
},
"packages": [
{
"kind": "skill",
"source": "clawhub",
"ref": "incident-triage",
"version": "1.0.0"
},
{
"kind": "plugin",
"source": "clawhub",
"ref": "@openclaw/plugin-pager-duty",
"version": "2.4.0"
}
],
"mcpServers": {
"statuspage": {
"command": "npx",
"args": ["--yes", "@acme/statuspage-mcp@1.0.0"],
"env": {
"STATUSPAGE_TOKEN": "${STATUSPAGE_TOKEN}"
}
}
},
"cronJobs": [
{
"id": "heartbeat-summary",
"name": "Incident heartbeat summary",
"schedule": {
"cron": "0 * * * *",
"timezone": "UTC"
},
"session": "isolated",
"message": "Review active incidents and prepare a concise status summary.",
"delivery": {
"mode": "announce",
"channel": "last"
}
}
]
}
@@ -0,0 +1,3 @@
# Incident Heartbeat
Check active incidents, unresolved owners, and overdue status updates.
+3
View File
@@ -0,0 +1,3 @@
# Incident Response
Prioritize accuracy, urgency, and clear ownership during incident response.
+115
View File
@@ -0,0 +1,115 @@
// E2E coverage for experimental grouped Claw inspection and add planning.
import { execFile } from "node:child_process";
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { describe, expect, it } from "vitest";
const execFileAsync = promisify(execFile);
async function runOpenClaw(args: string[], options?: { expectFailure?: boolean }) {
const stateDir = await mkdtemp(join(tmpdir(), "openclaw-claws-lifecycle-e2e-"));
const env = {
...process.env,
HOME: stateDir,
USERPROFILE: stateDir,
OPENCLAW_CONFIG_PATH: join(stateDir, "openclaw.json"),
OPENCLAW_EXPERIMENTAL_CLAWS: "1",
OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
OPENCLAW_HOME: stateDir,
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_TEST_FAST: "1",
OPENCLAW_TEST_RUNTIME_LOG: "1",
VITEST: "",
};
try {
const result = await execFileAsync(
process.execPath,
["--import", "tsx", "src/entry.ts", ...args],
{ cwd: process.cwd(), env, maxBuffer: 1024 * 1024 },
);
if (options?.expectFailure) {
throw new Error(`expected command to fail: ${args.join(" ")}`);
}
return { ok: true as const, stdout: result.stdout, stderr: result.stderr };
} catch (error) {
if (!options?.expectFailure) {
throw error;
}
const failed = error as Error & { stdout?: string; stderr?: string; code?: number };
return {
ok: false as const,
code: failed.code,
stdout: failed.stdout ?? "",
stderr: failed.stderr ?? "",
};
}
}
function parseJson(stdout: string): unknown {
const trimmed = stdout.trim();
expect(trimmed.length).toBeGreaterThan(0);
return JSON.parse(trimmed);
}
describe("claws lifecycle cli e2e", () => {
const manifestPath = "src/claws/fixtures/incident-response.claw.json";
it("inspects a grouped development manifest", async () => {
const inspect = parseJson(
(await runOpenClaw(["claws", "inspect", manifestPath, "--json"])).stdout,
);
expect(inspect).toMatchObject({
schemaVersion: "openclaw.clawInspect.v1",
stability: "experimental",
valid: true,
source: { kind: "development", version: "0.0.0-development" },
manifest: {
schemaVersion: 1,
agent: { id: "incident-response" },
packages: expect.any(Array),
},
});
});
it("builds a complete read-only plan with deferred package blockers", async () => {
const result = await runOpenClaw(["claws", "add", manifestPath, "--dry-run", "--json"], {
expectFailure: true,
});
const add = parseJson(result.stdout);
expect(add).toMatchObject({
schemaVersion: "openclaw.clawAddPlan.v1",
stability: "experimental",
dryRun: true,
mutationAllowed: false,
agent: { requestedId: "incident-response", finalId: "incident-response" },
summary: {
totalActions: 8,
agentActions: 1,
workspaceActions: 3,
packageActions: 2,
mcpServerActions: 1,
cronJobActions: 1,
blockedActions: 2,
},
blockers: [
{ code: "package_install_unavailable", phase: "plan" },
{ code: "package_install_unavailable", phase: "plan" },
],
});
expect(result.code).toBe(1);
});
it("fails closed when add is invoked without dry-run", async () => {
const result = await runOpenClaw(["claws", "add", manifestPath], {
expectFailure: true,
});
expect(result.ok).toBe(false);
expect(result.code).toBe(1);
expect(result.stderr).toContain("Claw add is dry-run only");
});
});
+474
View File
@@ -0,0 +1,474 @@
// Builds complete read-only Claw add plans without mutating local state.
import { createHash } from "node:crypto";
import { lstat, realpath } from "node:fs/promises";
import { homedir } from "node:os";
import { resolve } from "node:path";
import { stableStringify } from "../agents/stable-stringify.js";
import { resolveUserPath } from "../utils.js";
import { MAX_MANAGED_WORKSPACE_BYTES } from "./source-limits.js";
import {
CLAW_ADD_PLAN_SCHEMA_VERSION,
CLAW_BOOTSTRAP_FILE_NAMES,
CLAW_OUTPUT_STABILITY,
type ClawAddPlan,
type ClawAddPlanAction,
type ClawAddCapabilityChange,
type ClawDiagnostic,
type ClawManifest,
type ClawLocalPrerequisite,
type ClawSourceSnapshot,
type ClawWorkspaceSourceSnapshot,
type ClawSourceIdentity,
} from "./types.js";
const AGENT_ID_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/;
function capabilityChange(
change: Omit<ClawAddCapabilityChange, "classification" | "requiresDistinctConsent" | "digest">,
): ClawAddCapabilityChange {
return {
...change,
classification: "escalation",
requiresDistinctConsent: true,
digest: `sha256:${createHash("sha256").update(stableStringify(change.effect)).digest("hex")}`,
};
}
type ClawAddPlanContext = {
agentId?: string;
workspace?: string;
existingAgentIds?: Iterable<string>;
existingWorkspacePaths?: Iterable<string>;
existingMcpServerNames?: Iterable<string>;
existingCronJobIds?: Iterable<string>;
};
function blocker(code: string, path: string, message: string): ClawDiagnostic {
return { level: "error", code, phase: "plan", path, message };
}
function isNotFoundError(error: unknown): boolean {
const code = (error as NodeJS.ErrnoException | undefined)?.code;
return code === "ENOENT" || code === "ENOTDIR";
}
function inspectWorkspaceFileAction(params: {
source: ClawSourceIdentity;
workspace: string;
sourcePath: string;
targetPath: string;
id: string;
manifestPath: string;
snapshot?: ClawWorkspaceSourceSnapshot;
}): {
action: ClawAddPlanAction;
blocker?: ClawDiagnostic;
} {
const requestedSource = resolve(params.source.packageRoot, params.sourcePath);
const requestedTarget = resolve(params.workspace, params.targetPath);
if (params.snapshot) {
return {
action: {
kind: "workspaceFile",
id: params.id,
action: "write",
target: requestedTarget,
source: params.snapshot.realPath,
digest: params.snapshot.digest,
details: { expectedState: "absent" },
blocked: false,
},
};
}
const diagnostic = blocker(
"workspace_source_invalid",
params.manifestPath,
`Workspace source ${JSON.stringify(params.sourcePath)} was not captured in the validated Claw snapshot.`,
);
return {
action: {
kind: "workspaceFile",
id: params.id,
action: "write",
target: requestedTarget,
source: requestedSource,
blocked: true,
reason: diagnostic.message,
},
blocker: diagnostic,
};
}
export async function buildClawAddPlan(params: {
manifest: ClawManifest;
source: ClawSourceIdentity;
snapshot: ClawSourceSnapshot;
diagnostics?: ClawDiagnostic[];
context?: ClawAddPlanContext;
}): Promise<ClawAddPlan> {
const context = params.context ?? {};
const finalId = context.agentId ?? params.manifest.agent.id;
const workspace = resolve(
resolveUserPath(context.workspace ?? resolve(homedir(), ".openclaw", `workspace-${finalId}`)),
);
const packageRoot = await realpath(params.source.packageRoot).catch(
() => params.source.packageRoot,
);
const source = { ...params.source, packageRoot };
const workspaceSnapshots = new Map(
params.snapshot.workspaceSources.map((snapshot) => [snapshot.sourcePath, snapshot]),
);
const blockers: ClawDiagnostic[] = [];
const actions: ClawAddPlanAction[] = [];
const workspaceFileActions: ClawAddPlanAction[] = [];
const capabilityChanges: ClawAddCapabilityChange[] = [];
const readinessRequirements: ClawLocalPrerequisite[] = [];
if (!AGENT_ID_PATTERN.test(finalId)) {
blockers.push(
blocker(
"invalid_agent_id",
"$.agent.id",
`Final agent id ${JSON.stringify(finalId)} is not a valid portable agent id.`,
),
);
}
const existingAgentIds = new Set(context.existingAgentIds ?? []);
const agentBlocked = existingAgentIds.has(finalId);
if (agentBlocked) {
blockers.push(
blocker(
"agent_id_collision",
"$.agent.id",
`Agent id ${JSON.stringify(finalId)} already exists; Claws never merge into existing agents.`,
),
);
}
actions.push({
kind: "agent",
id: finalId,
action: "create",
target: `agents.list[${JSON.stringify(finalId)}]`,
details: { ...params.manifest.agent, id: finalId, workspace, expectedState: "absent" },
blocked: agentBlocked || !AGENT_ID_PATTERN.test(finalId),
});
const agentCapabilityEffect = {
...(params.manifest.agent.sandbox ? { sandbox: params.manifest.agent.sandbox } : {}),
...(params.manifest.agent.tools ? { tools: params.manifest.agent.tools } : {}),
...(params.manifest.agent.heartbeat ? { heartbeat: params.manifest.agent.heartbeat } : {}),
};
if (Object.keys(agentCapabilityEffect).length > 0) {
capabilityChanges.push(
capabilityChange({
kind: "agent",
id: finalId,
path: "agent",
action: "create",
reason: "The new agent declares sandbox, tool, or recurring heartbeat capabilities.",
effect: agentCapabilityEffect,
}),
);
}
const configuredWorkspacePaths = new Set(
[...(context.existingWorkspacePaths ?? [])].map((path) => resolve(resolveUserPath(path))),
);
let workspaceExists = configuredWorkspacePaths.has(workspace);
let workspaceProbeFailed = false;
if (!workspaceExists) {
try {
await lstat(workspace);
workspaceExists = true;
} catch (error) {
if (!isNotFoundError(error)) {
workspaceProbeFailed = true;
blockers.push(
blocker(
"workspace_probe_failed",
"$.workspace",
`Could not prove that workspace ${JSON.stringify(workspace)} is absent.`,
),
);
}
}
}
if (workspaceExists) {
blockers.push(
blocker(
"workspace_collision",
"$.workspace",
`Workspace ${JSON.stringify(workspace)} already exists; a Claw requires a new workspace.`,
),
);
}
actions.push({
kind: "workspace",
id: finalId,
action: "create",
target: workspace,
details: { expectedState: workspaceProbeFailed ? "unknown" : "absent" },
blocked: workspaceExists || workspaceProbeFailed,
...(workspaceExists
? { reason: `Workspace ${JSON.stringify(workspace)} already exists.` }
: workspaceProbeFailed
? { reason: `Could not prove that workspace ${JSON.stringify(workspace)} is absent.` }
: {}),
});
function addWorkspaceFileInspection(fileParams: {
sourcePath: string;
targetPath: string;
id: string;
manifestPath: string;
}): void {
const normalizedSourcePath = fileParams.sourcePath.replaceAll("\\", "/");
const result = inspectWorkspaceFileAction({
source,
workspace,
sourcePath: fileParams.sourcePath,
targetPath: fileParams.targetPath,
id: fileParams.id,
manifestPath: fileParams.manifestPath,
snapshot: workspaceSnapshots.get(normalizedSourcePath),
});
const action = result.action;
action.blocked ||= workspaceExists || workspaceProbeFailed;
if (workspaceExists) {
action.reason = `Workspace ${JSON.stringify(workspace)} already exists.`;
} else if (workspaceProbeFailed) {
action.reason = `Could not prove that workspace ${JSON.stringify(workspace)} is absent.`;
}
actions.push(action);
workspaceFileActions.push(action);
if (result.blocker) {
blockers.push(result.blocker);
}
}
for (const name of CLAW_BOOTSTRAP_FILE_NAMES) {
const declaration = params.manifest.workspace.bootstrapFiles[name];
if (!declaration) {
continue;
}
addWorkspaceFileInspection({
sourcePath: declaration.source,
targetPath: name,
id: name,
manifestPath: `$.workspace.bootstrapFiles.${name}`,
});
}
for (const [index, file] of params.manifest.workspace.files.entries()) {
addWorkspaceFileInspection({
sourcePath: file.source,
targetPath: file.path,
id: file.path,
manifestPath: `$.workspace.files[${index}]`,
});
}
const workspaceByteLength = params.snapshot.workspaceSources.reduce(
(total, snapshot) => total + snapshot.byteLength,
0,
);
if (workspaceByteLength > MAX_MANAGED_WORKSPACE_BYTES) {
const diagnostic = blocker(
"workspace_sources_too_large",
"$.workspace",
`Workspace sources exceed ${MAX_MANAGED_WORKSPACE_BYTES} aggregate bytes.`,
);
blockers.push(diagnostic);
for (const action of workspaceFileActions) {
action.blocked = true;
action.reason = diagnostic.message;
}
}
for (const pkg of params.manifest.packages) {
const diagnostic = blocker(
"package_install_unavailable",
"$.packages",
`Package ${JSON.stringify(`${pkg.kind}:${pkg.ref}@${pkg.version}`)} cannot be preflighted until the package-owner lifecycle slice is available.`,
);
blockers.push(diagnostic);
actions.push({
kind: "package",
id: `${pkg.kind}:${pkg.ref}`,
action: "install",
target: `${pkg.source}:${pkg.ref}@${pkg.version}`,
details: { ...pkg, expectedState: "unresolved" },
blocked: true,
reason: diagnostic.message,
});
capabilityChanges.push(
capabilityChange({
kind: "package",
id: `${pkg.kind}:${pkg.ref}`,
path: `packages.${pkg.kind}.${pkg.ref}`,
action: "install",
reason: "The Claw declares downloadable package content or executable code.",
effect: {
kind: pkg.kind,
source: pkg.source,
ref: pkg.ref,
version: pkg.version,
integrity: "unresolved",
},
}),
);
}
const existingMcpServerNames = new Set(context.existingMcpServerNames ?? []);
for (const [name, server] of Object.entries(params.manifest.mcpServers)) {
const blocked = existingMcpServerNames.has(name);
if (blocked) {
blockers.push(
blocker(
"mcp_server_collision",
`$.mcpServers.${name}`,
`MCP server ${JSON.stringify(name)} already exists and will not be overwritten.`,
),
);
}
if ("env" in server) {
for (const value of Object.values(server.env ?? {})) {
readinessRequirements.push({
kind: "environment",
mcpServer: name,
name: value.slice(2, -1),
});
}
}
if ("auth" in server && server.auth === "oauth") {
readinessRequirements.push({ kind: "oauth", mcpServer: name });
}
actions.push({
kind: "mcpServer",
id: name,
action: "configure",
target: `mcp.servers.${name}`,
details: {
...server,
expectedState: "absent",
prerequisites: readinessRequirements.filter(
(requirement) => requirement.mcpServer === name,
),
},
blocked,
});
capabilityChanges.push(
capabilityChange({
kind: "mcpServer",
id: name,
path: `mcpServers.${name}`,
action: "configure",
reason: "The Claw declares an MCP execution or network tool surface.",
effect: {
...server,
...("env" in server && server.env
? {
env: Object.entries(server.env)
.map(([envName, value]) => ({
name: envName,
reference: value.slice(2, -1),
}))
.toSorted((left, right) => left.name.localeCompare(right.name)),
}
: {}),
},
}),
);
}
const existingCronJobIds = new Set(context.existingCronJobIds ?? []);
for (const job of params.manifest.cronJobs) {
const blocked = existingCronJobIds.has(job.id);
if (blocked) {
blockers.push(
blocker(
"cron_job_collision",
`$.cronJobs.${job.id}`,
`Cron job ${JSON.stringify(job.id)} already exists and will not be overwritten.`,
),
);
}
actions.push({
kind: "cronJob",
id: job.id,
action: "schedule",
target: `cron:${job.id}:agent=${finalId}`,
details: {
...job,
agentId: finalId,
expectedState: "absent",
...(job.delivery?.channel === "last"
? { deliveryResolution: "local-channel-state:last" }
: {}),
},
blocked,
});
capabilityChanges.push(
capabilityChange({
kind: "cronJob",
id: job.id,
path: `cronJobs.${job.id}`,
action: "schedule",
reason: "The Claw declares recurring scheduled work.",
effect: { ...job, agentId: finalId },
}),
);
}
capabilityChanges.sort((left, right) =>
`${left.kind}:${left.id}:${left.path}`.localeCompare(`${right.kind}:${right.id}:${right.path}`),
);
const planIntegrity = `sha256:${createHash("sha256")
.update(
stableStringify({
manifestSchemaVersion: params.manifest.schemaVersion,
clawIntegrity: source.integrity,
finalId,
workspace,
actions,
capabilityChanges,
blockers,
}),
)
.digest("hex")}`;
return {
schemaVersion: CLAW_ADD_PLAN_SCHEMA_VERSION,
manifestSchemaVersion: params.manifest.schemaVersion,
stability: CLAW_OUTPUT_STABILITY,
dryRun: true,
mutationAllowed: false,
planIntegrity,
claw: source,
agent: {
requestedId: params.manifest.agent.id,
finalId,
workspace,
config: { ...params.manifest.agent, id: finalId, workspace },
},
summary: {
totalActions: actions.length,
agentActions: actions.filter((action) => action.kind === "agent").length,
workspaceActions: actions.filter(
(action) => action.kind === "workspace" || action.kind === "workspaceFile",
).length,
packageActions: actions.filter((action) => action.kind === "package").length,
mcpServerActions: actions.filter((action) => action.kind === "mcpServer").length,
cronJobActions: actions.filter((action) => action.kind === "cronJob").length,
blockedActions: actions.filter((action) => action.blocked).length,
capabilityEscalations: capabilityChanges.length,
},
actions,
capabilityChanges,
readiness: {
ready: readinessRequirements.length === 0,
requirements: readinessRequirements,
},
blockers,
diagnostics: params.diagnostics ?? [],
};
}
+424
View File
@@ -0,0 +1,424 @@
// Local package and development-manifest reader for Claws.
import { createHash } from "node:crypto";
import { realpath, stat } from "node:fs/promises";
import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
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";
import { parseClawManifest } from "./schema.js";
import { MAX_MANAGED_FILE_BYTES, MAX_MANAGED_WORKSPACE_BYTES } from "./source-limits.js";
import type {
ClawDiagnostic,
ClawManifest,
ClawReadResult,
ClawSourceIdentity,
ClawWorkspaceSourceSnapshot,
} from "./types.js";
type PackageJson = {
name: string;
version: string;
openclaw: { claw: string };
};
type ResolvedClawSource = Omit<ClawSourceIdentity, "integrity" | "integrityKind" | "byteLength"> & {
packageJsonRaw?: Buffer;
};
const MAX_CLAW_MANIFEST_BYTES = 1024 * 1024;
const MAX_CLAW_PACKAGE_JSON_BYTES = 256 * 1024;
async function readBoundedFile(path: string, maxBytes: number): Promise<Buffer> {
const fileRoot = await fsSafeRoot(dirname(path));
const read = await fileRoot.read(basename(path), {
hardlinks: "allow",
maxBytes,
nonBlockingRead: true,
symlinks: "reject",
});
return read.buffer;
}
function fileDiagnostic(code: string, message: string, path = "$"): ClawDiagnostic {
return { level: "error", code, phase: "parse", path, message };
}
function isContained(root: string, candidate: string): boolean {
const child = relative(root, candidate);
return child !== ".." && !child.startsWith(`..${sep}`) && !isAbsolute(child);
}
function updateSnapshotHash(
hash: ReturnType<typeof createHash>,
label: string,
bytes: Buffer,
): void {
hash.update(`${Buffer.byteLength(label, "utf8")}:${label}:${bytes.byteLength}:`, "utf8");
hash.update(bytes);
}
function workspaceSourceDiagnostic(error: unknown, sourcePath: string): ClawDiagnostic {
if (error instanceof FsSafeError && error.code === "too-large") {
return fileDiagnostic(
"workspace_source_too_large",
`Workspace source ${JSON.stringify(sourcePath)} exceeds ${MAX_MANAGED_FILE_BYTES} bytes.`,
"$.workspace",
);
}
if (
(error instanceof FsSafeError &&
(error.code === "symlink" || error.code === "hardlink" || error.code === "path-mismatch")) ||
(error instanceof Error && error.message.includes("symlinked directory"))
) {
return fileDiagnostic(
"workspace_source_unsafe",
`Workspace source ${JSON.stringify(sourcePath)} must be a regular, non-symlinked, non-hardlinked file.`,
"$.workspace",
);
}
return fileDiagnostic(
"workspace_source_invalid",
`Workspace source ${JSON.stringify(sourcePath)} must resolve inside the Claw source.`,
"$.workspace",
);
}
async function buildDevelopmentSnapshot(params: {
source: ResolvedClawSource;
manifest: ClawManifest;
manifestRaw: Buffer;
}): Promise<
| {
ok: true;
integrity: string;
byteLength: number;
workspaceSources: ClawWorkspaceSourceSnapshot[];
}
| { ok: false; diagnostics: ClawDiagnostic[] }
> {
const hash = createHash("sha256");
let byteLength = 0;
const add = (label: string, bytes: Buffer) => {
updateSnapshotHash(hash, label, bytes);
byteLength += bytes.byteLength;
};
add("canonical-source", Buffer.from(params.source.manifestPath, "utf8"));
add("manifest", params.manifestRaw);
if (params.source.kind === "package") {
const packageJson = params.source.packageJsonRaw;
if (!packageJson) {
return {
ok: false,
diagnostics: [fileDiagnostic("package_read_failed", "Could not snapshot package.json.")],
};
}
add("package.json", packageJson);
}
const declaredSources = [
...Object.values(params.manifest.workspace.bootstrapFiles)
.filter((entry): entry is { source: string } => entry !== undefined)
.map((entry) => entry.source),
...params.manifest.workspace.files.map((entry) => entry.source),
].toSorted((left, right) => Buffer.compare(Buffer.from(left), Buffer.from(right)));
const sourceRoot = await fsSafeRoot(params.source.packageRoot);
const openedSources: Array<{ sourcePath: string; opened: OpenResult }> = [];
const workspaceSources: ClawWorkspaceSourceSnapshot[] = [];
try {
let workspaceByteLength = 0;
for (const sourcePath of declaredSources) {
try {
await assertNoSymlinkParents({
rootDir: params.source.packageRoot,
targetPath: resolve(params.source.packageRoot, sourcePath),
allowMissing: false,
messagePrefix: "Workspace source",
});
const opened = await sourceRoot.open(sourcePath, {
hardlinks: "reject",
symlinks: "reject",
});
if (opened.stat.size > MAX_MANAGED_FILE_BYTES) {
await opened[Symbol.asyncDispose]();
throw new FsSafeError(
"too-large",
`file exceeds limit of ${MAX_MANAGED_FILE_BYTES} bytes (got ${opened.stat.size})`,
);
}
workspaceByteLength += opened.stat.size;
openedSources.push({ sourcePath, opened });
} catch (error) {
return { ok: false, diagnostics: [workspaceSourceDiagnostic(error, sourcePath)] };
}
}
if (workspaceByteLength > MAX_MANAGED_WORKSPACE_BYTES) {
return {
ok: false,
diagnostics: [
fileDiagnostic(
"workspace_sources_too_large",
`Workspace sources exceed ${MAX_MANAGED_WORKSPACE_BYTES} aggregate bytes.`,
"$.workspace",
),
],
};
}
let readWorkspaceByteLength = 0;
for (const { sourcePath, opened } of openedSources) {
const bytes = await opened.handle.readFile();
if (bytes.byteLength > MAX_MANAGED_FILE_BYTES) {
return {
ok: false,
diagnostics: [
workspaceSourceDiagnostic(
new FsSafeError("too-large", "workspace source grew while reading"),
sourcePath,
),
],
};
}
readWorkspaceByteLength += bytes.byteLength;
if (readWorkspaceByteLength > MAX_MANAGED_WORKSPACE_BYTES) {
return {
ok: false,
diagnostics: [
fileDiagnostic(
"workspace_sources_too_large",
`Workspace sources exceed ${MAX_MANAGED_WORKSPACE_BYTES} aggregate bytes.`,
"$.workspace",
),
],
};
}
const normalizedSourcePath = sourcePath.replaceAll("\\", "/");
const digest = `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
add(`workspace:${sourcePath.replaceAll("\\", "/")}`, bytes);
workspaceSources.push({
sourcePath: normalizedSourcePath,
realPath: opened.realPath,
byteLength: bytes.byteLength,
digest,
});
}
} finally {
await Promise.all(openedSources.map(({ opened }) => opened[Symbol.asyncDispose]()));
}
return { ok: true, integrity: `sha256:${hash.digest("hex")}`, byteLength, workspaceSources };
}
function parsePackageJson(value: unknown): PackageJson | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
const record = value as Record<string, unknown>;
const openclaw = record.openclaw;
if (!openclaw || typeof openclaw !== "object" || Array.isArray(openclaw)) {
return undefined;
}
const claw = (openclaw as Record<string, unknown>).claw;
if (
typeof record.name !== "string" ||
!isCanonicalClawHubPackageName(record.name) ||
typeof record.version !== "string" ||
!isExactSemVer(record.version) ||
typeof claw !== "string" ||
claw.trim() === ""
) {
return undefined;
}
return { name: record.name, version: record.version, openclaw: { claw } };
}
async function readJson(
path: string,
code: string,
maxBytes: number,
): Promise<
{ ok: true; raw: Buffer; value: unknown } | { ok: false; diagnostics: ClawDiagnostic[] }
> {
let raw: Buffer;
try {
raw = await readBoundedFile(path, maxBytes);
} 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 ${maxBytes} bytes.`
: `Could not read ${path}: ${(error as Error).message}`,
),
],
};
}
try {
return { ok: true, raw, value: JSON.parse(raw.toString("utf8")) };
} catch (error) {
return {
ok: false,
diagnostics: [
fileDiagnostic("invalid_json", `Could not parse ${path}: ${(error as Error).message}`),
],
};
}
}
async function resolvePackageSource(
packageRoot: string,
): Promise<
{ ok: true; source: ResolvedClawSource } | { ok: false; diagnostics: ClawDiagnostic[] }
> {
const packageRootReal = await realpath(packageRoot).catch(() => undefined);
if (!packageRootReal) {
return {
ok: false,
diagnostics: [fileDiagnostic("package_read_failed", `Could not resolve ${packageRoot}.`)],
};
}
const packageJsonPath = resolve(packageRootReal, "package.json");
const packageJsonResult = await readJson(
packageJsonPath,
"package_read_failed",
MAX_CLAW_PACKAGE_JSON_BYTES,
);
if (!packageJsonResult.ok) {
return packageJsonResult;
}
const packageJson = parsePackageJson(packageJsonResult.value);
if (!packageJson) {
return {
ok: false,
diagnostics: [
fileDiagnostic(
"invalid_package_metadata",
"package.json must declare non-empty name, version, and openclaw.claw fields.",
),
],
};
}
if (isAbsolute(packageJson.openclaw.claw)) {
return {
ok: false,
diagnostics: [
fileDiagnostic("manifest_escapes_package", "openclaw.claw must be package-relative."),
],
};
}
const manifestPath = await realpath(resolve(packageRootReal, packageJson.openclaw.claw)).catch(
() => undefined,
);
if (!manifestPath || !isContained(packageRootReal, manifestPath)) {
return {
ok: false,
diagnostics: [
fileDiagnostic(
"manifest_escapes_package",
"The declared Claw manifest must resolve inside its package root.",
),
],
};
}
return {
ok: true,
source: {
kind: "package",
name: packageJson.name,
version: packageJson.version,
packageRoot: packageRootReal,
manifestPath,
packageJsonRaw: packageJsonResult.raw,
},
};
}
async function resolveSource(
path: string,
): Promise<
{ ok: true; source: ResolvedClawSource } | { ok: false; diagnostics: ClawDiagnostic[] }
> {
const inputPath = resolve(path);
const inputStat = await stat(inputPath).catch(() => undefined);
if (!inputStat) {
return {
ok: false,
diagnostics: [fileDiagnostic("read_failed", `Could not resolve Claw source ${inputPath}.`)],
};
}
if (inputStat.isDirectory()) {
return resolvePackageSource(inputPath);
}
if (!inputStat.isFile()) {
return {
ok: false,
diagnostics: [
fileDiagnostic("unsupported_source", "Claw source must be a file or directory."),
],
};
}
const manifestPath = await realpath(inputPath);
const packageRoot = await realpath(dirname(manifestPath));
return {
ok: true,
source: {
kind: "development",
name: `local:${basename(manifestPath).replace(/\.json$/i, "")}`,
version: "0.0.0-development",
packageRoot,
manifestPath,
},
};
}
export async function readClawManifestFile(path: string): Promise<ClawReadResult> {
const sourceResult = await resolveSource(path);
if (!sourceResult.ok) {
return sourceResult;
}
const manifestResult = await readJson(
sourceResult.source.manifestPath,
"read_failed",
MAX_CLAW_MANIFEST_BYTES,
);
if (!manifestResult.ok) {
return manifestResult;
}
const parsed = parseClawManifest(manifestResult.value);
if (!parsed.ok) {
return parsed;
}
const snapshot = await buildDevelopmentSnapshot({
source: sourceResult.source,
manifest: parsed.manifest,
manifestRaw: manifestResult.raw,
});
if (!snapshot.ok) {
return snapshot;
}
const resolvedSource = sourceResult.source;
const source: ClawSourceIdentity = {
kind: resolvedSource.kind,
name: resolvedSource.name,
version: resolvedSource.version,
packageRoot: resolvedSource.packageRoot,
manifestPath: resolvedSource.manifestPath,
integrityKind: "development-snapshot",
integrity: snapshot.integrity,
byteLength: snapshot.byteLength,
};
return {
ok: true,
manifest: parsed.manifest,
source,
snapshot: { workspaceSources: snapshot.workspaceSources },
diagnostics: parsed.diagnostics,
};
}
+245
View File
@@ -0,0 +1,245 @@
// Conformance regressions for the portable Claw v1 contract.
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { readClawManifestFile } from "./reader.js";
import { parseClawManifest } from "./schema.js";
const baseManifest = {
schemaVersion: 1,
agent: { id: "portable-agent" },
workspace: { files: [] },
packages: [],
mcpServers: {},
cronJobs: [],
} as const;
describe("portable Claw schema conformance", () => {
it.each(["01.2.3", "1.02.3", "1.2.3-01", "v1.2.3", "1.2.x"])(
"rejects non-canonical package version %s",
(version) => {
const result = parseClawManifest({
...baseManifest,
packages: [{ kind: "skill", source: "clawhub", ref: "demo", version }],
});
expect(result.ok).toBe(false);
expect(result.diagnostics).toContainEqual(
expect.objectContaining({ phase: "schema", path: "$.packages[0].version" }),
);
},
);
it("requires canonical ClawHub package names", () => {
const result = parseClawManifest({
...baseManifest,
packages: [{ kind: "skill", source: "clawhub", ref: "Demo", version: "1.0.0" }],
});
expect(result.ok).toBe(false);
expect(result.diagnostics).toContainEqual(
expect.objectContaining({ path: "$.packages[0].ref" }),
);
});
it("rejects whitespace-only values without normalizing strict fields", () => {
const whitespaceName = parseClawManifest({
...baseManifest,
agent: { id: "portable-agent", name: " " },
});
const paddedPath = parseClawManifest({
...baseManifest,
workspace: { files: [{ source: " workspace/file.md", path: "file.md" }] },
});
expect(whitespaceName.ok).toBe(false);
expect(paddedPath.ok).toBe(false);
});
it("requires pinned package-manager MCP commands and safe environment keys", () => {
const unpinned = parseClawManifest({
...baseManifest,
mcpServers: { github: { command: "npx", args: ["--yes", "@acme/github-mcp"] } },
});
expect(unpinned.ok).toBe(false);
expect(unpinned.diagnostics).toContainEqual(
expect.objectContaining({ path: "$.mcpServers.github.args" }),
);
const dangerousEnv = parseClawManifest({
...baseManifest,
mcpServers: { github: { command: "node", env: { NODE_OPTIONS: "${NODE_OPTIONS}" } } },
});
expect(dangerousEnv.ok).toBe(false);
expect(dangerousEnv.diagnostics).toContainEqual(
expect.objectContaining({ path: "$.mcpServers.github.env.NODE_OPTIONS" }),
);
});
it("rejects unsafe remote URLs and duplicate tool filters", () => {
for (const url of [
"http://example.com/mcp",
"https://user@example.com/mcp",
"https://example.com/mcp#fragment",
]) {
const result = parseClawManifest({
...baseManifest,
mcpServers: { remote: { url, transport: "streamable-http" } },
});
expect(result.ok).toBe(false);
expect(result.diagnostics).toContainEqual(
expect.objectContaining({ path: "$.mcpServers.remote.url" }),
);
}
const duplicate = parseClawManifest({
...baseManifest,
mcpServers: {
github: { command: "node", toolFilter: { include: ["issues_*", "issues_*"] } },
},
});
expect(duplicate.ok).toBe(false);
expect(duplicate.diagnostics).toContainEqual(
expect.objectContaining({ path: "$.mcpServers.github.toolFilter.include[1]" }),
);
for (const pattern of ["issue?", "issue[0-9]"]) {
const unsupported = parseClawManifest({
...baseManifest,
mcpServers: { github: { command: "node", toolFilter: { include: [pattern] } } },
});
expect(unsupported.ok).toBe(false);
expect(unsupported.diagnostics).toContainEqual(
expect.objectContaining({ path: "$.mcpServers.github.toolFilter.include[0]" }),
);
}
});
it("uses portable workspace collision keys", () => {
const result = parseClawManifest({
...baseManifest,
workspace: {
files: [
{ source: "workspace/one.md", path: "Reference/Caf\u00e9.md" },
{ source: "workspace/two.md", path: "reference/Cafe\u0301.md" },
],
},
});
expect(result.ok).toBe(false);
expect(result.diagnostics).toContainEqual(
expect.objectContaining({ path: "$.workspace.files[1].path" }),
);
});
it("requires local avatars to be managed workspace destinations", () => {
const remote = parseClawManifest({
...baseManifest,
agent: { ...baseManifest.agent, identity: { avatar: "https://example.com/avatar.png" } },
});
expect(remote.ok).toBe(false);
const unmanaged = parseClawManifest({
...baseManifest,
agent: { ...baseManifest.agent, identity: { avatar: "avatars/agent.png" } },
});
expect(unmanaged.ok).toBe(false);
const managed = parseClawManifest({
...baseManifest,
agent: { ...baseManifest.agent, identity: { avatar: "avatars/agent.png" } },
workspace: {
files: [{ source: "workspace/avatar.png", path: "avatars/agent.png" }],
},
});
expect(managed.ok).toBe(true);
});
it("requires portable cron timezone, session, field count, and delivery", () => {
const valid = {
id: "daily",
schedule: { cron: "0 9 * * *", timezone: "UTC" },
session: "isolated",
message: "Summarize status.",
delivery: { mode: "announce", channel: "last" },
};
for (const cronJob of [
{ ...valid, schedule: { cron: "0 9 * * *" } },
{ ...valid, schedule: { cron: "0 0 9 * * *", timezone: "UTC" } },
{ ...valid, session: "current" },
{ ...valid, delivery: { mode: "none", channel: "last" } },
{ ...valid, delivery: { mode: "announce" } },
]) {
expect(parseClawManifest({ ...baseManifest, cronJobs: [cronJob] }).ok).toBe(false);
}
});
});
describe("development snapshot integrity", () => {
it("rejects oversized manifests and package metadata before parsing", async () => {
const root = await mkdtemp(join(tmpdir(), "openclaw-claw-bounded-read-"));
const manifestPath = join(root, "oversized.claw.json");
await writeFile(manifestPath, Buffer.alloc(1024 * 1024 + 1, 0x20));
const manifest = await readClawManifestFile(manifestPath);
expect(manifest.ok).toBe(false);
expect(manifest.diagnostics).toContainEqual(
expect.objectContaining({ code: "read_failed_too_large" }),
);
await writeFile(join(root, "package.json"), Buffer.alloc(256 * 1024 + 1, 0x20));
const packageResult = await readClawManifestFile(root);
expect(packageResult.ok).toBe(false);
expect(packageResult.diagnostics).toContainEqual(
expect.objectContaining({ code: "package_read_failed_too_large" }),
);
});
it.each([
["Demo", "1.0.0"],
["demo", "01.0.0"],
])("rejects noncanonical package metadata %s@%s", async (name, version) => {
const root = await mkdtemp(join(tmpdir(), "openclaw-claw-package-metadata-"));
await writeFile(
join(root, "package.json"),
JSON.stringify({ name, version, openclaw: { claw: "openclaw.claw.json" } }),
"utf8",
);
await writeFile(
join(root, "openclaw.claw.json"),
JSON.stringify({ schemaVersion: 1, agent: { id: "demo-agent" } }),
"utf8",
);
const result = await readClawManifestFile(root);
expect(result.ok).toBe(false);
expect(result.diagnostics).toContainEqual(
expect.objectContaining({ code: "invalid_package_metadata", phase: "parse" }),
);
});
it("binds every referenced workspace source", async () => {
const root = await mkdtemp(join(tmpdir(), "openclaw-claw-snapshot-"));
await mkdir(join(root, "workspace"));
const manifestPath = join(root, "demo.claw.json");
const sourcePath = join(root, "workspace", "SOUL.md");
await writeFile(sourcePath, "first\n", "utf8");
await writeFile(
manifestPath,
JSON.stringify({
schemaVersion: 1,
agent: { id: "demo-agent" },
workspace: { bootstrapFiles: { "SOUL.md": { source: "workspace/SOUL.md" } } },
}),
"utf8",
);
const first = await readClawManifestFile(manifestPath);
await writeFile(sourcePath, "second\n", "utf8");
const second = await readClawManifestFile(manifestPath);
expect(first.ok && second.ok).toBe(true);
if (!first.ok || !second.ok) {
throw new Error("expected snapshots to parse");
}
expect(second.source.integrity).not.toBe(first.source.integrity);
});
});
+141
View File
@@ -0,0 +1,141 @@
// Portable validation shared by Claw package metadata and grouped manifests.
import {
AVATAR_MAX_BYTES,
AVATAR_MAX_DATA_URL_CHARS,
isRenderableAvatarImageDataUrl,
} from "../shared/avatar-limits.js";
import { isSupportedLocalAvatarExtension } from "../shared/avatar-policy.js";
const EXACT_VERSION_PATTERN =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
const PACKAGE_NAME_PATTERN = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
const WINDOWS_INVALID_PATH_CHARS = /[<>:"|?*]/;
const WINDOWS_RESERVED_PATH_SEGMENT = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
const BASE64_PAYLOAD_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
export function isExactSemVer(value: string): boolean {
return EXACT_VERSION_PATTERN.test(value);
}
export function isCanonicalClawHubPackageName(value: string): boolean {
return PACKAGE_NAME_PATTERN.test(value);
}
export function isSafeClawRelativePath(value: string): boolean {
const normalized = value.replaceAll("\\", "/");
if (normalized.startsWith("/") || /^[A-Za-z]:\//.test(normalized)) {
return false;
}
return normalized
.split("/")
.every(
(segment) =>
segment !== "" &&
segment !== "." &&
segment !== ".." &&
!WINDOWS_INVALID_PATH_CHARS.test(segment) &&
!Array.from(segment).some((character) => character.charCodeAt(0) <= 0x1f) &&
!segment.endsWith(".") &&
!segment.endsWith(" ") &&
!WINDOWS_RESERVED_PATH_SEGMENT.test(segment),
);
}
export function portableClawPathKey(value: string): string {
return value.replaceAll("\\", "/").normalize("NFC").toLowerCase();
}
export function conflictsWithClawPath(targets: Set<string>, candidate: string): boolean {
for (const target of targets) {
if (
target === candidate ||
target.startsWith(`${candidate}/`) ||
candidate.startsWith(`${target}/`)
) {
return true;
}
}
return false;
}
export function isPortableClawAvatar(value: string): boolean {
if (isRenderableAvatarImageDataUrl(value)) {
if (value.length > AVATAR_MAX_DATA_URL_CHARS) {
return false;
}
const comma = value.indexOf(",");
if (comma < 0) {
return false;
}
const metadata = value.slice(0, comma);
const payload = value.slice(comma + 1);
try {
const base64 = /;base64(?:;|$)/i.test(metadata);
if (payload.length === 0 || (base64 && !BASE64_PAYLOAD_PATTERN.test(payload))) {
return false;
}
const bytes = base64
? Buffer.from(payload, "base64")
: Buffer.from(decodeURIComponent(payload), "utf8");
return bytes.byteLength > 0 && bytes.byteLength <= AVATAR_MAX_BYTES;
} catch {
return false;
}
}
return isSafeClawRelativePath(value) && isSupportedLocalAvatarExtension(value);
}
export function isValidClawTimezone(value: string): boolean {
try {
new Intl.DateTimeFormat("en-US", { timeZone: value }).format();
return true;
} catch {
return false;
}
}
function packageManagerArtifact(command: string, args: string[]): string | undefined {
const executable = command
.split(/[\\/]/)
.at(-1)
?.replace(/\.(?:cmd|exe)$/i, "")
.toLowerCase();
let start = 0;
if (executable === "pnpm" || executable === "yarn") {
if (args[0] !== "dlx") {
return undefined;
}
start = 1;
} else if (executable !== "npx" && executable !== "pnpx" && executable !== "bunx") {
return undefined;
}
for (let index = start; index < args.length; index += 1) {
const value = args[index];
if (!value) {
continue;
}
if (value === "-p" || value === "--package") {
return args[index + 1];
}
if (value.startsWith("--package=")) {
return value.slice("--package=".length);
}
if (!value.startsWith("-")) {
return value;
}
}
return "";
}
export function isClawPackageManagerArtifactPinned(
command: string,
args: string[],
): boolean | undefined {
const artifact = packageManagerArtifact(command, args);
if (artifact === undefined) {
return undefined;
}
const separator = artifact.lastIndexOf("@");
const scopedSlash = artifact.startsWith("@") ? artifact.indexOf("/") : -1;
return separator > 0 && separator > scopedSlash && isExactSemVer(artifact.slice(separator + 1));
}
+692
View File
@@ -0,0 +1,692 @@
// Tests for the grouped Claw manifest and read-only add plan.
import { createHash } from "node:crypto";
import { mkdir, mkdtemp, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { buildClawAddPlan } from "./lifecycle.js";
import { readClawManifestFile } from "./reader.js";
import { parseClawManifest } from "./schema.js";
import type { ClawManifest, ClawSourceIdentity, ClawSourceSnapshot } from "./types.js";
const baseManifest = {
schemaVersion: 1,
agent: {
id: "github-triage",
name: "GitHub Triage",
description: "Reviews incoming issues.",
identity: { name: "Triage", emoji: "search" },
groupChat: { mentionPatterns: ["@triage"] },
sandbox: { mode: "all", scope: "agent", workspaceAccess: "rw" },
tools: { allow: ["read", "write"], deny: ["exec"] },
heartbeat: { every: "30m", lightContext: true, skipWhenBusy: true },
humanDelay: { mode: "natural" },
},
workspace: {
bootstrapFiles: {
"AGENTS.md": { source: "workspace/AGENTS.md" },
},
files: [{ source: "workspace/reference/policy.md", path: "reference/policy.md" }],
},
packages: [
{ kind: "skill", source: "clawhub", ref: "@acme/triage", version: "1.2.0" },
{ kind: "plugin", source: "clawhub", ref: "@acme/github", version: "2.0.1" },
],
mcpServers: {
github: {
command: "npx",
args: ["--yes", "@acme/github-mcp@3.4.1"],
env: { API_TOKEN: "${GITHUB_TOKEN}" },
toolFilter: { include: ["issues_list"], exclude: ["repository_delete"] },
timeout: 30,
},
},
cronJobs: [
{
id: "weekday-triage",
name: "Weekday triage",
schedule: { cron: "0 9 * * 1-5", timezone: "America/New_York" },
session: "isolated",
message: "Review new issues.",
delivery: { mode: "announce", channel: "last" },
},
],
} as const;
function requireManifest(value: unknown = baseManifest): ClawManifest {
const result = parseClawManifest(value);
if (!result.ok) {
throw new Error(JSON.stringify(result.diagnostics));
}
return result.manifest;
}
function snapshotDigest(value: string | Buffer): string {
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
}
async function createPlanSource(): Promise<{
source: ClawSourceIdentity;
snapshot: ClawSourceSnapshot;
workspace: string;
}> {
const root = await mkdtemp(join(tmpdir(), "openclaw-claw-plan-"));
await mkdir(join(root, "workspace", "reference"), { recursive: true });
await writeFile(join(root, "workspace", "AGENTS.md"), "# Agent\n", "utf8");
await writeFile(join(root, "workspace", "reference", "policy.md"), "Policy\n", "utf8");
return {
source: {
kind: "package",
name: "@acme/github-triage",
version: "1.0.0",
packageRoot: root,
manifestPath: join(root, "openclaw.claw.json"),
integrityKind: "development-snapshot",
integrity: "sha256:test",
byteLength: 0,
},
snapshot: {
workspaceSources: [
{
sourcePath: "workspace/AGENTS.md",
realPath: join(root, "workspace", "AGENTS.md"),
byteLength: Buffer.byteLength("# Agent\n"),
digest: snapshotDigest("# Agent\n"),
},
{
sourcePath: "workspace/reference/policy.md",
realPath: join(root, "workspace", "reference", "policy.md"),
byteLength: Buffer.byteLength("Policy\n"),
digest: snapshotDigest("Policy\n"),
},
],
},
workspace: join(root, "new-workspace"),
};
}
describe("parseClawManifest", () => {
it("parses the grouped portable contract", () => {
const manifest = requireManifest();
expect(manifest.agent.id).toBe("github-triage");
expect(manifest.workspace.files).toHaveLength(1);
expect(manifest.packages.map((pkg) => pkg.kind)).toEqual(["skill", "plugin"]);
expect(Object.keys(manifest.mcpServers)).toEqual(["github"]);
expect(manifest.cronJobs[0]?.id).toBe("weekday-triage");
});
it("defaults optional ownership groups without inventing agent settings", () => {
const manifest = requireManifest({ schemaVersion: 1, agent: { id: "minimal-agent" } });
expect(manifest).toEqual({
schemaVersion: 1,
agent: { id: "minimal-agent" },
workspace: { bootstrapFiles: {}, files: [] },
packages: [],
mcpServers: {},
cronJobs: [],
});
});
it("rejects the prototype flat entries contract", () => {
const result = parseClawManifest({
schemaVersion: "openclaw.claw.v1",
id: "old-claw",
entries: [{ kind: "skill", id: "demo", required: false }],
});
expect(result.ok).toBe(false);
});
it.each(["model", "provider", "skills", "runtime", "bindings", "auth"])(
"rejects operator-controlled agent field %s",
(field) => {
const result = parseClawManifest({
schemaVersion: 1,
agent: { id: "unsafe-agent", [field]: field === "skills" ? ["demo"] : "value" },
});
expect(result.ok).toBe(false);
expect(result.diagnostics).toContainEqual(
expect.objectContaining({ code: "invalid_manifest", path: "$.agent" }),
);
},
);
it("rejects required flags and connector packages", () => {
const connector = parseClawManifest({
...baseManifest,
packages: [{ kind: "connector", source: "clawhub", ref: "@acme/chat", version: "1.0.0" }],
});
expect(connector.ok).toBe(false);
expect(connector.diagnostics[0]?.path).toBe("$.packages[0].kind");
const required = parseClawManifest({
...baseManifest,
packages: [{ ...baseManifest.packages[0], required: false }],
});
expect(required.ok).toBe(false);
expect(required.diagnostics[0]?.path).toBe("$.packages[0]");
});
it("requires exact package versions", () => {
const result = parseClawManifest({
...baseManifest,
packages: [{ kind: "skill", source: "clawhub", ref: "demo", version: "latest" }],
});
expect(result.ok).toBe(false);
expect(result.diagnostics[0]?.path).toBe("$.packages[0].version");
});
it("rejects resolved MCP secrets", () => {
const result = parseClawManifest({
...baseManifest,
mcpServers: {
github: { command: "npx", env: { GITHUB_TOKEN: "secret-value" } },
},
});
expect(result.ok).toBe(false);
expect(result.diagnostics[0]?.path).toBe("$.mcpServers.github.env.GITHUB_TOKEN");
});
it("accepts credential-free remote MCP with local OAuth completion", () => {
const manifest = requireManifest({
...baseManifest,
mcpServers: {
linear: {
url: "https://mcp.linear.app/mcp",
transport: "streamable-http",
auth: "oauth",
toolFilter: { include: ["list_issues"] },
},
},
});
expect(manifest.mcpServers.linear).toEqual({
url: "https://mcp.linear.app/mcp",
transport: "streamable-http",
auth: "oauth",
toolFilter: { include: ["list_issues"] },
});
});
it.each([
{
url: "https://example.com/mcp",
transport: "streamable-http",
headers: { Authorization: "secret" },
},
{ url: "https://example.com/mcp", transport: "streamable-http", command: "npx" },
{ url: "file:///tmp/mcp", transport: "sse" },
{ url: "https://example.com/mcp", transport: "stdio" },
])("rejects non-portable remote MCP config %#", (server) => {
const result = parseClawManifest({
...baseManifest,
mcpServers: { unsafe: server },
});
expect(result.ok).toBe(false);
expect(result.diagnostics[0]?.path).toMatch(/^\$\.mcpServers\.unsafe/);
});
it("rejects workspace traversal and duplicate destinations", () => {
const traversal = parseClawManifest({
...baseManifest,
workspace: { files: [{ source: "../outside", path: "inside.md" }] },
});
expect(traversal.ok).toBe(false);
const duplicate = parseClawManifest({
...baseManifest,
workspace: {
bootstrapFiles: { "AGENTS.md": { source: "workspace/AGENTS.md" } },
files: [{ source: "workspace/other.md", path: "AGENTS.md" }],
},
});
expect(duplicate.ok).toBe(false);
expect(duplicate.diagnostics).toContainEqual(
expect.objectContaining({ path: "$.workspace.files[0].path" }),
);
});
it("rejects duplicate packages and cron ids", () => {
const duplicatePackage = parseClawManifest({
...baseManifest,
packages: [baseManifest.packages[0], baseManifest.packages[0]],
});
expect(duplicatePackage.ok).toBe(false);
const duplicateCron = parseClawManifest({
...baseManifest,
cronJobs: [baseManifest.cronJobs[0], baseManifest.cronJobs[0]],
});
expect(duplicateCron.ok).toBe(false);
});
it("rejects invalid heartbeat durations and cron expressions", () => {
const heartbeat = parseClawManifest({
...baseManifest,
agent: { ...baseManifest.agent, heartbeat: { every: "eventually" } },
});
expect(heartbeat.ok).toBe(false);
expect(heartbeat.diagnostics).toContainEqual(
expect.objectContaining({ path: "$.agent.heartbeat.every" }),
);
const cron = parseClawManifest({
...baseManifest,
cronJobs: [
{
...baseManifest.cronJobs[0],
schedule: { cron: "not a cron expression", timezone: "UTC" },
},
],
});
expect(cron.ok).toBe(false);
expect(cron.diagnostics).toContainEqual(
expect.objectContaining({ path: "$.cronJobs[0].schedule.cron" }),
);
});
});
describe("readClawManifestFile", () => {
it("takes published identity from package.json", async () => {
const root = await mkdtemp(join(tmpdir(), "openclaw-claw-package-"));
await writeFile(
join(root, "package.json"),
JSON.stringify({
name: "@acme/github-triage",
version: "3.2.1",
openclaw: { claw: "openclaw.claw.json" },
}),
"utf8",
);
await writeFile(
join(root, "openclaw.claw.json"),
JSON.stringify({ schemaVersion: 1, agent: { id: "triage" } }),
"utf8",
);
const result = await readClawManifestFile(root);
expect(result.ok).toBe(true);
if (!result.ok) {
throw new Error("expected package to parse");
}
expect(result.source).toMatchObject({
kind: "package",
name: "@acme/github-triage",
version: "3.2.1",
integrityKind: "development-snapshot",
});
expect(result.source.integrity).toMatch(/^sha256:[a-f0-9]{64}$/);
expect(result.source.byteLength).toBeGreaterThan(0);
});
it("synthesizes explicit development identity for a standalone manifest", async () => {
const root = await mkdtemp(join(tmpdir(), "openclaw-claw-development-"));
const path = join(root, "demo.claw.json");
await writeFile(
path,
JSON.stringify({ schemaVersion: 1, agent: { id: "demo-agent" } }),
"utf8",
);
const result = await readClawManifestFile(path);
expect(result.ok).toBe(true);
if (!result.ok) {
throw new Error("expected development manifest to parse");
}
expect(result.source).toMatchObject({
kind: "development",
name: "local:demo.claw",
version: "0.0.0-development",
});
});
it("rejects workspace sources through an intermediate symlink", async () => {
const root = await mkdtemp(join(tmpdir(), "openclaw-claw-reader-symlink-"));
await mkdir(join(root, "workspace"));
await writeFile(join(root, "workspace", "AGENTS.md"), "# Agent\n", "utf8");
await symlink(
join(root, "workspace"),
join(root, "workspace-link"),
process.platform === "win32" ? "junction" : "dir",
);
const manifestPath = join(root, "demo.claw.json");
await writeFile(
manifestPath,
JSON.stringify({
schemaVersion: 1,
agent: { id: "symlink-agent" },
workspace: { bootstrapFiles: { "AGENTS.md": { source: "workspace-link/AGENTS.md" } } },
}),
"utf8",
);
const result = await readClawManifestFile(manifestPath);
expect(result.ok).toBe(false);
expect(result.diagnostics).toContainEqual(
expect.objectContaining({ code: "workspace_source_unsafe" }),
);
});
it("rejects a workspace source over the per-file byte limit", async () => {
const root = await mkdtemp(join(tmpdir(), "openclaw-claw-reader-file-limit-"));
await writeFile(join(root, "large.md"), Buffer.alloc(1024 * 1024 + 1));
const manifestPath = join(root, "demo.claw.json");
await writeFile(
manifestPath,
JSON.stringify({
schemaVersion: 1,
agent: { id: "large-agent" },
workspace: { files: [{ source: "large.md", path: "large.md" }] },
}),
"utf8",
);
const result = await readClawManifestFile(manifestPath);
expect(result.ok).toBe(false);
expect(result.diagnostics).toContainEqual(
expect.objectContaining({ code: "workspace_source_too_large" }),
);
});
it("rejects aggregate workspace bytes before reading source contents", async () => {
const root = await mkdtemp(join(tmpdir(), "openclaw-claw-reader-aggregate-limit-"));
const files = [];
for (let index = 0; index < 5; index += 1) {
const source = `large-${index}.md`;
await writeFile(join(root, source), Buffer.alloc(1024 * 1024, index));
files.push({ source, path: source });
}
const manifestPath = join(root, "demo.claw.json");
await writeFile(
manifestPath,
JSON.stringify({
schemaVersion: 1,
agent: { id: "large-agent" },
workspace: { files },
}),
"utf8",
);
const result = await readClawManifestFile(manifestPath);
expect(result.ok).toBe(false);
expect(result.diagnostics).toContainEqual(
expect.objectContaining({ code: "workspace_sources_too_large" }),
);
});
it("rejects package manifests that escape the package root", async () => {
const parent = await mkdtemp(join(tmpdir(), "openclaw-claw-escape-"));
const root = join(parent, "package");
await mkdir(root);
await writeFile(
join(parent, "outside.json"),
JSON.stringify({ schemaVersion: 1, agent: { id: "outside" } }),
"utf8",
);
await writeFile(
join(root, "package.json"),
JSON.stringify({
name: "@acme/escape",
version: "1.0.0",
openclaw: { claw: "../outside.json" },
}),
"utf8",
);
const result = await readClawManifestFile(root);
expect(result.ok).toBe(false);
expect(result.diagnostics).toContainEqual(
expect.objectContaining({ code: "manifest_escapes_package" }),
);
});
});
describe("buildClawAddPlan", () => {
it("plans one new agent, workspace, packages, MCP servers, and agent-pinned cron jobs", async () => {
const { source, snapshot, workspace } = await createPlanSource();
const plan = await buildClawAddPlan({
manifest: requireManifest(),
source,
snapshot,
context: { workspace },
});
expect(plan).toMatchObject({
schemaVersion: "openclaw.clawAddPlan.v1",
manifestSchemaVersion: 1,
stability: "experimental",
dryRun: true,
mutationAllowed: false,
planIntegrity: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
agent: { requestedId: "github-triage", finalId: "github-triage", workspace },
readiness: {
ready: false,
requirements: [{ kind: "environment", mcpServer: "github", name: "GITHUB_TOKEN" }],
},
summary: {
totalActions: 8,
agentActions: 1,
workspaceActions: 3,
packageActions: 2,
mcpServerActions: 1,
cronJobActions: 1,
blockedActions: 2,
capabilityEscalations: 5,
},
});
expect(plan.capabilityChanges).toEqual(
expect.arrayContaining([
expect.objectContaining({ kind: "agent", id: "github-triage" }),
expect.objectContaining({ kind: "package", id: "plugin:@acme/github" }),
expect.objectContaining({ kind: "mcpServer", id: "github" }),
expect.objectContaining({ kind: "cronJob", id: "weekday-triage" }),
]),
);
expect(
plan.capabilityChanges.find((change) => change.kind === "mcpServer")?.effect.env,
).toEqual([{ name: "API_TOKEN", reference: "GITHUB_TOKEN" }]);
expect(plan.actions).toContainEqual(
expect.objectContaining({
kind: "workspaceFile",
id: "AGENTS.md",
digest: expect.stringMatching(/^sha256:/),
}),
);
expect(plan.actions).toContainEqual(
expect.objectContaining({
kind: "cronJob",
id: "weekday-triage",
target: "cron:weekday-triage:agent=github-triage",
}),
);
});
it("blocks agent, configured workspace, MCP, and cron collisions", async () => {
const { source, snapshot, workspace } = await createPlanSource();
const plan = await buildClawAddPlan({
manifest: requireManifest(),
source,
snapshot,
context: {
workspace,
existingAgentIds: ["github-triage"],
existingWorkspacePaths: [workspace],
existingMcpServerNames: ["github"],
existingCronJobIds: ["weekday-triage"],
},
});
expect(plan.blockers.map((item) => item.code)).toEqual([
"agent_id_collision",
"workspace_collision",
"package_install_unavailable",
"package_install_unavailable",
"mcp_server_collision",
"cron_job_collision",
]);
expect(plan.summary.blockedActions).toBe(8);
});
it("uses an explicit unused agent id for every derived action", async () => {
const { source, snapshot, workspace } = await createPlanSource();
const plan = await buildClawAddPlan({
manifest: requireManifest(),
source,
snapshot,
context: { agentId: "triage-two", workspace },
});
expect(plan.agent.finalId).toBe("triage-two");
expect(plan.actions.find((action) => action.kind === "agent")?.id).toBe("triage-two");
expect(plan.actions.find((action) => action.kind === "cronJob")?.target).toContain(
"agent=triage-two",
);
});
it("blocks workspace sources missing from the validated snapshot", async () => {
const { source, snapshot, workspace } = await createPlanSource();
const plan = await buildClawAddPlan({
manifest: requireManifest({
schemaVersion: 1,
agent: { id: "symlink-agent" },
workspace: {
bootstrapFiles: { "AGENTS.md": { source: "workspace-link/AGENTS.md" } },
},
}),
source,
snapshot,
context: { workspace },
});
expect(plan.blockers).toContainEqual(
expect.objectContaining({ code: "workspace_source_invalid" }),
);
const workspaceAction = plan.actions.find(
(action) => action.kind === "workspaceFile" && action.id === "AGENTS.md",
);
expect(workspaceAction).toMatchObject({ kind: "workspaceFile", blocked: true });
expect(workspaceAction).not.toHaveProperty("digest");
});
it("blocks aggregate workspace bytes before hashing sources", async () => {
const { source, workspace } = await createPlanSource();
const files = [];
const workspaceSources = [];
for (let index = 0; index < 5; index += 1) {
const sourcePath = `workspace/large-${index}.md`;
await writeFile(join(source.packageRoot, sourcePath), Buffer.alloc(1024 * 1024, index));
files.push({ source: sourcePath, path: `large-${index}.md` });
workspaceSources.push({
sourcePath,
realPath: join(source.packageRoot, sourcePath),
byteLength: 1024 * 1024,
digest: snapshotDigest(Buffer.alloc(1024 * 1024, index)),
});
}
const plan = await buildClawAddPlan({
manifest: requireManifest({
schemaVersion: 1,
agent: { id: "large-agent" },
workspace: { files },
}),
source,
snapshot: { workspaceSources },
context: { workspace },
});
expect(plan.blockers).toContainEqual(
expect.objectContaining({ code: "workspace_sources_too_large" }),
);
const workspaceFileActions = plan.actions.filter((action) => action.kind === "workspaceFile");
expect(workspaceFileActions).toHaveLength(5);
expect(workspaceFileActions.every((action) => action.blocked)).toBe(true);
expect(workspaceFileActions.every((action) => action.blocked)).toBe(true);
});
it("uses the validated snapshot after source files change", async () => {
const { source, snapshot, workspace } = await createPlanSource();
const agentsSnapshot = snapshot.workspaceSources.find(
(entry) => entry.sourcePath === "workspace/AGENTS.md",
);
await writeFile(join(source.packageRoot, "workspace", "AGENTS.md"), "# Changed\n", "utf8");
const plan = await buildClawAddPlan({
manifest: requireManifest(),
source,
snapshot,
context: { workspace },
});
expect(
plan.actions.find((action) => action.kind === "workspaceFile" && action.id === "AGENTS.md")
?.digest,
).toBe(agentsSnapshot?.digest);
expect(agentsSnapshot?.digest).toBe(snapshotDigest("# Agent\n"));
});
it("blocks when workspace absence cannot be proven", async () => {
const { source, snapshot } = await createPlanSource();
const workspace = join(source.packageRoot, "x".repeat(300));
const plan = await buildClawAddPlan({
manifest: requireManifest(),
source,
snapshot,
context: { workspace },
});
expect(plan.blockers).toContainEqual(
expect.objectContaining({ code: "workspace_probe_failed" }),
);
expect(plan.actions.find((action) => action.kind === "workspace")).toMatchObject({
blocked: true,
details: { expectedState: "unknown" },
});
});
it("binds plan integrity to the source and planned mutations", async () => {
const { source, snapshot, workspace } = await createPlanSource();
const first = await buildClawAddPlan({
manifest: requireManifest(),
source,
snapshot,
context: { workspace },
});
const repeated = await buildClawAddPlan({
manifest: requireManifest(),
source,
snapshot,
context: { workspace },
});
const changed = await buildClawAddPlan({
manifest: requireManifest(),
source: { ...source, integrity: "sha256:changed" },
snapshot,
context: { workspace },
});
const changedCapability = await buildClawAddPlan({
manifest: requireManifest({
...baseManifest,
agent: { ...baseManifest.agent, tools: { allow: ["read", "exec"] } },
}),
source,
snapshot,
context: { workspace },
});
expect(repeated.planIntegrity).toBe(first.planIntegrity);
expect(changed.planIntegrity).not.toBe(first.planIntegrity);
expect(changedCapability.planIntegrity).not.toBe(first.planIntegrity);
});
});
+419
View File
@@ -0,0 +1,419 @@
// Strict parser for grouped Claw schema version 1 manifests.
import { z } from "zod";
import { parseDurationMs } from "../cli/parse-duration.js";
import { computeNextRunAtMs } from "../cron/schedule.js";
import { isDangerousHostEnvVarName } from "../infra/host-env-security.js";
import { isRenderableAvatarImageDataUrl } from "../shared/avatar-limits.js";
import {
conflictsWithClawPath,
isCanonicalClawHubPackageName,
isClawPackageManagerArtifactPinned,
isExactSemVer,
isPortableClawAvatar,
isSafeClawRelativePath,
isValidClawTimezone,
portableClawPathKey,
} from "./schema-portability.js";
import {
CLAW_BOOTSTRAP_FILE_NAMES,
CLAW_SCHEMA_VERSION,
type ClawDiagnostic,
type ClawManifest,
} from "./types.js";
const nonEmptyString = z
.string()
.min(1)
.refine(
(value) => value.length === value.trim().length && value.length > 0,
"Value must not have leading or trailing whitespace.",
);
const optionalString = nonEmptyString.optional();
const agentId = nonEmptyString.regex(
/^[a-z][a-z0-9_-]{0,63}$/,
"Agent id must start with a lowercase letter and contain only lowercase letters, digits, underscores, or hyphens.",
);
const exactVersion = nonEmptyString.refine(
isExactSemVer,
"Package version must be an exact semantic version.",
);
const clawHubPackageName = nonEmptyString.refine(
isCanonicalClawHubPackageName,
"ClawHub package references must use their canonical lowercase name.",
);
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 identitySchema = z
.object({
name: optionalString,
theme: optionalString,
emoji: optionalString,
avatar: nonEmptyString
.refine(isPortableClawAvatar, {
message:
"Avatar must be a bounded image data URL or managed workspace-relative image path.",
})
.optional(),
})
.strict();
const agentSchema = z
.object({
id: agentId,
name: optionalString,
description: optionalString,
identity: identitySchema.optional(),
groupChat: z
.object({ mentionPatterns: z.array(nonEmptyString).min(1).optional() })
.strict()
.optional(),
sandbox: z
.object({
mode: z.enum(["off", "non-main", "all"]).optional(),
scope: z.enum(["session", "agent", "shared"]).optional(),
workspaceAccess: z.enum(["none", "ro", "rw"]).optional(),
})
.strict()
.optional(),
tools: z
.object({
allow: z.array(nonEmptyString).min(1).optional(),
deny: z.array(nonEmptyString).min(1).optional(),
})
.strict()
.optional(),
heartbeat: z
.object({
every: nonEmptyString
.refine((value) => {
try {
parseDurationMs(value, { defaultUnit: "m" });
return true;
} catch {
return false;
}
}, "Invalid heartbeat duration.")
.optional(),
activeHours: z
.object({
start: nonEmptyString.regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/).optional(),
end: nonEmptyString.regex(/^(?:(?:[01]\d|2[0-3]):[0-5]\d|24:00)$/).optional(),
timezone: nonEmptyString
.refine(isValidClawTimezone, "Invalid IANA timezone.")
.optional(),
})
.strict()
.optional(),
lightContext: z.boolean().optional(),
isolatedSession: z.boolean().optional(),
skipWhenBusy: z.boolean().optional(),
timeoutSeconds: z.number().int().positive().optional(),
})
.strict()
.optional(),
humanDelay: z
.object({
mode: z.enum(["off", "natural", "custom"]).optional(),
minMs: z.number().int().nonnegative().optional(),
maxMs: z.number().int().nonnegative().optional(),
})
.strict()
.optional(),
})
.strict();
const workspaceSourceSchema = z.object({ source: packageRelativePath }).strict();
const bootstrapFilesSchema = z
.object(
Object.fromEntries(
CLAW_BOOTSTRAP_FILE_NAMES.map((name) => [name, workspaceSourceSchema.optional()]),
) as Record<
(typeof CLAW_BOOTSTRAP_FILE_NAMES)[number],
z.ZodOptional<typeof workspaceSourceSchema>
>,
)
.partial()
.strict();
const workspaceFileSchema = z
.object({ source: packageRelativePath, path: packageRelativePath })
.strict();
const workspaceSchema = z
.object({
bootstrapFiles: bootstrapFilesSchema.optional().default({}),
files: z.array(workspaceFileSchema).optional().default([]),
})
.strict()
.default({ bootstrapFiles: {}, files: [] });
const packageSchema = z
.object({
kind: z.enum(["skill", "plugin"]),
source: z.literal("clawhub"),
ref: clawHubPackageName,
version: exactVersion,
})
.strict();
const environmentReference = nonEmptyString.regex(
/^\$\{[A-Z_][A-Z0-9_]*\}$/,
"MCP environment values must be unresolved ${ENV_VAR} references.",
);
const mcpToolFilterSchema = z
.object({
include: z.array(nonEmptyString).min(1).optional(),
exclude: z.array(nonEmptyString).min(1).optional(),
})
.strict()
.superRefine((filter, ctx) => {
for (const field of ["include", "exclude"] as const) {
const seen = new Set<string>();
for (const [index, value] of (filter[field] ?? []).entries()) {
if (value.includes("?") || value.includes("[") || value.includes("]")) {
ctx.addIssue({
code: "custom",
path: [field, index],
message: "Tool filters support only exact names and * wildcards.",
});
}
if (seen.has(value)) {
ctx.addIssue({
code: "custom",
path: [field, index],
message: "Tool filter entries must be unique.",
});
}
seen.add(value);
}
}
});
const mcpServerCommonShape = {
toolFilter: mcpToolFilterSchema.optional(),
timeout: z.number().finite().positive().optional(),
connectTimeout: z.number().finite().positive().optional(),
};
const stdioMcpServerSchema = z
.object({
command: nonEmptyString,
transport: z.literal("stdio").optional(),
args: z.array(nonEmptyString).optional(),
env: z
.record(
nonEmptyString.regex(portableEnvKey, "Invalid portable environment key."),
environmentReference,
)
.optional(),
...mcpServerCommonShape,
})
.strict()
.superRefine((server, ctx) => {
if (isClawPackageManagerArtifactPinned(server.command, server.args ?? []) === false) {
ctx.addIssue({
code: "custom",
path: ["args"],
message: "Package-manager MCP commands must select one exact immutable package version.",
});
}
for (const key of Object.keys(server.env ?? {})) {
if (isDangerousHostEnvVarName(key)) {
ctx.addIssue({
code: "custom",
path: ["env", key],
message: "Environment key is blocked by the spawned-process safety policy.",
});
}
}
});
const remoteMcpServerSchema = z
.object({
url: nonEmptyString.url(),
transport: z.enum(["sse", "streamable-http"]),
auth: z.literal("oauth").optional(),
...mcpServerCommonShape,
})
.strict()
.superRefine((server, ctx) => {
const url = new URL(server.url);
const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
ctx.addIssue({
code: "custom",
path: ["url"],
message: "Remote MCP URLs must use HTTPS, except HTTP on an exact loopback host.",
});
}
if (url.username || url.password || url.hash) {
ctx.addIssue({
code: "custom",
path: ["url"],
message: "Remote MCP URLs must not contain user information or fragments.",
});
}
});
const mcpServerSchema = z.union([stdioMcpServerSchema, remoteMcpServerSchema]);
const cronJobSchema = z
.object({
id: agentId,
name: optionalString,
schedule: z.object({ cron: nonEmptyString, timezone: nonEmptyString }).strict(),
session: z.enum(["main", "isolated"]),
message: nonEmptyString,
delivery: z
.object({
mode: z.enum(["none", "announce"]),
channel: z.literal("last").optional(),
})
.strict()
.optional(),
})
.strict()
.superRefine((job, ctx) => {
if (job.schedule.cron.trim().split(/\s+/).length !== 5) {
ctx.addIssue({
code: "custom",
path: ["schedule", "cron"],
message: "Cron schedule must use exactly five fields.",
});
}
if (
(job.delivery?.mode === "none" && job.delivery.channel !== undefined) ||
(job.delivery?.mode === "announce" && job.delivery.channel !== "last")
) {
ctx.addIssue({
code: "custom",
path: ["delivery"],
message: 'Delivery must be { mode: "none" } or { mode: "announce", channel: "last" }.',
});
}
try {
computeNextRunAtMs(
{ kind: "cron", expr: job.schedule.cron, tz: job.schedule.timezone },
Date.now(),
);
} catch {
ctx.addIssue({
code: "custom",
path: ["schedule", "cron"],
message: "Invalid cron expression or timezone.",
});
}
});
const manifestSchema = z
.object({
schemaVersion: z.literal(CLAW_SCHEMA_VERSION),
agent: agentSchema,
workspace: workspaceSchema.optional().default({ bootstrapFiles: {}, files: [] }),
packages: z.array(packageSchema).optional().default([]),
mcpServers: z
.record(
nonEmptyString.regex(/^[a-z][a-z0-9_-]{0,63}$/, "Invalid MCP server name."),
mcpServerSchema,
)
.optional()
.default({}),
cronJobs: z.array(cronJobSchema).optional().default([]),
})
.strict()
.superRefine((manifest, ctx) => {
const workspaceTargets = new Set<string>();
for (const name of CLAW_BOOTSTRAP_FILE_NAMES) {
if (manifest.workspace.bootstrapFiles[name]) {
workspaceTargets.add(portableClawPathKey(name));
}
}
manifest.workspace.files.forEach((file, index) => {
const destinationKey = portableClawPathKey(file.path);
if (conflictsWithClawPath(workspaceTargets, destinationKey)) {
ctx.addIssue({
code: "custom",
path: ["workspace", "files", index, "path"],
message: `Workspace destination ${JSON.stringify(file.path)} is declared more than once.`,
});
}
workspaceTargets.add(destinationKey);
});
const packageKeys = new Set<string>();
manifest.packages.forEach((pkg, index) => {
const key = `${pkg.kind}:${pkg.source}:${pkg.ref.toLowerCase()}`;
if (packageKeys.has(key)) {
ctx.addIssue({
code: "custom",
path: ["packages", index],
message: `Package ${JSON.stringify(pkg.ref)} is declared more than once for ${pkg.kind}.`,
});
}
packageKeys.add(key);
});
const managedPaths = new Set(
manifest.workspace.files.map((file) => portableClawPathKey(file.path)),
);
const avatar = manifest.agent.identity?.avatar;
if (
avatar &&
!isRenderableAvatarImageDataUrl(avatar) &&
!managedPaths.has(portableClawPathKey(avatar))
) {
ctx.addIssue({
code: "custom",
path: ["agent", "identity", "avatar"],
message: "Workspace-relative avatar must match a workspace.files destination.",
});
}
const cronIds = new Set<string>();
manifest.cronJobs.forEach((job, index) => {
if (cronIds.has(job.id)) {
ctx.addIssue({
code: "custom",
path: ["cronJobs", index, "id"],
message: `Cron job id ${JSON.stringify(job.id)} is declared more than once.`,
});
}
cronIds.add(job.id);
});
});
function formatIssuePath(path: PropertyKey[]): string {
if (path.length === 0) {
return "$";
}
return `$${path
.map((part) => (typeof part === "number" ? `[${part}]` : `.${String(part)}`))
.join("")}`;
}
function diagnosticsFromZodError(error: z.ZodError): ClawDiagnostic[] {
return error.issues.map((issue) => ({
level: "error",
code: "invalid_manifest",
phase: "schema",
path: formatIssuePath(issue.path),
message: issue.message,
}));
}
export function parseClawManifest(
value: unknown,
):
| { ok: true; manifest: ClawManifest; diagnostics: ClawDiagnostic[] }
| { ok: false; diagnostics: ClawDiagnostic[] } {
const parsed = manifestSchema.safeParse(value);
if (!parsed.success) {
return { ok: false, diagnostics: diagnosticsFromZodError(parsed.error) };
}
return { ok: true, manifest: parsed.data as ClawManifest, diagnostics: [] };
}
+2
View File
@@ -0,0 +1,2 @@
export const MAX_MANAGED_FILE_BYTES = 1024 * 1024;
export const MAX_MANAGED_WORKSPACE_BYTES = 4 * MAX_MANAGED_FILE_BYTES;
+229
View File
@@ -0,0 +1,229 @@
// Shared types for grouped OpenClaw Claw manifests and read-only add plans.
export const CLAW_SCHEMA_VERSION = 1 as const;
export const CLAW_ADD_PLAN_SCHEMA_VERSION = "openclaw.clawAddPlan.v1" as const;
export const CLAW_INSPECT_RESULT_SCHEMA_VERSION = "openclaw.clawInspect.v1" as const;
export const CLAW_OUTPUT_STABILITY = "experimental" as const;
type ClawDiagnosticLevel = "error" | "warning";
export type ClawDiagnostic = {
level: ClawDiagnosticLevel;
code: string;
phase: "parse" | "schema" | "policy" | "plan" | "mutation";
path: string;
message: string;
};
type ClawAgent = {
id: string;
name?: string;
description?: string;
identity?: {
name?: string;
theme?: string;
emoji?: string;
avatar?: string;
};
groupChat?: {
mentionPatterns?: string[];
};
sandbox?: {
mode?: "off" | "non-main" | "all";
scope?: "session" | "agent" | "shared";
workspaceAccess?: "none" | "ro" | "rw";
};
tools?: {
allow?: string[];
deny?: string[];
};
heartbeat?: {
every?: string;
activeHours?: {
start?: string;
end?: string;
timezone?: string;
};
lightContext?: boolean;
isolatedSession?: boolean;
skipWhenBusy?: boolean;
timeoutSeconds?: number;
};
humanDelay?: {
mode?: "off" | "natural" | "custom";
minMs?: number;
maxMs?: number;
};
};
export const CLAW_BOOTSTRAP_FILE_NAMES = [
"AGENTS.md",
"SOUL.md",
"IDENTITY.md",
"TOOLS.md",
"HEARTBEAT.md",
] as const;
type ClawBootstrapFileName = (typeof CLAW_BOOTSTRAP_FILE_NAMES)[number];
type ClawWorkspaceFile = {
source: string;
path: string;
};
type ClawWorkspace = {
bootstrapFiles: Partial<Record<ClawBootstrapFileName, { source: string }>>;
files: ClawWorkspaceFile[];
};
type ClawPackage = {
kind: "skill" | "plugin";
source: "clawhub";
ref: string;
version: string;
};
type ClawMcpServerCommon = {
toolFilter?: {
include?: string[];
exclude?: string[];
};
timeout?: number;
connectTimeout?: number;
};
type ClawStdioMcpServer = ClawMcpServerCommon & {
command: string;
transport?: "stdio";
args?: string[];
env?: Record<string, string>;
};
type ClawRemoteMcpServer = ClawMcpServerCommon & {
url: string;
transport: "sse" | "streamable-http";
auth?: "oauth";
};
type ClawMcpServer = ClawStdioMcpServer | ClawRemoteMcpServer;
type ClawCronJob = {
id: string;
name?: string;
schedule: {
cron: string;
timezone: string;
};
session: "main" | "isolated";
message: string;
delivery?: {
mode: "none" | "announce";
channel?: "last";
};
};
export type ClawManifest = {
schemaVersion: typeof CLAW_SCHEMA_VERSION;
agent: ClawAgent;
workspace: ClawWorkspace;
packages: ClawPackage[];
mcpServers: Record<string, ClawMcpServer>;
cronJobs: ClawCronJob[];
};
export type ClawSourceIdentity = {
kind: "package" | "development";
name: string;
version: string;
packageRoot: string;
manifestPath: string;
integrityKind: "artifact" | "development-snapshot";
integrity: string;
byteLength: number;
};
export type ClawWorkspaceSourceSnapshot = {
sourcePath: string;
realPath: string;
byteLength: number;
digest: string;
};
export type ClawSourceSnapshot = {
workspaceSources: ClawWorkspaceSourceSnapshot[];
};
export type ClawReadResult =
| {
ok: true;
manifest: ClawManifest;
source: ClawSourceIdentity;
snapshot: ClawSourceSnapshot;
diagnostics: ClawDiagnostic[];
}
| {
ok: false;
diagnostics: ClawDiagnostic[];
};
export type ClawAddPlanAction = {
kind: "agent" | "workspace" | "workspaceFile" | "package" | "mcpServer" | "cronJob";
id: string;
action: "create" | "write" | "install" | "configure" | "schedule";
target: string;
source?: string;
digest?: string;
details?: Record<string, unknown>;
blocked: boolean;
reason?: string;
};
export type ClawAddCapabilityChange = {
kind: "agent" | "package" | "mcpServer" | "cronJob";
id: string;
path: string;
action: "create" | "install" | "configure" | "schedule";
classification: "escalation";
requiresDistinctConsent: true;
reason: string;
effect: Record<string, unknown>;
digest: string;
};
export type ClawLocalPrerequisite =
| { kind: "environment"; mcpServer: string; name: string }
| { kind: "oauth"; mcpServer: string };
export type ClawAddPlan = {
schemaVersion: typeof CLAW_ADD_PLAN_SCHEMA_VERSION;
manifestSchemaVersion: typeof CLAW_SCHEMA_VERSION;
stability: typeof CLAW_OUTPUT_STABILITY;
dryRun: true;
mutationAllowed: false;
planIntegrity: string;
claw: ClawSourceIdentity;
agent: {
requestedId: string;
finalId: string;
workspace: string;
config: ClawAgent & { workspace: string };
};
summary: {
totalActions: number;
agentActions: number;
workspaceActions: number;
packageActions: number;
mcpServerActions: number;
cronJobActions: number;
blockedActions: number;
capabilityEscalations: number;
};
actions: ClawAddPlanAction[];
capabilityChanges: ClawAddCapabilityChange[];
readiness: {
ready: boolean;
requirements: ClawLocalPrerequisite[];
};
blockers: ClawDiagnostic[];
diagnostics: ClawDiagnostic[];
};
+7 -1
View File
@@ -1,4 +1,5 @@
// Low-level CLI argv helpers for root options, help/version detection, and command paths.
import { isExperimentalClawsEnabled } from "../claws/experimental.js";
import { isBunRuntime, isNodeRuntime } from "../daemon/runtime-binary.js";
import {
consumeRootOptionToken,
@@ -12,7 +13,12 @@ import { SUB_CLI_DESCRIPTORS } from "./program/subcli-descriptors.js";
const HELP_FLAGS = new Set(["-h", "--help"]);
const VERSION_FLAGS = new Set(["-V", "--version"]);
const ROOT_VERSION_ALIAS_FLAG = "-v";
const ROOT_COMMAND_DESCRIPTORS = [...CORE_CLI_COMMAND_DESCRIPTORS, ...SUB_CLI_DESCRIPTORS];
const ROOT_COMMAND_DESCRIPTORS = [
...CORE_CLI_COMMAND_DESCRIPTORS.filter(
(descriptor) => descriptor.name !== "claws" || isExperimentalClawsEnabled(),
),
...SUB_CLI_DESCRIPTORS,
];
const KNOWN_ROOT_COMMANDS: ReadonlySet<string> = new Set(
ROOT_COMMAND_DESCRIPTORS.map((descriptor) => descriptor.name),
);
+179
View File
@@ -0,0 +1,179 @@
import { listAgentIds, resolveAgentWorkspaceDir } from "../agents/agent-scope-config.js";
import { assertExperimentalClawsEnabled } from "../claws/experimental.js";
import { buildClawAddPlan } from "../claws/lifecycle.js";
import { readClawManifestFile } from "../claws/reader.js";
import {
CLAW_INSPECT_RESULT_SCHEMA_VERSION,
CLAW_ADD_PLAN_SCHEMA_VERSION,
CLAW_OUTPUT_STABILITY,
type ClawAddPlan,
} from "../claws/types.js";
// Runtime handlers for experimental local Claws commands.
import { getRuntimeConfig } from "../config/config.js";
import {
loadCronJobsStoreWithConfigJobsReadOnly,
resolveCronJobsStorePath,
} from "../cron/store.js";
import { redactSensitiveText } from "../logging/redact.js";
import { defaultRuntime, writeRuntimeJson, type RuntimeEnv } from "../runtime.js";
import type { ClawsAddOptions, ClawsInspectOptions } from "./claws-cli.js";
type DiagnosticLike = { level: string; code: string; path: string; message: string };
function formatDiagnostics(diagnostics: DiagnosticLike[]): string {
return diagnostics
.map(
(diagnostic) =>
`${diagnostic.level.toUpperCase()} ${diagnostic.code} ${diagnostic.path}: ${diagnostic.message}`,
)
.join("\n");
}
function logExperimentalWarning(runtime: RuntimeEnv): void {
runtime.log("Experimental: Claws contracts may change while RFC 0016 is under review.");
}
function logClawAddPlanSummary(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(`Packages: ${plan.summary.packageActions}`);
runtime.log(`MCP servers: ${plan.summary.mcpServerActions}`);
runtime.log(`Cron jobs: ${plan.summary.cronJobActions}`);
if (plan.capabilityChanges.length > 0) {
runtime.log(`Capability escalations (${plan.capabilityChanges.length}):`);
for (const change of plan.capabilityChanges) {
runtime.log(
redactSensitiveText(` ! ${change.kind}:${change.id} ${JSON.stringify(change.effect)}`),
);
}
runtime.log("The plan integrity binds every capability line above.");
}
if (plan.summary.blockedActions > 0) {
runtime.log(`Blocked actions: ${plan.summary.blockedActions}`);
}
}
function failNonDryRun(opts: ClawsAddOptions, runtime: RuntimeEnv): boolean {
if (opts.dryRun) {
return false;
}
const message =
"Claw add is dry-run only in this OpenClaw build; pass --dry-run to preview lifecycle actions.";
if (opts.json) {
writeRuntimeJson(runtime, {
schemaVersion: CLAW_ADD_PLAN_SCHEMA_VERSION,
stability: CLAW_OUTPUT_STABILITY,
ok: false,
error: { code: "dry_run_required", message },
});
} else {
runtime.error(message);
}
runtime.exit(1);
return true;
}
export async function runClawsInspectCommand(
sourcePath: string,
opts: ClawsInspectOptions,
runtime: RuntimeEnv = defaultRuntime,
): Promise<void> {
assertExperimentalClawsEnabled();
const result = await readClawManifestFile(sourcePath);
if (!result.ok) {
if (opts.json) {
writeRuntimeJson(runtime, {
schemaVersion: CLAW_INSPECT_RESULT_SCHEMA_VERSION,
stability: CLAW_OUTPUT_STABILITY,
valid: false,
diagnostics: result.diagnostics,
});
} else {
runtime.error(formatDiagnostics(result.diagnostics));
}
runtime.exit(1);
return;
}
const payload = {
schemaVersion: CLAW_INSPECT_RESULT_SCHEMA_VERSION,
stability: CLAW_OUTPUT_STABILITY,
valid: true,
source: result.source,
manifest: result.manifest,
diagnostics: result.diagnostics,
};
if (opts.json) {
writeRuntimeJson(runtime, payload);
return;
}
logExperimentalWarning(runtime);
runtime.log(`Claw: ${result.source.name}@${result.source.version}`);
runtime.log(`Agent: ${result.manifest.agent.name ?? result.manifest.agent.id}`);
runtime.log(`Packages: ${result.manifest.packages.length}`);
runtime.log(`MCP servers: ${Object.keys(result.manifest.mcpServers).length}`);
runtime.log(`Cron jobs: ${result.manifest.cronJobs.length}`);
}
export async function runClawsAddCommand(
sourcePath: string,
opts: ClawsAddOptions,
runtime: RuntimeEnv = defaultRuntime,
): Promise<void> {
assertExperimentalClawsEnabled();
if (failNonDryRun(opts, runtime)) {
return;
}
const result = await readClawManifestFile(sourcePath);
if (!result.ok) {
if (opts.json) {
writeRuntimeJson(runtime, {
schemaVersion: CLAW_ADD_PLAN_SCHEMA_VERSION,
stability: CLAW_OUTPUT_STABILITY,
valid: false,
diagnostics: result.diagnostics,
});
} else {
runtime.error(formatDiagnostics(result.diagnostics));
}
runtime.exit(1);
return;
}
const config = getRuntimeConfig();
const existingAgentIds = listAgentIds(config);
const cronStore = await loadCronJobsStoreWithConfigJobsReadOnly(
resolveCronJobsStorePath(config.cron?.store),
);
const plan = await buildClawAddPlan({
manifest: result.manifest,
source: result.source,
snapshot: result.snapshot,
diagnostics: result.diagnostics,
context: {
...(opts.agentId ? { agentId: opts.agentId } : {}),
...(opts.workspace ? { workspace: opts.workspace } : {}),
existingAgentIds,
existingWorkspacePaths: existingAgentIds.map((agentId) =>
resolveAgentWorkspaceDir(config, agentId),
),
existingMcpServerNames: Object.keys(config.mcp?.servers ?? {}),
existingCronJobIds: cronStore.store.jobs.map((job) => job.id),
},
});
if (opts.json) {
writeRuntimeJson(runtime, plan);
} else {
logExperimentalWarning(runtime);
runtime.log(`Claw add plan: ${plan.claw.name}@${plan.claw.version}`);
logClawAddPlanSummary(plan, runtime);
if (plan.blockers.length > 0) {
runtime.error(formatDiagnostics(plan.blockers));
}
}
if (plan.blockers.length > 0) {
runtime.exit(1);
}
}
+224
View File
@@ -0,0 +1,224 @@
// Tests for the experimental grouped Claws CLI.
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => {
const logs: string[] = [];
const errors: string[] = [];
const runtime = {
log: vi.fn((value: unknown) => logs.push(String(value))),
error: vi.fn((value: unknown) => errors.push(String(value))),
writeJson: vi.fn((value: unknown, space = 2) =>
logs.push(JSON.stringify(value, null, space > 0 ? space : undefined)),
),
writeStdout: vi.fn(),
exit: vi.fn((code: number) => {
throw new Error(`__exit__:${code}`);
}),
};
return { logs, errors, runtime, loadConfig: vi.fn<() => Record<string, unknown>>(() => ({})) };
});
vi.mock("../runtime.js", async () => ({
...(await vi.importActual<typeof import("../runtime.js")>("../runtime.js")),
defaultRuntime: mocks.runtime,
writeRuntimeJson: (runtime: typeof mocks.runtime, value: unknown, space = 2) =>
runtime.writeJson(value, space),
}));
vi.mock("../config/config.js", async () => ({
...(await vi.importActual<typeof import("../config/config.js")>("../config/config.js")),
getRuntimeConfig: mocks.loadConfig,
loadConfig: mocks.loadConfig,
}));
const { registerClawsCli } = await import("./claws-cli.js");
const minimalManifest = { schemaVersion: 1, agent: { id: "demo-agent", name: "Demo Agent" } };
async function writeManifest(value: unknown = minimalManifest): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), "openclaw-claws-cli-"));
const path = join(dir, "openclaw.claw.json");
await writeFile(path, JSON.stringify(value), "utf8");
return path;
}
async function writePackage(): Promise<{ root: string; workspace: string }> {
const root = await mkdtemp(join(tmpdir(), "openclaw-claws-cli-package-"));
await mkdir(join(root, "workspace"));
await writeFile(join(root, "workspace", "AGENTS.md"), "# Demo\n", "utf8");
await writeFile(
join(root, "package.json"),
JSON.stringify({
name: "@acme/demo-agent",
version: "1.2.3",
openclaw: { claw: "openclaw.claw.json" },
}),
"utf8",
);
await writeFile(
join(root, "openclaw.claw.json"),
JSON.stringify({
schemaVersion: 1,
agent: { id: "demo-agent", name: "Demo Agent" },
workspace: {
bootstrapFiles: { "AGENTS.md": { source: "workspace/AGENTS.md" } },
},
packages: [{ kind: "skill", source: "clawhub", ref: "@acme/demo-skill", version: "1.0.0" }],
}),
"utf8",
);
return { root, workspace: join(root, "target-workspace") };
}
async function runCli(args: string[]) {
const program = new Command();
program.exitOverride();
registerClawsCli(program);
try {
await program.parseAsync(args, { from: "user" });
} catch (error) {
if (!(error instanceof Error && error.message.startsWith("__exit__:"))) {
throw error;
}
}
}
describe("claws cli", () => {
beforeEach(() => {
vi.stubEnv("OPENCLAW_EXPERIMENTAL_CLAWS", "1");
mocks.logs.length = 0;
mocks.errors.length = 0;
mocks.runtime.log.mockClear();
mocks.runtime.error.mockClear();
mocks.runtime.writeJson.mockClear();
mocks.runtime.exit.mockClear();
mocks.loadConfig.mockReset();
mocks.loadConfig.mockReturnValue({});
});
it("does not register without the process opt-in", () => {
vi.stubEnv("OPENCLAW_EXPERIMENTAL_CLAWS", "");
const program = new Command();
registerClawsCli(program);
expect(program.commands.map((command) => command.name())).not.toContain("claws");
});
it("registers inspect and add without exposing the prototype apply or feed commands", () => {
const program = new Command();
registerClawsCli(program);
const claws = program.commands.find((command) => command.name() === "claws");
expect(claws?.commands.map((command) => command.name())).toEqual(["inspect", "add"]);
});
it("prints versioned experimental JSON for a development manifest", async () => {
const manifestPath = await writeManifest();
await runCli(["claws", "inspect", manifestPath, "--json"]);
expect(JSON.parse(mocks.logs[0] ?? "{}")).toMatchObject({
schemaVersion: "openclaw.clawInspect.v1",
stability: "experimental",
valid: true,
source: { kind: "development", version: "0.0.0-development" },
manifest: { schemaVersion: 1, agent: { id: "demo-agent" } },
});
});
it("takes identity from package.json and plans one new agent", async () => {
const { root, workspace } = await writePackage();
await runCli(["claws", "add", root, "--dry-run", "--workspace", workspace, "--json"]);
expect(JSON.parse(mocks.logs[0] ?? "{}")).toMatchObject({
schemaVersion: "openclaw.clawAddPlan.v1",
stability: "experimental",
claw: { kind: "package", name: "@acme/demo-agent", version: "1.2.3" },
agent: { finalId: "demo-agent", workspace },
summary: { agentActions: 1, workspaceActions: 2, packageActions: 1, blockedActions: 1 },
});
expect(mocks.runtime.exit).toHaveBeenCalledWith(1);
});
it("blocks adding into an existing agent instead of merging", async () => {
const { root, workspace } = await writePackage();
mocks.loadConfig.mockReturnValue({ agents: { list: [{ id: "demo-agent" }] } });
await runCli(["claws", "add", root, "--dry-run", "--workspace", workspace, "--json"]);
const payload = JSON.parse(mocks.logs[0] ?? "{}");
expect(payload.blockers).toContainEqual(
expect.objectContaining({ code: "agent_id_collision" }),
);
expect(mocks.runtime.exit).toHaveBeenCalledWith(1);
});
it("honors an explicit unused agent id in the plan", async () => {
const { root, workspace } = await writePackage();
mocks.loadConfig.mockReturnValue({ agents: { list: [{ id: "demo-agent" }] } });
await runCli([
"claws",
"add",
root,
"--dry-run",
"--agent-id",
"demo-agent-two",
"--workspace",
workspace,
"--json",
]);
expect(JSON.parse(mocks.logs[0] ?? "{}").agent).toMatchObject({
requestedId: "demo-agent",
finalId: "demo-agent-two",
});
expect(mocks.runtime.exit).toHaveBeenCalledWith(1);
});
it("discloses capability escalations in the human dry-run", async () => {
const path = await writeManifest({
schemaVersion: 1,
agent: { id: "demo-agent", tools: { allow: ["read"] } },
mcpServers: {
docs: {
command: "node",
env: { API_TOKEN: "${GITHUB_TOKEN}" },
toolFilter: { include: ["search_*"] },
},
},
});
await runCli(["claws", "add", path, "--dry-run"]);
expect(mocks.logs).toContain("Capability escalations (2):");
expect(mocks.logs.some((line) => line.startsWith(" ! agent:demo-agent"))).toBe(true);
expect(mocks.logs.some((line) => line.startsWith(" ! mcpServer:docs"))).toBe(true);
expect(
mocks.logs.some(
(line) =>
line.includes('"name":"API_TOKEN"') && line.includes('"reference":"GITHUB_TOKEN"'),
),
).toBe(true);
expect(mocks.logs).toContain("The plan integrity binds every capability line above.");
});
it("fails closed when add is invoked without dry-run", async () => {
const path = await writeManifest();
await runCli(["claws", "add", path, "--json"]);
expect(JSON.parse(mocks.logs[0] ?? "{}")).toMatchObject({
stability: "experimental",
ok: false,
error: { code: "dry_run_required" },
});
expect(mocks.runtime.exit).toHaveBeenCalledWith(1);
});
});
+47
View File
@@ -0,0 +1,47 @@
// Commander registration for experimental Claws inspection and add previews.
import type { Command } from "commander";
import { isExperimentalClawsEnabled } from "../claws/experimental.js";
import { applyParentDefaultHelpAction } from "./program/parent-default-help.js";
export type ClawsInspectOptions = {
json?: boolean;
};
export type ClawsAddOptions = {
dryRun?: boolean;
json?: boolean;
agentId?: string;
workspace?: string;
};
export function registerClawsCli(program: Command) {
if (!isExperimentalClawsEnabled()) {
return;
}
const claws = program.command("claws").description("Inspect and add experimental OpenClaw Claws");
claws
.command("inspect")
.description("Validate a Claw package or local development manifest")
.argument("<source>", "Path to a Claw package directory or grouped manifest")
.option("--json", "Print JSON", false)
.action(async (source: string, opts: ClawsInspectOptions) => {
const { runClawsInspectCommand } = await import("./claws-cli.runtime.js");
await runClawsInspectCommand(source, opts);
});
claws
.command("add")
.description("Preview adding one new agent and workspace from a Claw")
.argument("<source>", "Path to a Claw package directory or grouped manifest")
.option("--dry-run", "Preview all actions without mutating state", false)
.option("--agent-id <id>", "Override the requested id with an unused local agent id")
.option("--workspace <path>", "Override the derived new workspace path")
.option("--json", "Print JSON", false)
.action(async (source: string, opts: ClawsAddOptions) => {
const { runClawsAddCommand } = await import("./claws-cli.runtime.js");
await runClawsAddCommand(source, opts);
});
applyParentDefaultHelpAction(claws);
}
+13 -7
View File
@@ -64,6 +64,11 @@ const coreEntrySpecs: readonly CommandGroupDescriptorSpec<
loadModule: () => import("../config-cli.js"),
exportName: "registerConfigCli",
},
{
commandNames: ["claws"],
loadModule: () => import("../claws-cli.js"),
exportName: "registerClawsCli",
},
{
commandNames: ["backup"],
loadModule: () => import("./register.backup.js"),
@@ -135,14 +140,15 @@ const coreEntrySpecs: readonly CommandGroupDescriptorSpec<
];
function resolveCoreCommandGroups(ctx: ProgramContext, argv: string[]): CommandGroupEntry[] {
// Descriptor metadata and import specs stay separate so help can stay cheap.
return buildCommandGroupEntries(
getCoreCliCommandDescriptors(),
coreEntrySpecs,
(register) => async (program) => {
await register({ program, ctx, argv });
},
const descriptors = getCoreCliCommandDescriptors();
const visibleCommandNames = new Set(descriptors.map((descriptor) => descriptor.name));
const visibleEntrySpecs = coreEntrySpecs.filter((spec) =>
spec.commandNames.every((name) => visibleCommandNames.has(name)),
);
// Descriptor metadata and import specs stay separate so help can stay cheap.
return buildCommandGroupEntries(descriptors, visibleEntrySpecs, (register) => async (program) => {
await register({ program, ctx, argv });
});
}
export function getCoreCliCommandNames(): string[] {
+12
View File
@@ -88,6 +88,18 @@ describe("command-registry", () => {
expect(names).toContain("agents");
});
it("only exposes Claws after an explicit process opt-in", () => {
vi.stubEnv("OPENCLAW_EXPERIMENTAL_CLAWS", "");
expect(getCoreCliCommandNames()).not.toContain("claws");
expect(getCoreCliCommandsWithSubcommands()).not.toContain("claws");
vi.stubEnv("OPENCLAW_EXPERIMENTAL_CLAWS", "1");
expect(getCoreCliCommandNames()).toContain("claws");
expect(getCoreCliCommandsWithSubcommands()).toContain("claws");
vi.unstubAllEnvs();
});
it("returns only commands that support subcommands", () => {
const names = getCoreCliCommandsWithSubcommands();
expect(names).toContain("config");
+21 -4
View File
@@ -1,4 +1,5 @@
// Core root-command descriptor catalog used for help placeholders and lazy registration.
import { isExperimentalClawsEnabled } from "../../claws/experimental.js";
import { defineCommandDescriptorCatalog } from "./command-descriptor-utils.js";
import type { NamedCommandDescriptor } from "./command-group-descriptors.js";
@@ -33,6 +34,12 @@ const coreCliCommandCatalog = defineCommandDescriptorCatalog([
"Non-interactive config helpers (get/set/patch/unset/file/schema/validate). Run without subcommand for guided setup.",
hasSubcommands: true,
},
{
name: "claws",
description: "Inspect and preview OpenClaw Claws",
hasSubcommands: true,
parentDefaultHelp: true,
},
{
name: "backup",
description: "Create and verify backup archives and SQLite snapshots",
@@ -124,22 +131,32 @@ const coreCliCommandCatalog = defineCommandDescriptorCatalog([
/** Static root-command descriptors for the core CLI surface. */
export const CORE_CLI_COMMAND_DESCRIPTORS = coreCliCommandCatalog.descriptors;
function visibleCoreCliCommandDescriptors(): ReadonlyArray<CoreCliCommandDescriptor> {
return isExperimentalClawsEnabled()
? CORE_CLI_COMMAND_DESCRIPTORS
: CORE_CLI_COMMAND_DESCRIPTORS.filter((descriptor) => descriptor.name !== "claws");
}
/** Return core root-command descriptors in help/registration order. */
export function getCoreCliCommandDescriptors(): ReadonlyArray<CoreCliCommandDescriptor> {
return coreCliCommandCatalog.getDescriptors();
return visibleCoreCliCommandDescriptors();
}
/** Return names for all core root commands. */
export function getCoreCliCommandNames(): string[] {
return coreCliCommandCatalog.getNames();
return visibleCoreCliCommandDescriptors().map((descriptor) => descriptor.name);
}
/** Return core root commands that own child subcommands. */
export function getCoreCliCommandsWithSubcommands(): string[] {
return coreCliCommandCatalog.getCommandsWithSubcommands();
return visibleCoreCliCommandDescriptors()
.filter((descriptor) => descriptor.hasSubcommands)
.map((descriptor) => descriptor.name);
}
/** Return core root commands whose parent action should default to help. */
export function getCoreCliParentDefaultHelpCommands(): string[] {
return coreCliCommandCatalog.getParentDefaultHelpCommands();
return visibleCoreCliCommandDescriptors()
.filter((descriptor) => descriptor.parentDefaultHelp)
.map((descriptor) => descriptor.name);
}