Terminal access¶
/terminal/ is a browser shell that runs as the agent's own unix user, framed by the
cockpit's terminal mini-app. Nothing runs at rest — only an authorized request starts the
backend. For the root maintenance shell, see Host maintenance shell.
| systemd unit | terminal@<agent>.service, one host-global template |
| shell runs as | the agent's own unix user, with its HOME and its PATH |
| enabled by | nothing per-agent; apply writes the marker for every agent unconditionally |
| default | on, once apply has run for the agent once |
| marker file | /etc/agentctl/terminal/<agent>.admin, root:root 0644 — the .admin name is a holdover and no longer means the agent holds the admin role |
| socket | /run/terminal-<agent>/tty.sock, in a 0700 dir owned by the agent |
| tmux session | shell |
| backend | vendored static ttyd, pinned in internal/converge/versions.go |
| TCP port | none; ttyd runs with -i <socket> and no -p |
| audited | no |
The unit has no [Install] section, so systemctl enable fails on it. It carries no
ProtectSystem, PrivateTmp or NoNewPrivileges.
Opening a session¶
Open the cockpit as the agent's own owner, then click Terminal in the launcher — every
applied agent has one; the icon is shown unconditionally, not gated by role.
The frame issues GET /terminal/enter?agent=<name>; everything after that is ttyd's own
client and websocket, reverse-proxied unmodified.
| handled by the cockpit | /terminal/enter, /terminal/exit |
| proxied | every other path under /terminal/ |
| prefix | ttyd runs with -b /terminal, so the proxy rewrites nothing |
| verbs for a human | none. terminal up and terminal tunnel are machine-invoked; terminal status is read-only |
What gates a session¶
| # | gate | enforcement |
|---|---|---|
| 1 | cockpit identity gate | allowAgent resolves scope from the X-User-Email header; an admin identity is allowed every agent at /terminal/enter, and a request with no header resolves to the empty scope and is refused |
| 2 | terminal marker | written by converge.AppendTerminal for every agent, unconditionally; no removal path — its absence means apply has not run for this agent yet, not "not admin" |
| 3 | AssertPathExists= on that marker |
terminal@.service; a start fails in systemd for an agent apply has never run for, with no agentctl involved |
Nothing in the request participates in gate 1's decision. Gate 3 survives the cockpit being
wrong, bypassed or replaced. These three gates are the whole of the enforcement. The cockpit
launcher's Terminal icon is not a gate: it is shown unconditionally for every applied
agent, regardless of role, and hiding or showing it changes nothing about what a session can
reach.
The session cookie¶
| name and path | agentctl_terminal, Path=/terminal |
| flags | HttpOnly, SameSite=Strict, Secure when the request arrived over TLS |
| TTL | 30 minutes |
| carries | the target agent, never authority |
| re-checked | scope and marker, on every proxied request |
A stolen cookie names an agent the thief must still be authorised for. Revoking the admin
role does not close a terminal session — see There is no revoke
below; the marker is the only thing that gates it, and nothing currently removes that for a
live agent. SameSite=Strict is load-bearing: ttyd runs without --check-origin.
The cockpit does not authenticate the identity header¶
The cockpit resolves the caller's scope from the X-User-Email header (allowAgent). A
request with no header resolves to the empty scope and is refused, and terminalProxyAgent
additionally requires the caller's scope to equal the cookie's agent exactly, consulting
no admin branch.
What the cockpit does not do is authenticate that header: it checks no peer address and
verifies no signature. A co-resident uid can assert an X-User-Email it does not own; to
reach a shell that way it must name the exact owner of an agent apply has run for — which
is every applied agent, so the marker's presence does not narrow the set the header lets
through. This is the trust gap left in the request path. Put a login proxy in front of the
cockpit, keep the port on loopback, and prefer a gateway host that runs nothing else.
Unit settings¶
| setting | effect |
|---|---|
User=%i, WorkingDirectory=/home/%i |
the shell is that agent's own login shell |
RuntimeDirectory=terminal-%i, mode 0700 |
one flat per-agent directory, never a group-writable shared parent |
RuntimeDirectoryPreserve=yes |
keeps the tmux control socket after ttyd exits on disconnect |
AssertPathExists= ×3 |
/usr/local/bin/ttyd, /usr/bin/tmux, and the terminal marker |
Restart=no |
a clean exit is the normal end of every session |
KillMode=process |
stops ttyd, leaves tmux; systemctl status can read inactive while the session lives |
RuntimeMaxSec=12h |
backstop for an enter whose viewer never connected |
TasksMax=256 |
ttyd -i /run/terminal-<agent>/tty.sock -b /terminal -W -q \
tmux -S /run/terminal-<agent>/tmux.sock new-session -A -s shell 'exec "$SHELL" -l'
-W makes the terminal writable; ttyd is read-only by default. -q (--exit-no-conn)
exits the instant the last client disconnects, with no grace period.
The proxy starts the unit rather than using socket activation, because ttyd has no
sd_listen_fds support. terminalEnsureUp runs systemctl start and then dials the
socket until it accepts, with a 5 s budget. A stat is not enough: a preserved runtime
directory can hold a stale socket inode.
terminal up¶
agentctl terminal up [self] is proto.Mutating, self-scope, peercred-gated. It exists
because an agent's unix user cannot systemctl start a system unit, and the cross-host
tunnel path needs it. The privilege delta is zero: the unit runs $SHELL -l as that same
uid, on a socket in that same uid's 0700 directory. A caller whose agent has never been
applyd still passes this daemon gate and then gets a start systemd refuses — the marker,
not a role, is what's missing.
Cross-host¶
An agent on another host is reached over the same tunnel /browser/ uses. Locality, not
connectivity, selects the transport: an agent on this host takes the unix socket even when
it is also dialed in.
| doorbell frame | the token and nothing else; the handshake back is ok/error, with no port |
| marker re-check | on the agent's own host (terminalTunnelUp → terminalAdminOK), where the marker lives |
| cache | 30 s (terminalRemoteTTL), also the idle timeout on pooled tunnel connections |
| marker-disappearance lag | up to 30 s cross-host; immediate locally, where the marker is re-stat'd per request — moot in practice, since nothing removes a live agent's marker (see There is no revoke) |
/terminal/exit |
does not stop a remote unit; a closed tab is still reaped by --exit-no-conn |
| unit status | the cockpit cannot report on a remote unit, and says so (terminalUnreachable) |
There is no revoke¶
Current behavior: agentctl agents admin <agent> --revoke does not touch the terminal
marker. converge.AppendTerminal always writes it and has no removal branch for an
individual agent. Revoking the admin role still does everything else revoke does — fleet
operator scope at agentd, the overlay-write ACL — see
The admin role; the agent's own terminal keeps working
regardless.
There is no per-agent or fleet-wide kill switch for the terminal at all (a deliberate
design choice, not an oversight). agentctl agents remove <agent> does not clean the
marker up either — it is not in that command's removal list — so a torn-down agent's
.admin file is orphaned in /etc/agentctl/terminal/, harmless because nothing can start
a shell for a uid that no longer exists. The only lever that ends a session is stopping the
live unit, on the agent's own host:
systemctl stop terminal@<agent>
That ends the PTY. It does not, and cannot, block the next enter from starting a fresh
one.
Session lifetime and reaping¶
| transport vs session | --exit-no-conn kills ttyd on disconnect, KillMode=process leaves tmux; a reconnect re-attaches to work in progress |
| two viewers | tmux new-session -A without -D shares one screen; the session sizes to the smallest attached client |
| reaper | agentctl-shell-reap.timer runs agentctl shell reap every 15 min as root |
| reaper's uid | drops to each socket's own owner with runuser |
| kill condition | unattached and silent past shellSessionIdleTTL = 8 hours |
| activity | updates on output as well as on input, so a long build is never reaped while producing |
| scope | globs /run/terminal-*/tmux.sock, so a removed agent's socket is collected too |
Diagnostics¶
agentctl terminal status <agent> [--caller <scope-agent>]
Read-only. It walks the same gate sequence the real path walks, in the same order, and starts nothing.
For a cross-host agent it reports the terminal marker as unknown, and its tunnel probe as
unknown in both directions. It is a one-shot process with no dial-in registry, so a "not
connected" result there proves nothing. Check systemctl status
agentctl-webview-dial@<agent> on the agent's own host instead.
Before you rely on it¶
There is no per-agent enable step — every agent gets the marker unconditionally the next
time apply runs. What is still worth checking:
- Check what authenticates the cockpit. Without something asserting
X-User-Email, every request is refused — and whatever does assert it is the whole of your authentication, since the cockpit verifies no signature and no peer. - Run
applyat least once for a new agent. A freshly-added agent has no marker until its firstapply;agentctl terminal status <agent>confirms it landed and the unit is startable. - Know there is no revocation. Neither a live websocket nor a fresh session can be
authorised away —
systemctl stop terminal@<agent>is the only thing that ends a session, ever, for any agent (see There is no revoke above). - Serve the cockpit over TLS, or a 30-minute shell cookie rides plaintext.
What the admin role adds here¶
Nothing, for reaching your own terminal — every applied agent has the marker regardless of
role. For reaching another agent's terminal: allowAgent lets an admin identity mint a
session cookie naming another agent at /terminal/enter, but terminalProxyAgent consults
no admin branch on the requests that follow (see
above), so that mint does not, in
practice, buy a working session. terminal up self is unconditionally self-scope for every
agent and is not role-gated.
Compromising an admin agent is still equivalent to compromising the fleet — through
agentd's operator scope, not through this route: agentctl on that agent's own PATH
resolves with that agent's own fleet-wide scope. See
The admin role and Host maintenance shell.