Skip to content

Commit fc929eb

Browse files
fix(agents/subagents): read SPAWN_ALLOWLIST env var as allowlist fallback
Container deployments (Docker / Coolify) that configure agents purely through env vars previously got 'allowed: none' from sessions_spawn because subagents.allowAgents was only read from per-agent config or agents.defaults. Adds resolveSpawnAllowlistFromEnv() that parses a comma-separated SPAWN_ALLOWLIST (with '*' wildcard support) and wires it into the spawn target-policy, ACP spawn, and agents_list resolution paths as a final fallback. Per-agent config and agents.defaults still take precedence. Fixes #79490 Co-authored-by: Cursor <[email protected]>
1 parent b0db82b commit fc929eb

6 files changed

Lines changed: 93 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ Docs: https://docs.openclaw.ai
5959

6060
### Fixes
6161

62+
- Agents/subagents: read `SPAWN_ALLOWLIST` from the host environment as a fallback for `subagents.allowAgents` in the spawn target-policy, ACP spawn, and `agents_list` paths so Docker / Coolify deployments that set `AGENTS=[…]` and `SPAWN_ALLOWLIST=*` purely through env vars get a working `sessions_spawn` allowlist instead of `allowed: none`. Comma-separated; per-agent and `agents.defaults` config still take precedence. Fixes #79490.
6263
- Control UI/performance: scope Nodes polling to the active Nodes tab, debounce stale session-list reconciliation, and bound chat-side session refreshes so long-running dashboards avoid background reload churn. Thanks @BunsDev.
6364
- Bonjour/Gateway: treat active ciao probing and fresh name-conflict renames as in-progress so the mDNS watchdog waits for probe settlement before retrying, preventing rapid re-advertise loops on Windows, WSL, and other multicast-hostile hosts. (#74778) Refs #74242. Thanks @fuller-stack-dev.
6465
- Providers/MiniMax: send a minimal Anthropic-compatible user fallback when message conversion filters a turn to an empty payload, so MiniMax M2.7 no longer returns `chat content is empty` after tool-heavy sessions. Fixes #74589. Thanks @neeravmakwana and @DerekEXS.

src/agents/acp-spawn.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,10 @@ import {
8383
} from "./subagent-capabilities.js";
8484
import { getSubagentDepthFromSessionStore } from "./subagent-depth.js";
8585
import { countActiveRunsForSession, getSubagentRunByChildSessionKey } from "./subagent-registry.js";
86-
import { resolveSubagentTargetPolicy } from "./subagent-target-policy.js";
86+
import {
87+
resolveSpawnAllowlistFromEnv,
88+
resolveSubagentTargetPolicy,
89+
} from "./subagent-target-policy.js";
8790
import { resolveInternalSessionKey, resolveMainSessionAlias } from "./tools/sessions-helpers.js";
8891

8992
const log = createSubsystemLogger("agents/acp-spawn");
@@ -790,7 +793,8 @@ function resolveAcpSubagentEnvelopeState(params: {
790793
requestedAgentId: params.requestedAgentId,
791794
allowAgents:
792795
resolveAgentConfig(params.cfg, requesterAgentId)?.subagents?.allowAgents ??
793-
params.cfg.agents?.defaults?.subagents?.allowAgents,
796+
params.cfg.agents?.defaults?.subagents?.allowAgents ??
797+
resolveSpawnAllowlistFromEnv(),
794798
});
795799
if (!targetPolicy.ok) {
796800
return {

src/agents/subagent-spawn.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,10 @@ import { getSubagentDepthFromSessionStore } from "./subagent-depth.js";
2929
import { buildSubagentInitialUserMessage } from "./subagent-initial-user-message.js";
3030
import { countActiveRunsForSession, registerSubagentRun } from "./subagent-registry.js";
3131
import { resolveSubagentSpawnAcceptedNote } from "./subagent-spawn-accepted-note.js";
32-
import { resolveSubagentTargetPolicy } from "./subagent-target-policy.js";
32+
import {
33+
resolveSpawnAllowlistFromEnv,
34+
resolveSubagentTargetPolicy,
35+
} from "./subagent-target-policy.js";
3336
import { normalizeSubagentTaskName } from "./subagent-task-name.js";
3437
export {
3538
SUBAGENT_SPAWN_ACCEPTED_NOTE,
@@ -818,7 +821,8 @@ export async function spawnSubagentDirect(
818821
requestedAgentId,
819822
allowAgents:
820823
resolveAgentConfig(cfg, requesterAgentId)?.subagents?.allowAgents ??
821-
cfg?.agents?.defaults?.subagents?.allowAgents,
824+
cfg?.agents?.defaults?.subagents?.allowAgents ??
825+
resolveSpawnAllowlistFromEnv(),
822826
});
823827
if (!targetPolicy.ok) {
824828
return {

src/agents/subagent-target-policy.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, it } from "vitest";
22
import {
3+
resolveSpawnAllowlistFromEnv,
34
resolveSubagentAllowedTargetIds,
45
resolveSubagentTargetPolicy,
56
} from "./subagent-target-policy.js";
@@ -64,4 +65,46 @@ describe("subagent target policy", () => {
6465
allowedIds: ["planner"],
6566
});
6667
});
68+
69+
describe("SPAWN_ALLOWLIST env-var fallback (#79490)", () => {
70+
it("returns undefined when SPAWN_ALLOWLIST is unset, blank, or comma-only", () => {
71+
expect(resolveSpawnAllowlistFromEnv({})).toBeUndefined();
72+
expect(resolveSpawnAllowlistFromEnv({ SPAWN_ALLOWLIST: "" })).toBeUndefined();
73+
expect(resolveSpawnAllowlistFromEnv({ SPAWN_ALLOWLIST: " " })).toBeUndefined();
74+
expect(resolveSpawnAllowlistFromEnv({ SPAWN_ALLOWLIST: ", , ," })).toBeUndefined();
75+
});
76+
77+
it("parses a single wildcard so docker-compose `SPAWN_ALLOWLIST=*` enables any-target spawns", () => {
78+
expect(resolveSpawnAllowlistFromEnv({ SPAWN_ALLOWLIST: "*" })).toEqual(["*"]);
79+
const result = resolveSubagentTargetPolicy({
80+
requesterAgentId: "main",
81+
targetAgentId: "basic-agent",
82+
requestedAgentId: "basic-agent",
83+
allowAgents: resolveSpawnAllowlistFromEnv({ SPAWN_ALLOWLIST: "*" }),
84+
});
85+
expect(result).toEqual({ ok: true });
86+
});
87+
88+
it("parses comma-separated agent ids and trims whitespace", () => {
89+
expect(
90+
resolveSpawnAllowlistFromEnv({ SPAWN_ALLOWLIST: "basic-agent, planner ,checker" }),
91+
).toEqual(["basic-agent", "planner", "checker"]);
92+
});
93+
94+
it("rejects targets that are not in the env-var allowlist with the same message as config-driven rejection", () => {
95+
const result = resolveSubagentTargetPolicy({
96+
requesterAgentId: "main",
97+
targetAgentId: "stranger",
98+
requestedAgentId: "stranger",
99+
allowAgents: resolveSpawnAllowlistFromEnv({ SPAWN_ALLOWLIST: "planner,checker" }),
100+
});
101+
expect(result.ok).toBe(false);
102+
if (result.ok) {
103+
throw new Error("Expected env-var allowlist to reject unknown target");
104+
}
105+
expect(result.error).toBe(
106+
"agentId is not allowed for sessions_spawn (allowed: checker, planner)",
107+
);
108+
});
109+
});
67110
});

src/agents/subagent-target-policy.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,38 @@
11
import { normalizeAgentId } from "../routing/session-key.js";
22

3+
const SPAWN_ALLOWLIST_ENV_VAR = "SPAWN_ALLOWLIST";
4+
35
type SubagentTargetPolicyResult = { ok: true } | { ok: false; allowedText: string; error: string };
46

7+
/**
8+
* Read the `SPAWN_ALLOWLIST` environment variable as a fallback subagent
9+
* allowlist when neither the per-agent `subagents.allowAgents` nor the
10+
* `agents.defaults.subagents.allowAgents` config keys are set. This lets
11+
* Docker / Kubernetes / Coolify deployments that ship `AGENTS=[...]` purely
12+
* through env vars also configure the spawn allowlist without having to
13+
* mount or write a config file. Comma-separated; `*` is preserved verbatim
14+
* so it flows through `normalizeAllowAgents`'s "allow any" branch.
15+
*
16+
* Returns `undefined` when the env var is unset or empty so callers can
17+
* keep using the standard nullish-coalescing chain
18+
* (`config-per-agent ?? config-default ?? env`).
19+
*
20+
* Refs #79490.
21+
*/
22+
export function resolveSpawnAllowlistFromEnv(
23+
env: NodeJS.ProcessEnv = process.env,
24+
): string[] | undefined {
25+
const raw = env[SPAWN_ALLOWLIST_ENV_VAR];
26+
if (typeof raw !== "string") {
27+
return undefined;
28+
}
29+
const parts = raw
30+
.split(",")
31+
.map((value) => value.trim())
32+
.filter(Boolean);
33+
return parts.length > 0 ? parts : undefined;
34+
}
35+
536
function normalizeAllowAgents(allowAgents: readonly string[] | undefined): {
637
configured: boolean;
738
allowAny: boolean;

src/agents/tools/agents-list-tool.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ import {
88
import { resolveModelAgentRuntimeMetadata } from "../agent-runtime-metadata.js";
99
import { resolveAgentConfig, resolveAgentEffectiveModelPrimary } from "../agent-scope.js";
1010
import { resolveDefaultModelForAgent } from "../model-selection.js";
11-
import { resolveSubagentAllowedTargetIds } from "../subagent-target-policy.js";
11+
import {
12+
resolveSpawnAllowlistFromEnv,
13+
resolveSubagentAllowedTargetIds,
14+
} from "../subagent-target-policy.js";
1215
import type { AnyAgentTool } from "./common.js";
1316
import { jsonResult } from "./common.js";
1417
import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js";
@@ -56,7 +59,8 @@ export function createAgentsListTool(opts?: {
5659

5760
const allowAgents =
5861
resolveAgentConfig(cfg, requesterAgentId)?.subagents?.allowAgents ??
59-
cfg?.agents?.defaults?.subagents?.allowAgents;
62+
cfg?.agents?.defaults?.subagents?.allowAgents ??
63+
resolveSpawnAllowlistFromEnv();
6064

6165
const configuredAgents = Array.isArray(cfg.agents?.list) ? cfg.agents?.list : [];
6266
const configuredIds = configuredAgents.map((entry) => normalizeAgentId(entry.id));

0 commit comments

Comments
 (0)