diff --git a/docs/concepts/session-tool.md b/docs/concepts/session-tool.md index cc6da5c7877..aa7b78607d4 100644 --- a/docs/concepts/session-tool.md +++ b/docs/concepts/session-tool.md @@ -156,6 +156,7 @@ Parameters: - `thread?` (default false; request thread-bound routing for this spawn when supported by the channel/plugin) - `mode?` (`run|session`; defaults to `run`, but defaults to `session` when `thread=true`; `mode="session"` requires `thread=true`) - `cleanup?` (`delete|keep`, default `keep`) +- `sandbox?` (`inherit|require`, default `inherit`; `require` rejects spawn unless the target child runtime is sandboxed) Allowlist: diff --git a/docs/tools/index.md b/docs/tools/index.md index 4e7d7f169c4..ab65287cbfb 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -466,7 +466,7 @@ Core parameters: - `sessions_list`: `kinds?`, `limit?`, `activeMinutes?`, `messageLimit?` (0 = none) - `sessions_history`: `sessionKey` (or `sessionId`), `limit?`, `includeTools?` - `sessions_send`: `sessionKey` (or `sessionId`), `message`, `timeoutSeconds?` (0 = fire-and-forget) -- `sessions_spawn`: `task`, `label?`, `runtime?`, `agentId?`, `model?`, `thinking?`, `cwd?`, `runTimeoutSeconds?`, `thread?`, `mode?`, `cleanup?` +- `sessions_spawn`: `task`, `label?`, `runtime?`, `agentId?`, `model?`, `thinking?`, `cwd?`, `runTimeoutSeconds?`, `thread?`, `mode?`, `cleanup?`, `sandbox?` - `session_status`: `sessionKey?` (default current; accepts `sessionId`), `model?` (`default` clears override) Notes: diff --git a/docs/tools/subagents.md b/docs/tools/subagents.md index a91ca2e325d..d5b4bfd8ce2 100644 --- a/docs/tools/subagents.md +++ b/docs/tools/subagents.md @@ -90,6 +90,7 @@ Tool params: - if `thread: true` and `mode` omitted, default becomes `session` - `mode: "session"` requires `thread: true` - `cleanup?` (`delete|keep`, default `keep`) +- `sandbox?` (`inherit|require`, default `inherit`; `require` rejects spawn unless target child runtime is sandboxed) ## Thread-bound sessions diff --git a/src/agents/openclaw-tools.sessions.test.ts b/src/agents/openclaw-tools.sessions.test.ts index 4baa4794a09..8cc029b8e45 100644 --- a/src/agents/openclaw-tools.sessions.test.ts +++ b/src/agents/openclaw-tools.sessions.test.ts @@ -92,6 +92,7 @@ describe("sessions tools", () => { expect(schemaProp("sessions_spawn", "runTimeoutSeconds").type).toBe("number"); expect(schemaProp("sessions_spawn", "thread").type).toBe("boolean"); expect(schemaProp("sessions_spawn", "mode").type).toBe("string"); + expect(schemaProp("sessions_spawn", "sandbox").type).toBe("string"); expect(schemaProp("sessions_spawn", "runtime").type).toBe("string"); expect(schemaProp("sessions_spawn", "cwd").type).toBe("string"); expect(schemaProp("subagents", "recentMinutes").type).toBe("number"); diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.test.ts index 970764e1480..d46beb61d14 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.test.ts @@ -47,12 +47,12 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => { return () => childSessionKey; } - async function executeSpawn(callId: string, agentId: string) { + async function executeSpawn(callId: string, agentId: string, sandbox?: "inherit" | "require") { const tool = await getSessionsSpawnTool({ agentSessionKey: "main", agentChannel: "whatsapp", }); - return tool.execute(callId, { task: "do thing", agentId }); + return tool.execute(callId, { task: "do thing", agentId, sandbox }); } async function expectAllowedSpawn(params: { @@ -191,4 +191,36 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => { expect(details.error).toContain("Sandboxed sessions cannot spawn unsandboxed subagents."); expect(callGatewayMock).not.toHaveBeenCalled(); }); + + it('forbids sandbox="require" when target runtime is unsandboxed', async () => { + setSessionsSpawnConfigOverride({ + session: { + mainKey: "main", + scope: "per-sender", + }, + agents: { + list: [ + { + id: "main", + subagents: { + allowAgents: ["research"], + }, + }, + { + id: "research", + sandbox: { + mode: "off", + }, + }, + ], + }, + }); + + const result = await executeSpawn("call12", "research", "require"); + const details = result.details as { status?: string; error?: string }; + + expect(details.status).toBe("forbidden"); + expect(details.error).toContain('sandbox="require"'); + expect(callGatewayMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index 92e3e3785b8..327a38eaf04 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -26,6 +26,8 @@ import { export const SUBAGENT_SPAWN_MODES = ["run", "session"] as const; export type SpawnSubagentMode = (typeof SUBAGENT_SPAWN_MODES)[number]; +export const SUBAGENT_SPAWN_SANDBOX_MODES = ["inherit", "require"] as const; +export type SpawnSubagentSandboxMode = (typeof SUBAGENT_SPAWN_SANDBOX_MODES)[number]; export type SpawnSubagentParams = { task: string; @@ -37,6 +39,7 @@ export type SpawnSubagentParams = { thread?: boolean; mode?: SpawnSubagentMode; cleanup?: "delete" | "keep"; + sandbox?: SpawnSubagentSandboxMode; expectsCompletionMessage?: boolean; }; @@ -174,6 +177,7 @@ export async function spawnSubagentDirect( const modelOverride = params.model; const thinkingOverrideRaw = params.thinking; const requestThreadBinding = params.thread === true; + const sandboxMode = params.sandbox === "require" ? "require" : "inherit"; const spawnMode = resolveSpawnMode({ requestedMode: params.mode, threadRequested: requestThreadBinding, @@ -278,11 +282,18 @@ export async function spawnSubagentDirect( cfg, sessionKey: childSessionKey, }); - if (requesterRuntime.sandboxed && !childRuntime.sandboxed) { + if (!childRuntime.sandboxed && (requesterRuntime.sandboxed || sandboxMode === "require")) { + if (requesterRuntime.sandboxed) { + return { + status: "forbidden", + error: + "Sandboxed sessions cannot spawn unsandboxed subagents. Set a sandboxed target agent or use the same agent runtime.", + }; + } return { status: "forbidden", error: - "Sandboxed sessions cannot spawn unsandboxed subagents. Set a sandboxed target agent or use the same agent runtime.", + 'sessions_spawn sandbox="require" needs a sandboxed target runtime. Pick a sandboxed agentId or use sandbox="inherit".', }; } const childDepth = callerDepth + 1; diff --git a/src/agents/tools/sessions-spawn-tool.test.ts b/src/agents/tools/sessions-spawn-tool.test.ts index c18f5bb8682..3414726ec11 100644 --- a/src/agents/tools/sessions-spawn-tool.test.ts +++ b/src/agents/tools/sessions-spawn-tool.test.ts @@ -53,6 +53,7 @@ describe("sessions_spawn tool", () => { thread: true, mode: "session", cleanup: "keep", + sandbox: "require", }); expect(result.details).toMatchObject({ @@ -70,6 +71,7 @@ describe("sessions_spawn tool", () => { thread: true, mode: "session", cleanup: "keep", + sandbox: "require", }), expect.objectContaining({ agentSessionKey: "agent:main:main", @@ -78,6 +80,25 @@ describe("sessions_spawn tool", () => { expect(hoisted.spawnAcpDirectMock).not.toHaveBeenCalled(); }); + it('defaults sandbox to "inherit" for subagent runtime', async () => { + const tool = createSessionsSpawnTool({ + agentSessionKey: "agent:main:main", + agentChannel: "discord", + }); + + await tool.execute("call-sandbox-default", { + task: "summarize logs", + agentId: "main", + }); + + expect(hoisted.spawnSubagentDirectMock).toHaveBeenCalledWith( + expect.objectContaining({ + sandbox: "inherit", + }), + expect.any(Object), + ); + }); + it("routes to ACP runtime when runtime=acp", async () => { const tool = createSessionsSpawnTool({ agentSessionKey: "agent:main:main", diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index e8f23f75660..3dccc863e45 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -7,6 +7,7 @@ import type { AnyAgentTool } from "./common.js"; import { jsonResult, readStringParam } from "./common.js"; const SESSIONS_SPAWN_RUNTIMES = ["subagent", "acp"] as const; +const SESSIONS_SPAWN_SANDBOX_MODES = ["inherit", "require"] as const; const SessionsSpawnToolSchema = Type.Object({ task: Type.String(), @@ -22,6 +23,7 @@ const SessionsSpawnToolSchema = Type.Object({ thread: Type.Optional(Type.Boolean()), mode: optionalStringEnum(SUBAGENT_SPAWN_MODES), cleanup: optionalStringEnum(["delete", "keep"] as const), + sandbox: optionalStringEnum(SESSIONS_SPAWN_SANDBOX_MODES), }); export function createSessionsSpawnTool(opts?: { @@ -55,6 +57,7 @@ export function createSessionsSpawnTool(opts?: { const mode = params.mode === "run" || params.mode === "session" ? params.mode : undefined; const cleanup = params.cleanup === "keep" || params.cleanup === "delete" ? params.cleanup : "keep"; + const sandbox = params.sandbox === "require" ? "require" : "inherit"; // Back-compat: older callers used timeoutSeconds for this tool. const timeoutSecondsCandidate = typeof params.runTimeoutSeconds === "number" @@ -98,6 +101,7 @@ export function createSessionsSpawnTool(opts?: { thread, mode, cleanup, + sandbox, expectsCompletionMessage: true, }, {