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
8 changes: 8 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,13 @@ ARG NEMOCLAW_WECHAT_CONFIG_B64=e30=
# Channel IDs scope Slack channel @mention handling. User allowlists still come
# from NEMOCLAW_MESSAGING_ALLOWED_IDS_B64. Default: empty map.
ARG NEMOCLAW_SLACK_CONFIG_B64=e30=
# Base64-encoded JSON array of secondary OpenClaw agent config entries
# (e.g. [{"id":"research","workspace":"/sandbox/.openclaw/workspace-research",
# "agentDir":"/sandbox/.openclaw/agents/research", ...}]).
# Each entry is appended to agents.list[] after the canonical "main" entry, so
# the primary agent always remains the default. See generate-openclaw-config.mts
# for the validator. Default: empty array (W10= == base64("[]")).
ARG NEMOCLAW_EXTRA_AGENTS_JSON_B64=W10=
# Set to "1" to force-disable device-pairing auth. Also auto-disabled when
# CHAT_UI_URL is a non-loopback address (Brev Launchable, remote deployments)
# since terminal-based pairing is impossible in those contexts.
Expand Down Expand Up @@ -521,6 +528,7 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \
NEMOCLAW_TELEGRAM_CONFIG_B64=${NEMOCLAW_TELEGRAM_CONFIG_B64} \
NEMOCLAW_WECHAT_CONFIG_B64=${NEMOCLAW_WECHAT_CONFIG_B64} \
NEMOCLAW_SLACK_CONFIG_B64=${NEMOCLAW_SLACK_CONFIG_B64} \
NEMOCLAW_EXTRA_AGENTS_JSON_B64=${NEMOCLAW_EXTRA_AGENTS_JSON_B64} \
NEMOCLAW_OPENCLAW_WECHAT_PLUGIN_PREINSTALLED=1 \
NEMOCLAW_DISABLE_DEVICE_AUTH=${NEMOCLAW_DISABLE_DEVICE_AUTH} \
NEMOCLAW_PROXY_HOST=${NEMOCLAW_PROXY_HOST} \
Expand Down
41 changes: 41 additions & 0 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1613,6 +1613,7 @@ OpenClaw-specific build-time agent configuration:
| `NEMOCLAW_MAX_TOKENS` | positive integer (tokens) | Overrides the model's `maxTokens` in the built OpenClaw config. |
| `NEMOCLAW_REASONING` | `true` or `false` | Overrides the model's reasoning-mode flag in the built OpenClaw config. |
| `NEMOCLAW_AGENT_HEARTBEAT_EVERY` | duration with `s`, `m`, or `h` suffix (for example `30m`, `1h`, or `0m`) | Overrides `agents.defaults.heartbeat.every` in the built OpenClaw config. Set `0m` to disable periodic agent turns. |
| `NEMOCLAW_EXTRA_AGENTS_JSON` | JSON array of OpenClaw secondary-agent entries | Adds secondary agents to `agents.list`; see [Extra OpenClaw agents](#extra-openclaw-agents) for the entry schema, path constraints, and validation rules. |

</AgentOnly>
<AgentOnly variant="hermes">
Expand All @@ -1627,6 +1628,46 @@ Hermes-specific provider authentication:

</AgentOnly>

<AgentOnly variant="openclaw">

#### 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.
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.
- `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.
- `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.

Example:

```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 }
}
]
```

</AgentOnly>

#### Linux Ollama install mode details

Set `NEMOCLAW_OLLAMA_INSTALL_MODE=system` to run the official `https://ollama.com/install.sh` installer, which uses sudo, writes to `/usr/local`, and configures systemd.
Expand Down
209 changes: 208 additions & 1 deletion scripts/generate-openclaw-config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
// NEMOCLAW_MESSAGING_ALLOWED_IDS_B64, NEMOCLAW_DISCORD_GUILDS_B64,
// NEMOCLAW_TELEGRAM_CONFIG_B64, NEMOCLAW_WECHAT_CONFIG_B64,
// NEMOCLAW_SLACK_CONFIG_B64, NEMOCLAW_DISABLE_DEVICE_AUTH,
// NEMOCLAW_EXTRA_AGENTS_JSON_B64,
// NEMOCLAW_PROXY_HOST, NEMOCLAW_PROXY_PORT,
// NEMOCLAW_OPENCLAW_MANAGED_PROXY, NEMOCLAW_WEB_SEARCH_ENABLED.

Expand Down Expand Up @@ -454,6 +455,209 @@ function coerceCompatDict(value: unknown): JsonObject {
throw new Error("NEMOCLAW_INFERENCE_COMPAT_B64 must decode to a JSON object or null");
}

// Canonical primary-agent entry. Always written first into agents.list, always
// flagged default: true. Pinning the slot here prevents the extra-agents env
// from displacing the primary agent: OpenClaw's resolveDefaultAgentId falls
// back to agents[0] when no entry carries default: true, so a wholesale list
// replacement would silently re-elect the first extra agent.
//
// The entry intentionally omits workspace/agentDir so OpenClaw applies its
// built-in defaults (and so the host-side migration-state collector does not
// register a phantom host root for the in-sandbox path).
const MAIN_AGENT_ID = "main";
const MAIN_AGENT_ENTRY: Readonly<JsonObject> = Object.freeze({
id: MAIN_AGENT_ID,
default: true,
});
const AGENT_ID_RE = /^[a-z][a-z0-9_-]{0,31}$/;
// Secondary agent paths must live under the canonical state dir
// (/sandbox/.openclaw/). The runtime startup script (scripts/nemoclaw-start.sh
// :: provision_agent_workspaces) discovers /sandbox/.openclaw/workspace-* and
// chowns them sandbox:sandbox on first boot. The legacy /sandbox/.openclaw-data
// path is migrated away on start, so it cannot host live agent state.
const AGENT_DATA_ROOT = "/sandbox/.openclaw";

// Per-agent paths must land in the canonical sandbox layout the runtime
// startup script provisions and the sandbox isolation policy expects:
// workspace -> /sandbox/.openclaw/workspace-<agent-id>
// agentDir -> /sandbox/.openclaw/agents/<agent-id>
// Allowing arbitrary descendants of /sandbox/.openclaw/ would let an
// operator point an agent at the gateway state, the openclaw.json config,
// or a credentials directory, bypassing per-agent isolation and the
// `provision_agent_workspaces` helper that chowns `workspace-*` dirs to
// the sandbox user on first boot.
function expectedAgentPath(kind: "workspace" | "agentDir", id: string): string {
const segment = kind === "workspace" ? `workspace-${id}` : `agents/${id}`;
return resolve(AGENT_DATA_ROOT, segment);
}

// Allowlisted operator-supplied keys for a secondary-agent entry. The
// validator copies only these keys into the baked openclaw.json so an
// unknown or credential-like field added by mistake cannot be carried into
// the image (e.g. a stray `apiKey`, `token`, or `env`). Each nested object
// has its own allowlist below — the top-level filter alone is not enough,
// because operators could still smuggle `tools.apiKey` or
// `subagents.token` into the baked config.
const ALLOWED_EXTRA_AGENT_KEYS = new Set<string>([
"id",
"workspace",
"agentDir",
"tools",
"subagents",
"description",
]);
const ALLOWED_TOOLS_KEYS = new Set<string>(["profile", "allow", "deny"]);
const ALLOWED_SUBAGENTS_KEYS = new Set<string>(["maxSpawnDepth"]);

function rejectUnknownKeys(obj: JsonObject, allowed: Set<string>, label: string): void {
const unknown = Object.keys(obj).filter((key) => !allowed.has(key));
if (unknown.length > 0) {
throw new Error(
`${label} contains unsupported field(s): ${unknown.sort().join(", ")}. Allowed: ${[...allowed].sort().join(", ")}.`,
);
}
}

function pickAllowed(obj: JsonObject, allowed: Set<string>): JsonObject {
const out: JsonObject = {};
for (const key of allowed) {
if (key in obj) {
out[key] = obj[key];
}
}
return out;
}

function validateExtraAgentTools(entry: JsonObject, label: string): JsonObject {
const tools = entry.tools;
if (!isObject(tools)) {
throw new Error(
`${label}.tools must be an object describing the per-agent tool policy (profile/allow/deny). Nothing is granted implicitly.`,
);
}
rejectUnknownKeys(tools, ALLOWED_TOOLS_KEYS, `${label}.tools`);
const allow = tools.allow;
const deny = tools.deny;
const hasAllow = Array.isArray(allow) && allow.length > 0;
const hasDeny = Array.isArray(deny) && deny.length > 0;
if (!hasAllow && !hasDeny) {
throw new Error(
`${label}.tools must declare a non-empty allow[] or deny[] (or both); secondary agents inherit no tools by default.`,
);
}
for (const key of ["allow", "deny"] as const) {
const value = tools[key];
if (value === undefined) continue;
if (!Array.isArray(value) || value.some((token) => typeof token !== "string" || !token)) {
throw new Error(
`${label}.tools.${key} must be an array of non-empty strings when present.`,
);
}
}
if (tools.profile !== undefined && typeof tools.profile !== "string") {
throw new Error(`${label}.tools.profile must be a string when present.`);
}
return pickAllowed(tools, ALLOWED_TOOLS_KEYS);
}

function validateExtraAgentSubagents(entry: JsonObject, label: string): JsonObject {
const subagents = entry.subagents;
if (!isObject(subagents)) {
throw new Error(
`${label}.subagents must be an object containing maxSpawnDepth. Set maxSpawnDepth: 0 to forbid further spawning.`,
);
}
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.`,
);
}
return pickAllowed(subagents, ALLOWED_SUBAGENTS_KEYS);
}

function validateExtraAgents(value: unknown): JsonObject[] {
if (value === null || value === undefined) {
return [];
}
if (!Array.isArray(value)) {
throw new Error(
"NEMOCLAW_EXTRA_AGENTS_JSON must decode to a JSON array of agent objects",
);
}
const seenIds = new Set<string>([MAIN_AGENT_ID]);
return value.map((entry, index) => {
const label = `NEMOCLAW_EXTRA_AGENTS_JSON[${index}]`;
if (!isObject(entry)) {
throw new Error(`${label} must be a JSON object`);
}
const id = entry.id;
if (typeof id !== "string" || !AGENT_ID_RE.test(id)) {
throw new Error(
`${label}.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);
const canonicalPaths: Record<string, string> = {};
for (const pathKey of ["workspace", "agentDir"] as const) {
const pathValue = entry[pathKey];
if (typeof pathValue !== "string" || pathValue.length === 0) {
throw new Error(`${label}.${pathKey} must be a non-empty string`);
}
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}"`,
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
canonicalPaths[pathKey] = expected;
}
if (entry.default === true) {
throw new Error(
`${label}.default cannot be true; the primary "${MAIN_AGENT_ID}" agent is always the default`,
);
}
rejectUnknownKeys(entry, ALLOWED_EXTRA_AGENT_KEYS, label);
const tools = validateExtraAgentTools(entry, label);
const subagents = validateExtraAgentSubagents(entry, label);
// 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
// path that resolves to the canonical target is normalised before
// bake, matching what provision_agent_workspaces parses);
// - only allowlisted keys reach the image, at every nesting level.
const canonical: JsonObject = {
id,
workspace: canonicalPaths.workspace,
agentDir: canonicalPaths.agentDir,
tools,
subagents,
};
if (typeof entry.description === "string") {
canonical.description = entry.description;
}
return canonical;
});
}

function buildAgentsList(extras: JsonObject[]): JsonObject[] {
return [{ ...MAIN_AGENT_ENTRY }, ...extras];
}

function applyOpenClawSetupEffects(
setup: JsonObject,
inferenceCompat: JsonObject,
Expand Down Expand Up @@ -558,6 +762,9 @@ export function buildConfig(env: Env = process.env): JsonObject {
const inferenceCompat = coerceCompatDict(
decodeJsonEnv(env, "NEMOCLAW_INFERENCE_COMPAT_B64", "e30="),
);
const extraAgents = validateExtraAgents(
decodeJsonEnv(env, "NEMOCLAW_EXTRA_AGENTS_JSON_B64", "W10="),
);
const openclawPlugins: JsonObject[] = [];
const openclawPluginIds = new Set<string>();
const openclawToolOverrides: JsonObject = {};
Expand Down Expand Up @@ -773,7 +980,7 @@ export function buildConfig(env: Env = process.env): JsonObject {
};

const config: JsonObject = {
agents: { defaults: agentDefaults },
agents: { defaults: agentDefaults, list: buildAgentsList(extraAgents) },
models: { mode: "merge", providers },
channels: { defaults: {}, ...channelConfig },
tools: openclawTools,
Expand Down
Loading
Loading