Skip to content

The webview gateway

Three processes sit behind the browser cockpit.

process listens on reached by decides
agentctl top --gateway — cockpit + WS router 127.0.0.1:8731 and 0.0.0.0:8735 the login proxy on loopback; agents on 8735, from any host which agent a request is scoped to
agentctl-webview-login.service — login proxy :8736, all interfaces browsers, through the HTTPS proxy in front who the person is; sets X-User-Email
an HTTPS proxy — the Caddy apply manages, or your own 80 and 443 the internet TLS, and whether :8736 is publicly reachable

webview-gateway install installs the first; webview-login install installs the proxy in front. The third is a choice: let agentctl install and converge Caddy on this host, or run your own proxy and meet the requirements below. It is not optional either way — the session cookie is Secure. See TLS and Caddy, Quick start, The cockpit and Channels.

Identity and scope

The cockpit performs no authentication. scopeFor (internal/cli/top_web.go) lowercases X-User-Email and looks it up in the user_email → agent ledger. No signature, cookie or peer address is checked.

X-User-Email result
absent or empty scope "" — denied everything. A login proxy in front is therefore mandatory
present, in the ledger scoped to that agent
present, not in the ledger scope "" — denied everything

An absent header used to be scoped=false, the "unscoped operator", with a full-fleet view — so a missing header was more privileged than a present one. It is now the same deny-all answer an unknown address gets, and scopeFor returns a single string with no unscoped value left to represent.

The login proxy uses Header.Set, so a browser-supplied X-User-Email is overwritten.

Any local uid on the gateway host can still curl http://127.0.0.1:8731/ with an X-User-Email it does not own and be believed — the listener checks no peer address and the header carries no signature. What that costs is now bounded by which address is asserted rather than being free, but it is not closed. The control is a host firewall rule on 8731, or a gateway host that runs nothing else. agentctl asserts neither.

Binds and peer checks

listener bind peer or origin check
cockpit :8731 127.0.0.1, forced in code none — handlers ignore r.RemoteAddr
dial-in :8735 0.0.0.0 none — WebSocket accepted with InsecureSkipVerify
stream-stats, /_internal/oauth-callback 0.0.0.0 loopback peers only
login proxy :8736 all interfaces none — it is the authenticator

The cockpit bind cannot be widened: webListenAddr rewrites the host part of --web to 127.0.0.1, and the rendered unit passes no --web. The nftables guard covers per-agent bands only (converge.PortBands), so 8731 and 8736 carry no rule — see Ports and the port guard § Host port map. Verify each host with ss -ltnp | grep -E '873[1-6]'.

Cockpit login: webview-login

WEBVIEW_AUTH_DRIVER selects the driver. This flow is not PKCE and is not tied to one identity provider.

key driver value
WEBVIEW_AUTH_DRIVER both basic or oidc (the default when unset; entra and google are accepted spellings of oidc)
WEBVIEW_BASIC_USERS basic user:password,user:password, one list per host
WEBVIEW_OIDC_ISSUER oidc issuer URL
WEBVIEW_OIDC_CLIENT_ID oidc required
WEBVIEW_OIDC_CLIENT_SECRET oidc required for most issuers
WEBVIEW_URL oidc redirect URI is ${WEBVIEW_URL}/auth/callback
WEBVIEW_HMAC_KEY both signs session, csrf and oidc-state
WEBVIEW_SESSION_TTL both any Go duration; 30 days by default
WEBVIEW_TRUSTED_PROXIES basic comma-separated IPs/CIDRs allowed to set X-Forwarded-For/X-Real-IP for rate limiting; default is loopback-only

The OIDC principal is the email claim, then preferred_username, then upn. Entra and Google are configuration, not code.

A driver that cannot be built refuses to start rather than serving a weaker login: an oidc driver missing WEBVIEW_OIDC_ISSUER or WEBVIEW_OIDC_CLIENT_ID, a basic driver with no entries, and a basic entry with an empty password all fail at startup — so a trailing colon in WEBVIEW_BASIC_USERS cannot open the host.

Both drivers end at a wv_session cookie holding base64(purpose|expiry|principal).base64(HMAC-SHA256), HttpOnly + Secure + SameSite=Lax. verifyToken compares the purpose in constant time and refuses an empty principal. Rotating WEBVIEW_HMAC_KEY invalidates every live session on the host.

basic driver behaviour

case behaviour
browser login themed HTML form at /auth/login
script login WWW-Authenticate: Basic, chosen when Accept lacks text/html
entry with an empty password refused at startup
form rate limit 8 failed attempts/min per client, one bucket shared with the Authorization: Basic path
client identity the TCP peer, unless that peer is listed in WEBVIEW_TRUSTED_PROXIES — only then are X-Forwarded-For/X-Real-IP believed
preemptive Authorization: Basic shares the same bucket as the form; not exempt

A caller reaching :8736 directly cannot mint a fresh bucket per attempt by forging X-Forwarded-For — that header is trusted only from a listed proxy. Leaving WEBVIEW_TRUSTED_PROXIES unset behind a remote (non-loopback) proxy is safe but blunt: every client behind that proxy shares one bucket.

Logout and unauthenticated paths

path behaviour
/auth/logout clears the cookie; registered once for all drivers
OIDC logout ends this application's session only; an IdP with an SSO session re-issues
/manifest.webmanifest, /sw.js, /icons/, /fonts/ bypass auth; proxied with X-User-Email deleted

RP-initiated logout at the issuer is not wired.

Agent login to its model provider

The auth card a fresh webview agent shows on its first turn is a different, PKCE flow. webview-login is not involved.

piece role
agentctl-webview-dial@<agent>.service always-on tier; delivers the authorization URL while the agent is logged out
agentctl login claude <agent> takes the pasted code#state and exchanges it as the agent's uid
agentd SO_PEERCRED the dial unit runs as User=%i, so it can only log in itself
agentctl auth-broker (:8734, optional) public /oauth/callback; verifies its own HMAC state and relays the code

The broker removes the paste step, holds no durable secret and never stores a code.

Dial-in: how an agent registers

Agents connect outward to :8735 and stay connected, so a cross-host agent needs no inbound path and no shared filesystem. The cockpit injects turns and runs ops over that live session.

webviewRegisterURL decides what the agent dials:

condition URL
gateway is this host, or no host.webview_gateway marker ws://127.0.0.1:8735
gateway is another host ws://<gateway-host>:8735

It reaches the agent as CHANNEL_WEBVIEW_GATEWAY_URL in agent.env / pi-agent.env, next to CHANNEL_USER_EMAIL.

The transport proves nothing about the caller. Authorization is on the register frame:

ident := lower(trim(frame.user_email))
if ident == "" || userLedger[ident] != frame.agent { close(PolicyViolation) }

user_email is an identifier, not a secret. Anyone who reaches :8735 and knows an agent's login identity can register as that agent and receive its inbound frames. The network is the primary control; the ledger check is a second gate. Known open gap.

Four more endpoints share the listener, since an agent already dials out to :8735: attachment pull, /browser/ and /terminal/ tunnel redemption, and the host-shell tunnel.

WEBVIEW_LEDGER and WEBVIEW_ADMINS are rendered into the gateway env file from agents.yaml at apply time, not read live. WEBVIEW_ADMINS is always emitted, even as []; its absence means an agentctl too old to render it.

Reverse-proxy requirements

Whatever terminates TLS in front of :8736 has to do three things.

requirement reason symptom if ignored
Serve HTTP/2 a tab holds 4 SSE streams: /events, /watch, /api/transcript, /api/fsevents HTTP/1.1 caps 6 connections per origin, so a second tab queues everything
Exclude those 4 paths from compression the proxy decides before the upstream produces a content type gzip buffers; the stream stalls or never flushes
Forward X-Request-Id ties browser, proxy journal and access log to one request no correlation across the three logs

apply installs Caddy on a gateway host and renders all three, injecting header_up X-Request-Id {http.request.uuid} — including the certificate, which Caddy obtains and renews itself unless one is in the secret store. That is the default path and TLS and Caddy covers it end to end.

Running your own proxy instead is equally supported; the three rows above are then yours to satisfy. Reproduce the compression exclusion:

@compressible {
    not path /events /watch /api/transcript /api/fsevents
}
encode @compressible zstd gzip

The cockpit sets X-Accel-Buffering: no on stream responses, which nginx honours — a hint to one proxy, not a substitute for configuring your own.

Cookies and headers that must pass through unmodified

webview-login sets and reads five cookies, all HttpOnly + Secure + SameSite=Lax. The proxy must forward the browser's Cookie: request header and the backend's Set-Cookie: response header untouched — not stripped, not rewritten, and not filtered by an allowlist that omits any of them.

cookie set by lifetime carries
wv_session both drivers, at the end of login WEBVIEW_SESSION_TTL, 30 days by default the session; every authenticated request presents it
wv_oidc_state oidc driver, at redirect 10 minutes the state for the /auth/callback leg only
wv_login_csrf basic driver, with the login form the form POST; path /auth/login the CSRF token the form POST is checked against
wv_refresh_ref inline refresh 90 days an opaque lookup id — never the refresh token itself
wv_wa_device passkey register and login 2 years the marker saying this browser already enrolled a passkey

X-WebAuthn-State has to survive in both directions too: the passkey flow returns it on the challenge response and reads it back off the next request. A proxy that drops unknown response headers breaks passkey login at the second step while the first step appears to succeed.

A proxy that authenticates on its own

Where the layer in front runs its own SSO — its own identity provider, its own session cookie — and proxies requests that are already authenticated, agentctl's login, session cookie and refresh machinery are not in the authorization path for ongoing API calls at all. Two consequences:

  • That layer must set X-User-Email on every proxied request. It is trusted unconditionally — see Identity and scope. A request that arrives without it is denied everything, whatever the upstream session says.
  • That layer's own session timeout governs how long a user stays logged in, not WEBVIEW_SESSION_TTL. When users are forced to re-authenticate more often than expected in this shape, check the SSO layer's session duration first. agentctl can neither see it nor control it.

The tell is a request whose Cookie: header carries only the upstream layer's own session cookie, with no wv_session at all, that still reaches webview-login — the response carries X-Request-Id — and succeeds.

What apply reconciles

Both units reconcile on every apply and fleet sync where installed, re-rendering unit body and env file and restarting only on a change.

case behaviour
--force both restart regardless
deb upgrade both force-bounce — the unit body is unchanged, the binary behind it is not
gateway restarted apply then restarts every local agent that has a user_email
WEBVIEW_HMAC_KEY reads back empty renderWebviewLoginEnv writes nothing and leaves the old env in place

The restart cascades because an older shim sends an empty identity that a newer gateway rejects. An empty WEBVIEW_HMAC_KEY read means the sops store is unreadable on this host.

The gateway also carries cross-host traffic: host jobs and agent administration route through it over each target's own dial-in connection. What a host needs for that is agentctl-host-dial.service, which holds the host's own connection to the gateway using the host's age key. It is a per-host unit, not a per-agent one, so every host gets exactly one — including a host with no agent living on it.

Diagnosing a cockpit that connects but never streams

Each step distinguishes failures the previous one cannot.

  1. Both units up, and the ledger holds your login identity?
agentctl webview-gateway status          # unit, user_email→agent ledger
systemctl status agentctl-webview-login.service

The printed ledger is rebuilt from agents.yaml, not read from the running process. A disagreement with observed behaviour means a stale env — run apply.

  1. Does the identity reach the cockpit?
curl -sI http://127.0.0.1:8731/ -H 'X-User-Email: person@example.com'

Run this on the gateway host. A 200 here with a browser failure puts the fault in the proxies, not the cockpit. Omitting the header is the operator case and always succeeds.

  1. Is the agent registered?
systemctl status agentctl-webview-dial@<agent>.service
journalctl -u agentctl-webview-gateway.service | grep -E 'register (accepted|rejected)'

An agent card showing offline is usually the dial tier. rejected register … (user_email "…" not authorized) means the frame's identity is absent from the rendered ledger.

  1. Do streams open and immediately close?
agentctl host stream-stats

Live per-route connect/disconnect counters from the running process. High disconnects on /events while the cockpit renders means compression or an HTTP/1.1 ceiling.

  1. Is a 403 a scope mismatch or a stale admin roster?
agentctl terminal status <agent>
agentctl browser status <agent>

Both walk the gate sequence of /terminal/enter and /browser/enter and report each step, because allowAgent returns byte-identical 403s for the two causes. A roster check reporting UNKNOWN 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 network tab, the login proxy journal and Caddy's access log. The login proxy generates that ID when the header arrives empty and echoes it on the response.