diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index eba825c231d..c90f22139d6 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -2158,36 +2158,54 @@ Export the credential before running `$$nemoclaw onboard` for that profile. #### Extra OpenClaw agents -Set `NEMOCLAW_EXTRA_AGENTS_JSON` to a JSON array of secondary-agent entries to bake them into `agents.list[]` at image build time. -Each entry must declare `id`, `workspace`, `agentDir`, `tools`, and `subagents.maxSpawnDepth`. -The canonical `main` entry is always written first with `default: true`, so secondary agents cannot displace the primary agent. +Set `NEMOCLAW_EXTRA_AGENTS_JSON` to either a JSON array of secondary-agent entries, or an object payload of the form `{"agents": [...], "defaults": {...}, "main": {...}}`, to bake them into `agents.list[]` at image build time. +Each entry must declare `id` and `tools`; `workspace`, `agentDir`, `subagents`, `description`, and `model` are optional. +The generator always writes the canonical `main` entry first with `default: true`, so secondary agents cannot displace the primary agent. Malformed JSON or invalid entries fail the image build with a structured error. Field rules: - `id` must match `^[a-z][a-z0-9_-]{0,31}$` and must not be `main`. -- `workspace` must equal `/sandbox/.openclaw/workspace-` exactly. -- `agentDir` must equal `/sandbox/.openclaw/agents/` exactly. +- `workspace` defaults to `/sandbox/.openclaw/workspace-`; when set, it must be an absolute path that resolves to that value. +- `agentDir` defaults to `/sandbox/.openclaw/agents/`; when set, it must be an absolute path that resolves to that value. - `tools` must declare a non-empty `allow[]` or `deny[]`; nothing is implicitly granted. -- `subagents.maxSpawnDepth` must be a non-negative integer; set to `0` to forbid further spawning. +- `model`, when set, must be a `"provider/model"` string whose provider portion matches the primary onboard provider. - `default: true` is rejected because the primary agent is the only default. -- Allowed entry fields: `id`, `workspace`, `agentDir`, `tools`, `subagents`, `description`. Any other key fails the image build (no implicit credential or env pass-through). -- Allowed `tools` fields: `profile`, `allow`, `deny`. Allowed `subagents` fields: `maxSpawnDepth`. Any other nested key fails the image build. +- Allowed entry fields: `id`, `workspace`, `agentDir`, `tools`, `subagents`, `description`, `model`. Any other key fails the image build (no implicit credential or env pass-through). +- Allowed `tools` fields: `profile`, `allow`, `deny`. Allowed per-agent `subagents` fields: `delegationMode`, `allowAgents`, `model`, `thinking`, `requireAgentId`. Any other nested key fails the image build. -Example: +OpenClaw accepts `subagents.maxSpawnDepth` only on `agents.defaults.subagents`, never inside a per-agent `subagents` object. +The value must be an integer between `1` and `5` (OpenClaw's accepted range); to set it, use the object payload shape and pass it under `defaults`: + +```json +{ + "agents": [ + { + "id": "research", + "tools": { + "profile": "minimal", + "allow": ["web_search", "web_fetch", "read", "write"], + "deny": ["exec", "gateway"] + } + } + ], + "defaults": { + "subagents": { "maxSpawnDepth": 1 } + } +} +``` + +Array-shape example (paths defaulted): ```json [ { "id": "research", - "workspace": "/sandbox/.openclaw/workspace-research", - "agentDir": "/sandbox/.openclaw/agents/research", "tools": { "profile": "minimal", "allow": ["web_search", "web_fetch", "read", "write"], "deny": ["exec", "gateway"] - }, - "subagents": { "maxSpawnDepth": 0 } + } } ] ``` diff --git a/scripts/generate-openclaw-config.mts b/scripts/generate-openclaw-config.mts index 258ceef613c..9a1fd5b527b 100755 --- a/scripts/generate-openclaw-config.mts +++ b/scripts/generate-openclaw-config.mts @@ -858,13 +858,17 @@ function validateExtraAgents(value: unknown, primaryProvider: string): ExtraAgen const canonicalPaths: Record = {}; for (const pathKey of ["workspace", "agentDir"] as const) { const pathValue = entry[pathKey]; + const expected = expectedAgentPath(pathKey, id); + if (pathValue === undefined) { + canonicalPaths[pathKey] = expected; + continue; + } if (typeof pathValue !== "string" || pathValue.length === 0) { - throw new Error(`${label}.${pathKey} must be a non-empty string`); + throw new Error(`${label}.${pathKey} must be a non-empty string when present`); } if (!isAbsolute(pathValue)) { throw new Error(`${label}.${pathKey} must be an absolute path, got "${pathValue}"`); } - const expected = expectedAgentPath(pathKey, id); if (resolve(pathValue) !== expected) { throw new Error( `${label}.${pathKey} must equal "${expected}" for agent id "${id}", got "${pathValue}"`, diff --git a/test/generate-openclaw-config-path-defaults.test.ts b/test/generate-openclaw-config-path-defaults.test.ts new file mode 100644 index 00000000000..c38dc64cece --- /dev/null +++ b/test/generate-openclaw-config-path-defaults.test.ts @@ -0,0 +1,122 @@ +// @ts-nocheck +// 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 { main } from "../scripts/generate-openclaw-config.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", +}; + +const TOOLS_OK = { profile: "minimal", allow: ["read"], deny: ["exec"] }; + +let tmpDir: string; + +function buildTestEnv(envOverrides: Record = {}): Record { + fs.writeFileSync(path.join(tmpDir, "openclaw"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + 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 original = { ...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, original); + } +} + +function runConfigScript(envOverrides: Record = {}): any { + const env = buildTestEnv(envOverrides); + withEnv(env, () => main()); + return JSON.parse(fs.readFileSync(path.join(tmpDir, ".openclaw", "openclaw.json"), "utf-8")); +} + +function extraAgentsB64(extras: unknown): string { + return Buffer.from(JSON.stringify(extras)).toString("base64"); +} + +describe("generate-openclaw-config.mts: extra-agents path defaulting", () => { + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-path-defaults-")); + }); + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("auto-fills workspace and agentDir from id when omitted (full or partial)", () => { + const config = runConfigScript({ + NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64([ + { id: "alpha", tools: TOOLS_OK }, + { id: "beta", workspace: "/sandbox/.openclaw/workspace-beta", tools: TOOLS_OK }, + ]), + }); + expect(config.agents.list[1]).toMatchObject({ + id: "alpha", + workspace: "/sandbox/.openclaw/workspace-alpha", + agentDir: "/sandbox/.openclaw/agents/alpha", + }); + expect(config.agents.list[2]).toMatchObject({ + id: "beta", + workspace: "/sandbox/.openclaw/workspace-beta", + agentDir: "/sandbox/.openclaw/agents/beta", + }); + }); + + it("accepts the legacy allow-only array payload with defaulted workspace and agentDir", () => { + const config = runConfigScript({ + NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64([ + { id: "legacy-worker", tools: { allow: ["read"] } }, + ]), + }); + expect(config.agents.list).toHaveLength(2); + expect(config.agents.list[1]).toMatchObject({ + id: "legacy-worker", + workspace: "/sandbox/.openclaw/workspace-legacy-worker", + agentDir: "/sandbox/.openclaw/agents/legacy-worker", + tools: { allow: ["read"] }, + }); + }); + + it("auto-fills workspace and agentDir for the object-shaped {agents} payload", () => { + const config = runConfigScript({ + NEMOCLAW_EXTRA_AGENTS_JSON_B64: extraAgentsB64({ + agents: [{ id: "legacy-worker", tools: { allow: ["read"] } }], + }), + }); + expect(config.agents.list).toHaveLength(2); + expect(config.agents.list[1]).toMatchObject({ + id: "legacy-worker", + workspace: "/sandbox/.openclaw/workspace-legacy-worker", + agentDir: "/sandbox/.openclaw/agents/legacy-worker", + tools: { allow: ["read"] }, + }); + }); +});