From 9e46cab2c268c75deabd040d76339a2a0ebc0786 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Tue, 22 Sep 2026 09:04:10 -0400 Subject: [PATCH] feat(agentd)!: name a context after its session and its subagent A name built from the subagent id alone asked the server for one account for two contexts whenever a harness reused that id across sessions. It now carries a readable prefix and a digest of the pair, which fits the one DNS label the server takes. Co-Authored-By: Claude Opus 5 (1M context) Change-Id: Ie6171f2ffa3e471bfedc3b85b2548dcf690db276 --- Cargo.lock | 1 + crates/didbot-agentd/Cargo.toml | 1 + crates/didbot-agentd/src/serve.rs | 145 +++++++++++++++++++++++++----- crates/didbot/tests/scenarios.rs | 6 +- docs/agentd.md | 5 +- docs/first-hour.md | 10 ++- plan/node.md | 14 +-- 7 files changed, 140 insertions(+), 42 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 19b362e5..0fe7de81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1085,6 +1085,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "sha2", "thiserror", "time", "tokio", diff --git a/crates/didbot-agentd/Cargo.toml b/crates/didbot-agentd/Cargo.toml index cad70b8d..a56eff2a 100644 --- a/crates/didbot-agentd/Cargo.toml +++ b/crates/didbot-agentd/Cargo.toml @@ -19,6 +19,7 @@ hex.workspace = true rand_core.workspace = true reqwest.workspace = true serde.workspace = true +sha2.workspace = true serde_json.workspace = true thiserror.workspace = true time = { workspace = true, features = ["serde-well-known"] } diff --git a/crates/didbot-agentd/src/serve.rs b/crates/didbot-agentd/src/serve.rs index 2e4ed776..9523fec4 100644 --- a/crates/didbot-agentd/src/serve.rs +++ b/crates/didbot-agentd/src/serve.rs @@ -37,6 +37,7 @@ const CREATE_ACCOUNT: &str = "bot.did.createAccount"; /// The kind every account the daemon creates is registered as. const AGENT_KIND: &str = "agent"; use crate::socket::Listener; +use sha2::{Digest, Sha256}; /// One asker owed one context's identity, until the answer carrying it has /// been written: what [`Store::told`](crate::context::Store::told) is given @@ -728,15 +729,52 @@ fn asker(report: &Report) -> &str { report.asker.as_deref().unwrap_or("-") } -/// The label a context's DID is built from. +/// How much of a name is the readable half, in bytes. +const PREFIX_BYTES: usize = 24; + +/// How many hex digits of the digest a name carries. +/// +/// Sixty-four bits. The digest is what makes one context's name its own, and +/// a name a second context also wants is refused by the server as an account +/// that already exists, so the cost of a collision is one legible refusal +/// rather than two contexts sharing an account. +const DIGEST_CHARS: usize = 16; + +/// The label a context's DID is built from: both halves of its key. +/// +/// A context is a session and a subagent together, so its name is too. Two +/// sessions whose harness hands out one subagent id are two contexts, and a +/// name built from the subagent alone asks the server for one account for +/// both of them. /// -/// A subagent id is already unique on the machine; a session with no subagent -/// is identified by the session alone. Both are uuids from the harness, so -/// this narrows the alphabet rather than shortening: what comes back has to -/// survive being a DNS label, and a harness that starts issuing ids with an -/// underscore in them should produce a duller name, not a broken account. +/// The server takes one DNS label of at most 63 bytes, lowercase letters, +/// digits and hyphens, and the harness's identifiers are uuids, so the pair +/// does not fit as written. What a name carries is a readable prefix — the +/// acting context, or the session when the session is acting — and a digest +/// of the exact pair beneath it. fn account_id(key: &Key) -> String { - let raw = key.context.as_deref().unwrap_or(&key.session); + let mut digest = Sha256::new(); + digest.update(key.session.as_bytes()); + // Which half is which, so a session named for another session's subagent + // is still a different context. + digest.update([u8::from(key.context.is_some())]); + digest.update(key.context.as_deref().unwrap_or_default().as_bytes()); + let digest = hex::encode(digest.finalize()); + + let prefix = readable(key.context.as_deref().unwrap_or(&key.session)); + if prefix.is_empty() { + return digest[..DIGEST_CHARS].to_owned(); + } + format!("{prefix}-{}", &digest[..DIGEST_CHARS]) +} + +/// As much of an identifier as a DNS label carries. +/// +/// Narrows the alphabet rather than shortening it: a harness that starts +/// issuing ids with an underscore in them should produce a duller name, not a +/// broken account. Every character it keeps is ASCII, so the cut is on a +/// character boundary. +fn readable(raw: &str) -> String { let clean: String = raw .chars() .map(|c| { @@ -746,8 +784,9 @@ fn account_id(key: &Key) -> String { '-' } }) + .take(PREFIX_BYTES) .collect(); - clean.trim_matches('-').to_string() + clean.trim_matches('-').to_owned() } /// The hostname a context's account is minted from: its label under the @@ -863,9 +902,27 @@ mod tests { } } - /// The identity a `Counting` registrar hands the only context these - /// tests use. - const MINTED: &str = "did:web:a-1.example"; + /// The identity a `Counting` registrar hands a context of the session + /// these tests report as. + /// + /// Worked out the way the daemon works it out, rather than written down: + /// what the name is made of is what the tests around it are checking, and + /// a copy here would agree with a wrong answer. + fn minted(context: Option<&str>) -> String { + let key = Key { + session: "sess-1".into(), + context: context.map(Into::into), + }; + format!( + "did:web:{}", + account_name(SERVER, &account_id(&key)).expect("a name under the server's zone") + ) + } + + /// The one these tests use most. + fn minted_a1() -> String { + minted(Some("a-1")) + } /// Wait for something the poller does in the background, or give up. /// @@ -890,7 +947,7 @@ mod tests { let (daemon, _scratch) = daemon("once", Counting::default()); let first = daemon.consider(report(Observed::Began, Some("a-1"))).await; - assert_eq!(first.identity.as_deref(), Some("did:web:a-1.example")); + assert_eq!(first.identity.as_deref(), Some(minted_a1().as_str())); // Every later call for the same context is silent, so the adapter has // nothing to inject and the model is not told its name again. @@ -932,7 +989,7 @@ mod tests { // The same context reporting again is named rather than met with // silence, and no second account is minted for it. let answer = daemon.consider(report(Observed::Acted, Some("a-1"))).await; - assert_eq!(answer.identity.as_deref(), Some(MINTED)); + assert_eq!(answer.identity.as_deref(), Some(minted_a1().as_str())); assert_eq!(daemon.registrar.minted.load(Ordering::SeqCst), 1); } @@ -954,7 +1011,7 @@ mod tests { }) .unwrap(); let named = first.consider(report(Observed::Began, Some("a-1"))).await; - assert_eq!(named.identity.as_deref(), Some(MINTED)); + assert_eq!(named.identity.as_deref(), Some(minted_a1().as_str())); let held = first.contexts().await[0] .token .as_ref() @@ -1027,7 +1084,7 @@ mod tests { .flatten() .collect(); assert!(!told.is_empty(), "the context was named"); - assert!(told.iter().all(|did| did == MINTED), "{told:?}"); + assert!(told.iter().all(|did| *did == minted_a1()), "{told:?}"); } #[tokio::test] @@ -1048,7 +1105,7 @@ mod tests { }) .unwrap(); let answer = daemon.consider(report(Observed::Acted, Some("a-1"))).await; - assert_eq!(answer.identity.as_deref(), Some("did:web:a-1.example")); + assert_eq!(answer.identity.as_deref(), Some(minted_a1().as_str())); } /// A host whose operator record is not there yet is refused by the @@ -1071,7 +1128,7 @@ mod tests { *daemon.registrar.refusing.lock().unwrap() = None; let answer = daemon.consider(report(Observed::Acted, Some("a-1"))).await; - assert_eq!(answer.identity.as_deref(), Some(MINTED)); + assert_eq!(answer.identity.as_deref(), Some(minted_a1().as_str())); assert_eq!(daemon.registrar.minted.load(Ordering::SeqCst), 1); } @@ -1085,7 +1142,7 @@ mod tests { daemon.consider(report(Observed::Began, Some("a-1"))).await; let wanted = daemon.registrar.last.lock().unwrap().clone().unwrap(); - assert_eq!(wanted.name, "a-1.example"); + assert_eq!(wanted.name, minted_a1().trim_start_matches("did:web:")); assert_eq!(wanted.kind, "agent"); let claims = crate::jwt::verify(wanted.credential.reveal(), &daemon.node().verifying_key()) @@ -1111,7 +1168,10 @@ mod tests { let answer = daemon .consider(report(Observed::Acted, Some("quiet"))) .await; - assert_eq!(answer.identity.as_deref(), Some("did:web:quiet.example")); + assert_eq!( + answer.identity.as_deref(), + Some(minted(Some("quiet")).as_str()) + ); } #[tokio::test] @@ -1176,7 +1236,7 @@ mod tests { // A version 1 adapter is the ordinary case, not a skew to refuse: // packages update at different times, and everything it sent is // still read. - assert_eq!(answer.identity.as_deref(), Some(MINTED)); + assert_eq!(answer.identity.as_deref(), Some(minted_a1().as_str())); assert!(answer.trouble.is_none()); } @@ -1208,7 +1268,7 @@ mod tests { /// A record the double will hand out, for the account `Counting` mints. fn offered(double: &double::Double, request_uri: &str, token: &str) -> Record { - let mut record = double::record(request_uri, MINTED, Some(token), &double::later()); + let mut record = double::record(request_uri, &minted_a1(), Some(token), &double::later()); // The client's callback is served by the double itself, so a // delivered code actually arrives somewhere. record.client.origin = double.origin.clone(); @@ -1466,7 +1526,7 @@ mod tests { // The record exists and names an account that is not this daemon's, // and the double enforces what the real route does: a record goes // only to the account it names. - double.state.authenticates("agent-token", MINTED); + double.state.authenticates("agent-token", &minted_a1()); let mut theirs = double::record( "r-theirs", "did:web:somebody.else", @@ -1494,7 +1554,7 @@ mod tests { #[tokio::test] async fn and_one_for_an_account_it_does_hold_is_found_among_several() { let double = double::start().await; - double.state.authenticates("agent-token", MINTED); + double.state.authenticates("agent-token", &minted_a1()); double.state.know(offered(&double, "r-mine", "k-mine")); let (daemon, _scratch) = daemon_with(&double).await; // A second context, so `find` has more than one account to try. @@ -1584,13 +1644,50 @@ mod tests { assert_eq!(pending[0].client_origin, double.origin); } + /// Whatever the harness invents, the name is a DNS label the server + /// takes: lowercase, no punctuation, and inside the length a label + /// carries. #[test] fn an_id_the_harness_invents_survives_becoming_a_label() { let key = Key { session: "s".into(), context: Some("Agent_07:B".into()), }; - assert_eq!(account_id(&key), "agent-07-b"); + assert!( + account_id(&key).starts_with("agent-07-b-"), + "{}", + account_id(&key) + ); + + // Two uuids, which is what a harness actually sends. + let long = Key { + session: "e0d3a1f2-6b4c-4d5e-8f90-112233445566".into(), + context: Some("9a8b7c6d-5e4f-4321-abcd-0f1e2d3c4b5a".into()), + }; + let label = account_id(&long); + assert!(label.len() <= 63, "{label} is {} bytes", label.len()); + didbot_identity::validate_label(&label).unwrap_or_else(|err| panic!("{label}: {err}")); + } + + /// The bug a name built from the subagent alone had: a harness that + /// hands two sessions the same subagent id was asking for one account + /// for two contexts. + #[tokio::test] + async fn two_sessions_reusing_one_subagent_id_get_two_accounts() { + let (daemon, _scratch) = daemon("reused", Counting::default()); + + let mut first = report(Observed::Began, Some("a-1")); + first.session = "sess-1".into(); + let mut second = report(Observed::Began, Some("a-1")); + second.session = "sess-2".into(); + + let one = daemon.consider(first).await; + let two = daemon.consider(second).await; + + assert!(one.identity.is_some(), "{one:?}"); + assert_ne!(one.identity, two.identity); + assert_eq!(daemon.registrar.minted.load(Ordering::SeqCst), 2); + assert_eq!(daemon.contexts().await.len(), 2); } /// Any process running as this user can connect, so a line with no end diff --git a/crates/didbot/tests/scenarios.rs b/crates/didbot/tests/scenarios.rs index 36075fc5..f8a7d2b0 100644 --- a/crates/didbot/tests/scenarios.rs +++ b/crates/didbot/tests/scenarios.rs @@ -3540,7 +3540,7 @@ async fn a_revoked_roots_grandchildren_stop_creating_with_it() { .await .ok(); let daemon = Daemon::start(&machine, server).await; - daemon.agent("ctx-1").await; + let agent = daemon.agent("ctx-1").await; // A create by the service itself, with a fresh ID token each time. let create_beneath_web = |n: usize| { let token = stack @@ -3616,7 +3616,9 @@ async fn a_revoked_roots_grandchildren_stop_creating_with_it() { ); assert_eq!( list_record_keys(&server.origin, &did_of(&host), "bot.did.operator").await, - vec![rkey_of(&format!("ctx-1.{}", server.host))], + vec![rkey_of( + &agent.trim_start_matches("did:web:").replace("%3A", ":") + )], "nothing landed beneath the host after the service's record went" ); } diff --git a/docs/agentd.md b/docs/agentd.md index c1c8d6f3..0172fd6b 100644 --- a/docs/agentd.md +++ b/docs/agentd.md @@ -180,8 +180,9 @@ default. The account stays standing at the server, and nothing is written to the ledger about it. Names come from a registrar. The one that speaks to this project's server -calls `bot.did.createAccount` for a name made from the harness's identifier -for the context under the server's zone, kind `agent`, and a JWT the +calls `bot.did.createAccount` for a name made from both halves of the +context's key — a readable prefix and a digest of the session and the +subagent together — under the server's zone, kind `agent`, and a JWT the host's key signs — `iss` the host, `aud` the server, `lxm` `bot.did.createAccount`. The server checks it against the host's document, writes `bot.did.operator/` into the host's diff --git a/docs/first-hour.md b/docs/first-hour.md index b290871e..11d1be54 100644 --- a/docs/first-hour.md +++ b/docs/first-hour.md @@ -84,11 +84,13 @@ is refused. Only then start the daemon: it holds the same key, and ```sh DIDBOT_PDS=nc.localhost:3413 didbot-agentd echo '{"hook_event_name":"SessionStart","session_id":"sess-1","cwd":"/tmp"}' | didbot-hook -didbot operate --check sess-1.nc.localhost:3413 +didbot operate --check ``` -The hook is the `didbot-claude` plugin's; the daemon answers it with the -agent it created, and the walk climbs two edges. +The hook is the `didbot-claude` plugin's. The daemon answers it with the +agent it created, and the hook prints that DID. A context's name is built +from its session and its subagent together, so read it off that answer. The +walk climbs two edges. ## A pipeline and its runs @@ -110,7 +112,7 @@ own repository, and nudges the poll: ```sh curl -X POST localhost:3413/xrpc/bot.did.pollOperatorClaim -didbot operate --check sess-1.nc.localhost:3413 # exit 1 +didbot operate --check # exit 1 ``` The host and every agent beneath it are locked `quarantined (parent)`: diff --git a/plan/node.md b/plan/node.md index 609c38ce..21d8b93a 100644 --- a/plan/node.md +++ b/plan/node.md @@ -60,16 +60,6 @@ host; [deploy](deploy.md) already treats agent hosts as their own deployment. - [ ] **Per-agent unix users or containers**, for deployments that want the boundary lower than one user. A deployment choice, not a requirement. -## What a restart keeps - -- [ ] **Name a context after both halves of its key.** `account_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. Changing what a name is built from - changes the names accounts are created under, so it belongs to the - same review. - ## The local transport is a privilege boundary - [ ] **Reaching the socket is the permission.** The same caller could read @@ -99,6 +89,10 @@ host; [deploy](deploy.md) already treats agent hosts as their own deployment. 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 -- 2.51.2