Driving a remote daemon

Login and the remote flag: certificate pinning, precedence, pinned reads, writes that enqueue on the other box, following a job live, and the inputs that do not cross the wire.

exocomp login pins a remote daemon's certificate and a bearer token so the same CLI can drive a box other than the one it runs on. --remote then points any command at that box's /api/v1 instead of the local data dir. Reads render the daemon's own projections; writes enqueue there and the job can be followed live. The transport is the Decision 0009 TLS listener (LAN access) with the certificate pinned trust-on-first-use.

Pinning a remote (TOFU)

exocomp login server.tailnet.ts.net:8443 --token exo_...
exocomp --remote server jobs list

login connects over TLS, captures the daemon's self-signed certificate SHA-256 fingerprint (the same hex doctor and the web login screen print), shows it, and — on confirmation — records {endpoint, fingerprint, token} under an alias in ~/.config/exocomp/hosts.toml (0600, atomic write via temp file + rename), the same trust-on-first-use discipline as ~/.ssh/known_hosts. Later calls verify the pin and refuse to connect if it changed.

--name sets the alias explicitly; otherwise it derives from the endpoint's first host label (server.tailnet.ts.net → server). The first login becomes the default remote unless --no-default is passed or a default is already set.

A first-time pin asks for interactive confirmation (y/N) unless --assume-yes is given; a non-interactive session with no --assume-yes refuses rather than blocking on stdin. A raw HTTP endpoint is rejected everywhere in the client (must be https): a bearer token over cleartext is sniffable, so there is no insecure remote path to opt into.

Replacing a changed pin

If the alias is already pinned to a different fingerprint, login refuses and prints both values — either an out-of-band re-key or a machine-in-the-middle. Accepting the change (or re-pointing an alias at a different endpoint) takes --replace-pin, and nothing else does it. --assume-yes pins a first fingerprint on non-interactive stdin but never replaces one. That split is deliberate: every script, CI step, and automated caller must pass --assume-yes to pin at all, so if it also replaced pins, the one invocation automation always uses would re-pin to whatever certificate answered and hand it the bearer token (see the threat model). Confirm the new fingerprint out of band first; a refusal exits non-zero and leaves hosts.toml untouched.

Selecting a remote: flags, env, precedence

Every command inherits two global flags plus the token flag:

Input Precedence
--remote <selector> wins over the environment
EXOCOMP_REMOTE used when --remote is not passed
neither the command stays local, against the data dir, unchanged
--remote= (empty value) remote mode is active; resolves to the hosts.toml default

<selector> is either a stored alias (server) or a raw https:// endpoint pinned under some alias by an earlier login — an endpoint that was never logged in is refused (TOFU demands the pin exist first). The hosts.toml default is consulted only once remote mode is already active (via --remote/EXOCOMP_REMOTE); a bare command with neither never gets silently redirected off the box, even when a default remote is stored.

The bearer token resolves the same way login resolves it: --token flag → EXOCOMP_TOKEN → the token login stored for that alias. --token/EXOCOMP_TOKEN let one invocation use a different-scoped token than the one pinned, without a re-login.

exocomp --remote server jobs list --output json
EXOCOMP_REMOTE=server EXOCOMP_TOKEN=exo_... exocomp doctor
exocomp --remote https://server.tailnet.ts.net:8443 workspace list

--remote is deliberately not --host: --host already names the ansible host pattern on command/ansible (which managed nodes a play targets) — a different axis from which daemon the CLI itself talks to. Reusing it would conflate the two.

Reads

The client (internal/client) re-renders the daemon's wire bytes exactly: --output json in remote mode is byte-identical to --output json run locally against the same resource.

Verb Behavior
exocomp doctor the remote daemon's health summary, not this workstation's
exocomp jobs list / get / events / follow the remote daemon's job state
exocomp approval list pending approvals on the remote daemon
exocomp source list, workspace list the remote daemon's registrations
exocomp drain cordons the remote daemon (drain mode)

Writes

Writes enqueue on the remote daemon and return its own envelope.

Command Over --remote
playbook run Enqueues, then follows the job to its end and exits with the job's code. --no-follow prints the id and exits 0
workspace plan Enqueues, prints the job id
workspace apply Enqueues, prints the job id — or the pending approval, its policy, deadline and TTL when the plan's verdict gates it
approval decide Settles the approval on the daemon, which derives the capability token itself. Needs an approve-scope token: operator may enqueue but may not settle what it enqueued, and no token may decide an approval for a job it asked for itself
jobs follow <id> Streams the job's events and exits with the job's code

Every write accepts --idempotency-key (or EXOCOMP_IDEMPOTENCY_KEY), which travels as the Idempotency-Key header: a retried request dedups to the one job instead of enqueuing a second. The daemon namespaces the key by the authenticated token and the route, so a key only ever dedups your retry of that verb — a guessable key chosen by one caller cannot swallow another's write (see the threat model).

--output json re-emits the daemon's own envelope — job.enqueued, approval.pending, approval.decision — byte for byte. A followed run emits one document, the settled job, rather than a stream of per-event envelopes.

Config writes stay local: every source/inventory/runsecret/schedule/secrets write, runner add, and backup run run against the local data dir. There is no --remote path for them.

What does not cross the wire

A remote write resolves its inputs on the daemon, so an input naming a file on the laptop is refused rather than sent:

  • a playbook path without --source (pass --source <name>, or an absolute path that exists on the daemon)
  • --inventory ./hosts.ini (name a registered inventory instead)
  • --extra-var @file (pass key=val, which travels in the request)
  • workspace apply --plan <path> (name the saved plan's artifact id)
  • --wait, which drives a hooked source's composed workflow — a lifecycle the write API does not offer

Following a job

jobs follow and a followed playbook run consume the daemon's SSE event stream. The stream is authoritative only from the moment it attaches, so each attach is paired with a read of the append-only event log from the last sequence; events are deduped by sequence. A dropped connection is re-established once, resuming from that sequence — a stream that keeps dropping fails the command rather than looping quietly.

Exit codes

A remote failure surfaces the daemon's wire error envelope (preserving its taxonomy code, not just a status) on --output json, or the plain error on text, and exits exactly as the same local failure would (not_found → 3, forbidden → 4, and the rest of the table). A certificate that no longer matches the pin is a PinMismatchError, refused before any request is sent, telling the operator to re-run login if the re-key is expected. A followed job exits with the job's own code; a degraded run (tolerated unreachable hosts) is a success.

workspace plan is the one deviation: locally it exits 2 when the plan found changes, read from the run's own driver output. That detail is not in the read API's event projection, so a remote plan exits 0 once the job is queued; read the outcome from the job's events.

Related: LAN access · Running the daemon · The API surface · Machine-readable output · Playbook run flags.