From 56cf452ecf5ce98d0991d7c9800ed40c8a060e2f Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Tue, 29 Sep 2026 10:28:26 -0400 Subject: [PATCH] feat(agentd): place a subagent's account with DIDBOT_SUBAGENT_OPERATOR Under agent a subagent is created as its session, with the session's token, beneath it; host, the default, keeps it beneath the host. A report's operator, under protocol version 3, wins over the setting, and didbot operate --check climbs four edges. Co-Authored-By: Claude Opus 5.5 Change-Id: I8353188d471a14b36b3fc827be4d18cae27f9c8c --- .../protocol/answer-browser-sign-in.json | 2 +- .../fixtures/protocol/answer-done.json | 2 +- .../fixtures/protocol/answer-identity.json | 2 +- .../fixtures/protocol/answer-pending-all.json | 2 +- .../fixtures/protocol/answer-pending.json | 2 +- .../fixtures/protocol/answer-quiet.json | 2 +- .../fixtures/protocol/answer-trouble.json | 2 +- .../protocol/ask-approve-by-token.json | 2 +- .../fixtures/protocol/ask-approve-by-url.json | 2 +- .../fixtures/protocol/ask-decline.json | 2 +- .../fixtures/protocol/ask-pending-all.json | 2 +- .../fixtures/protocol/ask-pending.json | 2 +- .../fixtures/protocol/ask-report-full.json | 3 +- .../fixtures/protocol/ask-report-minimal.json | 2 +- .../fixtures/protocol/ask-rotate.json | 2 +- .../fixtures/protocol/ask-show.json | 2 +- crates/didbot-agentd/src/bin/didbot-agentd.rs | 27 +- crates/didbot-agentd/src/context.rs | 12 + crates/didbot-agentd/src/lib.rs | 1 + crates/didbot-agentd/src/placement.rs | 58 +++ crates/didbot-agentd/src/protocol.rs | 22 +- crates/didbot-agentd/src/serve.rs | 484 ++++++++++++++++-- crates/didbot-agentd/tests/cli.rs | 28 + crates/didbot-agentd/tests/fixtures.rs | 2 + crates/didbot-operator/src/operate/walk.rs | 2 +- crates/didbot/tests/scenarios.rs | 19 +- crates/didbot/tests/scenarios/machine.rs | 61 ++- crates/didbot/tests/scenarios/stack.rs | 68 ++- crates/didbot/tests/scenarios/stranger.rs | 2 +- crates/didbot/tests/scenarios/subagents.rs | 317 ++++++++++++ docs/agentd.md | 18 + docs/cli.md | 13 +- docs/ownership.md | 2 +- plan/node.md | 2 +- plan/ownership.md | 2 +- plan/subagents.md | 97 +--- 36 files changed, 1068 insertions(+), 202 deletions(-) create mode 100644 crates/didbot-agentd/src/placement.rs create mode 100644 crates/didbot/tests/scenarios/subagents.rs diff --git a/crates/didbot-agentd/fixtures/protocol/answer-browser-sign-in.json b/crates/didbot-agentd/fixtures/protocol/answer-browser-sign-in.json index aab7a44d..8ba58dcc 100644 --- a/crates/didbot-agentd/fixtures/protocol/answer-browser-sign-in.json +++ b/crates/didbot-agentd/fixtures/protocol/answer-browser-sign-in.json @@ -1,4 +1,4 @@ { "browser_sign_in": "https://pds.example/oauth/browser?ticket=6Zb2kQyGv0V7cXq9mJ1rTn8sLw3eDfHa4uPxYoRiKc0", - "version": 2 + "version": 3 } diff --git a/crates/didbot-agentd/fixtures/protocol/answer-done.json b/crates/didbot-agentd/fixtures/protocol/answer-done.json index c0601f53..8f6ddfb7 100644 --- a/crates/didbot-agentd/fixtures/protocol/answer-done.json +++ b/crates/didbot-agentd/fixtures/protocol/answer-done.json @@ -7,5 +7,5 @@ "atproto", "repo:app.example.note" ], - "version": 2 + "version": 3 } diff --git a/crates/didbot-agentd/fixtures/protocol/answer-identity.json b/crates/didbot-agentd/fixtures/protocol/answer-identity.json index 55095b12..08096465 100644 --- a/crates/didbot-agentd/fixtures/protocol/answer-identity.json +++ b/crates/didbot-agentd/fixtures/protocol/answer-identity.json @@ -1,5 +1,5 @@ { "browser_sign_in": "https://pds.example/oauth/browser", "identity": "did:web:explorer-3f9a1c2d4e5f6071.pds.example", - "version": 2 + "version": 3 } diff --git a/crates/didbot-agentd/fixtures/protocol/answer-pending-all.json b/crates/didbot-agentd/fixtures/protocol/answer-pending-all.json index ae9f1bd5..88fb4ba6 100644 --- a/crates/didbot-agentd/fixtures/protocol/answer-pending-all.json +++ b/crates/didbot-agentd/fixtures/protocol/answer-pending-all.json @@ -34,5 +34,5 @@ "verdict": "allow" } ], - "version": 2 + "version": 3 } diff --git a/crates/didbot-agentd/fixtures/protocol/answer-pending.json b/crates/didbot-agentd/fixtures/protocol/answer-pending.json index c5167d71..bbf9bd03 100644 --- a/crates/didbot-agentd/fixtures/protocol/answer-pending.json +++ b/crates/didbot-agentd/fixtures/protocol/answer-pending.json @@ -19,5 +19,5 @@ "verdict": "narrow" } ], - "version": 2 + "version": 3 } diff --git a/crates/didbot-agentd/fixtures/protocol/answer-quiet.json b/crates/didbot-agentd/fixtures/protocol/answer-quiet.json index 85deee8e..cd2f236b 100644 --- a/crates/didbot-agentd/fixtures/protocol/answer-quiet.json +++ b/crates/didbot-agentd/fixtures/protocol/answer-quiet.json @@ -1,3 +1,3 @@ { - "version": 2 + "version": 3 } diff --git a/crates/didbot-agentd/fixtures/protocol/answer-trouble.json b/crates/didbot-agentd/fixtures/protocol/answer-trouble.json index 12cbc4f5..23131dab 100644 --- a/crates/didbot-agentd/fixtures/protocol/answer-trouble.json +++ b/crates/didbot-agentd/fixtures/protocol/answer-trouble.json @@ -1,4 +1,4 @@ { "trouble": "this daemon speaks version 2, the message is version 3", - "version": 2 + "version": 3 } diff --git a/crates/didbot-agentd/fixtures/protocol/ask-approve-by-token.json b/crates/didbot-agentd/fixtures/protocol/ask-approve-by-token.json index ab205e9a..638380be 100644 --- a/crates/didbot-agentd/fixtures/protocol/ask-approve-by-token.json +++ b/crates/didbot-agentd/fixtures/protocol/ask-approve-by-token.json @@ -1,5 +1,5 @@ { "asks": "approve", "token": "k-3f9a", - "version": 2 + "version": 3 } diff --git a/crates/didbot-agentd/fixtures/protocol/ask-approve-by-url.json b/crates/didbot-agentd/fixtures/protocol/ask-approve-by-url.json index ade73ed4..4289c9f5 100644 --- a/crates/didbot-agentd/fixtures/protocol/ask-approve-by-url.json +++ b/crates/didbot-agentd/fixtures/protocol/ask-approve-by-url.json @@ -1,5 +1,5 @@ { "asks": "approve", "url": "https://pds.example/oauth/authorize?request_uri=urn:ietf:params:oauth:request_uri:4ab2", - "version": 2 + "version": 3 } diff --git a/crates/didbot-agentd/fixtures/protocol/ask-decline.json b/crates/didbot-agentd/fixtures/protocol/ask-decline.json index fe4d03fe..5806f5d3 100644 --- a/crates/didbot-agentd/fixtures/protocol/ask-decline.json +++ b/crates/didbot-agentd/fixtures/protocol/ask-decline.json @@ -2,5 +2,5 @@ "asks": "decline", "reason": "not something I asked for", "token": "k-3f9a", - "version": 2 + "version": 3 } diff --git a/crates/didbot-agentd/fixtures/protocol/ask-pending-all.json b/crates/didbot-agentd/fixtures/protocol/ask-pending-all.json index f88ebeea..4c1ce5de 100644 --- a/crates/didbot-agentd/fixtures/protocol/ask-pending-all.json +++ b/crates/didbot-agentd/fixtures/protocol/ask-pending-all.json @@ -1,5 +1,5 @@ { "all": true, "asks": "pending", - "version": 2 + "version": 3 } diff --git a/crates/didbot-agentd/fixtures/protocol/ask-pending.json b/crates/didbot-agentd/fixtures/protocol/ask-pending.json index acf5557a..e96f5854 100644 --- a/crates/didbot-agentd/fixtures/protocol/ask-pending.json +++ b/crates/didbot-agentd/fixtures/protocol/ask-pending.json @@ -1,4 +1,4 @@ { "asks": "pending", - "version": 2 + "version": 3 } diff --git a/crates/didbot-agentd/fixtures/protocol/ask-report-full.json b/crates/didbot-agentd/fixtures/protocol/ask-report-full.json index 257b9dc6..adda5c3d 100644 --- a/crates/didbot-agentd/fixtures/protocol/ask-report-full.json +++ b/crates/didbot-agentd/fixtures/protocol/ask-report-full.json @@ -6,9 +6,10 @@ "context": "1b2c3d4e-5f60-4712-8394-a5b6c7d8e9f0", "kind": "explorer", "observed": "acted", + "operator": "agent", "seen_request_uris": [ "urn:ietf:params:oauth:request_uri:4ab2" ], "session": "ff7b0e6c-6a1e-4f7b-9d9e-7c1a2b3c4d5e", - "version": 2 + "version": 3 } diff --git a/crates/didbot-agentd/fixtures/protocol/ask-report-minimal.json b/crates/didbot-agentd/fixtures/protocol/ask-report-minimal.json index 1ef3c584..e0a7f291 100644 --- a/crates/didbot-agentd/fixtures/protocol/ask-report-minimal.json +++ b/crates/didbot-agentd/fixtures/protocol/ask-report-minimal.json @@ -2,5 +2,5 @@ "asks": "report", "observed": "began", "session": "ff7b0e6c-6a1e-4f7b-9d9e-7c1a2b3c4d5e", - "version": 2 + "version": 3 } diff --git a/crates/didbot-agentd/fixtures/protocol/ask-rotate.json b/crates/didbot-agentd/fixtures/protocol/ask-rotate.json index b3ad1930..f3472997 100644 --- a/crates/didbot-agentd/fixtures/protocol/ask-rotate.json +++ b/crates/didbot-agentd/fixtures/protocol/ask-rotate.json @@ -1,4 +1,4 @@ { "asks": "rotate", - "version": 2 + "version": 3 } diff --git a/crates/didbot-agentd/fixtures/protocol/ask-show.json b/crates/didbot-agentd/fixtures/protocol/ask-show.json index e92cb7cb..46a9ef3b 100644 --- a/crates/didbot-agentd/fixtures/protocol/ask-show.json +++ b/crates/didbot-agentd/fixtures/protocol/ask-show.json @@ -1,5 +1,5 @@ { "asks": "show", "url": "urn:ietf:params:oauth:request_uri:4ab2", - "version": 2 + "version": 3 } diff --git a/crates/didbot-agentd/src/bin/didbot-agentd.rs b/crates/didbot-agentd/src/bin/didbot-agentd.rs index 87eb98de..e4471753 100644 --- a/crates/didbot-agentd/src/bin/didbot-agentd.rs +++ b/crates/didbot-agentd/src/bin/didbot-agentd.rs @@ -9,6 +9,7 @@ use std::sync::Arc; use didbot_agentd::context::{Store, DEFAULT_LIFETIME_DAYS, LIFETIME_DAYS}; use didbot_agentd::node::{default_state_dir, Node}; +use didbot_agentd::placement::{self, Placement}; use didbot_agentd::registrar::Pds; use didbot_agentd::serve::Daemon; use didbot_agentd::socket::{default_socket_path, remove_if_stale, Listener}; @@ -34,6 +35,10 @@ environment is the whole configuration. replace the host's key once its credential record is this many days old, checked hourly, 1 to 365. Unset, the key is replaced only by `didbot register --rotate host`. + DIDBOT_SUBAGENT_OPERATOR + where a subagent's account is created: `agent`, as its + session and beneath it, or `host`, with the host's key and + beneath the host (default host). RUST_LOG the tracing filter (default didbot_agentd=info). -h, --help print this @@ -108,6 +113,13 @@ async fn main() -> ExitCode { return ExitCode::FAILURE; } }; + let placement = match subagent_placement() { + Ok(placement) => placement, + Err(why) => { + error!("{why}"); + return ExitCode::FAILURE; + } + }; // Under the lock the node just took, so no second daemon is reading these // bindings while this one writes them. let store = match Store::open(&state, lifetime) { @@ -133,7 +145,8 @@ async fn main() -> ExitCode { let daemon = Arc::new( Daemon::new(Pds::new(&server), node, store) .deciding_at(&server) - .rotating_at(&server), + .rotating_at(&server) + .placing_subagents(placement), ); if let Some(max_age) = rotate_after { tokio::spawn(Arc::clone(&daemon).rotate_when_older_than(max_age)); @@ -187,3 +200,15 @@ fn key_rotate_days() -> Result, String> { } Ok(Some(time::Duration::days(days as i64))) } + +/// Where a subagent's account is created: `DIDBOT_SUBAGENT_OPERATOR`, or +/// [`Placement::DEFAULT`]. Any other word refuses the start by name. +fn subagent_placement() -> Result { + let Some(set) = std::env::var_os(placement::KEY) else { + return Ok(Placement::DEFAULT); + }; + let said = set.to_string_lossy(); + said.trim() + .parse() + .map_err(|err| format!("{}: {err}", placement::KEY)) +} diff --git a/crates/didbot-agentd/src/context.rs b/crates/didbot-agentd/src/context.rs index 9620fd55..7b8f5849 100644 --- a/crates/didbot-agentd/src/context.rs +++ b/crates/didbot-agentd/src/context.rs @@ -263,6 +263,17 @@ impl Store { } } + /// Make a binding for `key` if none stands, as a report would, so an + /// account can be written into it. A session is created for its first + /// subagent this way when the session has not reported yet. + pub fn ensure(&mut self, key: &Key, now: OffsetDateTime) { + if !self.contexts.contains_key(key) { + self.contexts + .insert(key.clone(), Context::new(key.clone(), None, now)); + self.persist(now); + } + } + /// Write down the identity a registrar handed over, and its credential. pub fn provisioned(&mut self, key: &Key, did: impl Into, token: Secret) { if let Some(context) = self.contexts.get_mut(key) { @@ -482,6 +493,7 @@ mod tests { call: None, seen_request_uris: Vec::new(), browser_sign_in: None, + operator: None, } } diff --git a/crates/didbot-agentd/src/lib.rs b/crates/didbot-agentd/src/lib.rs index 2cb03047..6007f8ef 100644 --- a/crates/didbot-agentd/src/lib.rs +++ b/crates/didbot-agentd/src/lib.rs @@ -46,6 +46,7 @@ pub mod jwt; pub mod loopback; pub mod node; pub mod pending; +pub mod placement; pub mod poll; pub mod protocol; pub mod register; diff --git a/crates/didbot-agentd/src/placement.rs b/crates/didbot-agentd/src/placement.rs new file mode 100644 index 00000000..b3c5cec0 --- /dev/null +++ b/crates/didbot-agentd/src/placement.rs @@ -0,0 +1,58 @@ +//! Which account a subagent is created beneath. +//! +//! `DIDBOT_SUBAGENT_OPERATOR` sets it for the daemon, and a report's +//! [`operator`](crate::protocol::Report::operator) for one subagent. A +//! session's own account is always created with the host's key. + +use std::fmt; +use std::str::FromStr; + +/// The environment variable the daemon reads it from. +pub const KEY: &str = "DIDBOT_SUBAGENT_OPERATOR"; + +/// Where a subagent's account is created. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Placement { + /// As its session, with the session's account token, beneath the + /// session. + Agent, + /// With the host's key, beneath the host. + Host, +} + +impl Placement { + /// Where a subagent goes when nothing says. + pub const DEFAULT: Self = Self::Host; + + /// The word the setting and the report spell it with. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Agent => "agent", + Self::Host => "host", + } + } +} + +impl fmt::Display for Placement { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// A word that names neither placement. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error("{0:?} is neither `agent` nor `host`")] +pub struct Unknown(pub String); + +impl FromStr for Placement { + type Err = Unknown; + + fn from_str(word: &str) -> Result { + match word { + "agent" => Ok(Self::Agent), + "host" => Ok(Self::Host), + other => Err(Unknown(other.to_owned())), + } + } +} diff --git a/crates/didbot-agentd/src/protocol.rs b/crates/didbot-agentd/src/protocol.rs index e99dd3d9..30032eb9 100644 --- a/crates/didbot-agentd/src/protocol.rs +++ b/crates/didbot-agentd/src/protocol.rs @@ -27,10 +27,14 @@ use serde::{Deserialize, Serialize}; /// refuses a version newer than its own and nothing else — and an adapter /// still on version 1 finds the fields it knows where they were. /// +/// Version 3 added [`Report::operator`]. A version-2 daemon would drop the +/// field and create beneath the host, so a report carrying it says version +/// 3 and a version-2 daemon refuses it. +/// /// A field added within a version is optional and left off when empty, so a /// component that predates it reads the message as it always did: within /// version 2, [`Report::browser_sign_in`] and [`Answer::browser_sign_in`]. -pub const VERSION: u32 = 2; +pub const VERSION: u32 = 3; /// The longest request line the daemon reads, in bytes, newline included. /// @@ -162,6 +166,12 @@ pub struct Report { /// nothing. #[serde(default, skip_serializing_if = "Option::is_none")] pub browser_sign_in: Option, + /// Where a subagent's account is created, `agent` or `host`, over the + /// daemon's own setting ([`crate::placement`]). Read on the report that + /// creates a subagent's account, and ignored on a session's report and + /// once the account exists. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub operator: Option, } /// An agent approving a sign-in the daemon put in front of it. @@ -451,7 +461,7 @@ mod tests { fn a_quiet_answer_is_one_field_wide() { assert_eq!( serde_json::to_string(&Answer::quiet()).unwrap(), - r#"{"version":2}"# + r#"{"version":3}"# ); } @@ -621,7 +631,7 @@ mod tests { }]); assert_eq!( serde_json::to_string(&answer).unwrap(), - r#"{"version":2,"pending":[{"token":"k7f3","clientOrigin":"http://127.0.0.1:40831","firstTime":true,"requested":["atproto","repo:com.example.thing"],"granted":["atproto"],"cut":["repo:com.example.thing"],"rule":"ceiling","verdict":"narrow","expiresAt":"2026-09-09T12:04:00Z"}]}"# + r#"{"version":3,"pending":[{"token":"k7f3","clientOrigin":"http://127.0.0.1:40831","firstTime":true,"requested":["atproto","repo:com.example.thing"],"granted":["atproto"],"cut":["repo:com.example.thing"],"rule":"ceiling","verdict":"narrow","expiresAt":"2026-09-09T12:04:00Z"}]}"# ); } @@ -641,7 +651,7 @@ mod tests { }]); assert_eq!( serde_json::to_string(&answer).unwrap(), - r#"{"version":2,"pending":[{"clientOrigin":"http://127.0.0.1:40831","firstTime":true,"requested":["atproto"],"granted":[],"rule":"app-allowlist","reason":"that client is not admitted","verdict":"deny","expiresAt":"2026-09-09T12:04:00Z"}]}"# + r#"{"version":3,"pending":[{"clientOrigin":"http://127.0.0.1:40831","firstTime":true,"requested":["atproto"],"granted":[],"rule":"app-allowlist","reason":"that client is not admitted","verdict":"deny","expiresAt":"2026-09-09T12:04:00Z"}]}"# ); } @@ -668,7 +678,7 @@ mod tests { fn an_empty_list_of_decisions_is_left_off_rather_than_sent() { assert_eq!( serde_json::to_string(&Answer::quiet().and_pending(Vec::new())).unwrap(), - r#"{"version":2}"# + r#"{"version":3}"# ); } @@ -713,7 +723,7 @@ mod tests { let line = serde_json::to_string(&answer).unwrap(); let old: AsVersionOne = serde_json::from_str(&line).unwrap(); - assert_eq!(old.version, 2); + assert_eq!(old.version, VERSION); assert_eq!(old.identity.as_deref(), Some("did:web:one.example")); } } diff --git a/crates/didbot-agentd/src/serve.rs b/crates/didbot-agentd/src/serve.rs index 06db61be..39adb3be 100644 --- a/crates/didbot-agentd/src/serve.rs +++ b/crates/didbot-agentd/src/serve.rs @@ -25,6 +25,7 @@ use crate::decisions::{Account, Record}; use crate::loopback::Delivery; use crate::node::Node; use crate::pending::Held; +use crate::placement::{self, Placement}; use crate::protocol::{ Answer, Approve, DecisionForAccount, Decline, Message, Observed, Report, MAX_LINE, VERSION, }; @@ -61,6 +62,8 @@ pub struct Daemon { key_turn: tokio::sync::RwLock<()>, /// Where the host's key is replaced; see [`Daemon::rotating_at`]. rotation: Option, + /// Where a subagent's account is created when its report does not say. + placement: Placement, } impl Daemon { @@ -76,9 +79,18 @@ impl Daemon { node, key_turn: tokio::sync::RwLock::new(()), rotation: None, + placement: Placement::DEFAULT, } } + /// Create a subagent's account at `placement` when its report does not + /// say where. + #[must_use] + pub fn placing_subagents(mut self, placement: Placement) -> Self { + self.placement = placement; + self + } + /// Replace the host's key at `server` when asked, and settle a next key /// an earlier run left there at start-up. pub fn rotating_at(mut self, server: impl Into) -> Self { @@ -498,13 +510,17 @@ impl Daemon { } if matches!(next, Next::Provision(_)) { + let placement = match self.placement_for(&key, &report).await { + Ok(placement) => placement, + Err(trouble) => return (Answer::trouble(trouble), None), + }; next = match self.try_turn(&key).await { // A mint for this context is already under way. None => Next::Nothing, // Whoever held the turn before may have minted it. Some(turn) => match self.contexts.lock().await.observe(&report, now) { Next::Provision(key) => { - self.provision_in_background(key, turn); + self.provision_in_background(key, placement, turn); Next::Nothing } next => next, @@ -604,6 +620,36 @@ impl Daemon { turn.try_lock_owned().ok() } + /// Where a context's account is created. + /// + /// A session's is always created with the host's key. A subagent's goes + /// where its report's `operator` says, or where this daemon is set. A + /// report naming neither value is trouble, and so is a subagent to be + /// created as a session that holds an account and no token for it. + async fn placement_for(&self, key: &Key, report: &Report) -> Result { + if key.context.is_none() { + return Ok(Placement::Host); + } + let placement = match report.operator.as_deref() { + None => self.placement, + Some(word) => word + .parse() + .map_err(|err: placement::Unknown| format!("operator: {err}"))?, + }; + if placement == Placement::Agent { + let contexts = self.contexts.lock().await; + if let Some(session) = contexts.get(&session_of(key)) { + if let (Some(did), None) = (&session.did, &session.token) { + return Err(format!( + "the session's account {did} holds no token here to create a subagent \ + with" + )); + } + } + } + Ok(placement) + } + /// Mint a context's account in a task of its own, holding its turn until /// the mint is done. /// @@ -611,13 +657,17 @@ impl Daemon { /// adapter waits on its answer. So no report waits on a mint. A mint /// that fails is logged here, and the context's next report that begins /// or acts tries again. - fn provision_in_background(self: &Arc, key: Key, turn: OwnedMutexGuard<()>) - where + fn provision_in_background( + self: &Arc, + key: Key, + placement: Placement, + turn: OwnedMutexGuard<()>, + ) where R: 'static, { let daemon = Arc::clone(self); tokio::spawn(async move { - daemon.provision_context(&key).await; + daemon.provision_context(&key, placement).await; // The daemon before the turn: whoever takes the turn next may // expect this task to be done with the daemon. drop(daemon); @@ -628,14 +678,12 @@ impl Daemon { /// Mint an account for a context and start following its sign-ins. /// /// The context is told its name by the first report after this, since it - /// is written down with no asker told. - async fn provision_context(&self, key: &Key) { - // Signed under this host's identity, which is also the parent the - // account is created beneath. A host with none is refused here, and - // the context stays owed a name rather than getting one nobody - // answers for. The token names one method at one server and is - // spent once, so one read off the path cannot be spent on somebody - // else's account. + /// is written down with no asker told. A subagent placed as `agent` is + /// created with its session's account token, beneath the session; every + /// other context with a JWT the host's key signs, beneath the host. A + /// refusal is logged and never re-routed: a subagent refused as its + /// session is not created beneath the host instead. + async fn provision_context(&self, key: &Key, placement: Placement) { let server_did = match self.registrar.server_did().await { Ok(did) => did, Err(err) => { @@ -643,56 +691,133 @@ impl Daemon { return; } }; - // Held until the create is answered: a key replace waits for it, - // and it waits for a replace in flight. - let _key_turn = self.key_turn.read().await; - let credential = - match self - .node - .sign(&server_did, CREATE_ACCOUNT, time::OffsetDateTime::now_utc()) - { - Ok(token) => token, - Err(err) => { - warn!(error = %err, "could not sign for a context"); - return; - } - }; - let name = match account_name(&server_did, &account_id(key)) { - Ok(name) => name, - Err(err) => { - warn!(error = %err, "could not name a context"); + if key.context.is_none() || placement == Placement::Host { + // Held until the create is answered: a key replace waits for + // it, and it waits for a replace in flight. + let _key_turn = self.key_turn.read().await; + let (Some(name), Some(jwt)) = (name_of(key, &server_did), self.host_jwt(&server_did)) + else { return; + }; + if let Err(err) = self.create(key, name, jwt).await { + warn!(error = %err, "could not provision an identity"); } + return; + } + let Some(name) = name_of(key, &server_did) else { + return; }; + let Some(token) = self.session_token(key, &server_did).await else { + return; + }; + match self.create(key, name, token).await { + Ok(()) => {} + Err(err) if err.refused_as() == Some("NotAllowedToCreate") => warn!( + error = %err, + "could not create a subagent as its session; let accounts beneath the host \ + create with `didbot operate --creates agent --creates-beneath agent`, \ + or set {}=host", + placement::KEY + ), + Err(err) => warn!(error = %err, "could not create a subagent as its session"), + } + } + + /// A JWT the host's key signs for `bot.did.createAccount` at the server. + /// + /// The host is also the parent the account is created beneath. A host + /// with no identity is refused here, and the context stays owed a name + /// rather than getting one nobody answers for. The token names one + /// method at one server and is spent once, so one read off the path + /// cannot be spent on somebody else's account. + fn host_jwt(&self, server_did: &str) -> Option { + match self + .node + .sign(server_did, CREATE_ACCOUNT, OffsetDateTime::now_utc()) + { + Ok(token) => Some(Secret::new(token)), + Err(err) => { + warn!(error = %err, "could not sign for a context"); + None + } + } + } + + /// The account token of `key`'s session, which creates `key` beneath + /// it. A session with no account is created first, with the host's + /// key, under the session's own turn: waited for rather than skipped, + /// since the session's own report or a sibling may be creating it. + async fn session_token(&self, key: &Key, server_did: &str) -> Option { + let session = session_of(key); + let turn = Arc::clone(self.turns.lock().await.entry(session.clone()).or_default()); + let _turn = turn.lock_owned().await; + let held = self + .contexts + .lock() + .await + .get(&session) + .map(|context| (context.did.clone(), context.token.clone())); + match held { + Some((_, Some(token))) => return Some(token), + Some((Some(did), None)) => { + warn!(%did, "the session's account holds no token here to create a subagent with"); + return None; + } + Some((None, None)) | None => {} + } + self.contexts + .lock() + .await + .ensure(&session, OffsetDateTime::now_utc()); + let name = name_of(&session, server_did)?; + let _key_turn = self.key_turn.read().await; + let jwt = self.host_jwt(server_did)?; + if let Err(err) = self.create(&session, name, jwt).await { + warn!(error = %err, "could not create a subagent's session first"); + return None; + } + self.contexts.lock().await.get(&session)?.token.clone() + } + + /// Create `key`'s account as `name` with `credential`, write it into + /// the context's binding, and start following its sign-ins. + async fn create( + &self, + key: &Key, + name: String, + credential: Secret, + ) -> Result<(), crate::registrar::Trouble> { let wanted = Wanted { name, kind: AGENT_KIND.to_owned(), - credential: Secret::new(credential), + credential, }; // The store's lock is not held across this: provisioning talks to a // server, and every other hook on the machine would wait behind it. - match self.registrar.provision(wanted).await { - Ok(identity) => { - info!(did = %identity.did, operator = %identity.operator, "provisioned"); - let mut contexts = self.contexts.lock().await; - // The credential stays here, in memory, for as long as the - // context does. `plan/cred-delivery.md` puts it in the - // daemon rather than anywhere the model can read. - contexts.provisioned(key, &identity.did, identity.token.clone()); - drop(contexts); - - self.start_polling( - key.clone(), - Account { - did: identity.did.clone(), - token: identity.token, - }, - ) - .await; - } - Err(err) => warn!(error = %err, "could not provision an identity"), - } + let identity = self.registrar.provision(wanted).await?; + info!( + did = %identity.did, + operator = %identity.operator, + parent = identity.parent.as_deref().unwrap_or("-"), + "provisioned" + ); + let mut contexts = self.contexts.lock().await; + // The credential stays here, in memory, for as long as the context + // does. `plan/cred-delivery.md` puts it in the daemon rather than + // anywhere the model can read. + contexts.provisioned(key, &identity.did, identity.token.clone()); + drop(contexts); + + self.start_polling( + key.clone(), + Account { + did: identity.did.clone(), + token: identity.token, + }, + ) + .await; + Ok(()) } /// Mark the decisions a context reported seeing, fetching any that no @@ -934,6 +1059,21 @@ impl Daemon { /// and is answered: every field version 1 sent is still read, and the extra /// fields in the answer are ones it ignores. A version from the future is /// another matter, because this cannot know what it left out. +/// The name `key`'s account is created as, under the server's zone. +fn name_of(key: &Key, server_did: &str) -> Option { + account_name(server_did, &account_id(key)) + .map_err(|err| warn!(error = %err, "could not name a context")) + .ok() +} + +/// The session a context belongs to: itself, for a session. +fn session_of(key: &Key) -> Key { + Key { + session: key.session.clone(), + context: None, + } +} + fn too_new(version: u32) -> Option { (version > VERSION).then(|| { // Answered rather than dropped: an adapter that gets silence cannot @@ -1075,6 +1215,11 @@ mod tests { refusing: std::sync::Mutex>, /// The last thing it was asked for, for a test to verify. last: std::sync::Mutex>, + /// Everything it was asked for, in order. + shown: std::sync::Mutex>, + /// A refusal for a create that presents an account token rather + /// than a JWT, as the server words one. + refusing_tokens: std::sync::Mutex>, } impl Registrar for Counting { @@ -1084,6 +1229,7 @@ mod tests { async fn provision(&self, wanted: Wanted) -> Result { *self.last.lock().unwrap() = Some(wanted.clone()); + self.shown.lock().unwrap().push(wanted.clone()); self.asked.fetch_add(1, Ordering::SeqCst); if let Some(gate) = &self.gate { gate.acquire().await.unwrap().forget(); @@ -1094,6 +1240,11 @@ mod tests { if let Some(refusal) = self.refusing.lock().unwrap().clone() { return Err(Trouble::Refused(refusal)); } + if !is_jwt(wanted.credential.reveal()) { + if let Some(refusal) = self.refusing_tokens.lock().unwrap().clone() { + return Err(Trouble::Refused(refusal)); + } + } self.minted.fetch_add(1, Ordering::SeqCst); let token = if self.distinct_tokens { token_of(&format!("did:web:{}", wanted.name)) @@ -1161,6 +1312,7 @@ mod tests { call: None, seen_request_uris: Vec::new(), browser_sign_in: None, + operator: None, } } @@ -1191,6 +1343,59 @@ mod tests { format!("token-for-{did}") } + /// Whether a credential is a JWT, which the host's key signs, rather + /// than an account token. + fn is_jwt(credential: &str) -> bool { + credential.starts_with("ey") + } + + /// A daemon placing subagents at `placement`, whose registrar hands + /// each account a token of its own. + fn placing(label: &str, placement: Placement) -> (Arc>, Scratch) { + let (daemon, scratch) = bare( + label, + Counting { + distinct_tokens: true, + ..Default::default() + }, + ); + adopt(&daemon); + (Arc::new(daemon.placing_subagents(placement)), scratch) + } + + /// What each create presented, in order: `host` for a JWT the host's + /// key signs, or the DID whose account token it was. + fn presented(daemon: &Daemon) -> Vec<(String, String)> { + let host = daemon.node().verifying_key(); + daemon + .registrar + .shown + .lock() + .unwrap() + .iter() + .map(|wanted| { + let credential = wanted.credential.reveal(); + let by = if crate::jwt::verify(credential, &host).is_ok() { + "host".to_owned() + } else { + credential + .strip_prefix("token-for-") + .unwrap_or(credential) + .to_owned() + }; + (format!("did:web:{}", wanted.name), by) + }) + .collect() + } + + /// A report from subagent `context` of `sess-1`, saying where it goes. + fn saying(observed: Observed, context: Option<&str>, operator: &str) -> Report { + Report { + operator: Some(operator.into()), + ..report(observed, context) + } + } + /// Wait for something the poller does in the background, or give up. /// /// A poll is a round trip to the double, so the alternative to waiting is @@ -1268,6 +1473,181 @@ mod tests { assert!(crate::jwt::verify(signed.credential.reveal(), &before).is_err()); } + /// Under `agent`, a subagent's create presents its session's account + /// token, and the session's own presents a JWT the host's key signs. + #[tokio::test] + async fn a_subagent_is_created_with_its_sessions_token() { + let (daemon, _scratch) = placing("as-session", Placement::Agent); + let session = minted(None); + let subagent = minted(Some("a-1")); + + let told = named(&daemon, Observed::Began, None).await; + assert_eq!(told.identity.as_deref(), Some(session.as_str())); + let told = named(&daemon, Observed::Began, Some("a-1")).await; + assert_eq!(told.identity.as_deref(), Some(subagent.as_str())); + assert_eq!( + presented(&daemon), + vec![(session.clone(), "host".into()), (subagent, session)] + ); + } + + /// One report from a subagent whose session has no account makes two + /// creates, the session's first, and the session is told its identity + /// on its own next report. + #[tokio::test] + async fn a_subagent_before_its_session_creates_the_session_first() { + let (daemon, _scratch) = placing("session-first", Placement::Agent); + let session = minted(None); + let subagent = minted(Some("a-1")); + + let told = named(&daemon, Observed::Began, Some("a-1")).await; + assert_eq!(told.identity.as_deref(), Some(subagent.as_str())); + assert_eq!( + presented(&daemon), + vec![ + (session.clone(), "host".into()), + (subagent, session.clone()) + ] + ); + let told = daemon.consider(report(Observed::Acted, None)).await; + assert_eq!(told.identity.as_deref(), Some(session.as_str())); + } + + /// Two subagents of a session with no account, reporting at once, make + /// three creates: the session once, and each subagent beneath it. + #[tokio::test] + async fn two_subagents_of_a_session_with_no_account_create_it_once() { + let gate = Arc::new(tokio::sync::Semaphore::new(0)); + let (daemon, _scratch) = bare( + "session-once", + Counting { + gate: Some(Arc::clone(&gate)), + distinct_tokens: true, + ..Default::default() + }, + ); + adopt(&daemon); + let daemon = Arc::new(daemon.placing_subagents(Placement::Agent)); + + daemon.consider(report(Observed::Began, Some("a-1"))).await; + daemon.consider(report(Observed::Began, Some("a-2"))).await; + until!( + "the session's create is asked for", + daemon.registrar.asked.load(Ordering::SeqCst) > 0 + ); + gate.add_permits(3); + settled(&daemon, Some("a-1")).await; + settled(&daemon, Some("a-2")).await; + + let session = minted(None); + let mut shown = presented(&daemon); + assert_eq!(shown.remove(0), (session.clone(), "host".into())); + shown.sort(); + let mut beneath = vec![ + (minted(Some("a-1")), session.clone()), + (minted(Some("a-2")), session), + ]; + beneath.sort(); + assert_eq!(shown, beneath); + } + + /// A subagent refused as its session is not created beneath the host + /// instead. It stays owed a name, and its next report asks again as the + /// session. + #[tokio::test] + async fn a_refusal_as_the_session_is_never_retried_as_the_host() { + for refusal in [ + "NotAllowedToCreate: no allowance lets the session create", + "AccountLocked: the session is locked", + ] { + let (daemon, _scratch) = placing("refused-as-session", Placement::Agent); + let session = minted(None); + named(&daemon, Observed::Began, None).await; + *daemon.registrar.refusing_tokens.lock().unwrap() = Some(refusal.into()); + + let answer = named(&daemon, Observed::Began, Some("a-1")).await; + assert!(answer.identity.is_none(), "{refusal}: {answer:?}"); + let answer = named(&daemon, Observed::Acted, Some("a-1")).await; + assert!(answer.identity.is_none(), "{refusal}: {answer:?}"); + let subagent = minted(Some("a-1")); + assert_eq!( + presented(&daemon), + vec![ + (session.clone(), "host".into()), + (subagent.clone(), session.clone()), + (subagent, session), + ], + "{refusal}" + ); + } + } + + #[tokio::test] + async fn under_host_a_subagent_is_created_with_the_hosts_key() { + let (daemon, _scratch) = placing("as-host", Placement::Host); + named(&daemon, Observed::Began, None).await; + named(&daemon, Observed::Began, Some("a-1")).await; + assert_eq!( + presented(&daemon), + vec![ + (minted(None), "host".into()), + (minted(Some("a-1")), "host".into()), + ] + ); + } + + /// A report's `operator` wins over the daemon's setting, both ways. + #[tokio::test] + async fn a_report_saying_where_wins_over_the_key() { + let session = minted(None); + let subagent = minted(Some("a-1")); + for (setting, said, by) in [ + (Placement::Agent, "host", "host".to_owned()), + (Placement::Host, "agent", session.clone()), + ] { + let (daemon, _scratch) = placing("report-wins", setting); + named(&daemon, Observed::Began, None).await; + let first = daemon + .consider(saying(Observed::Began, Some("a-1"), said)) + .await; + assert!(first.trouble.is_none(), "{first:?}"); + settled(&daemon, Some("a-1")).await; + assert_eq!( + presented(&daemon).last(), + Some(&(subagent.clone(), by)), + "{said} under {setting}" + ); + } + } + + /// A session's own account is created with the host's key whatever its + /// report says. + #[tokio::test] + async fn a_sessions_report_saying_agent_is_created_beneath_the_host() { + let (daemon, _scratch) = placing("session-says-agent", Placement::Host); + daemon + .consider(saying(Observed::Began, None, "agent")) + .await; + settled(&daemon, None).await; + assert_eq!(presented(&daemon), vec![(minted(None), "host".into())]); + } + + /// A subagent's report naming neither value is trouble naming both, and + /// nothing is created. + #[tokio::test] + async fn a_report_naming_neither_value_is_refused_by_name() { + let (daemon, _scratch) = placing("neither", Placement::Agent); + let answer = daemon + .consider(saying(Observed::Began, Some("a-1"), "pool")) + .await; + let trouble = answer.trouble.expect("trouble"); + for word in ["pool", "`agent`", "`host`"] { + assert!(trouble.contains(word), "{word}: {trouble}"); + } + settled(&daemon, Some("a-1")).await; + assert_eq!(daemon.registrar.asked.load(Ordering::SeqCst), 0); + } + #[tokio::test] async fn a_context_is_minted_once_and_told_once() { let (daemon, _scratch) = daemon("once", Counting::default()); diff --git a/crates/didbot-agentd/tests/cli.rs b/crates/didbot-agentd/tests/cli.rs index 065c84d8..4e1615af 100644 --- a/crates/didbot-agentd/tests/cli.rs +++ b/crates/didbot-agentd/tests/cli.rs @@ -141,3 +141,31 @@ fn a_key_rotation_outside_its_range_refuses_the_start() { } let _ = std::fs::remove_dir_all(&state); } + +/// `DIDBOT_SUBAGENT_OPERATOR` is listed, and a word that is neither `agent` +/// nor `host` refuses the start, naming the key and both values. +#[test] +fn a_subagent_operator_that_is_neither_value_refuses_the_start() { + let help = Command::new(env!("CARGO_BIN_EXE_didbot-agentd")) + .arg("--help") + .output() + .expect("run didbot-agentd"); + assert!(String::from_utf8_lossy(&help.stdout).contains("DIDBOT_SUBAGENT_OPERATOR")); + + let state = + std::env::temp_dir().join(format!("didbot-agentd-placement-{}", std::process::id())); + let output = Command::new(env!("CARGO_BIN_EXE_didbot-agentd")) + .env("DIDBOT_PDS", "pds.example") + .env("DIDBOT_STATE", &state) + .env("DIDBOT_SOCK", state.join("agent.sock")) + .env("DIDBOT_SUBAGENT_OPERATOR", "pool") + .output() + .expect("run didbot-agentd"); + assert!(!output.status.success(), "pool started"); + let said = String::from_utf8_lossy(&output.stdout).into_owned() + + &String::from_utf8_lossy(&output.stderr); + for word in ["DIDBOT_SUBAGENT_OPERATOR", "`agent`", "`host`", "pool"] { + assert!(said.contains(word), "{word}: {said}"); + } + let _ = std::fs::remove_dir_all(&state); +} diff --git a/crates/didbot-agentd/tests/fixtures.rs b/crates/didbot-agentd/tests/fixtures.rs index ed28f5aa..c2ebb325 100644 --- a/crates/didbot-agentd/tests/fixtures.rs +++ b/crates/didbot-agentd/tests/fixtures.rs @@ -68,6 +68,7 @@ fn full_report() -> Report { call: Some("toolu_01abcdef".into()), seen_request_uris: vec!["urn:ietf:params:oauth:request_uri:4ab2".into()], browser_sign_in: Some("https://pds.example/oauth/browser".into()), + operator: Some("agent".into()), } } @@ -122,6 +123,7 @@ fn everything() -> BTreeMap<&'static str, Value> { call: None, seen_request_uris: Vec::new(), browser_sign_in: None, + operator: None, }), ), ("ask-report-full", Message::Report(full_report())), diff --git a/crates/didbot-operator/src/operate/walk.rs b/crates/didbot-operator/src/operate/walk.rs index 029a87a8..be9274ba 100644 --- a/crates/didbot-operator/src/operate/walk.rs +++ b/crates/didbot-operator/src/operate/walk.rs @@ -20,7 +20,7 @@ use crate::operate::identify::{self, DocumentFetcher}; use crate::operate::read::{self, Fetched}; /// How many edges a walk climbs before refusing. -pub const MAX_EDGES: usize = 3; +pub const MAX_EDGES: usize = 4; /// One verified edge. #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/didbot/tests/scenarios.rs b/crates/didbot/tests/scenarios.rs index 4646320b..f8f07285 100644 --- a/crates/didbot/tests/scenarios.rs +++ b/crates/didbot/tests/scenarios.rs @@ -46,6 +46,7 @@ mod scenarios { pub mod stack; pub mod standins; pub mod stranger; + pub mod subagents; pub mod subtree; } @@ -430,10 +431,24 @@ async fn operate_agent(stack: &Stack, name: &str) -> Ran { impl Laptop { async fn stand_up(stack: &Stack, server: &Server, label: &str) -> Self { + Self::stand_up_with(stack, server, label, false, &[]).await + } + + /// [`Self::stand_up`], its record letting accounts beneath it create + /// agents when `beneath`, and its daemon run with `env`. + async fn stand_up_with( + stack: &Stack, + server: &Server, + label: &str, + beneath: bool, + env: &[(&str, &str)], + ) -> Self { let machine = Machine::new(&stack.work, label); let name = format!("{label}.{}", server.zone()); - let did = stack.register_host(&machine, server, &name).await; - let daemon = Daemon::start(&machine, server).await; + let did = stack + .register_host_allowing(&machine, server, &name, beneath) + .await; + let daemon = Daemon::start_with(&machine, server, env).await; Self { machine, name, diff --git a/crates/didbot/tests/scenarios/machine.rs b/crates/didbot/tests/scenarios/machine.rs index 46712e8c..abfb07cc 100644 --- a/crates/didbot/tests/scenarios/machine.rs +++ b/crates/didbot/tests/scenarios/machine.rs @@ -134,6 +134,15 @@ pub(crate) struct Daemon { impl Daemon { pub(crate) async fn start(machine: &Machine, server: &Server) -> Self { + Self::start_with(machine, server, &[]).await + } + + /// [`Self::start`], with `env` set on the daemon too. + pub(crate) async fn start_with( + machine: &Machine, + server: &Server, + env: &[(&str, &str)], + ) -> Self { let sock = machine.state.join("agent.sock"); let log = machine.home.join("agentd.log"); std::fs::create_dir_all(&machine.home).expect("a home"); @@ -145,6 +154,7 @@ impl Daemon { .env("DIDBOT_STATE", &machine.state) .env("HOME", &machine.home) .env("NO_COLOR", "1") + .envs(env.iter().copied()) .stdin(Stdio::null()) .stdout(Stdio::from(log_file.try_clone().expect("a handle"))) .stderr(Stdio::from(log_file)) @@ -186,16 +196,27 @@ impl Daemon { /// background, so a new context's first report is answered with /// neither, and a later one names it. pub(crate) async fn began(&self, session: &str, context: &str) -> Result { - let answer = self - .ask(json!({ - "asks": "report", - "version": 2, - "observed": "began", - "session": session, - "context": context, - "asker": "scenarios", - })) - .await; + self.report(session, Some(context)).await + } + + /// [`Self::began`] for a session, with no `context`, or for one of its + /// subagents. + pub(crate) async fn report( + &self, + session: &str, + context: Option<&str>, + ) -> Result { + let mut report = json!({ + "asks": "report", + "version": 3, + "observed": "began", + "session": session, + "asker": "scenarios", + }); + if let Some(context) = context { + report["context"] = json!(context); + } + let answer = self.ask(report).await; match (answer["identity"].as_str(), answer["trouble"].as_str()) { (Some(did), _) => Ok(did.to_owned()), (None, Some(trouble)) => Err(trouble.to_owned()), @@ -224,6 +245,26 @@ impl Daemon { .await } + /// The account the daemon creates for `session`'s `context`, or for the + /// session itself with none, once it has. + pub(crate) async fn named(&self, session: &str, context: Option<&str>) -> String { + wait_for( + &format!("the daemon to create an account for {session} {context:?}"), + || async move { self.report(session, context).await.ok() }, + ) + .await + } + + /// Lines of the daemon's log that carry `words`. + pub(crate) fn logged(&self, words: &str) -> Vec { + std::fs::read_to_string(&self.log) + .unwrap_or_default() + .lines() + .filter(|line| line.contains(words)) + .map(str::to_owned) + .collect() + } + /// An agent for a fresh context, which the daemon creates beneath its /// host. pub(crate) async fn agent(&self, context: &str) -> String { diff --git a/crates/didbot/tests/scenarios/stack.rs b/crates/didbot/tests/scenarios/stack.rs index cb810595..38a3165f 100644 --- a/crates/didbot/tests/scenarios/stack.rs +++ b/crates/didbot/tests/scenarios/stack.rs @@ -149,7 +149,23 @@ impl Stack { server: &Server, name: &str, ) -> String { - let did = self.admit_host(machine, server, name).await; + self.register_host_allowing(machine, server, name, false) + .await + } + + /// [`Self::register_host`], and with `beneath` the record also lets + /// every account beneath the host create agents: `--creates-beneath + /// agent`, which a subagent created as its session needs. + pub(crate) async fn register_host_allowing( + &self, + machine: &Machine, + server: &Server, + name: &str, + beneath: bool, + ) -> String { + let did = self + .admit_host_allowing(machine, server, name, beneath) + .await; server.read_allowances().await; did } @@ -162,6 +178,17 @@ impl Stack { machine: &Machine, server: &Server, name: &str, + ) -> String { + self.admit_host_allowing(machine, server, name, false).await + } + + /// [`Self::admit_host`], with `--creates-beneath agent` when `beneath`. + pub(crate) async fn admit_host_allowing( + &self, + machine: &Machine, + server: &Server, + name: &str, + beneath: bool, ) -> String { let mut register = machine .command( @@ -197,31 +224,26 @@ impl Stack { .unwrap_or_else(|| panic!("register printed no fingerprint:\n{}", printed.join("\n"))); assert!(fingerprint.starts_with("SHA256:"), "{fingerprint}"); - let admitted = self - .laptop - .didbot( - &[ - "operate", - name, - self.human.did(), - "--fingerprint", - &fingerprint, - "--creates", - "agent", - "--json", - ], - &[], - ) - .await - .ok(); + let mut operate = vec![ + "operate", + name, + self.human.did(), + "--fingerprint", + &fingerprint, + "--creates", + "agent", + "--json", + ]; + let mut creates = vec![json!({ "scope": "subject", "kinds": ["agent"] })]; + if beneath { + operate.extend(["--creates-beneath", "agent"]); + creates.push(json!({ "scope": "descendants", "kinds": ["agent"] })); + } + let admitted = self.laptop.didbot(&operate, &[]).await.ok(); assert_eq!(admitted["kind"], "host", "{admitted}"); assert_eq!(admitted["did"], did_of(name), "{admitted}"); assert_eq!(admitted["operator"], self.human.did(), "{admitted}"); - assert_eq!( - admitted["creates"], - json!([{ "scope": "subject", "kinds": ["agent"] }]), - "{admitted}" - ); + assert_eq!(admitted["creates"], json!(creates), "{admitted}"); let record = self .human .record("bot.did.operator", &rkey_of(name)) diff --git a/crates/didbot/tests/scenarios/stranger.rs b/crates/didbot/tests/scenarios/stranger.rs index bbb9d97f..74f47186 100644 --- a/crates/didbot/tests/scenarios/stranger.rs +++ b/crates/didbot/tests/scenarios/stranger.rs @@ -262,7 +262,7 @@ async fn a_forged_tree_does_not_verify() { evil.vouch(&did_of(&a), &b); let refusal = stranger(vec![a.clone()]).await.refused(); assert!( - refusal.contains("too deep") && refusal.contains("after 3 edges"), + refusal.contains("too deep") && refusal.contains("after 4 edges"), "a loop is refused at the depth bound: {refusal}" ); diff --git a/crates/didbot/tests/scenarios/subagents.rs b/crates/didbot/tests/scenarios/subagents.rs new file mode 100644 index 00000000..6e310b91 --- /dev/null +++ b/crates/didbot/tests/scenarios/subagents.rs @@ -0,0 +1,317 @@ +// --------------------------------------------------------------------------- +// A subagent created as its session +// --------------------------------------------------------------------------- + +use crate::*; + +/// The daemon's setting that creates a subagent as its session. +const AS_SESSION: [(&str, &str); 1] = [("DIDBOT_SUBAGENT_OPERATOR", "agent")]; + +/// What the daemon logs when a create as the session is refused. +const REFUSED_AS_SESSION: &str = "could not create a subagent as its session"; + +/// A laptop whose daemon creates subagents as their sessions, its record +/// letting accounts beneath it create agents when `beneath`. +async fn laptop(stack: &Stack, server: &Server, beneath: bool) -> Laptop { + Laptop::stand_up_with(stack, server, "laptop", beneath, &AS_SESSION).await +} + +/// The hostname behind a `did:web`. +fn name_of(did: &str) -> String { + did.trim_start_matches("did:web:").replace("%3A", ":") +} + +/// An account's ledger entries, oldest first. +async fn ledger(server: &Server, did: &str) -> Vec { + let response = http() + .get(format!("{}/xrpc/bot.did.getAccountLedger", server.origin)) + .query(&[("did", did)]) + .send() + .await + .expect("getAccountLedger answers"); + let ledger: Value = response.json().await.expect("a ledger"); + ledger["entries"].as_array().cloned().unwrap_or_default() +} + +/// Report a new subagent, and wait for the daemon to log that its create as +/// the session was refused. Returns that log line. +async fn refused_as_session(daemon: &Daemon, session: &str, context: &str) -> String { + let before = daemon.logged(REFUSED_AS_SESSION).len(); + let answer = daemon.report(session, Some(context)).await; + assert!(answer.is_err(), "the subagent was named: {answer:?}"); + wait_for("the daemon to log the refused create", || async { + daemon.logged(REFUSED_AS_SESSION).get(before).cloned() + }) + .await +} + +/// `didbot --server --json` on the human's machine. +async fn operator(stack: &Stack, server: &Server, args: &[&str]) -> Ran { + let mut full = args.to_vec(); + full.extend(["--server", server.host.as_str(), "--json"]); + stack.laptop.didbot(&full, &[]).await +} + +/// A subagent is created beneath its session: its lineage is the host then +/// the session, the session's repository names it and the host's does not, +/// and a stranger's walk climbs subagent, session, host, human. +#[tokio::test(flavor = "multi_thread")] +async fn a_laptops_subagent_is_created_beneath_its_session() { + let stack = Stack::start(&["beneath"]).await; + let server = stack.server(); + let laptop = laptop(&stack, server, true).await; + + let subagent = laptop.daemon.named("s-1", Some("a-1")).await; + let session = laptop.daemon.named("s-1", None).await; + let registered = registration(server, &subagent).await; + assert_eq!(registered["operator"], stack.human.did(), "{registered}"); + assert_eq!( + registered["lineage"], + json!([laptop.did, session]), + "{registered}" + ); + let (status, record) = get_record( + &server.origin, + &session, + "bot.did.operator", + &rkey_of(&name_of(&subagent)), + ) + .await; + assert_eq!(status, 200, "the session names its subagent: {record}"); + assert_eq!(record["value"]["subject"], subagent, "{record}"); + assert_eq!( + list_record_keys(&server.origin, &laptop.did, "bot.did.operator").await, + vec![rkey_of(&name_of(&session))], + "the host names its session and not the subagent" + ); + assert_eq!( + stack.walk(&name_of(&subagent)).await, + vec![session, laptop.did.clone(), stack.human.did().to_owned()] + ); +} + +/// A host admitted with `--creates agent` alone: its session is created, +/// and the subagent is refused `NotAllowedToCreate`, logged with both ways +/// out, and never created beneath the host instead. +#[tokio::test(flavor = "multi_thread")] +async fn a_host_without_descendants_refuses_subagents_by_name() { + let stack = Stack::start(&["nodesc"]).await; + let server = stack.server(); + let laptop = laptop(&stack, server, false).await; + + let line = refused_as_session(&laptop.daemon, "s-1", "a-1").await; + for words in [ + "NotAllowedToCreate", + "--creates-beneath agent", + "DIDBOT_SUBAGENT_OPERATOR=host", + ] { + assert!(line.contains(words), "{words}: {line}"); + } + let session = laptop.daemon.named("s-1", None).await; + assert_eq!( + list_record_keys(&server.origin, &laptop.did, "bot.did.operator").await, + vec![rkey_of(&name_of(&session))], + "nothing but the session was created beneath the host" + ); + assert!( + laptop.daemon.report("s-1", Some("a-1")).await.is_err(), + "the subagent is still owed a name" + ); +} + +/// `didbot account lock` on a session hangs on its subagent, from +/// `parent`, and refuses its next subagent `AccountLocked`; `unlock` lifts +/// both. +#[tokio::test(flavor = "multi_thread")] +async fn locking_a_session_stops_its_subagents() { + let stack = Stack::start(&["lock"]).await; + let server = stack.server(); + let laptop = laptop(&stack, server, true).await; + let subagent = laptop.daemon.named("s-1", Some("a-1")).await; + let session = laptop.daemon.named("s-1", None).await; + + operator(&stack, server, &["account", "lock", &name_of(&session)]) + .await + .ok(); + let last = ledger(server, &subagent).await.pop().unwrap_or_default(); + assert_eq!(last["event"], "locked", "{last}"); + assert_eq!(last["party"], "parent", "{last}"); + let line = refused_as_session(&laptop.daemon, "s-1", "a-2").await; + assert!(line.contains("AccountLocked"), "{line}"); + + operator(&stack, server, &["account", "unlock", &name_of(&session)]) + .await + .ok(); + let last = ledger(server, &subagent).await.pop().unwrap_or_default(); + assert_eq!(last["event"], "unlocked", "{last}"); + let second = laptop.daemon.named("s-1", Some("a-2")).await; + assert_eq!( + registration(server, &second).await["lineage"], + json!([laptop.did, session]) + ); +} + +/// `didbot account delete` on a session with a subagent is refused +/// `LiveChildren`; with `--recursive` both go, the subagent first. +#[tokio::test(flavor = "multi_thread")] +async fn deleting_a_session_takes_its_subagents() { + let stack = Stack::start(&["delete"]).await; + let server = stack.server(); + let laptop = laptop(&stack, server, true).await; + let subagent = laptop.daemon.named("s-1", Some("a-1")).await; + let session = laptop.daemon.named("s-1", None).await; + + let refusal = operator(&stack, server, &["account", "delete", &name_of(&session)]) + .await + .refused(); + assert!(refusal.contains("LiveChildren"), "{refusal}"); + let deleted = operator( + &stack, + server, + &["account", "delete", &name_of(&session), "--recursive"], + ) + .await + .ok(); + let taken: Vec<&str> = deleted["accounts"] + .as_array() + .expect("the accounts it took") + .iter() + .filter_map(|row| row["did"].as_str()) + .collect(); + assert_eq!( + taken, + vec![subagent.as_str(), session.as_str()], + "{deleted}" + ); +} + +/// A released Revoke ends the session's token, so its next subagent is +/// refused `InvalidToken`; a session started after the release gets its +/// subagent. +#[tokio::test(flavor = "multi_thread")] +async fn a_released_revoke_ends_what_a_session_spawns() { + let stack = Stack::start(&["released"]).await; + let server = stack.server(); + let laptop = laptop(&stack, server, true).await; + laptop.daemon.named("s-1", Some("a-1")).await; + + operator(&stack, server, &["estop", "--revoke"]).await.ok(); + operator(&stack, server, &["estop", "--release"]).await.ok(); + + let line = refused_as_session(&laptop.daemon, "s-1", "a-2").await; + assert!(line.contains("InvalidToken"), "{line}"); + let fresh = laptop.daemon.named("s-2", Some("b-1")).await; + let session = laptop.daemon.named("s-2", None).await; + assert_eq!( + registration(server, &fresh).await["lineage"], + json!([laptop.did, session]) + ); +} + +/// A pool's instance creates a session and the session its subagent. The +/// walk climbs subagent, session, host, pool, human: four hosted edges. +#[tokio::test(flavor = "multi_thread")] +async fn a_pools_subagent_checks_in_four_edges() { + let stack = Stack::start(&["pool4"]).await; + let server = stack.server(); + let web = format!("web.{}", server.zone()); + stack + .laptop + .didbot( + &[ + "operate", + &web, + stack.human.did(), + "--kind", + "service", + "--oidc", + &stack.issuer.base, + "email=web@pool", + "--creates", + "host", + "--creates-beneath", + "agent", + "--json", + ], + &[], + ) + .await + .ok(); + server.read_allowances().await; + + let machine = Machine::new(&stack.work, "i-1"); + let host = format!("i-1.{}", server.zone()); + let token = stack + .issuer + .id_token(&server.did, json!({ "email": "web@pool" })); + let token_file = machine.secret_file("id-token", &token); + machine + .didbot( + &[ + "register", + "host", + &host, + "--under", + &web, + "--server", + &server.host, + "--token-file", + token_file.to_str().expect("utf-8"), + "--json", + ], + &[], + ) + .await + .ok(); + let daemon = Daemon::start_with(&machine, server, &AS_SESSION).await; + + let subagent = daemon.named("s-1", Some("a-1")).await; + let session = daemon.named("s-1", None).await; + assert_eq!( + registration(server, &subagent).await["lineage"], + json!([did_of(&web), did_of(&host), session]) + ); + assert_eq!( + stack.walk(&name_of(&subagent)).await, + vec![ + session, + did_of(&host), + did_of(&web), + stack.human.did().to_owned() + ] + ); +} + +/// Under two creates an hour, a session's two subagents are charged to the +/// session, so the host still creates a second session. A host creating +/// subagents itself is refused the second `CreatesTooFast`. +#[tokio::test(flavor = "multi_thread")] +async fn a_fan_out_is_charged_to_its_session() { + const CONFIG: &str = + "[operator]\ngrace_window_hours = 0\n[tree]\ncreates_per_hour = 2\n[park]\nrate_limit = 100\n"; + let stack = Stack::start_with(&["fanout"], CONFIG, false).await; + let server = stack.server(); + + let laptop = laptop(&stack, server, true).await; + for context in ["a-1", "a-2"] { + laptop.daemon.named("s-1", Some(context)).await; + } + laptop.daemon.named("s-2", None).await; + + // Its own session ids: a name is made from the context, whichever host + // creates it. + let other = Laptop::stand_up(&stack, server, "other").await; + other.daemon.named("t-1", None).await; + other.daemon.named("t-1", Some("a-1")).await; + let before = other.daemon.logged("could not provision an identity").len(); + assert!(other.daemon.report("t-1", Some("a-2")).await.is_err()); + let line = wait_for("the host's third create to be refused", || async { + other + .daemon + .logged("could not provision an identity") + .get(before) + .cloned() + }) + .await; + assert!(line.contains("CreatesTooFast"), "{line}"); +} diff --git a/docs/agentd.md b/docs/agentd.md index 9c4167c3..dd4d9895 100644 --- a/docs/agentd.md +++ b/docs/agentd.md @@ -218,6 +218,24 @@ first call after the refusal clears creates it. The daemon creates outside its o lock, because every other hook on the machine would otherwise wait behind a round trip to the server. +A session's account is always created that way, beneath the host. +`DIDBOT_SUBAGENT_OPERATOR` says where a subagent's goes, and a report's +`operator` says it for one subagent, over the setting: + +- `host`, the default: with a JWT the host's key signs, beneath the host. +- `agent`: as its session, with the session's account token, beneath the + session. The session's account is created first when it has none. The + subagent's `lineage` is the host then the session, and the session's + repository holds `bot.did.operator/` for it. This needs an + allowance for accounts beneath the host to create agents: `didbot operate + --creates agent --creates-beneath agent`. A refusal is + logged, naming that command and `DIDBOT_SUBAGENT_OPERATOR=host`, and the + subagent is never created beneath the host instead. + +Anything else in `DIDBOT_SUBAGENT_OPERATOR` refuses the start, and in a +report is answered with trouble. A report's `operator` is read only on the +report that creates a subagent's account. + ### The key it holds The daemon mints one secp256k1 key on its first start and keeps it in its diff --git a/docs/cli.md b/docs/cli.md index 0397fd6e..91994a3b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -505,11 +505,14 @@ key and its context bindings from `$XDG_STATE_HOME/didbot/agentd`; harness last reported it, 1 to 365 days and 30 by default; `DIDBOT_KEY_ROTATE_DAYS`, 1 to 365, has the daemon replace the host's key once its credential record is that many days old, checked hourly, and -unset it never does. 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 `bot.did.credential` -records. +unset it never does; `DIDBOT_SUBAGENT_OPERATOR` is `host` or `agent`, `host` +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. +A session's is created beneath the host `didbot register host` made it, +presenting a JWT its key signs; the server checks it against the host's +`bot.did.credential` records. A subagent's is created the same way under +`host`, and under `agent` as its session, with the session's account token, +beneath the session. 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`, diff --git a/docs/ownership.md b/docs/ownership.md index 1c923a18..b2dd6455 100644 --- a/docs/ownership.md +++ b/docs/ownership.md @@ -308,7 +308,7 @@ trust more. `didbot operate --check ` is the thorough option: it walks every edge — the registration names a parent, the parent's repository holds the record naming the account back — climbing until the operator's repository is on another server, and refuses a chain still on -this server after three edges. It exits 1 until every hop verifies, and +this server after four edges. It exits 1 until every hop verifies, and shows which hop failed. A pair only one side states fails, in both directions. Which server an diff --git a/plan/node.md b/plan/node.md index e5373975..a2e5723e 100644 --- a/plan/node.md +++ b/plan/node.md @@ -5,7 +5,7 @@ status: open crates: [didbot-agentd] dependsOn: [ownership] exitCriterion: > - A host the human admitted creates one agent per context beneath itself with + A host the human admitted creates each session's account 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. diff --git a/plan/ownership.md b/plan/ownership.md index 7e75e646..5254c53f 100644 --- a/plan/ownership.md +++ b/plan/ownership.md @@ -622,7 +622,7 @@ The owner took every recommendation: naming the lineage's first entry (or the account itself) and for the record naming the server. The count does not grow with depth. `didbot operate --check` remains the thorough walk, edge by edge, refusing a - chain still hosted here after three edges. + chain still hosted here after four edges. - [x] **Revocation, and how it propagates.** Two readers, two bounds, both in [docs/operator-verification.md](../docs/operator-verification.md). diff --git a/plan/subagents.md b/plan/subagents.md index 163d780b..749bb754 100644 --- a/plan/subagents.md +++ b/plan/subagents.md @@ -313,23 +313,10 @@ session's do to it. ### Build -- [ ] **The key.** `DIDBOT_SUBAGENT_OPERATOR` read and checked at start in - `crates/didbot-agentd/src/bin/didbot-agentd.rs`, and held by `Daemon`. -- [ ] **The create.** `provision_context` presents `{S}`'s token for a - subagent under `agent`, creates `{S}` first when it has no account, - and logs both ways out on `NotAllowedToCreate`. -- [ ] **The override.** `Report::operator` and protocol version 3, with the - fixtures blessed. -- [ ] **The walk.** `MAX_EDGES` in - `crates/didbot-operator/src/operate/walk.rs` goes from three to four. - [ ] **The words.** A host is admitted with `--creates agent --creates-beneath agent` in `docs/agentd.md`, the laptop record and scenario table in `docs/ownership.md`, - `docs/first-hour.md` and `docs/cli.md`. `docs/agentd.md`'s - provisioning paragraph says which credential creates a subagent, and - it, `docs/cli.md` and `didbot-agentd --help` list - `DIDBOT_SUBAGENT_OPERATOR`. [node](node.md)'s exit criterion says a - session's account is created beneath the host. + `docs/first-hour.md` and `docs/cli.md`. - [ ] **The scenarios.** The laptop in `crates/didbot/tests/scenarios/` is admitted with `--creates-beneath agent`, and each lineage it asserts follows the tree. @@ -337,79 +324,11 @@ session's do to it. `agent` becomes the default in its own breaking change, once the owner has said how a standing host gains its `descendants` allowance. -### Tests - -The daemon, against the `Counting` registrar in -`crates/didbot-agentd/src/serve.rs`, which keeps the credential it was shown: - -- [ ] `a_subagent_is_created_with_its_sessions_token`: under `agent`, the - subagent's create presents `{S}`'s token and `{S}`'s presents a host - JWT. -- [ ] `a_subagent_before_its_session_creates_the_session_first`: one report - from `{S, A}` makes two creates, `{S}` first. `{S}`'s next report is - told its identity, and both accounts are followed for sign-ins. -- [ ] `two_subagents_of_a_session_with_no_account_create_it_once`: two - reports at once make three creates. -- [ ] `a_refusal_as_the_session_is_the_subagents_trouble`: for - `NotAllowedToCreate` and for `AccountLocked`, one create and no host - JWT; the answer carries the server's words; the next report asks again - as `{S}`. -- [ ] `under_host_a_subagent_is_created_with_the_hosts_key`. -- [ ] `a_report_saying_where_wins_over_the_key`: `host` under `agent`, and - `agent` under `host`. -- [ ] `a_sessions_report_saying_agent_is_created_beneath_the_host`. -- [ ] `a_report_naming_neither_value_is_refused_by_name`. - -The wire and the binary: - -- [ ] `operator` round-trips in a version-3 report, and - `ask-report-full.json` carries it - (`crates/didbot-agentd/tests/fixtures.rs`). -- [ ] `didbot-agentd` refuses to start on `DIDBOT_SUBAGENT_OPERATOR=pool`, - naming the key and both values, and `--help` lists it - (`crates/didbot-agentd/tests/cli.rs`). - -End to end, with the real binaries in `crates/didbot/tests/scenarios/`: - -- [ ] `a_laptops_subagent_is_created_beneath_its_session`: its lineage is - `[host, session]`; the session's repository holds - `bot.did.operator/` and the host's does not; - `didbot operate --check` walks subagent, session, host, human. -- [ ] `a_host_without_descendants_refuses_subagents_by_name`: admitted with - `--creates agent` alone, the subagent is refused `NotAllowedToCreate`, - the daemon's log names both ways out, and nothing is created beneath - the host. -- [ ] `locking_a_session_stops_its_subagents`: after `didbot account lock` - on the session, its subagent carries `Frozen` from `parent` and a new - subagent is refused `AccountLocked`; `unlock` lifts both. -- [ ] `deleting_a_session_takes_its_subagents`: `didbot account delete` on - the session is refused `LiveChildren`; with `--recursive` both go, the - subagent first. -- [ ] `a_released_revoke_ends_what_a_session_spawns`: the session's next - subagent is refused `InvalidToken`, and a new session's subagent is - created. -- [ ] `a_pools_subagent_checks_in_four_edges`: subagent, session, host, - pool, human, which `--check` refuses as `TooDeep` until `MAX_EDGES` is - four. -- [ ] `a_fan_out_is_charged_to_its_session`: under `creates_per_hour = 2`, - a session, its two subagents and a second session are all created in - one hour, where `host` refuses the second subagent `CreatesTooFast`. - ### Questions for the owner -- [ ] **The default.** `agent`, as recommended, or `host`. - [ ] **A standing host's allowance.** `didbot operate` rewrites `creates` on a record whose account stands; or operators rewrite it by hand, and set `host` until they do. -- [ ] **`host` from a hook when the key says `agent`.** Honour it, as - recommended, since a hook already names the session; or refuse it, - under [node](node.md)'s rule that nothing from a hook widens anything. -- [ ] **The names.** The issue's `operator`, which is the record's name; or - `parent`, which `createAccount`'s answer, the account row and the - ledger use, and which never names the human. -- [ ] **What bounds a fan-out beneath `descendants`.** `[tree] max_depth` - and `[capacity] max_accounts`, as today; or a scope that reaches one - level, beside `subject` and `descendants`, which is a lexicon change. ## The identifier is not reliable, and disk is @@ -466,3 +385,17 @@ under `host`. - [x] **A subagent is created separately.** A context is keyed by session and subagent id, and a session and its two subagents get three accounts; [node](node.md) has the tests. +- [x] **The owner's answers.** Each recommendation is taken: `agent` as the + default, a report's `host` honoured under `agent`, the name + `operator`, and `[tree] max_depth` with `[capacity] max_accounts` as + what bounds a fan-out. +- [x] **Placement, with `host` as the default.** `DIDBOT_SUBAGENT_OPERATOR` + is read at start (`didbot_agentd::placement`), and `Report::operator` + under protocol version 3 wins over it. Under `agent`, + `provision_context` creates a subagent with its session's token and + creates the session first, under the session's turn, when it has + none. A server's refusal is logged with both ways out and retried on + the next report, as every create's is since reports are answered + before the mint; the checks needing no server answer as trouble. + `MAX_EDGES` is four. The daemon's tests and the scenarios in + `crates/didbot/tests/scenarios/subagents.rs` cover the rows above. -- 2.51.2