mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:40:44 +00:00
fix(acp): allow manual spawn with dispatch paused
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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:/),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user