Skip to content

feat: multi-agent gateway with separate-origin widget host (experimental)#769

Open
arpitjalan wants to merge 1 commit into
SamSaffron:mainfrom
arpitjalan:feat/term-llm-gateway
Open

feat: multi-agent gateway with separate-origin widget host (experimental)#769
arpitjalan wants to merge 1 commit into
SamSaffron:mainfrom
arpitjalan:feat/term-llm-gateway

Conversation

@arpitjalan

@arpitjalan arpitjalan commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds term-llm serve-gateway — a thin reverse proxy that fronts the per-agent web serves of several contain workspaces under one origin, and (optionally) serves each agent's widgets from a dedicated, isolated origin.

Screenshot 2026-06-05 at 14 43 47

A term-llm serve binds a single agent at startup, so multi-agent access is achieved by fronting many serves rather than routing agents inside one process (already the production shape: one container per agent). The gateway discovers each agent's published port + bearer token from its workspace .env and injects the token server-side, so the per-agent token never reaches the browser.

This is the foundation for two things: reaching many agents from one place, and the separate-origin widget host that future work (a signed access grant) will use to safely embed/publish widgets.

What's included

Agent proxy

  • GET /agents (JSON, never includes tokens), an HTML landing page, and ANY /agent/<name>/... reverse-proxied to that agent's serve.
  • Injects Authorization: Bearer <token>; strips client Authorization, Cookie, X-Api-Key, and spoofable X-Forwarded-*.
  • Rebases the agent's baked-in /chat prefix onto /agent/<name> so the SPA's API calls, service worker, and subresources all route back through the proxy — zero changes to the agent.

Separate-origin widget host (--widget-host, default widgets.localhost)

  • Serves only /w/<agent>/<mount>/... on a dedicated origin; every other path 404s there. That emptiness is the isolation property: a widget iframe granted allow-same-origin is confined to a throwaway origin that hosts nothing sensitive.
  • Routes by Host header; the agent origin never exposes /w/, and the widget origin never exposes /agents, /agent/*, or the landing page.

Discovery + UX

  • New contain port / contain token subcommands and contain.ReadWebConfig, the single source for reading a workspace's web config.
  • Landing page lists each reachable agent and its widgets (grouped per agent) with links to the widget origin; markup/styles live in embedded .html/.css files (matching the serveui convention).

Hardening

  • Refuses non-loopback binds (no gateway auth yet).
  • Validates WEB_PORT; rejects a loopback-literal --widget-host that would shadow the gateway; warns at startup if the widget host doesn't resolve.
  • Bounded dial/response-header timeouts that leave SSE streams open; per-agent web-config cache invalidated by .env mtime; no environment proxy on the backend transport (avoids leaking the injected token via HTTP_PROXY).

Docs: new Multi-agent gateway guide.

Security posture

Experimental and loopback-only by design:

  • No gateway-level authentication yet — anyone who can reach the gateway can reach every discoverable agent; the enforced loopback bind is the safety net. To expose it, front it with an authenticating proxy and keep the gateway on loopback.
  • No widget access grant yet — the widget origin proxies any /w/<agent>/<mount>/ request (loopback only). A signed, expiring HMAC grant for safe cross-origin use is the next step, and the piece that actually closes the widget-iframe XSS in production.

An independent multi-agent code review was run over the diff; the top findings were fixed (HEAD/empty-body response handling, the HTTP_PROXY token-leak path, query-string preservation on the widget redirect, and the widget-host/agent-host shadowing guard).

Add 'term-llm serve-gateway', a thin reverse proxy that fronts the
per-agent web serves of several contain workspaces under one origin, and
(optionally) serves each agent's widgets from a dedicated, isolated origin.
A term-llm serve binds one agent at startup, so multi-agent access is
achieved by fronting many serves; the gateway reads each agent's port and
bearer token from its workspace .env and injects the token server-side, so
the per-agent token never reaches the browser.

Agent proxy: GET /agents (JSON, no tokens), an HTML landing page, and
ANY /agent/<name>/... reverse-proxied to that agent's serve. Injects
Authorization: Bearer, strips client Authorization/Cookie/X-Api-Key and
spoofable X-Forwarded-*; rebases the agent's baked-in base prefix onto
/agent/<name> so the SPA re-homes onto the proxy with zero agent changes.

Widget host (--widget-host, default widgets.localhost): serves ONLY
/w/<agent>/<mount>/... on a dedicated origin (every other path 404s there),
so an embedded widget granted allow-same-origin is isolated to a throwaway
origin. Routes by Host header; widget responses pass through unrebased
(widgets resolve via relative URLs). Landing page lists each agent's
widgets, grouped per agent, linking to the widget origin.

Discovery: new 'contain port' / 'contain token' subcommands and
contain.ReadWebConfig (single source for a workspace's web config), built
on a shared contain.ReadEnvFile.

Built on the current upstream/main and integrated with its new code:
- Reuse webport.go's webPortBase as ReadWebConfig's port fallback, and
  collapse webport.go's readEnvWebPort into the shared ReadEnvFile parser
  (one .env parser, not two).
- Normalize WEB_BASE_PATH trailing slashes in ReadWebConfig to match the
  serve's normalizeBasePath, so the rebase needles always match.
- Reuse widgets.WidgetStatus for the landing-page widget list and route the
  mount charset through the new exported widgets.ValidMount.
- Drop cmd/contain.go's local readContainEnvFile; route printContainWebUIInfo
  and printContainAuthSeedHints through contain.ReadWebConfig/ReadEnvFile.
- A regression test feeds the real serve-rendered index through the rebase
  needles so any serveui/buildIndexHTML shape drift fails the build.

Hardening: refuses non-loopback binds (no gateway auth yet); validates
WEB_PORT; rejects a loopback-literal --widget-host; warns if the widget
host doesn't resolve; bounded dial/response-header timeouts that keep SSE
open; per-agent web-config cache invalidated by .env mtime; no env proxy on
the backend transport. Experimental and loopback-only; the HMAC widget
access grant is the next step. Includes a docs guide.
@arpitjalan arpitjalan force-pushed the feat/term-llm-gateway branch from 815a46c to 6fe1a2e Compare June 5, 2026 10:59
@SamSaffron

Copy link
Copy Markdown
Owner

give me a bit arpit, I want to think this one through

@arpitjalan

Copy link
Copy Markdown
Contributor Author

No rush, thanks Sam!

@arpitjalan

Copy link
Copy Markdown
Contributor Author

Also, here's some context on scope and the use case driving this:

Why: it comes out of the second-brain Discourse plugin, where widgets are currently proxied through Discourse's own origin and iframed with allow-same-origin - so a prompt-injected widget can run as Discourse (read PMs, act as the user). The fix is to serve widgets from a dedicated, isolated origin, which is what this PR adds (and it doubles as the mechanism for publishing widgets later).

This PR is the foundation: a thin multi-agent reverse proxy + the separate-origin widget host, loopback-only (it refuses non-loopback binds since the agent proxy has no auth yet). Merging it exposes nothing.

Kept intentionally scoped: the widget origin's access control - a short-lived signed grant the embedding app mints and the host verifies, so the Discourse session never reaches the widget origin - is the natural next step, left out here to keep this PR focused on the proxy/isolation foundation.

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