From 431a58fbbe7eaa43b3dcd5ce9a028a8f39bb6a63 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Sat, 5 Sep 2026 01:02:02 -0400 Subject: [PATCH] feat(node): confirm an authorization for an account a caller names `didbot confirm --as ` hands the daemon the page a client printed. The daemon fetches only the authorize endpoint this deployment publishes, follows only a loopback redirect, and confirms as the account it was given. Nothing checks the caller is that account: the type says so, and says what closing it would take. Askers are counted separately, so a second plugin on the same events is answered rather than met with silence. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 1 + crates/didbot-agentd/Cargo.toml | 1 + crates/didbot-agentd/src/bin/didbot-agentd.rs | 2 +- crates/didbot-agentd/src/bin/didbot.rs | 97 ++++++++++ crates/didbot-agentd/src/confirm.rs | 180 ++++++++++++++++++ crates/didbot-agentd/src/context.rs | 41 ++-- crates/didbot-agentd/src/lib.rs | 1 + crates/didbot-agentd/src/protocol.rs | 107 +++++++++-- crates/didbot-agentd/src/serve.rs | 58 +++++- 9 files changed, 453 insertions(+), 35 deletions(-) create mode 100644 crates/didbot-agentd/src/bin/didbot.rs create mode 100644 crates/didbot-agentd/src/confirm.rs diff --git a/Cargo.lock b/Cargo.lock index 42590e27..32aab3aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -972,6 +972,7 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "url", ] [[package]] diff --git a/crates/didbot-agentd/Cargo.toml b/crates/didbot-agentd/Cargo.toml index f31e9690..0ef102d1 100644 --- a/crates/didbot-agentd/Cargo.toml +++ b/crates/didbot-agentd/Cargo.toml @@ -16,6 +16,7 @@ thiserror.workspace = true tokio.workspace = true tracing.workspace = true tracing-subscriber.workspace = true +url.workspace = true [lints] workspace = true diff --git a/crates/didbot-agentd/src/bin/didbot-agentd.rs b/crates/didbot-agentd/src/bin/didbot-agentd.rs index 6655ba51..7389bbbd 100644 --- a/crates/didbot-agentd/src/bin/didbot-agentd.rs +++ b/crates/didbot-agentd/src/bin/didbot-agentd.rs @@ -49,7 +49,7 @@ async fn main() -> ExitCode { } }; - let daemon = Arc::new(Daemon::new(Pds::new(server, HARNESS))); + let daemon = Arc::new(Daemon::new(Pds::new(&server, HARNESS)).confirming_at(&server)); let err = daemon.run(listener).await; error!(error = %err, "stopped listening"); ExitCode::FAILURE diff --git a/crates/didbot-agentd/src/bin/didbot.rs b/crates/didbot-agentd/src/bin/didbot.rs new file mode 100644 index 00000000..bd84f524 --- /dev/null +++ b/crates/didbot-agentd/src/bin/didbot.rs @@ -0,0 +1,97 @@ +//! The command an agent runs. +//! +//! One job so far: hand the daemon an authorize URL a client printed, so the +//! authorization is confirmed as the account this command names. +//! +//! It names its own account, and nothing checks that it is that account. See +//! `didbot_agentd::protocol::Confirm` for what that means and why it is +//! where this development stack already stands. + +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::UnixStream; +use std::process::ExitCode; + +use didbot_agentd::protocol::{Answer, Confirm, Message, VERSION}; +use didbot_agentd::socket::default_socket_path; + +const USAGE: &str = "\ +didbot confirm --as confirm an authorization a client printed + +The client prints its authorize URL instead of opening it; this hands that URL +to the local daemon, which confirms it as the account named here. +"; + +fn main() -> ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + match args.first().map(String::as_str) { + Some("confirm") => confirm(&args[1..]), + Some("--help") | Some("-h") | None => { + print!("{USAGE}"); + ExitCode::SUCCESS + } + Some(other) => { + eprintln!("didbot: no such command `{other}`\n\n{USAGE}"); + ExitCode::FAILURE + } + } +} + +fn confirm(args: &[String]) -> ExitCode { + let url = args.iter().find(|arg| !arg.starts_with("--")); + let did = args + .iter() + .position(|arg| arg == "--as") + .and_then(|at| args.get(at + 1)); + + let (Some(url), Some(did)) = (url, did) else { + eprintln!("didbot confirm: needs the URL the client printed and `--as `"); + return ExitCode::FAILURE; + }; + + let message = Message::Confirm(Confirm { + version: VERSION, + did: did.clone(), + url: url.clone(), + }); + + match ask(&message) { + Ok(answer) => { + if let Some(trouble) = answer.trouble { + eprintln!("didbot confirm: {trouble}"); + return ExitCode::FAILURE; + } + println!("{}", answer.done.as_deref().unwrap_or("confirmed")); + ExitCode::SUCCESS + } + Err(err) => { + eprintln!("didbot confirm: {err}"); + ExitCode::FAILURE + } + } +} + +/// One line out, one line back. +fn ask(message: &Message) -> std::io::Result { + let path = std::env::var_os("DIDBOT_SOCK") + .map(Into::into) + .unwrap_or_else(default_socket_path); + let mut stream = UnixStream::connect(&path).map_err(|err| { + std::io::Error::new( + err.kind(), + format!("no daemon at {}: {err}", path.display()), + ) + })?; + let mut line = serde_json::to_vec(message)?; + line.push(b'\n'); + stream.write_all(&line)?; + stream.flush()?; + + let mut reply = String::new(); + BufReader::new(stream).read_line(&mut reply)?; + serde_json::from_str(&reply).map_err(|err| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("the daemon answered with something unreadable: {err}"), + ) + }) +} diff --git a/crates/didbot-agentd/src/confirm.rs b/crates/didbot-agentd/src/confirm.rs new file mode 100644 index 00000000..882c0f16 --- /dev/null +++ b/crates/didbot-agentd/src/confirm.rs @@ -0,0 +1,180 @@ +//! Confirming an authorization on a context's behalf. +//! +//! A client prints its authorize URL instead of opening it, and something +//! hands that URL here with the account it should be confirmed as. Which +//! account that is comes from the caller: see [`crate::protocol::Confirm`] +//! for what that costs and why it is where this development stack already +//! stands. +//! +//! Three things bound what this will fetch, because the URL came from a +//! process the model started: +//! +//! 1. Only the authorize endpoint this deployment advertises, matched +//! against its own discovery document rather than against a guess. +//! 2. Only a loopback redirect afterwards. +//! 3. No chains. Each request is made with redirects turned off. + +use serde::Deserialize; +use url::Url; + +/// Why a confirmation did not happen. +#[derive(Debug, thiserror::Error)] +pub enum Trouble { + /// The URL was not this deployment's authorize endpoint. + #[error("{0}")] + Refused(String), + /// Something on the way did not answer, or did not answer usefully. + #[error("{0}")] + Failed(String), +} + +/// What this deployment says its own endpoints are. +#[derive(Debug, Clone, Deserialize)] +struct Discovery { + authorization_endpoint: String, +} + +/// The confirming half of the two calls. +pub struct Confirmer { + http: reqwest::Client, + server: String, +} + +impl Confirmer { + /// `server` is the origin the daemon reaches this deployment on. + pub fn new(server: impl Into) -> Self { + Self { + // Redirects are followed deliberately, one at a time, or not at + // all. A client that follows them by default would chase whatever + // an authorize page happened to point at. + http: reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap_or_default(), + server: server.into().trim_end_matches('/').to_string(), + } + } + + /// Fetch the authorize page as this context, confirm what it offers, and + /// deliver the code to the client's own listener. + pub async fn run(&self, url: &str, did: &str) -> Result { + let authorize = + Url::parse(url).map_err(|err| Trouble::Refused(format!("that is not a URL: {err}")))?; + self.check_is_ours(&authorize).await?; + + let page = self + .http + .get(authorize.clone()) + .send() + .await + .map_err(|err| Trouble::Failed(format!("could not read the authorize page: {err}")))? + .text() + .await + .map_err(|err| Trouble::Failed(format!("could not read the authorize page: {err}")))?; + + let reference = reference_in(&page).ok_or_else(|| { + Trouble::Failed("the authorize page carried no consent reference".to_owned()) + })?; + + let response = self + .http + .post(format!("{}/oauth/confirm", self.server)) + .json(&serde_json::json!({ "reference": reference, "did": did })) + .send() + .await + .map_err(|err| Trouble::Failed(format!("could not confirm: {err}")))?; + + let status = response.status(); + let body = response + .text() + .await + .map_err(|err| Trouble::Failed(format!("could not confirm: {err}")))?; + if !status.is_success() { + return Err(Trouble::Refused(format!("{status}: {}", body.trim()))); + } + + let redirect = serde_json::from_str::(&body) + .ok() + .and_then(|value| value["redirect"].as_str().map(str::to_owned)) + .ok_or_else(|| Trouble::Failed(format!("no redirect in {}", body.trim())))?; + + self.deliver(&redirect).await?; + Ok(redirect) + } + + /// Refuses anything but the authorize endpoint this deployment publishes. + async fn check_is_ours(&self, authorize: &Url) -> Result<(), Trouble> { + let discovery: Discovery = self + .http + .get(format!( + "{}/.well-known/oauth-authorization-server", + self.server + )) + .send() + .await + .map_err(|err| Trouble::Failed(format!("could not read discovery: {err}")))? + .json() + .await + .map_err(|err| Trouble::Failed(format!("could not read discovery: {err}")))?; + + let ours = Url::parse(&discovery.authorization_endpoint) + .map_err(|err| Trouble::Failed(format!("this deployment publishes no URL: {err}")))?; + + // Origin and path, not the whole URL: the query carries the request. + if authorize.origin() != ours.origin() || authorize.path() != ours.path() { + return Err(Trouble::Refused(format!( + "this daemon confirms only at {ours}, and that URL is not there" + ))); + } + Ok(()) + } + + /// Hands the code to the client, which is listening on loopback for it. + async fn deliver(&self, redirect: &str) -> Result<(), Trouble> { + let target = Url::parse(redirect) + .map_err(|err| Trouble::Failed(format!("the redirect is unusable: {err}")))?; + let loopback = matches!(target.host_str(), Some(host) if host + .eq_ignore_ascii_case("localhost") + || host.parse::().is_ok_and(|ip| ip.is_loopback())); + if !loopback { + return Err(Trouble::Refused(format!( + "the client asked for its code at {target}, which is not loopback" + ))); + } + self.http + .get(target) + .send() + .await + .map_err(|err| Trouble::Failed(format!("the client did not take its code: {err}")))?; + Ok(()) + } +} + +/// Reads the one-time reference out of the authorize page. +/// +/// The attribute rather than the text, because the text is placeholder copy +/// somebody will replace and the attribute is the contract. +fn reference_in(page: &str) -> Option { + let marker = "data-consent-reference=\""; + let start = page.find(marker)? + marker.len(); + let rest = &page[start..]; + let end = rest.find('"')?; + Some(rest[..end].to_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_reference_comes_from_the_attribute() { + let page = "

KRILLBRIDGE

\ +

abc123

"; + assert_eq!(reference_in(page).as_deref(), Some("abc123")); + } + + #[test] + fn a_page_without_one_is_not_guessed_at() { + assert!(reference_in("

nothing here

").is_none()); + } +} diff --git a/crates/didbot-agentd/src/context.rs b/crates/didbot-agentd/src/context.rs index dad5958c..77ca6757 100644 --- a/crates/didbot-agentd/src/context.rs +++ b/crates/didbot-agentd/src/context.rs @@ -4,7 +4,7 @@ //! context *is* comes from the harness, what its name is comes from the //! registrar, and this decides which of them still needs one. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use crate::protocol::{Observed, Report}; @@ -21,6 +21,18 @@ pub struct Key { pub context: Option, } +/// What to call an asker that did not name itself. +/// +/// One unnamed asker is the ordinary single-plugin case and behaves as it +/// always did; two of them would share a slot, which is the old bug and is +/// why a plugin should say who it is. +const ANONYMOUS: &str = "-"; + +/// Which plugin a report came from. +fn asker_of(report: &Report) -> String { + report.asker.clone().unwrap_or_else(|| ANONYMOUS.to_owned()) +} + impl Key { fn of(report: &Report) -> Self { Self { @@ -39,8 +51,12 @@ pub struct Context { pub kind: Option, /// Its identity, once a registrar has handed one over. pub did: Option, - /// Whether the identity has been put in front of the context itself. - pub told: bool, + /// Which askers have put the identity in front of this context. + /// + /// A set rather than a flag: a machine runs more than one plugin on the + /// same events, and each has to be told once. A flag would answer the + /// first and silently withhold from the second. + pub told: HashSet, /// Whether the harness has said this one is finished. Kept rather than /// removed: a name is never returned to the pool, so neither is the row /// that records who held it. @@ -53,7 +69,7 @@ impl Context { key, kind, did: None, - told: false, + told: HashSet::new(), done: false, } } @@ -110,10 +126,11 @@ impl Store { Observed::Began | Observed::Acted => {} } + let asker = asker_of(report); let context = &self.contexts[&key]; match &context.did { None => Next::Reserve(key), - Some(_) if context.told => Next::Nothing, + Some(_) if context.told.contains(&asker) => Next::Nothing, Some(did) => Next::Tell(did.clone()), } } @@ -125,11 +142,11 @@ impl Store { } } - /// Note that a context has now been told its identity, so it is not told - /// again on its next tool call. - pub fn told(&mut self, key: &Key) { + /// Note that one asker has now told a context its identity, so that asker + /// is answered with silence on its next tool call and any other is not. + pub fn told(&mut self, key: &Key, asker: &str) { if let Some(context) = self.contexts.get_mut(key) { - context.told = true; + context.told.insert(asker.to_owned()); } } @@ -159,6 +176,8 @@ mod tests { session: "s1".into(), context: context.map(Into::into), kind: None, + asker: None, + call: None, } } @@ -182,7 +201,7 @@ mod tests { store.observe(&report(Observed::Acted, Some("a1"))), Next::Tell("did:web:one.example".into()) ); - store.told(&key); + store.told(&key, ANONYMOUS); for _ in 0..3 { assert_eq!( @@ -216,7 +235,7 @@ mod tests { }; store.observe(&report(Observed::Began, None)); store.reserved(&key, "did:web:one.example"); - store.told(&key); + store.told(&key, ANONYMOUS); store.observe(&report(Observed::Rested, None)); assert!(!store.get(&key).unwrap().done); diff --git a/crates/didbot-agentd/src/lib.rs b/crates/didbot-agentd/src/lib.rs index c167b7d7..be013701 100644 --- a/crates/didbot-agentd/src/lib.rs +++ b/crates/didbot-agentd/src/lib.rs @@ -18,6 +18,7 @@ #![forbid(unsafe_code)] +pub mod confirm; pub mod context; pub mod protocol; pub mod registrar; diff --git a/crates/didbot-agentd/src/protocol.rs b/crates/didbot-agentd/src/protocol.rs index bc6f5aa0..1d3007fe 100644 --- a/crates/didbot-agentd/src/protocol.rs +++ b/crates/didbot-agentd/src/protocol.rs @@ -6,9 +6,8 @@ //! than against this crate. A change here that an adapter cannot make without //! importing this crate is a change that has put the two back together. //! -//! One line of JSON per message, request and reply alike. The adapter is a -//! short-lived process spawned per event, so a connection carries one -//! exchange and closes; nothing here is a session. +//! One line of JSON per message, request and reply alike. A connection +//! carries one exchange and closes; nothing here is a session. use serde::{Deserialize, Serialize}; @@ -20,6 +19,16 @@ use serde::{Deserialize, Serialize}; /// version answers rather than closing the connection. pub const VERSION: u32 = 1; +/// What each caller wants. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "asks", rename_all = "snake_case")] +pub enum Message { + /// A hook saying what the harness just did. + Report(Report), + /// A tool asking for an authorization to be confirmed on its behalf. + Confirm(Confirm), +} + /// What the adapter observed, in the vocabulary the daemon works in. /// /// Deliberately not the harness's own event names: the daemon has no opinion @@ -59,6 +68,45 @@ pub struct Report { /// The harness's name for the kind of context, when it has one. #[serde(default, skip_serializing_if = "Option::is_none")] pub kind: Option, + /// Which plugin is asking. + /// + /// A machine runs more than one: a plugin carrying identity and a plugin + /// carrying a product's own instructions are different programs on the + /// same events. An identity is told to a context once *per asker*, so the + /// second plugin to ask is answered rather than met with the silence that + /// means "already told". + #[serde(default, skip_serializing_if = "Option::is_none")] + pub asker: Option, + /// The harness's identifier for this particular tool call. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub call: Option, +} + +/// A tool asking the daemon to confirm an authorization it started. +/// +/// The URL is the client's own authorize page, printed rather than opened. +/// +/// # The caller names its own account +/// +/// `did` is taken from the caller and nothing checks that the caller is it. +/// A model can read another context's identifier out of a transcript on this +/// machine, so any agent here can authorize as any other. The server compares +/// this against the account the pushed request named, which refuses a +/// *mismatch* and cannot refuse a correct claim by the wrong party. +/// +/// That is the same posture as the rest of this development stack — +/// `provisionAgent` authenticates nobody and `--oauth-open` grants every +/// client — rather than a gap in an otherwise closed system. Closing it needs +/// a way for a caller to prove which context it is, which is a credential, +/// and a credential is not something to arrive at by implication. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Confirm { + /// The wire version this message was written against. + pub version: u32, + /// The account the caller says it is. + pub did: String, + /// The authorize URL the client printed. + pub url: String, } /// What the daemon says back. @@ -67,15 +115,13 @@ pub struct Answer { /// The wire version the daemon answered with. pub version: u32, /// The identity this context may tell the world it has, present only the - /// first time the daemon has something new to say about it. - /// - /// Absent on every later report for the same context: an adapter that - /// injects what it is given would otherwise repeat itself on every tool - /// call, and a context told its name twice learns nothing the second - /// time. + /// first time this asker has something new to say about it. #[serde(default, skip_serializing_if = "Option::is_none")] pub identity: Option, - /// Why there is no identity, when there is none and there should be. + /// What the daemon did, when it did something worth naming. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub done: Option, + /// Why there is nothing to report, when there should have been. #[serde(default, skip_serializing_if = "Option::is_none")] pub trouble: Option, } @@ -86,6 +132,7 @@ impl Answer { Self { version: VERSION, identity: None, + done: None, trouble: None, } } @@ -95,15 +142,27 @@ impl Answer { Self { version: VERSION, identity: Some(did.into()), + done: None, + trouble: None, + } + } + + /// Something the daemon carried out. + pub fn done(what: impl Into) -> Self { + Self { + version: VERSION, + identity: None, + done: Some(what.into()), trouble: None, } } - /// Something an adapter should surface rather than swallow. + /// Something a caller should surface rather than swallow. pub fn trouble(why: impl Into) -> Self { Self { version: VERSION, identity: None, + done: None, trouble: Some(why.into()), } } @@ -115,14 +174,20 @@ mod tests { #[test] fn a_report_carrying_only_what_it_must_round_trips() { - let line = r#"{"version":1,"observed":"began","session":"s1"}"#; - let report: Report = serde_json::from_str(line).unwrap(); + let line = r#"{"asks":"report","version":1,"observed":"began","session":"s1"}"#; + let message: Message = serde_json::from_str(line).unwrap(); + let Message::Report(report) = message else { + panic!("a report"); + }; assert_eq!(report.observed, Observed::Began); assert!(report.context.is_none()); // Absent stays absent rather than becoming null: an adapter in another // language reads this back, and a field that appears only sometimes is // easier to write against than one that is sometimes null. - assert_eq!(serde_json::to_string(&report).unwrap(), line); + assert_eq!( + serde_json::to_string(&Message::Report(report)).unwrap(), + line + ); } #[test] @@ -135,7 +200,17 @@ mod tests { #[test] fn an_unknown_observation_is_refused_rather_than_guessed() { - let line = r#"{"version":1,"observed":"vanished","session":"s1"}"#; - assert!(serde_json::from_str::(line).is_err()); + let line = r#"{"asks":"report","version":1,"observed":"vanished","session":"s1"}"#; + assert!(serde_json::from_str::(line).is_err()); + } + + #[test] + fn a_confirmation_carries_the_account_the_caller_claims() { + let line = r#"{"asks":"confirm","version":1,"did":"did:web:a","url":"http://x/authorize"}"#; + let message: Message = serde_json::from_str(line).unwrap(); + let Message::Confirm(confirm) = message else { + panic!("a confirmation"); + }; + assert_eq!(confirm.did, "did:web:a"); } } diff --git a/crates/didbot-agentd/src/serve.rs b/crates/didbot-agentd/src/serve.rs index 366f32b6..7d5804c1 100644 --- a/crates/didbot-agentd/src/serve.rs +++ b/crates/didbot-agentd/src/serve.rs @@ -12,7 +12,7 @@ use tokio::sync::Mutex; use tracing::{debug, info, warn}; use crate::context::{Key, Next, Store}; -use crate::protocol::{Answer, Report, VERSION}; +use crate::protocol::{Answer, Confirm, Message, Report, VERSION}; use crate::registrar::{Registrar, Wanted}; use crate::socket::Listener; @@ -20,6 +20,7 @@ use crate::socket::Listener; pub struct Daemon { contexts: Mutex, registrar: R, + confirmer: Option, } impl Daemon { @@ -28,6 +29,41 @@ impl Daemon { Self { contexts: Mutex::new(Store::new()), registrar, + confirmer: None, + } + } + + /// Give it somewhere to confirm authorizations. + pub fn confirming_at(mut self, server: impl Into) -> Self { + self.confirmer = Some(crate::confirm::Confirmer::new(server)); + self + } + + /// Confirm an authorization for the account the caller names. + /// + /// See [`Confirm`] for what taking that from the caller costs. + pub async fn confirm(&self, confirm: Confirm) -> Answer { + if confirm.version != VERSION { + return Answer::trouble(format!( + "this daemon speaks version {VERSION}, the request is version {}", + confirm.version + )); + } + let Some(confirmer) = self.confirmer.as_ref() else { + return Answer::trouble("this daemon has nowhere to confirm against".to_owned()); + }; + + let did = confirm.did.clone(); + match confirmer.run(&confirm.url, &did).await { + Ok(redirect) => { + info!(did = %did, "confirmed an authorization"); + debug!(redirect = %redirect, "delivered the code"); + Answer::done(format!("confirmed as {did}")) + } + Err(err) => { + warn!(did = %did, error = %err, "could not confirm"); + Answer::trouble(err.to_string()) + } } } @@ -63,11 +99,12 @@ impl Daemon { return Ok(()); } - let answer = match serde_json::from_str::(&line) { - Ok(report) => self.consider(report).await, + let answer = match serde_json::from_str::(&line) { + Ok(Message::Report(report)) => self.consider(report).await, + Ok(Message::Confirm(confirm)) => self.confirm(confirm).await, Err(err) => { - warn!(error = %err, "unreadable report"); - Answer::trouble(format!("unreadable report: {err}")) + warn!(error = %err, "unreadable message"); + Answer::trouble(format!("unreadable message: {err}")) } }; @@ -96,7 +133,7 @@ impl Daemon { session: report.session.clone(), context: report.context.clone(), }; - self.contexts.lock().await.told(&key); + self.contexts.lock().await.told(&key, asker(&report)); return Answer::identity(did); } Next::Reserve(key) => key, @@ -115,7 +152,7 @@ impl Daemon { info!(did = %identity.did, handle = %identity.handle, "reserved"); let mut contexts = self.contexts.lock().await; contexts.reserved(&key, &identity.did); - contexts.told(&key); + contexts.told(&key, asker(&report)); Answer::identity(identity.did) } Err(err) => { @@ -137,6 +174,11 @@ impl Daemon { } } +/// Which plugin a report came from, for the once-per-asker bookkeeping. +fn asker(report: &Report) -> &str { + report.asker.as_deref().unwrap_or("-") +} + /// The label a context's DID is built from. /// /// A subagent id is already unique on the machine; a session with no subagent @@ -196,6 +238,8 @@ mod tests { session: "sess-1".into(), context: context.map(Into::into), kind: None, + asker: None, + call: None, } } -- 2.51.2