Skip to content

fix(remote): auth /acp dial via Authorization header, offer only acp.v1 - #45

Merged
brettchien merged 1 commit into
mainfrom
fix/remote-acp-handshake-subprotocol
Aug 14, 2026
Merged

brettchien merged 1 commit into
mainfrom
fix/remote-acp-handshake-subprotocol

Conversation

@brettchien

Copy link
Copy Markdown
Contributor

Symptom

Activating the remote connection dials wss://…/acp, saves config, then fails:

ERROR remote: dial wss://orca-acp.brettchien.cc/acp:
  WebSocket protocol error: SubProtocol error: Server sent an invalid subprotocol

TLS, the Cloudflare upgrade, and auth are all fine — it dies purely in sub-protocol negotiation.

Root cause (client-side, tokio-tungstenite 0.23)

bearer_subprotocol() returns openab.bearer.<token>, acp.v1, and remote.rs set that as a single Sec-WebSocket-Protocol header. tokio-tungstenite 0.23 parses the request's offer with split(",") without trimming:

// tungstenite handshake/client.rs:189
subprotocols.to_str()?.split(",").map(|s| s.to_string()).collect()
// -> ["openab.bearer.<token>", " acp.v1"]   // note the leading space

so when the server echoes acp.v1, the response check fails:

// client.rs:270-274
if !accepted_subprotocols.contains(&returned_subprotocol) { InvalidSubProtocol }
// " acp.v1" != "acp.v1"  ->  error

Browser / Python clients keep their own offer list and trim, so they were unaffected — which is why the gateway tested healthy.

Note: appending two separate Sec-WebSocket-Protocol header values does not work either — tungstenite's extract_subprotocols_from_request reads only the first via headers().get().

Fix

Studio is a native (non-browser) client, so:

  • send the bearer in the Authorization: Bearer <token> header (the gateway reads ws_bearer_token from it), and
  • offer only acp.v1 as the sub-protocol — one clean token that matches the server's echo.

Bonus: the secret bearer no longer rides Sec-WebSocket-Protocol, which proxies (Cloudflare is in the path) are prone to log. bearer_subprotocol() stays as the documented browser-only form, guarded by a new regression test.

Verification

  • Live, end-to-end: a compliant client using Authorization header + subprotocols=[acp.v1] connects and negotiates acp.v1 against wss://orca-acp.brettchien.cc/acp; a wrong bearer gets HTTP 401. (Server side verified by Jellyfish against openab-gateway acp_server.rs.)
  • cargo test -p acp-tunnel — 19 passed (incl. new acp_subprotocol_is_a_single_clean_token).
  • studio-desktop compilation left to CI (desktop.yml) — separate GUI workspace, not built on the ECS host to avoid OOM.

🤖 Generated with Claude Code

The remote reverse-MCP dial failed at the WS handshake with
`SubProtocol error: Server sent an invalid subprotocol`, even though the
gateway is healthy (verified live: a compliant client sending the exact
`openab.bearer.<token>, acp.v1` offer connects and negotiates `acp.v1`;
a wrong bearer gets HTTP 401).

Root cause is client-side, in tokio-tungstenite 0.23. It parses the
request's `Sec-WebSocket-Protocol` with `split(",")` and does NOT trim
(handshake/client.rs:189), so our single combined offer
`openab.bearer.<token>, acp.v1` is stored as
[`openab.bearer.<token>`, ` acp.v1`] — the second element keeps a leading
space. When the server echoes `acp.v1`, the response check
(client.rs:270-274) does `accepted_subprotocols.contains("acp.v1")`,
which is false (` acp.v1` != `acp.v1`), so it errors with
`InvalidSubProtocol`. Browser/Python clients maintain their own offer
list and trim, so they were unaffected.

Fix: Studio is a native (non-browser) client, so send the bearer in the
`Authorization` header (the gateway reads `ws_bearer_token` from it) and
offer only `acp.v1` as the sub-protocol — one clean token that matches
the server's echo. This also stops the secret bearer from riding
`Sec-WebSocket-Protocol`, which is prone to being logged by proxies
(Cloudflare sits in the path). `bearer_subprotocol()` is retained,
documented as the browser-only form, and guarded by a regression test.

Verified live end-to-end against wss://orca-acp.brettchien.cc/acp
(Authorization header + subprotocol acp.v1 connects). acp-tunnel unit
tests pass; studio-desktop compilation is left to CI (separate GUI
workspace).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@brettchien
brettchien merged commit 43f20c1 into main Aug 14, 2026
2 checks passed
brettchien added a commit that referenced this pull request Aug 14, 2026
…46)

Even with the #45 handshake fix, the remote `/acp` connection still fails
at `initialize`: Studio sends `"protocolVersion": "2024-11-05"` (an
MCP-style date string), but ACP versions the wire protocol with a **u16
integer**. The gateway deserializes the field as `u16`, so the string is
rejected:

    "protocolVersion": "2024-11-05"  -> error -32602
        "invalid type: string \"2024-11-05\", expected u16"
    "protocolVersion": 1             -> result { agentCapabilities … }  (verified live)

Fix: the outer `/acp` handshake now sends `acp::ACP_PROTOCOL_VERSION`
(u16 = 1). The *tunnelled* inner MCP `initialize` (relayed to the
`oab-mcp` sidecar in `handle_inner`) correctly keeps the MCP date string —
that one is MCP, not ACP, and is left untouched. A comment marks the
distinction so the two are not "unified" by mistake, and a regression
test asserts `ACP_PROTOCOL_VERSION` serializes to a JSON number.

Verified live against wss://orca-acp.brettchien.cc/acp: with pv=1 the
full initialize -> session/new completes and returns a sessionId
(server side confirmed by Jellyfish). acp-tunnel unit tests pass;
studio-desktop compilation left to CI.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant