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-Emailon 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.agentctlcan 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.
- 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.
- 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.
- 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.
- 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.
- 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.