--- id: node title: A host holds one key, and creates an agent for every context on it status: open crates: [didbot-agentd] dependsOn: [ownership] exitCriterion: > A host the human admitted creates one agent per context beneath itself with a JWT its key signs, a machine whose key nobody admitted is refused, and many hooks starting at once produce one create per context rather than one per hook. --- # node An agent host is an account whose `bot.did.credential` is a key held on the machine. `didbot-agentd` is the process that holds it. [ownership](ownership.md) is the tree the host sits in; [docs/agentd.md](../docs/agentd.md) draws the daemon as a reader meets it. A hook is a short-lived process spawned per tool call. Putting the host's key in it would mean every one of those processes reads the key off disk, and a machine running several sessions and their subagents would fire many hooks at once, each asking the server for an account. One long-lived process holds the key, and is the one place what reaches the server is serialised. The cost is another thing to install, supervise and version on every agent host; [deploy](deploy.md) already treats agent hosts as their own deployment. ## 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. 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. Under socket activation the daemon does not create its own socket, so every guarantee on the socket has to be expressed twice: in the unit, and in code for the path where it starts on its own. - [ ] **Installing unit files is an install step of the agent host's installer**, the shell the `didbot-claude` plugin owns: it writes the unit files and reports whether the plugin is present. Nothing here edits a person's settings file. ## The key - [ ] **Whether the key may be P-256, which a keystore can hold.** The key signs JWTs, never a repository commit, so atproto's two curves do not bind it. Every keystore on the hosts this project targets does P-256 and none do secp256k1. `didbot-key`'s `secp256r1` module already reads the multibase form, and the credential record carries the key as a multikey, so the curve can be a per-host fact. **This is a credential type and needs a human's approval before it is built.** - [ ] **Say what is in the trusted set, and check it.** The hook the harness runs supplies which context is calling, and the server binary holds every signing key. Both are trusted and neither is verified: nothing pins the hook a harness runs or says how a deployment knows its server binary is the one it built. - [ ] **Per-agent unix users or containers**, for deployments that want the boundary lower than one user. A deployment choice, not a requirement. ## The local transport is a privilege boundary - [ ] **Reaching the socket is the permission.** The same caller could read the key from the same filesystem, so the directory's and the socket's permissions do the real work. Nothing a caller presents over the socket decides whether it is allowed. - [ ] **A caller that cannot reach the daemon is refused, legibly.** Silence must not degrade into an unattributed write. - [ ] **Nothing the daemon accepts from a hook may widen anything.** A hook-asserted fact is a claim about accounts this server created, checkable against what it issued, and never taken on its word. A component sitting between the hook and the server is well placed to erode that. ## Done - [x] **A context binding is a credential at rest.** `contexts.json` in the `0700` state directory carries the context key, the account's DID and that account's token — the token is what the daemon presents to poll and approve sign-ins, the server sends it 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; this is the first file the daemon keeps that is a credential for an account rather than for the host. - [x] **What ends a binding.** A context the harness has said nothing about for `DIDBOT_CONTEXT_TTL_DAYS` — 1 to 365 days, 30 by default — is dropped at the next sweep, and its credential with it. The account stays standing at the server, since a name is never returned to the pool. - [x] **Name a context after both halves of its key.** `account_id` (`crates/didbot-agentd/src/serve.rs`) builds a label from a readable prefix and a digest of the session and the subagent together, so two sessions reusing one subagent id are two contexts with two accounts. - [x] **The shape.** One `0600` file, `contexts.json`, in the `0700` state directory, written through a synced temporary and a rename under the lock the daemon already holds for its life. It carries the key, the DID, the token, the harness's word for the kind, the askers already told, and when the harness last reported. Read back at start-up, and every account in it the harness has not ended is followed for sign-ins again; a file that does not parse refuses the start by name, because it is the credential for every account in it. - [x] **Do not linger.** `crates/didbot-agentd/didbot-agentd.service` is a systemd user unit wanted by `default.target`, so it starts with the login session and stops with it rather than holding the host's key while nobody is logged in. - [x] **Cap what is creating at once, and publish the bound.** `[tree] max_children` and `creates_per_hour` bound one creator's accounts and rate, `max_depth` bounds the chain, and `bot.did.stats` answers the three in force as `tree`, read live so an edit is visible. - [x] **The bootstrap, concretely.** `didbot register host ` mints the key on the host and parks its public half at the server; the human runs `didbot operate ` elsewhere, confirms the fingerprint, and the server writes the key as the host's first `bot.did.credential`. No secret crosses between them. - [x] **One key per host**, 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. Its module doc says what that is worth: the key is a file, and a 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.** `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 create 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 created 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 accounts (`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, 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 (`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.** `crates/didbot-agentd/src/node.rs` holds an exclusive lock beside the key for the daemon's life. - [x] **New crate, not a role something already running grows.** Growing the server would put credential issuance inside the process holding every signing key, and `didbot operate` is a one-shot command. So `didbot-agentd`, carrying the daemon, `didbot register` and `didbot oauth`.