Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 31 additions & 13 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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-<id>` exactly.
- `agentDir` must equal `/sandbox/.openclaw/agents/<id>` exactly.
- `workspace` defaults to `/sandbox/.openclaw/workspace-<id>`; when set, it must be an absolute path that resolves to that value.
- `agentDir` defaults to `/sandbox/.openclaw/agents/<id>`; 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 }
}
}
]
```
Expand Down
8 changes: 6 additions & 2 deletions scripts/generate-openclaw-config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -858,13 +858,17 @@ function validateExtraAgents(value: unknown, primaryProvider: string): ExtraAgen
const canonicalPaths: Record<string, string> = {};
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}"`,
Expand Down
122 changes: 122 additions & 0 deletions test/generate-openclaw-config-path-defaults.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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<string, string> = {}): Record<string, string> {
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<T>(env: Record<string, string>, 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<string, string> = {}): 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"] },
});
});
});
Loading