fix(acp): allow manual spawn with dispatch paused

This commit is contained in:
Peter Steinberger
2026-04-27 14:39:54 +01:00
parent c3b3da41fe
commit e035300d8e
13 changed files with 161 additions and 11 deletions

View File

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

View File

@@ -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 "<id>" 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. |

View File

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

View File

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

View File

@@ -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:/),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()),

View File

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

View File

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