--- id: node title: A host proves what it is once, and issues credentials to the sessions on it status: open crates: [didbot-agentd, didbot-attest] dependsOn: [attestation] exitCriterion: > A machine holding a node credential issues a session credential to a hook that asks for one, a machine without one is refused, and many hooks starting at once produce one provisioning request per session rather than one per hook. --- # node [credentials](credentials.md) states the shape and names this as its first item: *the shape mirrors SPIFFE — a node attests once, a local component issues credentials, and the workload talks to the server itself.* This epic is that local component, split out because it is a process with a lifetime, an install story and a privilege boundary of its own, and those do not fit inside a bullet. ## Why a daemon rather than the hook Both were on the table. The hook is the simpler answer and it does not survive contact with a busy machine. A hook is a short-lived process spawned per tool call. Putting the node credential in it means every one of those processes reads the credential off disk, so the blast radius of anything that can observe a process is the whole node identity rather than one session. And a machine running several sessions and their subagents fires many hooks at once, each asking the server for an account, so provisioning arrives as a stampede with nowhere to be coalesced. A long-lived component fixes both: one process holds the credential, and it is the natural place to serialise and rate-limit what reaches the server. The cost is honest and worth writing down: it is another thing to install, supervise and version on every agent host, and [deploy](deploy.md) already notes that agent hosts are their own deployment. [dev-setup](dev-setup.md)'s service/profile/binding model is where it has to fit. The argument above was written about code that did not exist. What it described as a daemon was one short-lived process per event, reading its state from a plaintext file. The reasoning holds; it was describing the design rather than the build. Two things it did not anticipate make it stronger. [subagents](subagents.md) gives every context an account, so a fan-out of parallel subagents acting at once is the same burst moved to the busiest moment rather than the quietest. And [cred-delivery](cred-delivery.md) has this process perform the write, which is what keeps a credential out of every place the model can read. ## What supervises it - [ ] **Start it from its socket, not from a hook.** A unit pair listening at a well-known path under the runtime directory, with the directory and the socket both created shut. The alternative — a hook that spawns the daemon when it is missing — is several hooks racing to be the one that launches it, which is the race this component exists to remove, reintroduced at its own front door. Socket activation makes it the kernel's problem: every caller connects to a socket that already exists. - [ ] **Do not linger.** The daemon dies with the login session, because a node credential resident while nobody is logged in is a target for no reason. - [ ] **Under socket activation the daemon does not create its own socket**, so every guarantee below has to be expressed twice — in the unit, and again in code for the path where it starts on its own. - [ ] **Installing unit files is where a narrow `didbot-setup apply` returns.** Not the one that edited a person's settings file and kept a ledger to undo it: this writes unit files and reports whether the plugin is present. `didbot_stack::ServiceKind` needs a variant so `didbot-setup check` can probe it. ## What it holds, and what that is worth - [ ] **Prefer a curve a keystore can hold.** The node key signs attestation claims, never a repository commit, so it is not bound by the two curves atproto accepts and can be chosen for where it lives instead. Every keystore on the hosts this project targets does P-256 and none do secp256k1. `p256` is already a direct dependency and `didbot-key`'s `secp256r1` module already reads the multibase form and knows the low-S predicate, so the cost is a curve-tagged signature in `NodeCredentialBackend` rather than a new dependency — and `deny.toml` denies multiple versions, so a third curve crate would not be cheap later. Carry the public half as a multikey and dispatch on the codec the way `didbot-claim`'s `identify` already does, so the curve stays a per-host fact. **This is a credential type and needs a human's approval before it is built.** - [ ] **The bootstrap, concretely.** How a host gets its credential in the first place is the root of this whole chain and the step most easily hand-waved. Somebody issues it out of band, and that somebody is the operator; what that looks like, and what stops a second machine using a copy, is this epic's to answer rather than to assume. ## What a restart keeps The context map (`crates/didbot-agentd/src/context.rs`) is memory only. A restart forgets which account belongs to which context, so the same context reporting again is one the store has never seen and is provisioned a second account. The first still resolves, still holds a live agent token, and nobody holds it — and a name is never returned to the pool, so every restart under load leaves another. - [ ] **A context binding is a credential at rest, and that is the decision to take first.** What a binding has to carry is the context key, the account's DID, *and that account's agent token*: the token is what the daemon presents to poll and approve sign-ins, the server keeps only a digest of it and sends the bytes once, so a binding without it names an account this daemon can no longer act as. [cred-delivery](cred-delivery.md) put that token in the daemon's memory deliberately. Writing it down puts a year-long bearer credential in the state directory beside the node key, and it is the first file this daemon would keep that is a credential for an account rather than for the host. **This is a credential and needs a human's approval before it is built.** - [ ] **The shape, once it is approved.** `didbot_agentd::sessions`'s, unchanged: one `0600` JSON file per context in the `0700` state directory, named for a hash of the key, written through a synced temporary and a rename, under the lock the daemon already holds for its life. The file carries the key, the DID, the token, the harness's word for the kind, and the askers already told. It is read back at start-up beside the sessions, and one file that does not read costs one context. - [ ] **Name a context after both halves of its key.** `agent_id` takes the subagent id alone when there is one (`crates/didbot-agentd/src/serve.rs`), on the stated assumption that a subagent id is unique on the machine, while the store is keyed by session and subagent together. [subagents](subagents.md) puts the custody boundary at the session, which is the half the name drops. Changing what a DID's label is built from changes the names accounts are minted under, so it belongs to the same review. - [ ] **Decide what ends a binding.** A context the harness says has ended keeps its row, because the account outlives it. On disk that is one file per context for the life of the state directory, each holding a credential. How long a binding outlives its context, and what removes it, is the state machine that makes the store bounded. ## The local transport is a privilege boundary - [ ] **Anything a caller presents is a selector, not an authorization.** Reaching the socket is the permission, exactly as e-stop concluded when it deleted its own operator secret — the same caller could read it from the same filesystem, and the permissions were doing the real work. A ticket that starts deciding whether a caller is allowed has reintroduced what that removal took out. - [ ] **Cap what is provisioning at once, and publish the depth.** Provisioning is allowed to be slow. An unbounded queue of it is not, and a fan-out of subagents will produce one. The cap doubles as the bound on how much work an unadmitted caller can ask for, and the depth is a health number rather than a constant nobody can see — see [alerts](alerts.md). - [ ] **A caller that cannot reach the daemon is refused, legibly.** Silence must not degrade into an unattributed write. [dev-setup](dev-setup.md) documents the recovery path that exists today — `PreToolUse` provisions when a session has no usable account — and whatever this becomes has to keep that working rather than replace it with a new way to fail quietly. ## Boundaries it must not move - [ ] **Nothing the daemon accepts from a hook may widen anything.** A hook-asserted fact is a claim about accounts this server minted, checkable against what it issued, and never taken on its word — [agent-accounts](agent-accounts.md)'s rule, which a component sitting between the hook and the server is well placed to erode. ## A layer this is not - [ ] **Cloud instance identity attests a different machine.** The AWS EC2 instance-identity backend in [attestation](attestation.md) proves things about the cloud instance it runs on. Agents run on laptops and workstations, which have no instance identity document, so it is not the mechanism for the hosts this deployment has. It is built and dormant, and becomes relevant the day agents run in cloud sandboxes, which [account-types](account-types.md) contemplates. The node credential is the mechanism for the hosts that exist now. ## Done - [x] **One device credential per node**, with the file treated as seriously as the server treats its signing keys. `crates/didbot-agentd/src/node.rs` mints one secp256k1 key on first start, in a `0700` directory as a `0600` file, under a lock held for the daemon's life, and hands out only the public half and signatures. The server's [`NodeCredentialBackend`](../crates/didbot-attest/src/node_credential.rs) keeps only the public half, registered once the host stands. - [x] **Say what it is not.** `crates/didbot-attest/src/node_credential.rs` and `node.rs`'s module doc both say the key is a file: anything that can read the disk can copy it, and the copy is indistinguishable from the original. - [x] **The socket carries e-stop's hardening, all four parts.** `crates/didbot-agentd/src/socket.rs`: a subdirectory this process owns under `XDG_RUNTIME_DIR` or the temporary directory; `0700` set on it on every start, which fails when another user owns it; `0600` on the socket after bind; and `remove_if_stale`, which unlinks only on `ECONNREFUSED`. - [x] **A peer credential check, which e-stop did not need.** `Listener::accept` reads the peer's credentials off the socket and drops a connection from any user other than the socket's owner, or one whose credentials cannot be read. - [x] **Nothing is prepared in advance.** The daemon's one call to `Registrar::provision` is in `provision_context` (`crates/didbot-agentd/src/serve.rs`), reached only when a report names a context with no identity. Reports for one context mint it once, whether they arrive in turn (`a_context_is_minted_once_and_told_once`) or together (`two_reports_for_one_new_context_at_once_mint_it_once`). - [x] **A subagent is issued for separately.** A context is keyed by session and subagent id (`crates/didbot-agentd/src/context.rs`), and a session and its two subagents get three identities (`a_session_and_its_subagents_get_their_own_identities`). - [x] **A name that did not reach its asker is still owed.** The mark that makes a context's next report silent is written while its turn is held, so two reports arriving at once still mint one account and name it once, and `Daemon::exchange` takes it back when the answer fails to reach the socket. A hook whose pipe broke is named again on its next call rather than met with silence for the life of the daemon (`an_answer_that_never_reached_its_asker_leaves_the_name_still_owed`). - [x] **Whatever key material this process holds, it is the only writer of.** The design this replaces documented a lost-update race it accepted, because the cost was one unstamped agent. On private keys the cost is a leaked or twice-issued one. `crates/didbot-agentd/src/node.rs` holds an exclusive lock beside the key for the daemon's life, and the identity the key is reserved under is written by the same process. - [x] **New crate, not a role something already running grows.** There is no daemon on an agent host to grow: `didbot-setup` is one-shot and deliberately read-only, `didbot-claim` and `didbot-verify` are one-shot commands, `didbot-swarm` is a load generator, and `didbot-pds` is the server `docs/deployment.md` keeps off a laptop on purpose. Growing the server would put credential issuance inside the process holding every signing key; growing the doctor would make the thing you run to ask "why is this not working" into an issuer. So `didbot-agentd`, carrying the daemon and the clients that reach it. This clears the bar this epic set — it is not a second daemon on an agent host, it is the first.