Something went wrong. Try again.
Identities for entities did.bot
agent llm did
Something went wrong. Try again.
28 kB · 661 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662//! What crosses the socket, and nothing about who sends it.//!//! A harness adapter lives in its own repository and is not written in Rust,//! so this is a wire format rather than a shared type: these definitions are//! the schema, and the adapter is checked against fixtures of them rather//! 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. A connection//! carries one exchange and closes; nothing here is a session — including the//! sign-in decisions [`Answer::pending`] carries, which ride an exchange the//! adapter started rather than arriving on one the daemon opened.
use serde::{Deserialize, Serialize};
/// The wire version, carried on every request.////// Two components installed months apart is the ordinary case — an adapter/// updates when a plugin does and the daemon when a package does — so skew is/// a handled case rather than a surprise. A daemon that does not know a/// version answers rather than closing the connection.////// Version 2 added the sign-in decisions: [`Answer::pending`],/// [`Report::seen_request_uris`], and the [`Message::Approve`],/// [`Message::Decline`], [`Message::Pending`] and [`Message::Show`] asks. Everything version 1/// sent is still read and answered — see `serve::Daemon::consider`, which/// 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.////// 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.pub const VERSION: u32 = 2;
/// The longest request line the daemon reads, in bytes, newline included.////// Anything longer is answered with trouble rather than buffered, since any/// process running as this user can connect. A report is a few identifiers and/// URLs, far under this.pub const MAX_LINE: usize = 64 * 1024;
/// What each caller wants.////// Serialized with an `asks` tag. Read without needing one: a sender that/// predates the tag is asking the only thing there was to ask, and refusing/// it would make every adapter update a flag day. That is the skew [`VERSION`]/// exists to survive, and a missing tag is the cheapest kind.#[derive(Debug, Clone, Serialize)]#[serde(tag = "asks", rename_all = "snake_case")]pub enum Message { /// A hook saying what the harness just did. Report(Report), /// An agent saying yes to a sign-in the daemon offered it. Approve(Approve), /// An agent saying no to one, so that the refusal is recorded rather /// than left to look like a timeout. Decline(Decline), /// A caller asking what the daemon is holding. Pending(Pending), /// A caller asking about one decision by name. Show(Show),}
impl<'de> Deserialize<'de> for Message { fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { use serde::de::Error; let value = serde_json::Value::deserialize(deserializer)?; match value.get("asks").and_then(serde_json::Value::as_str) { None | Some("report") => serde_json::from_value(value) .map(Message::Report) .map_err(D::Error::custom), Some("approve") => serde_json::from_value(value) .map(Message::Approve) .map_err(D::Error::custom), Some("decline") => serde_json::from_value(value) .map(Message::Decline) .map_err(D::Error::custom), Some("pending") => serde_json::from_value(value) .map(Message::Pending) .map_err(D::Error::custom), Some("show") => serde_json::from_value(value) .map(Message::Show) .map_err(D::Error::custom), Some(other) => Err(D::Error::custom(format!( "this daemon does not know how to `{other}`" ))), } }}
/// What the adapter observed, in the vocabulary the daemon works in.////// Deliberately not the harness's own event names: the daemon has no opinion/// about what a `SubagentStart` is, only about contexts beginning, acting and/// resting. Mapping one to the other is the adapter's whole job.#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]#[serde(rename_all = "snake_case")]pub enum Observed { /// A context exists and has done nothing yet. Began, /// A context is making a tool call. Acted, /// A context finished a unit of work. Not that it ended: a resumed /// context reports this and then begins again under the same id. Rested, /// A context will do no more. Ended, /// The harness said something about a context that does not change what /// it is owed. Most events are this: one wire carries every observation, /// and only some of them are about a context beginning or acting. Noted,}
/// One observation, as the adapter saw it.#[derive(Debug, Clone, Serialize, Deserialize)]pub struct Report { /// The wire version this message was written against. pub version: u32, /// What happened. pub observed: Observed, /// The harness's identifier for the session this belongs to. pub session: String, /// The harness's identifier for the acting context, absent when the /// session itself is acting. #[serde(default, skip_serializing_if = "Option::is_none")] pub context: Option<String>, /// The harness's name for the kind of context, when it has one. #[serde(default, skip_serializing_if = "Option::is_none")] pub kind: Option<String>, /// 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<String>, /// The harness's identifier for this particular tool call. #[serde(default, skip_serializing_if = "Option::is_none")] pub call: Option<String>, /// Pushed requests the adapter saw this context handle, by `request_uri`. /// /// This is what the daemon may show. Anyone can push a request that /// names an account, so [`Answer::pending`] carries a held request only /// once a report from its context has named it. The daemon fetches any of /// these it is not already holding, which is sooner than a poll. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub seen_request_uris: Vec<String>,}
/// An agent approving a sign-in the daemon put in front of it.////// Names a decision, and never an account. Either the one-time `token` from a/// decision the daemon already showed it, or the `url` of an authorize page/// for one it has not -- the manual path, which acts on a request whether or/// not a report named it. Both end in the same place: the daemon resolves/// them to a record it holds and acts as the account that record names, never/// as an account the caller asked for.#[derive(Debug, Clone, Serialize, Deserialize)]pub struct Approve { /// The wire version this message was written against. pub version: u32, /// The one-time approval token from the decision. #[serde(default, skip_serializing_if = "Option::is_none")] pub token: Option<String>, /// An authorize URL, or a bare `request_uri`, naming the decision. /// /// Exactly one of this and [`Approve::token`]: a message carrying neither /// names nothing, and one carrying both is refused rather than guessed at. #[serde(default, skip_serializing_if = "Option::is_none")] pub url: Option<String>,}
/// An agent refusing one.////// Worth sending rather than letting the request expire: an expiry and a/// refusal look the same from the server, and only one of them is a decision/// somebody made.#[derive(Debug, Clone, Serialize, Deserialize)]pub struct Decline { /// The wire version this message was written against. pub version: u32, /// The one-time approval token from the decision. #[serde(default, skip_serializing_if = "Option::is_none")] pub token: Option<String>, /// An authorize URL, or a bare `request_uri`, naming the decision. #[serde(default, skip_serializing_if = "Option::is_none")] pub url: Option<String>, /// Why, in the agent's own words, for the record. #[serde(default, skip_serializing_if = "Option::is_none")] pub reason: Option<String>,}
/// A caller asking about one decision by name, rather than waiting to be told.////// A report shows a context only the sign-ins it named, and that can miss: a/// client that printed its URL where no hook read it. This is the same/// lookup made deliberate -- asked for by an agent holding the URL, rather/// than by the daemon noticing it -- and it finds a request whether or not a/// report named it.////// It is a fetch, not a way around anything. The record still comes from/// `bot.did.getAuthorization` under an account's own credential, and the/// server hands a record only to the account it belongs to, so a URL naming/// somebody else's sign-in resolves to nothing here.#[derive(Debug, Clone, Serialize, Deserialize)]pub struct Show { /// The wire version this message was written against. pub version: u32, /// An authorize URL, or a bare `request_uri`. pub url: String,}
/// A caller asking what the daemon is holding.////// Answered for every context, not one. A `report` names the context it is/// about and is answered with that context's decisions only; this arrives/// from a command line, which names nothing the daemon can check — and the/// ceiling on that is the user account, as [`crate::socket`] already sets/// out. [`Answer::pending`] carries the sign-ins their context reported/// seeing, and [`Pending::all`] asks for the rest in [`Answer::unseen`].#[derive(Debug, Clone, Serialize, Deserialize)]pub struct Pending { /// The wire version this message was written against. pub version: u32, /// Whether to answer with the sign-ins their context has not reported /// seeing, too. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub all: bool,}
/// One sign-in decision, as an agent is shown it.////// The rendered subset of what the server said, and not the whole record:/// the client's own description of itself never leaves the server, and the/// account is not here because a decision is only ever shown to the context/// that holds it.#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]#[serde(rename_all = "camelCase")]pub struct DecisionForAccount { /// What to approve or decline with, absent when the verdict is `deny` /// and there is therefore nothing to choose. #[serde(default, skip_serializing_if = "Option::is_none")] pub token: Option<String>, /// Where the request came from. pub client_origin: String, /// Whether this account has seen that client before. pub first_time: bool, /// What was asked for. pub requested: Vec<String>, /// What the ceiling grants of the request now, and what an approval /// covers: the login is issued at this set. /// /// The ceiling is asked again at every use, so a tightened one takes /// atoms back from a login already approved. pub granted: Vec<String>, /// What the ceiling withholds of the request now, empty unless it was /// narrowed. /// /// Outside the approval: a ceiling loosened afterwards still answers /// [`DecisionForAccount::granted`], and one of these takes a fresh /// sign-in to ask for. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub cut: Vec<String>, /// The rule that narrowed or refused it, named on its own. #[serde(default, skip_serializing_if = "Option::is_none")] pub rule: Option<String>, /// Why it was refused, in the policy's own sentence. /// /// Separate from [`DecisionForAccount::rule`] rather than folded into it: /// the rule is an identifier an operator can go and look up, the reason is /// prose for the agent to read, and an adapter rendering them wants to /// treat the two differently. Absent unless the verdict is `deny`. #[serde(default, skip_serializing_if = "Option::is_none")] pub reason: Option<String>, /// `allow`, `narrow` or `deny`. pub verdict: String, /// When the request stops being answerable, RFC 3339. pub expires_at: String,}
/// What the daemon says back.#[derive(Debug, Clone, Serialize, Deserialize)]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 this asker has something new to say about it. #[serde(default, skip_serializing_if = "Option::is_none")] pub identity: Option<String>, /// What the daemon did, when it did something worth naming. #[serde(default, skip_serializing_if = "Option::is_none")] pub done: Option<String>, /// Why there is nothing to report, when there should have been. #[serde(default, skip_serializing_if = "Option::is_none")] pub trouble: Option<String>, /// Sign-ins waiting on a decision: a reporting context's own that it /// reported seeing, every one its context reported for a [`Pending`] ask, /// or the one a [`Show`] named. #[serde(default, skip_serializing_if = "Option::is_none")] pub pending: Option<Vec<DecisionForAccount>>, /// Sign-ins waiting that their context has not reported seeing, carried /// only for a [`Pending`] ask that sets [`Pending::all`]. #[serde(default, skip_serializing_if = "Option::is_none")] pub unseen: Option<Vec<DecisionForAccount>>, /// What the approved login may do now: the request as the ceiling grants /// it, which is less than was asked for while the ceiling narrows it. #[serde(default, skip_serializing_if = "Option::is_none")] pub granted: Option<Vec<String>>, /// What the approved login's client requested, beside [`Answer::granted`]. #[serde(default, skip_serializing_if = "Option::is_none")] pub requested: Option<Vec<String>>,}
impl Answer { /// Nothing to say, which is the ordinary case. pub fn quiet() -> Self { Self { version: VERSION, identity: None, done: None, trouble: None, pending: None, unseen: None, granted: None, requested: None, } }
/// A context's identity, to be told to it once. pub fn identity(did: impl Into<String>) -> Self { Self { identity: Some(did.into()), ..Self::quiet() } }
/// Something the daemon carried out. pub fn done(what: impl Into<String>) -> Self { Self { done: Some(what.into()), ..Self::quiet() } }
/// Something a caller should surface rather than swallow. pub fn trouble(why: impl Into<String>) -> Self { Self { trouble: Some(why.into()), ..Self::quiet() } }
/// Carry sign-in decisions back with whatever else this answer says. /// /// An empty list is left off entirely rather than sent as `[]`: an /// adapter reading this in another language should be able to test for /// the field's presence and not have to distinguish two kinds of nothing. #[must_use] pub fn and_pending(mut self, pending: Vec<DecisionForAccount>) -> Self { if !pending.is_empty() { self.pending = Some(pending); } self }
/// Carry the sign-ins their context has not reported seeing, left off /// when there are none, as [`Answer::and_pending`] does. #[must_use] pub fn and_unseen(mut self, unseen: Vec<DecisionForAccount>) -> Self { if !unseen.is_empty() { self.unseen = Some(unseen); } self }
/// Carry what an approval's client requested and what it was granted. #[must_use] pub fn and_granted(mut self, requested: Vec<String>, granted: Vec<String>) -> Self { self.requested = Some(requested); self.granted = Some(granted); self }}
#[cfg(test)]mod tests { use super::*;
#[test] fn a_report_carrying_only_what_it_must_round_trips() { let line = r#"{"asks":"report","version":2,"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(&Message::Report(report)).unwrap(), line ); }
#[test] fn a_quiet_answer_is_one_field_wide() { assert_eq!( serde_json::to_string(&Answer::quiet()).unwrap(), r#"{"version":2}"# ); }
#[test] fn a_sender_that_predates_the_tag_is_still_read() { // What every adapter sent before there was more than one thing to ask. let line = r#"{"version":1,"observed":"acted","session":"s1","context":"a1"}"#; let message: Message = serde_json::from_str(line).unwrap(); let Message::Report(report) = message else { panic!("a report"); }; assert_eq!(report.observed, Observed::Acted); }
#[test] fn the_confirm_that_used_to_exist_is_now_an_ask_like_any_other_unknown() { // Version 2 dropped it. An adapter still sending one is told so by // name rather than met with a parse error about a missing field. let line = r#"{"asks":"confirm","version":2,"did":"did:web:a","url":"http://x/authorize"}"#; let err = serde_json::from_str::<Message>(line).unwrap_err(); assert!(err.to_string().contains("confirm"), "{err}"); }
#[test] fn something_this_daemon_cannot_do_is_named_rather_than_guessed() { let line = r#"{"asks":"revoke","version":2}"#; let err = serde_json::from_str::<Message>(line) .unwrap_err() .to_string(); assert!(err.contains("revoke"), "{err}"); }
#[test] fn an_unknown_observation_is_refused_rather_than_guessed() { let line = r#"{"asks":"report","version":2,"observed":"vanished","session":"s1"}"#; assert!(serde_json::from_str::<Message>(line).is_err()); }
#[test] fn an_approval_names_a_token_and_nothing_else() { let line = r#"{"asks":"approve","version":2,"token":"k7f3"}"#; let message: Message = serde_json::from_str(line).unwrap(); let Message::Approve(approve) = message else { panic!("an approval"); }; assert_eq!(approve.token.as_deref(), Some("k7f3")); assert!(approve.url.is_none()); assert_eq!( serde_json::to_string(&Message::Approve(approve)).unwrap(), line ); }
#[test] fn or_the_url_of_one_it_has_not_been_shown() { let line = r#"{"asks":"approve","version":2,"url":"https://pds.example/oauth/authorize?request_uri=r1"}"#; let Message::Approve(approve) = serde_json::from_str::<Message>(line).unwrap() else { panic!("an approval"); }; assert!(approve.token.is_none()); assert_eq!( approve.url.as_deref(), Some("https://pds.example/oauth/authorize?request_uri=r1") ); // Still no account anywhere on the wire, which is the property that // survives adding a second way to name a decision. assert!(!line.contains("did:")); assert_eq!( serde_json::to_string(&Message::Approve(approve)).unwrap(), line ); }
#[test] fn asking_about_one_by_name_carries_the_url_and_a_version() { let line = r#"{"asks":"show","version":2,"url":"urn:ietf:params:oauth:request_uri:r1"}"#; let Message::Show(show) = serde_json::from_str::<Message>(line).unwrap() else { panic!("a lookup"); }; assert_eq!(show.url, "urn:ietf:params:oauth:request_uri:r1"); assert_eq!(serde_json::to_string(&Message::Show(show)).unwrap(), line); }
#[test] fn a_refusal_may_say_why_and_need_not() { let bare = r#"{"asks":"decline","version":2,"token":"k7f3"}"#; let Message::Decline(decline) = serde_json::from_str::<Message>(bare).unwrap() else { panic!("a refusal"); }; assert!(decline.reason.is_none()); assert_eq!(decline.token.as_deref(), Some("k7f3")); assert_eq!( serde_json::to_string(&Message::Decline(decline)).unwrap(), bare );
let spoken = r#"{"asks":"decline","version":2,"token":"k7f3","reason":"not mine"}"#; let Message::Decline(decline) = serde_json::from_str::<Message>(spoken).unwrap() else { panic!("a refusal"); }; assert_eq!(decline.reason.as_deref(), Some("not mine")); assert_eq!( serde_json::to_string(&Message::Decline(decline)).unwrap(), spoken ); }
#[test] fn asking_what_is_held_carries_only_a_version() { let line = r#"{"asks":"pending","version":2}"#; let message: Message = serde_json::from_str(line).unwrap(); let Message::Pending(pending) = message else { panic!("a question"); }; assert!( !pending.all, "a line without the flag asks for what was seen" ); assert_eq!( serde_json::to_string(&Message::Pending(pending)).unwrap(), line ); }
#[test] fn a_report_may_carry_what_the_adapter_saw_go_past() { let line = r#"{"asks":"report","version":2,"observed":"acted","session":"s1","seen_request_uris":["urn:ietf:params:oauth:request_uri:r1"]}"#; let Message::Report(report) = serde_json::from_str::<Message>(line).unwrap() else { panic!("a report"); }; assert_eq!(report.seen_request_uris.len(), 1); assert_eq!( serde_json::to_string(&Message::Report(report)).unwrap(), line ); }
#[test] fn an_answer_carrying_a_decision_is_the_shape_the_adapter_renders() { let answer = Answer::quiet().and_pending(vec![DecisionForAccount { token: Some("k7f3".into()), client_origin: "http://127.0.0.1:40831".into(), first_time: true, requested: vec!["atproto".into(), "repo:com.example.thing".into()], granted: vec!["atproto".into()], cut: vec!["repo:com.example.thing".into()], rule: Some("ceiling".into()), reason: None, verdict: "narrow".into(), expires_at: "2026-09-09T12:04:00Z".into(), }]); 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"}]}"# ); }
#[test] fn a_refusal_carries_the_rule_and_the_reason_as_two_fields() { let answer = Answer::quiet().and_pending(vec![DecisionForAccount { token: None, client_origin: "http://127.0.0.1:40831".into(), first_time: true, requested: vec!["atproto".into()], granted: Vec::new(), cut: Vec::new(), rule: Some("app-allowlist".into()), reason: Some("that client is not admitted".into()), verdict: "deny".into(), expires_at: "2026-09-09T12:04:00Z".into(), }]); 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"}]}"# ); }
#[test] fn and_a_verdict_that_refused_nothing_carries_no_reason() { let answer = Answer::quiet().and_pending(vec![DecisionForAccount { token: Some("k7f3".into()), client_origin: "http://127.0.0.1:40831".into(), first_time: false, requested: vec!["atproto".into()], granted: vec!["atproto".into()], cut: Vec::new(), rule: None, reason: None, verdict: "allow".into(), expires_at: "2026-09-09T12:04:00Z".into(), }]); let line = serde_json::to_string(&answer).unwrap(); assert!(!line.contains("reason"), "{line}"); assert!(!line.contains("rule"), "{line}"); }
#[test] 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}"# ); }
#[test] fn an_adapter_a_version_behind_is_still_understood() { // What a version 1 adapter sends. Every field of it is still read, // and the daemon's own version check accepts anything up to its own // -- see `serve::Daemon::consider`. let line = r#"{"asks":"report","version":1,"observed":"acted","session":"s1","context":"a1"}"#; let Message::Report(report) = serde_json::from_str::<Message>(line).unwrap() else { panic!("a report"); }; assert_eq!(report.version, 1); assert!(report.seen_request_uris.is_empty()); }
#[test] fn and_reads_the_answer_it_gets_back() { // The same adapter reading a version 2 answer. `pending` and // `granted` are fields it has never heard of, and the fields it does // know are where they were. #[derive(Deserialize)] struct AsVersionOne { version: u32, identity: Option<String>, }
let answer = Answer::identity("did:web:one.example").and_pending(vec![DecisionForAccount { token: Some("k7f3".into()), client_origin: "http://127.0.0.1:40831".into(), first_time: false, requested: vec!["atproto".into()], granted: vec!["atproto".into()], cut: Vec::new(), rule: None, reason: None, verdict: "allow".into(), expires_at: "2026-09-09T12:04:00Z".into(), }]); 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.identity.as_deref(), Some("did:web:one.example")); }}