//! 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. 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), /// Anything asking what this host is. Host(Host), /// The operator's command asking this host to take an identity. Become(Become), } 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("host") => serde_json::from_value(value) .map(Message::Host) .map_err(D::Error::custom), Some("become") => serde_json::from_value(value) .map(Message::Become) .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 go past in tool output. /// /// The fast path, and only that. [`Answer::pending`] is filled by what /// the daemon has already been told; this is for the case where a client /// printed its authorize URL in the very tool call being reported, which /// is sooner than a poll can have finished. The daemon fetches any of /// these it is not already holding. #[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, for a request no poll had delivered /// and no tool output was seen to carry. 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. /// /// The daemon hears about a sign-in by polling, and an adapter can hand it a /// `request_uri` it saw go past in tool output. Both can miss: a client that /// printed its URL where no hook read it, or a poll that has not come back /// yet. This is that same lookup made deliberate -- asked for by an agent /// holding the URL, rather than by the daemon noticing 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 with everything, not with one context's share. 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. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Pending { /// The wire version this message was written against. pub version: u32, } /// 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 DecisionForAgent { /// 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, and what an approval covers. pub requested: Vec, /// What the ceiling grants of the request now. /// /// 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. /// /// Withheld, not refused: the approval covers the whole request, so a /// loosened ceiling grants these to the same login without asking again. #[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 [`DecisionForAgent::rule`] rather than folded into it: /// the rule is an identifier an owner 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, } /// A caller asking what this host is: the public half of its key, and the /// identity it has been given, when it has one. /// /// The public half is the only thing about the key that crosses the socket. /// Signing happens inside the daemon. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Host { /// The wire version this message was written against. pub version: u32, } /// A caller asking the daemon to reserve an identity for its key at a server. /// /// The server is the caller's choice, and reaching the socket is what allows /// the choice: see [`crate::socket`]. What it cannot do is replace an /// identity the host already holds; a daemon that holds one answers with it /// and asks nobody. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Become { /// The wire version this message was written against. pub version: u32, /// The origin of the server to reserve at. pub server: String, } /// What this host is, as far as the daemon knows. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct HostAnswer { /// The public half of the node key, as `did:key`. pub key: String, /// The account this host is, once a server has reserved one. #[serde(default, skip_serializing_if = "Option::is_none")] pub did: Option, /// The name the operator claims it by, present with `did`. #[serde(default, skip_serializing_if = "Option::is_none")] pub hostname: Option, } /// 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, for the context that asked. #[serde(default, skip_serializing_if = "Option::is_none")] pub pending: 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>, /// What this host is, in answer to a [`Host`] question. #[serde(default, skip_serializing_if = "Option::is_none")] pub host: 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, granted: None, requested: None, host: 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() } } /// What this host is. pub fn host(host: HostAnswer) -> Self { Self { host: Some(host), ..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 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_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![DecisionForAgent { 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![DecisionForAgent { 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![DecisionForAgent { 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![DecisionForAgent { 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")); } #[test] fn a_host_question_is_read_and_its_answer_carries_only_the_public_half() { let line = r#"{"asks":"host","version":2}"#; assert!(matches!( serde_json::from_str::(line).unwrap(), Message::Host(_) )); let answer = Answer::host(HostAnswer { key: "did:key:zQ".into(), did: None, hostname: None, }); assert_eq!( serde_json::to_string(&answer).unwrap(), r#"{"version":2,"host":{"key":"did:key:zQ"}}"# ); } #[test] fn becoming_names_the_server() { let line = r#"{"asks":"become","version":2,"server":"http://pds.example"}"#; let Message::Become(asked) = serde_json::from_str::(line).unwrap() else { panic!("a become"); }; assert_eq!(asked.server, "http://pds.example"); } }