From 4e8050ff332bb79231693ad71993479aec47c706 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Wed, 9 Sep 2026 15:44:43 -0400 Subject: [PATCH] feat(oauth)!: remove the didbot CLI and the confirm path `didbot-oauth` is the only agent-facing command now. The `didbot` binary, its `confirm` command, the `confirm` ask on the socket and the page-scraping Confirmer behind it are all gone; the loopback delivery the approve path needs already lives in its own module and stays. Nothing on the wire or the command line takes an account from its caller any more, so there is no identity left for a model to name wrongly. Co-Authored-By: Claude Fable 5.1 Change-Id: Ia9a1d44ea0fdc89f7bf8eaf6d52844e61ceebbeb --- crates/didbot-agentd/src/bin/didbot-agentd.rs | 6 +- crates/didbot-agentd/src/bin/didbot.rs | 118 ------------ crates/didbot-agentd/src/confirm.rs | 174 ------------------ crates/didbot-agentd/src/decisions.rs | 9 +- crates/didbot-agentd/src/lib.rs | 6 +- crates/didbot-agentd/src/loopback.rs | 6 +- crates/didbot-agentd/src/protocol.rs | 63 +------ crates/didbot-agentd/src/serve.rs | 113 +----------- docs/agentd.md | 29 ++- plan/cred-delivery.md | 5 +- plan/oauth.md | 9 +- 11 files changed, 43 insertions(+), 495 deletions(-) delete mode 100644 crates/didbot-agentd/src/bin/didbot.rs delete mode 100644 crates/didbot-agentd/src/confirm.rs diff --git a/crates/didbot-agentd/src/bin/didbot-agentd.rs b/crates/didbot-agentd/src/bin/didbot-agentd.rs index fdc97e1c..46ce3c44 100644 --- a/crates/didbot-agentd/src/bin/didbot-agentd.rs +++ b/crates/didbot-agentd/src/bin/didbot-agentd.rs @@ -49,11 +49,7 @@ async fn main() -> ExitCode { } }; - let daemon = Arc::new( - Daemon::new(Pds::new(&server, HARNESS)) - .confirming_at(&server) - .deciding_at(&server), - ); + let daemon = Arc::new(Daemon::new(Pds::new(&server, HARNESS)).deciding_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 deleted file mode 100644 index 06198e1c..00000000 --- a/crates/didbot-agentd/src/bin/didbot.rs +++ /dev/null @@ -1,118 +0,0 @@ -//! What is left of the older command: `confirm`, and nothing else. -//! -//! Superseded by `didbot-oauth`, which is where an agent now sees a sign-in -//! and answers it. This is kept for one release for the case that command -//! cannot cover: an authorize URL a client printed, for a daemon that is not -//! holding the decision it names. -//! -//! The difference worth naming is the account. `confirm` takes one from the -//! caller, which is a thing a model can get wrong or be talked into getting -//! wrong — see `didbot_agentd::protocol::Confirm`. `didbot-oauth approve` -//! takes a token instead, and the account is the one the record names. When -//! the daemon does hold the record this URL points at, it approves it that -//! way regardless of what is passed here, and refuses a `--as` that disagrees -//! with the request. - -use std::process::ExitCode; - -use didbot_agentd::cli::{ask, flag, positional}; -use didbot_agentd::protocol::{Confirm, Message, VERSION}; - -const USAGE: &str = "\ -didbot confirm --as confirm an authorization a client printed - -Superseded by `didbot-oauth`, which is what an agent runs now: - - didbot-oauth pending what has asked to sign in as you - didbot-oauth approve let one of them in - didbot-oauth decline turn one of them down - -Those name a token rather than an account, so there is no identity to get -wrong. Prefer them; this stays for one release. -"; - -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" | "-h") | None => { - print!("{USAGE}"); - ExitCode::SUCCESS - } - Some(command @ ("pending" | "approve" | "decline")) => { - eprintln!("didbot: `{command}` moved to `didbot-oauth {command}`\n\n{USAGE}"); - ExitCode::FAILURE - } - Some(other) => { - eprintln!("didbot: no such command `{other}`\n\n{USAGE}"); - ExitCode::FAILURE - } - } -} - -fn confirm(args: &[String]) -> ExitCode { - let url = positional(args); - let did = flag(args, "as"); - - 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.to_owned(), - 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 - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn args(raw: &[&str]) -> Vec { - raw.iter().map(|arg| (*arg).to_owned()).collect() - } - - #[test] - fn a_confirmation_reads_its_url_and_its_account_from_one_line() { - let given = args(&[ - "https://pds.example/oauth/authorize?request_uri=r1", - "--as", - "did:web:a", - ]); - assert_eq!( - positional(&given).unwrap(), - "https://pds.example/oauth/authorize?request_uri=r1" - ); - assert_eq!(flag(&given, "as"), Some("did:web:a")); - } - - #[test] - fn the_help_sends_an_agent_to_the_command_that_replaced_this_one() { - for command in ["pending", "approve", "decline"] { - assert!( - USAGE.contains(&format!("didbot-oauth {command}")), - "{command} is not named in the usage" - ); - } - // And this binary no longer claims them. - assert!(!USAGE.contains("didbot pending"), "{USAGE}"); - assert!(!USAGE.contains("didbot approve"), "{USAGE}"); - } -} diff --git a/crates/didbot-agentd/src/confirm.rs b/crates/didbot-agentd/src/confirm.rs deleted file mode 100644 index 06b04f75..00000000 --- a/crates/didbot-agentd/src/confirm.rs +++ /dev/null @@ -1,174 +0,0 @@ -//! 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. -//! -//! Superseded by the approval path in [`crate::serve`]: a daemon holding the -//! decision the URL names approves it by token instead of reading the page. -//! This is what happens when it is not, and stays for one release. -//! -//! 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: didbot_http::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> { - crate::loopback::deliver(&self.http, redirect) - .await - .map_err(|err| match err { - crate::loopback::Trouble::Elsewhere(why) => Trouble::Refused(why), - crate::loopback::Trouble::Failed(why) => Trouble::Failed(why), - }) - } -} - -/// 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/decisions.rs b/crates/didbot-agentd/src/decisions.rs index 098b65ab..264eedf8 100644 --- a/crates/didbot-agentd/src/decisions.rs +++ b/crates/didbot-agentd/src/decisions.rs @@ -240,9 +240,8 @@ impl Pds { pub fn new(base: impl Into) -> Self { Self { base: base.into().trim_end_matches('/').to_string(), - // Long enough for a held poll, and redirects off for the same - // reason [`crate::confirm`] turns them off: nothing this daemon - // fetches gets to choose where the next request goes. + // Long enough for a held poll, and redirects off because nothing + // this daemon fetches gets to choose where the next request goes. http: didbot_http::builder() .timeout(Duration::from_secs(u64::from(WAIT) + 10)) .redirect(reqwest::redirect::Policy::none()) @@ -309,8 +308,8 @@ impl Pds { /// Hand a code to the client waiting for it on this machine. /// - /// The same bounded fetch [`crate::confirm`] makes, over the same client: - /// see [`crate::loopback`] for why the daemon is the one that makes it. + /// Bounded to loopback, with no chains: see [`crate::loopback`] for why + /// the daemon is the one that makes this fetch at all. pub async fn deliver(&self, redirect: &str) -> Result<(), crate::loopback::Trouble> { crate::loopback::deliver(&self.http, redirect).await } diff --git a/crates/didbot-agentd/src/lib.rs b/crates/didbot-agentd/src/lib.rs index b8993caa..3ea22f51 100644 --- a/crates/didbot-agentd/src/lib.rs +++ b/crates/didbot-agentd/src/lib.rs @@ -16,9 +16,8 @@ //! in another repository and is not written in Rust, so the wire format is //! the contract rather than these types. //! -//! [`cli`] is the other end of that wire: what the `didbot-oauth` and -//! `didbot` commands share, so that two binaries speaking one protocol keep -//! one copy of it. +//! [`cli`] is the other end of that wire: what `didbot-oauth`, the one +//! agent-facing command, uses to make an exchange over it. //! //! [`decisions`] is the other direction: the sign-in requests an agent is //! asked about. Those come from the server, so this daemon holds the account @@ -32,7 +31,6 @@ mod double; pub mod cli; -pub mod confirm; pub mod context; pub mod decisions; pub mod loopback; diff --git a/crates/didbot-agentd/src/loopback.rs b/crates/didbot-agentd/src/loopback.rs index 0642bc8e..b0139c2e 100644 --- a/crates/didbot-agentd/src/loopback.rs +++ b/crates/didbot-agentd/src/loopback.rs @@ -8,10 +8,8 @@ //! //! That is a request whose target came from a process the model started, so //! it is bounded twice: loopback only, and no chains. Both bounds live here -//! rather than in each caller, because there is now more than one — the -//! deprecated page-scrape path in [`crate::confirm`] and the approval path in -//! [`crate::serve`] — and a bound that has to be repeated is a bound that -//! will eventually be repeated wrongly. +//! live here rather than at the call site, so that a second caller cannot +//! acquire a weaker copy of them. use url::Url; diff --git a/crates/didbot-agentd/src/protocol.rs b/crates/didbot-agentd/src/protocol.rs index 4aa28934..4459d323 100644 --- a/crates/didbot-agentd/src/protocol.rs +++ b/crates/didbot-agentd/src/protocol.rs @@ -39,10 +39,6 @@ pub const VERSION: u32 = 2; 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. - /// - /// Superseded by [`Message::Approve`]; see [`Confirm`]. - Confirm(Confirm), /// 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 @@ -60,9 +56,6 @@ impl<'de> Deserialize<'de> for Message { None | Some("report") => serde_json::from_value(value) .map(Message::Report) .map_err(D::Error::custom), - Some("confirm") => serde_json::from_value(value) - .map(Message::Confirm) - .map_err(D::Error::custom), Some("approve") => serde_json::from_value(value) .map(Message::Approve) .map_err(D::Error::custom), @@ -141,43 +134,6 @@ pub struct Report { pub seen_request_uris: Vec, } -/// A tool asking the daemon to confirm an authorization it started. -/// -/// The URL is the client's own authorize page, printed rather than opened. -/// -/// # Deprecated, and kept for one release -/// -/// [`Message::Approve`] replaces this. An approval names a decision the -/// daemon is already holding, so the account is the one that decision names -/// and the caller supplies nothing but a one-time token — which is the whole -/// of the problem described below, gone. A daemon that is holding the record -/// this URL's `request_uri` names approves it that way; one that is not falls -/// back to reading the authorize page, exactly as it always did. -/// -/// # 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 a server with no denial loaded -/// admits 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, -} - /// An agent approving a sign-in the daemon put in front of it. /// /// One field, and it is not an account. The token was minted by the server @@ -387,6 +343,15 @@ mod tests { 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}"#; @@ -402,16 +367,6 @@ mod tests { assert!(serde_json::from_str::(line).is_err()); } - #[test] - fn a_confirmation_carries_the_account_the_caller_claims() { - let line = r#"{"asks":"confirm","version":2,"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"); - } - #[test] fn an_approval_names_a_token_and_nothing_else() { let line = r#"{"asks":"approve","version":2,"token":"k7f3"}"#; diff --git a/crates/didbot-agentd/src/serve.rs b/crates/didbot-agentd/src/serve.rs index dfcbf065..747c71d3 100644 --- a/crates/didbot-agentd/src/serve.rs +++ b/crates/didbot-agentd/src/serve.rs @@ -24,7 +24,7 @@ use crate::context::{Key, Next, Store}; use crate::decisions::{Account, Record}; use crate::pending::Held; use crate::protocol::{ - Answer, Approve, Confirm, DecisionForAgent, Decline, Message, Observed, Report, VERSION, + Answer, Approve, DecisionForAgent, Decline, Message, Observed, Report, VERSION, }; use crate::registrar::{Registrar, Wanted}; use crate::socket::Listener; @@ -33,7 +33,6 @@ use crate::socket::Listener; pub struct Daemon { contexts: Mutex, registrar: R, - confirmer: Option, decisions: Option>, held: Arc>, pollers: Mutex>>, @@ -45,76 +44,21 @@ impl Daemon { Self { contexts: Mutex::new(Store::new()), registrar, - confirmer: None, decisions: None, held: Arc::new(Mutex::new(Held::new())), pollers: Mutex::new(HashMap::new()), } } - /// 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 - } - /// Give it somewhere to fetch and answer sign-in decisions. /// - /// Optional, like the confirmer: a daemon with no server to ask still - /// issues identities, and says plainly that it has nowhere to ask rather - /// than failing to start. + /// Optional: a daemon with no server to ask still issues identities, and + /// says plainly that it has nowhere to ask rather than failing to start. pub fn deciding_at(mut self, server: impl Into) -> Self { self.decisions = Some(Arc::new(crate::decisions::Pds::new(server))); self } - /// Confirm an authorization for the account the caller names. - /// - /// Deprecated; see [`Confirm`]. When this daemon is already holding the - /// decision the URL names, that record is approved by token and the - /// caller's `did` is checked against it rather than believed. Otherwise - /// this is what it always was: read the page, post the reference. - pub async fn confirm(&self, confirm: Confirm) -> Answer { - if let Some(refusal) = too_new(confirm.version) { - return refusal; - } - - let did = confirm.did.clone(); - if let Some(held) = self.held_for_url(&confirm.url).await { - return match held.record.token.clone() { - Some(token) if held.record.account == did => { - info!(did = %did, "approving a decision this daemon holds"); - self.redeem(&token).await - } - Some(_) => Answer::trouble(format!( - "that request is for {}, and this asks to confirm it as {did}", - held.record.account - )), - // A refused request has no token, and reading the page would - // not produce one either. - None => Answer::trouble( - "that request was refused, so there is nothing to confirm".to_owned(), - ), - }; - } - - let Some(confirmer) = self.confirmer.as_ref() else { - return Answer::trouble("this daemon has nowhere to confirm against".to_owned()); - }; - - 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()) - } - } - } - /// Approve a sign-in, as the account the decision itself names. /// /// The caller supplies a token and nothing else. Which account signs in @@ -226,16 +170,6 @@ impl Daemon { }) } - /// The decision an authorize URL names, when this daemon holds it. - async fn held_for_url(&self, url: &str) -> Option { - let request_uri = url::Url::parse(url) - .ok()? - .query_pairs() - .find(|(name, _)| name == "request_uri") - .map(|(_, value)| value.into_owned())?; - self.held.lock().await.by_request_uri(&request_uri).cloned() - } - /// Serve until the listener fails. pub async fn run(self: Arc, listener: Listener) -> std::io::Error where @@ -270,7 +204,6 @@ impl Daemon { let answer = match serde_json::from_str::(&line) { Ok(Message::Report(report)) => self.consider(report).await, - Ok(Message::Confirm(confirm)) => self.confirm(confirm).await, Ok(Message::Approve(approve)) => self.approve(approve).await, Ok(Message::Decline(decline)) => self.decline(decline).await, Ok(Message::Pending(pending)) => self.pending(pending).await, @@ -858,46 +791,6 @@ mod tests { assert_eq!(pending[0].token.as_deref(), Some("k1")); } - #[tokio::test] - async fn the_old_confirm_approves_a_decision_this_daemon_is_holding() { - let double = double::start().await; - double.state.offer(offered(&double, "r1", "k1")); - let daemon = daemon_with(&double).await; - until!("the poll found it", !daemon.holding().await.is_empty()); - - // No authorize page is read and no consent reference is scraped: the - // URL is only where the request_uri comes from. - let answer = daemon - .confirm(Confirm { - version: VERSION, - did: MINTED.into(), - url: format!("{}/oauth/authorize?request_uri=r1", double.origin), - }) - .await; - - assert!(answer.trouble.is_none(), "{answer:?}"); - assert_eq!(double.state.approved(), vec!["k1".to_owned()]); - } - - #[tokio::test] - async fn and_refuses_a_caller_naming_an_account_that_is_not_the_requests() { - let double = double::start().await; - double.state.offer(offered(&double, "r1", "k1")); - let daemon = daemon_with(&double).await; - until!("the poll found it", !daemon.holding().await.is_empty()); - - let answer = daemon - .confirm(Confirm { - version: VERSION, - did: "did:web:somebody.else".into(), - url: format!("{}/oauth/authorize?request_uri=r1", double.origin), - }) - .await; - - assert!(answer.trouble.is_some(), "{answer:?}"); - assert!(double.state.approved().is_empty()); - } - #[tokio::test] async fn asking_what_is_held_answers_from_memory() { let double = double::start().await; diff --git a/docs/agentd.md b/docs/agentd.md index d431f6aa..724cc969 100644 --- a/docs/agentd.md +++ b/docs/agentd.md @@ -203,22 +203,19 @@ loopback on this host — so it answers with the redirect and the daemon fetches it. That fetch is bounded to loopback, with redirects turned off, as is every request the daemon makes on something the model influenced. -The two are separate programs on purpose. `didbot-oauth` is what an agent -runs, and every command in it names a token; `didbot` is what is left of the -older command, and the one thing still in it is the one that names an account. -Keeping them apart means the agent-facing binary has no `--as` flag to reach -for. They share one crate and one socket exchange (`didbot_agentd::cli`), so -the split is in the command surface and not in the code. - -`didbot confirm --as ` is the older command and works for one more -release. When the daemon is holding the record that URL's `request_uri` names, -it approves that record by token and refuses a caller naming an account the -request does not; otherwise it does what it always did, fetching the authorize -page this deployment publishes in its own discovery document and reading the -one-time reference out of it. The account is the one the caller names there, -and a model that can read another context's identifier out of a transcript can -name it — model-level custody, which is the ceiling the socket section above -already set. +`didbot-oauth` is the only agent-facing command, and every command in it names +a token. There is no `--as ` anywhere in it, and nothing left that takes +an account from its caller: the daemon reads the account off the record it is +holding the token in. That is what closes the gap the socket section above +describes — reaching the socket is still the authorization, and anything +running as this user can still connect, but there is no longer a field on the +wire for a caller to put another context's identity into. + +The command that did take one is gone. It handed the daemon an authorize URL +to fetch and an account to confirm it as, which meant a model that could read +another context's identifier out of a transcript could name it. Approving a +decision the daemon already holds needs neither, so the URL, the page it was +read from, and the account argument all went with it. ## Which hosts run it diff --git a/plan/cred-delivery.md b/plan/cred-delivery.md index 02574b51..405723d8 100644 --- a/plan/cred-delivery.md +++ b/plan/cred-delivery.md @@ -85,7 +85,10 @@ once, so an MCP transport needs no rewriting at all. the record it arrived in, and the account is read off that record. A token the daemon is not holding is refused rather than passed on, so there is no identity for a caller to get wrong — which is a narrower - claim than the ticket makes, and holds only for approvals. + claim than the ticket makes, and holds only for approvals. The one + command that did take an account from its caller has been removed + rather than kept and deprecated, so no path is left that needs the + check this item describes. ## What the model can still reach diff --git a/plan/oauth.md b/plan/oauth.md index 6e6dccd5..85203f65 100644 --- a/plan/oauth.md +++ b/plan/oauth.md @@ -49,10 +49,11 @@ Two tool calls, and no browser anywhere: code. 4. The client exchanges it and holds tokens bound to its own key. -The agent never handles a URL and never names its account. `didbot confirm - --as ` still works and is kept for one release; when the daemon is -already holding the record that URL's `request_uri` names, it approves that -record by token instead of reading the page. +The agent never handles a URL and never names its account. `didbot-oauth` is +the only agent-facing command, and there is no `--as ` in it: the daemon +reads the account off the record it holds the token in. The `didbot confirm` +command that used to take one, and the `confirm` ask on the socket it sent, are +both gone. - [ ] **Two things a client must support, and no more.** Print the URL rather than opening it, and accept an account identifier to resolve. Neither -- 2.51.2