The webview gateway¶
Three processes sit behind the browser cockpit, and they are usually confused for one:
| process | listens on | who reaches it | what it decides |
|---|---|---|---|
agentctl top --gateway — the cockpit + the WS router |
127.0.0.1:8731 (cockpit) and 0.0.0.0:8735 (dial-in) |
the login proxy on loopback; agents, from any host, on 8735 | which agent a request is scoped to, from X-User-Email |
agentctl-webview-login.service — the login proxy |
:8736, all interfaces |
browsers, directly or through your HTTPS proxy | who the person is, and it asserts that as X-User-Email |
| your reverse proxy | 443 | the internet | TLS, and whether the login proxy is publicly reachable at all |
One agentctl top --gateway process serves both :8731 and :8735 — the browser
cockpit and the agent dial-in are two listeners in the same binary, not two services.
webview-gateway install installs that one; webview-login install installs the proxy in
front of it. Installing both is Quick start; what the
cockpit shows is The cockpit; how the webview channel compares to
Telegram, Slack and Teams is Channels.
The trust chain, and the one fact it rests on¶
The cockpit performs no authentication. It reads X-User-Email, lowercases it, looks it up
in the gateway's user_email → agent ledger, and scopes the request to whatever it finds
(scopeFor, internal/cli/top_web.go). It never checks a signature, a cookie or a peer
address on that header. The login proxy is what makes the header mean something: it
authenticates the person, then Header.Sets the value — Set, never Add, so a
browser-supplied X-User-Email is overwritten rather than left first in the list where
Header.Get would read it.
A missing header is more privileged than a present one. scopeFor returns
scoped=false for an empty X-User-Email, and an unscoped caller is treated as the local
operator with full-fleet view — the correct reading for someone running top --web on
their own machine with no proxy in front. A header that maps to no ledger entry, by
contrast, is scoped to the empty agent and denied everything. So the interesting request to
the cockpit is not a spoofed identity; it is no identity at all.
Everything therefore depends on who can open a TCP connection to the cockpit's port.
Is :8731 genuinely loopback-bound?¶
Yes, and it is enforced in the listen path rather than left to convention or to the unit file.
startWebServer binds whatever address it is handed. Exactly one value can reach it:
webAddr in runTop, which is defaultWebAddr (127.0.0.1:8731) unless --web <addr> is
passed, and that argument goes through webListenAddr — which forces the host part to
127.0.0.1 on every branch, including the one that parses a complete host:port. There
is no environment variable for it and no other caller. TestWebListenAddr pins the case
that matters: 0.0.0.0:9000 normalises to 127.0.0.1:9000.
The rendered unit does not even exercise that path. ExecStart is
agentctl top --gateway --ws-port ${WEBVIEW_WS_PORT} — no --web at all, so the service
takes the default. An operator editing the unit by hand cannot widen the bind either; the
flag has no spelling that produces a non-loopback address.
What that does and does not buy you. It rules out a remote connection to :8731. It does not rule out a local one, and nothing in this repo does:
- The nftables port guard fences per-agent bands only — chrome, channel-ws, kasm and
webview-ws, each
base + uid − 1000(converge.PortBands). Host-service ports are fixed, not per-agent, so 8731 has no rule and no owning uid. See Operations § Host port map. - The cockpit handlers do not check
r.RemoteAddr. Two handlers in the same tree do —streamStatsHandlerand the gateway's/_internal/oauth-callbackboth reject anything that is not127.0.0.1/::1— so the mechanism exists in the codebase and is simply not applied here.
The practical statement: any local uid on the gateway host can curl http://127.0.0.1:8731/
and be served as an unscoped operator. On a host that runs agents alongside the gateway,
that includes every agent's own uid. If that matters for your deployment, the control is a
host firewall rule on 8731 or a gateway host that runs nothing else — the shape the L1D and
inventx topologies already use, where agents dial in from elsewhere. Neither is asserted by
agentctl.
The login proxy binds all interfaces¶
webview-login serve calls http.ListenAndServe(":8736", …) — an empty host, which is every
interface, not loopback. Quick start and The cockpit both describe the pair as
"bound locally"; that is accurate for the cockpit and not for the login proxy. It is
authenticated, so an exposed :8736 is a login page rather than an open door, but it means
your reverse proxy is not what keeps it private — the host firewall or cloud NSG is. Check
with ss -ltnp | grep 8736 on any host you care about.
The login flow¶
Two entirely different flows are both called "logging in" around this cockpit. Keep them apart.
The person logging into the cockpit¶
Handled by webview-login. Not PKCE, and not tied to any one identity provider — the driver
is pluggable, selected by WEBVIEW_AUTH_DRIVER:
basic — a credential list in WEBVIEW_BASIC_USERS (user:password,user:password), one
list per host. Browsers get a themed HTML form at /auth/login; scripts get an ordinary
WWW-Authenticate: Basic challenge, chosen by whether the request's Accept contains
text/html. The form exists because HTTP Basic's native prompt is suppressed in a
standalone-PWA display mode and its per-request Authorization replay is unreliable through
a service worker. A config entry with an empty password is refused at startup rather than
accepted — an empty password compares equal to an empty submission, so it would authenticate
anyone who guessed the username.
The form is rate limited to 8 attempts per minute per client IP, and that IP is taken from
X-Forwarded-For first, then X-Real-IP, then the actual peer address. Behind a proxy that
sets those headers, that is correct. Reached directly on :8736 — which the bind above
makes possible — a caller supplies its own X-Forwarded-For and gets a fresh bucket per
attempt, so the limit does not bound an offline-speed password guess. Treat it as protection
for the proxied path only. Note also that the preemptive Authorization: Basic path is not
rate limited at all.
oidc (also the default when unset) — a standard authorization-code flow against any
OIDC issuer; Entra and Google are configuration, not code. Needs WEBVIEW_OIDC_ISSUER,
WEBVIEW_OIDC_CLIENT_ID and usually WEBVIEW_OIDC_CLIENT_SECRET. The redirect URI is
${WEBVIEW_URL}/auth/callback. The principal is the email claim, falling back to
preferred_username then upn.
Either way the outcome is the same: a wv_session cookie holding
base64(purpose|expiry|principal).base64(HMAC-SHA256), signed with WEBVIEW_HMAC_KEY,
HttpOnly + Secure + SameSite=Lax, 30 days by default (WEBVIEW_SESSION_TTL, any Go
duration). Per-agent separation happens after authentication, as authorization, by mapping
the principal through the gateway's ledger.
WEBVIEW_HMAC_KEY signs three different things and the purpose is signed into the
payload, not inferred from the signature: session, csrf (the login form's nonce) and
oidc-state. That separation is load-bearing rather than tidy. The CSRF nonce is printed
into the login page for any anonymous visitor and the OIDC state rides in the address bar;
when all three shared one shape, either could be pasted into the wv_session cookie and
would verify as a session for a principal named "csrf". verifyToken now compares the
purpose in constant time and refuses an empty principal outright — an empty principal would
proxy through as an empty header and land in the unscoped-operator branch above.
Rotating WEBVIEW_HMAC_KEY invalidates every live session on the host.
/auth/logout clears the cookie and is registered once for all drivers, not per driver.
Under OIDC it ends the session with this application only; an IdP still holding an SSO
session will re-issue an assertion without prompting, so the visible result is a round trip
rather than a login screen. RP-initiated logout at the issuer is deliberately not wired —
it would sign the person out of every other application on that tenant.
Four path prefixes bypass authentication and proxy straight through:
/manifest.webmanifest, /sw.js, /icons/ and /fonts/. iOS Safari fetches the manifest
and icons unauthenticated when deciding installability, and the login page itself needs the
fonts it is rendered in. Those handlers serve only host-level branding or bytes compiled into
the binary, and the proxy Header.Dels X-User-Email on that path so an anonymous caller
cannot supply its own.
The agent logging into its model provider¶
This is the "auth card" a fresh webview agent shows in the cockpit on its first turn, and it
has nothing to do with webview-login. It is PKCE. The always-on
agentctl-webview-dial@<agent>.service tier stays up while the agent itself is logged out —
which is the whole reason it exists as a separate unit — probes credential status, and hands
the owner an authorization URL through the channel. The pasted code#state goes to
agentctl login claude <agent>, which routes through agentd and runs the exchange as the
agent's own uid; agentd's SO_PEERCRED check means that unit, running as User=%i, can
only ever log in itself.
The optional agentctl auth-broker (:8734) removes the paste step: it serves a public
/oauth/callback, verifies its own HMAC-signed state, and hands the code to the gateway's
loopback-only /_internal/oauth-callback, which relays it over the agent's existing outbound
WebSocket. The broker holds no durable secret and never stores a code.
The dial-in side: how an agent registers¶
Agents connect outward to :8735 and stay connected. The cockpit's per-agent handlers then read a live session, inject turns and run ops over that socket instead of shelling out over SSH — which is why a cross-host agent needs no inbound path and no shared filesystem.
webviewRegisterURL resolves what an agent dials: ws://127.0.0.1:8735 when the gateway is
this same host (or no host.webview_gateway marker is set), otherwise
ws://<gateway-host>:8735 over the fleet's private network. It lands in the agent's own
agent.env/pi-agent.env as CHANNEL_WEBVIEW_GATEWAY_URL, alongside CHANNEL_USER_EMAIL.
The listener binds 0.0.0.0 and accepts the WebSocket with InsecureSkipVerify (no origin
check), so the transport proves nothing about who is dialing in. Authorization is at the
application layer, on the register frame:
ident := lower(trim(frame.user_email))
if ident == "" || userLedger[ident] != frame.agent { close(PolicyViolation) }
Same shape as the Slack and Teams gateways (verified email / AAD → agent). Without it, any
WS client sending {"t":"register","agent":"<name>"} was trusted as that agent.
Be clear about what this check is. user_email is a person's email address — an
identifier, not a secret. Anyone who can reach :8735 and knows an agent's login identity can
register as that agent and receive its inbound frames. The gateway port is meant to be
fleet-internal, so the control here is the network, and the ledger check is a second gate
behind it rather than the primary one. This is a known open gap, not a subtlety of the
implementation.
Four more endpoints share the dial-in listener, deliberately: attachment pull, the
/browser/ transport tunnel redemption, the /terminal/ shell tunnel redemption, and the
host-shell tunnel. All four are things an agent has to reach, and :8735 is the address it
already dials out to, so they work through whatever NAT or firewall the control socket
already traverses. stream-stats is on the same listener but rejects any peer that is not
loopback.
WEBVIEW_LEDGER is rendered into the gateway's env file from agents.yaml at apply time,
not read live. That is on purpose: a dedicated gateway host may carry no fleet ledger at all
(agents dial in to it), where a live read would resolve to nothing and silently deauthorize
every agent. WEBVIEW_ADMINS — the roster behind adminScope — is rendered the same way and
is always emitted, even as [], so its absence unambiguously means "an agentctl too old to
render it" rather than "no admins".
Reverse-proxy requirements¶
Two things will produce a cockpit that loads, authenticates, and then never streams.
Use HTTP/2. The cockpit holds four concurrent SSE streams per open tab — /events,
/watch, /api/transcript and /api/fsevents. Browsers cap HTTP/1.1 at six connections per
origin, so those four leave two for everything else; a second tab exhausts the pool and
ordinary requests queue behind streams that never end. Over HTTP/2 they are multiplexed on
one connection and the ceiling disappears.
Do not compress the stream routes. Compression is a request-time decision — a proxy
chooses before the upstream has produced anything, so there is no "skip it if the
content-type turns out to be text/event-stream" switch. gzip buffers before it can emit a
block; wrapping a long-lived stream in it either delays delivery indefinitely or never
flushes at all. The rendered Caddyfile encodes a matcher that excludes exactly those four
paths:
@compressible {
not path /events /watch /api/transcript /api/fsevents
}
encode @compressible zstd gzip
A site-wide encode shipped across the whole L1D fleet once and broke every live session,
hidden by a static shell that compressed fine. If you front the cockpit with something other
than the Caddy agentctl renders, reproduce that exclusion. The cockpit does set
X-Accel-Buffering: no on its stream responses, which an nginx in the chain will honour, but
that is a hint to one proxy and not a substitute for configuring your own.
Caddy is rendered automatically on a host that runs a gateway, and it injects
header_up X-Request-Id {http.request.uuid}. The login proxy generates one when the header
arrives empty, forwards it upstream, and echoes it on the response, so a browser-side failure,
the proxy's own http: proxy error: … request_id=… log line and Caddy's
aborting with incomplete response can be tied to the same request. Before that they could
not be.
What apply reconciles¶
Both units are reconciled on every apply and fleet sync on a host that has them
installed. Each re-renders its unit body and its env file and restarts only if something
changed — except under --force, or after a deb upgrade.
The upgrade case is the one to understand. Both ExecStart lines name the packaged
/usr/bin/agentctl and carry no version string, so a new package replaces the binary on
disk while the unit body stays byte-identical. A change-only restart would leave both
services running the old code indefinitely. apply therefore force-bounces them whenever
it detects an upgrade. This was a real gap: the gateways were force-bounced and the login
proxy was not, so it stayed on the old binary and a WEBVIEW_* secret rotated in sops never
reached its rendered env.
A gateway bounce cascades: the gateway authorizes registrations by the shim's
user_email, and an older shim sends an empty identity that a newer gateway rejects. So
apply restarts every local agent that has a user_email after the gateway actually
restarted. reconcileWebviewGateway returns that fact for exactly this purpose; the login
proxy's equivalent returns it only for symmetry, since nothing registers into a plain reverse
proxy.
Two refusals worth knowing:
renderWebviewLoginEnvrefuses to write anything whenWEBVIEW_HMAC_KEYreads back empty. An empty read means the sops store is unreadable on this host — a missing or rotated age key — not that the key was removed. Writing the truncated env and restarting would take a working proxy down; leaving the old file alone keeps it serving.webview-gateway uninstallrefuses while non-terminal host operations exist, because the gateway host is the only route to their targets.--forceoverrides and names what is being abandoned.
webview-gateway install also installs agentctl-hostop-coordinator.service — one marker,
host.webview_gateway, answers "which host runs the coordinator", so the two are installed
and removed together. Their runtime lifecycles stay independent, and the coordinator's bounce
is deliberately not reported as a gateway bounce (it has no registrants to cascade to). See
Architecture § Durable host operations.
Diagnosing a cockpit that connects but never streams¶
Work down the chain; each step distinguishes failures the previous one cannot.
1. Are both units up, and does the ledger contain the identity you log in as?
agentctl webview-gateway status # unit, user_email→agent ledger, coordinator health
systemctl status agentctl-webview-login.service
The ledger printed here is rebuilt from agents.yaml, not read out of the running process —
so it tells you what the next reconcile would render. If it disagrees with the gateway's
behaviour, the gateway is running on a stale env and needs agentctl apply (or a force
bounce).
2. Is the identity reaching the cockpit at all?
curl -sI http://127.0.0.1:8731/ -H 'X-User-Email: person@example.com'
Run on the gateway host. A 200 here with a failure through the browser puts the fault in the login proxy or your HTTPS proxy, not in the cockpit. Remember that omitting the header entirely is the operator case and will succeed regardless — it proves the cockpit is alive, not that your identity resolves.
3. Is the agent actually registered?
An agent card that shows offline is usually the dial tier, not the gateway:
systemctl status agentctl-webview-dial@<agent>.service
journalctl -u agentctl-webview-gateway.service | grep -E 'register (accepted|rejected)'
rejected register for agent "x" (user_email "…" not authorized) means the frame's identity
does not match the rendered ledger — an agent added since the last gateway reconcile, or a
user_email changed without an apply.
4. Do streams open and immediately close?
agentctl host stream-stats
Live, in-memory, per-route connect/disconnect counters from the running gateway process —
the only way to read that state, since every other diagnostic spawns a fresh subprocess. A
high disconnect count on /events with the cockpit visibly rendering is the signature of
compression or an HTTP/1.1 connection ceiling at the proxy, not of a gateway fault.
5. Are the 403s a scope mismatch or a stale roster?
agentctl terminal status <agent>
agentctl browser status <agent>
Both walk the exact gate sequence /terminal/enter and /browser/enter run and report each
step. allowAgent returns byte-identical 403s for a caller-scope mismatch and for a gateway
admin roster that has drifted from the ledger; this is what tells them apart. The roster
check reports UNKNOWN rather than a green tick when it genuinely cannot read the running
process's memory — take that literally.
For a request that fails end to end, grep one X-Request-Id across the browser's network
tab, the login proxy's journal and Caddy's access log.