Skip to content

webui+gateway: fix relay tab TDZ + browser-EventSource auth (PR #324 review)#326

Open
FMXExpress wants to merge 5 commits into
claude/webui-relay-connect-snippetfrom
claude/webui-relay-eventsource-tdz
Open

webui+gateway: fix relay tab TDZ + browser-EventSource auth (PR #324 review)#326
FMXExpress wants to merge 5 commits into
claude/webui-relay-connect-snippetfrom
claude/webui-relay-eventsource-tdz

Conversation

@FMXExpress

Copy link
Copy Markdown
Owner

Summary

Three review fixes against PR #324, stacked on its branch so the diff shows only the delta.

P2 #1 — TDZ on /#relay deep link

Boot block ran showTab("relay") before the relay tab's let / const state bindings (relayTimer, relaySetupDone, relayShowToken, RELAY_TOKEN_PLACEHOLDER) had initialised. setupRelayTab() / relayEffectiveToken() then hit ReferenceError on each, and any bookmarked /#relay URL landed on a blank tab. Moved all four bindings above the boot block. Reviewer flagged the lets; jsdom test caught a fourth binding (RELAY_TOKEN_PLACEHOLDER) the same fix also needed.

P2 #2 — Nonexistent CLI command

Resolved by PR #323 landing into main. The pasclaw relay command exists now; the snippet is correct. No code change.

P2 #3 — Browser EventSource ignores headers option

WHATWG EventSource has no headers option, so the previous snippet would have hit:

  • 401 (no Authorization header on token-protected gateways)
  • 400 (X-Relay-Worker-Id is required)
    Fixed at three levels:
Layer Change
Gateway (HandleRelayPoll) Accept ?worker_id= and ?caps= as fallbacks for X-Relay-Worker-Id / X-Relay-Capabilities. Mirrors the existing ?token= fallback on /v1/*. Header still wins when both present.
Webui browser snippet Rewritten to use URLSearchParams + URL-only EventSource. Response POST keeps the header path via fetch().
docs/providers-relay.md Same broken snippet existed in the reference docs; rewritten to match. Added a "Query-string fallback for browser EventSource workers" note in the wire-protocol section so non-browser workers know they can keep the header path.

Test plan

  • make all clean, make webui-res clean.
  • Live gateway smoke: worker connects via query params ONLY (no Authorization, X-Relay-Worker-Id, X-Relay-Capabilities headers) and registers correctly with the right caps. Header-based path still works (no regression).
  • Missing worker id now returns a friendlier error mentioning the ?worker_id= fallback.
  • jsdom test loading http://.../#relay: zero runtime errors, relay tab activates, all 5 snippets render, browser snippet uses URLSearchParams + ?${q} query auth, no stale headers: on EventSource.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TBcLtmpj7dqA5tyFbGnQon


Generated by Claude Code

claude and others added 5 commits June 21, 2026 16:04
V1 of the inverse-direction relay: a pasclaw process polls a remote
gateway's /v1/relay/poll over SSE, forwards each inference request
through the locally-configured provider (default = Cfg.DefaultProvider,
or --provider NAME), and POSTs the result to /v1/relay/respond/<id>.

Lets a box with a real LLM lend that capacity to a separate gateway
box that doesn't: gateway runs `pasclaw gateway` with a Relay
provider; worker runs `pasclaw relay --gateway-url ...`; the worker's
local Anthropic/OpenAI/Gemini/etc. provider services jobs that
reached the gateway from its agent loop.

Scope: single-threaded, one Provider.Chat() per event, no tool loop
on the worker side (gateway-side owns the agent loop). Reconnects on
SSE drop with exponential backoff (1s -> 30s). Bearer auth via
PASCLAW_GATEWAY_TOKEN or --gateway-token. Refuses to forward to a
provider that is itself a relay (would loop).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBcLtmpj7dqA5tyFbGnQon
Sibling to /cog/ and /cog-build/. Runs the Pascal-side `pasclaw relay`
worker as a Replicate predictor: each predict() call seeds a
provider config from the operator's chosen API keys / local-server
URLs / custom-OpenAI-compat block, spawns the relay subprocess for
lifetime_seconds (default 300), then returns the captured worker log.

Use case is credential isolation: put one API key on the cog, point
N gateways at it. The gateway never holds the key; it just gets
results back through the relay queue. Also covers bursty CI (deploy,
burst, tear down) and air-gapped gateways (gateway can reach
Replicate but not the provider directly).

Provider-config shape matches cog-build verbatim (six cloud keys +
three local URLs + custom escape hatch). Worker bridge logic itself
is the existing `pasclaw relay` from the previous commit -- this
just wraps it.

README.md updated with the 2026-06-21 entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBcLtmpj7dqA5tyFbGnQon
cmd: pasclaw relay worker + cog-relay Replicate cog
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

@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: abed72cbd9

ℹ️ 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".

Comment on lines +135 to +136
Result[i].Role := MsgRoleFromString(Obj.GetStr('role', 'user'));
Result[i].Content := Obj.GetStr('content', '');

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 Preserve tool-call metadata in relay histories

When a relay-backed agent turn follows a tool call, the history contains the assistant ToolCalls and the ToolCallId on each mrTool result, but this worker reconstructs only role/content. The downstream providers require those fields (OpenAI emits Messages[i].ToolCallId and assistant tool_calls, and Anthropic emits tool_use_id), so the next Provider.Chat() after the first tool execution sends empty/missing IDs and the relay session fails or loses the tool context. The relay request envelope and this decoder need to carry tool_call_id and assistant tool_calls.

Useful? React with 👍 / 👎.

" // Browsers' native EventSource constructor has no headers option,\n" +
" // so auth + worker identity + capabilities ride as query params.\n" +
" const q = new URLSearchParams({ token: TOKEN, worker_id: WID, caps: MODEL });\n" +
" const events = new EventSource(`${URL}/v1/relay/poll?${q}`);\n" +

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Add CORS support for browser relay workers

In the advertised case where this snippet is pasted into a browser worker page that is not served from the gateway origin (for example a local WebLLM page pointing at a remote gateway), the browser applies CORS to both this EventSource and the later authorized JSON fetch. The gateway currently has no OPTIONS/Access-Control-Allow-Origin handling for /v1/relay/*, so those requests are blocked before the query-string token/worker_id ever reach HandleRelayPoll; either serve the worker page same-origin or add CORS/preflight support for these endpoints.

Useful? React with 👍 / 👎.

FMXExpress added a commit that referenced this pull request Jun 21, 2026
webui+gateway: relay tab + CORS for /v1/relay/* (replaces #324, #325, #326)
FMXExpress pushed a commit that referenced this pull request Jun 22, 2026
Two stacked fixes addressing the PR #333 P1 code review and the
"each new serve should generate a relay-only token" follow-up.

WHY (P1 from review):
  Loading @mlc-ai/web-llm via dynamic import in the parent page was
  gated on a button click, which deferred the network fetch -- but
  did NOT isolate the third-party code from the gateway UI's origin.
  Once loaded, the module sat in the same script realm as the webui:
  it could read localStorage["pasclaw.gw_token.v1"], call any /v1/*
  endpoint with the operator's credentials, and mutate the parent
  DOM. A compromised CDN / typo-squat / supply-chain push reached
  the whole authenticated PasClaw surface.

WHAT (sandbox):
  In-browser worker now lives inside a `<iframe sandbox="allow-scripts">`
  (no allow-same-origin -> opaque null origin). The webllm import
  and EventSource + dispatch loop run there; the parent UI only
  renders status / progress / log lines relayed via postMessage.

  Parent <-> child wire protocol (postMessage):
    parent -> child : connect{url,token,model,workerId} | disconnect
    child  -> parent: ready | log | status | progress | job-served
                      | connected | disconnected | connection-failed

  Source identity filter (event.source === iframeEl.contentWindow)
  not origin string -- null-origin iframes emit "null" but sticking
  to identity is robust to spec drift.

WHAT (scoped token):
  TGatewayServer.Create generates a fresh FRelayToken on every
  startup -- 8 Crockford base32 chars in two 4-char groups
  (A8M9-PXRT, ~40 bits entropy, phone-typable, no I/L/O/U
  confusables). Dual-token auth at the OnCommandGet gate accepts
  EITHER the main Cfg.Gateway.Token OR FRelayToken for /v1/relay/*
  paths (case-insensitive, hyphens stripped before compare so
  dictated tokens work). Main-token endpoints (/v1/chat /v1/config
  /v1/skills/...) reject the scoped token -- a compromised in-tab
  worker can pull jobs but cannot impersonate the operator.

  New GET /v1/relay/worker-token returns the scoped token as JSON.
  Gated by the MAIN token via the normal auth check (the dual-token
  helper deliberately excludes this path) so an attacker who only
  has the relay token cannot escalate to read it. CORS-stamped.

  pasclaw serve / pasclaw gateway print the token on startup so
  external `pasclaw relay` CLIs can use scoped credentials too.

WHAT (webui plumbing):
  setupInBrowserWorker creates the sandboxed iframe on tab init
  (srcdoc carries the postMessage responder + dynamic-import
  scaffold; ~3.8 KB inline). inbWorkerConnect first fetches
  /v1/relay/worker-token using gwFetch (main token), then
  postMessages the SCOPED token to the iframe -- the main token
  never crosses the origin boundary. UI hint text updated to
  surface the trust boundary so operators understand what's
  isolated and what isn't.

  Three new bindings (_relayInbIframe / _relayInbReady /
  _relayInbJobs) declared above the boot block alongside the other
  relay-tab state, same TDZ pattern PR #324/#326 already established
  (deep-linked /#relay loads the tab before the relay-impl block
  runs).

VERIFIED:
  - Static checks: iframe sandbox attr = "allow-scripts" without
    allow-same-origin; no eager `import {CreateMLCEngine} from esm.run`
    in the outer page; dynamic import present only inside the
    srcdoc; UI hint mentions sandbox and relay-scoped token.
  - jsdom render: zero runtime errors, iframe in DOM, srcdoc 3.8 KB
    with `await import('https://esm.run/...')` + postMessage logic.
  - Endpoint smoke (gateway with main token MAINTOKEN):
      no-auth /v1/relay/worker-token         -> 401
      relay-token-only worker-token          -> 401
      main-token /v1/relay/worker-token      -> 200 {"token":"KP44-5XZ3"}
      scoped /v1/relay/status                -> 200
      scoped /v1/config                      -> 401  (blast-radius
                                                       contained)
      main /v1/config                        -> 200
      lowercase / no-hyphen scoped tokens    -> 200 (case-insensitive)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBcLtmpj7dqA5tyFbGnQon
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