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
is the tree the host sits in; 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 already treats agent hosts as their own deployment.
What supervises it #
The key #
The local transport is a privilege boundary #
Done #
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 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.
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.
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.
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.
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.
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.
The bootstrap, concretely. didbot register host <name> mints the
key on the host and parks its public half at the server; the human
runs didbot operate <name> elsewhere, confirms the fingerprint, and
the server writes the key as the host's first bot.did.credential.
No secret crosses between them.
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.
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.
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.
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).
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).
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).
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.
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.