Skip to content

webui+gateway: relay tab + CORS for /v1/relay/* (replaces #324, #325, #326)#327

Merged
FMXExpress merged 5 commits into
mainfrom
claude/webui-relay-fixes-v2
Jun 21, 2026
Merged

webui+gateway: relay tab + CORS for /v1/relay/* (replaces #324, #325, #326)#327
FMXExpress merged 5 commits into
mainfrom
claude/webui-relay-fixes-v2

Conversation

@FMXExpress

Copy link
Copy Markdown
Owner

Summary

Replaces the conflicted PR #326 stack with a clean branch on current main carrying:

  1. PR webui: Relay tab with copy-paste worker connect snippets #324's relay tab (was conflicting because its base was pre-cmd: pasclaw relay worker + cog-relay Replicate cog #323)
  2. PR relay: round-trip tool metadata + surface upstream non-2xx as error (PR #323 review fix) #325's tool-metadata + non-2xx-as-error fix (reviewer flagged the same bug on webui+gateway: fix relay tab TDZ + browser-EventSource auth (PR #324 review) #326 because relay: round-trip tool metadata + surface upstream non-2xx as error (PR #323 review fix) #325 hadn't merged yet)
  3. PR webui+gateway: fix relay tab TDZ + browser-EventSource auth (PR #324 review) #326's TDZ + query-string-auth fix
  4. New CORS support for /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-Headers reflecting 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).
  • OnCommandOther wired on TIdHTTPServer to receive OPTIONS; routes /v1/relay/* preflights and 405s everything else.
  • HandleRelayPoll, HandleRelayRespond, HandleRelayStatus all stamp CORS headers on their response. HandleRelayStatus's signature gained an ARequest parameter.
  • 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 was silently dropping any CORS headers EmitRelayCors stamped, so /v1/relay/poll SSE 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 use credentials: include (cookies aren't involved; the token rides as a header or ?token= param).

Test plan

  • make all clean.
  • 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).
  • Live CORS smoke:
    • OPTIONS /v1/relay/respond/<id> → 204 + Allow-* headers
    • GET /v1/relay/poll with Origin: → 200 SSE + CORS headers
    • GET /v1/relay/status with Origin: → 200 JSON + CORS headers
    • POST /v1/relay/respond/<id> with Origin: → 200 + CORS headers
    • GET /v1/relay/poll without token → 401 + WWW-Authenticate (auth gate intact)
  • docs/providers-relay.md updated with a CORS callout under the wire-protocol section.
  • Webui browser snippet comment updated to note CORS is server-side.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TBcLtmpj7dqA5tyFbGnQon


Generated by Claude Code

claude added 4 commits June 21, 2026 17:38
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

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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
@FMXExpress FMXExpress merged commit c903bdc into main Jun 21, 2026
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.

2 participants