--- id: credentials title: A session gets a credential without a wrapper process status: open crates: [didbot-attest] dependsOn: [oauth, attestation] exitCriterion: > An ordinary session, configured only through settings.json, receives a credential scoped to its own agent account and writes with it. --- # credentials The system installs as configuration the harness already reads, and may not require a wrapper. That costs the kernel-level session marker a wrapper would give, and moves the anchor up to the node. The shape mirrors SPIFFE: a node attests once, a local component issues credentials, and the workload talks to the server itself. Nothing brokers a write, and nothing assumes co-location. - [ ] **The node component: device identity and credential issuance.** Split out into [node](node.md), because it is a process with a lifetime, an install story and a privilege boundary of its own. - [ ] **Nothing secret is delivered through the harness at all.** The one hook mechanism that reaches later tool calls is session-scoped and its contents are readable by any shell command the model asks for, so what a client needs is a socket path it can compute rather than a credential it is handed. [cred-delivery](cred-delivery.md) holds the threat model that decides this, and the daemon holds the credential. - [ ] **A subagent is a context and is issued for like one.** [subagents](subagents.md) settled that: the custody boundary is the context rather than the session. What it costs here is that issuance cannot ride session-scoped environment, so it happens over the daemon's socket — see [cred-delivery](cred-delivery.md). - [ ] **An agent needs no credential to authorize an app.** Its write credential is not also its authorization credential: what a sign-in needs is the one-time token on a decision record, which names no account, so nothing has to sit in a model's context for one to work. What the app ends up holding afterwards is a different question, and [cred-delivery](cred-delivery.md) answers it rather than leaving it implied. - [ ] **Say what is in the trusted set, and check it.** The hook binary supplies an identity the model cannot forge, and the server binary holds the keys. 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. A deployment choice, not a requirement. - [ ] **Something with no harness still needs a credential.** Everything above is session-scoped by construction, and a CI runner or a daemon has no hook to deliver anything. Half of this is now answered: there is nothing left to stamp, because `didbot-oauth` names a decision rather than an account, and it runs with no daemon and no hook at all against `DIDBOT_PDS` and `DIDBOT_AGENT_TOKEN` — so a runner holding a credential can spend one. How it comes to hold one is the half still open. See [account-types](account-types.md). ## What the daemon authenticates with The daemon presents each context's agent token on the four sign-in decision routes, and `didbot-oauth` direct mode spends one from the environment. [auth-types](auth-types.md) names that token as the one to retire. This section decides what replaces it before any of it is built. - [ ] **One OAuth session per context, taken by attestation.** The server already issues the credential this wants: a DPoP-bound access token that lives five minutes, and a refresh token that rotates on every use, where a replay ends the family (`oauth::token`). Taking it with a node-signed claim, rather than by approving a sign-in with the agent token, is what takes the agent token out of the daemon. A legacy session from `createSession` also rotates, but it is a bearer token. An OAuth token is useless without the key it is bound to. - [ ] **That is a new credential, and it waits for the owner's approval.** Three things change about what the server accepts: - A token grant whose authorization is a node-signed claim rather than a code. `AttestationClaim` names the host and nothing it is for, so it has to gain the account and the token endpoint. Otherwise one claim can be spent against another account. - `Credential::AgentSelf` accepting a DPoP-bound token, and only one this grant issued. An app an agent signed in to must not be able to approve further sign-ins as that agent. - `didbot-oauth` direct mode signing that claim, which puts a node key on a host with no daemon. Until then the daemon keeps the agent token, and no call changes which credential it presents. - [ ] **A restart resumes a context only if its account is stored too.** Sessions are keyed by account, not by context. Keeping which context holds which account across a restart is a new field in the context store, and it needs its state machine reviewed first. ## Done - [x] **What is stored per session is an existing credential.** Keyed by the account's DID, the issuer and the OAuth client's `client_id`: the access token and its expiry, the refresh token, the granted scopes, the DPoP private key, the last nonce from each of the authorization server and the resource server, and the endpoints it refreshes against. That is `jacquard_oauth::session::ClientSessionData`, which `didbot-claim` already keeps. `didbot_agentd::sessions::SessionStore` keeps many at once, one `0600` file each in a `0700` directory, written through a synced temporary and a rename. One stored copy can serve both the context store and the poller: `SessionStore::update` holds a lock per session across a refresh, so a rotated refresh token has one place to land, and a refresh refused for good removes the session. - [x] **At rest, file permissions alone.** The node key sits in the same directory under the same permissions, so a key derived from it protects nothing from whoever can read either file. The OS keyring needs a desktop Secret Service, which a headless agent host does not run. A session file or directory found wider than its owner stops the store from opening, naming the path. Adjacent, and worth knowing about here: a harness client carries a write credential (`didbot_pds::credential`, minted at provisioning) by stamping it onto the tool call, not through `CLAUDE_ENV_FILE`. That solves `plan/auth-types.md`'s narrower problem — a credential has to exist and reach the write path at all, for the repo write surface to require one without breaking every write this deployment already makes — without solving this epic's actual shape: a credential a hook stamps into one tool call is bound to that call, not delivered to the session for every later tool execution the way this epic's `CLAUDE_ENV_FILE` item asks for. It also does not touch node identity, the trusted-set question, or either of the per-agent boundary items below. Whether the stamped credential is the same thing this epic should end up issuing, or a stopgap this epic's delivery mechanism eventually replaces, is not decided here.