mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:00:42 +00:00
fix: gate acp spawn affordances
This commit is contained in:
@@ -66,6 +66,9 @@ Docs: https://docs.openclaw.ai
|
||||
directly for owner-authorized senders instead of returning `cronParams` and
|
||||
relying on a follow-up generic `cron` tool call. Fixes #70865. (#70937)
|
||||
Thanks @GaosCode.
|
||||
- Agents/ACP: hide `sessions_spawn` ACP runtime options unless an ACP backend is
|
||||
loaded, and make `/acp doctor` call out `plugins.allow` blocking bundled
|
||||
`acpx`. Thanks @vincentkoc.
|
||||
- Agents/subagents: keep queued subagent announces session-only when the
|
||||
requester has no external channel target, avoiding ambiguous multi-channel
|
||||
delivery failures. Fixes #59201. Thanks @larrylhollan.
|
||||
|
||||
@@ -958,7 +958,7 @@ Notes:
|
||||
```json5
|
||||
{
|
||||
acp: {
|
||||
enabled: false,
|
||||
enabled: true,
|
||||
dispatch: { enabled: true },
|
||||
backend: "acpx",
|
||||
defaultAgent: "main",
|
||||
@@ -982,9 +982,10 @@ Notes:
|
||||
}
|
||||
```
|
||||
|
||||
- `enabled`: global ACP feature gate (default: `false`).
|
||||
- `enabled`: global ACP feature gate (default: `true`; set `false` to hide ACP dispatch and spawn affordances).
|
||||
- `dispatch.enabled`: independent gate for ACP session turn dispatch (default: `true`). Set `false` to keep ACP commands available while blocking execution.
|
||||
- `backend`: default ACP runtime backend id (must match a registered ACP runtime plugin).
|
||||
If `plugins.allow` is set, include the backend plugin id (for example `acpx`) or the bundled default plugin will not load.
|
||||
- `defaultAgent`: fallback ACP target agent id when spawns do not specify an explicit target.
|
||||
- `allowedAgents`: allowlist of agent ids permitted for ACP runtime sessions; empty means no additional restriction.
|
||||
- `maxConcurrentSessions`: maximum concurrently active ACP sessions.
|
||||
|
||||
@@ -37,6 +37,7 @@ Usually, yes. Fresh installs ship the bundled `acpx` runtime plugin enabled by d
|
||||
|
||||
First-run gotchas:
|
||||
|
||||
- If `plugins.allow` is set, it is a restrictive plugin inventory and must include `acpx`; otherwise the bundled default is intentionally blocked and `/acp doctor` reports the missing allowlist entry.
|
||||
- Target harness adapters (Codex, Claude, etc.) may be fetched on demand with `npx` the first time you use them.
|
||||
- Vendor auth still has to exist on the host for that harness.
|
||||
- If the host has no npm or network access, first-run adapter fetches fail until caches are pre-warmed or the adapter is installed another way.
|
||||
@@ -78,12 +79,14 @@ Natural-language triggers that should route to the ACP runtime:
|
||||
|
||||
OpenClaw picks `runtime: "acp"`, resolves the harness `agentId`, binds to the current conversation or thread when supported, and routes follow-ups to that session until close/expiry. Codex only follows this path when ACP is explicit or the requested background runtime still needs ACP.
|
||||
|
||||
For `sessions_spawn`, `runtime: "acp"` targets ACP harness ids such as `codex`,
|
||||
`claude`, `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"`; otherwise use the default sub-agent runtime.
|
||||
When an OpenClaw agent is configured with `runtime.type="acp"`, OpenClaw uses
|
||||
`runtime.acp.agent` as the underlying harness id.
|
||||
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`, `claude`, `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"`; otherwise use
|
||||
the default sub-agent runtime. When an OpenClaw agent is configured with
|
||||
`runtime.type="acp"`, OpenClaw uses `runtime.acp.agent` as the underlying
|
||||
harness id.
|
||||
|
||||
## ACP versus sub-agents
|
||||
|
||||
@@ -551,7 +554,7 @@ plugin-tools and OpenClaw-tools MCP bridges, and ACP permission modes, see
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `ACP runtime backend is not configured` | Backend plugin missing or disabled. | Install and enable backend plugin, then run `/acp doctor`. |
|
||||
| `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 agent "<id>" is not allowed by policy` | Agent not in allowlist. | Use allowed `agentId` or update `acp.allowedAgents`. |
|
||||
|
||||
@@ -60,7 +60,7 @@ transcript path on disk when you need the raw full transcript.
|
||||
- `--model` and `--thinking` override defaults for that specific run.
|
||||
- Use `info`/`log` to inspect details and output after completion.
|
||||
- `/subagents spawn` is one-shot mode (`mode: "run"`). For persistent thread-bound sessions, use `sessions_spawn` with `thread: true` and `mode: "session"`.
|
||||
- For ACP harness sessions (Codex, Claude Code, Gemini CLI, OpenCode), use `sessions_spawn` with `runtime: "acp"` and see [ACP Agents](/tools/acp-agents), especially the [ACP delivery model](/tools/acp-agents#delivery-model) when debugging completions or agent-to-agent loops. `runtime: "acp"` expects an external ACP harness id, or an `agents.list[]` entry with `runtime.type="acp"`; use the default sub-agent runtime for normal OpenClaw config agents from `agents_list`.
|
||||
- For ACP harness sessions (Codex, Claude Code, Gemini CLI, OpenCode), use `sessions_spawn` with `runtime: "acp"` when the tool advertises that runtime, and see [ACP Agents](/tools/acp-agents), especially the [ACP delivery model](/tools/acp-agents#delivery-model) when debugging completions or agent-to-agent loops. OpenClaw hides `runtime: "acp"` until ACP is enabled, the requester is not sandboxed, and a backend plugin such as `acpx` is loaded. `runtime: "acp"` expects an external ACP harness id, or an `agents.list[]` entry with `runtime.type="acp"`; use the default sub-agent runtime for normal OpenClaw config agents from `agents_list`.
|
||||
|
||||
Primary goals:
|
||||
|
||||
|
||||
28
src/acp/runtime/availability.ts
Normal file
28
src/acp/runtime/availability.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { isAcpEnabledByPolicy } from "../policy.js";
|
||||
import { getAcpRuntimeBackend } from "./registry.js";
|
||||
|
||||
export function isAcpRuntimeSpawnAvailable(params: {
|
||||
config?: OpenClawConfig;
|
||||
sandboxed?: boolean;
|
||||
backendId?: string;
|
||||
}): boolean {
|
||||
if (params.sandboxed === true) {
|
||||
return false;
|
||||
}
|
||||
if (params.config && !isAcpEnabledByPolicy(params.config)) {
|
||||
return false;
|
||||
}
|
||||
const backend = getAcpRuntimeBackend(params.backendId ?? params.config?.acp?.backend);
|
||||
if (!backend) {
|
||||
return false;
|
||||
}
|
||||
if (!backend.healthy) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
return backend.healthy();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import path from "node:path";
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import type { ImageContent } from "@mariozechner/pi-ai";
|
||||
import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
|
||||
import { isAcpRuntimeSpawnAvailable } from "../../acp/runtime/availability.js";
|
||||
import type { ThinkLevel } from "../../auto-reply/thinking.js";
|
||||
import type { CliBackendConfig } from "../../config/types.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
@@ -111,7 +112,7 @@ export function buildSystemPrompt(params: {
|
||||
heartbeatPrompt: params.heartbeatPrompt,
|
||||
docsPath: params.docsPath,
|
||||
sourcePath: params.sourcePath,
|
||||
acpEnabled: params.config?.acp?.enabled !== false,
|
||||
acpEnabled: isAcpRuntimeSpawnAvailable({ config: params.config }),
|
||||
runtimeInfo,
|
||||
toolNames: params.tools.map((tool) => tool.name),
|
||||
modelAliasLines: buildModelAliasLines(params.config),
|
||||
|
||||
@@ -303,6 +303,7 @@ export function createOpenClawTools(
|
||||
agentGroupSpace: options?.agentGroupSpace,
|
||||
agentMemberRoleIds: options?.agentMemberRoleIds,
|
||||
sandboxed: options?.sandboxed,
|
||||
config: resolvedConfig,
|
||||
requesterAgentIdOverride: options?.requesterAgentIdOverride,
|
||||
workspaceDir: spawnWorkspaceDir,
|
||||
}),
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
estimateTokens,
|
||||
SessionManager,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import { isAcpRuntimeSpawnAvailable } from "../../acp/runtime/availability.js";
|
||||
import type { ThinkLevel } from "../../auto-reply/thinking.js";
|
||||
import { resolveChannelCapabilities } from "../../config/channel-capabilities.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
@@ -763,7 +764,10 @@ export async function compactEmbeddedPiSessionDirect(
|
||||
sourcePath: openClawReferences.sourcePath ?? undefined,
|
||||
ttsHint,
|
||||
promptMode,
|
||||
acpEnabled: params.config?.acp?.enabled !== false,
|
||||
acpEnabled: isAcpRuntimeSpawnAvailable({
|
||||
config: params.config,
|
||||
sandboxed: sandboxInfo?.enabled === true,
|
||||
}),
|
||||
runtimeInfo,
|
||||
reactionGuidance,
|
||||
messageToolHints,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
DefaultResourceLoader,
|
||||
SessionManager,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import { isAcpRuntimeSpawnAvailable } from "../../../acp/runtime/availability.js";
|
||||
import { filterHeartbeatPairs } from "../../../auto-reply/heartbeat-filter.js";
|
||||
import { resolveChannelCapabilities } from "../../../config/channel-capabilities.js";
|
||||
import { emitTrustedDiagnosticEvent } from "../../../infra/diagnostic-events.js";
|
||||
@@ -1117,7 +1118,10 @@ export async function runEmbeddedAttempt(
|
||||
workspaceNotes: workspaceNotes?.length ? workspaceNotes : undefined,
|
||||
reactionGuidance,
|
||||
promptMode: effectivePromptMode,
|
||||
acpEnabled: params.config?.acp?.enabled !== false,
|
||||
acpEnabled: isAcpRuntimeSpawnAvailable({
|
||||
config: params.config,
|
||||
sandboxed: sandboxInfo?.enabled === true,
|
||||
}),
|
||||
runtimeInfo,
|
||||
messageToolHints,
|
||||
sandboxInfo,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import crypto from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { isAcpRuntimeSpawnAvailable } from "../acp/runtime/availability.js";
|
||||
import type { SessionEntry } from "../config/sessions/types.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { SubagentSpawnPreparation } from "../context-engine/types.js";
|
||||
@@ -863,7 +864,10 @@ export async function spawnSubagentDirect(
|
||||
childSessionKey,
|
||||
label: label || undefined,
|
||||
task,
|
||||
acpEnabled: cfg.acp?.enabled !== false && !childRuntime.sandboxed,
|
||||
acpEnabled: isAcpRuntimeSpawnAvailable({
|
||||
config: cfg,
|
||||
sandboxed: childRuntime.sandboxed,
|
||||
}),
|
||||
childDepth,
|
||||
maxSpawnDepth,
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ export const SESSIONS_HISTORY_TOOL_DISPLAY_SUMMARY =
|
||||
"Read sanitized message history for a visible session.";
|
||||
export const SESSIONS_SEND_TOOL_DISPLAY_SUMMARY = "Send a message to another visible session.";
|
||||
export const SESSIONS_SPAWN_TOOL_DISPLAY_SUMMARY = "Spawn sub-agent or ACP sessions.";
|
||||
export const SESSIONS_SPAWN_SUBAGENT_TOOL_DISPLAY_SUMMARY = "Spawn sub-agent sessions.";
|
||||
export const SESSION_STATUS_TOOL_DISPLAY_SUMMARY = "Show session status, usage, and model state.";
|
||||
export const UPDATE_PLAN_TOOL_DISPLAY_SUMMARY = "Track a short structured work plan.";
|
||||
|
||||
@@ -31,14 +32,28 @@ export function describeSessionsSendTool(): string {
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
export function describeSessionsSpawnTool(): string {
|
||||
return [
|
||||
export function describeSessionsSpawnTool(options?: { acpAvailable?: boolean }): string {
|
||||
const baseDescription = [
|
||||
'Spawn a clean isolated session by default with `runtime="subagent"` or `runtime="acp"`.',
|
||||
'`mode="run"` is one-shot and `mode="session"` is persistent or thread-bound.',
|
||||
"Subagents inherit the parent workspace directory automatically.",
|
||||
'`runtime="acp"` is for external ACP harness ids such as codex, claude, gemini, or opencode, or agents configured with `agents.list[].runtime.type="acp"`.',
|
||||
'For native subagents only, set `context="fork"` when the child needs the current transcript context; otherwise omit it or use `context="isolated"`.',
|
||||
"Use this when the work should happen in a fresh child session instead of the current one.",
|
||||
];
|
||||
if (options?.acpAvailable === false) {
|
||||
return baseDescription
|
||||
.map((line) =>
|
||||
line.replace(
|
||||
' with `runtime="subagent"` or `runtime="acp"`',
|
||||
" with the native subagent runtime",
|
||||
),
|
||||
)
|
||||
.join(" ");
|
||||
}
|
||||
return [
|
||||
...baseDescription.slice(0, 3),
|
||||
'`runtime="acp"` is for external ACP harness ids such as codex, claude, gemini, or opencode, or agents configured with `agents.list[].runtime.type="acp"`.',
|
||||
...baseDescription.slice(3),
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
|
||||
@@ -29,13 +29,16 @@ vi.mock("../subagent-registry.js", () => ({
|
||||
}));
|
||||
|
||||
let createSessionsSpawnTool: typeof import("./sessions-spawn-tool.js").createSessionsSpawnTool;
|
||||
let acpRuntimeRegistry: typeof import("../../acp/runtime/registry.js");
|
||||
|
||||
describe("sessions_spawn tool", () => {
|
||||
beforeAll(async () => {
|
||||
({ createSessionsSpawnTool } = await import("./sessions-spawn-tool.js"));
|
||||
acpRuntimeRegistry = await import("../../acp/runtime/registry.js");
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
acpRuntimeRegistry.__testing.resetAcpRuntimeBackendsForTests();
|
||||
hoisted.spawnSubagentDirectMock.mockReset().mockResolvedValue({
|
||||
status: "accepted",
|
||||
childSessionKey: "agent:main:subagent:1",
|
||||
@@ -49,6 +52,114 @@ describe("sessions_spawn tool", () => {
|
||||
hoisted.registerSubagentRunMock.mockReset();
|
||||
});
|
||||
|
||||
function registerAcpBackendForTest() {
|
||||
acpRuntimeRegistry.registerAcpRuntimeBackend({
|
||||
id: "acpx",
|
||||
runtime: {
|
||||
ensureSession: vi.fn(async () => ({
|
||||
sessionKey: "agent:codex:acp:1",
|
||||
backend: "acpx",
|
||||
runtimeSessionName: "codex",
|
||||
})),
|
||||
async *runTurn() {},
|
||||
cancel: vi.fn(async () => {}),
|
||||
close: vi.fn(async () => {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it("hides ACP runtime affordances when no ACP backend is loaded", () => {
|
||||
const tool = createSessionsSpawnTool();
|
||||
const schema = tool.parameters as {
|
||||
properties?: {
|
||||
runtime?: { enum?: string[] };
|
||||
resumeSessionId?: unknown;
|
||||
streamTo?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
expect(tool.displaySummary).toBe("Spawn sub-agent sessions.");
|
||||
expect(tool.description).not.toContain("ACP");
|
||||
expect(tool.description).not.toContain('runtime="acp"');
|
||||
expect(schema.properties?.runtime?.enum).toEqual(["subagent"]);
|
||||
expect(schema.properties?.resumeSessionId).toBeUndefined();
|
||||
expect(schema.properties?.streamTo).toBeUndefined();
|
||||
});
|
||||
|
||||
it("advertises ACP runtime affordances when an ACP backend is loaded", () => {
|
||||
registerAcpBackendForTest();
|
||||
|
||||
const tool = createSessionsSpawnTool();
|
||||
const schema = tool.parameters as {
|
||||
properties?: {
|
||||
runtime?: { enum?: string[] };
|
||||
resumeSessionId?: unknown;
|
||||
streamTo?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
expect(tool.displaySummary).toBe("Spawn sub-agent or ACP sessions.");
|
||||
expect(tool.description).toContain('runtime="acp"');
|
||||
expect(schema.properties?.runtime?.enum).toEqual(["subagent", "acp"]);
|
||||
expect(schema.properties?.resumeSessionId).toBeDefined();
|
||||
expect(schema.properties?.streamTo).toBeDefined();
|
||||
});
|
||||
|
||||
it("hides ACP runtime affordances when the ACP backend is unhealthy", () => {
|
||||
acpRuntimeRegistry.registerAcpRuntimeBackend({
|
||||
id: "acpx",
|
||||
healthy: () => false,
|
||||
runtime: {
|
||||
ensureSession: vi.fn(async () => ({
|
||||
sessionKey: "agent:codex:acp:1",
|
||||
backend: "acpx",
|
||||
runtimeSessionName: "codex",
|
||||
})),
|
||||
async *runTurn() {},
|
||||
cancel: vi.fn(async () => {}),
|
||||
close: vi.fn(async () => {}),
|
||||
},
|
||||
});
|
||||
|
||||
const tool = createSessionsSpawnTool();
|
||||
const schema = tool.parameters as { properties?: { runtime?: { enum?: string[] } } };
|
||||
|
||||
expect(tool.description).not.toContain("ACP");
|
||||
expect(schema.properties?.runtime?.enum).toEqual(["subagent"]);
|
||||
});
|
||||
|
||||
it("rejects stale ACP runtime calls when no ACP backend is loaded", async () => {
|
||||
const tool = createSessionsSpawnTool();
|
||||
|
||||
const result = await tool.execute("call-acp-unavailable", {
|
||||
runtime: "acp",
|
||||
task: "investigate",
|
||||
agentId: "codex",
|
||||
});
|
||||
|
||||
expect(result.details).toMatchObject({
|
||||
status: "error",
|
||||
role: "codex",
|
||||
});
|
||||
expect(JSON.stringify(result.details)).toContain("no ACP runtime backend is loaded");
|
||||
expect(hoisted.spawnAcpDirectMock).not.toHaveBeenCalled();
|
||||
expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hides ACP runtime affordances when ACP policy is disabled", () => {
|
||||
registerAcpBackendForTest();
|
||||
|
||||
const tool = createSessionsSpawnTool({
|
||||
config: {
|
||||
acp: { enabled: false },
|
||||
},
|
||||
});
|
||||
const schema = tool.parameters as { properties?: { runtime?: { enum?: string[] } } };
|
||||
|
||||
expect(tool.description).not.toContain("ACP");
|
||||
expect(schema.properties?.runtime?.enum).toEqual(["subagent"]);
|
||||
});
|
||||
|
||||
it("uses subagent runtime by default", async () => {
|
||||
const tool = createSessionsSpawnTool({
|
||||
agentSessionKey: "agent:main:main",
|
||||
@@ -191,6 +302,7 @@ describe("sessions_spawn tool", () => {
|
||||
});
|
||||
|
||||
it('rejects lightContext when runtime is not "subagent"', async () => {
|
||||
registerAcpBackendForTest();
|
||||
const tool = createSessionsSpawnTool({
|
||||
agentSessionKey: "agent:main:main",
|
||||
});
|
||||
@@ -208,6 +320,7 @@ describe("sessions_spawn tool", () => {
|
||||
});
|
||||
|
||||
it("routes to ACP runtime when runtime=acp", async () => {
|
||||
registerAcpBackendForTest();
|
||||
const tool = createSessionsSpawnTool({
|
||||
agentSessionKey: "agent:main:main",
|
||||
agentChannel: "quietchat",
|
||||
@@ -251,6 +364,7 @@ describe("sessions_spawn tool", () => {
|
||||
});
|
||||
|
||||
it("forwards model override to ACP runtime spawns", async () => {
|
||||
registerAcpBackendForTest();
|
||||
const tool = createSessionsSpawnTool({
|
||||
agentSessionKey: "agent:main:main",
|
||||
});
|
||||
@@ -273,6 +387,7 @@ describe("sessions_spawn tool", () => {
|
||||
});
|
||||
|
||||
it("adds requested role to forwarded ACP failures", async () => {
|
||||
registerAcpBackendForTest();
|
||||
hoisted.spawnAcpDirectMock.mockResolvedValueOnce({
|
||||
status: "forbidden",
|
||||
error: "ACP disabled",
|
||||
@@ -296,10 +411,10 @@ describe("sessions_spawn tool", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards ACP sandbox options and requester sandbox context", async () => {
|
||||
it("forwards ACP sandbox options", async () => {
|
||||
registerAcpBackendForTest();
|
||||
const tool = createSessionsSpawnTool({
|
||||
agentSessionKey: "agent:main:subagent:parent",
|
||||
sandboxed: true,
|
||||
});
|
||||
|
||||
await tool.execute("call-2b", {
|
||||
@@ -316,7 +431,6 @@ describe("sessions_spawn tool", () => {
|
||||
}),
|
||||
expect.objectContaining({
|
||||
agentSessionKey: "agent:main:subagent:parent",
|
||||
sandboxed: true,
|
||||
}),
|
||||
);
|
||||
expect(hoisted.registerSubagentRunMock).toHaveBeenCalledWith(
|
||||
@@ -331,7 +445,29 @@ describe("sessions_spawn tool", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects ACP runtime calls from sandboxed requester sessions", async () => {
|
||||
registerAcpBackendForTest();
|
||||
const tool = createSessionsSpawnTool({
|
||||
agentSessionKey: "agent:main:subagent:parent",
|
||||
sandboxed: true,
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-sandboxed-acp", {
|
||||
runtime: "acp",
|
||||
task: "investigate",
|
||||
agentId: "codex",
|
||||
});
|
||||
|
||||
expect(result.details).toMatchObject({
|
||||
status: "error",
|
||||
role: "codex",
|
||||
});
|
||||
expect(JSON.stringify(result.details)).toContain("sandboxed sessions");
|
||||
expect(hoisted.spawnAcpDirectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes resumeSessionId through to ACP spawns", async () => {
|
||||
registerAcpBackendForTest();
|
||||
const tool = createSessionsSpawnTool({
|
||||
agentSessionKey: "agent:main:main",
|
||||
});
|
||||
@@ -369,6 +505,7 @@ describe("sessions_spawn tool", () => {
|
||||
});
|
||||
|
||||
it("rejects attachments for ACP runtime", async () => {
|
||||
registerAcpBackendForTest();
|
||||
const tool = createSessionsSpawnTool({
|
||||
agentSessionKey: "agent:main:main",
|
||||
agentChannel: "quietchat",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Type } from "typebox";
|
||||
import { isAcpRuntimeSpawnAvailable } from "../../acp/runtime/availability.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { callGateway } from "../../gateway/call.js";
|
||||
import { normalizeDeliveryContext } from "../../utils/delivery-context.shared.js";
|
||||
import type { GatewayMessageChannel } from "../../utils/message-channel.js";
|
||||
@@ -13,6 +15,7 @@ import {
|
||||
} from "../subagent-spawn.js";
|
||||
import {
|
||||
describeSessionsSpawnTool,
|
||||
SESSIONS_SPAWN_SUBAGENT_TOOL_DISPLAY_SUMMARY,
|
||||
SESSIONS_SPAWN_TOOL_DISPLAY_SUMMARY,
|
||||
} from "../tool-description-presets.js";
|
||||
import type { AnyAgentTool } from "./common.js";
|
||||
@@ -97,60 +100,79 @@ async function cleanupUntrackedAcpSession(sessionKey: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
const SessionsSpawnToolSchema = Type.Object({
|
||||
task: Type.String(),
|
||||
label: Type.Optional(Type.String()),
|
||||
runtime: optionalStringEnum(SESSIONS_SPAWN_RUNTIMES),
|
||||
agentId: Type.Optional(Type.String()),
|
||||
resumeSessionId: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
'Resume an existing agent session by its ID (e.g. a Codex session UUID from ~/.codex/sessions/). Requires runtime="acp". The agent replays conversation history via session/load instead of starting fresh.',
|
||||
}),
|
||||
),
|
||||
model: Type.Optional(Type.String()),
|
||||
thinking: Type.Optional(Type.String()),
|
||||
cwd: Type.Optional(Type.String()),
|
||||
runTimeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })),
|
||||
// Back-compat: older callers used timeoutSeconds for this tool.
|
||||
timeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })),
|
||||
thread: Type.Optional(Type.Boolean()),
|
||||
mode: optionalStringEnum(SUBAGENT_SPAWN_MODES),
|
||||
cleanup: optionalStringEnum(["delete", "keep"] as const),
|
||||
sandbox: optionalStringEnum(SESSIONS_SPAWN_SANDBOX_MODES),
|
||||
context: optionalStringEnum(SUBAGENT_SPAWN_CONTEXT_MODES, {
|
||||
description:
|
||||
'Native subagent context mode. Omit or use "isolated" for a clean child session; use "fork" only when the child needs the requester transcript context.',
|
||||
}),
|
||||
streamTo: optionalStringEnum(SESSIONS_SPAWN_ACP_STREAM_TARGETS),
|
||||
lightContext: Type.Optional(
|
||||
Type.Boolean({
|
||||
description:
|
||||
"When true, spawned subagent runs use lightweight bootstrap context. Only applies to runtime='subagent'.",
|
||||
}),
|
||||
),
|
||||
|
||||
// Inline attachments (snapshot-by-value).
|
||||
// NOTE: Attachment contents are redacted from transcript persistence by sanitizeToolCallInputs.
|
||||
attachments: Type.Optional(
|
||||
Type.Array(
|
||||
Type.Object({
|
||||
name: Type.String(),
|
||||
content: Type.String(),
|
||||
encoding: Type.Optional(optionalStringEnum(["utf8", "base64"] as const)),
|
||||
mimeType: Type.Optional(Type.String()),
|
||||
}),
|
||||
{ maxItems: 50 },
|
||||
function createSessionsSpawnToolSchema(params: { acpAvailable: boolean }) {
|
||||
const schema = {
|
||||
task: Type.String(),
|
||||
label: Type.Optional(Type.String()),
|
||||
runtime: optionalStringEnum(
|
||||
params.acpAvailable ? SESSIONS_SPAWN_RUNTIMES : (["subagent"] as const),
|
||||
),
|
||||
),
|
||||
attachAs: Type.Optional(
|
||||
Type.Object({
|
||||
// Where the spawned agent should look for attachments.
|
||||
// Kept as a hint; implementation materializes into the child workspace.
|
||||
mountPath: Type.Optional(Type.String()),
|
||||
agentId: Type.Optional(Type.String()),
|
||||
model: Type.Optional(Type.String()),
|
||||
thinking: Type.Optional(Type.String()),
|
||||
cwd: Type.Optional(Type.String()),
|
||||
runTimeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })),
|
||||
// Back-compat: older callers used timeoutSeconds for this tool.
|
||||
timeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })),
|
||||
thread: Type.Optional(Type.Boolean()),
|
||||
mode: optionalStringEnum(SUBAGENT_SPAWN_MODES),
|
||||
cleanup: optionalStringEnum(["delete", "keep"] as const),
|
||||
sandbox: optionalStringEnum(SESSIONS_SPAWN_SANDBOX_MODES),
|
||||
context: optionalStringEnum(SUBAGENT_SPAWN_CONTEXT_MODES, {
|
||||
description:
|
||||
'Native subagent context mode. Omit or use "isolated" for a clean child session; use "fork" only when the child needs the requester transcript context.',
|
||||
}),
|
||||
),
|
||||
});
|
||||
lightContext: Type.Optional(
|
||||
Type.Boolean({
|
||||
description:
|
||||
"When true, spawned subagent runs use lightweight bootstrap context. Only applies to runtime='subagent'.",
|
||||
}),
|
||||
),
|
||||
|
||||
// Inline attachments (snapshot-by-value).
|
||||
// NOTE: Attachment contents are redacted from transcript persistence by sanitizeToolCallInputs.
|
||||
attachments: Type.Optional(
|
||||
Type.Array(
|
||||
Type.Object({
|
||||
name: Type.String(),
|
||||
content: Type.String(),
|
||||
encoding: Type.Optional(optionalStringEnum(["utf8", "base64"] as const)),
|
||||
mimeType: Type.Optional(Type.String()),
|
||||
}),
|
||||
{ maxItems: 50 },
|
||||
),
|
||||
),
|
||||
attachAs: Type.Optional(
|
||||
Type.Object({
|
||||
// Where the spawned agent should look for attachments.
|
||||
// Kept as a hint; implementation materializes into the child workspace.
|
||||
mountPath: Type.Optional(Type.String()),
|
||||
}),
|
||||
),
|
||||
...(params.acpAvailable
|
||||
? {
|
||||
resumeSessionId: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
'Resume an existing agent session by its ID (e.g. a Codex session UUID from ~/.codex/sessions/). Requires runtime="acp". The agent replays conversation history via session/load instead of starting fresh.',
|
||||
}),
|
||||
),
|
||||
streamTo: optionalStringEnum(SESSIONS_SPAWN_ACP_STREAM_TARGETS),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
return Type.Object(schema);
|
||||
}
|
||||
|
||||
function resolveAcpUnavailableMessage(opts?: { sandboxed?: boolean; config?: OpenClawConfig }) {
|
||||
if (opts?.sandboxed === true) {
|
||||
return 'runtime="acp" is unavailable from sandboxed sessions because ACP sessions run on the host. Use runtime="subagent".';
|
||||
}
|
||||
if (opts?.config?.acp?.enabled === false) {
|
||||
return 'runtime="acp" is unavailable because ACP is disabled by policy (`acp.enabled=false`). Use runtime="subagent".';
|
||||
}
|
||||
return 'runtime="acp" is unavailable in this session because no ACP runtime backend is loaded. Enable the acpx plugin or use runtime="subagent".';
|
||||
}
|
||||
|
||||
export function createSessionsSpawnTool(
|
||||
opts?: {
|
||||
@@ -160,16 +182,23 @@ export function createSessionsSpawnTool(
|
||||
agentTo?: string;
|
||||
agentThreadId?: string | number;
|
||||
sandboxed?: boolean;
|
||||
config?: OpenClawConfig;
|
||||
/** Explicit agent ID override for cron/hook sessions where session key parsing may not work. */
|
||||
requesterAgentIdOverride?: string;
|
||||
} & SpawnedToolContext,
|
||||
): AnyAgentTool {
|
||||
const acpAvailable = isAcpRuntimeSpawnAvailable({
|
||||
config: opts?.config,
|
||||
sandboxed: opts?.sandboxed,
|
||||
});
|
||||
return {
|
||||
label: "Sessions",
|
||||
name: "sessions_spawn",
|
||||
displaySummary: SESSIONS_SPAWN_TOOL_DISPLAY_SUMMARY,
|
||||
description: describeSessionsSpawnTool(),
|
||||
parameters: SessionsSpawnToolSchema,
|
||||
displaySummary: acpAvailable
|
||||
? SESSIONS_SPAWN_TOOL_DISPLAY_SUMMARY
|
||||
: SESSIONS_SPAWN_SUBAGENT_TOOL_DISPLAY_SUMMARY,
|
||||
description: describeSessionsSpawnTool({ acpAvailable }),
|
||||
parameters: createSessionsSpawnToolSchema({ acpAvailable }),
|
||||
execute: async (_toolCallId, args) => {
|
||||
const params = args as Record<string, unknown>;
|
||||
const unsupportedParam = UNSUPPORTED_SESSIONS_SPAWN_PARAM_KEYS.find((key) =>
|
||||
@@ -197,6 +226,14 @@ export function createSessionsSpawnTool(
|
||||
params.context === "fork" || params.context === "isolated" ? params.context : undefined;
|
||||
const streamTo = params.streamTo === "parent" ? "parent" : undefined;
|
||||
const lightContext = params.lightContext === true;
|
||||
const roleContext = requestedAgentId ? { role: requestedAgentId } : {};
|
||||
if (runtime === "acp" && !acpAvailable) {
|
||||
return jsonResult({
|
||||
status: "error",
|
||||
error: resolveAcpUnavailableMessage(opts),
|
||||
...roleContext,
|
||||
});
|
||||
}
|
||||
if (runtime === "acp" && lightContext) {
|
||||
throw new Error("lightContext is only supported for runtime='subagent'.");
|
||||
}
|
||||
@@ -224,8 +261,6 @@ export function createSessionsSpawnTool(
|
||||
}>)
|
||||
: undefined;
|
||||
|
||||
const roleContext = requestedAgentId ? { role: requestedAgentId } : {};
|
||||
|
||||
if (streamTo && runtime !== "acp") {
|
||||
return jsonResult({
|
||||
status: "error",
|
||||
|
||||
@@ -1911,6 +1911,25 @@ describe("/acp command", () => {
|
||||
expect(result?.reply?.text).toContain("next:");
|
||||
});
|
||||
|
||||
it("explains when acpx is blocked by plugins.allow", async () => {
|
||||
hoisted.getAcpRuntimeBackendMock.mockReturnValue(null);
|
||||
hoisted.requireAcpRuntimeBackendMock.mockImplementation(() => {
|
||||
throw new AcpRuntimeError(
|
||||
"ACP_BACKEND_MISSING",
|
||||
"ACP runtime backend is not configured. Install and enable the acpx runtime plugin.",
|
||||
);
|
||||
});
|
||||
|
||||
const result = await runDiscordAcpCommand("/acp doctor", {
|
||||
...baseCfg,
|
||||
plugins: { allow: ["discord"] },
|
||||
});
|
||||
|
||||
expect(result?.reply?.text).toContain("pluginActivation: blocked");
|
||||
expect(result?.reply?.text).toContain("acpx");
|
||||
expect(result?.reply?.text).toContain('add "acpx" to plugins.allow');
|
||||
});
|
||||
|
||||
it("shows deterministic install instructions via /acp install", async () => {
|
||||
const result = await runDiscordAcpCommand("/acp install", baseCfg);
|
||||
|
||||
|
||||
@@ -22,6 +22,23 @@ import {
|
||||
} from "./shared.js";
|
||||
import { resolveBoundAcpThreadSessionKey } from "./targets.js";
|
||||
|
||||
function isBackendPluginBlockedByAllowlist(params: {
|
||||
cfg: HandleCommandsParams["cfg"];
|
||||
backendId: string;
|
||||
}): boolean {
|
||||
const allow = params.cfg.plugins?.allow;
|
||||
if (!Array.isArray(allow) || allow.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const normalizedBackendId = normalizeLowercaseStringOrEmpty(params.backendId);
|
||||
if (!normalizedBackendId) {
|
||||
return false;
|
||||
}
|
||||
return !allow.some(
|
||||
(pluginId) => normalizeLowercaseStringOrEmpty(pluginId) === normalizedBackendId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function handleAcpDoctorAction(
|
||||
params: HandleCommandsParams,
|
||||
restTokens: string[],
|
||||
@@ -56,6 +73,13 @@ export async function handleAcpDoctorAction(
|
||||
} else {
|
||||
lines.push("registeredBackend: (none)");
|
||||
}
|
||||
const backendBlockedByAllowlist = isBackendPluginBlockedByAllowlist({
|
||||
cfg: params.cfg,
|
||||
backendId,
|
||||
});
|
||||
if (backendBlockedByAllowlist) {
|
||||
lines.push(`pluginActivation: blocked (${backendId} is missing from plugins.allow)`);
|
||||
}
|
||||
|
||||
if (registeredBackend?.runtime.doctor) {
|
||||
try {
|
||||
@@ -102,6 +126,9 @@ export async function handleAcpDoctorAction(
|
||||
});
|
||||
lines.push("healthy: no");
|
||||
lines.push(formatAcpRuntimeErrorText(acpError));
|
||||
if (backendBlockedByAllowlist) {
|
||||
lines.push(`next: add "${backendId}" to plugins.allow or unset plugins.allow.`);
|
||||
}
|
||||
lines.push(`next: ${installHint}`);
|
||||
lines.push(`next: openclaw config set plugins.entries.${backendId}.enabled true`);
|
||||
if (normalizeLowercaseStringOrEmpty(backendId) === "acpx") {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { isAcpRuntimeSpawnAvailable } from "../../acp/runtime/availability.js";
|
||||
import { resolveSessionAgentIds } from "../../agents/agent-scope.js";
|
||||
import { resolveBootstrapContextForRun } from "../../agents/bootstrap-files.js";
|
||||
import { canExecRequestNode } from "../../agents/exec-defaults.js";
|
||||
@@ -162,7 +163,10 @@ export async function resolveCommandsSystemPromptBundle(
|
||||
skillsPrompt,
|
||||
heartbeatPrompt: undefined,
|
||||
ttsHint,
|
||||
acpEnabled: params.cfg?.acp?.enabled !== false,
|
||||
acpEnabled: isAcpRuntimeSpawnAvailable({
|
||||
config: params.cfg,
|
||||
sandboxed: sandboxRuntime.sandboxed,
|
||||
}),
|
||||
runtimeInfo,
|
||||
sandboxInfo,
|
||||
memoryCitationsMode: params.cfg?.memory?.citations,
|
||||
|
||||
@@ -306,6 +306,9 @@ describe("applyPluginAutoEnable providers", () => {
|
||||
acp: {
|
||||
enabled: true,
|
||||
},
|
||||
plugins: {
|
||||
allow: ["telegram"],
|
||||
},
|
||||
},
|
||||
candidates: [
|
||||
{
|
||||
@@ -317,6 +320,7 @@ describe("applyPluginAutoEnable providers", () => {
|
||||
env,
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.allow).toEqual(["telegram", "acpx"]);
|
||||
expect(result.config.plugins?.entries?.acpx?.enabled).toBe(true);
|
||||
expect(result.changes.join("\n")).toContain("ACP runtime configured, enabled automatically.");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user