Something went wrong. Try again.
Identities for entities did.bot
agent llm did
Something went wrong. Try again.
16 kB · 432 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433//! What the agent-facing commands share: the socket call, and how a decision//! is put on a line.//!//! `didbot-oauth`, reached as `didbot oauth`, is the command an agent runs —//! see what is waiting, say yes, say no. There is no `--as` flag in it to be//! talked into filling in: which account is being acted as is the daemon's to//! know.//!//! Nothing here decides anything. It writes what a caller asked for onto the//! socket and renders what comes back; which account is being acted as is the//! daemon's to know, and is deliberately not a thing these commands can say.
use std::io::{BufRead, BufReader, Write};use std::os::unix::net::UnixStream;
use crate::protocol::{Answer, DecisionForAccount, Message};use crate::socket::default_socket_path;
/// Which way `didbot-oauth` is talking to the world this time.pub enum Mode { /// Over the socket, to a daemon that holds the credentials. Daemon, /// Straight to the server, as the one account named by the environment. Direct(Box<crate::direct::Direct>),}
/// Decide which, without asking for anything that is not needed.////// `--direct` is taken at its word and **never probes the socket**: a caller/// who said which mode they wanted should not have a connection attempted on/// their behalf, and a test can prove that only if nothing is opened.////// Otherwise a daemon is preferred, because on a host that has one it holds a/// credential per context and the environment holds at most one. Only when/// nothing answers there does the environment get a turn — and if it names/// nothing either, the error a caller sees is the daemon's, since that is/// what almost everyone was expecting to reach.pub fn choose(forced: bool, socket: &std::path::Path) -> Result<Mode, String> { if forced { return crate::direct::Direct::from_env().map(|d| Mode::Direct(Box::new(d))); } if UnixStream::connect(socket).is_ok() { return Ok(Mode::Daemon); } if crate::direct::Direct::configured() { return crate::direct::Direct::from_env().map(|d| Mode::Direct(Box::new(d))); } Ok(Mode::Daemon)}
/// Where the socket is, honouring the override the daemon also reads.pub fn socket_path() -> std::path::PathBuf { std::env::var_os("DIDBOT_SOCK") .map(Into::into) .unwrap_or_else(default_socket_path)}
/// One line out, one line back.////// The connection carries one exchange and closes, which is the whole of the/// protocol's session model — see [`crate::protocol`].pub fn ask(message: &Message) -> std::io::Result<Answer> { let path = 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}"), ) })}
/// The first argument that is not a flag or a flag's value.////// Hand-rolled rather than an argument-parsing dependency, because the whole/// surface is one positional and one option: a token, and a reason for/// refusing. The one thing worth getting right is that a reason which reads/// like a token is still the reason.pub fn positional(args: &[String]) -> Option<&String> { let mut skip = false; for arg in args { if skip { skip = false; continue; } if arg.starts_with("--") { skip = !arg.contains('='); continue; } return Some(arg); } None}
/// The value of `--name`, written either way round.pub fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> { let long = format!("--{name}"); let joined = format!("--{name}="); for (at, arg) in args.iter().enumerate() { if arg == &long { return args.get(at + 1).map(String::as_str); } if let Some(value) = arg.strip_prefix(&joined) { return Some(value); } } None}
/// One string a server chose, ready to put on a terminal.////// Everything outside printable ASCII is written as its escape rather than/// sent through, so a scope atom or an origin carrying `ESC[` cannot move the/// cursor, repaint the line above it, or hide what an approval covers. An/// agent reads these at the moment it decides, which is the worst moment for/// the screen to be lying.pub fn printable(text: &str) -> String { text.chars() .map(|c| { if c.is_ascii_graphic() || c == ' ' { c.to_string() } else { c.escape_debug().to_string() } }) .collect()}
/// A scope set, as one field of a line.pub fn list(scopes: &[String]) -> String { if scopes.is_empty() { "none".to_owned() } else { scopes .iter() .map(|scope| printable(scope)) .collect::<Vec<_>>() .join(",") }}
/// What a narrowed line adds after its rule: an approval covers `granted`,/// and the ceiling is checked again at every use.////// So the login is issued at `granted`, and the ceiling in force can only/// take atoms off it — at exchange, at every refresh and at every write./// `cut` is outside the approval, so a ceiling loosened afterwards still/// answers `granted`, and a cut atom takes a fresh sign-in to ask for.////// This line is the consent screen on this path. It says the same thing the/// browser page's `data-approves` does.const NARROW_APPROVES: &str = "approves=granted ceiling-checked=each-use";
/// One decision on one line, with the token last.////// Last because it is the part that gets copied into the next command, and/// because a line cut short by a narrow terminal should lose the commentary/// rather than the thing being named. Fields that say nothing are left off/// entirely: a request granted exactly as asked does not repeat itself, and/// a verdict that refused nothing carries no rule.pub fn one_line(decision: &DecisionForAccount) -> String { let mut line = format!( "{} {} asked={} verdict={}", printable(&decision.client_origin), if decision.first_time { "first-time" } else { "seen-before" }, list(&decision.requested), printable(&decision.verdict), ); if decision.granted != decision.requested { line.push_str(&format!(" granted={}", list(&decision.granted))); } if !decision.cut.is_empty() { line.push_str(&format!(" cut={}", list(&decision.cut))); } if let Some(rule) = &decision.rule { line.push_str(&format!(" rule={rule:?}")); } // After the rule, because it is the rule's own sentence: an operator reads // the identifier, an agent reads the prose. if let Some(reason) = &decision.reason { line.push_str(&format!(" reason={reason:?}")); } if decision.verdict == "narrow" { line.push(' '); line.push_str(NARROW_APPROVES); } line.push_str(&format!(" expires={}", printable(&decision.expires_at))); match &decision.token { Some(token) => line.push_str(&format!(" token={}", printable(token))), None => line.push_str(" token=none"), } line}
#[cfg(test)]mod tests { use super::*;
fn args(raw: &[&str]) -> Vec<String> { raw.iter().map(|arg| (*arg).to_owned()).collect() }
/// A socket that counts what connects to it, and nothing else. /// /// Enough to answer the only question here: was anything opened? struct Watched { path: std::path::PathBuf, accepted: std::sync::Arc<std::sync::atomic::AtomicUsize>, _dir: std::path::PathBuf, }
fn listening() -> Watched { use std::sync::atomic::{AtomicUsize, Ordering}; let dir = std::env::temp_dir().join(format!( "didbot-cli-{}-{:?}", std::process::id(), std::thread::current().id() )); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("agent.sock"); let listener = std::os::unix::net::UnixListener::bind(&path).unwrap(); let accepted = std::sync::Arc::new(AtomicUsize::new(0)); let counter = std::sync::Arc::clone(&accepted); std::thread::spawn(move || { for stream in listener.incoming() { if stream.is_err() { break; } counter.fetch_add(1, Ordering::SeqCst); } }); Watched { path, accepted, _dir: dir, } }
#[test] fn direct_mode_never_opens_the_socket() { use std::sync::atomic::Ordering; let watched = listening();
// A daemon *is* listening, and would be preferred. `--direct` says // not to look, so nothing may connect -- not even to find out. let chosen = choose(true, &watched.path);
// No credential in this environment, so it refuses; the refusal is // about the environment, never about the socket. let why = chosen.err().expect("no credential here"); assert!( why.contains(crate::direct::SERVER) || why.contains(crate::direct::TOKEN), "{why}" ); std::thread::sleep(std::time::Duration::from_millis(50)); assert_eq!( watched.accepted.load(Ordering::SeqCst), 0, "--direct opened the socket" ); std::fs::remove_dir_all(&watched._dir).ok(); }
#[test] fn a_daemon_that_is_listening_is_preferred_when_nothing_is_forced() { use std::sync::atomic::Ordering; let watched = listening();
let chosen = choose(false, &watched.path).expect("a mode"); assert!(matches!(chosen, Mode::Daemon));
std::thread::sleep(std::time::Duration::from_millis(50)); assert_eq!(watched.accepted.load(Ordering::SeqCst), 1); std::fs::remove_dir_all(&watched._dir).ok(); }
#[test] fn and_with_no_daemon_and_no_credential_the_error_is_still_the_daemons() { // The ordinary mistake is that the daemon is not running, so that is // what a caller who configured nothing is told about -- by `ask`, // which names the path it could not reach. let nowhere = std::path::Path::new("/nonexistent/didbot/agent.sock"); assert!(matches!(choose(false, nowhere), Ok(Mode::Daemon))); }
#[test] fn a_token_is_found_whichever_side_of_the_flags_it_is_on() { assert_eq!(positional(&args(&["k7f3"])).unwrap(), "k7f3"); assert_eq!( positional(&args(&["--reason", "no", "k7f3"])).unwrap(), "k7f3" ); assert_eq!( positional(&args(&["k7f3", "--reason", "no"])).unwrap(), "k7f3" ); // A reason that reads like a token is still the reason. assert_eq!( positional(&args(&["--reason=nope", "k7f3"])).unwrap(), "k7f3" ); assert!(positional(&args(&["--reason", "no"])).is_none()); }
#[test] fn a_flag_may_be_written_either_way_round() { assert_eq!(flag(&args(&["--reason", "no"]), "reason"), Some("no")); assert_eq!(flag(&args(&["--reason=no"]), "reason"), Some("no")); assert_eq!(flag(&args(&["k7f3"]), "reason"), None); // The other binary's flag, parsed by the same helper. assert_eq!(flag(&args(&["--as", "did:web:a"]), "as"), Some("did:web:a")); }
fn decision() -> DecisionForAccount { 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(), } }
#[test] fn a_line_stays_one_line_however_the_server_wrote_its_fields() { // Every field here is chosen by whoever answered, so each is a place // an escape sequence could be handed to the terminal. let mut decision = decision(); decision.client_origin = "http://one.example\u{1b}[2K".into(); decision.requested = vec!["atproto\u{1b}[A".into()]; decision.granted = vec!["atproto".into()]; decision.cut = vec![]; decision.verdict = "allow\r".into(); decision.token = Some("k7f3\nrogue".into());
let line = one_line(&decision); assert!(!line.contains('\u{1b}'), "{line}"); assert!(!line.contains('\n'), "{line}"); assert!(!line.contains('\r'), "{line}"); assert!(line.contains(r"asked=atproto\u{1b}[A"), "{line}"); assert!(line.ends_with(r"token=k7f3\nrogue"), "{line}"); }
#[test] fn a_decision_is_one_line_and_the_token_is_the_end_of_it() { let line = one_line(&decision()); assert!(!line.contains('\n')); assert!(line.ends_with("token=k7f3"), "{line}"); assert!( line.starts_with("http://127.0.0.1:40831 first-time"), "{line}" ); for part in ["asked=", "granted=", "cut=", "rule=", "expires="] { assert!(line.contains(part), "{part} missing from {line}"); } }
#[test] fn a_narrowed_line_says_the_approval_covers_what_was_granted() { // An agent approving this approves `granted`, and the ceiling is // asked again at every use, which can only take more off. let line = one_line(&decision()); assert!( line.contains(" approves=granted ceiling-checked=each-use "), "{line}" ); // Commentary on the rule, so it follows it, and the token stays last. assert!(line.find("rule=") < line.find("approves="), "{line}"); assert!(line.ends_with("token=k7f3"), "{line}"); }
#[test] fn one_that_was_refused_says_so_rather_than_offering_a_token() { let mut refused = decision(); refused.token = None; refused.verdict = "deny".into(); refused.granted = Vec::new(); refused.cut = Vec::new(); refused.rule = Some("app-allowlist".into()); refused.reason = Some("that client is not admitted".into()); let line = one_line(&refused); assert!(line.contains("verdict=deny"), "{line}"); assert!(line.contains("granted=none"), "{line}"); // The rule and the reason are two fields, and the reason follows the // rule it belongs to. let rule_at = line.find("rule=").expect("the rule"); let reason_at = line.find("reason=").expect("the reason"); assert!(rule_at < reason_at, "{line}"); assert!( line.contains(r#"reason="that client is not admitted""#), "{line}" ); assert!(!line.contains("approves="), "nothing to approve: {line}"); assert!(line.ends_with("token=none"), "{line}"); }
#[test] fn a_request_granted_in_full_does_not_repeat_itself() { let mut whole = decision(); whole.verdict = "allow".into(); whole.granted = whole.requested.clone(); whole.cut = Vec::new(); whole.rule = None; whole.reason = None; let line = one_line(&whole); assert!(!line.contains("granted="), "{line}"); assert!(!line.contains("cut="), "{line}"); assert!(!line.contains("reason="), "{line}"); assert!(!line.contains("approves="), "{line}"); }}