Skip to content

agentd and the unit model

agentd is the small root daemon that owns config and secrets, runs the reconcile, and is the privilege boundary. It exists so that an agent's unprivileged Linux user can request a root-requiring operation without holding root, and without a sudoers drop-in that would give it one. The CLI is a thin client to it.

It ships as /usr/bin/agentd with /lib/systemd/system/agentd.service (Type=notify, Restart=on-failure), enabled by agentctl provision. It refuses to start as anything but uid 0 — it owns /etc/agentctl, the age key, and the reconcile.

The socket

agentd creates /run/agentctl.sock itself at startup, after removing any stale one, then chowns it to group agents and chmods it 0660. Agent users are created with useradd --groups agents, so every agent on the host can connect and nothing else can.

If the agents group does not exist — an unprovisioned host — the socket is left root:root. That is the safe direction: only root connects.

The mode is a coarse first gate, not the authorization. A uid outside the agents group fails at connect(2) with EACCES before the daemon sees anything; the CLI reports that as the socket being unreachable and suggests checking whether agentd is running, which is misleading wording for a permission failure but is a closed door either way.

SO_PEERCRED: what actually identifies the caller

There is no token, no caller field, and no header. proto.Request carries a verb, args, and optional stdin — nothing that names who is asking.

On accept, before reading the request, the daemon calls syscall.GetsockoptUcred(fd, SOL_SOCKET, SO_PEERCRED) and keeps Ucred.Uid. The kernel stamped that uid onto the socket at the client's connect(2); the client cannot set it, change it, or lie about it. That uid is the entire trust root.

The uid is then resolved to a username through the passwd database (best-effort). The name is used for two things: comparing a verb's target against the caller, and being threaded into the executed reconcile as AGENTCTL_CALLER. It is never used to decide scope — the uid is.

Two scopes, resolved per request:

  • Operator (full) scope — uid 0, or the uid of an agent flagged admin: true in agents.yaml. Admin resolution goes name → user.Lookup → uid comparison, and agents.yaml is re-read on every request, so a promote or demote takes effect without restarting the daemon. See The admin role.
  • Self scope — every other uid. An allowlist of verbs, each of which must name the caller itself.

Scope and identity are deliberately separate. Only true root gets an empty AGENTCTL_CALLER; an admin agent has operator scope but keeps its own name, so its login self / agents restart-self still resolve to the right agent.

peerCred has a non-Linux build-tag stub that always returns an error. The daemon only runs on Linux — SO_PEERCRED has no portable equivalent — and the stub exists so the package compiles and tests on a macOS dev box. It fails closed: no verifiable uid, nothing authorized.

The verb surface

internal/proto/verbs.go classifies every canonical verb. Classification, not the daemon, decides whether a call leaves the client's process at all.

Class Count Where it runs
Mutating 108 Always through agentd. Authorized, queued, re-exec'd as root.
PrivilegedRead 48 Locally for a self target; through agentd only for a peer or fleet-wide target.
everything else In-process in the client, no daemon hop.

Two entries in that table are load-bearing and easy to misread:

  • Every secret subcommand is Mutating, including get and list. Not because they mutate, but because agentd owns the age key: if they ran locally in an agent's client they would bypass the gate entirely. The classification is the security property.
  • provision is deliberately absent from Mutating. It installs the daemon, so it can never route through it. It runs locally as root.

A privileged read is answered outside the reconcile queue and outside the converge lock — reads must neither block nor be blocked by mutations — and is authorized to operator scope before being re-exec'd as root. A non-admin agent asking to read a peer's home is denied at the daemon, mirroring the CLI's own local self-scope gate.

Six verbs are answered by the daemon itself with no reconcile at all: queue, ping, apply logs, auth-register, auth-callback, and host shell attach.

The self-scope allowlist

For a non-operator caller, authorize() is a switch over the verb with a default deny. A verb that appears in no case is refused unconditionally — including for self. That is not an oversight in the verbs it omits; for host shell up, host shell tunnel, host shell grant and host set, the absence is the gate, and a test pins it so a future edit cannot quietly add a case.

What a plain agent may do, and the shape of each check:

Verbs Rule
login, agents restart, agents restart-self, agents stop-self, agents trigger, chrome restart, terminal up, inject, notify, render, render cancel, apply, mcp add/remove, tool add/remove, agents skills\|subagents\|hooks add/remove, subsession start/resume/clear/rm/rename/prompt, ws write/mkdir/rm/mv, attachment fetch Target must be self or the caller's own username. * and all are refused.
cron add, cron remove --host refused; --agent <someone-else> refused. A self-scoped cron is bound to User=<caller> by the daemon.
secret set, secret get, secret remove Must carry --agent self. A bare (unscoped) secret op targets the shared store and is operator-only. The two root-only keys that sign root executions are refused in every scope, including self. secret list is not self-scoped at all.
agent-message Any recipient — the verb exists to address a peer. Only the fleet wildcard is refused. The sender is not in the args; it is the peercred name.
host shell audit-open, host shell audit-close Allowed with no target check. They append a row to the audit trail and start nothing; the actor is derived from the peercred name, not from the args.

Catalog authoring (tool catalog add, tool wrap-mcp, the * catalog add verbs) stays operator-only. Agents activate from the catalog for themselves; the operator curates it.

What happens if you talk to the socket as the wrong user

Every layer fails closed, and none of them execute anything first — the gate runs before the FIFO queue and before any state change.

  1. uid outside the agents groupconnect(2) fails with EACCES. The daemon is never involved.
  2. SO_PEERCRED unreadable — one stderr frame, one exit frame with code 1, before the request line is even read.
  3. uid with no passwd entrydenied: uid N is not a resolvable user.
  4. Non-operator, verb not in the allowlist — denied, with the full self-scope list in the message.
  5. Non-operator, allowlisted verb, but the target is another agent (or * / all) — denied, naming both the caller and the refused target.
  6. agents.yaml unreadable — the admin lookup returns nothing, so no agent has operator scope and every non-root caller falls to self scope.

A denial is one stderr frame and a terminal exit frame, exit code 1. Host-operation denials use 126 instead.

The two exceptions, and what replaces the uid check

Three request kinds are authorized by a signed capability rather than by the caller's uid. This is not a relaxation of the boundary; it is the boundary answering a question the uid cannot answer.

The reason is the same in all three cases. The only process that can legitimately carry these requests is a courier — the target host's own agent connector, which runs under agentctl-webview-dial@.service with User=%i, i.e. an ordinary agent uid, deliberately and non-negotiably (an always-on connector must not run as root). A peercred operator check refuses exactly that process, and did: every cross-host shell hung silently until the gate moved.

  • host shell tunnel is routed to authorizeHostShellTunnel instead of authorize(). The gate is a hostop.ShellGrant signature bound to this host's own name, minted on the source host by root through host shell grant — which is operator-only, by its absence from the switch above. So the operator check was relocated to the end of the chain where an operator actually stands, not deleted. The gate also strips --gateway from the caller's args: a grant says a tunnel may be opened to this host, and says nothing about where the resulting root PTY may be spliced to.
  • host op exec and the durable host-op relay are answered before authorize() and never call it. The gate is an HMAC capability keyed on /etc/agentctl/env/hostop-capability.env (0600 root:root), plus a check that the capability's target names the connected agent. The target check alone is answerable by any agent naming itself, so the signature carries the whole gate — which is sound only while the key is unreadable by an agent. A host that has upgraded but not applied has no key, and refuses every host operation rather than falling back. See Architecture § Durable host operations.

How a client reaches it

The wire protocol is one request per connection: the client writes a single JSON line ({verb, args, async, stdin_b64}) and reads newline-delimited frames back — stdout / stderr / info / accepted — until a terminal exit frame carrying the real exit code.

agentctl picks its route per verb, in runNode:

  • AGENTCTL_DAEMON_EXEC=1 set → run locally. We are the reconcile agentd spawned, already root. Dialing back into the socket would recurse forever.
  • Mutating verb → dial the socket. --async / --no-wait asks the daemon to register the request and return a request id instead of streaming.
  • Privileged read naming a different agent → dial. If the socket is unreachable the client errors rather than falling through to a local read that would return empty on permissions.
  • Everything else → local.

An agent reaches the daemon by running the same agentctl binary as its own user. There is no sudo, no setuid, and no separate agent-facing API — the agent and the operator use one client and are told apart by the kernel. The cockpit's op-relay is the same path once more removed: the gateway asks a dialed-in agent's connector to run a whitelisted verb, the connector runs it as that agent's uid, and it meets this gate like any other call.

Once authorized, agentd re-execs agentctl <verb> <args> as root in its own process group, with AGENTCTL_DAEMON_EXEC=1 and AGENTCTL_CALLER=<name>, and streams the subprocess's stdout and stderr back as frames. If the client disconnects — Ctrl-C, hangup — the daemon cancels the context and kills the whole process group rather than orphaning the reconcile.

Serialization is a separate concern from authorization and is covered in Architecture: an in-process FIFO ticket, then a host-wide flock on /run/agentctl/reconcile.lock that a directly-run apply and a durable host-op worker take as well.

TODO: needs authoring

A page-level treatment of the unit model specifically: the full set of units a provisioned host carries and which layer owns each one (agentd.service; per-agent agent@<name>.service / pi-agent@<name>.service, browser@, xvnc@, openbox@, the agentctl-*-watch@ watchers, the per-agent agentctl-<job>@ timers; the host units — agentctl-hostcron-<label>, agentctl-notify-failure@, agentctl-portguard, the gateways, agentctl-hostop-coordinator and the agentctl-hostop@ / agentctl-subsession@ templates), where each unit file comes from (/usr/share/agentctl/units/{agent,host}/), how the override template is rendered, and what apply restarts versus leaves alone. Grep internal/converge/host.go and packaging/payload/units/ before writing this.