diff --git a/CHANGELOG.md b/CHANGELOG.md index 54e8b319cb8..53fd1622114 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -158,6 +158,7 @@ Docs: https://docs.openclaw.ai - Discord/thread session lifecycle: reset thread-scoped sessions when a thread is archived so reopening a thread starts fresh without deleting transcript history. Thanks @thewilloftheshadow. - Discord/presence defaults: send an online presence update on ready when no custom presence is configured so bots no longer appear offline by default. Thanks @thewilloftheshadow. - Discord/typing cleanup: stop typing indicators after silent/NO_REPLY runs by marking the run complete before dispatch idle cleanup. Thanks @thewilloftheshadow. +- ACP/sandbox spawn parity: block `/acp spawn` from sandboxed requester sessions with the same host-runtime guard already enforced for `sessions_spawn({ runtime: "acp" })`, preserving non-sandbox ACP flows while closing the command-path policy gap. Thanks @patte. - Discord/config SecretRef typing: align Discord account token config typing with SecretInput so SecretRef tokens typecheck. (#32490) Thanks @scoootscooob. - Discord/voice messages: request upload slots with JSON fetch calls so voice message uploads no longer fail with content-type errors. Thanks @thewilloftheshadow. - Discord/voice decoder fallback: drop the native Opus dependency and use opusscript for voice decoding to avoid native-opus installs. Thanks @thewilloftheshadow. diff --git a/docs/tools/acp-agents.md b/docs/tools/acp-agents.md index aa51e986552..74ed73248f1 100644 --- a/docs/tools/acp-agents.md +++ b/docs/tools/acp-agents.md @@ -252,7 +252,7 @@ ACP sessions currently run on the host runtime, not inside the OpenClaw sandbox. Current limitations: -- If the requester session is sandboxed, ACP spawns are blocked. +- If the requester session is sandboxed, ACP spawns are blocked for both `sessions_spawn({ runtime: "acp" })` and `/acp spawn`. - Error: `Sandboxed sessions cannot spawn ACP sessions because runtime="acp" runs on the host. Use runtime="subagent" from sandboxed sessions.` - `sessions_spawn` with `runtime: "acp"` does not support `sandbox: "require"`. - Error: `sessions_spawn sandbox="require" is unsupported for runtime="acp" because ACP sessions run outside the sandbox. Use runtime="subagent" or sandbox="inherit".` diff --git a/src/agents/acp-spawn.ts b/src/agents/acp-spawn.ts index d5da9d199d8..13cb66c2b54 100644 --- a/src/agents/acp-spawn.ts +++ b/src/agents/acp-spawn.ts @@ -81,6 +81,27 @@ export const ACP_SPAWN_ACCEPTED_NOTE = export const ACP_SPAWN_SESSION_ACCEPTED_NOTE = "thread-bound ACP session stays active after this task; continue in-thread for follow-ups."; +export function resolveAcpSpawnRuntimePolicyError(params: { + cfg: OpenClawConfig; + requesterSessionKey?: string; + requesterSandboxed?: boolean; + sandbox?: SpawnAcpSandboxMode; +}): string | undefined { + const sandboxMode = params.sandbox === "require" ? "require" : "inherit"; + const requesterRuntime = resolveSandboxRuntimeStatus({ + cfg: params.cfg, + sessionKey: params.requesterSessionKey, + }); + const requesterSandboxed = params.requesterSandboxed === true || requesterRuntime.sandboxed; + if (requesterSandboxed) { + return 'Sandboxed sessions cannot spawn ACP sessions because runtime="acp" runs on the host. Use runtime="subagent" from sandboxed sessions.'; + } + if (sandboxMode === "require") { + return 'sessions_spawn sandbox="require" is unsupported for runtime="acp" because ACP sessions run outside the sandbox. Use runtime="subagent" or sandbox="inherit".'; + } + return undefined; +} + type PreparedAcpThreadBinding = { channel: string; accountId: string; @@ -242,7 +263,6 @@ export async function spawnAcpDirect( error: "ACP is disabled by policy (`acp.enabled=false`).", }; } - const sandboxMode = params.sandbox === "require" ? "require" : "inherit"; const streamToParentRequested = params.streamTo === "parent"; const parentSessionKey = ctx.agentSessionKey?.trim(); if (streamToParentRequested && !parentSessionKey) { @@ -251,23 +271,16 @@ export async function spawnAcpDirect( error: 'sessions_spawn streamTo="parent" requires an active requester session context.', }; } - const requesterRuntime = resolveSandboxRuntimeStatus({ + const runtimePolicyError = resolveAcpSpawnRuntimePolicyError({ cfg, - sessionKey: ctx.agentSessionKey, + requesterSessionKey: ctx.agentSessionKey, + requesterSandboxed: ctx.sandboxed, + sandbox: params.sandbox, }); - const requesterSandboxed = ctx.sandboxed === true || requesterRuntime.sandboxed; - if (requesterSandboxed) { + if (runtimePolicyError) { return { status: "forbidden", - error: - 'Sandboxed sessions cannot spawn ACP sessions because runtime="acp" runs on the host. Use runtime="subagent" from sandboxed sessions.', - }; - } - if (sandboxMode === "require") { - return { - status: "forbidden", - error: - 'sessions_spawn sandbox="require" is unsupported for runtime="acp" because ACP sessions run outside the sandbox. Use runtime="subagent" or sandbox="inherit".', + error: runtimePolicyError, }; } diff --git a/src/auto-reply/reply/commands-acp.test.ts b/src/auto-reply/reply/commands-acp.test.ts index 5850e003b5a..7447419fd1e 100644 --- a/src/auto-reply/reply/commands-acp.test.ts +++ b/src/auto-reply/reply/commands-acp.test.ts @@ -592,6 +592,25 @@ describe("/acp command", () => { ); }); + it("forbids /acp spawn from sandboxed requester sessions", async () => { + const cfg = { + ...baseCfg, + agents: { + defaults: { + sandbox: { mode: "all" }, + }, + }, + } satisfies OpenClawConfig; + + const result = await runDiscordAcpCommand("/acp spawn codex", cfg); + + expect(result?.reply?.text).toContain("Sandboxed sessions cannot spawn ACP sessions"); + expect(hoisted.requireAcpRuntimeBackendMock).not.toHaveBeenCalled(); + expect(hoisted.ensureSessionMock).not.toHaveBeenCalled(); + expect(hoisted.sessionBindingBindMock).not.toHaveBeenCalled(); + expect(hoisted.callGatewayMock).not.toHaveBeenCalled(); + }); + it("cancels the ACP session bound to the current thread", async () => { mockBoundThreadSession({ state: "running" }); const result = await runThreadAcpCommand("/acp cancel", baseCfg); diff --git a/src/auto-reply/reply/commands-acp/lifecycle.ts b/src/auto-reply/reply/commands-acp/lifecycle.ts index feab0b60e24..43896f3ada3 100644 --- a/src/auto-reply/reply/commands-acp/lifecycle.ts +++ b/src/auto-reply/reply/commands-acp/lifecycle.ts @@ -15,6 +15,7 @@ import { resolveAcpSessionCwd, resolveAcpThreadSessionDetailLines, } from "../../../acp/runtime/session-identifiers.js"; +import { resolveAcpSpawnRuntimePolicyError } from "../../../agents/acp-spawn.js"; import { resolveThreadBindingIntroText, resolveThreadBindingThreadName, @@ -253,6 +254,13 @@ export async function handleAcpSpawnAction( } const spawn = parsed.value; + const runtimePolicyError = resolveAcpSpawnRuntimePolicyError({ + cfg: params.cfg, + requesterSessionKey: params.sessionKey, + }); + if (runtimePolicyError) { + return stopWithText(`⚠️ ${runtimePolicyError}`); + } const agentPolicyError = resolveAcpAgentPolicyError(params.cfg, spawn.agentId); if (agentPolicyError) { return stopWithText(