webui: Relay tab with copy-paste worker connect snippets#324
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
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0d6df9086d
ℹ️ 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".
| let relayTimer = null; | ||
| let relaySetupDone = false; | ||
| let relayShowToken = false; |
There was a problem hiding this comment.
Move relay state before hash activation
When the page is loaded directly at /#relay, the existing boot path calls showTab(location.hash.slice(1)) before these newly added let initializers have executed. tabLoaders.relay() then calls setupRelayTab(), which reads relaySetupDone while it is still in the temporal dead zone, so the Relay tab's snippets/status fail to initialize for bookmarked or reloaded Relay URLs. Move the relay state initialization above the boot block or defer hash activation until after it.
Useful? React with 👍 / 👎.
| const cli = "pasclaw relay \\\n" | ||
| + " --gateway-url " + url + " \\\n" | ||
| + " --gateway-token " + tok; |
There was a problem hiding this comment.
Replace the nonexistent relay CLI command
This snippet tells users to run pasclaw relay, but there is no relay subcommand registered: src/cmd/PasClaw.Cmd.Root.pas dispatches the known commands at lines 184-234 and unknown commands fall through to unknown command at lines 235-238, and a repo-wide search finds no Cmd.Relay implementation. Copying either CLI snippet from the new Relay tab therefore fails immediately instead of attaching a worker.
Useful? React with 👍 / 👎.
| " headers: {\n" + | ||
| " \"Authorization\": `Bearer ${TOKEN}`,\n" + | ||
| " \"X-Relay-Worker-Id\": `tab-${crypto.randomUUID()}`,\n" + | ||
| " \"X-Relay-Capabilities\": MODEL,\n" + | ||
| " },\n" + |
There was a problem hiding this comment.
Stop relying on EventSource headers
In real browsers the native EventSource constructor ignores arbitrary request headers; this file already uses gwSseUrl() for logs because EventSource cannot set headers, and the server requires X-Relay-Worker-Id in HandleRelayPoll before registering a worker. As written, the browser worker snippet cannot connect: token-protected gateways receive no Authorization header, and even untokened gateways reject the missing worker-id header with HTTP 400.
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
pasclaw relay(flag form), env-var form, browser/WebLLM, Python+llama.cpp, Replicatecog-relay.<PASCLAW_GATEWAY_TOKEN>placeholder so the panel is safe to screen-share; per-tab "show" toggle swaps in the real localStorage value when an operator wants to copy the snippet somewhere private./v1/relay/statusmini-dashboard: 5 stat cards (workers / pending / in-flight / completed / failed) + a connected-workers table with caps + last-seen. 5 s refresh. 503/non-200 surfaces "relay disabled on this gateway" rather than blanking the panel.Test plan
make allcleandata-tab="relay",id="tab-relay", snippet templates,setupRelayTab/refreshRelayJS all served from/<script>parses cleanly (no syntax errors)window.location.origin, token rendered masked, "show" toggle reveals real token, snippets re-render with substitution, 5 code blocks present, copy buttons wired/v1/relay/statusreturns expected shape (gateway withdefault_provider=relayconfigured)🤖 Generated with Claude Code
https://claude.ai/code/session_01TBcLtmpj7dqA5tyFbGnQon
Generated by Claude Code