Skip to content

feat(acp): ACP server over WebSocket (revives #1260)#1418

Open
brettchien wants to merge 44 commits into
openabdev:mainfrom
brettchien:feat/acp-server-revive
Open

feat(acp): ACP server over WebSocket (revives #1260)#1418
brettchien wants to merge 44 commits into
openabdev:mainfrom
brettchien:feat/acp-server-revive

Conversation

@brettchien

@brettchien brettchien commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

What problem does this solve?

OpenAB exposes agents only through platform-specific adapters (Discord, Telegram, LINE, …) and an OpenAI-compatible VTuber endpoint. Every new frontend — IDE plugin, desktop app, browser/web client, CLI — needs a bespoke adapter, and none of them get bidirectional streaming, tool-call approval, or session resume for free.

This adds an ACP (Agent Client Protocol) server over WebSocket at GET /acp, so any standard ACP client can drive an OpenAB agent through one wire-conformant protocol.

  • Revives the auto-closed implementation in feat(acp): ACP Server with WebSocket transport #1260 (resolves its outstanding review fix).
  • Absorbs the ADR proposal in docs(adr): ACP Server with WebSocket Transport #1258 (kept verbatim as acp-server-websocket.md), plus an as-built companion ADR (acp-server-websocket-base.md) and a design-only forward ADR (acp-server-websocket-mcp-browser.md, Status: Proposed) that records the post-base roadmap referenced from base ADR §6. No browser/MCP code lands in this PR — the ADR is documentation only.

Discord Discussion URL: https://discord.com/channels/1491295327620169908/1527521703255605338

At a Glance

ACP client (browser / Zed / JetBrains / CLI)
      │  WSS  GET /acp   (JSON-RPC 2.0)
      ▼
openab-gateway :: adapters/acp_server
   initialize · session/new · session/resume · session/prompt · session/cancel
      │  GatewayEvent (platform="acp", channel=acp_<uuid>)
      ▼
OAB core  ──session_key = acp:<channel_id>──▶  agent (claude / codex / kiro)
      ▲  GatewayReply (Phase-1: one terminal snapshot)
      └── session/update { agent_message_chunk } ──▶ client

Prior Art & Industry Research

OpenClawopenclaw acp (docs, acpx) is a Gateway-backed bridge: it speaks ACP over stdio and forwards prompts to the OpenClaw Gateway over WebSocket, mapping each ACP session id to a Gateway session key so IDEs can reconnect to the same transcript. This is essentially the architecture used here (ACP ↔ gateway event bus, sessionIdchannel_id/session_key, default isolated acp:<uuid> session). The difference: OpenClaw's bridge is a locally-spawned stdio process; OpenAB exposes ACP directly at the gateway over WebSocket.

Hermes Agenthermes acp (docs, #569) starts a stdio JSON-RPC ACP server used by VS Code / Zed / JetBrains, exposing session creation, prompt, streaming agent-message chunks, tool-call events, permission requests, cancel, and auth, with sessions that persist across editor restarts. OpenAB's Phase 1 matches the chat subset (create / resume / prompt / stream / cancel); tool-calls + permissions are deferred to Phase 2.

ProtocolAgent Client Protocol (Apache-2.0, by Zed; backed by Zed + JetBrains). This PR is pinned to Schema v1.19.0; the exact method surface and OpenAB's coverage are tracked in docs/acp-official-methods.md.

Both references use stdio (local, IDE-spawned). OpenAB deliberately chooses WebSocket to also serve remote and browser clients, which stdio cannot.

Proposed Solution

A new acp_server gateway adapter behind the acp feature + OPENAB_ACP_ENABLED, wire-conformant with ACP Schema v1.19.0:

  • initialize → integer protocolVersion: 1, official agentCapabilities (sessionCapabilities.resume, loadSession: false, promptCapabilities), authMethods: [].
  • session/new{ sessionId }; session/prompt delivers the reply via a session/update notification (agent_message_chunk) — Phase-1 sends the whole reply as one terminal chunk (backend streaming=false); progressive multi-chunk streaming is Phase-2 — and returns { stopReason } (end_turn / cancelled).
  • session/cancel → one-way notification; ends the in-flight prompt with stopReason:"cancelled".
  • session/resume → re-attach to a persisted session without replaying history.
  • Token auth on the WS upgrade (timing-safe compare).
  • Content blockstext and resource_link (ACP baseline) are accepted; resource_link is passed through as a text reference (not fetched — SSRF-safe), and any other block type (image / audio / embedded resource) is rejected explicitly rather than silently dropped.

sessionId = sess_<uuid> and channel_id = acp_<uuid> share one uuid; prompts become a GatewayEvent and OAB core keys continuity by session_key = acp:<channel_id>.

Why this approach?

  • WebSocket, not stdio — unlike OpenClaw/Hermes' locally-spawned stdio bridges, WSS serves remote and browser clients directly.
  • session/resume, not session/load — OpenAB keeps no replayable upstream transcript (conversation state lives in the downstream agent CLI), so it cannot satisfy session/load's replay contract; it advertises loadSession: false and re-attaches via core's persisted session mapping. Rationale and the core evidence are in docs/adr/acp-server-websocket-base.md §3.
  • Hand-rolled JSON-RPC — the Phase 1 surface is small; avoids adding the agent-client-protocol crate dependency.

Alternatives Considered

  • stdio bridge (OpenClaw/Hermes style) — rejected: cannot serve browser/remote clients.
  • Extend the OpenAI-compatible VTuber endpoint (feat(vtuber): add OpenAI-compatible adapter #1234) — rejected: request/response SSE is one-way, so no tool-approval / cancel / resume.
  • session/load with replay — deferred to Phase 3; requires an upstream transcript store OpenAB does not have today.

Validation

Mirrors ci.yml:

  • cargo check --workspace
  • cargo clippy --workspace -- -D warnings
  • cargo clippy --workspace --features unified -- -D warnings
  • cargo test --workspace

Manual client testing against a live ACP client (Zed) for field-level exactness is noted as follow-up in docs/acp-official-methods.md.

Scope / follow-ups (not in this PR)

  • Phase 2: tool-call session/update variants + session/request_permission.
  • Phase 3: session/load with history replay.
  • Phase 4: multi-agent fan-out. Phase 5: Streamable HTTP.
  • cwd / mcpServers are accepted for wire conformance but not yet propagated to the agent.

Review Contract

Goal

Ship a wire-conformant ACP v1 server over WebSocket at GET /acp for the streaming chat subset (initialize / session.new / session.resume / session.prompt / session.cancel), mounted on both the standalone gateway and the embedded openab run server, with generated (typify) serde-only wire types and conformance/handler/streaming tests. Also lands the review-hardening fixes: JSON-RPC notification semantics, required-param validation, content-block rejection, resource caps, trace redaction, grapheme-safe message splitting, and full (untruncated) ACP reply delivery.

Non-goals

  • Tool calls / session.request_permission, fs/*, terminal/* (later phases).
  • Multi-agent fan-out, Streamable HTTP transport, session.load history replay.
  • Full backpressure (bounded outbound channel), idle eviction, global connection limits.
  • Backend cancellation propagation and process-wide session ownership (see Follow-ups).
  • Enabling transport auth by default (see Accepted Residual Risks).

Accepted Residual Risks

  • Transport auth (addressed)/acp requires OPENAB_ACP_AUTH_KEY on any non-loopback bind (fail-closed off loopback, F1); no-key is permitted only on a loopback bind for local dev. The key is carried via Authorization: Bearer or, for browsers, the Sec-WebSocket-Protocol subprotocol (openab.bearer.<key>) — kept out of the URL (F11); ?token= remains only as a deprecated fallback. Residual: a header-logging middlebox could still capture the key (use wss://); identity admission still relies on the gateway trust registry (GATEWAY_ALLOWED_USERS).
  • session.cancel does not stop the backend — it ends the gateway waiter with stopReason:"cancelled"; downstream model/tool work may continue (F3).
  • Resume has no process-wide ownership — concurrent holders of a resumable session id can race; low risk for the single-user base (F5).
  • Outbound queue unbounded — per-connection session/in-flight/frame caps are applied, but the outbound channel itself is not yet bounded (F6 partial).

Acceptance Criteria

  • Gate green: cargo clippy --workspace --features unified -- -D warnings, cargo test --workspace, cargo build --features unified.
  • Conformance, handler-level, and streaming unit tests pass; a runnable scripts/acp-ws-smoke.py drives a live deployment.
  • Live e2e on a real backend: initialize → session.new → streamed prompt → stopReason:end_turn → resume, plus /model and /reset replies rendering back over ACP.

Follow-ups

  • Backend cancellation propagation (F3) and process-wide session ownership (F5).
  • Full backpressure / bounded outbound channel / idle eviction / global limits (F6).
  • initialize negotiation validation (F8). (F10 resource_link support is resolved — accepted as SSRF-safe passthrough; see Proposed Solution.)
  • Live long-reply e2e re-verify of the truncation fix (needs a redeploy).

brettchien and others added 3 commits July 17, 2026 21:55
Restore the ACP server adapter from the auto-closed openabdev#1260, rebased onto
main. Exposes OAB as an ACP server over WebSocket at `GET /acp`, gated by
the `acp` feature + `OPENAB_ACP_ENABLED`. Prompts are bridged to
`GatewayEvent` and replies streamed back through the existing pipeline.

Also resolves the outstanding review finding on openabdev#1260: `serve()` built
`AcpConfig::from_env()` twice (two independent registries) — now extracted
once, matching `AppState::from_env()`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Align the ACP server to the official Agent Client Protocol (Schema v1.19.0)
so standard ACP clients interoperate:

- initialize: integer `protocolVersion: 1`, official `agentCapabilities`
  (`sessionCapabilities.resume`, `loadSession: false`, `promptCapabilities`),
  `authMethods: []`.
- streaming: `session/update` notifications with
  `sessionUpdate: "agent_message_chunk"` and a `content` ContentBlock
  (was a custom `session/notification` + `chunk` wrapper).
- stopReason: official snake_case (`end_turn` / `cancelled`); a backend
  timeout has no ACP stopReason and returns a JSON-RPC error instead.
- session/cancel: one-way notification (no response) that ends the in-flight
  prompt with `stopReason: "cancelled"`.
- session/resume: re-attach to a persisted session without replaying history
  (`session/load` is intentionally unsupported — the gateway keeps no
  replayable transcript; conversation state lives in the downstream agent).

Also make delta streaming panic-safe: slice the reply snapshot with
`str::get` instead of a byte index, so a boundary landing mid-codepoint
(CJK / emoji) skips the frame instead of panicking.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Absorbs the pending ADR proposal from openabdev#1258 and adds two companion docs:

- `docs/adr/acp-server-websocket.md` — the original proposal (@pahud), verbatim.
- `docs/adr/acp-server-websocket-phase1.md` — the as-built Phase 1 ADR: the
  wire-conformant primitive surface, the `session/resume` (not `session/load`)
  decision with its rationale, and divergences from the proposal.
- `docs/acp-official-methods.md` — the official ACP method surface pinned to
  Schema v1.19.0, with OpenAB's Phase 1 coverage and intentional non-support.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@brettchien
brettchien requested a review from thepagent as a code owner July 17, 2026 14:05
@openab-app openab-app Bot added the closing-soon PR missing Discord Discussion URL — will auto-close in 24 hours. label Jul 17, 2026
@chaodu-agent

This comment has been minimized.

@chaodu-agent chaodu-agent left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

CHANGES REQUESTED ⚠️ — The ACP endpoint is not safely or functionally integrated with the trust, unified-routing, cancellation, and turn-finalization contracts yet.

Consolidated review: #1418 (comment)

}
let auth_key = std::env::var("OPENAB_ACP_AUTH_KEY").ok();
if auth_key.is_none() {
warn!("OPENAB_ACP_AUTH_KEY not set — ACP endpoint is UNAUTHENTICATED");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 F1 — Fail closed when ACP authentication is absent

Enabling ACP with no key leaves /acp unauthenticated; an empty environment value is also accepted as a configured key. On the default 0.0.0.0 listener, a configuration omission exposes agent access to the network.

Requested change: Require a non-empty key before mounting /acp, or gate anonymous mode behind an explicit loopback-only opt-in.


// Convert to GatewayEvent and dispatch
let event = GatewayEvent::new(
"acp",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 F2 — Register ACP with the shared trust gate

Both gateway ingress paths gate this platform: "acp" event, but the trust registry never registers acp; its fallback is L3 deny-all. Authenticated prompts therefore stop before dispatch.

Requested change: Add an explicit ACP trust policy tied to successful transport authentication and test that authenticated prompts are admitted.

Comment thread Cargo.toml

# Opt-in: compile all gateway adapters into a single unified binary
unified = ["telegram", "line", "feishu", "googlechat", "wecom", "teams"]
unified = ["telegram", "line", "feishu", "googlechat", "wecom", "teams", "acp"]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 F3 — Do not advertise incomplete unified support

This enables ACP in unified builds, but the unified Axum router does not mount /acp and UnifiedGatewayAdapter::dispatch_reply has no "acp" arm, so the feature is unreachable and replies would be dropped.

Requested change: Wire both route and reply dispatch in unified mode, or remove acp from this feature until that integration is complete.

registry.lock().unwrap_or_else(|e| e.into_inner()).remove(key);
}
}
None | Some("send_message") => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 F4 — Use an explicit end-of-turn signal

Inferring completion from send_message breaks both modes: streaming normally finalizes by editing the first chunk and never emits Done, while send-once output over 4096 characters becomes multiple sends and this branch ends/removes the route after the first chunk.

Requested change: Carry explicit turn completion across the core↔gateway contract and test short streaming plus multi-chunk send-once responses.

if let Some(k) = sess_key {
let notify = sessions.lock().await.get(k).and_then(|s| s.cancel.clone());
if let Some(n) = notify {
n.notify_one();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 F5 — Propagate cancellation to the backend

This only wakes the WebSocket-side waiter. The core session keeps running, so tool calls and side effects can continue after the client receives stopReason: "cancelled".

Requested change: Send cancellation through a core control path to the underlying session and finalize the ACP request only after cancellation is propagated.

registry
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(channel_id.clone(), reply_tx);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F6 — Add connection ownership to resumed sessions

The reply registry is global and keyed only by channel ID, while each connection owns a separate session map. Concurrent connections can resume the same session and this insertion replaces the earlier waiter; stale disconnect cleanup can then remove the newer route.

Requested change: Track connection/turn ownership, define reject-or-handoff semantics, and use compare-and-remove cleanup.

let mut prompt_tasks: Vec<tokio::task::JoinHandle<()>> = Vec::new();

// Channel for sending messages back to the client
let (out_tx, mut out_rx) = mpsc::unbounded_channel::<String>();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F7 — Bound client-controlled queues and session state

The outbound queue is unbounded and the same connection can create unlimited sessions and prompt tasks. A leaked shared token can therefore drive unbounded memory growth.

Requested change: Use a bounded queue and enforce per-connection session/prompt caps plus a global connection limit with explicit overload errors.

@brettchien
brettchien force-pushed the feat/acp-server-revive branch from d28d352 to 01ab50b Compare July 17, 2026 16:10
@chaodu-agent

This comment has been minimized.

@chaodu-agent chaodu-agent left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

CHANGES REQUESTED ⚠️ — The embedded route now exists, but ACP authentication, trust admission, turn completion, cancellation, ownership, resource bounds, and feature-matrix behavior still require changes.

Consolidated review: #1418 (comment)

}
let auth_key = std::env::var("OPENAB_ACP_AUTH_KEY").ok();
if auth_key.is_none() {
warn!("OPENAB_ACP_AUTH_KEY not set — ACP endpoint is UNAUTHENTICATED");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 F1 — Fail closed when ACP authentication is absent

Enabling ACP with no key leaves /acp unauthenticated, and an empty value is accepted as the expected key. The embedded server listens on 0.0.0.0:8080 by default, so a configuration omission exposes agent access.

Requested change: Require a non-empty key before mounting /acp, or gate anonymous mode behind an explicit loopback-only opt-in.


// Convert to GatewayEvent and dispatch
let event = GatewayEvent::new(
"acp",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 F2 — Bind ACP authentication to trust admission

This emits platform: "acp", but the shared trust registry never registers ACP; the unknown-platform fallback is L3 deny-all. Every prompt is rejected before dispatch even after the new embedded route is mounted.

Requested change: Register an ACP policy tied to successful transport authentication and test authenticated admission plus unauthenticated denial.

registry.lock().unwrap_or_else(|e| e.into_inner()).remove(key);
}
}
None | Some("send_message") => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 F3 — Use an explicit end-of-turn signal

Treating send_message as completion breaks both delivery modes: normal streaming finalizes via edit_message and never emits Done, while send-once output over 4096 characters produces multiple sends and this first send closes the route before later chunks.

Requested change: Carry explicit turn completion/correlation across the core↔gateway contract and test short streaming plus multi-chunk send-once responses.

if let Some(k) = sess_key {
let notify = sessions.lock().await.get(k).and_then(|s| s.cancel.clone());
if let Some(n) = notify {
n.notify_one();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 F4 — Propagate cancellation to the backend

This only wakes the WebSocket-side waiter. The core session keeps running, so tool calls and side effects can continue after the client receives stopReason: "cancelled", and late output can race with a subsequent prompt.

Requested change: Cancel the underlying core session and retain a generation/ownership barrier until the old turn cannot affect a new prompt.

registry
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(channel_id.clone(), reply_tx);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F5 — Add process-wide ownership to resumed sessions

The global registry is keyed only by channel ID, while session state is per connection. A second connection can resume the same session and overwrite this sender; stale prompt or disconnect cleanup can then remove the newer route.

Requested change: Track connection/turn ownership, define duplicate-resume semantics, and use generation-bound compare-and-remove cleanup.

let mut prompt_tasks: Vec<tokio::task::JoinHandle<()>> = Vec::new();

// Channel for sending messages back to the client
let (out_tx, mut out_rx) = mpsc::unbounded_channel::<String>();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F6 — Bound client-controlled state and queues

This outbound queue is unbounded, as are per-prompt reply queues, session entries, and prompt tasks. Full response snapshots are cloned into queues, so a slow or unauthenticated client can drive unbounded memory growth.

Requested change: Use bounded queues and enforce per-connection session/prompt caps plus a global connection limit with explicit overload errors.

Comment thread src/main.rs
// The ACP endpoint (mounted below) needs this embedded HTTP server too —
// start it even when only non-webhook platforms (e.g. Discord, which the
// core connects to directly) are configured.
let acp_enabled = cfg!(feature = "acp")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F7 — Compile the embedded server for the ACP-only feature

This branch is nested inside an outer #[cfg(any(...))] that omits feature = "acp"; the same omission guards mod unified_adapter and unified_platform_enabled. Therefore --no-default-features --features acp compiles out the server and /acp route.

Requested change: Add ACP to all three guards and add an ACP-only feature-matrix build/test.

continue;
}

let id = req.id.clone().unwrap_or(Value::Null);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F8 — Require IDs for request-only ACP methods

A missing ID becomes null, after which request-only methods execute and send id: null responses. Notification-shaped session/new and session/prompt can mutate state or start backend work without a correlatable request.

Requested change: Do not execute request-only ACP methods without an ID, and never send a response to a JSON-RPC notification.

.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.or_else(|| query.get("token").map(|s| s.as_str()));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F9 — Keep long-lived bearer credentials out of URLs

?token= can be captured by reverse-proxy/access logs, browser history, and observability systems, leaking the shared ACP key.

Requested change: Use Authorization for non-browser clients and a redacted short-lived ticket or secure cookie flow for browsers; otherwise require short-lived query credentials and documented log redaction.

@brettchien
brettchien force-pushed the feat/acp-server-revive branch 2 times, most recently from 35b59a4 to 19ec888 Compare July 17, 2026 17:53
@openab-app openab-app Bot removed the closing-soon PR missing Discord Discussion URL — will auto-close in 24 hours. label Jul 17, 2026
@chaodu-agent

This comment has been minimized.

@chaodu-agent chaodu-agent left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

CHANGES REQUESTED ⚠️ — Trust admission is fixed, but ACP authentication, turn finalization, backend cancellation, ownership, resource bounds, protocol request handling, and tests still require changes.

Consolidated review: #1418 (comment)

if !enabled {
return None;
}
let auth_key = std::env::var("OPENAB_ACP_AUTH_KEY").ok();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 F1 — Fail closed when ACP authentication is absent

Enabling ACP without a key skips authentication entirely, and an empty configured key accepts an empty bearer/query token. On the default 0.0.0.0:8080 listener, a configuration omission exposes agent access.

Requested change: Require a non-empty key before mounting /acp, or gate anonymous mode behind an explicit loopback-only opt-in; test absent, empty, invalid, and valid keys.

match reply.command.as_deref() {
Some("edit_message") => {
// Streaming update — send as text snapshot
if tx.send(ReplyChunk::Text(full_text)).is_err() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 F2 — Use an explicit end-of-turn signal

Streaming finalization arrives as edit_message, which emits text but never Done, so successful prompts wait for the 180-second timeout. Conversely, a multi-chunk send-once response emits Done and removes the route after its first chunk.

Requested change: Carry explicit correlated turn completion across the core↔gateway contract and test short streaming plus multi-chunk send-once responses.

if let Some(k) = sess_key {
let notify = sessions.lock().await.get(k).and_then(|s| s.cancel.clone());
if let Some(n) = notify {
n.notify_one();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 F3 — Propagate cancellation to the backend

This only wakes the WebSocket-side waiter. The core session keeps running, so tool calls and side effects can continue after the client receives stopReason: "cancelled", and late output can race with the next prompt.

Requested change: Cancel the underlying core session and retain a generation/ownership fence until the old turn can no longer affect a new prompt.

registry
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(channel_id.clone(), reply_tx);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F4 — Add process-wide ownership to resumed sessions

The global registry is keyed only by deterministic channel ID while session state is per connection. A second connection can replace this sender, and stale prompt/disconnect cleanup can remove the newer route.

Requested change: Track connection/turn generations, define duplicate-resume semantics, and use generation-bound compare-and-remove cleanup.

let mut prompt_tasks: Vec<tokio::task::JoinHandle<()>> = Vec::new();

// Channel for sending messages back to the client
let (out_tx, mut out_rx) = mpsc::unbounded_channel::<String>();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F5 — Bound client-controlled state and queues

This outbound queue is unbounded, as are per-prompt reply queues, session entries, and prompt tasks. Full response snapshots are cloned into queues, so a slow client or leaked credential can drive unbounded memory growth.

Requested change: Use bounded queues and enforce per-connection session/prompt caps, a global connection limit, input limits, and deterministic overload behavior.

Comment thread src/main.rs
// The ACP endpoint (mounted below) needs this embedded HTTP server too —
// start it even when only non-webhook platforms (e.g. Discord, which the
// core connects to directly) are configured.
let acp_enabled = cfg!(feature = "acp")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F6 — Compile the embedded server for the ACP-only feature

This runtime branch is nested inside outer #[cfg(any(...))] guards that omit feature = "acp"; the same omission guards mod unified_adapter and unified_platform_enabled. Therefore an ACP-only root feature build compiles out the server and route.

Requested change: Add ACP to all enclosing guards and add an ACP-only startup test for /health plus /acp.

continue;
}

let id = req.id.clone().unwrap_or(Value::Null);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F7 — Require IDs for request-only ACP methods

A missing ID becomes null, after which request-only methods execute and send id: null responses. Notification-shaped session/new and session/prompt can mutate state or start backend work without a correlatable request.

Requested change: Do not execute request-only ACP methods without an ID, and never send a response to a JSON-RPC notification.

.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.or_else(|| query.get("token").map(|s| s.as_str()));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F8 — Keep long-lived bearer credentials out of URLs

?token= can be captured by reverse-proxy/CDN logs, browser history, and observability systems, leaking the shared ACP key.

Requested change: Use Authorization for non-browser clients and a redacted short-lived ticket or secure-cookie flow for browsers; otherwise require short-lived query credentials and documented log redaction.

openabdev#1260 mounted the ACP endpoint only on the standalone `openab-gateway`
binary (`serve()`). Fleet deployments run the unified binary's embedded
gateway (`openab run`, main.rs) instead, which never called `serve()` — so
`/acp` was unreachable there.

Mount `/acp` on the embedded gateway too (gated by `OPENAB_ACP_ENABLED`),
and route ACP replies back through the unified adapter's `dispatch_reply`
(`platform == "acp"`). Also start the embedded HTTP server when ACP is
enabled even if only non-webhook platforms (e.g. Discord, which the core
connects to directly) are configured — otherwise the listener never binds.

Seed the `acp` platform into the gateway trust registry so its identity
gating honours `GATEWAY_ALLOW_ALL_USERS` / `GATEWAY_ALLOWED_USERS` (the
ACP sender id is `acp_client`); without this the acp platform defaulted to
deny-all and every prompt was rejected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@brettchien
brettchien force-pushed the feat/acp-server-revive branch from 19ec888 to 0c099df Compare July 18, 2026 02:40
brettchien and others added 5 commits July 18, 2026 12:11
Proposed design for the agent's LLM to autonomously operate the user's
browser ("computer use" against the real, logged-in Chrome), exposed as
MCP tools tunnelled over the existing `/acp` WebSocket (MCP-over-ACP):

- extension runs the MCP server role over its outbound WS (MV3 can't listen);
- OpenAB core proxies the browser tools to the in-pod agent (MCP client);
- needs the agent→client request direction added, and generated (v1) types.

Design only — not implemented. Linked from the Phase 1 roadmap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reframe the as-built ACP-over-WS server ADR from "Phase 1" to "base" — it is
the foundation later phases build on, not one numbered phase of many. Renames
`acp-server-websocket-phase1.md` → `acp-server-websocket-base.md`, retitles,
and updates the two referencing docs (method coverage + the Phase 2
browser-control ADR). Roadmap phase numbers (Phase 2–5) are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`acp-mcp-browser-control.md` → `acp-server-websocket-mcp-browser.md` so all
three ADRs share the `acp-server-websocket-*` prefix (proposal / base /
mcp-browser) and group together. Updates the base ADR roadmap link.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the original proposal's numbered Phase 2–5 roadmap with a re-scoped
plan driven by the north-star goal (LLM operating the user's browser):

- base §6: Critical path (agent→client requests, request_permission,
  MCP-over-ACP + core proxy, generated typed v1) / Optional / Not needed /
  Observability tiers.
- Multi-agent "conversation" clarified: N independent OpenAB instances relayed
  by the client (a room) — client-side, not ACP fan-out. Fan-out removed.
- Note OpenAB command parity is mostly free over ACP (text directives/slash are
  platform-agnostic).
- Recommend an ACP trace mode first (know behavior, de-risk generated types).
- Browser ADR de-phased (proposed north-star, not "Phase 2").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fold the standalone typing analysis into base ADR §7: hand-rolled now →
generated (typify, serde-only, ~0 dep) for the expanded surface; full
`agent-client-protocol` crate rejected (2nd async runtime), schema-only crate
is +24 schemars-heavy deps; v1 only; round-trip caveat; hand-roll trivial /
generate complex, downstream core client is the bigger ROI. Renumbers refs to §8.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@chaodu-agent

This comment has been minimized.

@chaodu-agent chaodu-agent left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

CHANGES REQUESTED ⚠️ — Documentation was expanded, but fail-open authentication, broken turn finalization, backend cancellation, ownership, resource, protocol, and test gaps remain.

Consolidated review: #1418 (comment)

if !enabled {
return None;
}
let auth_key = std::env::var("OPENAB_ACP_AUTH_KEY").ok();

@chaodu-agent chaodu-agent Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 F1 — Fail closed when transport authentication is absent

Enabling ACP without a non-empty key skips upgrade authentication. Because every connection becomes the same synthetic acp_client, allowing that identity in the trust registry makes an omitted key a network-wide bypass.

Requested change: Refuse to enable/mount /acp without a non-empty key, or require an explicit loopback-only insecure mode; test absent, empty, invalid, and valid keys.

registry.lock().unwrap_or_else(|e| e.into_inner()).remove(key);
}
}
None | Some("send_message") => {

@chaodu-agent chaodu-agent Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 F2 — Do not infer turn completion from send_message

Core normally finalizes streaming with edit_message, which never emits Done, while any send_message emits Done and removes the route. Short streamed turns time out; multi-chunk output can end after the first send and drop the rest.

Requested change: Carry an explicit correlated turn-finished signal (or aggregate the complete turn) and test short streaming plus every overflow/send-once path.

if let Some(k) = sess_key {
let notify = sessions.lock().await.get(k).and_then(|s| s.cancel.clone());
if let Some(n) = notify {
n.notify_one();

@chaodu-agent chaodu-agent Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 F3 — Propagate cancellation to the backend

This only wakes the local WebSocket waiter. The dispatched core/downstream turn keeps running, so tool calls and side effects can continue after the client receives stopReason:"cancelled".

Requested change: Route cancellation through the core session-control path, observe backend termination, and fence late output before allowing the next prompt.

registry
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(channel_id.clone(), reply_tx);

@chaodu-agent chaodu-agent Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F4 — Bind reply routes to a connection/turn owner

The registry is process-wide and keyed only by deterministic channel ID, while busy is connection-local. Two holders of the same resumed session can overwrite this sender, and either stale prompt/disconnect can remove the newer route.

Requested change: Add connection/turn generations, reject or explicitly hand off duplicate ownership, and perform compare-and-remove cleanup.

let mut prompt_tasks: Vec<tokio::task::JoinHandle<()>> = Vec::new();

// Channel for sending messages back to the client
let (out_tx, mut out_rx) = mpsc::unbounded_channel::<String>();

@chaodu-agent chaodu-agent Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F5 — Bound client-controlled state and queues

The outbound channel is unbounded; the per-prompt reply channel is also unbounded, and session/task counts have no caps. A slow or malicious authenticated client can drive unbounded memory/task growth.

Requested change: Use bounded queues/backpressure, per-connection session/prompt caps, a global connection limit, and deterministic overload behavior.

Comment thread src/main.rs
// The ACP endpoint (mounted below) needs this embedded HTTP server too —
// start it even when only non-webhook platforms (e.g. Discord, which the
// core connects to directly) are configured.
let acp_enabled = cfg!(feature = "acp")

@chaodu-agent chaodu-agent Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F6 — Make the ACP-only feature path reachable

This check is nested inside outer #[cfg(any(...))] guards that omit feature = "acp"; the same omission guards mod unified_adapter and unified_platform_enabled. An ACP-only root build therefore compiles this branch out despite the ADR saying it starts the embedded listener.

Requested change: Add ACP to every enclosing/paired guard and cover --no-default-features --features acp with an endpoint smoke test.

continue;
}

let id = req.id.clone().unwrap_or(Value::Null);

@chaodu-agent chaodu-agent Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F7 — Enforce request-versus-notification shape

Converting a missing ID to null lets request-only methods execute and respond to notification-shaped messages. That can create sessions or start backend work with no correlatable request.

Requested change: Require IDs for ACP request methods, never execute/respond to notification-shaped requests, and validate session/cancel as a notification.

.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.or_else(|| query.get("token").map(|s| s.as_str()));

@chaodu-agent chaodu-agent Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F8 — Keep long-lived credentials out of URLs

A shared key in ?token= can be retained by reverse-proxy/CDN logs, browser history, observability systems, and traces.

Requested change: Use the Authorization header where possible and a short-lived one-use ticket or secure-cookie flow for browsers; otherwise require expiry and log redaction.

Comment thread docs/acp-official-methods.md Outdated

| Method | Direction | Purpose | OpenAB base |
|---|---|---|---|
| `session/cancel` | Client → Agent | Cancel in-flight work (one-way, no response) | ✅ conformant (notification; prompt ends `stopReason:"cancelled"`) |

@chaodu-agent chaodu-agent Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F10 — Do not mark cancellation conformant before backend cancellation works

The implementation only cancels the local waiter; it does not cancel the backend turn. This table and the new accepted ADR also claim wire conformance/end-to-end verification while turn completion, request-shape handling, and ACP-only startup still diverge from the described contract.

Requested change: Downgrade these claims and list the known divergences until executable tests verify the full behavior.

brettchien and others added 2 commits July 18, 2026 15:07
Flag-gated (OPENAB_ACP_TRACE=1|true) logging of every JSON-RPC frame on the
upstream client<->gateway hop, in both directions (dir="in"/"out"). Off by
default. Traced at the two choke points: the inbound ws_rx read and the single
outbound writer task. Captures real ACP traffic to validate the planned
generated-type round-trip against what clients/agents actually emit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add crates/openab-gateway/src/adapters/acp_schema.rs, generated by cargo-typify
0.7.0 from the vendored ACP v1 JSON schema (schemas/acp-v1.schema.json, pinned
to upstream schema.json @ eb88e992 / ACP Schema v1.19.0). Plain serde — no
schemars/serde_with runtime dep. Feature-gated (acp), allow(dead_code) until
acp_server migrates onto it.

Full v1 surface is generated (one closed dep graph); the base wires only the
chat subset. Round-trip of the chat subset against known-good wire was proven
before committing (Initialize/NewSession/Prompt/StopReason/ContentBlock/
SessionNotification all exact; StopReason snake_case; ContentBlock untagged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@chaodu-agent

This comment has been minimized.

@chaodu-agent chaodu-agent left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

CHANGES REQUESTED ⚠️ — Generated ACP types remain unused and critical lifecycle/security gaps remain.

Consolidated review: #1418 (comment)

if !enabled {
return None;
}
let auth_key = std::env::var("OPENAB_ACP_AUTH_KEY").ok();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 F1 — Fail closed when ACP authentication is absent

Enabling ACP without a non-empty key skips upgrade authentication; an empty configured key also accepts an empty token. On the default network listener, a configuration omission can expose the admitted synthetic ACP identity.

Requested change: Require a non-empty key before mounting /acp, or gate an explicit insecure mode to loopback; test absent, empty, invalid, and valid keys.

registry.lock().unwrap_or_else(|e| e.into_inner()).remove(key);
}
}
None | Some("send_message") => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 F2 — Do not infer turn completion from message commands

Streaming normally finalizes through edit_message, which never emits Done; multi-chunk send paths hit this branch repeatedly, but the first call emits Done and removes the route. Successful turns can therefore time out or lose later chunks.

Requested change: Carry an explicit correlated turn-finished signal (or aggregate the complete turn) and test streaming plus all overflow/send-once paths.

if let Some(k) = sess_key {
let notify = sessions.lock().await.get(k).and_then(|s| s.cancel.clone());
if let Some(n) = notify {
n.notify_one();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 F3 — Propagate cancellation to the backend

This only wakes the local WebSocket waiter. The dispatched core/downstream turn keeps running, so model requests, tool calls, and side effects can continue after the client receives stopReason:"cancelled". ACP requires the cancelled response only after ongoing operations have been aborted.

Requested change: Route cancellation through the backend session-control path, observe termination, and fence late output before allowing the next prompt.

registry
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(channel_id.clone(), reply_tx);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F4 — Bind reply routes to a connection and turn owner

The registry is process-wide and keyed only by deterministic channel ID while busy/session state is connection-local. Another connection can replace this sender, and stale prompt/disconnect cleanup can remove the newer route.

Requested change: Add connection/turn generations, reject or explicitly hand off duplicate ownership, preserve active resume state, and use compare-and-remove cleanup.

let mut prompt_tasks: Vec<tokio::task::JoinHandle<()>> = Vec::new();

// Channel for sending messages back to the client
let (out_tx, mut out_rx) = mpsc::unbounded_channel::<String>();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F5 — Bound client-controlled state and queues

This outbound channel and the per-prompt reply channels are unbounded; session and prompt-task counts are also uncapped. A slow or malicious authenticated client can drive unbounded memory/task growth.

Requested change: Add bounded queues/backpressure, per-connection caps, a global connection/worker limit, input limits, idle eviction, and deterministic overload behavior.

continue;
}

let id = req.id.clone().unwrap_or(Value::Null);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F7 — Enforce request-versus-notification shape

Converting a missing ID to null lets request-only methods execute and emit id:null responses. ACP/JSON-RPC notifications must never receive success or error responses; the generated schema now distinguishes these shapes but is not used here.

Requested change: Require IDs for ACP request methods, never execute/respond to notification-shaped requests, and validate session/cancel as a notification.

.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.or_else(|| query.get("token").map(|s| s.as_str()));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F8 — Keep long-lived credentials out of URLs

A shared key in ?token= can be retained by reverse-proxy/CDN logs, browser history, observability systems, and traces.

Requested change: Use Authorization where possible and a short-lived one-use ticket or secure-cookie flow for browsers; otherwise require expiry and mandatory log redaction.

let send_task = tokio::spawn(async move {
while let Some(msg) = out_rx.recv().await {
if trace {
info!(connection = %send_conn, dir = "out", frame = %msg, "ACP frame");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F9 — Do not log complete ACP payloads at INFO

These frames contain prompts, responses, resources, session capability IDs, and future tool inputs/outputs. INFO logs commonly have broad access and long retention, so enabling diagnostics can create a durable sensitive-data copy.

Requested change: Log redacted metadata by default; put any raw payload dump behind an explicitly unsafe development-only switch, document the risk, and add redaction tests.

pub mod acp_server;
#[cfg(feature = "acp")]
#[allow(clippy::all, dead_code, unused)]
pub mod acp_schema;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F10 — Wire generated types into behavior or defer them

The latest commit adds 27,873 lines of schema/generated code, exports it under broad dead-code/lint allowances, but no runtime code consumes it. The live parser remains hand-written, so this does not enforce conformance or fix request/notification handling.

Requested change: Use the generated types at the current wire boundary with fixture/round-trip tests and narrow allowances, or defer this module until the phase that consumes it.

Comment thread docs/acp-official-methods.md Outdated

| Method | Direction | Purpose | OpenAB base |
|---|---|---|---|
| `session/cancel` | Client → Agent | Cancel in-flight work (one-way, no response) | ✅ conformant (notification; prompt ends `stopReason:"cancelled"`) |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F12 — Do not claim cancellation conformance before backend cancellation works

The implementation confirms cancellation after stopping only the local waiter; it does not stop model/tool activity. Turn completion and request/notification handling also diverge from the claimed wire-conformant subset.

Requested change: List these divergences and downgrade the verified/conformant claims until executable tests prove the full contract.

Add acp_conformance test module: every payload acp_server hand-rolls (initialize
/ session.new / resume / prompt stopReason / session.update agent_message_chunk)
and accepts (prompt request) is asserted to deserialize into the generated
acp_schema ACP v1 types, with serde proven a stable fixed point. Any casing /
field-name / shape drift — the class of bug fixed during the base build
(agentMessageChunk->agent_message_chunk, integer protocolVersion, snake_case
stopReason) — now fails CI.

Per ADR §7 the trivial chat payloads stay hand-rolled; this guard locks them to
the schema. Typed construction for the complex bidirectional/MCP surface is the
roadmap's job.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…treaming

Group-review follow-ups on the ACP server base:

- session/resume now applies the same MAX_SESSIONS_PER_CONNECTION cap as
  session/new (was an unbounded insert path — a client can mint unlimited
  sess_<uuid>); over-cap resume returns -32000.
- Fence stale replies after timeout/cancel: register each turn's reply sink
  under the originating GatewayEvent id (round-tripped as reply_to) so a late
  reply from a superseded turn is dropped, not delivered into the next prompt's
  stream on the reused channel_id.
- ACP no longer inherits the unified adapter's Telegram streaming flag; it
  decides streaming explicitly by platform.
- has_unified_platform recognises an ACP-only deploy so `openab run` mounts
  /acp without another platform configured.
- Warn when OPENAB_ACP_AUTH_KEY has non-RFC6455-token chars (base64 = and /
  break the browser subprotocol handshake).
- Document the caps/fence/backend-cancel residual in the base ADR.

Adds regression tests for the resume cap, the stale-reply fence, and the
subprotocol charset check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@thepagent

Copy link
Copy Markdown
Collaborator

/review

@chaodu-agent

Copy link
Copy Markdown
Collaborator

Important

CHANGES REQUESTED ⚠️ — The current head still has two prompt/session state races, and the advertised live-streaming behavior is not exercised by the ACP path or CI.

What This PR Does

This PR adds a feature-gated ACP v1 WebSocket server at GET /acp, translating ACP sessions and prompts into OpenAB gateway events and routing gateway replies back as ACP session/update notifications and prompt responses. It mounts the endpoint in both the standalone gateway and embedded openab run, adds transport authentication and resource caps, and documents the Phase 1 protocol boundary.

How It Works

initialize, session/new, session/resume, session/prompt, and session/cancel are handled by a connection-local state machine. A sess_<uuid> maps to acp_<uuid>, prompts become GatewayEvent(platform="acp"), and a process-wide reply registry maps GatewayReply snapshots back to the active prompt using the originating event id for stale-reply fencing. ACP transport auth is fail-closed on non-loopback binds; the synthetic acp_client identity is intentionally subject to the shared trust registry.

Findings

# Severity Finding Location
F1 🔴 An immediate session/cancel can be lost before the prompt task installs its cancel handle. crates/openab-gateway/src/adapters/acp_server.rs:571-580
F2 🔴 session/resume can overwrite an active session and let old cleanup destroy a newer turn's state. crates/openab-gateway/src/adapters/acp_server.rs:763-770
F3 🟡 ACP is forced into send-once mode, so it does not provide progressive session/update streaming. crates/openab-core/src/adapter.rs:703-710
F4 🟡 The default CI test command does not compile or run the new acp-gated tests. .github/workflows/ci.yml:51-52
F5 🟡 The live smoke test's Unicode assertion can pass on a lone 👨 and therefore is not a reliable integrity check. scripts/acp-ws-smoke.py:254
F6 🟢 Loopback-aware auth mounting, constant-time token comparison, frame-size enforcement, resource-link handling, and stale-reply fencing are useful hardening work.
Finding Details

🔴 F1: Reserve cancellation state before spawning the prompt

The WebSocket loop spawns handle_session_prompt at lines 566-568, but that task only sets AcpSession.cancel after it is scheduled and reaches the session lock at lines 807-826. A client that sends session/prompt followed immediately by the required session/cancel can have the second frame processed first; the cancel branch then observes cancel=None, drops the notification, and the prompt continues without ever receiving a cancellation signal.

Requested change: Reserve a per-prompt cancellation state synchronously before spawning the task, or add a pending-cancel/generation state machine so a cancel received during task startup is consumed by that prompt. Add a back-to-back prompt/cancel regression test that does not wait for a first update.

🔴 F2: Preserve active ownership across resume and cleanup

session/resume unconditionally replaces an existing AcpSession with busy:false and cancel:None at lines 763-770. If a prompt is active, a resume followed by another prompt can replace the process-wide reply sink. When the old prompt later reaches cleanup at lines 956-963, it unconditionally removes the shared channel entry and clears the session state, dropping the newer prompt's replies and cancellation handle.

Requested change: Reject resume while the local session is busy, or make resume generation/owner-aware. Cleanup must compare the active turn/owner before removing the registry entry or resetting busy/cancel; add an overlapping resume/prompt test.

🟡 F3: Restore progressive ACP updates or narrow the contract

The ACP branch explicitly forces streaming=false at crates/openab-core/src/adapter.rs:703-710. Consequently the post-and-edit path that emits intermediate snapshots is not created, and the ACP adapter normally receives only the final send_message snapshot before Done. The live 27/27 run proves that a final update can arrive, but it does not prove progressive streaming promised by the PR's WebSocket/streaming design.

Requested change: Either implement a dedicated append-only ACP delivery path that emits multiple agent_message_chunk updates before the prompt response, or narrow the documented acceptance criteria to final-chunk delivery. Add a deterministic test asserting at least two updates precede the correlated prompt response when the backend emits multiple text deltas.

🟡 F4: Run the ACP feature-gated tests in CI

The new server tests are behind #[cfg(feature = "acp")], while .github/workflows/ci.yml runs cargo test --workspace without --features unified or --features acp. The successful default workspace test therefore does not execute the ACP conformance, handler, review-fix, or streaming tests.

Requested change: Add cargo test --workspace --features unified or a focused cargo test -p openab-gateway --features acp job, and include an ACP-only feature-matrix build for the embedded endpoint.

🟡 F5: Make the smoke assertion fail closed

At scripts/acp-ws-smoke.py:254, Python precedence makes the condition equivalent to ("🎉" in text and family in text) or ("👨" in text). A response containing only the first code point of the family sequence can therefore pass the check while the advertised ZWJ/CJK/heart integrity is unverified.

Requested change: Require every expected marker (including 你好, 🎉, the complete 👨‍👩‍👧‍👦, and ❤️) with all(...), and fail on timeout or JSON-RPC error before checking content.

Accepted Residual Risks

The PR explicitly documents backend cancellation propagation, full outbound backpressure/global limits, and the legacy query-token fallback as follow-ups or residual risks. They remain operational concerns, but are not duplicated as new blocking findings in this round. The synthetic identity also intentionally requires GATEWAY_ALLOW_ALL_USERS=true or GATEWAY_ALLOWED_USERS=acp_client; that policy is documented in the ACP ADR.

Addressing External Reviewer Feedback

brettchien — live conformance reports (27/27)

The live deployment reports 27/27 checks passing, including transport authentication, protocol validation, whole-reply delivery, Unicode content, oversized-frame close, header auth, and a cancellation result.

Accepted as evidence for those exercised paths. The run does not cover immediate prompt-to-cancel ordering, resume during an active prompt, or feature-gated CI coverage, so it does not resolve F1, F2, or F4 above. It also observes a cancellation response but cannot establish backend work termination, which the PR lists as an accepted residual risk.

5️⃣ Three Reasons We Might Not Need This PR

  1. The gateway contract is still message-oriented — making turn completion, cancellation, and ownership explicit in the shared core bridge first may avoid ACP-specific lifecycle races.
  2. A local stdio bridge may cover current IDE demand with less operational exposure — it could reuse the mature downstream ACP path without adding a remotely reachable resumable-session surface.
  3. The current WebSocket contract may be premature — shipping only after progressive streaming and lifecycle behavior are deterministic would reduce the risk of clients depending on a misleading streaming/conformance claim.
Baseline Check
  • PR opened: 2026-07-17.
  • Main already has: downstream ACP-over-stdio support for agent CLIs and the generic gateway event/reply bridge; it does not expose this client-facing /acp WebSocket endpoint.
  • Net-new value: the external ACP WebSocket transport, deterministic session mapping, embedded/standalone mounting, and protocol documentation.
  • Current head reviewed locally: f4cca5e46a68876077882e12a4f36232afc4598d.
  • Validation evidence: GitHub reports the current checks successful, including check, validate, unified smoke variants, and container smoke variants. Local Rust validation was unavailable because cargo is not installed in this review environment. The local diff was inspected at the exact head; no source or diff content was fetched through GitHub API.
What's Good (🟢)
  • Authenticated non-loopback mounting now fails closed when the key is absent, while loopback development remains possible.
  • Constant-time comparison, input-frame caps, explicit unsupported content handling, UUID namespace validation, and event-id reply fencing are careful low-level choices.
  • The PR records the trust-layer distinction, accepted cancellation/backpressure residuals, schema provenance, and live validation evidence rather than hiding the remaining boundaries.

@chaodu-agent chaodu-agent left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

CHANGES REQUESTED ⚠️ — The current head still has two prompt/session state races, and the advertised live-streaming behavior is not exercised by the ACP path or CI.

Consolidated review: #1418 (comment)

.and_then(|p| p.get("sessionId"))
.and_then(|v| v.as_str());
if let Some(k) = sess_key {
let notify = sessions.lock().await.get(k).and_then(|s| s.cancel.clone());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 F1 — Reserve cancellation state before spawning the prompt

The prompt task installs AcpSession.cancel only after it is scheduled. If the next WebSocket frame is session/cancel, this lookup can observe None and silently drop the cancellation.

Requested change: Reserve a per-prompt cancellation state before spawning, or queue cancellation by prompt generation so an immediate cancel cannot be lost.

format!("Too many sessions on this connection (max {MAX_SESSIONS_PER_CONNECTION})"),
);
}
guard.insert(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 F2 — Preserve active session ownership during resume

This unconditional insert resets busy and cancel for an existing session. A resume during an active prompt can replace the shared reply sink; the old prompt's cleanup then removes the newer turn's route and state.

Requested change: Reject resume while busy or make the session generation/owner-aware, and compare ownership before cleanup.

// coupling): it streams append-only `agent_message_chunk` deltas built from the
// post+edit (`edit_message` snapshot) path, i.e. streaming=false. Decide it
// explicitly by platform rather than by whatever Telegram happens to be set to.
let streaming = if thread_channel.platform == "acp" {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F3 — Preserve progressive ACP updates

For platform == "acp", this forces streaming=false, so the edit/snapshot path is never used and clients normally receive only the final send_message chunk. That does not provide the progressive session/update streaming promised by the PR.

Requested change: Add a dedicated append-only ACP delivery path or narrow the documented contract to final-chunk delivery, with a test proving multiple updates precede the prompt response.

Comment thread scripts/acp-ws-smoke.py Outdated
…6-F5)

The ZWJ-family check parsed as `("🎉" in text and "👨‍👩‍👧‍👦" in text) or ("👨" in text)`,
so a lone 👨 (or any partial reply) passed. Require ALL of 你好/🎉/👨‍👩‍👧‍👦/❤️ via all(),
and fail closed first on a timeout or JSON-RPC error so a dropped reply can't slip
through the content assertion. Script-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
brettchien and others added 4 commits July 22, 2026 04:23
`cargo test --workspace` builds with default features, so openab-gateway's
`acp`-gated conformance/handler/streaming tests never compiled or ran in CI (and
the old comment claiming they were "covered by cargo test --workspace" was wrong).
Add an explicit `cargo test -p openab-gateway --features acp` step (scoped to the
one crate to avoid the workspace hooks::tests parallel flake) and a
`cargo build --features unified` step so the embedded /acp endpoint is compiled in
CI. YAML validated with yq; local unified gate green (clippy + full test + build).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…l race (R16-F1)

An immediate session/prompt → session/cancel could process the cancel before the
spawned prompt task installed its cancel handle: s.cancel was still None, so the
notification was dropped and the prompt ran uncancelled. Move the reservation
(busy-check + install the cancel Notify, under the session lock) into the
session/prompt read-loop branch, synchronously, BEFORE tokio::spawn — the read loop
is sequential, so a cancel on the next frame now finds s.cancel installed. The
handler takes the reserved session_id + cancel, releases the reservation on every
early return (new release_prompt helper), and no longer re-reserves. A tokio::Notify
stores one permit, so even a cancel fired before the handler's select! is consumed
as stopReason "cancelled". Regression test: pre-reserved session + pre-fired cancel
→ the turn resolves "cancelled" and the reservation is released.

Gate: clippy --features unified -D warnings + test --features unified (705+292) + build, all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
handle_session_resume unconditionally rewrote AcpSession{busy:false,cancel:None}.
Resuming during an active prompt therefore dropped the in-flight turn's cancel
handle, and then that turn's cleanup clobbered the freshly-resumed registry entry /
state — losing the newer turn's replies. Minimal, deterministic fix: reject a
resume whose local session is currently `busy` with a JSON-RPC -32001, leaving the
active turn's busy flag + cancel handle untouched (owner/generation tagging is the
Phase-2 robust variant, deferred). Regression test: resume against a busy session
is rejected and the session's busy + cancel survive.

Gate: clippy --features unified -D warnings + test --features unified (705+293) + build, green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… (R16-F3 option A)

The ACP ChatAdapter is streaming=false, so Phase-1 delivers the whole reply as one
terminal agent_message_chunk before the session/prompt response — not progressive
multi-chunk streaming. Align the docs to that reality (option A; progressive
streaming stays Phase-2, not implemented here):
- ADR acp-server-websocket-base.md: reword the §1 scope + the agent_message_chunk
  acceptance wording to "single terminal chunk (streaming=false)", and add a Phase-2
  progressive-streaming roadmap bullet under §6.
- PR openabdev#1418 body: softened the At-a-Glance diagram + Proposed-Solution line the same
  way (wording-only; applied via gh, everything else byte-for-byte).
- Test phase1_emits_single_terminal_agent_message_chunk: a final (send_message) reply
  yields exactly ONE agent_message_chunk + stopReason end_turn, anchoring the claim.

Gate: clippy --features unified -D warnings + test --features unified (705+294) + build, green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@chaodu-agent

Copy link
Copy Markdown
Collaborator

/review

…e (R16-F2)

The unconditional registry remove / busy reset in the prompt cleanup path is
correct only because no newer turn can exist on the same session_id while one is
in flight — session/prompt and session/resume both reject with -32001 when busy.
Document that coupling so a future relaxation of the busy gate doesn't silently
reintroduce the F2 cleanup-clobber; cross-connection ownership stays a residual (F5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@brettchien

Copy link
Copy Markdown
Contributor Author

Thanks for the round-16 review — the findings were precise and actionable. All five are addressed on the current head (f7c9378). Summary below; each item links its commit.

Round-16 findings

  • F1 🔴 — prompt→cancel startup race (acb…eb3cf07): the per-prompt cancel state (busy + cancel Notify) is now reserved synchronously under the session lock in the read loop, before the prompt task is spawned. Because the read loop is sequential, a session/cancel arriving on the next frame always observes the installed handle. Regression test prompt_cancel_race_before_first_update_cancels sends prompt→cancel with no wait and asserts stopReason:"cancelled".
  • F2 🔴 — resume/cleanup ownership (1388591d): session/resume now rejects with -32001 while the session has a prompt in flight, so the active turn's cancel handle and reply sink are never clobbered. Combined with the existing busy-gate on session/prompt, no newer turn can exist on a session_id while one is running, which also makes the prompt cleanup path safe. Test resume_while_busy_is_rejected_and_preserves_state asserts the rejection and that busy/cancel survive. I took the "reject while busy" option; the generation/owner-aware cleanup variant is documented as a Phase-2 follow-up, and I added an explicit invariant comment at the cleanup site (f7c9378) so the busy-gate coupling can't be relaxed by accident. Cross-connection same-session_id ownership remains the accepted residual (F5) noted in the ADR.
  • F3 🟡 — streaming claim (c314ece6): rather than implement progressive chunking now, I narrowed the contract to match the implementation. The ADR and PR body now state that Phase-1 delivers the whole reply as a single terminal agent_message_chunk (streaming=false); progressive multi-chunk streaming is Phase-2 (ADR §6). Test phase1_emits_single_terminal_agent_message_chunk anchors the documented behavior.
  • F4 🟡 — ACP tests not in CI (70847fe1): ci.yml now runs cargo test -p openab-gateway --features acp and builds --features unified so the embedded /acp endpoint and the conformance/handler/streaming tests are exercised (scoped to -p openab-gateway to avoid the workspace hooks::tests parallel flake). Removed the incorrect "covered by workspace test" comment.
  • F5 🟡 — smoke assertion precedence (acb0bde6): the Unicode check was ("🎉" in text and family in text) or ("👨" in text), which passed on a lone 👨. It now requires every marker via all(m in text for m in ["你好","🎉","👨‍👩‍👧‍👦","❤️"]) and fails closed on timeout / JSON-RPC error before inspecting content.

Local verification at the new head: cargo test -p openab-gateway --features acp294 passed, 0 failed.

Review status

The OpenAB PR Review check is now review-limit-reached / "Circuit breaker: exceeded 30 review cycles", so the automated reviewer will not re-evaluate these fixes. The two 🔴 blockers and the three 🟡 items above are resolved with regression tests; the remaining boundaries (backend cancellation propagation, full outbound backpressure, process-wide session ownership) are the accepted residuals / later-phase items already documented in the PR body and ADR.

Could a maintainer take a look for the human review pass? Happy to adjust anything.

@smallgun01

Copy link
Copy Markdown
Contributor

@brettchien We are evaluating an ACP-over-WS client for a persistent Live2D/Electron companion. Our current frontend uses the VTuber OpenAI-compatible endpoint and keeps continuity through the existing VTuber gateway path.

For a future ACP client, is there an intended/supported way to link or hand off continuity between an ACP session and an existing platform/gateway session, or are ACP sessions intentionally isolated (acp:<uuid>) by design? If isolation is intentional, we can treat ACP as a separate companion session; we just want to avoid relying on unsupported cross-channel session behavior.

We understand Phase 1 deliberately emits a terminal reply rather than progressive chunks, which is fine for an initial canary. Is cross-connection ownership/handoff for session/resume expected in a later phase, or should clients treat a session capability as single-active-client only?

@brettchien

Copy link
Copy Markdown
Contributor Author

@smallgun01 Thanks — good questions, and they land right on the boundaries the Phase-1 ADR calls out explicitly.

1. ACP session isolation vs. handoff to the existing gateway session

Isolation is intentional by design. An ACP session lives entirely in its own namespace: session/new mints sessionId = sess_<uuid>, the gateway derives channel_id = acp_<uuid> from the same uuid, and the core keys continuity by session_key = acp:<channel_id> (ADR §2, "Session ↔ core mapping"). There is deliberately no supported bridge that links or hands off continuity between an acp:<…> session and a different platform's session (e.g. your VTuber OpenAI-compatible gateway path) — those are separate session_key namespaces, and nothing in the base crosses them.

So your instinct is right: treat the ACP client as its own companion session, and don't rely on cross-channel session behavior — it isn't supported and isn't planned as a cross-platform handoff. Continuity within ACP does persist (the core keeps a thread_key → agent sessionId mapping that survives a process restart, within the downstream agent's retention / session_ttl_hours, default 4h), but that's continuity of the same ACP session, not a link into the VTuber path.

If what you actually want is one logical companion identity shared across both frontends, that's an orchestration concern on the client side (your app deciding both surfaces talk to the same logical companion), not something ACP is intended to broker.

2. Phase-1 terminal reply

Correct, and glad it works as a canary. Phase-1 emits the whole reply as one terminal agent_message_chunk because the adapter reports streaming=false. Progressive multi-chunk streaming is a Phase-2 item (ADR §6) — and the gateway's delta path is already char-boundary-safe for multiple chunks, so it's a forward-compatible flip, not a wire-breaking change for your client.

3. session/resume — cross-connection ownership / single-active-client

For now, treat a session capability as single-active-client. The base is 1:1 by construction (ADR §5): the reply registry is channel_id → single reply_tx, and the delta stream assumes one monotonic text — which matches ACP's 1:1 nature (one client ↔ one agent).

The supported resume flow is reconnect: on WS disconnect the per-connection session map is dropped, and the same client reconnects with session/resume + its persisted sessionId to restore context (no history replay — the client keeps its own transcript for display; ADR §3). While a prompt is in flight, session/resume is rejected (-32001) so an active turn's state can't be clobbered.

What is not in the base is generation/owner-aware cleanup for two different connections claiming the same session_id concurrently — cross-connection same-session_id ownership is an accepted residual (review finding F5), documented in the ADR. Proper cross-connection ownership/handoff is a later-phase item; until then, don't design for two live connections sharing one sessionId — one active client per session, reconnect-to-resume.

Happy to go deeper on any of these, or to note your Live2D/Electron use case as a driver for the Phase-2 streaming + ownership work.

@chaodu-obk

chaodu-obk Bot commented Jul 22, 2026

Copy link
Copy Markdown

Important

CHANGES REQUESTED ⚠️ -- The current ACP transport still permits unsafe browser access in anonymous loopback mode and accepts wire shapes outside the declared ACP contract.

What This PR Does

This PR adds a feature-gated ACP v1 WebSocket server at GET /acp, translating ACP sessions and prompts into OpenAB gateway events and routing gateway replies back as ACP notifications. It mounts the endpoint in standalone and embedded gateway paths, adds transport authentication, resource caps, conformance tests, and ADR documentation.

How It Works

initialize, session/new, session/resume, session/prompt, and session/cancel are handled by a connection-local ACP state machine. A sess_<uuid> maps to acp_<uuid>, prompts become GatewayEvent(platform="acp"), and a process-wide reply registry routes matching GatewayReply snapshots back to the active prompt using the originating event id as a stale-reply fence. Phase 1 deliberately delivers one terminal agent_message_chunk because the ACP adapter uses streaming=false.

Findings

# Severity Finding Location
F1 🔴 Critical Keyless loopback mode accepts browser WebSockets without validating Origin, so an arbitrary website can drive the local ACP endpoint. crates/openab-gateway/src/adapters/acp_server.rs:306-343
F2 🟡 Important The legacy ?token= fallback still exposes the long-lived bearer key through URLs, access logs, browser history, and tracing infrastructure. crates/openab-gateway/src/adapters/acp_server.rs:317-322
F3 🟡 Important The live parser is still hand-rolled and accepts shapes outside the generated ACP schema; it also acknowledges request-shaped session/cancel although that method is notification-only. acp_server.rs:618-643, 1040-1090; acp_schema.rs:7475-7487, 8293-8323
F4 🟢 Praise The latest fixes reserve cancellation before spawning, reject resume while busy, fence stale replies, and add explicit ACP CI coverage. acp_server.rs:540-591, 763-790; .github/workflows/ci.yml:57-59
Finding Details

🔴 F1: Validate browser Origin in anonymous loopback mode

When OPENAB_ACP_AUTH_KEY is absent, the endpoint intentionally allows a loopback bind without a bearer token. The upgrade handler checks the token when configured but does not inspect the WebSocket Origin header. WebSocket handshakes are not protected by the browser same-origin policy, so any malicious page opened in the user's browser can connect to ws://127.0.0.1:<port>/acp, initialize a session, and submit prompts to the local agent. This is especially sensitive because ACP prompts may reach model tools and local side effects.

Requested change: Reject non-allowlisted Origin values in keyless loopback mode, or make anonymous mode an explicit non-browser-only development option. Add regression tests for absent, allowed, and disallowed origins while retaining bearer authentication for remote binds.

🟡 F2: Remove or harden the query-token compatibility path

The handler still accepts ?token=<shared-key> after the Authorization and WebSocket subprotocol paths. A long-lived shared key in a URL can be retained by reverse-proxy/access logs, browser history, telemetry, and copied links. The subprotocol path is a better browser mechanism, but retaining the query fallback means deployments can still use the unsafe path.

Requested change: Remove the query fallback by default. If backward compatibility is required, use a short-lived one-use ticket and document mandatory URL redaction at every proxy and observability layer.

🟡 F3: Enforce the generated ACP request shapes at the wire boundary

The generated PromptRequest requires an array of ContentBlock, while extract_prompt_params also accepts a plain string. The generated ResourceLink requires its schema fields, while the live parser accepts a missing name/title fallback. The dispatch path also sends an empty success response when session/cancel arrives with an id, even though ACP defines it as a one-way notification. These extensions make the implementation less wire-conformant than the declared v1.19.0 contract and allow clients to receive behavior not covered by the generated schema.

Requested change: Deserialize request payloads through the generated types (or explicitly document and version extensions), reject non-conformant shapes, and add negative tests for string prompts, incomplete resource links, and request-shaped session/cancel.

🟢 F4: Good lifecycle hardening

The prompt cancellation state is reserved synchronously before spawning, resume rejects an in-flight session without clobbering its cancel handle, and reply routing fences stale event ids. The PR also adds feature-gated ACP tests and a unified build step to CI, and the Phase-1 terminal-only streaming contract is now documented rather than overstated.

Addressing External Reviewer Feedback

External client/session feedback

ACP sessions should be treated as isolated sessions, and the current capability should be treated as single-active-client until process-wide ownership is implemented.

Accepted. The PR documents acp:<uuid> isolation and the single-active-client limitation. Cross-connection ownership remains a documented residual and is not duplicated as a new finding here.

Existing review-round feedback

The previously reported prompt-to-cancel race, busy-session resume clobber, ACP-only CI coverage, terminal-only streaming contract, and smoke assertion issues are acknowledged as addressed by the commits leading to the current head. Backend cancellation propagation, full outbound backpressure/global limits, and cross-connection ownership remain explicitly documented follow-ups/residuals.

Baseline Check

  • PR opened: 2026-07-17.
  • Declared base: main; declared base SHA: 2e3292bc3e3e01f0fb203dc38003ad4c9afc7ca5.
  • Merge-base: 30bc145233a202754d205b21e8529491d97b1a7c.
  • Main already has downstream ACP-over-stdio support and the generic gateway event/reply bridge, but it does not expose this client-facing ACP WebSocket endpoint.
  • Net-new value: the remote/browser ACP transport, deterministic session mapping, embedded/standalone mounting, and protocol documentation.

What's Good (🟢)

  • Constant-time bearer comparison and fail-closed behavior for non-loopback binds.
  • Explicit unsupported content handling avoids silently dropping image/audio/resource blocks.
  • UUID namespace validation prevents forged session ids from escaping the ACP channel namespace.
  • Stale-reply fencing and the latest busy/cancel reservation fixes are careful cross-component hardening.
  • The PR records its Phase-1 boundaries and deferred cancellation/backpressure/ownership work instead of hiding them.

5. Three Reasons We Might Not Need This PR

  1. The gateway contract is not fully turn-oriented yet -- adding explicit correlation, completion, and cancellation to the shared core bridge first may avoid ACP-specific lifecycle workarounds.
  2. A local stdio bridge may cover current IDE demand with less exposure -- it could reuse the mature downstream ACP path while remote/browser demand is validated.
  3. The WebSocket contract may be premature -- shipping only after browser-origin policy and wire-shape enforcement are deterministic would reduce the risk of clients depending on ambiguous behavior.

@chaodu-obk chaodu-obk Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

CHANGES REQUESTED ⚠️ -- Keyless loopback WebSockets lack Origin validation, the legacy query-token path leaks bearer credentials, and the live parser accepts shapes outside the declared ACP schema.

Consolidated review: #1418 (comment)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants