feat(agents): add sessions_spawn sandbox require mode

This commit is contained in:
Peter Steinberger
2026-03-02 01:27:25 +00:00
parent a6a742f3d0
commit bfeadb80b6
8 changed files with 76 additions and 5 deletions

View File

@@ -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:

View File

@@ -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:

View File

@@ -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

View File

@@ -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");

View File

@@ -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();
});
});

View File

@@ -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;

View File

@@ -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",

View File

@@ -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,
},
{