From e035300d8ed3761d3005c26d21b6ff2a885ecd44 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 14:39:54 +0100 Subject: [PATCH] fix(acp): allow manual spawn with dispatch paused --- CHANGELOG.md | 1 + docs/tools/acp-agents.md | 8 ++- src/acp/policy.test.ts | 26 ++++++++ src/acp/policy.ts | 7 ++ src/agents/acp-spawn.test.ts | 1 + src/agents/acp-spawn.ts | 1 + .../agent-command.live-model-switch.test.ts | 65 ++++++++++++++++++- src/agents/agent-command.ts | 16 +++-- src/agents/command/types.ts | 4 ++ src/agents/tools/sessions-spawn-tool.test.ts | 17 +++++ src/gateway/protocol/schema/agent.ts | 1 + src/gateway/server-methods/agent.test.ts | 23 +++++++ src/gateway/server-methods/agent.ts | 2 + 13 files changed, 161 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4485229a5a..fec9157e06d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- ACP/sessions_spawn: let explicit `sessions_spawn(runtime="acp")` bootstrap turns run while `acp.dispatch.enabled=false` still blocks automatic ACP thread dispatch. Fixes #63591. Thanks @moeedahmed. - Google Meet: route local Chrome joins through OpenClaw browser control instead of raw default Chrome, so agents use the configured OpenClaw browser profile when opening Meet. - Plugins/discovery: follow symlinked plugin directories in global and workspace plugin roots while keeping broken links ignored and existing package safety checks in place. Fixes #36754; carries forward #72695 and #63206. Thanks @Quackstro, @ming1523, and @xsfX20. - Plugins/install: allow exact package-manager peer links back to the trusted OpenClaw host package during install security scans while continuing to block spoofed or nested escaping `node_modules` symlinks. Carries forward #70819. Thanks @fgabelmannjr. diff --git a/docs/tools/acp-agents.md b/docs/tools/acp-agents.md index 34fb35ac1bd..fce4f87641e 100644 --- a/docs/tools/acp-agents.md +++ b/docs/tools/acp-agents.md @@ -191,7 +191,9 @@ Quick `/acp` flow from chat: For `sessions_spawn`, `runtime: "acp"` is advertised only when ACP is enabled, the requester is not sandboxed, and an ACP runtime - backend is loaded. It targets ACP harness ids such as `codex`, + backend is loaded. `acp.dispatch.enabled=false` pauses automatic + ACP thread dispatch but does not hide or block explicit + `sessions_spawn({ runtime: "acp" })` calls. It targets ACP harness ids such as `codex`, `claude`, `droid`, `gemini`, or `opencode`. Do not pass a normal OpenClaw config agent id from `agents_list` unless that entry is explicitly configured with `agents.list[].runtime.type="acp"`; @@ -286,7 +288,7 @@ Examples: Required feature flags for thread-bound ACP: - `acp.enabled=true` - - `acp.dispatch.enabled` is on by default (set `false` to pause ACP dispatch). + - `acp.dispatch.enabled` is on by default (set `false` to pause automatic ACP thread dispatch; explicit `sessions_spawn({ runtime: "acp" })` calls still work). - Channel-adapter ACP thread-spawn flag enabled (adapter-specific): - Discord: `channels.discord.threadBindings.spawnAcpSessions=true` - Telegram: `channels.telegram.threadBindings.spawnAcpSessions=true` @@ -792,7 +794,7 @@ permission modes, see | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `ACP runtime backend is not configured` | Backend plugin missing, disabled, or blocked by `plugins.allow`. | Install and enable backend plugin, include `acpx` in `plugins.allow` when that allowlist is set, then run `/acp doctor`. | | `ACP is disabled by policy (acp.enabled=false)` | ACP globally disabled. | Set `acp.enabled=true`. | -| `ACP dispatch is disabled by policy (acp.dispatch.enabled=false)` | Dispatch from normal thread messages disabled. | Set `acp.dispatch.enabled=true`. | +| `ACP dispatch is disabled by policy (acp.dispatch.enabled=false)` | Automatic dispatch from normal thread messages disabled. | Set `acp.dispatch.enabled=true` to resume automatic thread routing; explicit `sessions_spawn({ runtime: "acp" })` calls still work. | | `ACP agent "" is not allowed by policy` | Agent not in allowlist. | Use allowed `agentId` or update `acp.allowedAgents`. | | `/acp doctor` reports backend not ready right after startup | Plugin dependency probe or self-repair is still running. | Wait briefly and rerun `/acp doctor`; if it stays unhealthy, inspect the backend install error and plugin allow/deny policy. | | Harness command not found | Adapter CLI is not installed or first-run `npx` fetch failed. | Install/prewarm the adapter on the Gateway host, or configure the acpx agent command explicitly. | diff --git a/src/acp/policy.test.ts b/src/acp/policy.test.ts index 38da8d992c8..0bedbace3ad 100644 --- a/src/acp/policy.test.ts +++ b/src/acp/policy.test.ts @@ -8,6 +8,7 @@ import { resolveAcpDispatchPolicyError, resolveAcpDispatchPolicyMessage, resolveAcpDispatchPolicyState, + resolveAcpExplicitTurnPolicyError, } from "./policy.js"; describe("acp policy", () => { @@ -44,6 +45,31 @@ describe("acp policy", () => { expect(resolveAcpDispatchPolicyMessage(cfg)).toContain("acp.dispatch.enabled=false"); }); + it("allows explicit ACP turns when only dispatch is disabled", () => { + const cfg = { + acp: { + enabled: true, + dispatch: { + enabled: false, + }, + }, + } satisfies OpenClawConfig; + expect(resolveAcpDispatchPolicyError(cfg)?.code).toBe("ACP_DISPATCH_DISABLED"); + expect(resolveAcpExplicitTurnPolicyError(cfg)).toBeNull(); + }); + + it("blocks explicit ACP turns when ACP is disabled", () => { + const cfg = { + acp: { + enabled: false, + dispatch: { + enabled: false, + }, + }, + } satisfies OpenClawConfig; + expect(resolveAcpExplicitTurnPolicyError(cfg)?.message).toContain("acp.enabled=false"); + }); + it("applies allowlist filtering for ACP agents", () => { const cfg = { acp: { diff --git a/src/acp/policy.ts b/src/acp/policy.ts index bcbe132ad80..21d8b1dc1cf 100644 --- a/src/acp/policy.ts +++ b/src/acp/policy.ts @@ -46,6 +46,13 @@ export function resolveAcpDispatchPolicyError(cfg: OpenClawConfig): AcpRuntimeEr return new AcpRuntimeError("ACP_DISPATCH_DISABLED", message); } +export function resolveAcpExplicitTurnPolicyError(cfg: OpenClawConfig): AcpRuntimeError | null { + if (isAcpEnabledByPolicy(cfg)) { + return null; + } + return new AcpRuntimeError("ACP_DISPATCH_DISABLED", ACP_DISABLED_MESSAGE); +} + export function isAcpAgentAllowedByPolicy(cfg: OpenClawConfig, agentId: string): boolean { const allowed = (cfg.acp?.allowedAgents ?? []) .map((entry) => normalizeAgentId(entry)) diff --git a/src/agents/acp-spawn.test.ts b/src/agents/acp-spawn.test.ts index 9333c14d131..28d2ce19e71 100644 --- a/src/agents/acp-spawn.test.ts +++ b/src/agents/acp-spawn.test.ts @@ -710,6 +710,7 @@ describe("spawnAcpDirect", () => { expect(agentCall?.params?.threadId).toBe("child-thread"); expect(agentCall?.params?.deliver).toBe(true); expect(agentCall?.params?.lane).toBe("subagent"); + expect(agentCall?.params?.acpTurnSource).toBe("manual_spawn"); expect(hoisted.initializeSessionMock).toHaveBeenCalledWith( expect.objectContaining({ sessionKey: expect.stringMatching(/^agent:codex:acp:/), diff --git a/src/agents/acp-spawn.ts b/src/agents/acp-spawn.ts index 89fb963caf9..6f60a288937 100644 --- a/src/agents/acp-spawn.ts +++ b/src/agents/acp-spawn.ts @@ -1318,6 +1318,7 @@ export async function spawnAcpDirect( idempotencyKey: childIdem, deliver: deliveryPlan.useInlineDelivery, lane: AGENT_LANE_SUBAGENT, + acpTurnSource: "manual_spawn", ...(params.runTimeoutSeconds != null ? { timeout: params.runTimeoutSeconds } : {}), label: params.label || undefined, }, diff --git a/src/agents/agent-command.live-model-switch.test.ts b/src/agents/agent-command.live-model-switch.test.ts index a404307e5cd..8449e6f815d 100644 --- a/src/agents/agent-command.live-model-switch.test.ts +++ b/src/agents/agent-command.live-model-switch.test.ts @@ -8,6 +8,9 @@ const state = vi.hoisted(() => ({ buildAcpResultMock: vi.fn(), createAcpVisibleTextAccumulatorMock: vi.fn(), persistAcpTurnTranscriptMock: vi.fn(), + resolveAcpAgentPolicyErrorMock: vi.fn(), + resolveAcpDispatchPolicyErrorMock: vi.fn(), + resolveAcpExplicitTurnPolicyErrorMock: vi.fn(), runWithModelFallbackMock: vi.fn(), runAgentAttemptMock: vi.fn(), resolveEffectiveModelFallbacksMock: vi.fn().mockReturnValue(undefined), @@ -83,12 +86,16 @@ vi.mock("./command/session.js", () => ({ vi.mock("./command/types.js", () => ({})); vi.mock("../acp/policy.js", () => ({ - resolveAcpAgentPolicyError: () => null, - resolveAcpDispatchPolicyError: () => null, + resolveAcpAgentPolicyError: (...args: unknown[]) => state.resolveAcpAgentPolicyErrorMock(...args), + resolveAcpDispatchPolicyError: (...args: unknown[]) => + state.resolveAcpDispatchPolicyErrorMock(...args), + resolveAcpExplicitTurnPolicyError: (...args: unknown[]) => + state.resolveAcpExplicitTurnPolicyErrorMock(...args), })); vi.mock("../acp/runtime/errors.js", () => ({ - toAcpRuntimeError: vi.fn(), + toAcpRuntimeError: ({ error }: { error: unknown }) => + error instanceof Error ? error : new Error(String(error)), })); vi.mock("../acp/runtime/session-identifiers.js", () => ({ @@ -404,6 +411,9 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { beforeEach(() => { vi.clearAllMocks(); state.acpResolveSessionMock.mockReturnValue(null); + state.resolveAcpAgentPolicyErrorMock.mockReturnValue(null); + state.resolveAcpDispatchPolicyErrorMock.mockReturnValue(null); + state.resolveAcpExplicitTurnPolicyErrorMock.mockReturnValue(null); state.acpRunTurnMock.mockImplementation(async (params: unknown) => { const onEvent = (params as { onEvent?: (event: unknown) => void }).onEvent; onEvent?.({ type: "text_delta", stream: "output", text: "done" }); @@ -629,6 +639,55 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { expect(transcriptParams.transcriptBody).not.toContain(INTERNAL_RUNTIME_CONTEXT_END); }); + it("allows manual ACP spawn turns when ACP dispatch is disabled", async () => { + state.acpResolveSessionMock.mockReturnValue({ + kind: "ready", + meta: { + agent: "claude", + cwd: "/tmp/workspace", + }, + }); + state.resolveAcpDispatchPolicyErrorMock.mockReturnValue( + new Error("ACP dispatch is disabled by policy (`acp.dispatch.enabled=false`)."), + ); + + await agentCommand({ + message: "bootstrap ACP child", + sessionKey: "agent:main", + senderIsOwner: true, + acpTurnSource: "manual_spawn", + }); + + expect(state.resolveAcpExplicitTurnPolicyErrorMock).toHaveBeenCalledTimes(1); + expect(state.resolveAcpDispatchPolicyErrorMock).not.toHaveBeenCalled(); + expect(state.acpRunTurnMock).toHaveBeenCalledTimes(1); + }); + + it("keeps ordinary ACP turns blocked when ACP dispatch is disabled", async () => { + state.acpResolveSessionMock.mockReturnValue({ + kind: "ready", + meta: { + agent: "claude", + cwd: "/tmp/workspace", + }, + }); + state.resolveAcpDispatchPolicyErrorMock.mockReturnValue( + new Error("ACP dispatch is disabled by policy (`acp.dispatch.enabled=false`)."), + ); + + await expect( + agentCommand({ + message: "automatic ACP turn", + sessionKey: "agent:main", + senderIsOwner: true, + }), + ).rejects.toThrow("ACP dispatch is disabled"); + + expect(state.resolveAcpExplicitTurnPolicyErrorMock).not.toHaveBeenCalled(); + expect(state.resolveAcpDispatchPolicyErrorMock).toHaveBeenCalledTimes(1); + expect(state.acpRunTurnMock).not.toHaveBeenCalled(); + }); + it("flips hasSessionModelOverride on provider-only switch with same model", async () => { setupModelSwitchRetry({ provider: "openai", diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index af28a3d17dd..7e055159e69 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -471,11 +471,17 @@ async function agentCommandInternal( const visibleTextAccumulator = attemptExecutionRuntime.createAcpVisibleTextAccumulator(); let stopReason: string | undefined; try { - const { resolveAcpAgentPolicyError, resolveAcpDispatchPolicyError } = - await loadAcpPolicyRuntime(); - const dispatchPolicyError = resolveAcpDispatchPolicyError(cfg); - if (dispatchPolicyError) { - throw dispatchPolicyError; + const { + resolveAcpAgentPolicyError, + resolveAcpDispatchPolicyError, + resolveAcpExplicitTurnPolicyError, + } = await loadAcpPolicyRuntime(); + const turnPolicyError = + opts.acpTurnSource === "manual_spawn" + ? resolveAcpExplicitTurnPolicyError(cfg) + : resolveAcpDispatchPolicyError(cfg); + if (turnPolicyError) { + throw turnPolicyError; } const acpAgent = normalizeAgentId( acpResolution.meta.agent || resolveAgentIdFromSessionKey(sessionKey), diff --git a/src/agents/command/types.ts b/src/agents/command/types.ts index 6be01e08ece..00ca410b63e 100644 --- a/src/agents/command/types.ts +++ b/src/agents/command/types.ts @@ -19,6 +19,8 @@ export type AgentCommandResultMetaOverrides = { fallbackFrom?: "gateway"; }; +export type AcpTurnSource = "manual_spawn"; + export type AgentRunContext = { messageChannel?: string; accountId?: string; @@ -105,6 +107,8 @@ export type AgentCommandOpts = { modelRun?: boolean; /** Internal prompt-mode override for trusted local/gateway callsites. */ promptMode?: PromptMode; + /** Internal ACP-ready session turn source. Manual spawn turns bypass only the dispatch gate. */ + acpTurnSource?: AcpTurnSource; }; export type AgentCommandIngressOpts = Omit< diff --git a/src/agents/tools/sessions-spawn-tool.test.ts b/src/agents/tools/sessions-spawn-tool.test.ts index 954aa42dca2..0a28d82fd90 100644 --- a/src/agents/tools/sessions-spawn-tool.test.ts +++ b/src/agents/tools/sessions-spawn-tool.test.ts @@ -161,6 +161,23 @@ describe("sessions_spawn tool", () => { expect(schema.properties?.runtime?.enum).toEqual(["subagent"]); }); + it("advertises ACP runtime affordances when only automatic ACP dispatch is disabled", () => { + registerAcpBackendForTest(); + + const tool = createSessionsSpawnTool({ + config: { + acp: { + enabled: true, + dispatch: { enabled: false }, + }, + }, + }); + const schema = tool.parameters as { properties?: { runtime?: { enum?: string[] } } }; + + expect(tool.description).toContain('runtime="acp"'); + expect(schema.properties?.runtime?.enum).toEqual(["subagent", "acp"]); + }); + it("uses subagent runtime by default", async () => { const tool = createSessionsSpawnTool({ agentSessionKey: "agent:main:main", diff --git a/src/gateway/protocol/schema/agent.ts b/src/gateway/protocol/schema/agent.ts index c71de26b29c..24f264f6ff3 100644 --- a/src/gateway/protocol/schema/agent.ts +++ b/src/gateway/protocol/schema/agent.ts @@ -164,6 +164,7 @@ export const AgentParamsSchema = Type.Object( bootstrapContextRunKind: Type.Optional( Type.Union([Type.Literal("default"), Type.Literal("heartbeat"), Type.Literal("cron")]), ), + acpTurnSource: Type.Optional(Type.Literal("manual_spawn")), internalEvents: Type.Optional(Type.Array(AgentInternalEventSchema)), inputProvenance: Type.Optional(InputProvenanceSchema), voiceWakeTrigger: Type.Optional(Type.String()), diff --git a/src/gateway/server-methods/agent.test.ts b/src/gateway/server-methods/agent.test.ts index 4faa4e15d3c..0d4e901efd1 100644 --- a/src/gateway/server-methods/agent.test.ts +++ b/src/gateway/server-methods/agent.test.ts @@ -525,6 +525,29 @@ describe("gateway agent handler", () => { ); }); + it("forwards explicit ACP turn source markers", async () => { + primeMainAgentRun(); + + await invokeAgent( + { + message: "bootstrap ACP child", + agentId: "main", + sessionKey: "agent:main:main", + acpTurnSource: "manual_spawn", + idempotencyKey: "test-acp-turn-source", + }, + { reqId: "test-acp-turn-source" }, + ); + + await waitForAssertion(() => expect(mocks.agentCommand).toHaveBeenCalled()); + const lastCall = mocks.agentCommand.mock.calls.at(-1); + expect(lastCall?.[0]).toEqual( + expect.objectContaining({ + acpTurnSource: "manual_spawn", + }), + ); + }); + it("rejects provider and model overrides for write-scoped callers", async () => { primeMainAgentRun(); mocks.agentCommand.mockClear(); diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index 5f9f98a37ac..aa061ce0c02 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -428,6 +428,7 @@ export const agentHandlers: GatewayRequestHandlers = { promptMode?: "full" | "minimal" | "none"; bootstrapContextMode?: "full" | "lightweight"; bootstrapContextRunKind?: "default" | "heartbeat" | "cron"; + acpTurnSource?: "manual_spawn"; internalEvents?: AgentInternalEvent[]; idempotencyKey: string; timeout?: number; @@ -1181,6 +1182,7 @@ export const agentHandlers: GatewayRequestHandlers = { extraSystemPrompt: request.extraSystemPrompt, bootstrapContextMode: request.bootstrapContextMode, bootstrapContextRunKind: request.bootstrapContextRunKind, + acpTurnSource: request.acpTurnSource, internalEvents: request.internalEvents, inputProvenance, abortSignal: activeRunAbort.controller.signal,