From fad7a023d860a5911ca487bd0714dec3308d6194 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Mon, 15 Jun 2026 03:44:34 +0000 Subject: [PATCH 01/13] feat(onboard): add agents.yaml declarative manifest Signed-off-by: Tinson Lai --- docs/index.yml | 3 + .../inference/declarative-agents-manifest.mdx | 154 +++++++++ scripts/generate-openclaw-config.mts | 314 +++++++++++++++--- src/lib/onboard/agents-manifest.test.ts | 185 +++++++++++ src/lib/onboard/agents-manifest.ts | 119 +++++++ src/lib/onboard/command-support.ts | 9 +- .../dockerfile-patch-extra-agents.test.ts | 2 +- src/lib/onboard/legacy-command.test.ts | 101 ++++++ src/lib/onboard/legacy-command.ts | 35 +- test/generate-openclaw-config.test.ts | 251 ++++++++++++-- 10 files changed, 1096 insertions(+), 77 deletions(-) create mode 100644 docs/inference/declarative-agents-manifest.mdx create mode 100644 src/lib/onboard/agents-manifest.test.ts create mode 100644 src/lib/onboard/agents-manifest.ts diff --git a/docs/index.yml b/docs/index.yml index 703e37c360b..e9a81cd6c14 100644 --- a/docs/index.yml +++ b/docs/index.yml @@ -66,6 +66,9 @@ navigation: - page: "Set Up Task-Specific Sub-Agents" path: inference/set-up-sub-agent.mdx slug: set-up-sub-agent + - page: "Declarative Multi-Agent Manifest" + path: inference/declarative-agents-manifest.mdx + slug: declarative-agents-manifest - section: "Manage Sandboxes" slug: manage-sandboxes collapsed: open-by-default diff --git a/docs/inference/declarative-agents-manifest.mdx b/docs/inference/declarative-agents-manifest.mdx new file mode 100644 index 00000000000..34c82b7fab9 --- /dev/null +++ b/docs/inference/declarative-agents-manifest.mdx @@ -0,0 +1,154 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +title: "Declarative Multi-Agent Manifest" +sidebar-title: "Declarative Multi-Agent Manifest" +description: "Bake secondary OpenClaw agents into a NemoClaw sandbox image from a checked-in agents.yaml manifest, including per-agent models and OpenClaw-native sub-agent delegation." +description-agent: "Documents the `nemoclaw onboard --agents ` flag and the YAML schema it consumes. Use when users ask how to declare a manager-worker layout, how to give a secondary agent its own model, or how to express OpenClaw's `subagents.allowAgents` from NemoClaw." +keywords: + - "nemoclaw agents.yaml" + - "declarative agents" + - "agents.list bake" + - "manager worker agents" + - "subagents allowAgents" + - "per-agent model" +content: + type: "how_to" +skill: + priority: 30 +--- + +NemoClaw can bake a multi-agent OpenClaw layout into a sandbox image from a single checked-in manifest. +Supply the manifest at onboard time with `--agents ` and NemoClaw embeds the resulting `agents.list` entries, per-agent overrides, and `agents.defaults.subagents` block into `openclaw.json` during the image build. + +The schema mirrors OpenClaw's own `agents.list[]` field names, so the same keys you read in [OpenClaw's sub-agents reference](https://docs.openclaw.ai/tools/subagents) appear verbatim in the manifest. + +## When To Use This + +Use `--agents` when: + +- You want a repeatable, GitOps-friendly multi-agent sandbox (manager + workers, or a research / writing split). +- A secondary agent needs its own model (different size, different capability profile). +- You want OpenClaw's `sessions_spawn` validator to enforce a fixed spawn allowlist, not the broad default. + +For a single primary agent on the configured inference route, no manifest is required — the canonical `main` agent is always baked in as the default. + +## Invocation + +```bash +nemoclaw onboard --agents ./agents.yaml --name my-assistant +``` + +NemoClaw reads the manifest on the host, sets `NEMOCLAW_EXTRA_AGENTS_JSON` for the Dockerfile patcher, and the build-time validator in `scripts/generate-openclaw-config.mts` is the single source of truth for structured errors. +A malformed manifest fails the image build with a clear error message. + +## Manifest Shape + +```yaml +defaults: + subagents: + maxSpawnDepth: 2 # optional; OpenClaw allows 1..5 + +main: # optional augments to the canonical "main" agent + tools: + profile: minimal + allow: [read] + subagents: + allowAgents: [logs-reader, writer] + delegationMode: prefer + requireAgentId: true + +agents: # required when secondary agents are needed + - id: logs-reader + description: "Reads sandbox logs" + model: nvidia/nemotron-3-nano-30b + tools: + allow: [read, exec] + subagents: + requireAgentId: true + + - id: writer + model: nvidia/nemotron-3-super-120b-a12b + tools: + allow: [read, write] +``` + +### Top-Level Fields + +| Field | Purpose | Bakes Into | +|---|---|---| +| `defaults.subagents.maxSpawnDepth` | Maximum nesting depth for sub-agent spawning. Integer 1..5. | `agents.defaults.subagents.maxSpawnDepth` | +| `main.tools` | Per-agent tool policy for the canonical `main` agent. | `agents.list[id=main].tools` | +| `main.subagents` | Sub-agent delegation policy for `main`. Same shape as a secondary agent's `subagents` block. | `agents.list[id=main].subagents` | +| `agents[]` | Secondary agents to append after `main` in `agents.list`. | `agents.list[]` | + +The `main` agent is always written first into `agents.list` with `default: true`. +Operators cannot set `default: true` on a secondary agent and cannot rename the primary slot. + +### Per-Agent Fields + +| Field | Required | Purpose | +|---|---|---| +| `id` | yes | Lowercase alphanumeric + `_`/`-`, 1-32 chars, must start with a letter. Cannot be `main`. | +| `workspace` | auto-filled | Defaults to `/sandbox/.openclaw/workspace-`. Must match the canonical sandbox layout if supplied. | +| `agentDir` | auto-filled | Defaults to `/sandbox/.openclaw/agents/`. Must match the canonical sandbox layout if supplied. | +| `tools` | yes | `{profile?, allow?, deny?}`. Must declare a non-empty `allow[]` or `deny[]` — secondary agents inherit no tools by default. | +| `description` | no | Human-readable. Baked verbatim. | +| `model` | no | `provider/model` reference. The provider must match the onboard provider; cross-provider manifests are not supported. | +| `subagents` | no | OpenClaw-native sub-agent delegation policy. See below. | + +### Sub-Agent Delegation Block + +Both `main.subagents` and `agents[].subagents` use the same shape, which mirrors OpenClaw's [`agents.list[].subagents`](https://docs.openclaw.ai/gateway/config-agents). + +| Field | Type | Purpose | +|---|---|---| +| `delegationMode` | `"suggest"` or `"prefer"` | Prompt-only steering for how strongly this agent should delegate. No enforcement. | +| `allowAgents` | `string[]` | Allowlist of agent ids this agent may target via `sessions_spawn`. `["*"]` allows any configured target; omit for self-only. | +| `model` | `provider/model` | Default model for spawned sub-agents. Provider must match the onboard provider. | +| `thinking` | string | Default thinking level for spawned sub-agents. | +| `requireAgentId` | boolean | Force the model to pass `agentId` explicitly to `sessions_spawn` rather than defaulting to self. | + +`maxSpawnDepth` is **not accepted per-agent** — OpenClaw only honours it on `agents.defaults.subagents`, so the manifest exposes it only under the top-level `defaults` block. + +### Multi-Model Sandboxes + +When a secondary agent declares its own `model` (or `subagents.model`), NemoClaw widens the baked `models.providers[].models[]` array with one entry per unique `provider/model` reference. +The base `contextWindow`, `maxTokens`, `reasoning`, and `input` settings from the onboard route apply to each appended entry. +Per-model overrides beyond these defaults are out of scope for v1 — edit the generated `openclaw.json` in-place if you need finer control. + +## Example: Manager-Worker + +```yaml +defaults: + subagents: + maxSpawnDepth: 2 + +main: + subagents: + allowAgents: [logs-reader] + delegationMode: prefer + requireAgentId: true + +agents: + - id: logs-reader + description: "Reads /var/log and surfaces error lines" + tools: + allow: [read] +``` + +What this produces in the baked `openclaw.json`: + +- `agents.list[0]` is `main` with `default: true`, the operator-supplied `tools`/`subagents` merged in. +- `agents.list[1]` is `logs-reader` at the canonical workspace/agentDir paths. +- The primary model stays whatever was selected at onboard. +- `agents.defaults.subagents.maxSpawnDepth` is `2`. +- `sessions_spawn` from `main` resolves to `logs-reader` only. + +## Iterating + +Edit `agents.yaml`, re-run `nemoclaw onboard --agents ./agents.yaml --recreate-sandbox`. +Workspaces under `/sandbox/.openclaw/workspace-` are preserved across rebuilds because the runtime startup script provisions them on first boot rather than baking their contents. + +For ad-hoc per-agent edits inside an existing sandbox (no rebuild), use the in-sandbox CLI: `nemoclaw agents add|delete|list`. +The manifest path is for fixed, checked-in layouts; the CLI passthrough is for interactive work. diff --git a/scripts/generate-openclaw-config.mts b/scripts/generate-openclaw-config.mts index 22e685f7bdb..5da67696485 100755 --- a/scripts/generate-openclaw-config.mts +++ b/scripts/generate-openclaw-config.mts @@ -554,9 +554,26 @@ const ALLOWED_EXTRA_AGENT_KEYS = new Set([ "tools", "subagents", "description", + "model", ]); const ALLOWED_TOOLS_KEYS = new Set(["profile", "allow", "deny"]); -const ALLOWED_SUBAGENTS_KEYS = new Set(["maxSpawnDepth"]); +// Mirrors the OpenClaw per-agent `agents.list[].subagents` zod schema (see +// openclaw/src/config/zod-schema.agent-runtime.ts). OpenClaw uses +// .strict() on that object, so any field we do not list here would be +// rejected by the runtime parser at boot. `maxSpawnDepth` is intentionally +// absent: OpenClaw only accepts it on `agents.defaults.subagents`, never +// per-agent. +const ALLOWED_SUBAGENTS_KEYS = new Set([ + "delegationMode", + "allowAgents", + "model", + "thinking", + "requireAgentId", +]); +const ALLOWED_AGENTS_DEFAULTS_KEYS = new Set(["subagents"]); +const ALLOWED_DEFAULTS_SUBAGENTS_KEYS = new Set(["maxSpawnDepth"]); +const ALLOWED_MAIN_KEYS = new Set(["tools", "subagents"]); +const SUBAGENT_DELEGATION_MODES = new Set(["suggest", "prefer"]); function rejectUnknownKeys(obj: JsonObject, allowed: Set, label: string): void { const unknown = Object.keys(obj).filter((key) => !allowed.has(key)); @@ -607,31 +624,182 @@ function validateExtraAgentTools(entry: JsonObject, label: string): JsonObject { return pickAllowed(tools, ALLOWED_TOOLS_KEYS); } -function validateExtraAgentSubagents(entry: JsonObject, label: string): JsonObject { - const subagents = entry.subagents; - if (!isObject(subagents)) { +function validateModelRef(label: string, raw: unknown, primaryProvider: string): string { + if (typeof raw !== "string" || raw.length === 0) { + throw new Error(`${label} must be a non-empty "provider/model" string when present`); + } + const slash = raw.indexOf("/"); + if (slash <= 0 || slash === raw.length - 1) { + throw new Error(`${label} must be of the form "provider/model", got "${raw}"`); + } + const provider = raw.slice(0, slash); + if (provider !== primaryProvider) { + throw new Error( + `${label} provider "${provider}" must match the onboard provider "${primaryProvider}"; cross-provider manifests are not supported`, + ); + } + return raw; +} + +function validateSubagentsBlock(raw: unknown, label: string, primaryProvider: string): JsonObject { + if (raw === undefined || raw === null) { + return {}; + } + if (!isObject(raw)) { + throw new Error( + `${label} must be an object with any of: ${[...ALLOWED_SUBAGENTS_KEYS].sort().join(", ")}`, + ); + } + if ("maxSpawnDepth" in raw) { + throw new Error( + `${label}.maxSpawnDepth is not accepted per-agent; OpenClaw honours it only on agents.defaults.subagents. Set it under the manifest 'defaults.subagents.maxSpawnDepth' instead.`, + ); + } + rejectUnknownKeys(raw, ALLOWED_SUBAGENTS_KEYS, label); + const out: JsonObject = {}; + if (raw.delegationMode !== undefined) { + if ( + typeof raw.delegationMode !== "string" || + !SUBAGENT_DELEGATION_MODES.has(raw.delegationMode) + ) { + throw new Error( + `${label}.delegationMode must be one of: ${[...SUBAGENT_DELEGATION_MODES].sort().join(", ")}`, + ); + } + out.delegationMode = raw.delegationMode; + } + if (raw.allowAgents !== undefined) { + if ( + !Array.isArray(raw.allowAgents) || + raw.allowAgents.some((token) => typeof token !== "string" || !token) + ) { + throw new Error(`${label}.allowAgents must be an array of non-empty strings when present`); + } + out.allowAgents = [...raw.allowAgents]; + } + if (raw.model !== undefined) { + out.model = validateModelRef(`${label}.model`, raw.model, primaryProvider); + } + if (raw.thinking !== undefined) { + if (typeof raw.thinking !== "string" || !raw.thinking) { + throw new Error(`${label}.thinking must be a non-empty string when present`); + } + out.thinking = raw.thinking; + } + if (raw.requireAgentId !== undefined) { + if (typeof raw.requireAgentId !== "boolean") { + throw new Error(`${label}.requireAgentId must be a boolean when present`); + } + out.requireAgentId = raw.requireAgentId; + } + return out; +} + +function validateAgentsDefaults(raw: unknown): { + subagents: JsonObject; +} { + if (raw === undefined || raw === null) { + return { subagents: {} }; + } + if (!isObject(raw)) { + throw new Error( + `NEMOCLAW_EXTRA_AGENTS_JSON.defaults must be an object (allowed: ${[...ALLOWED_AGENTS_DEFAULTS_KEYS].sort().join(", ")})`, + ); + } + rejectUnknownKeys(raw, ALLOWED_AGENTS_DEFAULTS_KEYS, "NEMOCLAW_EXTRA_AGENTS_JSON.defaults"); + const subagentsRaw = raw.subagents; + if (subagentsRaw === undefined || subagentsRaw === null) { + return { subagents: {} }; + } + if (!isObject(subagentsRaw)) { + throw new Error( + `NEMOCLAW_EXTRA_AGENTS_JSON.defaults.subagents must be an object (allowed: ${[...ALLOWED_DEFAULTS_SUBAGENTS_KEYS].sort().join(", ")})`, + ); + } + rejectUnknownKeys( + subagentsRaw, + ALLOWED_DEFAULTS_SUBAGENTS_KEYS, + "NEMOCLAW_EXTRA_AGENTS_JSON.defaults.subagents", + ); + const out: JsonObject = {}; + if (subagentsRaw.maxSpawnDepth !== undefined) { + const depth = subagentsRaw.maxSpawnDepth; + if (typeof depth !== "number" || !Number.isInteger(depth) || depth < 1 || depth > 5) { + throw new Error( + "NEMOCLAW_EXTRA_AGENTS_JSON.defaults.subagents.maxSpawnDepth must be an integer between 1 and 5 (OpenClaw schema)", + ); + } + out.maxSpawnDepth = depth; + } + return { subagents: out }; +} + +function validateMainOverrides( + raw: unknown, + primaryProvider: string, +): { tools?: JsonObject; subagents?: JsonObject } { + if (raw === undefined || raw === null) { + return {}; + } + if (!isObject(raw)) { throw new Error( - `${label}.subagents must be an object containing maxSpawnDepth. Set maxSpawnDepth: 0 to forbid further spawning.`, + `NEMOCLAW_EXTRA_AGENTS_JSON.main must be an object (allowed: ${[...ALLOWED_MAIN_KEYS].sort().join(", ")})`, ); } - rejectUnknownKeys(subagents, ALLOWED_SUBAGENTS_KEYS, `${label}.subagents`); - const depth = subagents.maxSpawnDepth; - if (typeof depth !== "number" || !Number.isInteger(depth) || depth < 0) { - throw new Error(`${label}.subagents.maxSpawnDepth must be a non-negative integer.`); + rejectUnknownKeys(raw, ALLOWED_MAIN_KEYS, "NEMOCLAW_EXTRA_AGENTS_JSON.main"); + const out: { tools?: JsonObject; subagents?: JsonObject } = {}; + if (raw.tools !== undefined) { + out.tools = validateExtraAgentTools({ tools: raw.tools }, "NEMOCLAW_EXTRA_AGENTS_JSON.main"); } - return pickAllowed(subagents, ALLOWED_SUBAGENTS_KEYS); + if (raw.subagents !== undefined) { + const subagents = validateSubagentsBlock( + raw.subagents, + "NEMOCLAW_EXTRA_AGENTS_JSON.main.subagents", + primaryProvider, + ); + if (Object.keys(subagents).length > 0) { + out.subagents = subagents; + } + } + return out; } -function validateExtraAgents(value: unknown): JsonObject[] { +export type ExtraAgentsPayload = { + agents: JsonObject[]; + defaults: { subagents: JsonObject }; + main: { tools?: JsonObject; subagents?: JsonObject }; +}; + +function validateExtraAgents(value: unknown, primaryProvider: string): ExtraAgentsPayload { if (value === null || value === undefined) { - return []; + return { agents: [], defaults: { subagents: {} }, main: {} }; + } + let agentsRaw: unknown; + let defaultsRaw: unknown; + let mainRaw: unknown; + if (Array.isArray(value)) { + // Legacy payload shape: bare array of secondary agents. + agentsRaw = value; + } else if (isObject(value)) { + rejectUnknownKeys( + value, + new Set(["agents", "defaults", "main"]), + "NEMOCLAW_EXTRA_AGENTS_JSON", + ); + agentsRaw = value.agents ?? []; + defaultsRaw = value.defaults; + mainRaw = value.main; + } else { + throw new Error( + "NEMOCLAW_EXTRA_AGENTS_JSON must decode to a JSON array of agent objects or an object with {agents,defaults?,main?}", + ); } - if (!Array.isArray(value)) { - throw new Error("NEMOCLAW_EXTRA_AGENTS_JSON must decode to a JSON array of agent objects"); + if (!Array.isArray(agentsRaw)) { + throw new Error("NEMOCLAW_EXTRA_AGENTS_JSON.agents must be a JSON array of agent objects"); } const seenIds = new Set([MAIN_AGENT_ID]); - return value.map((entry, index) => { - const label = `NEMOCLAW_EXTRA_AGENTS_JSON[${index}]`; + const agents = agentsRaw.map((entry, index) => { + const label = `NEMOCLAW_EXTRA_AGENTS_JSON.agents[${index}]`; if (!isObject(entry)) { throw new Error(`${label} must be a JSON object`); } @@ -674,7 +842,11 @@ function validateExtraAgents(value: unknown): JsonObject[] { } rejectUnknownKeys(entry, ALLOWED_EXTRA_AGENT_KEYS, label); const tools = validateExtraAgentTools(entry, label); - const subagents = validateExtraAgentSubagents(entry, label); + const subagents = validateSubagentsBlock( + entry.subagents, + `${label}.subagents`, + primaryProvider, + ); // Build the canonical entry from a fresh object, never from the raw // operator input. This guarantees: // - workspace/agentDir are the canonical strings (a dot-segment-laden @@ -686,17 +858,37 @@ function validateExtraAgents(value: unknown): JsonObject[] { workspace: canonicalPaths.workspace, agentDir: canonicalPaths.agentDir, tools, - subagents, }; + if (Object.keys(subagents).length > 0) { + canonical.subagents = subagents; + } if (typeof entry.description === "string") { canonical.description = entry.description; } + if (entry.model !== undefined) { + canonical.model = validateModelRef(`${label}.model`, entry.model, primaryProvider); + } return canonical; }); + return { + agents, + defaults: validateAgentsDefaults(defaultsRaw), + main: validateMainOverrides(mainRaw, primaryProvider), + }; } -function buildAgentsList(extras: JsonObject[]): JsonObject[] { - return [{ ...MAIN_AGENT_ENTRY }, ...extras]; +function buildAgentsList( + extras: JsonObject[], + mainOverrides: { tools?: JsonObject; subagents?: JsonObject }, +): JsonObject[] { + const main: JsonObject = { ...MAIN_AGENT_ENTRY }; + if (mainOverrides.tools !== undefined) { + main.tools = mainOverrides.tools; + } + if (mainOverrides.subagents !== undefined) { + main.subagents = mainOverrides.subagents; + } + return [main, ...extras]; } function applyOpenClawSetupEffects( @@ -803,9 +995,11 @@ export function buildConfig(env: Env = process.env): JsonObject { const inferenceCompat = coerceCompatDict( decodeJsonEnv(env, "NEMOCLAW_INFERENCE_COMPAT_B64", "e30="), ); - const extraAgents = validateExtraAgents( + const extraAgentsPayload = validateExtraAgents( decodeJsonEnv(env, "NEMOCLAW_EXTRA_AGENTS_JSON_B64", "W10="), + providerKey, ); + const extraAgents = extraAgentsPayload.agents; const openclawPlugins: JsonObject[] = []; const openclawPluginIds = new Set(); const openclawToolOverrides: JsonObject = {}; @@ -838,28 +1032,64 @@ export function buildConfig(env: Env = process.env): JsonObject { const disableDeviceAuth = env.NEMOCLAW_DISABLE_DEVICE_AUTH === "1" || isRemote; const allowInsecure = parsed.scheme === "http"; + const providerModels: JsonObject[] = [ + { + ...(Object.keys(inferenceCompat).length > 0 ? { compat: inferenceCompat } : {}), + id: model, + name: primaryModelRef, + reasoning, + input: inferenceInputs, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow, + maxTokens, + }, + ]; + const seenModelRefs = new Set([primaryModelRef]); + const referencedRefs: string[] = []; + const collectRef = (ref: unknown): void => { + if (typeof ref !== "string" || !ref) return; + if (seenModelRefs.has(ref)) return; + seenModelRefs.add(ref); + referencedRefs.push(ref); + }; + for (const agent of extraAgents) { + collectRef(agent.model); + if (isObject(agent.subagents)) { + collectRef(agent.subagents.model); + } + } + if (extraAgentsPayload.main.subagents !== undefined) { + collectRef(extraAgentsPayload.main.subagents.model); + } + for (const ref of referencedRefs) { + const slash = ref.indexOf("/"); + const secondaryModelId = ref.slice(slash + 1); + providerModels.push({ + id: secondaryModelId, + name: ref, + reasoning, + input: inferenceInputs, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow, + maxTokens, + }); + } const providers = { [providerKey]: { baseUrl: inferenceBaseUrl, apiKey: "unused", api: inferenceApi, - models: [ - { - ...(Object.keys(inferenceCompat).length > 0 ? { compat: inferenceCompat } : {}), - id: model, - name: primaryModelRef, - reasoning, - input: inferenceInputs, - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow, - maxTokens, - }, - ], + models: providerModels, }, }; @@ -910,9 +1140,15 @@ export function buildConfig(env: Env = process.env): JsonObject { skipBootstrap: true, thinkingDefault: "off", }; + if (Object.keys(extraAgentsPayload.defaults.subagents).length > 0) { + agentDefaults.subagents = extraAgentsPayload.defaults.subagents; + } const config: JsonObject = { - agents: { defaults: agentDefaults, list: buildAgentsList(extraAgents) }, + agents: { + defaults: agentDefaults, + list: buildAgentsList(extraAgents, extraAgentsPayload.main), + }, models: { mode: "merge", providers }, channels: { defaults: {} }, tools: openclawTools, diff --git a/src/lib/onboard/agents-manifest.test.ts b/src/lib/onboard/agents-manifest.test.ts new file mode 100644 index 00000000000..4895bcb99b4 --- /dev/null +++ b/src/lib/onboard/agents-manifest.test.ts @@ -0,0 +1,185 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { applyAgentsManifestEnv, loadAgentsManifest } from "./agents-manifest"; + +let tmpDir: string; + +function manifestPath(name: string, content: string): string { + const file = path.join(tmpDir, name); + fs.writeFileSync(file, content, "utf-8"); + return file; +} + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-agents-manifest-")); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe("loadAgentsManifest", () => { + it("returns an empty agents list for an empty file", () => { + const file = manifestPath("empty.yaml", ""); + expect(loadAgentsManifest(file)).toEqual({ agents: [] }); + }); + + it("parses the proposed manager-worker example shape", () => { + const file = manifestPath( + "manager-worker.yaml", + [ + "agents:", + " - id: manager", + " model: test-provider/nemotron-super", + " tools:", + " allow: [read]", + " subagents:", + " allowAgents: [logs-reader]", + " delegationMode: prefer", + " requireAgentId: true", + " - id: logs-reader", + " model: test-provider/nemotron-nano", + " tools:", + " allow: [kubectl]", + "", + ].join("\n"), + ); + const payload = loadAgentsManifest(file); + expect(payload.agents).toHaveLength(2); + expect(payload.agents[0]).toMatchObject({ + id: "manager", + workspace: "/sandbox/.openclaw/workspace-manager", + agentDir: "/sandbox/.openclaw/agents/manager", + model: "test-provider/nemotron-super", + subagents: { + allowAgents: ["logs-reader"], + delegationMode: "prefer", + requireAgentId: true, + }, + }); + expect(payload.agents[1]).toMatchObject({ + id: "logs-reader", + workspace: "/sandbox/.openclaw/workspace-logs-reader", + agentDir: "/sandbox/.openclaw/agents/logs-reader", + }); + }); + + it("preserves operator-supplied workspace/agentDir without overwriting", () => { + const file = manifestPath( + "explicit-paths.yaml", + [ + "agents:", + " - id: alpha", + " workspace: /sandbox/.openclaw/workspace-alpha", + " agentDir: /sandbox/.openclaw/agents/alpha", + " tools:", + " allow: [read]", + "", + ].join("\n"), + ); + const payload = loadAgentsManifest(file); + expect(payload.agents[0]).toMatchObject({ + id: "alpha", + workspace: "/sandbox/.openclaw/workspace-alpha", + agentDir: "/sandbox/.openclaw/agents/alpha", + }); + }); + + it("passes through defaults and main blocks unchanged", () => { + const file = manifestPath( + "with-defaults-main.yaml", + [ + "defaults:", + " subagents:", + " maxSpawnDepth: 3", + "main:", + " subagents:", + " allowAgents: [alpha]", + " delegationMode: prefer", + "agents:", + " - id: alpha", + " tools:", + " allow: [read]", + "", + ].join("\n"), + ); + const payload = loadAgentsManifest(file); + expect(payload.defaults).toEqual({ subagents: { maxSpawnDepth: 3 } }); + expect(payload.main).toEqual({ + subagents: { allowAgents: ["alpha"], delegationMode: "prefer" }, + }); + }); + + it("rejects unknown top-level keys", () => { + const file = manifestPath("rogue.yaml", "rogue: true\nagents: []\n"); + expect(() => loadAgentsManifest(file)).toThrow( + /agents manifest contains unsupported top-level field "rogue"/, + ); + }); + + it("rejects a list at the top level", () => { + const file = manifestPath("list.yaml", "- id: alpha\n"); + expect(() => loadAgentsManifest(file)).toThrow( + /must be a YAML mapping \(object\) at the top level/, + ); + }); + + it("rejects a non-list agents field", () => { + const file = manifestPath("scalar-agents.yaml", "agents: not-a-list\n"); + expect(() => loadAgentsManifest(file)).toThrow(/'agents' must be a list/); + }); + + it("surfaces YAML parse errors with the underlying reason", () => { + const file = manifestPath("broken.yaml", "agents:\n - id: alpha\n tools: [\n"); + expect(() => loadAgentsManifest(file)).toThrow(/--agents YAML parse error:/); + }); + + it("reports a clear error when the path does not exist", () => { + const missing = path.join(tmpDir, "does-not-exist.yaml"); + expect(() => loadAgentsManifest(missing)).toThrow(/path not found:/); + }); + + it("reports a clear error when the path is a directory", () => { + const dir = path.join(tmpDir, "child-dir"); + fs.mkdirSync(dir); + expect(() => loadAgentsManifest(dir)).toThrow(/must point to a file:/); + }); +}); + +describe("applyAgentsManifestEnv", () => { + const previous = process.env.NEMOCLAW_EXTRA_AGENTS_JSON; + beforeEach(() => { + delete process.env.NEMOCLAW_EXTRA_AGENTS_JSON; + }); + afterEach(() => { + if (previous === undefined) { + delete process.env.NEMOCLAW_EXTRA_AGENTS_JSON; + } else { + process.env.NEMOCLAW_EXTRA_AGENTS_JSON = previous; + } + }); + + it("sets NEMOCLAW_EXTRA_AGENTS_JSON to the parsed payload", () => { + const file = manifestPath( + "set-env.yaml", + ["agents:", " - id: alpha", " tools:", " allow: [read]", ""].join("\n"), + ); + const returned = applyAgentsManifestEnv(file); + expect(returned.agents).toHaveLength(1); + const raw = process.env.NEMOCLAW_EXTRA_AGENTS_JSON; + expect(typeof raw).toBe("string"); + const decoded = JSON.parse(raw as string); + expect(decoded.agents[0]).toMatchObject({ + id: "alpha", + workspace: "/sandbox/.openclaw/workspace-alpha", + agentDir: "/sandbox/.openclaw/agents/alpha", + }); + }); +}); diff --git a/src/lib/onboard/agents-manifest.ts b/src/lib/onboard/agents-manifest.ts new file mode 100644 index 00000000000..5fd111f4f63 --- /dev/null +++ b/src/lib/onboard/agents-manifest.ts @@ -0,0 +1,119 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import path from "node:path"; + +// Load YAML lazily via require to match the rest of the onboard pipeline +// (see src/lib/sandbox/config.ts and src/lib/policy/index.ts). Importing +// statically would force `yaml` into the CLI cold-start path even when no +// agents manifest is supplied. +type YamlLoader = { parse(input: string): unknown }; +function loadYaml(): YamlLoader { + return require("yaml") as YamlLoader; +} + +const ALLOWED_TOP_KEYS = new Set(["agents", "defaults", "main"]); +const AGENT_DATA_ROOT = "/sandbox/.openclaw"; + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function expectedAgentPath(kind: "workspace" | "agentDir", id: string): string { + const segment = kind === "workspace" ? `workspace-${id}` : `agents/${id}`; + return `${AGENT_DATA_ROOT}/${segment}`; +} + +function fillAgentDefaults(entry: Record): Record { + const id = entry.id; + if (typeof id !== "string" || !id) { + return entry; + } + const out: Record = { ...entry }; + if (out.workspace === undefined) { + out.workspace = expectedAgentPath("workspace", id); + } + if (out.agentDir === undefined) { + out.agentDir = expectedAgentPath("agentDir", id); + } + return out; +} + +export interface AgentsManifestPayload { + agents: unknown[]; + defaults?: unknown; + main?: unknown; +} + +/** + * Load and shallow-shape-check the agents manifest YAML. Heavy validation + * (shape of each agent entry, model-ref/provider match, allowlists) lives + * at the build-time validator in scripts/generate-openclaw-config.mts so + * the build is the single source of truth for structured errors. We only + * surface obvious early errors (missing file, top-level shape) and + * auto-fill canonical workspace/agentDir paths from the agent id so the + * caller can write a terse YAML. + */ +export function loadAgentsManifest(filePath: string): AgentsManifestPayload { + const resolved = path.resolve(filePath); + if (!fs.existsSync(resolved)) { + throw new Error(`--agents path not found: ${resolved}`); + } + const stat = fs.statSync(resolved); + if (!stat.isFile()) { + throw new Error(`--agents must point to a file: ${resolved}`); + } + const raw = fs.readFileSync(resolved, "utf-8"); + let parsed: unknown; + try { + parsed = loadYaml().parse(raw); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + throw new Error(`--agents YAML parse error: ${reason}`); + } + if (parsed === null || parsed === undefined) { + return { agents: [] }; + } + if (!isObject(parsed)) { + throw new Error("agents manifest must be a YAML mapping (object) at the top level"); + } + for (const key of Object.keys(parsed)) { + if (!ALLOWED_TOP_KEYS.has(key)) { + const allowed = [...ALLOWED_TOP_KEYS].sort().join(", "); + throw new Error( + `agents manifest contains unsupported top-level field "${key}". Allowed: ${allowed}`, + ); + } + } + const agentsRaw = parsed.agents; + let agents: unknown[]; + if (agentsRaw === undefined || agentsRaw === null) { + agents = []; + } else if (!Array.isArray(agentsRaw)) { + throw new Error("agents manifest 'agents' must be a list when present"); + } else { + agents = agentsRaw.map((entry) => (isObject(entry) ? fillAgentDefaults(entry) : entry)); + } + const out: AgentsManifestPayload = { agents }; + if (parsed.defaults !== undefined) { + out.defaults = parsed.defaults; + } + if (parsed.main !== undefined) { + out.main = parsed.main; + } + return out; +} + +/** + * Read the manifest at `filePath` and set `NEMOCLAW_EXTRA_AGENTS_JSON` so + * the downstream Dockerfile patcher can base64-encode and bake it. The + * patcher does not parse or shape-check the payload (that is the build + * validator's job), so structured errors raised here would mask the + * authoritative build-time errors; we keep host-side checks light. + */ +export function applyAgentsManifestEnv(filePath: string): AgentsManifestPayload { + const payload = loadAgentsManifest(filePath); + process.env.NEMOCLAW_EXTRA_AGENTS_JSON = JSON.stringify(payload); + return payload; +} diff --git a/src/lib/onboard/command-support.ts b/src/lib/onboard/command-support.ts index ea178179115..619a7e0a6d2 100644 --- a/src/lib/onboard/command-support.ts +++ b/src/lib/onboard/command-support.ts @@ -8,7 +8,7 @@ import { NOTICE_ACCEPT_FLAG } from "./usage-notice"; const acceptFlagName = NOTICE_ACCEPT_FLAG.replace(/^--/, ""); export const onboardUsage = [ - `onboard [--non-interactive] [--resume | --fresh] [--recreate-sandbox] [--gpu | --no-gpu] [--from ] [--name ] [--sandbox-gpu | --no-sandbox-gpu] [--sandbox-gpu-device ] [--agent ] [--control-ui-port ] [--yes | -y] [--no-ollama-autostart] [${NOTICE_ACCEPT_FLAG}]`, + `onboard [--non-interactive] [--resume | --fresh] [--recreate-sandbox] [--gpu | --no-gpu] [--from ] [--name ] [--sandbox-gpu | --no-sandbox-gpu] [--sandbox-gpu-device ] [--agent ] [--agents ] [--control-ui-port ] [--yes | -y] [--no-ollama-autostart] [${NOTICE_ACCEPT_FLAG}]`, ]; export const onboardExamples = [ @@ -17,6 +17,7 @@ export const onboardExamples = [ "<%= config.bin %> onboard --resume", "<%= config.bin %> onboard --fresh", "<%= config.bin %> onboard --from ./Dockerfile --name alpha", + "<%= config.bin %> onboard --agents ./agents.yaml", "<%= config.bin %> onboard --sandbox-gpu --sandbox-gpu-device nvidia.com/gpu=0", `<%= config.bin %> onboard --non-interactive --yes --name alpha ${NOTICE_ACCEPT_FLAG}`, ]; @@ -34,6 +35,7 @@ export type OnboardFlags = { "no-sandbox-gpu"?: boolean; "sandbox-gpu-device"?: string; agent?: string; + agents?: string; "control-ui-port"?: number; yes?: boolean; "no-ollama-autostart"?: boolean; @@ -74,6 +76,10 @@ export function buildOnboardFlags(): Record { "OpenShell GPU device selector to pass to sandbox create; requires --sandbox-gpu", }), agent: Flags.string({ description: "Agent runtime to onboard" }), + agents: Flags.string({ + description: + "Path to a YAML manifest declaring secondary OpenClaw agents, agents.defaults, and main-agent overrides; baked into the sandbox image", + }), "control-ui-port": Flags.integer({ description: "Host port for the local control UI", max: 65535, @@ -107,6 +113,7 @@ export function toLegacyOnboardArgs(flags: OnboardFlags): string[] { args.push("--sandbox-gpu-device", flags["sandbox-gpu-device"]); } if (flags.agent !== undefined) args.push("--agent", flags.agent); + if (flags.agents !== undefined) args.push("--agents", flags.agents); if (flags["control-ui-port"] !== undefined) { args.push("--control-ui-port", String(flags["control-ui-port"])); } diff --git a/src/lib/onboard/dockerfile-patch-extra-agents.test.ts b/src/lib/onboard/dockerfile-patch-extra-agents.test.ts index bdcfa82d501..c627ad69fb5 100644 --- a/src/lib/onboard/dockerfile-patch-extra-agents.test.ts +++ b/src/lib/onboard/dockerfile-patch-extra-agents.test.ts @@ -72,7 +72,7 @@ describe("patchStagedDockerfile :: NEMOCLAW_EXTRA_AGENTS_JSON", () => { workspace: "/sandbox/.openclaw/workspace-research", agentDir: "/sandbox/.openclaw/agents/research", tools: { profile: "minimal", allow: ["read"], deny: ["exec"] }, - subagents: { maxSpawnDepth: 0 }, + subagents: { allowAgents: ["analyst"] }, }, ]; withExtraAgentsEnv(JSON.stringify(extras), () => diff --git a/src/lib/onboard/legacy-command.test.ts b/src/lib/onboard/legacy-command.test.ts index b02ee8d7ede..5b8ba83e2f6 100644 --- a/src/lib/onboard/legacy-command.test.ts +++ b/src/lib/onboard/legacy-command.test.ts @@ -45,6 +45,7 @@ describe("onboard command", () => { sandboxGpuDevice: null, acceptThirdPartySoftware: true, agent: null, + agentsManifest: null, controlUiPort: null, gpu: false, noGpu: false, @@ -94,6 +95,7 @@ describe("onboard command", () => { sandboxGpuDevice: null, acceptThirdPartySoftware: true, agent: null, + agentsManifest: null, controlUiPort: null, gpu: false, noGpu: false, @@ -124,6 +126,7 @@ describe("onboard command", () => { sandboxGpuDevice: null, acceptThirdPartySoftware: false, agent: null, + agentsManifest: null, controlUiPort: null, gpu: false, noGpu: false, @@ -153,6 +156,7 @@ describe("onboard command", () => { expect(lines.join("\n")).toContain("node_modules, .git, .venv, __pycache__"); expect(lines.join("\n")).toContain(".env*, .ssh, .aws"); expect(lines.join("\n")).toContain("--agent "); + expect(lines.join("\n")).toContain("--agents "); expect(lines.join("\n")).toContain("--no-gpu"); }); @@ -183,6 +187,7 @@ describe("onboard command", () => { sandboxGpuDevice: null, acceptThirdPartySoftware: false, agent: null, + agentsManifest: null, controlUiPort: null, gpu: false, noGpu: false, @@ -191,6 +196,98 @@ describe("onboard command", () => { }); }); + it("parses --agents into agentsManifest", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-agents-parse-")); + const manifestPath = path.join(tmpDir, "agents.yaml"); + fs.writeFileSync(manifestPath, "agents: []\n"); + + const result = parseOnboardArgs( + ["--agents", manifestPath], + "--yes-i-accept-third-party-software", + "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE", + { + env: {}, + error: () => {}, + exit: exitWithCode, + }, + ); + expect(result.agentsManifest).toBe(manifestPath); + }); + + it("rejects --agents when the file is missing", () => { + const errors: string[] = []; + expect(() => + parseOnboardArgs( + ["--agents", "/nonexistent/agents.yaml"], + "--yes-i-accept-third-party-software", + "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE", + { + env: {}, + error: (message = "") => errors.push(message), + exit: exitWithPrefixedCode, + }, + ), + ).toThrow("exit:1"); + expect(errors.join("\n")).toContain("--agents path not found"); + }); + + it("rejects --agents when the value is missing", () => { + const errors: string[] = []; + expect(() => + parseOnboardArgs( + ["--agents"], + "--yes-i-accept-third-party-software", + "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE", + { + env: {}, + error: (message = "") => errors.push(message), + exit: exitWithPrefixedCode, + }, + ), + ).toThrow("exit:1"); + expect(errors.join("\n")).toContain("--agents requires a path to a YAML manifest"); + }); + + it("sets NEMOCLAW_EXTRA_AGENTS_JSON before invoking runOnboard when --agents is supplied", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-agents-env-")); + const manifestPath = path.join(tmpDir, "agents.yaml"); + fs.writeFileSync( + manifestPath, + ["agents:", " - id: alpha", " tools:", " allow: [read]", ""].join("\n"), + ); + const previous = process.env.NEMOCLAW_EXTRA_AGENTS_JSON; + delete process.env.NEMOCLAW_EXTRA_AGENTS_JSON; + let observedRaw: string | undefined; + const runOnboard = vi.fn(async () => { + observedRaw = process.env.NEMOCLAW_EXTRA_AGENTS_JSON; + }); + try { + await runOnboardCommand({ + args: ["--agents", manifestPath], + noticeAcceptFlag: "--yes-i-accept-third-party-software", + noticeAcceptEnv: "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE", + env: {}, + runOnboard, + error: () => {}, + exit: exitWithCode, + }); + expect(observedRaw).toBeDefined(); + const payload = JSON.parse(observedRaw as string); + expect(payload.agents).toHaveLength(1); + expect(payload.agents[0]).toMatchObject({ + id: "alpha", + workspace: "/sandbox/.openclaw/workspace-alpha", + agentDir: "/sandbox/.openclaw/agents/alpha", + }); + } finally { + if (previous === undefined) { + delete process.env.NEMOCLAW_EXTRA_AGENTS_JSON; + } else { + process.env.NEMOCLAW_EXTRA_AGENTS_JSON = previous; + } + } + }); + it("parses --fresh and surfaces it as fresh=true", () => { expect( parseOnboardArgs( @@ -214,6 +311,7 @@ describe("onboard command", () => { sandboxGpuDevice: null, acceptThirdPartySoftware: false, agent: null, + agentsManifest: null, controlUiPort: null, gpu: false, noGpu: false, @@ -266,6 +364,7 @@ describe("onboard command", () => { sandboxGpuDevice: null, acceptThirdPartySoftware: false, agent: null, + agentsManifest: null, controlUiPort: null, gpu: false, noGpu: false, @@ -407,6 +506,7 @@ describe("onboard command", () => { sandboxGpuDevice: null, acceptThirdPartySoftware: false, agent: "openclaw", + agentsManifest: null, controlUiPort: null, gpu: false, noGpu: false, @@ -580,6 +680,7 @@ describe("onboard command", () => { sandboxGpuDevice: null, acceptThirdPartySoftware: false, agent: null, + agentsManifest: null, controlUiPort: null, gpu: false, noGpu: false, diff --git a/src/lib/onboard/legacy-command.ts b/src/lib/onboard/legacy-command.ts index 00b0d956f43..9a14df51ac5 100644 --- a/src/lib/onboard/legacy-command.ts +++ b/src/lib/onboard/legacy-command.ts @@ -5,6 +5,7 @@ import fs from "node:fs"; import path from "node:path"; import { CLI_NAME } from "../cli/branding"; +import { applyAgentsManifestEnv } from "./agents-manifest"; export interface OnboardCommandOptions { nonInteractive: boolean; @@ -17,6 +18,7 @@ export interface OnboardCommandOptions { sandboxGpuDevice: string | null; acceptThirdPartySoftware: boolean; agent: string | null; + agentsManifest: string | null; controlUiPort: number | null; gpu: boolean; noGpu: boolean; @@ -55,9 +57,10 @@ const ONBOARD_BASE_ARGS = [ function onboardUsageLines(noticeAcceptFlag: string): string[] { const name = CLI_NAME; return [ - ` Usage: ${name} onboard [--non-interactive] [--resume | --fresh] [--recreate-sandbox] [--gpu | --no-gpu] [--from ] [--name ] [--sandbox-gpu | --no-sandbox-gpu] [--sandbox-gpu-device ] [--agent ] [--control-ui-port ] [--yes | -y] [--no-ollama-autostart] [${noticeAcceptFlag}]`, + ` Usage: ${name} onboard [--non-interactive] [--resume | --fresh] [--recreate-sandbox] [--gpu | --no-gpu] [--from ] [--name ] [--sandbox-gpu | --no-sandbox-gpu] [--sandbox-gpu-device ] [--agent ] [--agents ] [--control-ui-port ] [--yes | -y] [--no-ollama-autostart] [${noticeAcceptFlag}]`, "", " --from uses the Dockerfile's parent directory as the Docker build context.", + " --agents declares secondary OpenClaw agents, agents.defaults, and main-agent overrides; the YAML is baked into the sandbox image at build time.", " --no-ollama-autostart skips the wizard's eager Ollama auto-start during inference-provider selection so onboard surfaces the unreachable-Ollama warning and the default fallback model; later setup steps still expect a reachable Ollama, and on Linux hosts with a systemd Ollama unit the loopback-override path may still restart the daemon ahead of this gate.", " --gpu enables direct NVIDIA GPU access inside the sandbox; --no-gpu forces CPU sandbox behavior.", " --sandbox-gpu enables direct NVIDIA GPU access inside the sandbox; --no-sandbox-gpu forces CPU sandbox behavior.", @@ -141,6 +144,32 @@ export function parseOnboardArgs( parsedArgs.splice(agentIdx, 2); } + let agentsManifest: string | null = null; + const agentsIdx = parsedArgs.indexOf("--agents"); + if (agentsIdx !== -1) { + const agentsValue = parsedArgs[agentsIdx + 1]; + if ( + typeof agentsValue !== "string" || + agentsValue.length === 0 || + agentsValue.startsWith("--") + ) { + error(" --agents requires a path to a YAML manifest"); + printOnboardUsage(error, noticeAcceptFlag); + exit(1); + } + const resolved = path.resolve(agentsValue); + if (!fs.existsSync(resolved)) { + error(` --agents path not found: ${resolved}`); + exit(1); + } + if (!fs.statSync(resolved).isFile()) { + error(` --agents must point to a file: ${resolved}`); + exit(1); + } + agentsManifest = resolved; + parsedArgs.splice(agentsIdx, 2); + } + let controlUiPort: number | null = null; const portIdx = parsedArgs.indexOf("--control-ui-port"); if (portIdx !== -1) { @@ -244,6 +273,7 @@ export function parseOnboardArgs( acceptThirdPartySoftware: parsedArgs.includes(noticeAcceptFlag) || String(deps.env[noticeAcceptEnv] || "") === "1", agent, + agentsManifest, controlUiPort, gpu, noGpu, @@ -261,6 +291,9 @@ export async function runOnboardCommand(deps: RunOnboardCommandDeps): Promise { // without "main" present. const TOOLS_OK = { profile: "minimal", allow: ["read"], deny: ["exec"] }; - const SUBAGENTS_OK = { maxSpawnDepth: 0 }; function makeExtra(overrides: Record = {}): Record { return { @@ -884,7 +883,6 @@ describe("generate-openclaw-config.mts: config generation", () => { workspace: "/sandbox/.openclaw/workspace-research", agentDir: "/sandbox/.openclaw/agents/research", tools: TOOLS_OK, - subagents: SUBAGENTS_OK, ...overrides, }; } @@ -1056,27 +1054,28 @@ describe("generate-openclaw-config.mts: config generation", () => { ); }); - it("rejects extras whose subagents.maxSpawnDepth is missing or invalid", () => { - expectBuildConfigError( - { - NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64([makeExtra({ subagents: undefined })]), - }, - /\.subagents must be an object/, - ); + it("treats subagents as optional and omits it when absent or empty", () => { + const config = runConfigScript({ + NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64([makeExtra()]), + }); + expect(config.agents.list[1]).not.toHaveProperty("subagents"); + }); + + it("rejects per-agent subagents.maxSpawnDepth with a migration hint", () => { expectBuildConfigError( { NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64([ - makeExtra({ subagents: { maxSpawnDepth: -1 } }), + makeExtra({ subagents: { maxSpawnDepth: 2 } }), ]), }, - /maxSpawnDepth must be a non-negative integer/, + /maxSpawnDepth is not accepted per-agent.*defaults\.subagents\.maxSpawnDepth/, ); }); - it("rejects extras when the payload is not an array", () => { + it("rejects extras when the payload is neither array nor object", () => { expectBuildConfigError( - { NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64({ id: "research" }) }, - /must decode to a JSON array/, + { NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64("not-a-list") }, + /must decode to a JSON array of agent objects or an object with/, ); }); @@ -1105,25 +1104,12 @@ describe("generate-openclaw-config.mts: config generation", () => { it("rejects extras that smuggle credential-like keys inside subagents", () => { expectBuildConfigError( { - NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64([ - makeExtra({ subagents: { ...SUBAGENTS_OK, token: "x" } }), - ]), + NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64([makeExtra({ subagents: { token: "x" } })]), }, /\.subagents contains unsupported field\(s\): token/, ); }); - it("rejects extras with an operator-supplied model override (currently unsupported)", () => { - expectBuildConfigError( - { - NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64([ - makeExtra({ model: { primary: "evil/model" } }), - ]), - }, - /contains unsupported field\(s\): model/, - ); - }); - it("emits canonical paths for workspace/agentDir even when operator input contains dot segments", () => { // Resolves to the canonical /sandbox/.openclaw/workspace-research path, // but the operator-supplied string is the dot-segment form. The bake @@ -1143,11 +1129,15 @@ describe("generate-openclaw-config.mts: config generation", () => { }); it("strips operator entries to the allowlist when writing agents.list", () => { - // Even if the operator includes an unrecognised but harmless-looking - // field, the validator must drop it before it reaches the baked image. - // (The previous test confirms unknown fields fail; this test guards - // against an allowlist drift where an unknown field is accepted but a - // known one is dropped.) + // The validator must drop unknown keys at every nesting level before + // they reach the baked image. (The previous tests confirm unknown + // fields fail; this test guards against an allowlist drift where an + // unknown field is accepted but a known one is dropped.) + const subagentsInput = { + delegationMode: "prefer", + allowAgents: ["analyst"], + requireAgentId: true, + }; const config = runConfigScript({ NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64([ { @@ -1155,7 +1145,7 @@ describe("generate-openclaw-config.mts: config generation", () => { workspace: "/sandbox/.openclaw/workspace-research", agentDir: "/sandbox/.openclaw/agents/research", tools: TOOLS_OK, - subagents: SUBAGENTS_OK, + subagents: subagentsInput, description: "Researches things", }, ]), @@ -1165,7 +1155,7 @@ describe("generate-openclaw-config.mts: config generation", () => { workspace: "/sandbox/.openclaw/workspace-research", agentDir: "/sandbox/.openclaw/agents/research", tools: TOOLS_OK, - subagents: SUBAGENTS_OK, + subagents: subagentsInput, description: "Researches things", }); }); @@ -1191,6 +1181,197 @@ describe("generate-openclaw-config.mts: config generation", () => { expect(resolved).toBe("main"); }); + // ─── agents-manifest extensions ─────────────────────────────────────────── + // These exercise the v1 `{agents,defaults?,main?}` payload shape that the + // YAML loader emits: per-agent model + subagents OpenClaw-native fields, + // agents.defaults bake, main-agent augmentation, and providers.models + // expansion for unique secondary model refs. + + it("accepts the new payload object shape {agents, defaults?, main?}", () => { + const config = runConfigScript({ + NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64({ + agents: [makeExtra({ id: "research" })], + }), + }); + expect(config.agents.list[1]).toMatchObject({ id: "research" }); + }); + + it("rejects unknown top-level keys in the object payload", () => { + expectBuildConfigError( + { + NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64({ + agents: [], + rogue: "x", + }), + }, + /NEMOCLAW_EXTRA_AGENTS_JSON contains unsupported field\(s\): rogue/, + ); + }); + + it("accepts a same-provider per-agent model and adds it to providers.models[]", () => { + const config = runConfigScript({ + NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64({ + agents: [makeExtra({ model: "test-provider/secondary-1" })], + }), + }); + expect(config.agents.list[1].model).toBe("test-provider/secondary-1"); + const refs = config.models.providers["test-provider"].models.map( + (entry: { name: string }) => entry.name, + ); + expect(refs).toEqual(["test-ref", "test-provider/secondary-1"]); + const secondary = config.models.providers["test-provider"].models[1]; + expect(secondary.id).toBe("secondary-1"); + }); + + it("dedups model refs across multiple agents and the main override", () => { + const config = runConfigScript({ + NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64({ + agents: [ + makeExtra({ id: "research", model: "test-provider/shared-model" }), + makeExtra({ + id: "writing", + workspace: "/sandbox/.openclaw/workspace-writing", + agentDir: "/sandbox/.openclaw/agents/writing", + model: "test-provider/shared-model", + subagents: { model: "test-provider/shared-model" }, + }), + ], + main: { + subagents: { + allowAgents: ["research", "writing"], + model: "test-provider/shared-model", + }, + }, + }), + }); + const refs = config.models.providers["test-provider"].models.map( + (entry: { name: string }) => entry.name, + ); + expect(refs).toEqual(["test-ref", "test-provider/shared-model"]); + }); + + it("rejects a per-agent model whose provider does not match the onboard provider", () => { + expectBuildConfigError( + { + NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64({ + agents: [makeExtra({ model: "other-provider/model-x" })], + }), + }, + /model provider "other-provider" must match the onboard provider "test-provider"/, + ); + }); + + it("rejects per-agent model strings without a provider/model split", () => { + expectBuildConfigError( + { + NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64({ + agents: [makeExtra({ model: "bare-name" })], + }), + }, + /must be of the form "provider\/model"/, + ); + }); + + it("accepts subagents.allowAgents and bakes it verbatim under the agent entry", () => { + const config = runConfigScript({ + NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64({ + agents: [ + makeExtra({ + subagents: { + allowAgents: ["analyst", "writer"], + delegationMode: "prefer", + requireAgentId: true, + }, + }), + ], + }), + }); + expect(config.agents.list[1].subagents).toEqual({ + allowAgents: ["analyst", "writer"], + delegationMode: "prefer", + requireAgentId: true, + }); + }); + + it("rejects subagents.delegationMode values outside the OpenClaw enum", () => { + expectBuildConfigError( + { + NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64({ + agents: [makeExtra({ subagents: { delegationMode: "force" } })], + }), + }, + /delegationMode must be one of: prefer, suggest/, + ); + }); + + it("rejects subagents.allowAgents that is not an array of non-empty strings", () => { + expectBuildConfigError( + { + NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64({ + agents: [makeExtra({ subagents: { allowAgents: ["", "ok"] } })], + }), + }, + /allowAgents must be an array of non-empty strings/, + ); + }); + + it("bakes defaults.subagents.maxSpawnDepth into agents.defaults.subagents", () => { + const config = runConfigScript({ + NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64({ + agents: [], + defaults: { subagents: { maxSpawnDepth: 3 } }, + }), + }); + expect(config.agents.defaults.subagents).toEqual({ maxSpawnDepth: 3 }); + }); + + it("rejects defaults.subagents.maxSpawnDepth outside OpenClaw's 1..5 range", () => { + for (const depth of [0, 6, -1, 1.5]) { + expectBuildConfigError( + { + NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64({ + agents: [], + defaults: { subagents: { maxSpawnDepth: depth } }, + }), + }, + /maxSpawnDepth must be an integer between 1 and 5/, + ); + } + }); + + it("merges main.subagents and main.tools onto the canonical main entry", () => { + const mainTools = { profile: "minimal", allow: ["read", "write"] }; + const mainSubagents = { + allowAgents: ["research"], + delegationMode: "prefer", + requireAgentId: true, + }; + const config = runConfigScript({ + NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64({ + agents: [makeExtra({ id: "research" })], + main: { tools: mainTools, subagents: mainSubagents }, + }), + }); + expect(config.agents.list[0]).toEqual({ + id: "main", + default: true, + tools: mainTools, + subagents: mainSubagents, + }); + }); + + it("rejects main overrides that target fields outside the allowlist", () => { + expectBuildConfigError( + { + NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64({ + agents: [], + main: { workspace: "/sandbox/.openclaw/workspace-main" }, + }), + }, + /NEMOCLAW_EXTRA_AGENTS_JSON\.main contains unsupported field\(s\): workspace/, + ); + }); + it("keeps compatible endpoints on the managed inference.local OpenClaw provider", () => { const config = runConfigScript({ NEMOCLAW_MODEL: "deepseek-ai/DeepSeek-V4-Flash", From 22d8ba01c418b8e18d321b573bde465fd4591f4a Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Mon, 15 Jun 2026 04:33:24 +0000 Subject: [PATCH 02/13] test(generate-openclaw-config): split agents-manifest cases into focused file Signed-off-by: Tinson Lai --- ...te-openclaw-config-agents-manifest.test.ts | 351 ++++++++++++++++++ test/generate-openclaw-config.test.ts | 192 +--------- 2 files changed, 354 insertions(+), 189 deletions(-) create mode 100644 test/generate-openclaw-config-agents-manifest.test.ts diff --git a/test/generate-openclaw-config-agents-manifest.test.ts b/test/generate-openclaw-config-agents-manifest.test.ts new file mode 100644 index 00000000000..361264b03ec --- /dev/null +++ b/test/generate-openclaw-config-agents-manifest.test.ts @@ -0,0 +1,351 @@ +// @ts-nocheck +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Agents-manifest extensions for scripts/generate-openclaw-config.mts. +// Exercises the v1 `{agents,defaults?,main?}` payload shape that the YAML +// loader emits: per-agent model + subagents OpenClaw-native fields, +// agents.defaults bake, main-agent augmentation, and providers.models +// expansion for unique secondary model refs. Split out of +// generate-openclaw-config.test.ts to keep that file under the legacy +// test-file-size budget. + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { buildConfig, main } from "../scripts/generate-openclaw-config.mts"; +import { + applyMessagingAgentRenderToObject, + readMessagingBuildPlanFromEnv, +} from "../src/lib/messaging/applier/build/messaging-build-applier.mts"; +import { withLegacyMessagingPlanEnv } from "./messaging-plan-test-helper"; + +const SCRIPT_PATH = path.join(import.meta.dirname, "..", "scripts", "generate-openclaw-config.mts"); +const SCRIPT_ARGS = ["--experimental-strip-types", SCRIPT_PATH]; +const APPLIER_PATH = path.join( + import.meta.dirname, + "..", + "src", + "lib", + "messaging", + "applier", + "build", + "messaging-build-applier.mts", +); + +const BASE_ENV: Record = { + NEMOCLAW_MODEL: "test-model", + NEMOCLAW_PROVIDER_KEY: "test-provider", + NEMOCLAW_PRIMARY_MODEL_REF: "test-ref", + CHAT_UI_URL: "http://127.0.0.1:18789", + NEMOCLAW_INFERENCE_BASE_URL: "http://localhost:8080", + NEMOCLAW_INFERENCE_API: "openai", + NEMOCLAW_INFERENCE_COMPAT_B64: Buffer.from("{}").toString("base64"), + NEMOCLAW_PROXY_HOST: "10.200.0.1", + NEMOCLAW_PROXY_PORT: "3128", + NEMOCLAW_CONTEXT_WINDOW: "131072", + NEMOCLAW_MAX_TOKENS: "4096", + NEMOCLAW_REASONING: "false", + NEMOCLAW_AGENT_TIMEOUT: "600", +}; + +let tmpDir: string; + +function ensureFakeOpenClaw(): string { + const fakeOpenclaw = path.join(tmpDir, "openclaw"); + fs.writeFileSync(fakeOpenclaw, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + return fakeOpenclaw; +} + +function buildTestEnv(envOverrides: Record = {}): Record { + ensureFakeOpenClaw(); + const env = { + PATH: `${tmpDir}:${process.env.PATH || "/usr/bin:/bin"}`, + ...BASE_ENV, + ...envOverrides, + HOME: tmpDir, + }; + return withLegacyMessagingPlanEnv(env, "openclaw"); +} + +function withEnv(env: Record, fn: () => T): T { + const originalEnv = { ...process.env }; + try { + for (const key of Object.keys(process.env)) { + delete process.env[key]; + } + Object.assign(process.env, env); + return fn(); + } finally { + for (const key of Object.keys(process.env)) { + delete process.env[key]; + } + Object.assign(process.env, originalEnv); + } +} + +function runMessagingPostInstall(env: Record): void { + const result = spawnSync( + "node", + [ + "--experimental-strip-types", + APPLIER_PATH, + "--agent", + "openclaw", + "--phase", + "post-agent-install", + ], + { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + env, + timeout: 10_000, + }, + ); + if (result.status !== 0) { + throw new Error( + `Messaging applier failed (exit ${result.status}): +stdout: ${result.stdout} +stderr: ${result.stderr}`, + ); + } +} + +function runConfigScript(envOverrides: Record = {}): any { + const env = buildTestEnv(envOverrides); + withEnv(env, () => main()); + runMessagingPostInstall(env); + const configPath = path.join(tmpDir, ".openclaw", "openclaw.json"); + return JSON.parse(fs.readFileSync(configPath, "utf-8")); +} + +function buildConfigDirect(envOverrides: Record = {}): any { + const env = buildTestEnv(envOverrides); + return withEnv(env, () => { + const config = buildConfig(); + applyMessagingAgentRenderToObject( + config, + readMessagingBuildPlanFromEnv(env, "openclaw"), + "openclaw.json", + ); + return config; + }); +} + +function expectBuildConfigError(envOverrides: Record, message: string | RegExp) { + expect(() => buildConfigDirect(envOverrides)).toThrow(message); +} + +const TOOLS_OK = { profile: "minimal", allow: ["read"], deny: ["exec"] }; + +function makeExtra(overrides: Record = {}): Record { + return { + id: "research", + workspace: "/sandbox/.openclaw/workspace-research", + agentDir: "/sandbox/.openclaw/agents/research", + tools: TOOLS_OK, + ...overrides, + }; +} + +function extraAgentsB64(extras: unknown): string { + return Buffer.from(JSON.stringify(extras)).toString("base64"); +} + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-generate-config-agents-")); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe("generate-openclaw-config :: agents manifest", () => { + it("accepts the new payload object shape {agents, defaults?, main?}", () => { + const config = runConfigScript({ + NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64({ + agents: [makeExtra({ id: "research" })], + }), + }); + expect(config.agents.list[1]).toMatchObject({ id: "research" }); + }); + + it("rejects unknown top-level keys in the object payload", () => { + expectBuildConfigError( + { + NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64({ + agents: [], + rogue: "x", + }), + }, + /NEMOCLAW_EXTRA_AGENTS_JSON contains unsupported field\(s\): rogue/, + ); + }); + + it("accepts a same-provider per-agent model and adds it to providers.models[]", () => { + const config = runConfigScript({ + NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64({ + agents: [makeExtra({ model: "test-provider/secondary-1" })], + }), + }); + expect(config.agents.list[1].model).toBe("test-provider/secondary-1"); + const refs = config.models.providers["test-provider"].models.map( + (entry: { name: string }) => entry.name, + ); + expect(refs).toEqual(["test-ref", "test-provider/secondary-1"]); + const secondary = config.models.providers["test-provider"].models[1]; + expect(secondary.id).toBe("secondary-1"); + }); + + it("dedups model refs across multiple agents and the main override", () => { + const config = runConfigScript({ + NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64({ + agents: [ + makeExtra({ id: "research", model: "test-provider/shared-model" }), + makeExtra({ + id: "writing", + workspace: "/sandbox/.openclaw/workspace-writing", + agentDir: "/sandbox/.openclaw/agents/writing", + model: "test-provider/shared-model", + subagents: { model: "test-provider/shared-model" }, + }), + ], + main: { + subagents: { + allowAgents: ["research", "writing"], + model: "test-provider/shared-model", + }, + }, + }), + }); + const refs = config.models.providers["test-provider"].models.map( + (entry: { name: string }) => entry.name, + ); + expect(refs).toEqual(["test-ref", "test-provider/shared-model"]); + }); + + it("rejects a per-agent model whose provider does not match the onboard provider", () => { + expectBuildConfigError( + { + NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64({ + agents: [makeExtra({ model: "other-provider/model-x" })], + }), + }, + /model provider "other-provider" must match the onboard provider "test-provider"/, + ); + }); + + it("rejects per-agent model strings without a provider/model split", () => { + expectBuildConfigError( + { + NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64({ + agents: [makeExtra({ model: "bare-name" })], + }), + }, + /must be of the form "provider\/model"/, + ); + }); + + it("accepts subagents.allowAgents and bakes it verbatim under the agent entry", () => { + const config = runConfigScript({ + NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64({ + agents: [ + makeExtra({ + subagents: { + allowAgents: ["analyst", "writer"], + delegationMode: "prefer", + requireAgentId: true, + }, + }), + ], + }), + }); + expect(config.agents.list[1].subagents).toEqual({ + allowAgents: ["analyst", "writer"], + delegationMode: "prefer", + requireAgentId: true, + }); + }); + + it("rejects subagents.delegationMode values outside the OpenClaw enum", () => { + expectBuildConfigError( + { + NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64({ + agents: [makeExtra({ subagents: { delegationMode: "force" } })], + }), + }, + /delegationMode must be one of: prefer, suggest/, + ); + }); + + it("rejects subagents.allowAgents that is not an array of non-empty strings", () => { + expectBuildConfigError( + { + NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64({ + agents: [makeExtra({ subagents: { allowAgents: ["", "ok"] } })], + }), + }, + /allowAgents must be an array of non-empty strings/, + ); + }); + + it("bakes defaults.subagents.maxSpawnDepth into agents.defaults.subagents", () => { + const config = runConfigScript({ + NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64({ + agents: [], + defaults: { subagents: { maxSpawnDepth: 3 } }, + }), + }); + expect(config.agents.defaults.subagents).toEqual({ maxSpawnDepth: 3 }); + }); + + it("rejects defaults.subagents.maxSpawnDepth outside OpenClaw's 1..5 range", () => { + for (const depth of [0, 6, -1, 1.5]) { + expectBuildConfigError( + { + NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64({ + agents: [], + defaults: { subagents: { maxSpawnDepth: depth } }, + }), + }, + /maxSpawnDepth must be an integer between 1 and 5/, + ); + } + }); + + it("merges main.subagents and main.tools onto the canonical main entry", () => { + const mainTools = { profile: "minimal", allow: ["read", "write"] }; + const mainSubagents = { + allowAgents: ["research"], + delegationMode: "prefer", + requireAgentId: true, + }; + const config = runConfigScript({ + NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64({ + agents: [makeExtra({ id: "research" })], + main: { tools: mainTools, subagents: mainSubagents }, + }), + }); + expect(config.agents.list[0]).toEqual({ + id: "main", + default: true, + tools: mainTools, + subagents: mainSubagents, + }); + }); + + it("rejects main overrides that target fields outside the allowlist", () => { + expectBuildConfigError( + { + NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64({ + agents: [], + main: { workspace: "/sandbox/.openclaw/workspace-main" }, + }), + }, + /NEMOCLAW_EXTRA_AGENTS_JSON\.main contains unsupported field\(s\): workspace/, + ); + }); +}); diff --git a/test/generate-openclaw-config.test.ts b/test/generate-openclaw-config.test.ts index a89b5f59279..4a76d8f7650 100644 --- a/test/generate-openclaw-config.test.ts +++ b/test/generate-openclaw-config.test.ts @@ -1182,195 +1182,9 @@ describe("generate-openclaw-config.mts: config generation", () => { }); // ─── agents-manifest extensions ─────────────────────────────────────────── - // These exercise the v1 `{agents,defaults?,main?}` payload shape that the - // YAML loader emits: per-agent model + subagents OpenClaw-native fields, - // agents.defaults bake, main-agent augmentation, and providers.models - // expansion for unique secondary model refs. - - it("accepts the new payload object shape {agents, defaults?, main?}", () => { - const config = runConfigScript({ - NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64({ - agents: [makeExtra({ id: "research" })], - }), - }); - expect(config.agents.list[1]).toMatchObject({ id: "research" }); - }); - - it("rejects unknown top-level keys in the object payload", () => { - expectBuildConfigError( - { - NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64({ - agents: [], - rogue: "x", - }), - }, - /NEMOCLAW_EXTRA_AGENTS_JSON contains unsupported field\(s\): rogue/, - ); - }); - - it("accepts a same-provider per-agent model and adds it to providers.models[]", () => { - const config = runConfigScript({ - NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64({ - agents: [makeExtra({ model: "test-provider/secondary-1" })], - }), - }); - expect(config.agents.list[1].model).toBe("test-provider/secondary-1"); - const refs = config.models.providers["test-provider"].models.map( - (entry: { name: string }) => entry.name, - ); - expect(refs).toEqual(["test-ref", "test-provider/secondary-1"]); - const secondary = config.models.providers["test-provider"].models[1]; - expect(secondary.id).toBe("secondary-1"); - }); - - it("dedups model refs across multiple agents and the main override", () => { - const config = runConfigScript({ - NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64({ - agents: [ - makeExtra({ id: "research", model: "test-provider/shared-model" }), - makeExtra({ - id: "writing", - workspace: "/sandbox/.openclaw/workspace-writing", - agentDir: "/sandbox/.openclaw/agents/writing", - model: "test-provider/shared-model", - subagents: { model: "test-provider/shared-model" }, - }), - ], - main: { - subagents: { - allowAgents: ["research", "writing"], - model: "test-provider/shared-model", - }, - }, - }), - }); - const refs = config.models.providers["test-provider"].models.map( - (entry: { name: string }) => entry.name, - ); - expect(refs).toEqual(["test-ref", "test-provider/shared-model"]); - }); - - it("rejects a per-agent model whose provider does not match the onboard provider", () => { - expectBuildConfigError( - { - NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64({ - agents: [makeExtra({ model: "other-provider/model-x" })], - }), - }, - /model provider "other-provider" must match the onboard provider "test-provider"/, - ); - }); - - it("rejects per-agent model strings without a provider/model split", () => { - expectBuildConfigError( - { - NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64({ - agents: [makeExtra({ model: "bare-name" })], - }), - }, - /must be of the form "provider\/model"/, - ); - }); - - it("accepts subagents.allowAgents and bakes it verbatim under the agent entry", () => { - const config = runConfigScript({ - NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64({ - agents: [ - makeExtra({ - subagents: { - allowAgents: ["analyst", "writer"], - delegationMode: "prefer", - requireAgentId: true, - }, - }), - ], - }), - }); - expect(config.agents.list[1].subagents).toEqual({ - allowAgents: ["analyst", "writer"], - delegationMode: "prefer", - requireAgentId: true, - }); - }); - - it("rejects subagents.delegationMode values outside the OpenClaw enum", () => { - expectBuildConfigError( - { - NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64({ - agents: [makeExtra({ subagents: { delegationMode: "force" } })], - }), - }, - /delegationMode must be one of: prefer, suggest/, - ); - }); - - it("rejects subagents.allowAgents that is not an array of non-empty strings", () => { - expectBuildConfigError( - { - NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64({ - agents: [makeExtra({ subagents: { allowAgents: ["", "ok"] } })], - }), - }, - /allowAgents must be an array of non-empty strings/, - ); - }); - - it("bakes defaults.subagents.maxSpawnDepth into agents.defaults.subagents", () => { - const config = runConfigScript({ - NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64({ - agents: [], - defaults: { subagents: { maxSpawnDepth: 3 } }, - }), - }); - expect(config.agents.defaults.subagents).toEqual({ maxSpawnDepth: 3 }); - }); - - it("rejects defaults.subagents.maxSpawnDepth outside OpenClaw's 1..5 range", () => { - for (const depth of [0, 6, -1, 1.5]) { - expectBuildConfigError( - { - NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64({ - agents: [], - defaults: { subagents: { maxSpawnDepth: depth } }, - }), - }, - /maxSpawnDepth must be an integer between 1 and 5/, - ); - } - }); - - it("merges main.subagents and main.tools onto the canonical main entry", () => { - const mainTools = { profile: "minimal", allow: ["read", "write"] }; - const mainSubagents = { - allowAgents: ["research"], - delegationMode: "prefer", - requireAgentId: true, - }; - const config = runConfigScript({ - NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64({ - agents: [makeExtra({ id: "research" })], - main: { tools: mainTools, subagents: mainSubagents }, - }), - }); - expect(config.agents.list[0]).toEqual({ - id: "main", - default: true, - tools: mainTools, - subagents: mainSubagents, - }); - }); - - it("rejects main overrides that target fields outside the allowlist", () => { - expectBuildConfigError( - { - NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64({ - agents: [], - main: { workspace: "/sandbox/.openclaw/workspace-main" }, - }), - }, - /NEMOCLAW_EXTRA_AGENTS_JSON\.main contains unsupported field\(s\): workspace/, - ); - }); + // The v1 `{agents,defaults?,main?}` payload shape covered by + // test/generate-openclaw-config-agents-manifest.test.ts to keep this file + // under the legacy size budget. it("keeps compatible endpoints on the managed inference.local OpenClaw provider", () => { const config = runConfigScript({ From 576e74c7989ce476014331d6b2645958beb336ef Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Mon, 15 Jun 2026 04:52:33 +0000 Subject: [PATCH 03/13] fix(onboard): address CodeRabbit and CodeQL review findings Signed-off-by: Tinson Lai --- ci/test-file-size-budget.json | 2 +- .../inference/declarative-agents-manifest.mdx | 11 ++++++++- scripts/generate-openclaw-config.mts | 11 +++++++++ src/lib/onboard/agents-manifest.ts | 23 +++++++++++++------ ...te-openclaw-config-agents-manifest.test.ts | 13 +++++++++-- 5 files changed, 49 insertions(+), 11 deletions(-) diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index d09ee26f009..5e904335b36 100644 --- a/ci/test-file-size-budget.json +++ b/ci/test-file-size-budget.json @@ -6,7 +6,7 @@ "src/lib/inference/nim.test.ts": 2068, "src/lib/onboard/preflight.test.ts": 1905, "test/channels-add-preset.test.ts": 1871, - "test/generate-openclaw-config.test.ts": 1989, + "test/generate-openclaw-config.test.ts": 1984, "test/install-preflight.test.ts": 4207, "test/nemoclaw-start.test.ts": 5230, "test/onboard-messaging.test.ts": 2063, diff --git a/docs/inference/declarative-agents-manifest.mdx b/docs/inference/declarative-agents-manifest.mdx index 34c82b7fab9..403af00728c 100644 --- a/docs/inference/declarative-agents-manifest.mdx +++ b/docs/inference/declarative-agents-manifest.mdx @@ -117,7 +117,7 @@ When a secondary agent declares its own `model` (or `subagents.model`), NemoClaw The base `contextWindow`, `maxTokens`, `reasoning`, and `input` settings from the onboard route apply to each appended entry. Per-model overrides beyond these defaults are out of scope for v1 — edit the generated `openclaw.json` in-place if you need finer control. -## Example: Manager-Worker +## Manager-Worker Example ```yaml defaults: @@ -152,3 +152,12 @@ Workspaces under `/sandbox/.openclaw/workspace-` are preserved across rebuil For ad-hoc per-agent edits inside an existing sandbox (no rebuild), use the in-sandbox CLI: `nemoclaw agents add|delete|list`. The manifest path is for fixed, checked-in layouts; the CLI passthrough is for interactive work. + +## Next Steps + +Use the following resources for more information: + +- Refer to [OpenClaw Sub-Agents](https://docs.openclaw.ai/tools/subagents) for the runtime semantics of `sessions_spawn`, `subagents.allowAgents`, and nesting depth. +- Refer to [Set Up Task-Specific Sub-Agents](set-up-sub-agent) for the in-sandbox path that edits `agents.list` directly without a rebuild. +- Refer to [Switch Inference Providers](switch-inference-providers) before swapping the primary onboard provider — per-agent `model` refs must share that provider. +- Refer to [Workspace Files](../manage-sandboxes/workspace-files) to understand how per-agent `workspace-` directories are provisioned and persisted across rebuilds. diff --git a/scripts/generate-openclaw-config.mts b/scripts/generate-openclaw-config.mts index 5da67696485..d6b44ab8bad 100755 --- a/scripts/generate-openclaw-config.mts +++ b/scripts/generate-openclaw-config.mts @@ -633,6 +633,17 @@ function validateModelRef(label: string, raw: unknown, primaryProvider: string): throw new Error(`${label} must be of the form "provider/model", got "${raw}"`); } const provider = raw.slice(0, slash); + const modelTail = raw.slice(slash + 1); + if (provider.trim() !== provider || provider.length === 0) { + throw new Error( + `${label} provider portion must be non-empty and contain no surrounding whitespace, got "${raw}"`, + ); + } + if (modelTail.trim() !== modelTail || modelTail.length === 0) { + throw new Error( + `${label} model portion must be non-empty and contain no surrounding whitespace, got "${raw}"`, + ); + } if (provider !== primaryProvider) { throw new Error( `${label} provider "${provider}" must match the onboard provider "${primaryProvider}"; cross-provider manifests are not supported`, diff --git a/src/lib/onboard/agents-manifest.ts b/src/lib/onboard/agents-manifest.ts index 5fd111f4f63..afb76c9aef2 100644 --- a/src/lib/onboard/agents-manifest.ts +++ b/src/lib/onboard/agents-manifest.ts @@ -57,14 +57,23 @@ export interface AgentsManifestPayload { */ export function loadAgentsManifest(filePath: string): AgentsManifestPayload { const resolved = path.resolve(filePath); - if (!fs.existsSync(resolved)) { - throw new Error(`--agents path not found: ${resolved}`); - } - const stat = fs.statSync(resolved); - if (!stat.isFile()) { - throw new Error(`--agents must point to a file: ${resolved}`); + let raw: string; + try { + // Single fs call avoids the existsSync/statSync/readFileSync TOCTOU + // window CodeQL flags as a race (CWE-367): the manifest path can change + // between the pre-check and the read on a shared filesystem. + raw = fs.readFileSync(resolved, "utf-8"); + } catch (err) { + const nodeErr = err as NodeJS.ErrnoException; + if (nodeErr?.code === "ENOENT") { + throw new Error(`--agents path not found: ${resolved}`); + } + if (nodeErr?.code === "EISDIR") { + throw new Error(`--agents must point to a file: ${resolved}`); + } + const reason = err instanceof Error ? err.message : String(err); + throw new Error(`--agents read error: ${reason}`); } - const raw = fs.readFileSync(resolved, "utf-8"); let parsed: unknown; try { parsed = loadYaml().parse(raw); diff --git a/test/generate-openclaw-config-agents-manifest.test.ts b/test/generate-openclaw-config-agents-manifest.test.ts index 361264b03ec..e3e2f5482db 100644 --- a/test/generate-openclaw-config-agents-manifest.test.ts +++ b/test/generate-openclaw-config-agents-manifest.test.ts @@ -23,8 +23,6 @@ import { } from "../src/lib/messaging/applier/build/messaging-build-applier.mts"; import { withLegacyMessagingPlanEnv } from "./messaging-plan-test-helper"; -const SCRIPT_PATH = path.join(import.meta.dirname, "..", "scripts", "generate-openclaw-config.mts"); -const SCRIPT_ARGS = ["--experimental-strip-types", SCRIPT_PATH]; const APPLIER_PATH = path.join( import.meta.dirname, "..", @@ -249,6 +247,17 @@ describe("generate-openclaw-config :: agents manifest", () => { ); }); + it("rejects per-agent model strings whose model portion is whitespace-only", () => { + expectBuildConfigError( + { + NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64({ + agents: [makeExtra({ model: "test-provider/ " })], + }), + }, + /model portion must be non-empty and contain no surrounding whitespace/, + ); + }); + it("accepts subagents.allowAgents and bakes it verbatim under the agent entry", () => { const config = runConfigScript({ NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64({ From 317f6c9e2aea08b1455332aebfe7ccfb1a7e1537 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Mon, 15 Jun 2026 05:10:23 +0000 Subject: [PATCH 04/13] docs(reference): list --agents under onboard + setup aliases for cli-parity Signed-off-by: Tinson Lai --- docs/reference/commands.mdx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index ee29b93ae2d..18aebf216d0 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -112,7 +112,7 @@ The wizard creates an OpenShell gateway, registers inference providers, builds t Use this command for new installs and for recreating a sandbox after changes to policy or configuration. ```bash -$$nemoclaw onboard [--non-interactive] [--resume | --fresh] [--recreate-sandbox] [--gpu | --no-gpu] [--from ] [--name ] [--sandbox-gpu | --no-sandbox-gpu] [--sandbox-gpu-device ] [--agent ] [--control-ui-port ] [--yes | -y] [--no-ollama-autostart] [--yes-i-accept-third-party-software] +$$nemoclaw onboard [--non-interactive] [--resume | --fresh] [--recreate-sandbox] [--gpu | --no-gpu] [--from ] [--name ] [--sandbox-gpu | --no-sandbox-gpu] [--sandbox-gpu-device ] [--agent ] [--agents ] [--control-ui-port ] [--yes | -y] [--no-ollama-autostart] [--yes-i-accept-third-party-software] ``` @@ -263,6 +263,9 @@ It also prints a `Try: ` recovery line whenever it can derive a Names that match global CLI commands (`status`, `list`, `debug`, etc.) are rejected to avoid routing conflicts. Use `--agent ` to target a specific installed agent profile during onboarding. +Use `--agents ` to declare secondary OpenClaw agents, `agents.defaults`, and main-agent overrides in a checked-in manifest that NemoClaw bakes into the sandbox image at build time. +See [Declarative Multi-Agent Manifest](../inference/declarative-agents-manifest) for the schema and OpenClaw-native sub-agent field semantics. + Use `--control-ui-port ` to choose the host dashboard port for a sandbox. The value must be an integer from `1024` through `65535`. This flag takes precedence over `CHAT_UI_URL`, `NEMOCLAW_DASHBOARD_PORT`, the previous registry value, and the default port. @@ -1742,7 +1745,7 @@ The `$$nemoclaw setup` command is deprecated. Use `$$nemoclaw onboard` instead. -This command remains as a compatibility alias to `$$nemoclaw onboard` and accepts the same flags: `--non-interactive`, `--resume`, `--fresh`, `--recreate-sandbox`, `--gpu` / `--no-gpu`, `--from`, `--name`, `--sandbox-gpu` / `--no-sandbox-gpu`, `--sandbox-gpu-device`, `--agent`, `--control-ui-port`, `--yes` / `-y`, `--no-ollama-autostart`, `--yes-i-accept-third-party-software`. +This command remains as a compatibility alias to `$$nemoclaw onboard` and accepts the same flags: `--non-interactive`, `--resume`, `--fresh`, `--recreate-sandbox`, `--gpu` / `--no-gpu`, `--from`, `--name`, `--sandbox-gpu` / `--no-sandbox-gpu`, `--sandbox-gpu-device`, `--agent`, `--agents`, `--control-ui-port`, `--yes` / `-y`, `--no-ollama-autostart`, `--yes-i-accept-third-party-software`. ```bash $$nemoclaw setup @@ -1755,7 +1758,7 @@ The `$$nemoclaw setup-spark` command is deprecated. Use the standard installer and run `$$nemoclaw onboard` instead, because current OpenShell releases handle the older DGX Spark cgroup behavior. -This command remains as a compatibility alias to `$$nemoclaw onboard` and accepts the same flags: `--non-interactive`, `--resume`, `--fresh`, `--recreate-sandbox`, `--gpu` / `--no-gpu`, `--from`, `--name`, `--sandbox-gpu` / `--no-sandbox-gpu`, `--sandbox-gpu-device`, `--agent`, `--control-ui-port`, `--yes` / `-y`, `--no-ollama-autostart`, `--yes-i-accept-third-party-software`. +This command remains as a compatibility alias to `$$nemoclaw onboard` and accepts the same flags: `--non-interactive`, `--resume`, `--fresh`, `--recreate-sandbox`, `--gpu` / `--no-gpu`, `--from`, `--name`, `--sandbox-gpu` / `--no-sandbox-gpu`, `--sandbox-gpu-device`, `--agent`, `--agents`, `--control-ui-port`, `--yes` / `-y`, `--no-ollama-autostart`, `--yes-i-accept-third-party-software`. ```bash $$nemoclaw setup-spark From 7361e693105204dd34cc528f41f61e3f0b051a2a Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Mon, 15 Jun 2026 05:17:05 +0000 Subject: [PATCH 05/13] test(onboard): split --agents lifecycle into focused legacy-command-agents.test.ts Signed-off-by: Tinson Lai --- src/lib/onboard/legacy-command-agents.test.ts | 117 ++++++++++++++++++ src/lib/onboard/legacy-command.test.ts | 95 +------------- 2 files changed, 121 insertions(+), 91 deletions(-) create mode 100644 src/lib/onboard/legacy-command-agents.test.ts diff --git a/src/lib/onboard/legacy-command-agents.test.ts b/src/lib/onboard/legacy-command-agents.test.ts new file mode 100644 index 00000000000..acd29454f8c --- /dev/null +++ b/src/lib/onboard/legacy-command-agents.test.ts @@ -0,0 +1,117 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Focused coverage for the `--agents ` lifecycle on `onboard`: +// parse-arg-into-option, missing-path/value rejection, env-var application +// before runOnboard is invoked. Split from legacy-command.test.ts so the +// hotspot does not grow further. + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it, vi } from "vitest"; + +import { parseOnboardArgs, runOnboardCommand } from "./legacy-command"; + +function exitWithCode(code: number): never { + throw new Error(String(code)); +} + +function exitWithPrefixedCode(code: number): never { + throw new Error(`exit:${code}`); +} + +describe("onboard --agents", () => { + it("parses --agents into agentsManifest", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-agents-parse-")); + const manifestPath = path.join(tmpDir, "agents.yaml"); + fs.writeFileSync(manifestPath, "agents: []\n"); + + const result = parseOnboardArgs( + ["--agents", manifestPath], + "--yes-i-accept-third-party-software", + "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE", + { + env: {}, + error: () => {}, + exit: exitWithCode, + }, + ); + expect(result.agentsManifest).toBe(manifestPath); + }); + + it("rejects --agents when the file is missing", () => { + const errors: string[] = []; + expect(() => + parseOnboardArgs( + ["--agents", "/nonexistent/agents.yaml"], + "--yes-i-accept-third-party-software", + "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE", + { + env: {}, + error: (message = "") => errors.push(message), + exit: exitWithPrefixedCode, + }, + ), + ).toThrow("exit:1"); + expect(errors.join("\n")).toContain("--agents path not found"); + }); + + it("rejects --agents when the value is missing", () => { + const errors: string[] = []; + expect(() => + parseOnboardArgs( + ["--agents"], + "--yes-i-accept-third-party-software", + "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE", + { + env: {}, + error: (message = "") => errors.push(message), + exit: exitWithPrefixedCode, + }, + ), + ).toThrow("exit:1"); + expect(errors.join("\n")).toContain("--agents requires a path to a YAML manifest"); + }); + + it("sets NEMOCLAW_EXTRA_AGENTS_JSON before invoking runOnboard when --agents is supplied", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-agents-env-")); + const manifestPath = path.join(tmpDir, "agents.yaml"); + fs.writeFileSync( + manifestPath, + ["agents:", " - id: alpha", " tools:", " allow: [read]", ""].join("\n"), + ); + const previous = process.env.NEMOCLAW_EXTRA_AGENTS_JSON; + delete process.env.NEMOCLAW_EXTRA_AGENTS_JSON; + let observedRaw: string | undefined; + const runOnboard = vi.fn(async () => { + observedRaw = process.env.NEMOCLAW_EXTRA_AGENTS_JSON; + }); + try { + await runOnboardCommand({ + args: ["--agents", manifestPath], + noticeAcceptFlag: "--yes-i-accept-third-party-software", + noticeAcceptEnv: "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE", + env: {}, + runOnboard, + error: () => {}, + exit: exitWithCode, + }); + expect(observedRaw).toBeDefined(); + const payload = JSON.parse(observedRaw as string); + expect(payload.agents).toHaveLength(1); + expect(payload.agents[0]).toMatchObject({ + id: "alpha", + workspace: "/sandbox/.openclaw/workspace-alpha", + agentDir: "/sandbox/.openclaw/agents/alpha", + }); + } finally { + if (previous === undefined) { + delete process.env.NEMOCLAW_EXTRA_AGENTS_JSON; + } else { + process.env.NEMOCLAW_EXTRA_AGENTS_JSON = previous; + } + } + }); +}); diff --git a/src/lib/onboard/legacy-command.test.ts b/src/lib/onboard/legacy-command.test.ts index 5b8ba83e2f6..ab3b9bf84be 100644 --- a/src/lib/onboard/legacy-command.test.ts +++ b/src/lib/onboard/legacy-command.test.ts @@ -196,97 +196,10 @@ describe("onboard command", () => { }); }); - it("parses --agents into agentsManifest", () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-agents-parse-")); - const manifestPath = path.join(tmpDir, "agents.yaml"); - fs.writeFileSync(manifestPath, "agents: []\n"); - - const result = parseOnboardArgs( - ["--agents", manifestPath], - "--yes-i-accept-third-party-software", - "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE", - { - env: {}, - error: () => {}, - exit: exitWithCode, - }, - ); - expect(result.agentsManifest).toBe(manifestPath); - }); - - it("rejects --agents when the file is missing", () => { - const errors: string[] = []; - expect(() => - parseOnboardArgs( - ["--agents", "/nonexistent/agents.yaml"], - "--yes-i-accept-third-party-software", - "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE", - { - env: {}, - error: (message = "") => errors.push(message), - exit: exitWithPrefixedCode, - }, - ), - ).toThrow("exit:1"); - expect(errors.join("\n")).toContain("--agents path not found"); - }); - - it("rejects --agents when the value is missing", () => { - const errors: string[] = []; - expect(() => - parseOnboardArgs( - ["--agents"], - "--yes-i-accept-third-party-software", - "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE", - { - env: {}, - error: (message = "") => errors.push(message), - exit: exitWithPrefixedCode, - }, - ), - ).toThrow("exit:1"); - expect(errors.join("\n")).toContain("--agents requires a path to a YAML manifest"); - }); - - it("sets NEMOCLAW_EXTRA_AGENTS_JSON before invoking runOnboard when --agents is supplied", async () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-agents-env-")); - const manifestPath = path.join(tmpDir, "agents.yaml"); - fs.writeFileSync( - manifestPath, - ["agents:", " - id: alpha", " tools:", " allow: [read]", ""].join("\n"), - ); - const previous = process.env.NEMOCLAW_EXTRA_AGENTS_JSON; - delete process.env.NEMOCLAW_EXTRA_AGENTS_JSON; - let observedRaw: string | undefined; - const runOnboard = vi.fn(async () => { - observedRaw = process.env.NEMOCLAW_EXTRA_AGENTS_JSON; - }); - try { - await runOnboardCommand({ - args: ["--agents", manifestPath], - noticeAcceptFlag: "--yes-i-accept-third-party-software", - noticeAcceptEnv: "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE", - env: {}, - runOnboard, - error: () => {}, - exit: exitWithCode, - }); - expect(observedRaw).toBeDefined(); - const payload = JSON.parse(observedRaw as string); - expect(payload.agents).toHaveLength(1); - expect(payload.agents[0]).toMatchObject({ - id: "alpha", - workspace: "/sandbox/.openclaw/workspace-alpha", - agentDir: "/sandbox/.openclaw/agents/alpha", - }); - } finally { - if (previous === undefined) { - delete process.env.NEMOCLAW_EXTRA_AGENTS_JSON; - } else { - process.env.NEMOCLAW_EXTRA_AGENTS_JSON = previous; - } - } - }); + // --agents parsing covered by + // src/lib/onboard/legacy-command-agents.test.ts to keep this hotspot from + // growing further; that file owns the full --agents lifecycle (parse, + // missing-path/value rejection, env-var application before runOnboard). it("parses --fresh and surfaces it as fresh=true", () => { expect( From 5017ed7ae9b1d60f1b4096f8bdb621ad7d224c3c Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Mon, 15 Jun 2026 05:46:18 +0000 Subject: [PATCH 06/13] fix(onboard): advisor follow-ups, credential denylist, allowAgents conformance Signed-off-by: Tinson Lai --- .../inference/declarative-agents-manifest.mdx | 5 + docs/reference/commands-nemohermes.mdx | 9 +- src/lib/onboard/agents-manifest.test.ts | 26 ++ src/lib/onboard/agents-manifest.ts | 31 ++ ...agents-manifest-policy-conformance.test.ts | 326 ++++++++++++++++++ 5 files changed, 394 insertions(+), 3 deletions(-) create mode 100644 test/agents-manifest-policy-conformance.test.ts diff --git a/docs/inference/declarative-agents-manifest.mdx b/docs/inference/declarative-agents-manifest.mdx index 403af00728c..da9f115a33f 100644 --- a/docs/inference/declarative-agents-manifest.mdx +++ b/docs/inference/declarative-agents-manifest.mdx @@ -12,10 +12,15 @@ keywords: - "manager worker agents" - "subagents allowAgents" - "per-agent model" +topics: ["generative_ai", "ai_agents"] +tags: ["nemoclaw", "openclaw", "openshell", "agents.yaml", "subagents"] content: type: "how_to" + difficulty: technical_intermediate + audience: ["developer", "engineer"] skill: priority: 30 +status: published --- NemoClaw can bake a multi-agent OpenClaw layout into a sandbox image from a single checked-in manifest. diff --git a/docs/reference/commands-nemohermes.mdx b/docs/reference/commands-nemohermes.mdx index c3fdd39cec3..9f59a962a88 100644 --- a/docs/reference/commands-nemohermes.mdx +++ b/docs/reference/commands-nemohermes.mdx @@ -75,7 +75,7 @@ The wizard creates an OpenShell gateway, registers inference providers, builds t Use this command for new installs and for recreating a sandbox after changes to policy or configuration. ```bash -nemohermes onboard [--non-interactive] [--resume | --fresh] [--recreate-sandbox] [--gpu | --no-gpu] [--from ] [--name ] [--sandbox-gpu | --no-sandbox-gpu] [--sandbox-gpu-device ] [--agent ] [--control-ui-port ] [--yes | -y] [--no-ollama-autostart] [--yes-i-accept-third-party-software] +nemohermes onboard [--non-interactive] [--resume | --fresh] [--recreate-sandbox] [--gpu | --no-gpu] [--from ] [--name ] [--sandbox-gpu | --no-sandbox-gpu] [--sandbox-gpu-device ] [--agent ] [--agents ] [--control-ui-port ] [--yes | -y] [--no-ollama-autostart] [--yes-i-accept-third-party-software] ``` For Hermes, use the alias or pass the agent explicitly: @@ -192,6 +192,9 @@ It also prints a `Try: ` recovery line whenever it can derive a Names that match global CLI commands (`status`, `list`, `debug`, etc.) are rejected to avoid routing conflicts. Use `--agent ` to target a specific installed agent profile during onboarding. +Use `--agents ` to declare secondary OpenClaw agents, `agents.defaults`, and main-agent overrides in a checked-in manifest that NemoClaw bakes into the sandbox image at build time. +See [Declarative Multi-Agent Manifest](../inference/declarative-agents-manifest) for the schema and OpenClaw-native sub-agent field semantics. + Use `--control-ui-port ` to choose the host dashboard port for a sandbox. The value must be an integer from `1024` through `65535`. This flag takes precedence over `CHAT_UI_URL`, `NEMOCLAW_DASHBOARD_PORT`, the previous registry value, and the default port. @@ -1479,7 +1482,7 @@ The `nemohermes setup` command is deprecated. Use `nemohermes onboard` instead. -This command remains as a compatibility alias to `nemohermes onboard` and accepts the same flags: `--non-interactive`, `--resume`, `--fresh`, `--recreate-sandbox`, `--gpu` / `--no-gpu`, `--from`, `--name`, `--sandbox-gpu` / `--no-sandbox-gpu`, `--sandbox-gpu-device`, `--agent`, `--control-ui-port`, `--yes` / `-y`, `--no-ollama-autostart`, `--yes-i-accept-third-party-software`. +This command remains as a compatibility alias to `nemohermes onboard` and accepts the same flags: `--non-interactive`, `--resume`, `--fresh`, `--recreate-sandbox`, `--gpu` / `--no-gpu`, `--from`, `--name`, `--sandbox-gpu` / `--no-sandbox-gpu`, `--sandbox-gpu-device`, `--agent`, `--agents`, `--control-ui-port`, `--yes` / `-y`, `--no-ollama-autostart`, `--yes-i-accept-third-party-software`. ```bash nemohermes setup @@ -1492,7 +1495,7 @@ The `nemohermes setup-spark` command is deprecated. Use the standard installer and run `nemohermes onboard` instead, because current OpenShell releases handle the older DGX Spark cgroup behavior. -This command remains as a compatibility alias to `nemohermes onboard` and accepts the same flags: `--non-interactive`, `--resume`, `--fresh`, `--recreate-sandbox`, `--gpu` / `--no-gpu`, `--from`, `--name`, `--sandbox-gpu` / `--no-sandbox-gpu`, `--sandbox-gpu-device`, `--agent`, `--control-ui-port`, `--yes` / `-y`, `--no-ollama-autostart`, `--yes-i-accept-third-party-software`. +This command remains as a compatibility alias to `nemohermes onboard` and accepts the same flags: `--non-interactive`, `--resume`, `--fresh`, `--recreate-sandbox`, `--gpu` / `--no-gpu`, `--from`, `--name`, `--sandbox-gpu` / `--no-sandbox-gpu`, `--sandbox-gpu-device`, `--agent`, `--agents`, `--control-ui-port`, `--yes` / `-y`, `--no-ollama-autostart`, `--yes-i-accept-third-party-software`. ```bash nemohermes setup-spark diff --git a/src/lib/onboard/agents-manifest.test.ts b/src/lib/onboard/agents-manifest.test.ts index 4895bcb99b4..b4c849f8727 100644 --- a/src/lib/onboard/agents-manifest.test.ts +++ b/src/lib/onboard/agents-manifest.test.ts @@ -151,6 +151,32 @@ describe("loadAgentsManifest", () => { fs.mkdirSync(dir); expect(() => loadAgentsManifest(dir)).toThrow(/must point to a file:/); }); + + it("rejects manifests with nested credential-named keys before they reach the build", () => { + for (const key of [ + "apiKey", + "api_key", + "token", + "secret", + "password", + "credential", + "bearer", + ]) { + const file = manifestPath( + `credential-${key}.yaml`, + [ + "agents:", + " - id: alpha", + " tools:", + " allow: [read]", + " subagents:", + ` ${key}: leaking-secret-disguised-as-config`, + "", + ].join("\n"), + ); + expect(() => loadAgentsManifest(file)).toThrow(/looks like a credential and is not allowed/); + } + }); }); describe("applyAgentsManifestEnv", () => { diff --git a/src/lib/onboard/agents-manifest.ts b/src/lib/onboard/agents-manifest.ts index afb76c9aef2..bd8f4a729d9 100644 --- a/src/lib/onboard/agents-manifest.ts +++ b/src/lib/onboard/agents-manifest.ts @@ -16,10 +16,40 @@ function loadYaml(): YamlLoader { const ALLOWED_TOP_KEYS = new Set(["agents", "defaults", "main"]); const AGENT_DATA_ROOT = "/sandbox/.openclaw"; +// Defence-in-depth credential-name denylist: the authoritative reject lives +// at the build-time validator (scripts/generate-openclaw-config.mts) which +// only permits a small allowlist of nested keys. Even so, the host writes +// `NEMOCLAW_EXTRA_AGENTS_JSON` and the Dockerfile patcher base64-bakes the +// payload into `NEMOCLAW_EXTRA_AGENTS_JSON_B64` before the build runs. A +// pre-transport scan keeps obvious credential-named values from ever +// reaching the staged Dockerfile/build context if an operator accidentally +// drops `apiKey`, `token`, `secret`, etc. into the YAML. +const CREDENTIAL_NAME_PATTERN = + /^(?:api[-_]?key|token|secret|password|passphrase|credential|bearer|auth)$/i; + function isObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +function assertNoCredentialFields(value: unknown, label: string): void { + if (isObject(value)) { + for (const [key, child] of Object.entries(value)) { + if (CREDENTIAL_NAME_PATTERN.test(key)) { + throw new Error( + `agents manifest field "${label}.${key}" looks like a credential and is not allowed; pass credentials through the OpenShell provider profile instead`, + ); + } + assertNoCredentialFields(child, `${label}.${key}`); + } + return; + } + if (Array.isArray(value)) { + value.forEach((child, index) => { + assertNoCredentialFields(child, `${label}[${index}]`); + }); + } +} + function expectedAgentPath(kind: "workspace" | "agentDir", id: string): string { const segment = kind === "workspace" ? `workspace-${id}` : `agents/${id}`; return `${AGENT_DATA_ROOT}/${segment}`; @@ -111,6 +141,7 @@ export function loadAgentsManifest(filePath: string): AgentsManifestPayload { if (parsed.main !== undefined) { out.main = parsed.main; } + assertNoCredentialFields(out, "agents-manifest"); return out; } diff --git a/test/agents-manifest-policy-conformance.test.ts b/test/agents-manifest-policy-conformance.test.ts new file mode 100644 index 00000000000..9e43a3c5187 --- /dev/null +++ b/test/agents-manifest-policy-conformance.test.ts @@ -0,0 +1,326 @@ +// @ts-nocheck +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Policy conformance: prove that a manifest-baked openclaw.json produces an +// `agents.list[].subagents.allowAgents` shape that OpenClaw's runtime +// `sessions_spawn` validator honours for configured ids, unknown ids, and +// the `"*"` wildcard. Heavy E2E (rebuild + sandbox boot + spawn) lives in +// the nightly E2E suite; this in-process test mirrors OpenClaw's +// `resolveSubagentTargetPolicy` (openclaw/src/agents/subagent-target-policy.ts) +// so the bake can be checked against the upstream contract during the +// fast CLI test lane. + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { buildConfig } from "../scripts/generate-openclaw-config.mts"; +import { + applyMessagingAgentRenderToObject, + readMessagingBuildPlanFromEnv, +} from "../src/lib/messaging/applier/build/messaging-build-applier.mts"; +import { withLegacyMessagingPlanEnv } from "./messaging-plan-test-helper"; + +const BASE_ENV: Record = { + NEMOCLAW_MODEL: "test-model", + NEMOCLAW_PROVIDER_KEY: "test-provider", + NEMOCLAW_PRIMARY_MODEL_REF: "test-ref", + CHAT_UI_URL: "http://127.0.0.1:18789", + NEMOCLAW_INFERENCE_BASE_URL: "http://localhost:8080", + NEMOCLAW_INFERENCE_API: "openai", + NEMOCLAW_INFERENCE_COMPAT_B64: Buffer.from("{}").toString("base64"), + NEMOCLAW_PROXY_HOST: "10.200.0.1", + NEMOCLAW_PROXY_PORT: "3128", + NEMOCLAW_CONTEXT_WINDOW: "131072", + NEMOCLAW_MAX_TOKENS: "4096", + NEMOCLAW_REASONING: "false", + NEMOCLAW_AGENT_TIMEOUT: "600", +}; + +let tmpDir: string; + +function ensureFakeOpenClaw(): string { + const fakeOpenclaw = path.join(tmpDir, "openclaw"); + fs.writeFileSync(fakeOpenclaw, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + return fakeOpenclaw; +} + +function buildTestEnv(envOverrides: Record = {}): Record { + ensureFakeOpenClaw(); + const env = { + PATH: `${tmpDir}:${process.env.PATH || "/usr/bin:/bin"}`, + ...BASE_ENV, + ...envOverrides, + HOME: tmpDir, + }; + return withLegacyMessagingPlanEnv(env, "openclaw"); +} + +function withEnv(env: Record, fn: () => T): T { + const originalEnv = { ...process.env }; + try { + for (const key of Object.keys(process.env)) { + delete process.env[key]; + } + Object.assign(process.env, env); + return fn(); + } finally { + for (const key of Object.keys(process.env)) { + delete process.env[key]; + } + Object.assign(process.env, originalEnv); + } +} + +function buildBakedConfig(envOverrides: Record = {}): any { + const env = buildTestEnv(envOverrides); + return withEnv(env, () => { + const config = buildConfig(); + applyMessagingAgentRenderToObject( + config, + readMessagingBuildPlanFromEnv(env, "openclaw"), + "openclaw.json", + ); + return config; + }); +} + +function extraAgentsB64(payload: unknown): string { + return Buffer.from(JSON.stringify(payload)).toString("base64"); +} + +// ─── OpenClaw policy mirror ────────────────────────────────────────────── +// Local copy of resolveSubagentTargetPolicy from +// openclaw/src/agents/subagent-target-policy.ts. Keep in sync with the +// upstream contract; the test is meaningful only as long as this mirror +// matches OpenClaw's enforcement. + +function normalizeAgentId(id: string): string { + return (id || "").trim().toLowerCase(); +} + +function normalizeAllowAgents(allowAgents: readonly string[] | undefined): { + configured: boolean; + allowAny: boolean; + allowedIds: string[]; +} { + if (!Array.isArray(allowAgents)) { + return { configured: false, allowAny: false, allowedIds: [] }; + } + const allowedIds = allowAgents + .map((value) => value.trim()) + .filter((value) => value && value !== "*") + .map((value) => normalizeAgentId(value)) + .filter(Boolean); + return { + configured: true, + allowAny: allowAgents.some((value) => value.trim() === "*"), + allowedIds: [...new Set(allowedIds)].sort(), + }; +} + +function resolveSubagentTargetPolicy(params: { + requesterAgentId: string; + targetAgentId: string; + requestedAgentId?: string; + allowAgents?: readonly string[]; + configuredAgentIds: readonly string[]; +}): { ok: boolean; reason?: string } { + const requesterAgentId = normalizeAgentId(params.requesterAgentId); + const targetAgentId = normalizeAgentId(params.targetAgentId); + const configuredIds = new Set(params.configuredAgentIds.map(normalizeAgentId)); + if (!params.requestedAgentId?.trim() && targetAgentId === requesterAgentId) { + return { ok: true }; + } + const policy = normalizeAllowAgents(params.allowAgents); + if (!policy.configured) { + if (targetAgentId === requesterAgentId) return { ok: true }; + return { ok: false, reason: "self-only default" }; + } + if (policy.allowAny) { + if (!configuredIds.has(targetAgentId) && targetAgentId !== requesterAgentId) { + return { ok: false, reason: `unknown target ${targetAgentId}` }; + } + return { ok: true }; + } + if (!configuredIds.has(targetAgentId)) { + return { ok: false, reason: `unknown target ${targetAgentId}` }; + } + if (!policy.allowedIds.includes(targetAgentId)) { + return { ok: false, reason: `not in allowAgents [${policy.allowedIds.join(", ")}]` }; + } + return { ok: true }; +} + +function configuredAgentIds(config: any): string[] { + return (config.agents.list as Array<{ id: string }>).map((entry) => entry.id); +} + +function mainAllowAgents(config: any): string[] | undefined { + const main = ( + config.agents.list as Array<{ id: string; subagents?: { allowAgents?: string[] } }> + ).find((entry) => entry.id === "main"); + return main?.subagents?.allowAgents; +} + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-conformance-")); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe("agents manifest :: OpenClaw subagent-target policy conformance", () => { + it("allows main to target configured ids listed in subagents.allowAgents", () => { + const config = buildBakedConfig({ + NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64({ + agents: [ + { + id: "research", + workspace: "/sandbox/.openclaw/workspace-research", + agentDir: "/sandbox/.openclaw/agents/research", + tools: { profile: "minimal", allow: ["read"] }, + }, + { + id: "writer", + workspace: "/sandbox/.openclaw/workspace-writer", + agentDir: "/sandbox/.openclaw/agents/writer", + tools: { profile: "minimal", allow: ["read"] }, + }, + ], + main: { + subagents: { + allowAgents: ["research", "writer"], + }, + }, + }), + }); + const ids = configuredAgentIds(config); + const allow = mainAllowAgents(config); + expect(allow).toEqual(["research", "writer"]); + expect( + resolveSubagentTargetPolicy({ + requesterAgentId: "main", + targetAgentId: "research", + allowAgents: allow, + configuredAgentIds: ids, + }).ok, + ).toBe(true); + expect( + resolveSubagentTargetPolicy({ + requesterAgentId: "main", + targetAgentId: "writer", + allowAgents: allow, + configuredAgentIds: ids, + }).ok, + ).toBe(true); + }); + + it("rejects main targeting configured ids that are not listed", () => { + const config = buildBakedConfig({ + NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64({ + agents: [ + { + id: "research", + workspace: "/sandbox/.openclaw/workspace-research", + agentDir: "/sandbox/.openclaw/agents/research", + tools: { profile: "minimal", allow: ["read"] }, + }, + { + id: "writer", + workspace: "/sandbox/.openclaw/workspace-writer", + agentDir: "/sandbox/.openclaw/agents/writer", + tools: { profile: "minimal", allow: ["read"] }, + }, + ], + main: { + subagents: { + allowAgents: ["research"], + }, + }, + }), + }); + const verdict = resolveSubagentTargetPolicy({ + requesterAgentId: "main", + targetAgentId: "writer", + allowAgents: mainAllowAgents(config), + configuredAgentIds: configuredAgentIds(config), + }); + expect(verdict.ok).toBe(false); + expect(verdict.reason).toMatch(/not in allowAgents/); + }); + + it("rejects unknown target ids even when wildcard is configured", () => { + const config = buildBakedConfig({ + NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64({ + agents: [ + { + id: "research", + workspace: "/sandbox/.openclaw/workspace-research", + agentDir: "/sandbox/.openclaw/agents/research", + tools: { profile: "minimal", allow: ["read"] }, + }, + ], + main: { + subagents: { + allowAgents: ["*"], + }, + }, + }), + }); + const allow = mainAllowAgents(config); + expect(allow).toEqual(["*"]); + expect( + resolveSubagentTargetPolicy({ + requesterAgentId: "main", + targetAgentId: "research", + allowAgents: allow, + configuredAgentIds: configuredAgentIds(config), + }).ok, + ).toBe(true); + const denied = resolveSubagentTargetPolicy({ + requesterAgentId: "main", + targetAgentId: "ghost", + allowAgents: allow, + configuredAgentIds: configuredAgentIds(config), + }); + expect(denied.ok).toBe(false); + expect(denied.reason).toMatch(/unknown target/); + }); + + it("falls back to self-only when allowAgents is omitted", () => { + const config = buildBakedConfig({ + NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64({ + agents: [ + { + id: "research", + workspace: "/sandbox/.openclaw/workspace-research", + agentDir: "/sandbox/.openclaw/agents/research", + tools: { profile: "minimal", allow: ["read"] }, + }, + ], + }), + }); + expect(mainAllowAgents(config)).toBeUndefined(); + expect( + resolveSubagentTargetPolicy({ + requesterAgentId: "main", + targetAgentId: "main", + allowAgents: undefined, + configuredAgentIds: configuredAgentIds(config), + }).ok, + ).toBe(true); + const denied = resolveSubagentTargetPolicy({ + requesterAgentId: "main", + targetAgentId: "research", + allowAgents: undefined, + configuredAgentIds: configuredAgentIds(config), + }); + expect(denied.ok).toBe(false); + expect(denied.reason).toMatch(/self-only/); + }); +}); From 222486fa635ccf5bb8910dd8a0c06f982622decf Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Mon, 15 Jun 2026 06:08:59 +0000 Subject: [PATCH 07/13] feat(sandbox): add `nemoclaw agents apply -f ` verb Signed-off-by: Tinson Lai --- .../inference/declarative-agents-manifest.mdx | 12 + docs/reference/commands-nemohermes.mdx | 14 + docs/reference/commands.mdx | 14 + src/commands/sandbox/agents.ts | 1 + src/commands/sandbox/agents/apply.ts | 101 +++++++ src/lib/actions/sandbox/agents/apply.test.ts | 193 +++++++++++++ src/lib/actions/sandbox/agents/apply.ts | 258 ++++++++++++++++++ src/lib/actions/sandbox/agents/passthrough.ts | 1 + 8 files changed, 594 insertions(+) create mode 100644 src/commands/sandbox/agents/apply.ts create mode 100644 src/lib/actions/sandbox/agents/apply.test.ts create mode 100644 src/lib/actions/sandbox/agents/apply.ts diff --git a/docs/inference/declarative-agents-manifest.mdx b/docs/inference/declarative-agents-manifest.mdx index da9f115a33f..1c97b7681a9 100644 --- a/docs/inference/declarative-agents-manifest.mdx +++ b/docs/inference/declarative-agents-manifest.mdx @@ -158,6 +158,18 @@ Workspaces under `/sandbox/.openclaw/workspace-` are preserved across rebuil For ad-hoc per-agent edits inside an existing sandbox (no rebuild), use the in-sandbox CLI: `nemoclaw agents add|delete|list`. The manifest path is for fixed, checked-in layouts; the CLI passthrough is for interactive work. +## Apply To An Existing Sandbox + +`nemoclaw agents apply -f ` reconciles the live sandbox roster against the manifest **without a rebuild**. +The verb lists current agents via `openclaw agents list --json`, diffs them against the manifest, and drives `openclaw agents add|delete` per item. +Per-agent `model`, `subagents.*`, top-level `defaults`, and `main` overrides require a sandbox rebuild and are reported as warnings the verb prints before exit; rerun `nemoclaw onboard --agents --recreate-sandbox` to bake those. + +```bash +nemoclaw my-assistant agents apply -f ./agents.yaml --yes +``` + +The flag pair `--yes / --non-interactive` is required for scripted use: `--yes` confirms the printed roster diff, and `--non-interactive` makes the verb fail fast when `--yes` is absent rather than waiting for an interactive prompt that scripted callers cannot deliver. + ## Next Steps Use the following resources for more information: diff --git a/docs/reference/commands-nemohermes.mdx b/docs/reference/commands-nemohermes.mdx index 9f59a962a88..e9f2345cffd 100644 --- a/docs/reference/commands-nemohermes.mdx +++ b/docs/reference/commands-nemohermes.mdx @@ -1014,6 +1014,20 @@ nemohermes my-assistant agents delete work nemohermes my-assistant agents delete work --force --json ``` +### `nemohermes agents apply` + +Reconcile the live sandbox roster against a declarative [agents.yaml manifest](../inference/declarative-agents-manifest). +The verb lists current agents via `openclaw agents list --json`, diffs them against the manifest, and adds missing secondaries or deletes orphan ones through `openclaw agents add|delete`. +Per-agent `model`, `subagents.*`, top-level `defaults`, and `main` overrides need a sandbox rebuild and are surfaced as warnings rather than silently dropped; rerun `nemohermes onboard --agents --recreate-sandbox` to bake those fields. + +```bash +nemohermes my-assistant agents apply -f ./agents.yaml +nemohermes my-assistant agents apply -f ./agents.yaml --yes +nemohermes my-assistant agents apply -f ./agents.yaml --yes --non-interactive +``` + +Pass `--yes` to confirm the roster diff above; `--non-interactive` fails fast when `--yes` is absent so scripted callers cannot accidentally hang on a missing prompt. + ### `nemohermes sessions` List OpenClaw conversation sessions in the sandbox. diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 18aebf216d0..cca593c78da 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1250,6 +1250,20 @@ $$nemoclaw my-assistant agents delete work $$nemoclaw my-assistant agents delete work --force --json ``` +### `$$nemoclaw agents apply` + +Reconcile the live sandbox roster against a declarative [agents.yaml manifest](../inference/declarative-agents-manifest). +The verb lists current agents via `openclaw agents list --json`, diffs them against the manifest, and adds missing secondaries or deletes orphan ones through `openclaw agents add|delete`. +Per-agent `model`, `subagents.*`, top-level `defaults`, and `main` overrides need a sandbox rebuild and are surfaced as warnings rather than silently dropped; rerun `nemoclaw onboard --agents --recreate-sandbox` to bake those fields. + +```bash +$$nemoclaw my-assistant agents apply -f ./agents.yaml +$$nemoclaw my-assistant agents apply -f ./agents.yaml --yes +$$nemoclaw my-assistant agents apply -f ./agents.yaml --yes --non-interactive +``` + +Pass `--yes` to confirm the roster diff above; `--non-interactive` fails fast when `--yes` is absent so scripted callers cannot accidentally hang on a missing prompt. + ### `$$nemoclaw sessions` List OpenClaw conversation sessions in the sandbox. diff --git a/src/commands/sandbox/agents.ts b/src/commands/sandbox/agents.ts index 6e19ce46e09..ced6311ad74 100644 --- a/src/commands/sandbox/agents.ts +++ b/src/commands/sandbox/agents.ts @@ -15,6 +15,7 @@ export default class SandboxAgentsCommand extends NemoClawCommand { "<%= config.bin %> sandbox agents alpha --help", "<%= config.bin %> sandbox agents list alpha --json", "<%= config.bin %> sandbox agents add alpha work --model gpt-4o", + "<%= config.bin %> sandbox agents apply alpha -f ./agents.yaml --yes", "<%= config.bin %> sandbox agents delete alpha work --force --json", ]; diff --git a/src/commands/sandbox/agents/apply.ts b/src/commands/sandbox/agents/apply.ts new file mode 100644 index 00000000000..ac7ec05efc8 --- /dev/null +++ b/src/commands/sandbox/agents/apply.ts @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import path from "node:path"; +import { runAgentsApply } from "../../../lib/actions/sandbox/agents/apply"; +import { CLI_NAME } from "../../../lib/cli/branding"; +import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; + +function printApplyHelp(): void { + console.log(""); + console.log( + ` Usage: ${CLI_NAME} agents apply -f [--yes] [--non-interactive]`, + ); + console.log(""); + console.log( + ` Reconcile the live sandbox roster against a declarative agents manifest. The verb`, + ); + console.log( + " adds missing secondary agents and deletes orphan ones via `openclaw agents add|delete`.", + ); + console.log( + " Per-agent `model`, `subagents.*`, top-level `defaults`, and `main` overrides require a", + ); + console.log( + " sandbox rebuild and are reported as warnings; rerun `nemoclaw onboard --agents ", + ); + console.log(" --recreate-sandbox` to bake them."); + console.log(""); + console.log(" Flags:"); + console.log(" -f, --file Path to the manifest (required)."); + console.log( + " --yes Confirm roster changes without an interactive prompt.", + ); + console.log(" --non-interactive Fail fast if `--yes` is not supplied."); + console.log(""); +} + +export default class SandboxAgentsApplyCommand extends NemoClawCommand { + static id = "sandbox:agents:apply"; + static strict = false; + static summary = "Reconcile a sandbox's OpenClaw agents against a declarative manifest"; + static description = + "Read an `agents.yaml` manifest and apply roster diffs (add/delete) to the live sandbox via `openclaw agents add|delete`. Per-agent config fields (`model`, `subagents.*`, top-level `defaults`, `main`) need a rebuild and are surfaced as warnings instead of silent no-ops."; + static usage = [" agents apply -f [--yes] [--non-interactive]"]; + static examples = [ + "<%= config.bin %> sandbox agents apply alpha -f ./agents.yaml", + "<%= config.bin %> sandbox agents apply alpha -f ./agents.yaml --yes", + ]; + + public async run(): Promise { + this.parsed = true; + const [sandboxName, ...rest] = this.argv; + if (!sandboxName || sandboxName === "--help" || sandboxName === "-h") { + printApplyHelp(); + return; + } + let manifestPath: string | undefined; + let yes = false; + let nonInteractive = false; + for (let index = 0; index < rest.length; index++) { + const arg = rest[index]; + if (arg === "--help" || arg === "-h") { + printApplyHelp(); + return; + } + if (arg === "-f" || arg === "--file") { + const value = rest[index + 1]; + if (typeof value !== "string" || !value || value.startsWith("--")) { + console.error(" -f/--file requires a path to a YAML manifest"); + printApplyHelp(); + process.exit(1); + } + manifestPath = value; + index += 1; + continue; + } + if (arg === "--yes" || arg === "-y") { + yes = true; + continue; + } + if (arg === "--non-interactive") { + nonInteractive = true; + continue; + } + console.error(` Unknown flag: ${arg}`); + printApplyHelp(); + process.exit(1); + } + if (!manifestPath) { + console.error(" -f/--file is required"); + printApplyHelp(); + process.exit(1); + } + await runAgentsApply({ + sandboxName, + manifestPath: path.resolve(manifestPath), + yes, + nonInteractive, + }); + } +} diff --git a/src/lib/actions/sandbox/agents/apply.test.ts b/src/lib/actions/sandbox/agents/apply.test.ts new file mode 100644 index 00000000000..7251f9fd419 --- /dev/null +++ b/src/lib/actions/sandbox/agents/apply.test.ts @@ -0,0 +1,193 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { buildAgentsApplyDiff, computeAgentsApplyDiff, runAgentsApply } from "./apply"; + +let tmpDir: string; + +function manifestFile(name: string, content: string): string { + const file = path.join(tmpDir, name); + fs.writeFileSync(file, content, "utf-8"); + return file; +} + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-agents-apply-")); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe("computeAgentsApplyDiff", () => { + it("adds manifest agents that are not currently in the sandbox", () => { + const result = computeAgentsApplyDiff( + [{ id: "main" }], + [ + { id: "research", workspace: "/sandbox/.openclaw/workspace-research" }, + { id: "writer", workspace: "/sandbox/.openclaw/workspace-writer" }, + ], + ); + expect(result.toAdd).toEqual([ + { id: "research", workspace: "/sandbox/.openclaw/workspace-research", agentDir: undefined }, + { id: "writer", workspace: "/sandbox/.openclaw/workspace-writer", agentDir: undefined }, + ]); + expect(result.toDelete).toEqual([]); + }); + + it("deletes secondary agents that are missing from the manifest", () => { + const result = computeAgentsApplyDiff( + [{ id: "main" }, { id: "research" }, { id: "obsolete" }], + [{ id: "research" }], + ); + expect(result.toAdd).toEqual([]); + expect(result.toDelete).toEqual(["obsolete"]); + }); + + it("never deletes the canonical main agent even when missing from the manifest", () => { + const result = computeAgentsApplyDiff([{ id: "main" }], []); + expect(result.toDelete).toEqual([]); + }); + + it("ignores the manifest entry whose id is 'main'", () => { + const result = computeAgentsApplyDiff([{ id: "main" }], [{ id: "main" }, { id: "alpha" }]); + expect(result.toAdd).toEqual([{ id: "alpha", workspace: undefined, agentDir: undefined }]); + }); +}); + +describe("buildAgentsApplyDiff", () => { + it("surfaces rebuild-only fields when the manifest declares any", () => { + const diff = buildAgentsApplyDiff([{ id: "main" }, { id: "alpha" }], { + agents: [ + { id: "alpha", model: "test/secondary" }, + { id: "beta", subagents: { allowAgents: ["alpha"] } }, + ], + defaults: { subagents: { maxSpawnDepth: 3 } }, + main: { subagents: { allowAgents: ["alpha"] } }, + }); + expect(diff.toAdd.map((entry) => entry.id)).toEqual(["beta"]); + expect(diff.toDelete).toEqual([]); + expect(diff.rebuildOnlyFields.sort()).toEqual( + ["agents[alpha].model", "agents[beta].subagents", "defaults", "main"].sort(), + ); + }); +}); + +describe("runAgentsApply", () => { + it("invokes add for missing manifest agents and delete for orphans", async () => { + const manifestPath = manifestFile( + "roster.yaml", + [ + "agents:", + " - id: alpha", + " tools:", + " allow: [read]", + " - id: bravo", + " tools:", + " allow: [read]", + "", + ].join("\n"), + ); + const log = vi.fn(); + const ensureLive = vi.fn(async () => undefined); + const listAgents = vi.fn(() => [{ id: "main" }, { id: "obsolete" }]); + const addAgent = vi.fn(); + const deleteAgent = vi.fn(); + await runAgentsApply( + { sandboxName: "my-assistant", manifestPath, yes: true }, + { ensureLive, listAgents, addAgent, deleteAgent, log }, + ); + expect(ensureLive).toHaveBeenCalledWith("my-assistant", { allowNonReadyPhase: false }); + expect(deleteAgent.mock.calls).toEqual([["my-assistant", "obsolete"]]); + expect(addAgent.mock.calls.map(([, id]) => id)).toEqual(["alpha", "bravo"]); + }); + + it("prints rebuild-only fields without applying them", async () => { + const manifestPath = manifestFile( + "rebuild-only.yaml", + [ + "defaults:", + " subagents:", + " maxSpawnDepth: 3", + "agents:", + " - id: alpha", + " model: test-provider/alpha", + " tools:", + " allow: [read]", + "", + ].join("\n"), + ); + const messages: string[] = []; + const addAgent = vi.fn(); + const deleteAgent = vi.fn(); + await runAgentsApply( + { sandboxName: "my-assistant", manifestPath, yes: true }, + { + ensureLive: async () => undefined, + listAgents: () => [{ id: "main" }, { id: "alpha" }], + addAgent, + deleteAgent, + log: (message) => messages.push(message), + }, + ); + expect(messages.some((line) => line.includes("agents[alpha].model"))).toBe(true); + expect(messages.some((line) => line.includes("defaults"))).toBe(true); + expect(messages.some((line) => line.includes("--recreate-sandbox"))).toBe(true); + expect(addAgent).not.toHaveBeenCalled(); + expect(deleteAgent).not.toHaveBeenCalled(); + }); + + it("refuses to apply roster changes without --yes in non-interactive mode", async () => { + const manifestPath = manifestFile( + "roster.yaml", + ["agents:", " - id: alpha", " tools:", " allow: [read]", ""].join("\n"), + ); + const exit = vi.fn((code: number) => { + throw new Error(`exit:${code}`); + }); + const addAgent = vi.fn(); + await expect( + runAgentsApply( + { sandboxName: "my-assistant", manifestPath, nonInteractive: true }, + { + ensureLive: async () => undefined, + listAgents: () => [{ id: "main" }], + addAgent, + deleteAgent: vi.fn(), + log: () => {}, + exit: exit as unknown as (code: number) => never, + }, + ), + ).rejects.toThrow("exit:1"); + expect(addAgent).not.toHaveBeenCalled(); + }); + + it("returns cleanly when the roster already matches the manifest", async () => { + const manifestPath = manifestFile( + "match.yaml", + ["agents:", " - id: alpha", " tools:", " allow: [read]", ""].join("\n"), + ); + const messages: string[] = []; + const addAgent = vi.fn(); + const deleteAgent = vi.fn(); + await runAgentsApply( + { sandboxName: "my-assistant", manifestPath }, + { + ensureLive: async () => undefined, + listAgents: () => [{ id: "main" }, { id: "alpha" }], + addAgent, + deleteAgent, + log: (message) => messages.push(message), + }, + ); + expect(addAgent).not.toHaveBeenCalled(); + expect(deleteAgent).not.toHaveBeenCalled(); + expect(messages.some((line) => /No roster changes to apply/.test(line))).toBe(true); + }); +}); diff --git a/src/lib/actions/sandbox/agents/apply.ts b/src/lib/actions/sandbox/agents/apply.ts new file mode 100644 index 00000000000..91c8bef1b7f --- /dev/null +++ b/src/lib/actions/sandbox/agents/apply.ts @@ -0,0 +1,258 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; + +import { loadAgentsManifest } from "../../../onboard/agents-manifest"; +import { buildOpenshellExecArgs } from "../exec"; + +// Lazy-require `ensureLiveSandboxOrExit` because its import chain pulls in +// `runner`/`./platform`, which the Vitest TS loader cannot resolve at module +// load. Matches the pattern in `auto-pair-approval.ts`. +type EnsureLive = ( + sandboxName: string, + options?: { allowNonReadyPhase?: boolean }, +) => Promise; +function lazyEnsureLive(): EnsureLive { + return (require("../gateway-state") as typeof import("../gateway-state")) + .ensureLiveSandboxOrExit as EnsureLive; +} + +interface OpenClawAgentEntry { + id: string; + workspace?: string; + agentDir?: string; +} + +export interface AgentsApplyDiff { + toAdd: Array<{ id: string; workspace?: string; agentDir?: string }>; + toDelete: string[]; + rebuildOnlyFields: string[]; +} + +const MAIN_AGENT_ID = "main"; +const PROTECTED_IDS = new Set([MAIN_AGENT_ID]); + +// Manifest fields that v1 apply cannot reconcile without a rebuild. +// `main.tools`/`main.subagents`, `defaults.subagents.*`, per-agent `model`, +// and per-agent `subagents.*` all live in baked `openclaw.json` keys that +// require `openclaw config set` choreography we have not built yet; for now +// they require `nemoclaw onboard --agents --recreate-sandbox`. +function findRebuildOnlyFields(manifest: { + agents: unknown[]; + defaults?: unknown; + main?: unknown; +}): string[] { + const findings: string[] = []; + if ( + manifest.defaults && + typeof manifest.defaults === "object" && + Object.keys(manifest.defaults as Record).length > 0 + ) { + findings.push("defaults"); + } + if ( + manifest.main && + typeof manifest.main === "object" && + Object.keys(manifest.main as Record).length > 0 + ) { + findings.push("main"); + } + for (const entry of manifest.agents) { + if (entry === null || typeof entry !== "object") continue; + const record = entry as Record; + const id = typeof record.id === "string" ? record.id : "?"; + if (record.model !== undefined) { + findings.push(`agents[${id}].model`); + } + if ( + record.subagents && + typeof record.subagents === "object" && + Object.keys(record.subagents as Record).length > 0 + ) { + findings.push(`agents[${id}].subagents`); + } + } + return findings; +} + +export function computeAgentsApplyDiff( + currentList: OpenClawAgentEntry[], + manifestAgents: unknown[], +): { toAdd: AgentsApplyDiff["toAdd"]; toDelete: string[] } { + const currentIds = new Set(currentList.map((entry) => entry.id)); + const manifestIds = new Set(); + const toAdd: AgentsApplyDiff["toAdd"] = []; + for (const entry of manifestAgents) { + if (entry === null || typeof entry !== "object") continue; + const record = entry as Record; + const id = record.id; + if (typeof id !== "string" || !id) continue; + manifestIds.add(id); + if (id === MAIN_AGENT_ID) continue; + if (currentIds.has(id)) continue; + toAdd.push({ + id, + workspace: typeof record.workspace === "string" ? record.workspace : undefined, + agentDir: typeof record.agentDir === "string" ? record.agentDir : undefined, + }); + } + const toDelete: string[] = []; + for (const entry of currentList) { + if (PROTECTED_IDS.has(entry.id)) continue; + if (!manifestIds.has(entry.id)) { + toDelete.push(entry.id); + } + } + return { toAdd, toDelete }; +} + +export function buildAgentsApplyDiff( + currentList: OpenClawAgentEntry[], + manifest: { agents: unknown[]; defaults?: unknown; main?: unknown }, +): AgentsApplyDiff { + const { toAdd, toDelete } = computeAgentsApplyDiff(currentList, manifest.agents); + return { + toAdd, + toDelete, + rebuildOnlyFields: findRebuildOnlyFields(manifest), + }; +} + +export interface RunAgentsApplyOptions { + sandboxName: string; + manifestPath: string; + yes?: boolean; + nonInteractive?: boolean; +} + +export interface RunAgentsApplyDeps { + ensureLive?: EnsureLive; + listAgents?: (sandboxName: string) => OpenClawAgentEntry[]; + addAgent?: (sandboxName: string, id: string, workspace: string | undefined) => void; + deleteAgent?: (sandboxName: string, id: string) => void; + log?: (message: string) => void; + exit?: (code: number) => never; +} + +function defaultListAgents(sandboxName: string): OpenClawAgentEntry[] { + const { getOpenshellBinary } = + require("../../../adapters/openshell/runtime") as typeof import("../../../adapters/openshell/runtime"); + const result = spawnSync( + getOpenshellBinary(), + buildOpenshellExecArgs(sandboxName, ["openclaw", "agents", "list", "--json"]), + { stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }, + ); + if (result.status !== 0) { + const stderr = String(result.stderr || "").trim(); + throw new Error( + `openclaw agents list --json failed (exit ${result.status ?? "?"})${stderr ? `: ${stderr}` : ""}`, + ); + } + const parsed = JSON.parse(String(result.stdout || "[]")); + if (!Array.isArray(parsed)) { + throw new Error("openclaw agents list --json did not return a JSON array"); + } + return parsed.filter((entry): entry is OpenClawAgentEntry => { + return ( + entry !== null && + typeof entry === "object" && + typeof (entry as { id?: unknown }).id === "string" + ); + }); +} + +function defaultAddAgent(sandboxName: string, id: string, workspace: string | undefined): void { + const { getOpenshellBinary } = + require("../../../adapters/openshell/runtime") as typeof import("../../../adapters/openshell/runtime"); + const args = ["openclaw", "agents", "add", id, "--non-interactive"]; + if (workspace) { + args.push("--workspace", workspace); + } + const result = spawnSync(getOpenshellBinary(), buildOpenshellExecArgs(sandboxName, args), { + stdio: "inherit", + }); + if (result.status !== 0) { + throw new Error(`openclaw agents add ${id} failed (exit ${result.status ?? "?"})`); + } +} + +function defaultDeleteAgent(sandboxName: string, id: string): void { + const { getOpenshellBinary } = + require("../../../adapters/openshell/runtime") as typeof import("../../../adapters/openshell/runtime"); + const result = spawnSync( + getOpenshellBinary(), + buildOpenshellExecArgs(sandboxName, [ + "openclaw", + "agents", + "delete", + id, + "--force", + "--non-interactive", + ]), + { stdio: "inherit" }, + ); + if (result.status !== 0) { + throw new Error(`openclaw agents delete ${id} failed (exit ${result.status ?? "?"})`); + } +} + +export async function runAgentsApply( + options: RunAgentsApplyOptions, + deps: RunAgentsApplyDeps = {}, +): Promise { + const log = deps.log ?? ((message: string) => console.log(message)); + const exit = deps.exit ?? ((code: number) => process.exit(code)); + const ensureLive = deps.ensureLive ?? lazyEnsureLive(); + const listAgents = deps.listAgents ?? defaultListAgents; + const addAgent = deps.addAgent ?? defaultAddAgent; + const deleteAgent = deps.deleteAgent ?? defaultDeleteAgent; + + await ensureLive(options.sandboxName, { allowNonReadyPhase: false }); + + const manifest = loadAgentsManifest(options.manifestPath); + const currentList = listAgents(options.sandboxName); + const diff = buildAgentsApplyDiff(currentList, manifest); + + log(` Sandbox: ${options.sandboxName}`); + log(` Manifest: ${options.manifestPath}`); + log( + ` Plan: ${diff.toAdd.length} agent(s) to add, ${diff.toDelete.length} to delete, ${currentList.length} currently present.`, + ); + for (const entry of diff.toAdd) { + log(` + ${entry.id}`); + } + for (const id of diff.toDelete) { + log(` - ${id}`); + } + if (diff.rebuildOnlyFields.length > 0) { + log(""); + log(" ⚠ The following manifest fields require a sandbox rebuild and are not applied here:"); + for (const field of diff.rebuildOnlyFields) { + log(` - ${field}`); + } + log(" Run `nemoclaw onboard --agents --recreate-sandbox` to bake those fields."); + } + if (diff.toAdd.length === 0 && diff.toDelete.length === 0) { + log(" No roster changes to apply."); + return; + } + if (!options.yes && options.nonInteractive) { + log(" Pass --yes to apply roster changes in non-interactive mode."); + exit(1); + } + if (!options.yes && !options.nonInteractive) { + log(" Pass --yes to confirm the roster changes above."); + exit(2); + } + + for (const id of diff.toDelete) { + log(` Deleting agent: ${id}`); + deleteAgent(options.sandboxName, id); + } + for (const entry of diff.toAdd) { + log(` Adding agent: ${entry.id}`); + addAgent(options.sandboxName, entry.id, entry.workspace); + } + log(" Apply complete."); +} diff --git a/src/lib/actions/sandbox/agents/passthrough.ts b/src/lib/actions/sandbox/agents/passthrough.ts index 82a3eb5dc4a..0ea6d0182d6 100644 --- a/src/lib/actions/sandbox/agents/passthrough.ts +++ b/src/lib/actions/sandbox/agents/passthrough.ts @@ -43,6 +43,7 @@ export function printAgentsParentHelp(): void { console.log(""); console.log(" Subcommands:"); console.log(" add Add an OpenClaw agent in the sandbox."); + console.log(" apply Reconcile the sandbox roster against an agents.yaml manifest."); console.log(" delete Delete an OpenClaw agent in the sandbox."); console.log(" list List OpenClaw agents configured in the sandbox."); console.log(""); From a918593741842465984d485f7a2a8320b2f602d4 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Mon, 15 Jun 2026 08:05:46 +0000 Subject: [PATCH 08/13] fix(sandbox): register agents apply in display layout + parity + variant sync Signed-off-by: Tinson Lai --- docs/reference/commands-nemohermes.mdx | 8 ++++---- docs/reference/commands.mdx | 8 ++++---- src/lib/cli/command-registry.test.ts | 12 ++++++------ src/lib/cli/public-display-agents.ts | 8 ++++++++ 4 files changed, 22 insertions(+), 14 deletions(-) diff --git a/docs/reference/commands-nemohermes.mdx b/docs/reference/commands-nemohermes.mdx index e9f2345cffd..09d949e0f64 100644 --- a/docs/reference/commands-nemohermes.mdx +++ b/docs/reference/commands-nemohermes.mdx @@ -1018,7 +1018,7 @@ nemohermes my-assistant agents delete work --force --json Reconcile the live sandbox roster against a declarative [agents.yaml manifest](../inference/declarative-agents-manifest). The verb lists current agents via `openclaw agents list --json`, diffs them against the manifest, and adds missing secondaries or deletes orphan ones through `openclaw agents add|delete`. -Per-agent `model`, `subagents.*`, top-level `defaults`, and `main` overrides need a sandbox rebuild and are surfaced as warnings rather than silently dropped; rerun `nemohermes onboard --agents --recreate-sandbox` to bake those fields. +Per-agent `model`, `subagents.*`, top-level `defaults`, and `main` overrides need a sandbox rebuild and are surfaced as warnings rather than silently dropped; rerun `nemohermes onboard --agents --recreate-sandbox` to bake those fields. ```bash nemohermes my-assistant agents apply -f ./agents.yaml @@ -1026,7 +1026,7 @@ nemohermes my-assistant agents apply -f ./agents.yaml --yes nemohermes my-assistant agents apply -f ./agents.yaml --yes --non-interactive ``` -Pass `--yes` to confirm the roster diff above; `--non-interactive` fails fast when `--yes` is absent so scripted callers cannot accidentally hang on a missing prompt. +Pass `-f` / `--file ` to point at the manifest; `--yes` confirms the roster diff above; `--non-interactive` fails fast when `--yes` is absent so scripted callers cannot accidentally hang on a missing prompt. ### `nemohermes sessions` @@ -1496,7 +1496,7 @@ The `nemohermes setup` command is deprecated. Use `nemohermes onboard` instead. -This command remains as a compatibility alias to `nemohermes onboard` and accepts the same flags: `--non-interactive`, `--resume`, `--fresh`, `--recreate-sandbox`, `--gpu` / `--no-gpu`, `--from`, `--name`, `--sandbox-gpu` / `--no-sandbox-gpu`, `--sandbox-gpu-device`, `--agent`, `--agents`, `--control-ui-port`, `--yes` / `-y`, `--no-ollama-autostart`, `--yes-i-accept-third-party-software`. +This command remains as a compatibility alias to `nemohermes onboard` and accepts the same flags: `--non-interactive`, `--resume`, `--fresh`, `--recreate-sandbox`, `--gpu` / `--no-gpu`, `--from`, `--name`, `--sandbox-gpu` / `--no-sandbox-gpu`, `--sandbox-gpu-device`, `--agent`, `--agents `, `--control-ui-port`, `--yes` / `-y`, `--no-ollama-autostart`, `--yes-i-accept-third-party-software`. ```bash nemohermes setup @@ -1509,7 +1509,7 @@ The `nemohermes setup-spark` command is deprecated. Use the standard installer and run `nemohermes onboard` instead, because current OpenShell releases handle the older DGX Spark cgroup behavior. -This command remains as a compatibility alias to `nemohermes onboard` and accepts the same flags: `--non-interactive`, `--resume`, `--fresh`, `--recreate-sandbox`, `--gpu` / `--no-gpu`, `--from`, `--name`, `--sandbox-gpu` / `--no-sandbox-gpu`, `--sandbox-gpu-device`, `--agent`, `--agents`, `--control-ui-port`, `--yes` / `-y`, `--no-ollama-autostart`, `--yes-i-accept-third-party-software`. +This command remains as a compatibility alias to `nemohermes onboard` and accepts the same flags: `--non-interactive`, `--resume`, `--fresh`, `--recreate-sandbox`, `--gpu` / `--no-gpu`, `--from`, `--name`, `--sandbox-gpu` / `--no-sandbox-gpu`, `--sandbox-gpu-device`, `--agent`, `--agents `, `--control-ui-port`, `--yes` / `-y`, `--no-ollama-autostart`, `--yes-i-accept-third-party-software`. ```bash nemohermes setup-spark diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index cca593c78da..241a79dd5fd 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1254,7 +1254,7 @@ $$nemoclaw my-assistant agents delete work --force --json Reconcile the live sandbox roster against a declarative [agents.yaml manifest](../inference/declarative-agents-manifest). The verb lists current agents via `openclaw agents list --json`, diffs them against the manifest, and adds missing secondaries or deletes orphan ones through `openclaw agents add|delete`. -Per-agent `model`, `subagents.*`, top-level `defaults`, and `main` overrides need a sandbox rebuild and are surfaced as warnings rather than silently dropped; rerun `nemoclaw onboard --agents --recreate-sandbox` to bake those fields. +Per-agent `model`, `subagents.*`, top-level `defaults`, and `main` overrides need a sandbox rebuild and are surfaced as warnings rather than silently dropped; rerun `$$nemoclaw onboard --agents --recreate-sandbox` to bake those fields. ```bash $$nemoclaw my-assistant agents apply -f ./agents.yaml @@ -1262,7 +1262,7 @@ $$nemoclaw my-assistant agents apply -f ./agents.yaml --yes $$nemoclaw my-assistant agents apply -f ./agents.yaml --yes --non-interactive ``` -Pass `--yes` to confirm the roster diff above; `--non-interactive` fails fast when `--yes` is absent so scripted callers cannot accidentally hang on a missing prompt. +Pass `-f` / `--file ` to point at the manifest; `--yes` confirms the roster diff above; `--non-interactive` fails fast when `--yes` is absent so scripted callers cannot accidentally hang on a missing prompt. ### `$$nemoclaw sessions` @@ -1759,7 +1759,7 @@ The `$$nemoclaw setup` command is deprecated. Use `$$nemoclaw onboard` instead. -This command remains as a compatibility alias to `$$nemoclaw onboard` and accepts the same flags: `--non-interactive`, `--resume`, `--fresh`, `--recreate-sandbox`, `--gpu` / `--no-gpu`, `--from`, `--name`, `--sandbox-gpu` / `--no-sandbox-gpu`, `--sandbox-gpu-device`, `--agent`, `--agents`, `--control-ui-port`, `--yes` / `-y`, `--no-ollama-autostart`, `--yes-i-accept-third-party-software`. +This command remains as a compatibility alias to `$$nemoclaw onboard` and accepts the same flags: `--non-interactive`, `--resume`, `--fresh`, `--recreate-sandbox`, `--gpu` / `--no-gpu`, `--from`, `--name`, `--sandbox-gpu` / `--no-sandbox-gpu`, `--sandbox-gpu-device`, `--agent`, `--agents `, `--control-ui-port`, `--yes` / `-y`, `--no-ollama-autostart`, `--yes-i-accept-third-party-software`. ```bash $$nemoclaw setup @@ -1772,7 +1772,7 @@ The `$$nemoclaw setup-spark` command is deprecated. Use the standard installer and run `$$nemoclaw onboard` instead, because current OpenShell releases handle the older DGX Spark cgroup behavior. -This command remains as a compatibility alias to `$$nemoclaw onboard` and accepts the same flags: `--non-interactive`, `--resume`, `--fresh`, `--recreate-sandbox`, `--gpu` / `--no-gpu`, `--from`, `--name`, `--sandbox-gpu` / `--no-sandbox-gpu`, `--sandbox-gpu-device`, `--agent`, `--agents`, `--control-ui-port`, `--yes` / `-y`, `--no-ollama-autostart`, `--yes-i-accept-third-party-software`. +This command remains as a compatibility alias to `$$nemoclaw onboard` and accepts the same flags: `--non-interactive`, `--resume`, `--fresh`, `--recreate-sandbox`, `--gpu` / `--no-gpu`, `--from`, `--name`, `--sandbox-gpu` / `--no-sandbox-gpu`, `--sandbox-gpu-device`, `--agent`, `--agents `, `--control-ui-port`, `--yes` / `-y`, `--no-ollama-autostart`, `--yes-i-accept-third-party-software`. ```bash $$nemoclaw setup-spark diff --git a/src/lib/cli/command-registry.test.ts b/src/lib/cli/command-registry.test.ts index 708dd33a7e3..0292f5df12e 100644 --- a/src/lib/cli/command-registry.test.ts +++ b/src/lib/cli/command-registry.test.ts @@ -55,12 +55,12 @@ describe("command-registry", () => { }); describe("sandboxCommands()", () => { - it("should return exactly 47 entries", () => { - // 41 visible + 6 hidden (shields×3 + config get/set/rotate-token). - // 41 visible includes the sessions group (root + list + reset + delete + - // export), the agents trio (add + delete + list), and the download + - // upload host-side openshell wrappers. - expect(sandboxCommands()).toHaveLength(47); + it("should return exactly 48 entries", () => { + // 42 visible + 6 hidden (shields×3 + config get/set/rotate-token). + // 42 visible includes the sessions group (root + list + reset + delete + + // export), the agents quartet (add + apply + delete + list), and the + // download + upload host-side openshell wrappers. + expect(sandboxCommands()).toHaveLength(48); }); it("every entry has scope sandbox", () => { diff --git a/src/lib/cli/public-display-agents.ts b/src/lib/cli/public-display-agents.ts index 3dd03c3bee0..52f787185d7 100644 --- a/src/lib/cli/public-display-agents.ts +++ b/src/lib/cli/public-display-agents.ts @@ -28,4 +28,12 @@ export const SANDBOX_AGENTS_DISPLAY_LAYOUT: Record [--yes] [--non-interactive]", + description: "Reconcile sandbox agents against a declarative manifest", + }, + ], }; From d0191dcdd38aaa683f82de904c37dc6b3c06ebb6 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Mon, 15 Jun 2026 13:12:25 +0000 Subject: [PATCH 09/13] fix(sandbox): treat agents[].tools as rebuild-only in apply Signed-off-by: Tinson Lai --- docs/reference/commands.mdx | 2 +- src/lib/actions/sandbox/agents/apply.test.ts | 48 ++++++++++++++++ src/lib/actions/sandbox/agents/apply.ts | 59 ++++++++++++++------ 3 files changed, 90 insertions(+), 19 deletions(-) diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 241a79dd5fd..cb48bd5bd60 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1254,7 +1254,7 @@ $$nemoclaw my-assistant agents delete work --force --json Reconcile the live sandbox roster against a declarative [agents.yaml manifest](../inference/declarative-agents-manifest). The verb lists current agents via `openclaw agents list --json`, diffs them against the manifest, and adds missing secondaries or deletes orphan ones through `openclaw agents add|delete`. -Per-agent `model`, `subagents.*`, top-level `defaults`, and `main` overrides need a sandbox rebuild and are surfaced as warnings rather than silently dropped; rerun `$$nemoclaw onboard --agents --recreate-sandbox` to bake those fields. +Per-agent `model`, `subagents.*`, `tools`, top-level `defaults`, and `main` overrides need a sandbox rebuild and are surfaced as warnings rather than silently dropped; rerun `$$nemoclaw onboard --agents --recreate-sandbox` to bake those fields. ```bash $$nemoclaw my-assistant agents apply -f ./agents.yaml diff --git a/src/lib/actions/sandbox/agents/apply.test.ts b/src/lib/actions/sandbox/agents/apply.test.ts index 7251f9fd419..3f909418140 100644 --- a/src/lib/actions/sandbox/agents/apply.test.ts +++ b/src/lib/actions/sandbox/agents/apply.test.ts @@ -77,6 +77,21 @@ describe("buildAgentsApplyDiff", () => { ["agents[alpha].model", "agents[beta].subagents", "defaults", "main"].sort(), ); }); + + it("treats per-agent tools as rebuild-only so a live add cannot drop a tool policy", () => { + const diff = buildAgentsApplyDiff([{ id: "main" }], { + agents: [ + { id: "alpha", tools: { allow: ["read"] } }, + { id: "beta", tools: { allow: [], deny: ["write"] } }, + { id: "gamma", tools: {} }, + { id: "delta", tools: [] }, + ], + }); + expect(diff.toAdd.map((entry) => entry.id)).toEqual(["alpha", "beta", "gamma", "delta"]); + expect(diff.rebuildOnlyFields.sort()).toEqual( + ["agents[alpha].tools", "agents[beta].tools"].sort(), + ); + }); }); describe("runAgentsApply", () => { @@ -168,6 +183,39 @@ describe("runAgentsApply", () => { expect(addAgent).not.toHaveBeenCalled(); }); + it("warns before adding a manifest agent that declares a tools policy", async () => { + const manifestPath = manifestFile( + "tools.yaml", + ["agents:", " - id: alpha", " tools:", " allow: [read]", " - id: bravo", ""].join( + "\n", + ), + ); + const messages: string[] = []; + const addAgent = vi.fn(); + const deleteAgent = vi.fn(); + await runAgentsApply( + { sandboxName: "my-assistant", manifestPath, yes: true }, + { + ensureLive: async () => undefined, + listAgents: () => [{ id: "main" }], + addAgent, + deleteAgent, + log: (message) => messages.push(message), + }, + ); + expect(messages.some((line) => line.includes("agents[alpha].tools"))).toBe(true); + const warningIndex = messages.findIndex((line) => + line.includes('Manifest declares tools for "alpha"'), + ); + const addIndex = messages.findIndex((line) => line === " Adding agent: alpha"); + expect(warningIndex).toBeGreaterThanOrEqual(0); + expect(addIndex).toBeGreaterThan(warningIndex); + expect(messages.some((line) => line.includes('Manifest declares tools for "bravo"'))).toBe( + false, + ); + expect(addAgent.mock.calls.map(([, id]) => id)).toEqual(["alpha", "bravo"]); + }); + it("returns cleanly when the roster already matches the manifest", async () => { const manifestPath = manifestFile( "match.yaml", diff --git a/src/lib/actions/sandbox/agents/apply.ts b/src/lib/actions/sandbox/agents/apply.ts index 91c8bef1b7f..53963c56ad7 100644 --- a/src/lib/actions/sandbox/agents/apply.ts +++ b/src/lib/actions/sandbox/agents/apply.ts @@ -33,29 +33,33 @@ export interface AgentsApplyDiff { const MAIN_AGENT_ID = "main"; const PROTECTED_IDS = new Set([MAIN_AGENT_ID]); +function hasNonEmptyFields(value: unknown): boolean { + if (value === null || value === undefined) return false; + if (Array.isArray(value)) return value.length > 0; + if (typeof value === "object") { + return Object.keys(value as Record).length > 0; + } + return false; +} + // Manifest fields that v1 apply cannot reconcile without a rebuild. // `main.tools`/`main.subagents`, `defaults.subagents.*`, per-agent `model`, -// and per-agent `subagents.*` all live in baked `openclaw.json` keys that -// require `openclaw config set` choreography we have not built yet; for now -// they require `nemoclaw onboard --agents --recreate-sandbox`. +// per-agent `subagents.*`, and per-agent `tools` all live in baked +// `openclaw.json` keys that require `openclaw config set` choreography we +// have not built yet; for now they require `nemoclaw onboard --agents +// --recreate-sandbox`. The live `openclaw agents add` path does not bake a +// tool policy, so silently adding an agent that declares `tools` would let a +// manifest-required security boundary go missing in the sandbox. function findRebuildOnlyFields(manifest: { agents: unknown[]; defaults?: unknown; main?: unknown; }): string[] { const findings: string[] = []; - if ( - manifest.defaults && - typeof manifest.defaults === "object" && - Object.keys(manifest.defaults as Record).length > 0 - ) { + if (hasNonEmptyFields(manifest.defaults)) { findings.push("defaults"); } - if ( - manifest.main && - typeof manifest.main === "object" && - Object.keys(manifest.main as Record).length > 0 - ) { + if (hasNonEmptyFields(manifest.main)) { findings.push("main"); } for (const entry of manifest.agents) { @@ -65,17 +69,30 @@ function findRebuildOnlyFields(manifest: { if (record.model !== undefined) { findings.push(`agents[${id}].model`); } - if ( - record.subagents && - typeof record.subagents === "object" && - Object.keys(record.subagents as Record).length > 0 - ) { + if (hasNonEmptyFields(record.subagents)) { findings.push(`agents[${id}].subagents`); } + if (hasNonEmptyFields(record.tools)) { + findings.push(`agents[${id}].tools`); + } } return findings; } +function findManifestToolsByAgentId(manifestAgents: unknown[]): Set { + const ids = new Set(); + for (const entry of manifestAgents) { + if (entry === null || typeof entry !== "object") continue; + const record = entry as Record; + const id = record.id; + if (typeof id !== "string" || !id) continue; + if (hasNonEmptyFields(record.tools)) { + ids.add(id); + } + } + return ids; +} + export function computeAgentsApplyDiff( currentList: OpenClawAgentEntry[], manifestAgents: unknown[], @@ -246,11 +263,17 @@ export async function runAgentsApply( exit(2); } + const toolsAgentIds = findManifestToolsByAgentId(manifest.agents); for (const id of diff.toDelete) { log(` Deleting agent: ${id}`); deleteAgent(options.sandboxName, id); } for (const entry of diff.toAdd) { + if (toolsAgentIds.has(entry.id)) { + log( + ` ⚠ Manifest declares tools for "${entry.id}"; the live add cannot bake a tool policy. Rerun \`nemoclaw onboard --agents --recreate-sandbox\` to apply it.`, + ); + } log(` Adding agent: ${entry.id}`); addAgent(options.sandboxName, entry.id, entry.workspace); } From a740052a8a390390c922b99c64a4f3c378916bfa Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Mon, 15 Jun 2026 14:04:05 +0000 Subject: [PATCH 10/13] docs(reference): sync Hermes variant after agents apply tools warning Signed-off-by: Tinson Lai --- docs/reference/commands-nemohermes.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/commands-nemohermes.mdx b/docs/reference/commands-nemohermes.mdx index 09d949e0f64..c7888962645 100644 --- a/docs/reference/commands-nemohermes.mdx +++ b/docs/reference/commands-nemohermes.mdx @@ -1018,7 +1018,7 @@ nemohermes my-assistant agents delete work --force --json Reconcile the live sandbox roster against a declarative [agents.yaml manifest](../inference/declarative-agents-manifest). The verb lists current agents via `openclaw agents list --json`, diffs them against the manifest, and adds missing secondaries or deletes orphan ones through `openclaw agents add|delete`. -Per-agent `model`, `subagents.*`, top-level `defaults`, and `main` overrides need a sandbox rebuild and are surfaced as warnings rather than silently dropped; rerun `nemohermes onboard --agents --recreate-sandbox` to bake those fields. +Per-agent `model`, `subagents.*`, `tools`, top-level `defaults`, and `main` overrides need a sandbox rebuild and are surfaced as warnings rather than silently dropped; rerun `nemohermes onboard --agents --recreate-sandbox` to bake those fields. ```bash nemohermes my-assistant agents apply -f ./agents.yaml From 12ea9d789f5ebbbe27f9b27de4ceab4f5fa24a81 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Mon, 15 Jun 2026 14:37:58 +0000 Subject: [PATCH 11/13] fix(onboard): catch camelCase secret names in manifest credential scan Signed-off-by: Tinson Lai --- src/lib/onboard/agents-manifest.test.ts | 36 ++++++++++++++++++++++++ src/lib/onboard/agents-manifest.ts | 37 ++++++++++++++++++++++--- 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/src/lib/onboard/agents-manifest.test.ts b/src/lib/onboard/agents-manifest.test.ts index b4c849f8727..451245c16b0 100644 --- a/src/lib/onboard/agents-manifest.test.ts +++ b/src/lib/onboard/agents-manifest.test.ts @@ -156,11 +156,31 @@ describe("loadAgentsManifest", () => { for (const key of [ "apiKey", "api_key", + "API_KEY", "token", "secret", "password", + "passphrase", "credential", "bearer", + "auth", + "clientSecret", + "client_secret", + "accessToken", + "refreshToken", + "refresh-token", + "sessionToken", + "idToken", + "apiToken", + "privateKey", + "private_key", + "publicKey", + "signingKey", + "encryptionKey", + "AccessKey", + "bearerToken", + "webhookSecret", + "encryption_passphrase", ]) { const file = manifestPath( `credential-${key}.yaml`, @@ -177,6 +197,22 @@ describe("loadAgentsManifest", () => { expect(() => loadAgentsManifest(file)).toThrow(/looks like a credential and is not allowed/); } }); + + it("accepts benign field names that are not credential-shaped", () => { + for (const key of ["model", "workspace", "agentDir", "allowAgents", "maxSpawnDepth"]) { + const file = manifestPath( + `benign-${key}.yaml`, + [ + "agents:", + " - id: alpha", + " subagents:", + ` ${key}: ok-not-a-credential`, + "", + ].join("\n"), + ); + expect(() => loadAgentsManifest(file)).not.toThrow(); + } + }); }); describe("applyAgentsManifestEnv", () => { diff --git a/src/lib/onboard/agents-manifest.ts b/src/lib/onboard/agents-manifest.ts index bd8f4a729d9..64fdd8f9728 100644 --- a/src/lib/onboard/agents-manifest.ts +++ b/src/lib/onboard/agents-manifest.ts @@ -23,9 +23,38 @@ const AGENT_DATA_ROOT = "/sandbox/.openclaw"; // payload into `NEMOCLAW_EXTRA_AGENTS_JSON_B64` before the build runs. A // pre-transport scan keeps obvious credential-named values from ever // reaching the staged Dockerfile/build context if an operator accidentally -// drops `apiKey`, `token`, `secret`, etc. into the YAML. -const CREDENTIAL_NAME_PATTERN = - /^(?:api[-_]?key|token|secret|password|passphrase|credential|bearer|auth)$/i; +// drops `apiKey`, `token`, `clientSecret`, etc. into the YAML. We normalise +// the field name (lower-case, strip separators) so composite/camelCase +// variants such as `accessToken` or `private_key` are caught alongside the +// exact names. +const CREDENTIAL_NAME_SUBSTRINGS = [ + "apikey", + "apitoken", + "accesskey", + "accesstoken", + "refreshtoken", + "sessiontoken", + "bearertoken", + "idtoken", + "secretkey", + "signingkey", + "encryptionkey", + "privatekey", + "publickey", + "token", + "secret", + "password", + "passphrase", + "credential", + "bearer", +]; +const CREDENTIAL_NAME_EXACT = new Set(["auth", "key"]); + +function isCredentialName(key: string): boolean { + const normalised = key.toLowerCase().replace(/[-_]/g, ""); + if (CREDENTIAL_NAME_EXACT.has(normalised)) return true; + return CREDENTIAL_NAME_SUBSTRINGS.some((needle) => normalised.includes(needle)); +} function isObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -34,7 +63,7 @@ function isObject(value: unknown): value is Record { function assertNoCredentialFields(value: unknown, label: string): void { if (isObject(value)) { for (const [key, child] of Object.entries(value)) { - if (CREDENTIAL_NAME_PATTERN.test(key)) { + if (isCredentialName(key)) { throw new Error( `agents manifest field "${label}.${key}" looks like a credential and is not allowed; pass credentials through the OpenShell provider profile instead`, ); From fcdf36f619aa4c125b09488c424aaa5fa2a8dec7 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Mon, 15 Jun 2026 14:53:26 +0000 Subject: [PATCH 12/13] feat(onboard): gate --agents and agents apply to OpenClaw sandboxes Signed-off-by: Tinson Lai --- docs/reference/commands-nemohermes.mdx | 17 ---------- docs/reference/commands.mdx | 8 +++++ src/lib/actions/sandbox/agents/apply.test.ts | 32 +++++++++++++++++++ src/lib/actions/sandbox/agents/apply.ts | 17 ++++++++++ src/lib/onboard/legacy-command-agents.test.ts | 20 ++++++++++++ src/lib/onboard/legacy-command.ts | 8 +++++ 6 files changed, 85 insertions(+), 17 deletions(-) diff --git a/docs/reference/commands-nemohermes.mdx b/docs/reference/commands-nemohermes.mdx index c7888962645..fa6af2e4df5 100644 --- a/docs/reference/commands-nemohermes.mdx +++ b/docs/reference/commands-nemohermes.mdx @@ -192,9 +192,6 @@ It also prints a `Try: ` recovery line whenever it can derive a Names that match global CLI commands (`status`, `list`, `debug`, etc.) are rejected to avoid routing conflicts. Use `--agent ` to target a specific installed agent profile during onboarding. -Use `--agents ` to declare secondary OpenClaw agents, `agents.defaults`, and main-agent overrides in a checked-in manifest that NemoClaw bakes into the sandbox image at build time. -See [Declarative Multi-Agent Manifest](../inference/declarative-agents-manifest) for the schema and OpenClaw-native sub-agent field semantics. - Use `--control-ui-port ` to choose the host dashboard port for a sandbox. The value must be an integer from `1024` through `65535`. This flag takes precedence over `CHAT_UI_URL`, `NEMOCLAW_DASHBOARD_PORT`, the previous registry value, and the default port. @@ -1014,20 +1011,6 @@ nemohermes my-assistant agents delete work nemohermes my-assistant agents delete work --force --json ``` -### `nemohermes agents apply` - -Reconcile the live sandbox roster against a declarative [agents.yaml manifest](../inference/declarative-agents-manifest). -The verb lists current agents via `openclaw agents list --json`, diffs them against the manifest, and adds missing secondaries or deletes orphan ones through `openclaw agents add|delete`. -Per-agent `model`, `subagents.*`, `tools`, top-level `defaults`, and `main` overrides need a sandbox rebuild and are surfaced as warnings rather than silently dropped; rerun `nemohermes onboard --agents --recreate-sandbox` to bake those fields. - -```bash -nemohermes my-assistant agents apply -f ./agents.yaml -nemohermes my-assistant agents apply -f ./agents.yaml --yes -nemohermes my-assistant agents apply -f ./agents.yaml --yes --non-interactive -``` - -Pass `-f` / `--file ` to point at the manifest; `--yes` confirms the roster diff above; `--non-interactive` fails fast when `--yes` is absent so scripted callers cannot accidentally hang on a missing prompt. - ### `nemohermes sessions` List OpenClaw conversation sessions in the sandbox. diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index cb48bd5bd60..2cc0a4eb6eb 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -263,9 +263,13 @@ It also prints a `Try: ` recovery line whenever it can derive a Names that match global CLI commands (`status`, `list`, `debug`, etc.) are rejected to avoid routing conflicts. Use `--agent ` to target a specific installed agent profile during onboarding. + + Use `--agents ` to declare secondary OpenClaw agents, `agents.defaults`, and main-agent overrides in a checked-in manifest that NemoClaw bakes into the sandbox image at build time. See [Declarative Multi-Agent Manifest](../inference/declarative-agents-manifest) for the schema and OpenClaw-native sub-agent field semantics. + + Use `--control-ui-port ` to choose the host dashboard port for a sandbox. The value must be an integer from `1024` through `65535`. This flag takes precedence over `CHAT_UI_URL`, `NEMOCLAW_DASHBOARD_PORT`, the previous registry value, and the default port. @@ -1250,6 +1254,8 @@ $$nemoclaw my-assistant agents delete work $$nemoclaw my-assistant agents delete work --force --json ``` + + ### `$$nemoclaw agents apply` Reconcile the live sandbox roster against a declarative [agents.yaml manifest](../inference/declarative-agents-manifest). @@ -1264,6 +1270,8 @@ $$nemoclaw my-assistant agents apply -f ./agents.yaml --yes --non-interactive Pass `-f` / `--file ` to point at the manifest; `--yes` confirms the roster diff above; `--non-interactive` fails fast when `--yes` is absent so scripted callers cannot accidentally hang on a missing prompt. + + ### `$$nemoclaw sessions` List OpenClaw conversation sessions in the sandbox. diff --git a/src/lib/actions/sandbox/agents/apply.test.ts b/src/lib/actions/sandbox/agents/apply.test.ts index 3f909418140..3507c747f4e 100644 --- a/src/lib/actions/sandbox/agents/apply.test.ts +++ b/src/lib/actions/sandbox/agents/apply.test.ts @@ -216,6 +216,38 @@ describe("runAgentsApply", () => { expect(addAgent.mock.calls.map(([, id]) => id)).toEqual(["alpha", "bravo"]); }); + it("refuses to apply against a non-OpenClaw sandbox", async () => { + const manifestPath = manifestFile( + "hermes.yaml", + ["agents:", " - id: alpha", " tools:", " allow: [read]", ""].join("\n"), + ); + const exit = vi.fn((code: number) => { + throw new Error(`exit:${code}`); + }); + const messages: string[] = []; + const addAgent = vi.fn(); + const deleteAgent = vi.fn(); + const listAgents = vi.fn(); + await expect( + runAgentsApply( + { sandboxName: "hermes-sandbox", manifestPath, yes: true }, + { + ensureLive: async () => undefined, + getSandboxAgent: () => "hermes", + listAgents, + addAgent, + deleteAgent, + log: (message) => messages.push(message), + exit: exit as unknown as (code: number) => never, + }, + ), + ).rejects.toThrow("exit:1"); + expect(listAgents).not.toHaveBeenCalled(); + expect(addAgent).not.toHaveBeenCalled(); + expect(deleteAgent).not.toHaveBeenCalled(); + expect(messages.some((line) => line.includes("OpenClaw-specific"))).toBe(true); + }); + it("returns cleanly when the roster already matches the manifest", async () => { const manifestPath = manifestFile( "match.yaml", diff --git a/src/lib/actions/sandbox/agents/apply.ts b/src/lib/actions/sandbox/agents/apply.ts index 53963c56ad7..600c288b24e 100644 --- a/src/lib/actions/sandbox/agents/apply.ts +++ b/src/lib/actions/sandbox/agents/apply.ts @@ -4,6 +4,8 @@ import { spawnSync } from "node:child_process"; import { loadAgentsManifest } from "../../../onboard/agents-manifest"; +import { isOpenclawAgent } from "../../../onboard/openclaw-otel-policy-presets"; +import * as registry from "../../../state/registry"; import { buildOpenshellExecArgs } from "../exec"; // Lazy-require `ensureLiveSandboxOrExit` because its import chain pulls in @@ -145,6 +147,7 @@ export interface RunAgentsApplyOptions { export interface RunAgentsApplyDeps { ensureLive?: EnsureLive; + getSandboxAgent?: (sandboxName: string) => string | null; listAgents?: (sandboxName: string) => OpenClawAgentEntry[]; addAgent?: (sandboxName: string, id: string, workspace: string | undefined) => void; deleteAgent?: (sandboxName: string, id: string) => void; @@ -152,6 +155,11 @@ export interface RunAgentsApplyDeps { exit?: (code: number) => never; } +function defaultGetSandboxAgent(sandboxName: string): string | null { + const entry = registry.getSandbox(sandboxName); + return entry?.agent ?? null; +} + function defaultListAgents(sandboxName: string): OpenClawAgentEntry[] { const { getOpenshellBinary } = require("../../../adapters/openshell/runtime") as typeof import("../../../adapters/openshell/runtime"); @@ -221,12 +229,21 @@ export async function runAgentsApply( const log = deps.log ?? ((message: string) => console.log(message)); const exit = deps.exit ?? ((code: number) => process.exit(code)); const ensureLive = deps.ensureLive ?? lazyEnsureLive(); + const getSandboxAgent = deps.getSandboxAgent ?? defaultGetSandboxAgent; const listAgents = deps.listAgents ?? defaultListAgents; const addAgent = deps.addAgent ?? defaultAddAgent; const deleteAgent = deps.deleteAgent ?? defaultDeleteAgent; await ensureLive(options.sandboxName, { allowNonReadyPhase: false }); + const sandboxAgent = getSandboxAgent(options.sandboxName); + if (!isOpenclawAgent(sandboxAgent)) { + log( + ` agents apply is OpenClaw-specific; sandbox "${options.sandboxName}" runs ${sandboxAgent}. Manage agents through the in-sandbox CLI for that runtime.`, + ); + exit(1); + } + const manifest = loadAgentsManifest(options.manifestPath); const currentList = listAgents(options.sandboxName); const diff = buildAgentsApplyDiff(currentList, manifest); diff --git a/src/lib/onboard/legacy-command-agents.test.ts b/src/lib/onboard/legacy-command-agents.test.ts index acd29454f8c..15cc207b45f 100644 --- a/src/lib/onboard/legacy-command-agents.test.ts +++ b/src/lib/onboard/legacy-command-agents.test.ts @@ -75,6 +75,26 @@ describe("onboard --agents", () => { expect(errors.join("\n")).toContain("--agents requires a path to a YAML manifest"); }); + it("rejects --agents when --agent is set to a non-OpenClaw runtime", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-agents-hermes-")); + const manifestPath = path.join(tmpDir, "agents.yaml"); + fs.writeFileSync(manifestPath, "agents: []\n"); + const errors: string[] = []; + expect(() => + parseOnboardArgs( + ["--agent", "hermes", "--agents", manifestPath], + "--yes-i-accept-third-party-software", + "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE", + { + env: {}, + error: (message = "") => errors.push(message), + exit: exitWithPrefixedCode, + }, + ), + ).toThrow("exit:1"); + expect(errors.join("\n")).toContain("--agents is OpenClaw-specific"); + }); + it("sets NEMOCLAW_EXTRA_AGENTS_JSON before invoking runOnboard when --agents is supplied", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-agents-env-")); const manifestPath = path.join(tmpDir, "agents.yaml"); diff --git a/src/lib/onboard/legacy-command.ts b/src/lib/onboard/legacy-command.ts index 9a14df51ac5..8ba4d2370f1 100644 --- a/src/lib/onboard/legacy-command.ts +++ b/src/lib/onboard/legacy-command.ts @@ -6,6 +6,7 @@ import path from "node:path"; import { CLI_NAME } from "../cli/branding"; import { applyAgentsManifestEnv } from "./agents-manifest"; +import { isOpenclawAgent } from "./openclaw-otel-policy-presets"; export interface OnboardCommandOptions { nonInteractive: boolean; @@ -157,6 +158,13 @@ export function parseOnboardArgs( printOnboardUsage(error, noticeAcceptFlag); exit(1); } + if (!isOpenclawAgent(agent)) { + error( + ` --agents is OpenClaw-specific and cannot be used with --agent ${agent}; the declarative manifest only drives OpenClaw secondary agents.`, + ); + printOnboardUsage(error, noticeAcceptFlag); + exit(1); + } const resolved = path.resolve(agentsValue); if (!fs.existsSync(resolved)) { error(` --agents path not found: ${resolved}`); From ffe43779c674d4e3501347474d493f7be44caf39 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Mon, 15 Jun 2026 14:58:04 +0000 Subject: [PATCH 13/13] fix(sandbox): validate agents manifest ids and paths before live apply Signed-off-by: Tinson Lai --- src/lib/actions/sandbox/agents/apply.test.ts | 150 ++++++++++++++++++- src/lib/actions/sandbox/agents/apply.ts | 77 ++++++++++ 2 files changed, 226 insertions(+), 1 deletion(-) diff --git a/src/lib/actions/sandbox/agents/apply.test.ts b/src/lib/actions/sandbox/agents/apply.test.ts index 3507c747f4e..996bf8557b1 100644 --- a/src/lib/actions/sandbox/agents/apply.test.ts +++ b/src/lib/actions/sandbox/agents/apply.test.ts @@ -7,7 +7,12 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { buildAgentsApplyDiff, computeAgentsApplyDiff, runAgentsApply } from "./apply"; +import { + buildAgentsApplyDiff, + computeAgentsApplyDiff, + runAgentsApply, + validateAgentsManifestForApply, +} from "./apply"; let tmpDir: string; @@ -94,6 +99,114 @@ describe("buildAgentsApplyDiff", () => { }); }); +describe("validateAgentsManifestForApply", () => { + it("rejects ids that do not match the AGENT_ID regex", () => { + for (const id of [ + "--help", + "-h", + "Alpha", + "ALPHA", + "1leading", + "alpha space", + "../escape", + "alpha/sub", + "a".repeat(33), + ]) { + expect(() => + validateAgentsManifestForApply([ + { + id, + workspace: `/sandbox/.openclaw/workspace-${id}`, + agentDir: `/sandbox/.openclaw/agents/${id}`, + }, + ]), + ).toThrow(/must match/); + } + }); + + it("rejects the reserved `main` id", () => { + expect(() => + validateAgentsManifestForApply([ + { + id: "main", + workspace: "/sandbox/.openclaw/workspace-main", + agentDir: "/sandbox/.openclaw/agents/main", + }, + ]), + ).toThrow(/reserved for the primary agent/); + }); + + it("rejects duplicate ids in the manifest", () => { + expect(() => + validateAgentsManifestForApply([ + { + id: "alpha", + workspace: "/sandbox/.openclaw/workspace-alpha", + agentDir: "/sandbox/.openclaw/agents/alpha", + }, + { + id: "alpha", + workspace: "/sandbox/.openclaw/workspace-alpha", + agentDir: "/sandbox/.openclaw/agents/alpha", + }, + ]), + ).toThrow(/duplicated/); + }); + + it("rejects non-canonical workspace paths", () => { + expect(() => + validateAgentsManifestForApply([ + { + id: "alpha", + workspace: "/sandbox/.openclaw/openclaw.json", + agentDir: "/sandbox/.openclaw/agents/alpha", + }, + ]), + ).toThrow(/workspace must equal/); + }); + + it("rejects non-canonical agentDir paths", () => { + expect(() => + validateAgentsManifestForApply([ + { + id: "alpha", + workspace: "/sandbox/.openclaw/workspace-alpha", + agentDir: "/etc/passwd", + }, + ]), + ).toThrow(/agentDir must equal/); + }); + + it("rejects entries with unsupported top-level keys", () => { + expect(() => + validateAgentsManifestForApply([ + { + id: "alpha", + workspace: "/sandbox/.openclaw/workspace-alpha", + agentDir: "/sandbox/.openclaw/agents/alpha", + env: { LEAK: "1" }, + }, + ]), + ).toThrow(/unsupported field "env"/); + }); + + it("accepts valid entries with the allowed key set", () => { + expect(() => + validateAgentsManifestForApply([ + { + id: "alpha", + workspace: "/sandbox/.openclaw/workspace-alpha", + agentDir: "/sandbox/.openclaw/agents/alpha", + description: "research bot", + model: "test-provider/secondary", + tools: { allow: ["read"] }, + subagents: { allowAgents: ["beta"] }, + }, + ]), + ).not.toThrow(); + }); +}); + describe("runAgentsApply", () => { it("invokes add for missing manifest agents and delete for orphans", async () => { const manifestPath = manifestFile( @@ -216,6 +329,41 @@ describe("runAgentsApply", () => { expect(addAgent.mock.calls.map(([, id]) => id)).toEqual(["alpha", "bravo"]); }); + it("refuses to mutate a sandbox when the manifest fails id/workspace validation", async () => { + const manifestPath = manifestFile( + "bad-id.yaml", + [ + "agents:", + ' - id: "--help"', + " workspace: /sandbox/.openclaw/workspace---help", + " agentDir: /sandbox/.openclaw/agents/--help", + "", + ].join("\n"), + ); + const exit = vi.fn((code: number) => { + throw new Error(`exit:${code}`); + }); + const messages: string[] = []; + const addAgent = vi.fn(); + const deleteAgent = vi.fn(); + await expect( + runAgentsApply( + { sandboxName: "my-assistant", manifestPath, yes: true }, + { + ensureLive: async () => undefined, + listAgents: () => [{ id: "main" }], + addAgent, + deleteAgent, + log: (message) => messages.push(message), + exit: exit as unknown as (code: number) => never, + }, + ), + ).rejects.toThrow("exit:1"); + expect(addAgent).not.toHaveBeenCalled(); + expect(deleteAgent).not.toHaveBeenCalled(); + expect(messages.some((line) => line.includes("Manifest rejected before mutation"))).toBe(true); + }); + it("refuses to apply against a non-OpenClaw sandbox", async () => { const manifestPath = manifestFile( "hermes.yaml", diff --git a/src/lib/actions/sandbox/agents/apply.ts b/src/lib/actions/sandbox/agents/apply.ts index 600c288b24e..ba61c6b4d0c 100644 --- a/src/lib/actions/sandbox/agents/apply.ts +++ b/src/lib/actions/sandbox/agents/apply.ts @@ -35,6 +35,76 @@ export interface AgentsApplyDiff { const MAIN_AGENT_ID = "main"; const PROTECTED_IDS = new Set([MAIN_AGENT_ID]); +// Mirrors the authoritative build-time gates in +// scripts/generate-openclaw-config.mts. Live apply runs on the host before +// the in-sandbox OpenClaw CLI is invoked, so it cannot rely on the build-time +// validator (which only runs at image-build time inside the container). The +// gates below are the defensive subset that prevents the live add/delete +// loop from mutating the sandbox with an unsafe id, non-canonical workspace, +// or unknown nested key. +const AGENT_ID_RE = /^[a-z][a-z0-9_-]{0,31}$/; +const AGENT_DATA_ROOT = "/sandbox/.openclaw"; +const ALLOWED_AGENT_ENTRY_KEYS = new Set([ + "id", + "workspace", + "agentDir", + "tools", + "subagents", + "description", + "model", +]); + +function expectedAgentPath(kind: "workspace" | "agentDir", id: string): string { + const segment = kind === "workspace" ? `workspace-${id}` : `agents/${id}`; + return `${AGENT_DATA_ROOT}/${segment}`; +} + +export function validateAgentsManifestForApply(manifestAgents: unknown[]): void { + const seenIds = new Set(); + for (let index = 0; index < manifestAgents.length; index++) { + const entry = manifestAgents[index]; + const label = `agents[${index}]`; + if (entry === null || typeof entry !== "object" || Array.isArray(entry)) { + throw new Error(`${label} must be a YAML mapping (object)`); + } + const record = entry as Record; + const id = record.id; + if (typeof id !== "string" || !AGENT_ID_RE.test(id)) { + throw new Error( + `${label}.id ${JSON.stringify(id)} must match ${AGENT_ID_RE} (1-32 chars, lowercase alphanumeric, dash, underscore; must start with a letter)`, + ); + } + if (id === MAIN_AGENT_ID) { + throw new Error( + `${label}.id "${MAIN_AGENT_ID}" is reserved for the primary agent; use a different id`, + ); + } + if (seenIds.has(id)) { + throw new Error(`${label}.id "${id}" is duplicated; agent ids must be unique`); + } + seenIds.add(id); + for (const key of Object.keys(record)) { + if (!ALLOWED_AGENT_ENTRY_KEYS.has(key)) { + throw new Error( + `${label} contains unsupported field "${key}". Allowed: ${[...ALLOWED_AGENT_ENTRY_KEYS].sort().join(", ")}.`, + ); + } + } + for (const pathKey of ["workspace", "agentDir"] as const) { + const pathValue = record[pathKey]; + if (typeof pathValue !== "string" || pathValue.length === 0) { + throw new Error(`${label}.${pathKey} must be a non-empty string`); + } + const expected = expectedAgentPath(pathKey, id); + if (pathValue !== expected) { + throw new Error( + `${label}.${pathKey} must equal "${expected}" for agent id "${id}", got "${pathValue}"`, + ); + } + } + } +} + function hasNonEmptyFields(value: unknown): boolean { if (value === null || value === undefined) return false; if (Array.isArray(value)) return value.length > 0; @@ -245,6 +315,13 @@ export async function runAgentsApply( } const manifest = loadAgentsManifest(options.manifestPath); + try { + validateAgentsManifestForApply(manifest.agents); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + log(` Manifest rejected before mutation: ${reason}`); + exit(1); + } const currentList = listAgents(options.sandboxName); const diff = buildAgentsApplyDiff(currentList, manifest);