# 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](../plan/node.md) is the epic that owns the decisions; `crates/didbot-agentd` is the code, and [the shape of the system](architecture.md) 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.
AGENT HOST · one user account anything running as this user reaches the socket · that is the ceiling harness process session agent a context in its own right subagent its own account subagent its own account distinct DIDs separate attribution and policy one process holds them all hook adapter another repository · not Rust one short-lived process per event didbot-oauth approve <token> run by an agent, with the token from a decision it was shown. no account hooks agent.sock directory 0700 socket 0600 peer uid must be the owner's the path is public reaching it is the authorization report approve one line of JSON each way didbot-agentd contexts keyed session + context told once per asker registrar asks for a name decisions polled, held in memory node key the daemon is its only writer agent pds bot.did.provisionAgent one account per context the write credential is sent once listPendingAuthorizations long-polled, one task per account the daemon calls out, never in approveAuthorization redeems a token, for what was granted provision approve as the record's account the code goes to the client's loopback listener · the daemon reads the answer, never the model
### 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](trust-model.md) 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. A request longer than `MAX_LINE` bytes, set in that file, is answered with trouble. Two components installed months apart is the ordinary case, so an adapter a version behind is answered normally: every field it sends is still read, and the fields it has never heard of in the answer are ones it ignores. A version *newer* than the daemon's own is refused, and refused in words rather than by closing, because 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. One 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, and the second to ask is answered rather than met with the silence that means "already told". The other is a sign-in waiting on that context's decision, which is the next section. ### 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. Hooks fire in parallel, so two reports for a new context can arrive together; the second waits for the first to mint rather than minting again. 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` and stays owed a name. The server provisions only under a claim from a host its operator has vouched for and has not locked. It refuses a reservation still waiting for that vouch as `HostNotAdmitted`. The context is answered with the server's refusal, name and sentence, and stays owed a name, so its first call after the vouch provisions it. Otherwise 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 over it is answered with the public half as `did:key` and, once the host has one, its identity. ### Becoming a host A `become` message over the socket, naming a server, 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 operator then claims that hostname from their own machine, which is the vouch [the chain](#where-it-sits-in-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 message names. ### Signing an agent in An app asking to sign in as an agent becomes a decision record at the server: which client asked, what it asked for, and what policy made of that. The daemon follows those records for the accounts it issued and puts them in front of the agent they belong to. It follows them outbound only, because nothing on an agent host is reachable from the server. One task per account long-polls `bot.did.listPendingAuthorizations`, holding the request open for twenty-five seconds at a time and backing off from a second to half a minute when the server is not answering. A task starts when a context is provisioned and stops when the harness says that context has ended. Records are held in memory, keyed by the pushed request, and dropped when they expire or when the server says they were settled. Nothing is pushed at the agent. What is waiting for a context rides the answer to the next report that context's adapter sends, so an agent learns of a sign-in at its next tool call. A report may also name request identifiers the adapter saw go past in tool output, which the daemon fetches directly — the fast path for a client that printed its URL in the very call being reported. The agent answers with `didbot-oauth approve ` or `didbot-oauth decline `. The token names one request, is good once, and is not an account: the daemon finds the record it is holding that token in and acts as the account that record names, so there is nothing here for a caller to fill in with somebody else's identity, and a token the daemon is not holding is refused rather than passed on. Approving as the account means presenting that account's own agent token, which the daemon has held in memory since provisioning and writes nowhere. An approval covers a narrowed decision's `granted`, and the login is issued at that set. The server asks its scope ceiling again at every use, so a ceiling tightened afterwards takes granted atoms back. `cut` is outside the approval, so a loosened ceiling still answers `granted`, and a cut atom takes a fresh sign-in to ask for. A narrowed line says so with `approves=granted ceiling-checked=each-use`. An approval's answer lists the scopes requested, then the scopes the scope ceiling allows. The server cannot deliver the resulting code — the client is listening on loopback on this host — so it answers with the redirect and the daemon fetches it. That fetch is bounded to loopback, with redirects turned off, as is every request the daemon makes on something the model influenced. When neither path reached the agent — no hook saw the client print its URL, and the poll has not come back — `didbot-oauth show ` looks one up by that URL, and `approve --url` and `decline --url` answer it; the daemon refuses a URL on any origin but this deployment's, then fetches the record through `bot.did.getAuthorization` as whichever of its accounts the record names, which is the same authenticated fetch the fast path makes. `didbot-oauth` is the only agent-facing command, and everything in it names a decision — a token, or the URL of one. There is no `--as ` anywhere in it, and nothing left that takes an account from its caller: the daemon reads the account off the record it is holding the token in. That is what closes the gap the socket section above describes — reaching the socket is still the authorization, and anything running as this user can still connect, but there is no longer a field on the wire for a caller to put another context's identity into. The command that did take one is gone. It handed the daemon an authorize URL to fetch and an account to confirm it as, which meant a model that could read another context's identifier out of a transcript could name it. Approving a decision the daemon already holds needs neither, so the URL, the page it was read from, and the account argument all went with it. ### Without the daemon A host with one agent on it has neither problem the daemon solves: nothing to multiplex and one credential, not one per context. `didbot-oauth` runs there with no daemon and no hook. Set `DIDBOT_PDS` to the server and either `DIDBOT_AGENT_TOKEN` or `DIDBOT_AGENT_TOKEN_FILE` to that account's own agent token, and `pending`, `show`, `approve` and `decline` work as they do otherwise, against the same routes as the same `AgentSelf` credential. Passing `--direct` insists on that mode; without it a running daemon is preferred, and the environment is consulted only when nothing answers on the socket. It is the same client code throughout — the same origin check on a URL, the same bounded loopback fetch delivering the code after an approval — so the two modes cannot drift into disagreeing about what a sign-in is. What it does not do is obtain a credential: something still has to put one in that environment, which is [credentials](../plan/credentials.md)' open question and not this command's. The token is read into a type that has no `Display` and no `Serialize`, so no command here can print or log it. ## 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](../plan/account-types.md) 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](trust-model.md) 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. This backend verifies a document handed to it directly; the claim `bot.did.provisionAgent` carries goes to the node-credential backend, which `didbot_pds::Provisioner` keys by the node keys of the hosts it has admitted — [attestation](attestation.md) works that path through end to end. 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.
OPERATOR'S REPOSITORY bot.did.operator the host's DID and its public key what this host may do set by the operator, per host only the operator's key writes here PDS HOST agent pds verifies a claim against the key the operator vouched for, or against the platform's certificate mints one account per claim polls never writes THE AGENT'S REPOSITORY bot.did.registration who admitted this account and how much that proves written at birth · read by a stranger writes AGENT HOST · many sessions didbot-agentd · holds the node key signs a claim, asks for one account per context session and subagent contexts each told its own identity once claim signed with the node key SINGLE-PURPOSE HOST · a CI job, a cloud instance one context, one claim, one account presents the platform's signed identity document fetched fresh for the claim no long-lived key on the host to guard claim carrying the identity document
**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](../plan/handshake.md) 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.** Outside the `.localhost` development stack, 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](ownership-verification.md) is the check that closes it at the human.