webui+gateway: fix relay tab TDZ + browser-EventSource auth (PR #324 review)#326
webui+gateway: fix relay tab TDZ + browser-EventSource auth (PR #324 review)#326FMXExpress wants to merge 5 commits into
Conversation
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
There was a problem hiding this comment.
💡 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".
| Result[i].Role := MsgRoleFromString(Obj.GetStr('role', 'user')); | ||
| Result[i].Content := Obj.GetStr('content', ''); |
There was a problem hiding this comment.
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" + |
There was a problem hiding this comment.
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 👍 / 👎.
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
Summary
Three review fixes against PR #324, stacked on its branch so the diff shows only the delta.
P2 #1 — TDZ on
/#relaydeep linkBoot block ran
showTab("relay")before the relay tab'slet/conststate bindings (relayTimer,relaySetupDone,relayShowToken,RELAY_TOKEN_PLACEHOLDER) had initialised.setupRelayTab()/relayEffectiveToken()then hit ReferenceError on each, and any bookmarked/#relayURL landed on a blank tab. Moved all four bindings above the boot block. Reviewer flagged thelets; 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 relaycommand exists now; the snippet is correct. No code change.P2 #3 — Browser
EventSourceignoresheadersoptionWHATWG
EventSourcehas no headers option, so the previous snippet would have hit:Authorizationheader on token-protected gateways)X-Relay-Worker-Id is required)Fixed at three levels:
HandleRelayPoll)?worker_id=and?caps=as fallbacks forX-Relay-Worker-Id/X-Relay-Capabilities. Mirrors the existing?token=fallback on/v1/*. Header still wins when both present.URLSearchParams+ URL-onlyEventSource. Response POST keeps the header path viafetch().docs/providers-relay.mdTest plan
make allclean,make webui-resclean.Authorization,X-Relay-Worker-Id,X-Relay-Capabilitiesheaders) and registers correctly with the rightcaps. Header-based path still works (no regression).?worker_id=fallback.http://.../#relay: zero runtime errors, relay tab activates, all 5 snippets render, browser snippet usesURLSearchParams+?${q}query auth, no staleheaders:onEventSource.🤖 Generated with Claude Code
https://claude.ai/code/session_01TBcLtmpj7dqA5tyFbGnQon
Generated by Claude Code