//! 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>(deserializer: D) -> Result { 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, /// 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, /// 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, } /// 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, /// 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, } /// 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, /// An authorize URL, or a bare `request_uri`, naming the decision. #[serde(default, skip_serializing_if = "Option::is_none")] pub url: Option, /// Why, in the agent's own words, for the record. #[serde(default, skip_serializing_if = "Option::is_none")] pub reason: Option, } /// 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, /// 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, /// 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, /// 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, /// The rule that narrowed or refused it, named on its own. #[serde(default, skip_serializing_if = "Option::is_none")] pub rule: Option, /// 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, /// `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, /// 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, /// 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>, /// 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>, /// 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>, /// What the approved login's client requested, beside [`Answer::granted`]. #[serde(default, skip_serializing_if = "Option::is_none")] pub requested: Option>, } 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) -> Self { Self { identity: Some(did.into()), ..Self::quiet() } } /// Something the daemon carried out. pub fn done(what: impl Into) -> Self { Self { done: Some(what.into()), ..Self::quiet() } } /// Something a caller should surface rather than swallow. pub fn trouble(why: impl Into) -> 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) -> 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) -> 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, granted: Vec) -> 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::(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::(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::(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::(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::(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::(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::(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::(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::(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, } 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")); } }