mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
acp: fail honestly in bridge mode (#41424)
Merged via squash.
Prepared head SHA: b5e6e13afe
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
This commit is contained in:
@@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Cron/subagent followup: do not misclassify empty or `NO_REPLY` cron responses as interim acknowledgements that need a rerun, so deliberately silent cron jobs are no longer retried. (#41383) thanks @jackal092927.
|
- Cron/subagent followup: do not misclassify empty or `NO_REPLY` cron responses as interim acknowledgements that need a rerun, so deliberately silent cron jobs are no longer retried. (#41383) thanks @jackal092927.
|
||||||
- Auth/cooldowns: reset expired auth-profile cooldown error counters before computing the next backoff so stale on-disk counters do not re-escalate into long cooldown loops after expiry. (#41028) thanks @zerone0x.
|
- Auth/cooldowns: reset expired auth-profile cooldown error counters before computing the next backoff so stale on-disk counters do not re-escalate into long cooldown loops after expiry. (#41028) thanks @zerone0x.
|
||||||
- Gateway/node pending drain followup: keep `hasMore` true when the deferred baseline status item still needs delivery, and avoid allocating empty pending-work state for drain-only nodes with no queued work. (#41429) Thanks @mbelinky.
|
- Gateway/node pending drain followup: keep `hasMore` true when the deferred baseline status item still needs delivery, and avoid allocating empty pending-work state for drain-only nodes with no queued work. (#41429) Thanks @mbelinky.
|
||||||
|
- ACP/bridge mode: reject unsupported per-session MCP server setup and propagate rejected session-mode changes so IDE clients see explicit bridge limitations instead of silent success. (#41424) Thanks @mbelinky.
|
||||||
|
|
||||||
## 2026.3.8
|
## 2026.3.8
|
||||||
|
|
||||||
|
|||||||
34
docs.acp.md
34
docs.acp.md
@@ -17,6 +17,40 @@ Key goals:
|
|||||||
- Works with existing Gateway session store (list/resolve/reset).
|
- Works with existing Gateway session store (list/resolve/reset).
|
||||||
- Safe defaults (isolated ACP session keys by default).
|
- Safe defaults (isolated ACP session keys by default).
|
||||||
|
|
||||||
|
## Bridge Scope
|
||||||
|
|
||||||
|
`openclaw acp` is a Gateway-backed ACP bridge, not a full ACP-native editor
|
||||||
|
runtime. It is designed to route IDE prompts into an existing OpenClaw Gateway
|
||||||
|
session with predictable session mapping and basic streaming updates.
|
||||||
|
|
||||||
|
## Compatibility Matrix
|
||||||
|
|
||||||
|
| ACP area | Status | Notes |
|
||||||
|
| --------------------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `initialize`, `newSession`, `prompt`, `cancel` | Implemented | Core bridge flow over stdio to Gateway chat/send + abort. |
|
||||||
|
| `listSessions`, slash commands | Implemented | Session list works against Gateway session state; commands are advertised via `available_commands_update`. |
|
||||||
|
| `loadSession` | Partial | Rebinds the ACP session to a Gateway session key. Stored history is not replayed yet. |
|
||||||
|
| Prompt content (`text`, embedded `resource`, images) | Partial | Text/resources are flattened into chat input; images become Gateway attachments. |
|
||||||
|
| Session modes | Partial | `session/set_mode` is supported, but this bridge does not yet expose broader ACP-native mode or config surfaces. |
|
||||||
|
| Tool streaming | Partial | Tool start and result updates are forwarded, but without ACP-native terminal or richer editor metadata. |
|
||||||
|
| Per-session MCP servers (`mcpServers`) | Unsupported | Bridge mode rejects per-session MCP server requests. Configure MCP on the OpenClaw gateway or agent instead. |
|
||||||
|
| Client filesystem methods (`fs/read_text_file`, `fs/write_text_file`) | Unsupported | The bridge does not call ACP client filesystem methods. |
|
||||||
|
| Client terminal methods (`terminal/*`) | Unsupported | The bridge does not create ACP client terminals or stream terminal ids through tool calls. |
|
||||||
|
| Session plans / thought streaming | Unsupported | The bridge currently emits output text and tool status, not ACP plan or thought updates. |
|
||||||
|
|
||||||
|
## Known Limitations
|
||||||
|
|
||||||
|
- `loadSession` rebinds to an existing Gateway session, but it does not replay
|
||||||
|
prior user or assistant history yet.
|
||||||
|
- If multiple ACP clients share the same Gateway session key, event and cancel
|
||||||
|
routing are best-effort rather than strictly isolated per client. Prefer the
|
||||||
|
default isolated `acp:<uuid>` sessions when you need clean editor-local
|
||||||
|
turns.
|
||||||
|
- Gateway stop states are translated into ACP stop reasons, but that mapping is
|
||||||
|
less expressive than a fully ACP-native runtime.
|
||||||
|
- Tool follow-along data is intentionally narrow in bridge mode. The bridge
|
||||||
|
does not yet emit ACP terminals, file locations, or structured diffs.
|
||||||
|
|
||||||
## How can I use this
|
## How can I use this
|
||||||
|
|
||||||
Use ACP when an IDE or tooling speaks Agent Client Protocol and you want it to
|
Use ACP when an IDE or tooling speaks Agent Client Protocol and you want it to
|
||||||
|
|||||||
@@ -13,6 +13,38 @@ Run the [Agent Client Protocol (ACP)](https://agentclientprotocol.com/) bridge t
|
|||||||
This command speaks ACP over stdio for IDEs and forwards prompts to the Gateway
|
This command speaks ACP over stdio for IDEs and forwards prompts to the Gateway
|
||||||
over WebSocket. It keeps ACP sessions mapped to Gateway session keys.
|
over WebSocket. It keeps ACP sessions mapped to Gateway session keys.
|
||||||
|
|
||||||
|
`openclaw acp` is a Gateway-backed ACP bridge, not a full ACP-native editor
|
||||||
|
runtime. It focuses on session routing, prompt delivery, and basic streaming
|
||||||
|
updates.
|
||||||
|
|
||||||
|
## Compatibility Matrix
|
||||||
|
|
||||||
|
| ACP area | Status | Notes |
|
||||||
|
| --------------------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `initialize`, `newSession`, `prompt`, `cancel` | Implemented | Core bridge flow over stdio to Gateway chat/send + abort. |
|
||||||
|
| `listSessions`, slash commands | Implemented | Session list works against Gateway session state; commands are advertised via `available_commands_update`. |
|
||||||
|
| `loadSession` | Partial | Rebinds the ACP session to a Gateway session key. Stored history is not replayed yet. |
|
||||||
|
| Prompt content (`text`, embedded `resource`, images) | Partial | Text/resources are flattened into chat input; images become Gateway attachments. |
|
||||||
|
| Session modes | Partial | `session/set_mode` is supported, but this bridge does not yet expose broader ACP-native mode or config surfaces. |
|
||||||
|
| Tool streaming | Partial | Tool start and result updates are forwarded, but without ACP-native terminal or richer editor metadata. |
|
||||||
|
| Per-session MCP servers (`mcpServers`) | Unsupported | Bridge mode rejects per-session MCP server requests. Configure MCP on the OpenClaw gateway or agent instead. |
|
||||||
|
| Client filesystem methods (`fs/read_text_file`, `fs/write_text_file`) | Unsupported | The bridge does not call ACP client filesystem methods. |
|
||||||
|
| Client terminal methods (`terminal/*`) | Unsupported | The bridge does not create ACP client terminals or stream terminal ids through tool calls. |
|
||||||
|
| Session plans / thought streaming | Unsupported | The bridge currently emits output text and tool status, not ACP plan or thought updates. |
|
||||||
|
|
||||||
|
## Known Limitations
|
||||||
|
|
||||||
|
- `loadSession` rebinds to an existing Gateway session, but it does not replay
|
||||||
|
prior user or assistant history yet.
|
||||||
|
- If multiple ACP clients share the same Gateway session key, event and cancel
|
||||||
|
routing are best-effort rather than strictly isolated per client. Prefer the
|
||||||
|
default isolated `acp:<uuid>` sessions when you need clean editor-local
|
||||||
|
turns.
|
||||||
|
- Gateway stop states are translated into ACP stop reasons, but that mapping is
|
||||||
|
less expressive than a fully ACP-native runtime.
|
||||||
|
- Tool follow-along data is intentionally narrow in bridge mode. The bridge
|
||||||
|
does not yet emit ACP terminals, file locations, or structured diffs.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
LoadSessionRequest,
|
LoadSessionRequest,
|
||||||
NewSessionRequest,
|
NewSessionRequest,
|
||||||
PromptRequest,
|
PromptRequest,
|
||||||
|
SetSessionModeRequest,
|
||||||
} from "@agentclientprotocol/sdk";
|
} from "@agentclientprotocol/sdk";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { GatewayClient } from "../gateway/client.js";
|
import type { GatewayClient } from "../gateway/client.js";
|
||||||
@@ -38,6 +39,14 @@ function createPromptRequest(
|
|||||||
} as unknown as PromptRequest;
|
} as unknown as PromptRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createSetSessionModeRequest(sessionId: string, modeId: string): SetSessionModeRequest {
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
modeId,
|
||||||
|
_meta: {},
|
||||||
|
} as unknown as SetSessionModeRequest;
|
||||||
|
}
|
||||||
|
|
||||||
async function expectOversizedPromptRejected(params: { sessionId: string; text: string }) {
|
async function expectOversizedPromptRejected(params: { sessionId: string; text: string }) {
|
||||||
const request = vi.fn(async () => ({ ok: true })) as GatewayClient["request"];
|
const request = vi.fn(async () => ({ ok: true })) as GatewayClient["request"];
|
||||||
const sessionStore = createInMemorySessionStore();
|
const sessionStore = createInMemorySessionStore();
|
||||||
@@ -97,6 +106,71 @@ describe("acp session creation rate limit", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("acp unsupported bridge session setup", () => {
|
||||||
|
it("rejects per-session MCP servers on newSession", async () => {
|
||||||
|
const sessionStore = createInMemorySessionStore();
|
||||||
|
const connection = createAcpConnection();
|
||||||
|
const sessionUpdate = vi.spyOn(connection, "sessionUpdate");
|
||||||
|
const agent = new AcpGatewayAgent(connection, createAcpGateway(), {
|
||||||
|
sessionStore,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
agent.newSession({
|
||||||
|
...createNewSessionRequest(),
|
||||||
|
mcpServers: [{ name: "docs", command: "mcp-docs" }] as never[],
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/does not support per-session MCP servers/i);
|
||||||
|
|
||||||
|
expect(sessionStore.hasSession("docs-session")).toBe(false);
|
||||||
|
expect(sessionUpdate).not.toHaveBeenCalled();
|
||||||
|
sessionStore.clearAllSessionsForTest();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects per-session MCP servers on loadSession", async () => {
|
||||||
|
const sessionStore = createInMemorySessionStore();
|
||||||
|
const connection = createAcpConnection();
|
||||||
|
const sessionUpdate = vi.spyOn(connection, "sessionUpdate");
|
||||||
|
const agent = new AcpGatewayAgent(connection, createAcpGateway(), {
|
||||||
|
sessionStore,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
agent.loadSession({
|
||||||
|
...createLoadSessionRequest("docs-session"),
|
||||||
|
mcpServers: [{ name: "docs", command: "mcp-docs" }] as never[],
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/does not support per-session MCP servers/i);
|
||||||
|
|
||||||
|
expect(sessionStore.hasSession("docs-session")).toBe(false);
|
||||||
|
expect(sessionUpdate).not.toHaveBeenCalled();
|
||||||
|
sessionStore.clearAllSessionsForTest();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("acp setSessionMode bridge behavior", () => {
|
||||||
|
it("surfaces gateway mode patch failures instead of succeeding silently", async () => {
|
||||||
|
const sessionStore = createInMemorySessionStore();
|
||||||
|
const request = vi.fn(async (method: string) => {
|
||||||
|
if (method === "sessions.patch") {
|
||||||
|
throw new Error("gateway rejected mode");
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}) as GatewayClient["request"];
|
||||||
|
const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(request), {
|
||||||
|
sessionStore,
|
||||||
|
});
|
||||||
|
|
||||||
|
await agent.loadSession(createLoadSessionRequest("mode-session"));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
agent.setSessionMode(createSetSessionModeRequest("mode-session", "high")),
|
||||||
|
).rejects.toThrow(/gateway rejected mode/i);
|
||||||
|
|
||||||
|
sessionStore.clearAllSessionsForTest();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("acp prompt size hardening", () => {
|
describe("acp prompt size hardening", () => {
|
||||||
it("rejects oversized prompt blocks without leaking active runs", async () => {
|
it("rejects oversized prompt blocks without leaking active runs", async () => {
|
||||||
await expectOversizedPromptRejected({
|
await expectOversizedPromptRejected({
|
||||||
|
|||||||
@@ -170,9 +170,7 @@ export class AcpGatewayAgent implements Agent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
|
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
|
||||||
if (params.mcpServers.length > 0) {
|
this.assertSupportedSessionSetup(params.mcpServers);
|
||||||
this.log(`ignoring ${params.mcpServers.length} MCP servers`);
|
|
||||||
}
|
|
||||||
this.enforceSessionCreateRateLimit("newSession");
|
this.enforceSessionCreateRateLimit("newSession");
|
||||||
|
|
||||||
const sessionId = randomUUID();
|
const sessionId = randomUUID();
|
||||||
@@ -193,9 +191,7 @@ export class AcpGatewayAgent implements Agent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
|
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
|
||||||
if (params.mcpServers.length > 0) {
|
this.assertSupportedSessionSetup(params.mcpServers);
|
||||||
this.log(`ignoring ${params.mcpServers.length} MCP servers`);
|
|
||||||
}
|
|
||||||
if (!this.sessionStore.hasSession(params.sessionId)) {
|
if (!this.sessionStore.hasSession(params.sessionId)) {
|
||||||
this.enforceSessionCreateRateLimit("loadSession");
|
this.enforceSessionCreateRateLimit("loadSession");
|
||||||
}
|
}
|
||||||
@@ -256,7 +252,7 @@ export class AcpGatewayAgent implements Agent {
|
|||||||
this.log(`setSessionMode: ${session.sessionId} -> ${params.modeId}`);
|
this.log(`setSessionMode: ${session.sessionId} -> ${params.modeId}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.log(`setSessionMode error: ${String(err)}`);
|
this.log(`setSessionMode error: ${String(err)}`);
|
||||||
throw err;
|
throw err instanceof Error ? err : new Error(String(err));
|
||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
@@ -536,6 +532,15 @@ export class AcpGatewayAgent implements Agent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private assertSupportedSessionSetup(mcpServers: ReadonlyArray<unknown>): void {
|
||||||
|
if (mcpServers.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
"ACP bridge mode does not support per-session MCP servers. Configure MCP on the OpenClaw gateway or agent instead.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private enforceSessionCreateRateLimit(method: "newSession" | "loadSession"): void {
|
private enforceSessionCreateRateLimit(method: "newSession" | "loadSession"): void {
|
||||||
const budget = this.sessionCreateRateLimiter.consume();
|
const budget = this.sessionCreateRateLimiter.consume();
|
||||||
if (budget.allowed) {
|
if (budget.allowed) {
|
||||||
|
|||||||
Reference in New Issue
Block a user