diff --git a/docs/.i18n/glossary.zh-CN.json b/docs/.i18n/glossary.zh-CN.json index c946bf68e15c..e61bcc9fe83f 100644 --- a/docs/.i18n/glossary.zh-CN.json +++ b/docs/.i18n/glossary.zh-CN.json @@ -43,6 +43,10 @@ "source": "ClawHub", "target": "ClawHub" }, + { + "source": "Claws", + "target": "Claws" + }, { "source": "ClawRouter (managed multi-provider routing)", "target": "ClawRouter(托管式多提供商路由)" diff --git a/docs/cli/claws.md b/docs/cli/claws.md new file mode 100644 index 000000000000..32b9063c640d --- /dev/null +++ b/docs/cli/claws.md @@ -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 ` | Validate a package directory or JSON manifest. | +| `claws add ` | 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) diff --git a/docs/docs.json b/docs/docs.json index 28f6fc30c415..8e0b50865071 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1801,6 +1801,7 @@ "pages": [ "cli/agent", "cli/agents", + "cli/claws", "cli/audit", "cli/hooks", "cli/infer", diff --git a/docs/docs_map.md b/docs/docs_map.md index d68b7e9397c8..16f675b6606d 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -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 diff --git a/src/claws/experimental.test.ts b/src/claws/experimental.test.ts new file mode 100644 index 000000000000..ab1b1e93f0f5 --- /dev/null +++ b/src/claws/experimental.test.ts @@ -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"); + }); +}); diff --git a/src/claws/experimental.ts b/src/claws/experimental.ts new file mode 100644 index 000000000000..a08e53b292a8 --- /dev/null +++ b/src/claws/experimental.ts @@ -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.`, + ); +} diff --git a/src/claws/fixtures/incident-response.claw.json b/src/claws/fixtures/incident-response.claw.json new file mode 100644 index 000000000000..4cc72abdc53c --- /dev/null +++ b/src/claws/fixtures/incident-response.claw.json @@ -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" + } + } + ] +} diff --git a/src/claws/fixtures/workspace/HEARTBEAT.md b/src/claws/fixtures/workspace/HEARTBEAT.md new file mode 100644 index 000000000000..022769cea551 --- /dev/null +++ b/src/claws/fixtures/workspace/HEARTBEAT.md @@ -0,0 +1,3 @@ +# Incident Heartbeat + +Check active incidents, unresolved owners, and overdue status updates. diff --git a/src/claws/fixtures/workspace/SOUL.md b/src/claws/fixtures/workspace/SOUL.md new file mode 100644 index 000000000000..7f55f3a2b86d --- /dev/null +++ b/src/claws/fixtures/workspace/SOUL.md @@ -0,0 +1,3 @@ +# Incident Response + +Prioritize accuracy, urgency, and clear ownership during incident response. diff --git a/src/claws/lifecycle.e2e.test.ts b/src/claws/lifecycle.e2e.test.ts new file mode 100644 index 000000000000..b3218446397d --- /dev/null +++ b/src/claws/lifecycle.e2e.test.ts @@ -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"); + }); +}); diff --git a/src/claws/lifecycle.ts b/src/claws/lifecycle.ts new file mode 100644 index 000000000000..0988a1465404 --- /dev/null +++ b/src/claws/lifecycle.ts @@ -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 { + return { + ...change, + classification: "escalation", + requiresDistinctConsent: true, + digest: `sha256:${createHash("sha256").update(stableStringify(change.effect)).digest("hex")}`, + }; +} + +type ClawAddPlanContext = { + agentId?: string; + workspace?: string; + existingAgentIds?: Iterable; + existingWorkspacePaths?: Iterable; + existingMcpServerNames?: Iterable; + existingCronJobIds?: Iterable; +}; + +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 { + 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 ?? [], + }; +} diff --git a/src/claws/reader.ts b/src/claws/reader.ts new file mode 100644 index 000000000000..b5df3401d46e --- /dev/null +++ b/src/claws/reader.ts @@ -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 & { + 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 { + 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, + 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; + const openclaw = record.openclaw; + if (!openclaw || typeof openclaw !== "object" || Array.isArray(openclaw)) { + return undefined; + } + const claw = (openclaw as Record).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 { + 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, + }; +} diff --git a/src/claws/schema-conformance.test.ts b/src/claws/schema-conformance.test.ts new file mode 100644 index 000000000000..c16ee32d1af4 --- /dev/null +++ b/src/claws/schema-conformance.test.ts @@ -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); + }); +}); diff --git a/src/claws/schema-portability.ts b/src/claws/schema-portability.ts new file mode 100644 index 000000000000..087a2264f57a --- /dev/null +++ b/src/claws/schema-portability.ts @@ -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, 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)); +} diff --git a/src/claws/schema.test.ts b/src/claws/schema.test.ts new file mode 100644 index 000000000000..453404026fc5 --- /dev/null +++ b/src/claws/schema.test.ts @@ -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); + }); +}); diff --git a/src/claws/schema.ts b/src/claws/schema.ts new file mode 100644 index 000000000000..74feaecbc16c --- /dev/null +++ b/src/claws/schema.ts @@ -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 + >, + ) + .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(); + 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(); + 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(); + 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(); + 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: [] }; +} diff --git a/src/claws/source-limits.ts b/src/claws/source-limits.ts new file mode 100644 index 000000000000..3ade59544d88 --- /dev/null +++ b/src/claws/source-limits.ts @@ -0,0 +1,2 @@ +export const MAX_MANAGED_FILE_BYTES = 1024 * 1024; +export const MAX_MANAGED_WORKSPACE_BYTES = 4 * MAX_MANAGED_FILE_BYTES; diff --git a/src/claws/types.ts b/src/claws/types.ts new file mode 100644 index 000000000000..af5caeb24a00 --- /dev/null +++ b/src/claws/types.ts @@ -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>; + 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; +}; + +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; + 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; + 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; + 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[]; +}; diff --git a/src/cli/argv.ts b/src/cli/argv.ts index 0e1412a5db9a..4c1bd6e2313c 100644 --- a/src/cli/argv.ts +++ b/src/cli/argv.ts @@ -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 = new Set( ROOT_COMMAND_DESCRIPTORS.map((descriptor) => descriptor.name), ); diff --git a/src/cli/claws-cli.runtime.ts b/src/cli/claws-cli.runtime.ts new file mode 100644 index 000000000000..e4062b6848e9 --- /dev/null +++ b/src/cli/claws-cli.runtime.ts @@ -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 { + 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 { + 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); + } +} diff --git a/src/cli/claws-cli.test.ts b/src/cli/claws-cli.test.ts new file mode 100644 index 000000000000..91dc76d7204f --- /dev/null +++ b/src/cli/claws-cli.test.ts @@ -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>(() => ({})) }; +}); + +vi.mock("../runtime.js", async () => ({ + ...(await vi.importActual("../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("../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 { + 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); + }); +}); diff --git a/src/cli/claws-cli.ts b/src/cli/claws-cli.ts new file mode 100644 index 000000000000..ae8de7907814 --- /dev/null +++ b/src/cli/claws-cli.ts @@ -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("", "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("", "Path to a Claw package directory or grouped manifest") + .option("--dry-run", "Preview all actions without mutating state", false) + .option("--agent-id ", "Override the requested id with an unused local agent id") + .option("--workspace ", "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); +} diff --git a/src/cli/program/command-registry-core.ts b/src/cli/program/command-registry-core.ts index d0564f20169f..1ba9487f579d 100644 --- a/src/cli/program/command-registry-core.ts +++ b/src/cli/program/command-registry-core.ts @@ -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[] { diff --git a/src/cli/program/command-registry.test.ts b/src/cli/program/command-registry.test.ts index c4f36a4d1d62..955cefa10095 100644 --- a/src/cli/program/command-registry.test.ts +++ b/src/cli/program/command-registry.test.ts @@ -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"); diff --git a/src/cli/program/core-command-descriptors.ts b/src/cli/program/core-command-descriptors.ts index 809589bd70cb..81277cd6dc26 100644 --- a/src/cli/program/core-command-descriptors.ts +++ b/src/cli/program/core-command-descriptors.ts @@ -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 { + 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 { - 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); }