webui+gateway: relay tab + CORS for /v1/relay/* (replaces #324, #325, #326)#327
Conversation
Adds a "Relay" tab between Stats and Settings that surfaces: - Auto-detected gateway URL (defaults to window.location.origin, editable if workers reach the gateway via a tunnel / proxy / external hostname). - Bearer token from localStorage, MASKED by default so the panel is safe to screen-share. A per-tab "show" toggle swaps the placeholder <PASCLAW_GATEWAY_TOKEN> for the real value when copying snippets somewhere private. - Five ready-to-copy snippets via the existing codeBlock + copy-button helpers: pasclaw relay CLI (flag form), pasclaw relay (env-var form), Browser/WebLLM worker, Python+llama.cpp worker, Replicate cog-relay predict() call. - Live mini-dashboard from /v1/relay/status: connected workers, queue depth, completed/failed counts, plus a table of currently-attached workers with their advertised caps + last-seen. Refreshes every 5s while the tab is active. 503/non-200 falls back to a clear "relay disabled" message rather than a blank table. webui.res regenerated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TBcLtmpj7dqA5tyFbGnQon
…review) Three review fixes against PR #324. P2 #1: temporal-dead-zone on /#relay deep link. Page loaded with the hash already set ran showTab("relay") in the boot block before the relay tab's `let` / `const` state bindings (relayTimer, relaySetupDone, relayShowToken, RELAY_TOKEN_PLACEHOLDER) had initialised. setupRelayTab() / renderRelaySnippets() then hit ReferenceError on each, and bookmarked Relay URLs landed on a blank tab. Moved all four bindings above the boot block. P2 #2: nonexistent CLI command -- resolved on its own. PR #323 landed `pasclaw relay` into main; the snippet's command is real now. No code change needed. P2 #3: browser snippet relied on EventSource constructor headers, which native browser EventSource silently ignores. Without Authorization the gateway returns 401 on token-protected setups, and even untokened setups returned 400 ("X-Relay-Worker-Id is required"). Two changes: - Gateway (HandleRelayPoll): accept ?worker_id= and ?caps= as fallbacks for the equivalent headers. Mirrors the existing ?token= fallback the bearer-auth gate already uses on /v1/* routes. Header still wins when both are present. - Webui browser snippet: rewritten to use URLSearchParams + URL-only EventSource. Auth + worker identity + capabilities all ride as query params. fetch() for the response POST keeps the Authorization header. - docs/providers-relay.md: the same broken EventSource-headers pattern was in the docs reference implementation. Rewritten to match. Added a "Query-string fallback for browser EventSource workers" note to the wire-protocol section so non-browser workers know they can keep the header path (no extra URL noise, no token-in-server-logs concern). webui.res regenerated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TBcLtmpj7dqA5tyFbGnQon
Two PR #323 review fixes. P1: Tool-call metadata MUST round-trip through the envelope. A multi- turn relayed session that fires a tool call had assistant messages with ToolCalls and tool-result messages with ToolCallId/Name; both got dropped because BuildRelayRequestBody only emitted role+content, and the worker's DecodeMessages only read those two. The second turn then sent OpenAI/Anthropic/Gemini a request with no tool_call_id -> "missing tool_call_id" 400 / silent stall. Encoder now emits the OpenAI-shape fields (tool_calls array, tool_call_id, name) when non-empty; worker decoder reads them back. Same wire fields workers already see for response tool calls (HandleRelayRespond), so any worker that already forwards results forward this too. P2: Workers must encode any non-2xx upstream provider status as a relay error, not as a successful reply. Pre-fix, BuildResponseJSON only treated StatusCode=-1 (socket/TLS failure) as an error. A 429 or 5xx from upstream OpenAI/etc. fell into the else branch with the error JSON body as content, and the gateway's DecodeResponse mapped that to StatusCode := 200 -- bypassing Cfg.Fallbacks entirely and surfacing the provider error text to the agent as the assistant's reply. New ResponseIsError helper treats StatusCode -1 and anything outside [200..299] as error (StatusCode = 0 from older providers still treats as success to preserve compat). Tests: extended src/tests/relay_queue_tests.pas with - tool-call metadata round-trips through the envelope - worker encodes non-2xx upstream status as a relay error covering 429 / 500 / -1 / 200 / 0 paths. Same suite, runs via `make test-relay-queue`. All 20 cases green. BuildRelayWorkerResponseJSON is now exported from PasClaw.Cmd.Relay solely so the test program can drive the encoder directly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TBcLtmpj7dqA5tyFbGnQon
PR #326 review P2: a browser worker page served from a different origin than the gateway -- the documented browser/WebLLM case -- got blocked by CORS before the bearer-token / worker-id checks ever ran. The query-string fallback fix in the previous commit was necessary (EventSource can't set headers) but not sufficient (CORS blocks all of it). Adds permissive CORS on the three /v1/relay/* endpoints: - EmitRelayCors: Access-Control-Allow-Origin: *, Allow-Methods: GET, POST, OPTIONS, Allow-Headers reflecting the browser's preflight request (or a sensible default), Max-Age: 600 (10-minute preflight cache). - HandleRelayOptionsPreflight: 204 + Allow-* headers for the CORS preflight request, before the bearer-token gate (per the CORS spec, preflights must not carry credentials). - OnCommandOther wired on TIdHTTPServer to receive OPTIONS; routes /v1/relay/* preflights to HandleRelayOptionsPreflight and 405s everything else. - HandleRelayPoll, HandleRelayRespond, HandleRelayStatus stamp CORS headers on the actual response. HandleRelayStatus's signature gained an ARequest parameter so it can read Access-Control-Request-Headers like the others. - EmitSSEResponseHeaders now copies AResp.CustomHeaders into the raw HTTP response line. The SSE handler bypasses Indy's response writer to fix the chunked-encoding bug; that bypass silently dropped any CORS headers EmitRelayCors stamped, so /v1/relay/poll's SSE response was missing them. Now passes through cleanly. - Auth-gate intact: GET /v1/relay/poll without a token still returns 401 + WWW-Authenticate. The CORS preflight doesn't require auth, but the subsequent actual request does. - docs/providers-relay.md: new "CORS" callout under the wire protocol section explaining the policy + why permissive is safe (token = auth boundary, no cookies). - webui browser snippet: comment updated to note CORS is handled server-side, no client setup required. Why permissive (Allow-Origin: *)? The relay endpoints are guarded by a bearer token (or the ?token= query fallback). Anyone reaching them already had to present credentials. Echoing the Origin header (vs hard-allowlisting) would require operators to configure a CORS allowlist, which buys nothing security-wise because the token IS the gate. Allow-Origin: * is safe here specifically because we don't use credentials=include -- cookies aren't involved, the token rides as a header or query param. Live smoke (port 18889, token gated): - OPTIONS /v1/relay/respond/req_x -> 204 + Allow-* headers - GET /v1/relay/poll w/ Origin -> 200 SSE + CORS headers - GET /v1/relay/status w/ Origin -> 200 JSON + CORS headers - POST /v1/relay/respond w/ Origin -> 200 + CORS headers - GET /v1/relay/poll w/o token -> 401 + WWW-Authenticate This branch (claude/webui-relay-fixes-v2) replaces the PR #326 stack: rebased the two #324 commits + the #325 tool-metadata fix + this CORS commit straight onto current main. Closes the merge conflict on PR #326 (stale base after PR #323 merged) and addresses the P1 tool-metadata review by carrying #325's fix into this PR. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TBcLtmpj7dqA5tyFbGnQon
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 983eb26e74
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| HandleRelayOptionsPreflight(ARequest, AResponse); | ||
| Exit; | ||
| end; | ||
| AResponse.ResponseNo := 405; |
There was a problem hiding this comment.
Route existing PUT/DELETE handlers from OnCommandOther
When clients use non-GET/non-POST methods, this newly assigned OnCommandOther now catches them and returns 405 unless they are relay preflights. That prevents the existing router branches for PUT /v1/config, DELETE /v1/skills/*, and /v1/sessions/* PUT/DELETE from ever running, so the web UI can no longer save settings, persist existing chats after the initial POST, or delete sessions/skills. Route those verbs through the normal auth/router path instead of blanket-405ing them.
Useful? React with 👍 / 👎.
Codex P1 review on PR #327. Wiring OnCommandOther for the CORS preflight broke Indy's previous fallback behaviour: when OnCommandOther was unassigned, Indy fired OnCommandGet for PUT / DELETE / PATCH / etc, which is how the existing PUT /v1/config, DELETE /v1/skills/*, and PUT/DELETE /v1/sessions/* handlers in the OnCommandGet dispatch block ever ran. Once OnCommandOther was assigned, those verbs started getting the blanket 405 I added -- killing the web UI's settings save, session delete, and skill remove from the browser. Fix: only handle the OPTIONS preflight on /v1/relay/* here (those go before the bearer-token gate per the CORS spec); delegate everything else to OnCommandGet so the normal dispatch + auth gate runs. Truly-unknown verb+path pairs fall through to the existing 404 catch-all inside OnCommandGet. Live smoke (port 18890, token gated): PUT /v1/config -> 200 (saved config) DELETE /v1/skills/nonexistent -> 404 (handler reached, correctly reports unknown skill) OPTIONS /v1/relay/respond/x -> 204 + Access-Control-Allow-* OPTIONS /v1/config -> 404 (dispatched, no handler -- correct: PasClaw doesn't serve OPTIONS on /v1/config) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TBcLtmpj7dqA5tyFbGnQon
Summary
Replaces the conflicted PR #326 stack with a clean branch on current main carrying:
/v1/relay/*(this PR's main addition — PR webui+gateway: fix relay tab TDZ + browser-EventSource auth (PR #324 review) #326 review P2)The branch was built by cherry-picking
f0e8ac6(#324) +abed72c(#326) +00d9ba6(#325) onto current main, then adding the CORS commit on top. Once this lands, PRs #324, #325, and #326 can all be closed.CORS implementation
Browser workers on a different origin from the gateway (the documented WebLLM case) got blocked by CORS before the gateway's bearer / worker-id checks even ran. The query-string fallback fix was necessary (EventSource has no headers option) but not sufficient.
EmitRelayCors:Access-Control-Allow-Origin: *,Allow-Methods: GET, POST, OPTIONS,Allow-Headersreflecting the browser's preflight request (with a sensible default),Max-Age: 600(10-minute preflight cache).HandleRelayOptionsPreflight: 204 + Allow-* headers, before the bearer-token gate (preflights must not carry credentials per CORS spec).OnCommandOtherwired onTIdHTTPServerto receiveOPTIONS; routes/v1/relay/*preflights and 405s everything else.HandleRelayPoll,HandleRelayRespond,HandleRelayStatusall stamp CORS headers on their response.HandleRelayStatus's signature gained anARequestparameter.EmitSSEResponseHeadersnow copiesAResp.CustomHeadersinto the raw HTTP response line. The SSE handler bypasses Indy's response writer to fix the chunked-encoding bug; that bypass was silently dropping any CORS headersEmitRelayCorsstamped, so/v1/relay/pollSSE responses came back without them. Now passes through cleanly.Why
Allow-Origin: *The relay endpoints are bearer-token-gated. Anyone reaching them already had to present credentials, so the Origin allowlist buys nothing — the token IS the auth boundary.
*is safe specifically because PasClaw doesn't usecredentials: include(cookies aren't involved; the token rides as a header or?token=param).Test plan
make allclean.make test-relay-queue— all 20 cases green (includes the tool-metadata round-trip + non-2xx-as-error coverage from relay: round-trip tool metadata + surface upstream non-2xx as error (PR #323 review fix) #325).OPTIONS /v1/relay/respond/<id>→ 204 + Allow-* headersGET /v1/relay/pollwithOrigin:→ 200 SSE + CORS headersGET /v1/relay/statuswithOrigin:→ 200 JSON + CORS headersPOST /v1/relay/respond/<id>withOrigin:→ 200 + CORS headersGET /v1/relay/pollwithout token → 401 +WWW-Authenticate(auth gate intact)docs/providers-relay.mdupdated with a CORS callout under the wire-protocol section.🤖 Generated with Claude Code
https://claude.ai/code/session_01TBcLtmpj7dqA5tyFbGnQon
Generated by Claude Code