Skip to content

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.

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:

  1. 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.
  2. Run apply at least once for a new agent. A freshly-added agent has no marker until its first apply; agentctl terminal status <agent> confirms it landed and the unit is startable.
  3. 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).
  4. 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.