Webview API¶
The seam between the cockpit frontend (and the mini-apps inside it) and the agentctl backend. There are two halves, and each has exactly one authoritative source:
| half | authoritative source | drifts when |
|---|---|---|
the HTTP surface, /api/… |
the mux.HandleFunc block in startWebServer, internal/cli/top_web.go |
a route is added or renamed |
| the WS frame protocol | FRAME_KINDS in runtime/channel/protocol.ts |
a frame kind is added |
Read the source for the request bodies field by field; this page gives the shape, the
method, and the scope each route requires. Who the caller is — how X-User-Email is
established and why a missing header is more privileged than a present one — is
The webview gateway.
What an attacker holding a cockpit session reaches is
Security model.
The four scopes¶
Every scope decision in the cockpit derives from scopeFor, which reads X-User-Email and
resolves it through the gateway's user_email → agent ledger. There is no request value
anywhere in the chain, so there is nothing for a caller to claim. Four outcomes:
| scope | how it arises | reaches |
|---|---|---|
| operator | no X-User-Email at all (scoped=false) |
everything, unfiltered |
| admin | header maps to an agent in the WEBVIEW_ADMINS roster |
every agent on the host, plus the agent-less host surfaces |
| owner | header maps to exactly one agent | that agent only |
| stranger | header authenticates but maps to no ledger entry (scope "") |
nothing — deny-all |
Three predicates read those facts, and the column in the tables below names which one a route uses:
allowAgent(name)— the per-agent gate. Operator passes; stranger is denied for every name including the empty one; owner passes only for their own agent; admin passes for any.fleetView— operator or admin. Used where a route is host-wide rather than per-agent.adminScope— admin only, never the operator. Used to decide filtering, not access.
The stranger case is the one with history: allowAgent's earlier form compared
name != scope, so a request with agent="" satisfied "" != "" as false and passed,
reaching the agent-less def handlers and from there a traversal read of root-owned files.
top_web_scope_test.go pins that closed and pins that adminScope can never answer true for
the empty scope.
Session, transcript and turns¶
agent is a query parameter on the GETs and a body field on the POSTs. session selects a
subsession lane; empty means the primary lane. host is vestigial on every route that takes
it — remoteTarget always returns "" since the multi-host collector retired.
| route | method | request | response | gate |
|---|---|---|---|---|
/api/snapshot |
GET | — | fleet snapshot JSON | scopeFor + adminScope; an owner's copy is filtered by scopeSnapshot to their own card, an admin's is verbatim |
/events |
GET | — | SSE, snapshot deltas | scopeFor + adminScope, same filtering |
/watch |
GET | agent |
SSE, one JSON fleet.Event per frame, 12-event seed |
allowAgent |
/api/transcript |
GET | agent, session |
the same SSE feed, 200-event seed | allowAgent |
/api/history |
GET | agent, before, limit |
JSON page of older events | allowAgent; before must match the cursor grammar <digits>[:<digits>] |
/api/pending |
GET | agent, session |
JSON []fleet.PendingTurn — sent but not yet in the transcript |
allowAgent |
/api/sessions |
GET | agent |
JSON session registry, main + subs | allowAgent |
/api/subagents |
GET | agent, session |
JSON running/finished subagents | allowAgent |
/api/session/list |
GET | agent, since, limit |
JSON sessions active in a window | allowAgent |
/api/session/timeline |
GET | agent, session |
JSON tool-call waterfall | allowAgent |
/api/fsevents |
GET | agent, session |
SSE, one fs_event frame per line |
allowAgent; gateway-mode only, 501 otherwise |
/api/inject |
POST | {agent, host, text, session, uploads[], message_id} |
{ok, …} |
allowAgent, resolved before the agent name is used to read any path |
/api/permission |
POST | {agent, session, request_id, behavior} where behavior is allow|deny |
{ok, …} |
allowAgent; gateway-mode only |
/api/agentmsg/engage |
POST | {agent, …} |
{ok, …} |
allowAgent — an ownership check on the receiving agent; it grants the sender nothing |
/api/inject takes JSON only. Attachments are named by tus upload id, never inlined —
multipart was removed because it cannot be chunked, resumed, retried or measured.
Subsession control is five routes over one handler (subsessionControl), all
POST {agent, title, new_title?}, all allowAgent, all gateway-mode only. They op-relay
subsession <verb> … self to the agent, which routes it to its own local agentd:
/api/subsession/start · resume · clear (stop, keep transcript) · rm (deregister, keep
transcript) · rename (label only — sid, unit and transcript untouched).
Workspace¶
The read routes run locally or over the agent's dial-in; the mutating ones are op-relayed to
the agent, which executes them as itself. All are gated by allowAgent on the agent
parameter, which is what binds a path to its owner.
| route | method | request | response | notes |
|---|---|---|---|---|
/api/ls |
GET | agent, path |
JSON directory listing | the file tree's seed; /api/fsevents is the follow |
/api/file |
GET | agent, path, hash=1 |
text/plain, or JSON when hash=1 |
|
/api/raw |
GET/HEAD | agent, path, dl |
file bytes, content-type by extension | serves Range requests; caches byte length only, never content |
/api/render |
GET | agent, path |
text/html |
server-side Markdown render |
/api/inbox |
GET/HEAD | agent, file | fileid |
inbound-attachment bytes | chat thumbnails |
/api/write |
POST | agent, path, base |
{…} JSON |
base is the optimistic-concurrency precondition |
/api/mkdir |
POST | agent, path |
{…} JSON |
|
/api/rm |
POST | agent, path, recursive=1 |
{…} JSON |
|
/api/mv |
POST | agent, src, dst |
{…} JSON |
Uploads — /api/tus/. The only upload path. tus 2.x resumable protocol: POST creates,
HEAD reports offset, PATCH appends, DELETE discards, OPTIONS reports capabilities.
tusGate runs outside tusd's own middleware, so no request reaches the store — not even
to learn whether an id exists — before scope is checked. Two details are load-bearing:
X-HTTP-Method-Overrideis refused, not emulated. tusd honours it on aPOSTand rewritesr.Methodafter the gate has run, so aPOSTto someone else's upload id carryingOverride: PATCHwould be authorised as a create and executed as a patch.- An unresolvable id returns 404, not 403, so the endpoint is not an oracle for which upload ids exist.
Office viewer. /api/office/open (POST, stage a workspace docx/xlsx/pptx into
OneDrive), /api/office/pdf (GET, PDF bytes for the <embed>), /api/office/preview (GET,
Office-for-web embed URL), /api/office/pullback (POST, reconcile edited bytes back to the
local file — fired by the client on blur/close/beacon). All take agent + path and gate on
allowAgent. The cockpit tracks which files a live session has open so that the main-SSE
close can reconcile them if the browser disappears.
Mini-apps and the data plane¶
| route | method | request | response | gate |
|---|---|---|---|---|
/api/apps |
GET | agent |
JSON active apps (installed × wired) | allowAgent, then keepAdminApps(…, fleetView) |
/api/apps/<id>/bundle.js |
GET | agent |
the app's component JS | allowAgent |
/api/mcp/wired |
GET | agent |
JSON wired-MCP set | allowAgent |
/api/mcp/read |
POST | {agent, mcp, tool, args} |
raw tool JSON | read-only predicate then allowAgent |
/api/mcp/call |
POST | {agent, mcp, tool, args, timeout} |
raw tool JSON | allowAgent then the risk catalog + per-tool arg narrowing |
A manifest's admin: true only filters the launcher — keepAdminApps hides an icon. It is
not a boundary; the endpoints an admin app reads are gated independently.
/api/mcp/read is the read-only plane. isReadOnlyMCPInvocation is checked in this
handler and again in the mcp read verb the op-relay carries. Note the ordering: the
read-only check runs before allowAgent, so a non-read-only tool name is refused with 403
regardless of who asked — the caller learns nothing about the agent from the refusal.
/api/mcp/call is the action plane, and it is narrower than "not read-only". Two gates,
both mcpActionToolAllowed, both re-checked verb-side:
- the risk catalog — an allowlist of MCPs. Everything outside it is refused outright and
keeps
mcp readas its only turnless path, so nothing here is reachable for gmail, drive or ms365. - per-tool argument narrowing — an MCP may be reachable with only some of its tools and
only some of their argument keys.
videois unnarrowed;browseris one tool with one argument.
Both planes are turnless: they op-relay to the connected agent, which invokes the tool over
its own MCP socket with no agent turn and no root daemon hop. allowAgent is what makes a
turnless action an action the owner took. The consequence worth naming is in
Security model:
a tool declared risk: ask is reachable here with no confirmation step, because the
confirmation lives in the agent's turn and this plane has none.
Both return 502 with {"ok":false,"msg":"agent not connected"} when the agent has no live
dial-in.
Transport tunnels¶
/browser/ and /terminal/ are not JSON APIs; they are proxies with a two-step entry. Both
follow the same shape:
GET /<mount>/enter?agent=<name>→allowAgent, then resolve the agent's transport, then mint a session token andSet-Cookie. Resolution happens before the cookie is minted, so an agent with no reachable transport returns 404 rather than a cookie that fails later.GET /<mount>/exit→ drop the session, clear the cookie, 204.GET /<mount>/<anything else>→ proxied to the agent's transport, prefix stripped. No cookie means 403, not 404 — a prober learns nothing about whether the agent, the port or the display exists.
The browser cookie is HttpOnly and SameSite=Strict. Strict is measured, not tidy: KasmVNC
1.4.0 requires an Origin header on the websockify upgrade but does not validate it, so a
foreign origin gets a 101 all the same and the cookie is the only thing between a hostile
page and a websocket onto that browser. Secure is set only when the request arrived over
TLS, or a plain loopback cockpit would drop the cookie entirely.
What sits behind these two routes, and why /terminal/ additionally requires a root-owned
admin marker that systemd itself asserts, is in
Security model.
Host and fleet routes¶
/api/agents/restart-self (POST) is self-scope by construction: the target is derived from
scopeFor, and a request that is not scoped to an agent is refused. A client that sends a
different name gets the resolved one echoed back.
/api/agents/restart (POST {agent, host}) restarts one named agent and gates on
fleetView. The check runs before the body is read, so nothing in the request can influence
it and an unauthorised caller learns nothing about which agents exist. validAgentName
rejects the selector keywords all, self and shared — load-bearing, because the local
branch hands the string to agentctl agents restart <target> where all is a real argument.
The remaining host routes are the add wizard and the catalog authoring surface. They do not
write files themselves; each shells an agentctl verb through runVerb, which in the
single-host mode that ships today execs the local binary against the on-box daemon.
| route | method | response |
|---|---|---|
/api/agents/add |
POST | NDJSON progress (phase/step/log/progress/done/error) |
/api/apply-all |
POST | NDJSON, {"t":"hosts"} first, per-host events tagged host, {"t":"all-done"} last |
/api/host/<host>/capabilities |
GET | JSON |
/api/host/<host>/catalog |
GET | JSON catalog browse |
/api/host/<host>/catalog/add |
POST | {ok, msg} |
/api/host/<host>/catalog/remove |
POST | {ok, msg} |
/api/host/<host>/defaults |
GET | JSON |
/api/host/<host>/defaults/set |
POST | JSON |
/api/host/<host>/gateway/install |
POST | NDJSON |
/api/host/<host>/slack-gateway/install |
POST | NDJSON |
These routes carry no scope check of their own
Every route in the table above enforces its HTTP method and validates its body, and then
executes. None of them calls allowAgent, fleetView or scopeFor, so unlike
/api/agents/restart directly above them they do not distinguish an operator or an admin
from an owner or from an authenticated stranger. The cockpit only offers them to a
fleetView caller, but that is a launcher decision in the browser, not a server-side gate.
The reasoning they were written under is recorded in top_defaults.go: the web layer
never writes files itself, so the daemon "performs the privileged write+commit and its
peercred gate keeps overlay editing operator-scoped". That gate is real, but it reads the
uid of the process running the verb, not the identity of the HTTP caller. The verb
here is run by the cockpit, and agentctl-webview-gateway.service carries no User= —
so the uid agentd sees is root, which is operator scope by definition. The daemon check
therefore cannot separate one cockpit caller from another; it only confirms that the
cockpit is the cockpit.
Treat the reachability of the cockpit port as the effective control on this group, and read The webview gateway § Is :8731 genuinely loopback-bound? for who that leaves.
/api/def (GET agent, kind, name, host, path=1|json=1|catalog=1) is the
exception in this group and shows the intended shape: it gates on allowAgent first, and
then, for the two agent-less lookups that ignore name and read host-global files —
catalog=1 and kind=hostcron — re-checks scopeFor + adminScope so a scoped owner is
refused even though they cleared the per-agent gate. A stranger is denied twice.
PWA and static routes¶
/, /manifest.webmanifest, /sw.js, /icons/ and /fonts/. The index resolves scope and
injects window.__WV_SCOPE__ = {agent, admin} for the shell to paint with; it grants nothing,
because every handler re-derives the same facts per request. An authenticated principal the
ledger has no entry for gets a real 403 that never touches the cockpit shell — no scope
injection, no version string, no boot gate — because the earlier behaviour served a
live-looking cockpit whose every data call then 403'd, which reads as "broken" rather than
"not authorised". The identity is echoed into that 403 page and is HTML-escaped.
The manifest is per-agent (name and start URL follow the scoped agent) so an installed PWA is
that agent's. The login proxy lets these five prefixes through unauthenticated and deletes
any X-User-Email on that path — see
The webview gateway.
WS frames¶
FRAME_KINDS in runtime/channel/protocol.ts is authoritative, and it is not a typechecker
convenience: ws.ts consults isFrame() on the live inbound path and drops anything not
in the set. A frame declared only in a handler's switch never reaches that switch. The 31
kinds:
inbound, outbound, permission_request, permission_reply, attachment_fetch,
attachment_result, register, hello, reject, op, op_result, op_cancel,
host_op_request, host_op_result, host_op_dispatch, host_op_cancel, host_op_sync,
host_op_receipt, host_op_output, host_op_terminal, host_op_advertise, fs_event,
browser_tunnel, terminal_tunnel, host_shell_tunnel, oauth_callback,
oauth_callback_result, agent_message, agent_roster, agent_message_result,
agent_message_deliver.
Frames are newline-delimited JSON, one per line. isFrame is shallow — it validates that t
is a known kind and nothing deeper, because the socket is same-host and 0600, so it guards
against corruption rather than against an adversary.
Four groups are worth knowing as groups:
op/op_result/op_cancelcarry the op-relay every workspace, MCP and subsession route above runs on. The cockpit sendsopwith a sequence id; results route back to the waiting request by that id. Chunks are base64-encoded so binaryws rawandws renderoutput survives the JSON frame.host_op_*(nine kinds) are durable host operations, which survive a gateway restart and a target reconnect. Adding one has four homes — see Architecture.*_tunnel(browser,terminal,host_shell) carry the transport tunnels the/browser/and/terminal/routes redeem.agent_message*andagent_rosterare agent-to-agent messaging.agent_message_deliverputs a card on the receiving owner's screen; only/api/agentmsg/engageturns it into a turn.
register carries the authoritative routing key and is the frame the gateway authorises a
dial-in on — see
Channels for lanes
and The webview gateway
for what that check is and is not.