Skip to main content

Node agent

orbit-agent is a small Rust program on every managed Linux Node. It reports that it runs, the state of the Node’s Orbit Processes, and the Git state of the Node’s task checkouts. While someone watches a log live, it also reads that log and sends the lines to the Gateway. The web app shows these reports live, and the Gateway keeps a view of them to skip repeated SSH reads. To build and check the agent, see Bun and Rust projects. The agent service observes and listens on no port, and it does not accept remote execution requests.

Where it runs

The Gateway installs the agent only on active managed Linux Nodes with a WireGuard address and a pinned SSH host key. macOS tool-only Nodes need no agent and hold no agent secret. An install or repair aimed at macOS returns node.agent_unsupported (HTTP 422) before SSH. Only a Node inside the Linux agent boundary runs the agent and holds an agent secret. Exporter selection uses the same boundary. The agent needs no role. A Node that the Gateway does not manage over SSH runs no agent.

What it observes

The agent watches two sources and reports each Orbit Process unit or container by its full name, such as orbit-process-42-web. The values use the same words as the runtime_status field of the Process API. The agent does not know which Process owns a name. The Gateway matches the name to a Process, so a candidate container such as orbit-process-42-web-candidate never matches. The agent does not read Schedules, role services, CPU, or memory. Without Docker, the agent watches systemd only and reports Docker as absent. It checks for the socket every 10 seconds and sends a new snapshot when it appears. When the Docker event stream ends, the agent reconnects and sends a new snapshot. It merges changes to one unit that arrive within 250 milliseconds and sends only the latest state. It sends a heartbeat every 5 seconds and a complete snapshot at least every 60 seconds.

Task workspaces

The agent reports the Git state of each task checkout on its Node, so the Gateway can skip git over SSH. It reads only Git metadata, never file contents or file names, and it starts no program.

Watch list

The agent asks GET /api/v1/agent/workspaces which checkouts to watch, at start and every 60 seconds:
The list holds the Instances on the caller’s Node that hold the workspace of an unfinished task: backlog or todo with an Instance, reserved, running, reviewing, or settling. base is the Project’s default branch. start is the Instance’s starting commit, or null. The list is empty while Tasks is disabled, and it holds at most 64 entries. The agent accepts only a normalized absolute path of at most 4,096 bytes that opens as the root of a Git work tree. A linked worktree works. It never opens a path that the list does not name, and never opens a submodule.

Reading Git

The agent reads Git with libgit2 in its own process, which runs no hooks, filters, or fsmonitor programs. The checkouts belong to orbit and the agent runs as root, so the agent turns off libgit2’s owner check. Every 2 seconds, the agent checks the size, time, and inode of HEAD, index, packed-refs, the current branch ref, the local base ref, and origin/base. When one changed, it reads the checkout again. It also reads every checkout every 30 seconds, because an edit to a working file changes none of those files. A checkout that the agent cannot open or read is left out of its reports, and the Gateway reads it over SSH. The agent publishes the state as client-workspaces and client-workspace events. Realtime events defines them.

Log tails

The agent reads an Instance log or a Process log only while a viewer watches it through a live log stream. It sends the lines only to the Gateway.

Stream list

The agent asks GET /api/v1/agent/log-streams which logs to read. The list holds at most 16 active streams whose source is on the caller’s Node. Each entry has an id, a number of first lines, and a source. The agent fetches the list when it joins its log channel, when the Gateway publishes log-streams.changed there, and every 15 seconds while it reads a stream. The event carries no data, so the agent reads only what the HTTPS response names. It stops every stream that the list does not name. When it cannot fetch the list for 60 seconds, the agent stops every stream and ends each one with client-log-end and reason list_unavailable. It never reads that stream again.

Sources

The agent checks each source itself and runs no program for any of them. Refusing a file that root owns means the agent reads only files that other users may read, inside the Instance root that its unit binds. So a link in a checkout cannot point the read at another user’s file. The journal reader supports the compact and regular formats and zstd and lz4 compression. A field compressed with xz shows as [orbit] entry not readable. When the agent cannot open or keep reading a source, it ends the stream with source_unavailable. The agent redacts each line with the Gateway’s secret patterns before it sends it. Live logs lists the patterns. The agent and the Gateway tests share one table of cases in apps/agent/tests/redaction_cases.json. The agent drops a line that exceeds a rate and reports the count in its next event. It sends log events on a second presence channel, presence-node-logs.{id}, as member agent.{id}. Only the agent and the Gateway’s subscriber join it. Realtime events defines the events.

How it connects

The agent connects to the Gateway at https://gateway.orbit and to Reverb at wss://reverb.orbit. It never uses system DNS. It connects to the Gateway’s WireGuard address from its configuration, gateway_address, and to the Reverb address from the realtime response. It verifies each certificate for the hostname against the Orbit root certificate in ca.pem.
  1. The agent calls GET /api/v1/agent/realtime and reads the Reverb connection from the response.
    Without an active websocket role, url, address, and key are null, and the agent asks again every 60 seconds.
  2. It opens a connection to the Reverb address on port 443 and reads its socket_id.
  3. It asks POST /api/v1/agent/broadcasting/auth to sign its membership, with socket_id, channel_name, and version. The Gateway signs agent.{id} only on the caller’s own two channels.
  4. It joins presence-node.{id}, sends its snapshot, and then publishes its events.
  5. It joins presence-node-logs.{id} the same way and fetches its stream list.
When the gateway role moves, the Gateway’s WireGuard address changes. Every agent then loses the Gateway until its configuration is rewritten. Run orbit node:add <node> or a role converge on each Node. The Reverb address needs no converge, because each realtime response carries it. Every agent endpoint call requires an active WireGuard peer and a valid agent secret; there is no exemption for older agents or Nodes without a stored secret. Agent endpoints need no access grant and record no Activity. The agent bounds each step, so a peer that stops answering never holds it. After a drop, the agent reconnects with a backoff from 2 to 30 seconds, with jitter. A session that stayed joined for 60 seconds starts the backoff again from 2 seconds. Every connection repeats all steps, because Reverb gives each connection a new socket_id. One Node runs one agent. The agent holds an exclusive lock on /etc/orbit/agent while it runs, and a second process exits with another orbit-agent already runs on this Node. The agent also refuses to start outside orbit-agent.service, so a copy started from a shell never joins as the Node’s agent.

Agent secret

Every Unix user on a Node reaches the Gateway from the Node’s WireGuard address, and production Nodes run customer code as unprivileged users. The agent secret keeps those users from acting as the agent. The agent reads the file when it starts. It exits with an error when the file is missing or malformed, and systemd starts it again after 2 seconds. The Gateway compares the hashes in constant time. Each agent converge reads the file’s hash with sudo sha256sum and keeps the secret while it matches the stored hash. Otherwise it writes a new secret through standard input, never in a command’s arguments, and restarts the agent. There is no scheduled rotation. To rotate a secret, delete the file on the Node and converge. The converge writes the secret file first, then installs the other files, restarts the agent, and stores the new hash last. The running agent reads its secret only when it starts. So a converge that fails at any step leaves the running agent with a secret that the Gateway still accepts. Doctor then reports mismatch, and the next converge repairs it. One converge runs per Node at a time, under a lock in a file cache store under ORBIT_HOME. A second converge waits up to 2 minutes and then fails with agent.converge_busy. The lock expires after 7 minutes, which covers the wait for the Node’s update lock below. A running converge renews it before each step and stops with agent.converge_lock_lost when the lock has expired. The binary download is limited to 20 seconds to connect and 120 seconds in total, so one step fits in the lock’s term.

Gateway view

The Gateway keeps the latest state that each agent reports, so it can skip repeated SSH reads. A long-running Gateway process, the agent view subscriber, receives the reports. The view is only an input to reads.

Subscriber

The subscriber runs next to PHP-FPM on the Gateway machine. A new member makes every agent on the channel send a complete snapshot. So when a channel owes a snapshot for 5 seconds, the subscriber leaves and joins it again. A channel owes one while agent events arrive without a complete snapshot, or after the agent’s sequence goes back without a membership change. The subscriber asks each channel at most once every 5 seconds and keeps what it knows until the snapshot arrives. Every 5 seconds, the subscriber checks the Reverb connection, and every 30 seconds the Node list. It joins new Nodes, leaves removed ones, and reconnects when the Reverb key changes. During a websocket move, it keeps one link to each Reverb server that holds clients and uses the link with the newest agent event. Without an active websocket role, it checks again every 60 seconds. When the connection drops, the subscriber clears the view and reconnects with a backoff from 1 to 30 seconds, with jitter. It sends its own ping after 30 quiet seconds and reconnects when no answer comes in 30 more seconds. It ignores a message larger than 64 KB and keeps at most 4,096 units per Node. Every 60 seconds, the subscriber compares the Gateway checkout’s commit with the one it started from. When they differ, it exits, and systemd starts it with the new code. The unit and that check use the stable ORBIT_GATEWAY_CHECKOUT path, so in the release layout they follow a release switch. A release’s runtime handoff also restarts the subscriber at once.

Stored state

The view lives in its own file cache store in ORBIT_HOME/cache/agent-view, whatever CACHE_STORE says, so heartbeats never write the SQLite database. The subscriber and every PHP-FPM worker run as orbit and share those files. The subscriber accepts an event only when Reverb’s user_id is agent.{id}. It applies a snapshot once every part has arrived, and starts over when the agent’s sequence restarts. It removes a Node’s entry when agent.{id} leaves the channel. It keeps a unit only when the name has the form orbit-process-{id}-{name}, the runtime is systemd or docker, and the status is a short lowercase word.

Freshness

A reader asks for one Node’s state and gets one of three answers. Freshness uses only the Gateway’s clock. In a fresh view, a systemd Process that the agent does not list is inactive, and a Docker Process it does not list is exited. When the agent reports Docker as absent, the view does not answer for Docker Processes.

Reads that use the view

These reads ask the view first and fall back when it is not fresh. A task read uses a workspace only when its base and start equal the values the reader would use. Reads that decide what the Gateway commits or tells an agent stay on SSH: the branch check before an approval commit, a subtask’s start commit, turn receipts, and the Project check. The status that a start, stop, or restart returns also stays on SSH, because it must show the change the Gateway just made.

Publish runs

The subscriber never reads Prometheus, writes the database, or broadcasts in its socket loop. It starts php artisan orbit:agent-view-publish as a child process with the queued work and does not wait for it. Task workspaces, Process usage, and log lines each have their own lane, with one run at a time. A run that takes longer than 12 seconds is stopped. When a workspace of an unfinished task reports a new head or new diff counts, a run stores the task’s line counts when the diff is complete and broadcasts task_group.updated. A failed run retries after 15 seconds. After five failures in a row, the workspace is dropped until the agent reports a new change.

Process usage

The subscriber pushes the CPU and memory of every Process to browsers, so no browser polls the Process list for them. While at least one viewer.* member is present on the channels it joins, it queues a sample every 15 seconds. A publish run reads CPU and memory for every Process from Prometheus and broadcasts one process.usage event on orbit. With no viewer, nothing is queried. A failed sample is dropped, because the next one replaces it.

Log relay

The subscriber accepts client-log and client-log-end only from agent.{id} on that Node’s log channel, and queues them in order. A publish run drops lines unless the stream is open for that Node. It redacts each line again, also with the stored environment values of the Instance or Process, and publishes log.lines in parts under 10,000 bytes. A failed run puts its events back in front of the queue and retries after 2 seconds. Live logs lists the Gateway’s queue limits and the end reasons.

Install and upgrade

The Gateway pins a released agent version and its architecture checksums. It stores the SHA-256 checksum of each architecture’s binary and picks the asset for the Node’s recorded architecture, x86_64 or aarch64. The Node downloads the asset to a candidate file, checks the checksum, and moves it into place. A mismatch deletes the candidate and fails with agent.checksum_mismatch. When the installed binary already matches, the download is skipped. The Gateway restarts the agent only when the binary, configuration, certificate, secret, or unit changed. The unit runs the agent as root with Restart=always and RestartSec=2. It grants no capabilities and sets NoNewPrivileges=yes, ProtectSystem=strict, ProtectHome=tmpfs, BindReadOnlyPaths=-{Instance root}, PrivateTmp=yes, MemoryMax=128M, and RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6. The Instance root is the Node’s apps root. The bind line appears only when the root lies under /home or /root. When the Gateway cannot resolve the managed user, or the root has characters other than letters, digits, ., _, -, and /, the unit sets ProtectHome=yes, and the Gateway reads that Node’s checkouts over SSH. The Instance root and each checkout must stay world-traversable, mode 0755 as Orbit creates them. Otherwise the agent leaves the checkout out. Orbit removes the world bits from an Instance .env when it configures the Laravel URL, after registration or transfer, and on each agent converge. Agent converge independently closes environment permissions for the Instances under the Node’s apps root; it does not rely on registration or transfer having run. For Laravel, it uses the shared application directory: root apps/site/public means <checkout>/apps/site/.env, not only <checkout>/.env. Root public and non-Laravel Instances keep the checkout-root path. It closes only regular environment files, without following symlinks or discovering apps by scanning the repository. A converge that cannot close a .env logs a warning and continues. The Gateway converges the agent at these points. To upgrade the fleet, release a new version, update the pin in the Gateway, and release the Gateway. The fleet rollout then runs sudo orbit self-update on each Node of the rollout set, one at a time. It reads the pin from the Gateway’s desired fleet state and replaces the binary only when it differs from the pin, with the candidate, checksum, owner, mode, and rename steps above. It then restarts orbit-agent.service and restores the previous binary when the agent does not stay up. The rollout leaves out the Gateway’s own Node. The runtime handoff of each release updates that Node’s agent with the same steps. The converge holds /run/lock/orbit-self-update.lock, the lock orbit self-update holds, from the secret check through the agent restart. So it never swaps or restarts the agent while a self-update replaces it or watches its health. The converge holds the lock across its SSH commands through a transient unit, orbit-update-lock-<random>, that waits up to 5 minutes for the lock and keeps it until the converge stops the unit. The unit ends after 30 minutes in any case, so a Gateway process that dies cannot keep the lock. A self-update that keeps the lock longer than 5 minutes fails the converge with node.update_busy. self-update changes no configuration, certificate, secret, or unit; the rollout’s footprint step re-applies those when their digest changed. Doctor reports each Node that runs another version. The agent still only observes. The agent sends its version with each channel authorization. The Gateway keeps the version of presence-node.{id} in the agent view’s file store without an expiry, because an agent that stays connected never joins again. The fleet rollout waits for the pinned version there after self-update, and the catch-up visits a Node whose agent reports another version.

Failures

The agent recovers from each failure below without an operator. The agent logs to the systemd journal. Its logs contain no Reverb key, signature, secret, or streamed log line.

Removal

Online node:remove stops and disables orbit-agent.service and deletes the unit, the binary, and /etc/orbit/agent, secret included. A failure only logs a warning. --offline --force for an unreachable Node leaves the agent installed and lists it under retained_on_node. The Gateway then refuses its requests with peer.identity_unknown, and the agent keeps retrying.

Doctor

Doctor checks the agent in the node family on every managed Linux Node. Run orbit node:add <node> to repair the first four. node:add refuses a Node that owns Instances. Repair such a Node by converging one of its roles with orbit node:role:add <node> <role> --converge. node.agent_view_stale reports what it observed.

Releases

A tag named agent-v{version} releases the agent. The version must equal the one in apps/agent/Cargo.toml. The release job runs on ubuntu-26.04, builds static musl binaries, and publishes them as a GitHub release of that tag. Create the tag with the GitHub CLI on a commit that is already on GitHub:
The job refuses to replace the assets of an existing release. Pushes to main, and pull requests that change apps/agent or the CI workflow, build and test the agent without publishing.

Limits

The agent has these limits.
  • The agent reports presence, Process state, and task checkout Git state, and tails logs only for live log streams.
  • One-shot log reads, task reads that gate an action, the Horizon queue, and ufw status run over SSH.
  • The agent does not report CPU or memory. The Gateway pushes them from Prometheus as Process usage.
  • The agent supports Linux on x86_64 and aarch64 only. There is no macOS agent.
  • The agent cannot trigger orbit self-update. The fleet rollout runs it over SSH.

Why it works this way

These reasons explain the design. Check them before you propose a change.

The agent only observes

An agent that also runs commands would be a second way to change a Node, with its own authorization, listener, and recovery, while SSH would still be needed when the agent breaks. So the agent listens on no port, runs nothing on anyone’s behalf, and SSH stays the only way to change a Node. A compromised Gateway gains no new way into a Node, and a compromised Node can only report false state about itself. The same boundary keeps updates out of the agent. An agent that pulls and applies updates would make a compromised Gateway, Reverb secret, or release key a root code-execution path to every Node. It would need offline signing and a privileged updater first, and it would still not cover Macs, which have no agent. So the Gateway runs orbit self-update on each Node over SSH. A self-update that an agent signal triggers stays possible, but it needs its own decision, with signed manifests and a narrow privileged trigger.

Rust

The agent runs on every Node, so memory matters. A Rust agent uses about 5 MB. PHP or Bun would use about 20 times more. Rust also has mature libraries for systemd D-Bus and the Docker socket.

Pinned GitHub release assets

A workflow artifact needs a GitHub login and expires. A release asset of the public repository has a permanent URL that any Node can fetch. Building on each Node would put a Rust toolchain on production hosts. Copying the binary from the Gateway would make the Gateway store and serve binaries. Resolving the latest release at install time would let an unreviewed release reach Nodes. So the Gateway pins the version and checksums in code.

The view in a file cache

HTTP reports would cost a PHP-FPM worker per heartbeat on a small Gateway. A PHP-FPM request cannot wait for a snapshot, which comes only when a member joins. SQLite would take a write every few seconds that competes with API writes. So one long-running subscriber keeps a disposable view in files, and every reader falls back to Prometheus and SSH when the view is missing.

Rejoin to ask for a snapshot

Reverb gives neither client events nor presence events a socket ID, so the subscriber cannot tell two connections of one member apart. A client event that asks for a snapshot is rejected, because the subscriber never sends client events. A new membership already makes every agent send a snapshot, so the subscriber leaves and joins again.

A per-Node secret

The WireGuard address alone does not tell the agent apart from other users on the Node. A firewall owner match depends on per-Node rules that fail silently when missing. Mutual TLS needs a certificate issuer and renewal. A bearer secret in a file that only root can read fails closed, at a fraction of that cost. The Gateway stores only the hash, because a leaked database then reveals no secret, and a slow password hash adds nothing for 256 random bits.

The hash is stored last

Storing a new hash before the agent restarts would refuse the running agent if the restart fails, while the file would match the record. Doctor would then report nothing. Storing it last keeps the running agent accepted and leaves a mismatch that Doctor reports.