Replace HTTP Copilot endpoint with WebSocket streaming protocol and broaden host allowlisting
ADR 1870: Replace HTTP Copilot endpoint with WebSocket streaming protocol and broaden host allowlisting
Section titled “ADR 1870: Replace HTTP Copilot endpoint with WebSocket streaming protocol and broaden host allowlisting”Status
Section titled “Status”Draft
Context
Section titled “Context”The dashboard local dev server (dashboard/local-server.mjs) previously exposed a single-shot POST endpoint /__dashboard_copilot (copilotEndpoint) that read a JSON request body via readJsonRequest, validated Origin and Content-Type headers, ran one Copilot prompt synchronously through copilotRuntime.prompt(...), and returned a single JSON response ({ ok: true } or { error: ... }). Only one Copilot request could be active at a time, enforced by a copilotRequestActive flag that caused the endpoint to return HTTP 409 when busy.
Separately, the server already maintained a WebSocket connection for pushing dashboard preview-reload notifications, using a minimal, non-standard “receivedHeaderBytes” 2-byte header scheme rather than real WebSocket framing.
The server’s HTTP request handler and WebSocket upgrade handler each independently enforced host validation by comparing the Host header against a single expectedAuthority value (=== expectedAuthority). This strict single-authority check is implicated by the PR title (“fixing local server + localhost”) as a source of local development / Codespaces access problems, since it apparently did not accept localhost variants or GitHub Codespaces forwarded-port hostnames.
Decision
Section titled “Decision”- Remove the HTTP
/__dashboard_copilotPOST endpoint and its synchronous request/response model entirely. - Multiplex a new command/event protocol for Copilot requests onto the existing dashboard preview WebSocket connection, replacing the ad hoc 2-byte header scheme with real WebSocket frame parsing (
readWebsocketFrames): text frames (opcode 0x1), ping frames (opcode 0x9, answered withwebsocketPongFrame), and close frames (opcode 0x8), with a 16,384-byte frame size cap and rejection of unsupported opcodes or oversized frames. - Define client→server commands
copilot.start({ view, request }, withviewvalidated to 1–200 characters andrequestvalidated to 1–10000 trimmed characters — the same validation previously applied to the HTTP POST body) andcopilot.stop(only honored if sent from the same socket,copilotSocket, that started the active request). - Define server→client streaming events sent via
sendSocketEvent:started,debug,assistant-delta,assistant-message,status,error,stopped, anddone, replacing the single synchronous JSON response with a multi-event async stream. - Extend
startCopilotRuntime’s returned runtime with astop()method and anactiveSessionhandle (wrappingsession.abort()+session.disconnect());prompt()now accepts anonEventcallback forwarding session events (assistant.message_delta,assistant.message,tool.execution_start,tool.execution_complete,session.error,session.idlewithaborted) and returns{ aborted }.close()now also stops any active session before shutdown. On socket disconnect, the handler callscopilotRuntime?.stop()for cleanup. - Preserve the single-active-request concurrency constraint, but enforce it by rejecting
copilot.startover the socket instead of returning HTTP 409. - Remove the Origin/Content-Type header check that previously guarded the HTTP Copilot endpoint, relying instead on the existing WebSocket upgrade validation (
isAllowedHostplussec-websocket-key/sec-websocket-versionchecks) as the trust boundary for Copilot requests. - Introduce
codespaceAuthorityandlocalhostAuthorityvariables and a newisAllowedHost(host)predicate that accepts a request if theHostheader matchesexpectedAuthorityORlocalhostAuthorityORcodespaceAuthority. Apply this single predicate to both the HTTP request handler and the WebSocket upgrade handler, replacing the two separate strict=== expectedAuthoritychecks.
Alternatives Considered
Section titled “Alternatives Considered”- Keep the synchronous HTTP endpoint and add polling for progress. The diff replaces the single JSON HTTP response with a multi-event WebSocket stream (
assistant-delta,status, etc.), implying that continuing to use request/response HTTP (e.g., with client-side polling for progress) was rejected in favor of a persistent streaming connection. This alternative and the specific reasoning for rejecting it are inferred from the diff’s shift to streaming events, not stated explicitly in the evidence. - Keep the strict single-authority host check and require users to work around it externally (e.g., manual host-header rewriting or proxy configuration) instead of expanding server-side allowlisting. The introduction of
isAllowedHostwithlocalhostAuthorityandcodespaceAuthorityalternatives implies that the prior single-expectedAuthoritycheck was a barrier to local/Codespaces development, and that broadening the allowlist server-side was chosen over pushing the workaround to the client/environment. This alternative and rejection rationale are inferred from the diff, not asserted as fact in the evidence.
Consequences
Section titled “Consequences”Positive:
- The dashboard UI can now receive live, incremental progress updates (
assistant-delta,status,debug) during a Copilot request instead of waiting for a single final response, enabling streaming UX. - Mid-flight cancellation is now supported via
copilot.stopandstop()/activeSession, which was not possible under the prior synchronous request/response model. - Cleanup on socket disconnect (
copilotRuntime?.stop()) prevents orphaned active sessions when a client disconnects mid-request, an improvement over the connection-agnostic HTTP model. - Local development and Codespaces access to the dashboard preview server should improve, since
isAllowedHostnow accepts localhost and Codespaces-forwarded hostnames in addition to the original expected authority, applied consistently across both the HTTP and WebSocket upgrade paths. - Consolidating host-authority validation into one
isAllowedHostpredicate, used by both the HTTP handler and WebSocket upgrade handler, removes the prior duplication of two separate strict equality checks.
Negative:
- The removal of the dedicated Origin/Content-Type header check for the Copilot endpoint means Copilot request trust now depends entirely on the WebSocket upgrade validation (
isAllowedHostplussec-websocket-key/sec-websocket-version); any gap in that validation would now also expose the Copilot command channel, whereas previously it had its own independent check. - Broadening
isAllowedHostto acceptlocalhostAuthorityandcodespaceAuthorityin addition toexpectedAuthorityincreases the set of Host header values the server will accept, which is a strictly larger trust surface than the prior single-authority check. - The protocol complexity increases: the server must now implement WebSocket frame parsing (
readWebsocketFrames) with size caps and opcode handling, session lifecycle management (activeSession,stop()), and multiple event types (started,debug,assistant-delta,assistant-message,status,error,stopped,done), replacing what was a simple single-endpoint HTTP handler. - Not inferable from current pull request evidence: any performance or latency impact of the new protocol, or any user-facing/API compatibility considerations for clients still expecting the old
/__dashboard_copilotHTTP endpoint.