The daemon on an agent host #
One process on an agent host issues a credential to every context that acts
there. This page draws that process, says which hosts run one, separates
proving a host from running the daemon on it, and places both in the chain
that runs from an operator to an agent. node is the epic
that owns the decisions; crates/didbot-agentd is the code, and
the shape of the system draws the hosts around it.
One process, many contexts #
A harness fires a hook for every event: a session starting, a subagent
starting, each tool call. A hook is a process that lives for milliseconds, and
a machine running several sessions fires many at once. didbot-agentd is the
one process those hooks report to. It holds what they must not, and it is the
one place their requests can be coalesced: a fan-out of subagents starting
together becomes one provisioning request per context rather than one per
hook.
The socket is the privilege boundary #
The daemon listens on a unix socket in a directory it creates and owns under
the runtime directory. The directory is created at 0700, or tightened to
0700 when it already exists, which fails when another user owns it; the
socket file is set to 0600 after binding rather than left to the umask. Every
accepted connection is checked against the credentials the kernel reports for
the connecting process, and a peer from another user is dropped. A stale socket
is unlinked only when connecting to it is refused, because any other failure
means a live daemon may be behind it. crates/didbot-agentd/src/socket.rs
holds all of this, and crates/didbot-serve/src/estop_admin.rs is where the
pattern was first paid for.
Reaching the socket is the authorization. The path is a location the client computes, published where any shell command the model runs can print it, so it is guarded by the directory and the peer check rather than by staying unknown. What a caller sends selects which context is asking. It never decides whether the caller may ask: anything running as this user can connect, and the trust model names that as the ceiling. The design defends a credential from leaving the machine, and not from being misused on it.
The protocol is the contract #
The adapter that speaks to this socket lives in another repository and is not
written in Rust. The wire format in crates/didbot-agentd/src/protocol.rs is
therefore the schema, and the adapter is checked against fixtures of it rather
than against this crate. A change the adapter cannot make without importing the
crate has put the two back together.
One line of JSON crosses each way, one exchange per connection, and every request carries the wire version. Two components installed months apart is the ordinary case, so a daemon that meets a version it does not speak answers with that rather than closing: an adapter given silence cannot tell a version mismatch from a daemon that is down.
A report says what the adapter observed, in the daemon's own vocabulary of contexts beginning, acting and resting, rather than in the harness's event names; mapping one onto the other is the adapter's whole job. The answer is quiet unless the daemon has something to say, and the thing it says is a context's identity, exactly once per asker. Two plugins on one machine watch the same events, so "once" is counted per plugin: the second to ask is answered rather than met with the silence that means "already told".
What the daemon keeps #
A context is a session together with the harness's identifier for whatever is
acting inside it, and a session with no subagent is a context in its own
right. Whichever report mentions a context first creates it, so a context that
acts before announcing itself is still named. A context that has ended keeps
its row and its name: a name is never returned to a pool, and neither is the
record of who held it. crates/didbot-agentd/src/context.rs is that store:
bookkeeping in memory, deciding which context still needs a name.
Names come from a registrar. The one that speaks to this project's server
calls bot.did.provisionAgent with the harness's identifier for the context,
the harness's word for its kind, the host's DID as its parent, and a claim
signed with the node key: didbot-attest's node-credential format, naming the
host's DID as the node. A host with no identity signs nothing, so a context on
it is answered with trouble naming become-host and stays owed a name. The
server answers with the account's DID, the handle it chose, and the account's
write credential, which it sends once and keeps no copy of. The daemon
provisions outside its own lock, because every other hook on the machine would
otherwise wait behind a round trip to the server.
The key it holds #
The daemon mints one secp256k1 key on its first start and keeps it in its
state directory, $XDG_STATE_HOME/didbot/agentd unless DIDBOT_STATE names
another. The directory is created at 0700 and tightened to it on every
start; the key file is created at 0600 and tightened back to it when found
wider. A lock file beside the key carries an exclusive flock, so a second
daemon pointed at the same directory is refused before it reads a byte, and
the kernel drops the lock with the process. crates/didbot-agentd/src/node.rs
is the custody.
The private half never crosses the socket. A host question is answered with
the public half as did:key and, once the host has one, its identity; that is
what didbot host prints.
Becoming a host #
didbot become-host <pds-url> asks the daemon to reserve an identity for its
key. The daemon posts the public half to bot.did.reserveIdentity at that
server, writes the DID and hostname it gets back beside the key, and answers
with both; the command prints them. The operator then claims that hostname
from their own machine, which is the vouch the chain
below describes. Becoming a host happens once: a daemon that holds an identity
answers with it and asks no server, whichever one the command names.
Confirming an authorization #
An agent signs in to a third-party app the way
the shape of the system draws it: the client prints its
authorize URL instead of opening a browser, and didbot confirm <url> --as <did> hands that URL across the socket. The daemon fetches the page, reads
the one-time reference out of it, confirms that reference against the
server's own route as the named account, and delivers the resulting code to
the listener the client opened on loopback.
Three things bound what the daemon will fetch, because the URL came from a process the model started. It confirms only at the authorize endpoint this deployment publishes in its own discovery document. It delivers only to loopback. Every request is made with redirects turned off, so an authorize page cannot point it somewhere else.
The account is the one the caller names. The server compares that name against the account the pushed request named and refuses a mismatch, and a model that can read another context's identifier out of a transcript on this machine can name it here. That is model-level custody, the same posture the sign-in section of the shape of the system describes, and it is the ceiling the socket section above already set.
Which hosts run it #
The daemon exists to multiplex. A host running many sessions, each spawning subagents, has many contexts to keep apart and needs a distinct credential for each. One process holding one node key issues all of them, coalesces the burst of a fan-out into one request per context, and is the only place the key is read. That host runs the daemon.
A single-purpose host presents its evidence directly. A CI job runs once and asks for one account; a cloud instance minted to run one agent is one context for as long as it lives. Each makes one claim to the server with whatever evidence it has, and the account it is given is the account it uses. account-types says what such an account renews and keeps.
The line between the two is the number of contexts a host has to tell apart. One context is one claim and one account; several contexts on one host is a daemon, because that is what keeps one key in one process while many things act.
Proving the host is a separate choice #
Running the daemon is about how many credentials a host issues. Proving the
host is about what evidence the server accepts before it admits any of them,
and crates/didbot-attest holds the backends that decide it. Each turns a
claim into a provenance the server writes down when the account is born. That
record is an audit artifact, read by a person: it says which backend admitted
the account and how much that check proves, and the trust model
reads its assurance level the same way for every backend.
A node credential. NodeCredentialBackend verifies a signature over the
node's identifier, a nonce and a timestamp against a public key registered for
that identifier. The private half is generated on the host and stays there; the
verifier holds public halves only, and that registry is the allowlist, because
a node with no registered key can produce no claim that verifies. On a host
running the daemon, the daemon holds the key and is its only writer. The
signature proves the claim came from whoever holds the credential issued to
the node it names, so compromising one node yields one node. The credential is
a file, and a copy of it is indistinguishable from the original, which is the
limit the crate's own documentation states.
A platform identity. AwsInstanceIdentityBackend verifies an EC2 instance
identity document and AWS's detached signature over it, against the regional
certificate this deployment was configured with, and admits only the AWS
accounts it was told to. The subject it binds to is the account and instance
identifiers AWS assigns once and never renames. The evidence is fetched on the
instance from the metadata service, fresh for every claim, by the caller; the
backend receives both parts and checks the signature against the certificate.
The document carries neither an audience nor an expiry, so the
freshness window on its timestamp and the store of spent claims do that work,
as they do for every backend here.
The trade between them is the key. A platform identity is preferred wherever a platform exists to issue one, precisely because it leaves no long-lived key on the host: nothing to generate, nothing to store, nothing a copy of the disk carries away, and nothing a daemon has to be the only writer of. A node key is for the hosts with no platform to vouch for them, which is laptops and workstations, and those are the hosts that run the daemon.
Where it sits in the chain #
Two vouches, each written where the party it vouches for cannot write.
The operator vouches for a host. They write bot.did.operator into their
own repository, naming the host's DID and the public key it holds. The server
reads that repository on a poll and holds no credential for it, so a
compromised server or host can lose the relationship and cannot forge one;
crates/didbot-serve/src/ownership_poll.rs is the reader, and the section
"The same pattern, for a node's admission" of handshake
is the decision. A host that comes up before anybody has vouched for it mints
its key, presents the public half, and waits as a reservation the operator
approves by writing that same record. What the host may then do is the
operator's to set, in the same repository, read by the same poll.
The host vouches for the agents it spawns. Every account is minted by a claim from a host, and the server writes the admission into the agent's own registration record: which backend admitted it and how much that proves. On a host running the daemon, the daemon makes the claim and names the context it is for. A single-purpose host claims once, for itself.
A stranger reads the chain upward from the agent, trusting neither server on its own: the agent's registration names its admission, the operator's record names the host, and verifying who an agent belongs to is the check that closes it at the human.