The command line #
What commands didbot has, who runs which, where each is installed, and how
they fit together. cli is the epic that owns the
decisions; crates/didbot-dispatch, crates/didbot-cli,
crates/didbot-operator and crates/didbot-agentd are the code, and
the shape of the system draws the hosts these run on.
Six binaries #
A person types didbot. What runs is one binary per place the code runs,
each versioned and installed on its own.
| Binary | Runs on | Commands |
|---|---|---|
didbot-pds |
the server, as a container of the release image under one systemd unit | didbot-pds --help lists its flags. No verb reaches it |
didbot |
the operator's own machine, and every agent host | didbot --list, didbot help <verb>, and every verb below — each one run by the binary it is listed under |
└─ didbot-operator |
the operator's own machine | didbot operate, didbot login, didbot estop, didbot account, didbot app, didbot announce |
└─ didbot-oauth |
an agent host | didbot oauth pending, didbot oauth show, didbot oauth approve, didbot oauth decline |
└─ didbot-register |
the machine that will be the account | didbot register |
didbot-agentd |
an agent host, as one long-running process | none; the environment is its whole configuration |
didbot is a router and nothing more. It carries a table of the first-party
verbs, the binary that runs each and the crate that ships it, so a verb whose
binary is absent is met with what to install rather than "unknown command".
What didbot does with a verb #
didbot <verb> … looks the verb up in its table, finds the binary on
PATH, and replaces itself with it. The verb receives the rest of the
command line as typed and this process's environment with every credential
variable removed: DIDBOT_ACCOUNT_TOKEN and DIDBOT_ACCOUNT_TOKEN_FILE. Nothing
is added. On unix the verb's process replaces the dispatcher's, so the verb's
exit status is the shell's.
didbot --list scans PATH for didbot-*, asks each for --version, and
prints name, version and path; a binary that does not respond inside three
seconds is listed with ?. A first-party binary that is not installed is
named after the list, with the verbs it would run:
didbot 0.1.0 /home/operator/.cargo/bin/didbot
didbot-operator 0.1.0 /home/operator/.cargo/bin/didbot-operator
didbot-oauth: not installed (oauth)
didbot-register: not installed (register)
didbot help <verb> is didbot <verb> --help, resolved the same way; didbot help alone prints the dispatcher's own help.
The operator's commands #
didbot-operator holds two sign-ins. operate authenticates to the
operator's own atproto account and writes bot.did.operator into their
own repository; the session it keeps is checked by the operator's own PDS.
login starts the deployment's own sign-in and keeps the session the
deployment minted; estop, account, app and announce present that
session to the same /dashboard/api/* routes the dashboard's buttons call.
Admitting an account uses both: the record goes in with the first, and the
create at the deployment carries the second. Neither session works where
the other does.
didbot operate <NAME> <OPERATOR> signs in to the operator's own account
through OAuth. It asks for one scope,
repo:bot.did.operator?action=create&action=update&action=delete, beside
atproto, for a server and an account alike. A browser opens for the
consent screen once; the session is kept in
$XDG_CONFIG_HOME/didbot-operator/sessions.json and resumed on the next run
while its grant covers what the run asks for. What follows depends on the
name: a name that answers describeServer with its own DID is a server, and
any other name, or one given with --kind or --oidc, is an account.
A server's own hostname is the top edge. The command waits for the server to answer over TLS, checks its identity, writes the record naming it, prints the record's URI and returns. The server polls the operator's repository on its own interval and finds the claim there.
An account is admitted beneath a server the operator already claimed:
didbot operate kestrel.pds.example op.example --kind agent. Which server
is read from the operator's own repository, with no credential: the name
must end in .<SERVER> for a server that repository holds
bot.did.operator/<SERVER> for, and --server <HOSTNAME> picks one when it
sits under more than one. Nothing that answers at the name has a say. The
record naming the account goes into the operator's repository, keyed by the
name. Then the server is asked to create did:web:<NAME>, carrying the
operator session didbot login --server <SERVER> kept. Before writing
anything the command checks that session with one read at the server. The
server keeps operator sessions in memory, so a restart ends them. With no
session kept, or one the server refuses, the command signs in the way
didbot login does, keeps the new session, and then goes on. The server checks the session and reads the record;
it creates only when both stand. The credential for the operator's own
account never reaches the server. A server that refuses the create,
including for a session that has aged out, has the record taken back — deleted, or put back as it
stood before the run — and the refusal says which. A key didbot register
parked under the name is shown with its kind and fingerprint and admitted on
a y; --fingerprint SHA256:..., copied from what register printed,
admits the key parked with it without the question, even when another key
was parked under the name after it. It goes to the server in the create and
into no record. The newest park's kind wins over --kind; an older park's
is --kind's, or else the newest park's, and the server refuses a kind
that is not the park's own. With nothing parked, --kind says what the
account is. --oidc <ISSUER> <CLAIM=VALUE>... makes a token from that issuer a way of signing in, beside
a parked key or alone: every claim named must equal its value, a number or a
boolean spelled as JSON spells it, and a name beginning with / is a JSON
pointer into a nested claim (/kubernetes.io/namespace=agents). From AWS
OIDC to didbot has the lines for each platform. The admitted
key and the OIDC identity go to the server in the create, and the server
writes each as one of the account's own bot.did.credential records: that
is where what logs in as the account lives.
The record's creates says what the account may create, and only a record
in the operator's own repository grants it. --creates [KIND...] lets the
account create beneath itself and --creates-beneath [KIND...] lets every
account beneath it create, at any depth; each names the kinds it allows, one
of the four --kind takes, and either flag with no kinds allows any. Both
may be given. A laptop that will run agents:
didbot operate laptop.pds.example op.example --fingerprint SHA256:... --creates agent
A pool whose hosts, and their agents, create what they need:
didbot operate pool.pds.example op.example --kind host --creates-beneath
The session the server returns is written under
$DIDBOT_STATE/accounts/<NAME>.token, the file DIDBOT_ACCOUNT_TOKEN_FILE
points didbot-oauth at.
didbot operate --check <NAME> signs in to nothing and writes nothing, and
exits 1 until it passes, so a script can wait on it. For a server it resolves
the hostname, opens HTTPS to it, reads its did:web document and
cross-checks describeServer. For an account it walks the tree: the
account's bot.did.registration/self names its parent, the parent's
bot.did.operator/<NAME> names the account back, and the walk climbs to the
operator while the parent's repository is on the same server, printing each
edge as the account, its kind, its parent and the record read, and refusing
past three. The last edge printed is the server's own: the operator the
walk ended at holds bot.did.operator/<SERVER> naming it.
didbot login --server <HOSTNAME> prints a URL and listens on a
loopback port. The deployment runs its own sign-in against the identity
--operator names and no other, identity only, and mints a session; the browser brings it back to
the loopback port and the command stores it in
$XDG_CONFIG_HOME/didbot/operator.json, mode 0600, one entry per server.
When a command carrying that session is refused with 401, the fix the
refusal names is to run didbot login --server <HOSTNAME> again.
didbot estop --server <HOSTNAME> with no setting reports what the stop is
doing and what it has refused. --pause refuses every token and all
provisioning until the stop is released, and ends nothing. --revoke refuses
the same and ends every token, so a release does not bring them back.
--release clears the stop. One setting at a time. A service-auth token is
the exception: either setting refuses every new one and every proxied call,
but one already minted works at the service it names until its exp, at
most an hour, or a minute for the ones the proxy mints.
didbot announce --server <HOSTNAME> asks the configured relay to crawl the
deployment, and --resume calls the deprecated notifyOfUpdate instead.
didbot account <VERB> <NAME> --server <HOSTNAME> acts on one account the
deployment hosts, carrying the same session. <NAME> is the account's
handle or its did:. Each verb prints the account it reached and the ledger
entry it made, and --json prints the route's own answer:
lockrefuses the account's writes and leaves its repository readable.unlocklifts that same lock.lift-quarantinetakes off a quarantine a policy hung. The operator is the only party who may.erasetakes the repository, its history, its blobs and its credentials, and burns the name; the DID document keeps resolving, so signatures the account made stay checkable. It erases a locked or quarantined account too.deletetakes the row too, leaving the ledger. An account with accounts beneath it is refused;--recursivetakes the subtree, deepest first.revoke-tokensends every token that authenticates as the account: its account tokens, every app's login on it and its unredeemed authorization codes. It leaves the account's state and locks as they are, and prints how many account tokens and app logins ended. When nothing was live, the ledger gains no entry. An account that logs in with a key or an OpenID Connect identity takes a new session with a fresh proof afterwards.
didbot account lock kestrel.agents.example --server pds.example
didbot account delete laptop.pds.example --recursive --server pds.example
didbot account revoke-tokens deploy.pds.example --server pds.example
didbot app end-logins --client <CLIENT> --server <HOSTNAME> ends every
login one OAuth application holds on the deployment, carrying the same
session. <CLIENT> is the application's client_id, the URL its metadata
document is served at. --account <NAME> narrows it to one account, by
handle or did:; without it, every account this deployment holds. The
command prints how many logins, unredeemed authorization codes and pending
consents it took, so nothing in flight becomes a token afterwards, and the
loginsEnded entry each account a login ended on gained in its ledger. If
the deployment cannot write the ending to pds.grants, the logins still end
on the running server, and the command fails, saying they may return after a
restart.
This is what an application whose keys or tokens leaked gets. It ends those
logins for good and says nothing about whether the application may sign in
again: refusing it is a denyClient policy, written from the policy
dashboard, which stops the same logins working while it stands and lets
them work again when it lifts.
didbot app end-logins --client https://app.example/client.json --server pds.example
didbot app end-logins --client https://app.example/client.json \
--account kestrel.agents.example --server pds.example
didbot operate --revoke <NAME> is the other direction, and it runs against
the operator's own repository rather than the deployment: it deletes
bot.did.operator/<NAME> there, through the delete action of the same scope
the claim was written with, after reading the record back and confirming it
names that account. The deployment pauses the account and everything beneath
it after its grace window, six hours by default. didbot account lock <NAME>
stops an account at once, and didbot estop --pause stops a server.
The agent host's commands #
An agent host runs didbot-agentd once, and the hook the
didbot-claude plugin
installs reports every harness event to it over a unix socket. The daemon
provisions one account per context, holds each account's token, and follows
the sign-in requests the server holds for those accounts. didbot oauth is
the one command an agent runs, and everything in it names a request — a
token, or the URL of one — and never an account. The daemon on an agent
host draws the daemon's inside; this picture is the commands and
the socket between them.
didbot oauth pending prints one line per request an agent on this host
reported seeing, each with its token; --all adds the requests none did, each
line starting not-seen-by-any-agent-here. didbot oauth approve <token> lets
one in and didbot oauth decline <token> [--reason WHY] turns it down. A
token names one request, is good once, and is not an account: the daemon acts
as the account named by the record it holds that token in. When no hook saw
the client print its URL, didbot oauth show <url> looks the request up by
that URL, and didbot oauth approve --url <url> and
didbot oauth decline --url <url> [--reason WHY] settle it.
didbot register <KIND> <NAME> makes the machine it runs on an account at
the server --server <HOSTNAME> or DIDBOT_PDS names, as a hostname or a
URL; <NAME> must end in .<HOSTNAME> as that server's describeServer
reports it, and nothing is asked at the name. It mints a key under
$DIDBOT_STATE — the daemon's own key for a host, keys/<NAME>.key for a
service, pipeline or agent — and parks the public half at the server
under the name, printing the fingerprint and the didbot operate <NAME> --fingerprint ... to run on the operator's machine. Parks are held per key,
so another machine's park under the same name does not stop this one from
parking; didbot operate shows the newest park under the name. It then
waits, --timeout seconds (default
600), for the operator to admit the key, signs in as did:web:<NAME> with a
JWT the key signs, and keeps the session under
$DIDBOT_STATE/accounts/<NAME>.token. With --token-file <PATH> or
--token-stdin and --under <PARENT>, an OIDC token is the parent instead:
the account is created now, beneath the account whose credential records
name that issuer, with the fresh key as its own first credential record. A
token is never taken on the command line, where ps would show it.
didbot-agentd takes no options. DIDBOT_PDS names the server that mints
accounts, as a hostname or a URL, and is required; DIDBOT_SOCK moves the socket from
$XDG_RUNTIME_DIR/didbot-agent/agent.sock; DIDBOT_STATE moves the host's
key and its context bindings from $XDG_STATE_HOME/didbot/agentd;
DIDBOT_CONTEXT_TTL_DAYS is how long a context keeps its account after the
harness last reported it, 1 to 365 days and 30 by default. The hook and
didbot oauth read DIDBOT_SOCK too, so all three agree on where the socket
is. The daemon
creates one account per context beneath the host didbot register host
made it, presenting a JWT its key signs; the server checks it against the
host's document.
A host with no daemon runs the binary by name. With DIDBOT_PDS and either
DIDBOT_ACCOUNT_TOKEN or DIDBOT_ACCOUNT_TOKEN_FILE set, didbot-oauth pending,
show, approve and decline talk to that server as that one account, and
pending lists every request waiting for it; --direct insists on it rather
than trying the socket first. Through the
dispatcher both credential variables are removed before the hand-off, so
didbot oauth reaches the socket and nothing else. An account whose
document names an OpenID Connect identity signs in with --token-file <PATH>
or --token-stdin instead: didbot oauth pending --token-file /run/secrets/id-token opens a session at DIDBOT_PDS through
bot.did.createSession, then runs the command as that account. --token <JWT> is refused by name, so a token is never an argument ps can show.
Installing #
Every binary is a crate in one workspace, and cargo install takes the
crate's name. The operator's own machine:
cargo install --git https://tangled.org/permadeath.com/didbot didbot-dispatch
cargo install --git https://tangled.org/permadeath.com/didbot didbot-operator
An agent host, where the didbot-agentd crate carries didbot-agentd,
didbot-oauth and didbot-register:
cargo install --git https://tangled.org/permadeath.com/didbot didbot-dispatch
cargo install --git https://tangled.org/permadeath.com/didbot didbot-agentd
The hook that reports to the daemon is the didbot-claude plugin, installed
from Claude Code:
claude plugin marketplace add git@tangled.org:permadeath.com/didbot-claude
claude plugin install didbot@didbot
The server runs didbot-pds as a container of the release image, built from
the repository's Dockerfile and started by one systemd unit;
deployment says how the image is built and run, and
operations how it is upgraded and rolled back. The same
binary runs on a laptop: it is the didbot-serve crate's, so cargo install --git https://tangled.org/permadeath.com/didbot didbot-serve installs it, and
scripts/dev-pds.sh runs it from a checkout.
Extending #
didbot foo runs didbot-foo from PATH. The program receives the rest of
the command line as its own arguments and the ordinary environment, less
DIDBOT_ACCOUNT_TOKEN and DIDBOT_ACCOUNT_TOKEN_FILE; nothing is added, and no
credential from the shell reaches it. It authenticates for itself, the way
the first-party verbs do. didbot --list shows it beside them, with whatever
its --version prints, and didbot help foo runs didbot-foo --help.
Conventions #
Every binary accepts --help and --version, prints on stdout and exits 0.
--version prints <name> <version>, which is what didbot --list shows.
Three exit statuses: 0 did it, 1 understood the request and could not or
would not do it, 2 did not understand the request. A refusal is one line on
stderr, <program>: <message>, where the program is the spelling the person
typed: didbot estop: --server <HOSTNAME> is required.
Every didbot-operator verb takes --server <HOSTNAME>, the deployment to
act on, and --json, the result as one line of JSON on stdout for a script.
Both read the same before or after the verb. didbot register takes
--server <HOSTNAME> or reads DIDBOT_PDS; didbot oauth names its server
through the daemon, or through DIDBOT_PDS when run by name. A server is
spelled the same way in both, a hostname such as pds.example or a URL
such as https://nc.localhost:3413. A hostname with no port is reached on
443, unless DIDBOT_RESOLVE_PORTS says that zone answers somewhere else.
DIDBOT_PDS is not a credential, and the dispatcher passes it through.