mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
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:
@@ -43,6 +43,10 @@
|
||||
"source": "ClawHub",
|
||||
"target": "ClawHub"
|
||||
},
|
||||
{
|
||||
"source": "Claws",
|
||||
"target": "Claws"
|
||||
},
|
||||
{
|
||||
"source": "ClawRouter (managed multi-provider routing)",
|
||||
"target": "ClawRouter(托管式多提供商路由)"
|
||||
|
||||
@@ -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)
|
||||
@@ -1801,6 +1801,7 @@
|
||||
"pages": [
|
||||
"cli/agent",
|
||||
"cli/agents",
|
||||
"cli/claws",
|
||||
"cli/audit",
|
||||
"cli/hooks",
|
||||
"cli/infer",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Incident Response
|
||||
|
||||
Prioritize accuracy, urgency, and clear ownership during incident response.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 ?? [],
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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: [] };
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const MAX_MANAGED_FILE_BYTES = 1024 * 1024;
|
||||
export const MAX_MANAGED_WORKSPACE_BYTES = 4 * MAX_MANAGED_FILE_BYTES;
|
||||
@@ -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
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user