Skip to main content

okbot_peer/
node.rs

1//! A bot's place in its teams: manifests, keys, and the transport to other members.
2
3use std::collections::{BTreeMap, HashMap, VecDeque};
4use std::path::{Path, PathBuf};
5use std::sync::{Arc, Mutex, OnceLock, RwLock};
6use std::time::{Duration, Instant};
7
8use jiff::Timestamp;
9use okbot_core::{
10    Clock, ConversationKey, ConversationStore, InboundSink, PeerCaller, PeerError, Prompt, SentRef,
11};
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14use sha2::Digest as _;
15use tokio::io::{AsyncBufReadExt as _, AsyncReadExt as _, AsyncWriteExt as _, BufReader};
16
17use crate::key::{PublicKey, SigningKey, bot_public_key};
18use crate::manifest::{self, Manifest, Member};
19use crate::token::{self, Claims, Replays};
20use crate::{Error, a2a};
21
22/// Where the sockets of the bots of this machine live: `$OKBOT_PEER_DIR`, else
23/// `$XDG_RUNTIME_DIR/okbot`, else `<temp>/okbot-<user>`. Bots under different OS users share one
24/// directory by setting `OKBOT_PEER_DIR` alike.
25pub fn socket_dir() -> PathBuf {
26    if let Some(dir) = std::env::var_os("OKBOT_PEER_DIR") {
27        return PathBuf::from(dir);
28    }
29    if let Some(dir) = std::env::var_os("XDG_RUNTIME_DIR") {
30        return PathBuf::from(dir).join("okbot");
31    }
32    let user = std::env::var("USER").unwrap_or_else(|_| "user".into());
33    std::env::temp_dir().join(format!("okbot-{user}"))
34}
35
36/// The socket in `sockets` of the bot in `bot_dir`, named after its canonical path (short whatever
37/// the path's length, and the same for the bot and for the members that call it).
38pub fn socket_path(sockets: &Path, bot_dir: &Path) -> PathBuf {
39    let dir = std::fs::canonicalize(bot_dir).unwrap_or_else(|_| bot_dir.to_path_buf());
40    let digest = sha2::Sha256::digest(dir.as_os_str().as_encoded_bytes());
41    sockets.join(format!("{}.sock", &hex::encode(digest)[..20]))
42}
43/// Header carrying the response's signature.
44pub const SIGNATURE_HEADER: &str = "okbot-peer-signature";
45/// The authorization scheme.
46pub(crate) const SCHEME: &str = "OkbotPeer ";
47/// How often fetched manifests are refreshed.
48pub(crate) const REFRESH: Duration = Duration::from_secs(600);
49/// How long a member that did not answer is skipped.
50const DOWN_FOR: Duration = Duration::from_secs(60);
51/// Largest request or response body.
52pub(crate) const MAX_BODY: usize = 4 << 20;
53/// Time for one request to another member.
54const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
55
56/// Where a team's manifest comes from.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum ManifestSource {
59    /// A file on this machine (trusted as is).
60    File(PathBuf),
61    /// Built from `[a2a]` (the team `external`).
62    Builtin,
63    /// An `https://` URL, with `<url>.sig` signed by the team key.
64    Url {
65        /// The manifest URL.
66        url: String,
67        /// The pinned team key.
68        team_key: PublicKey,
69    },
70}
71
72/// This bot's settings for one team (`[teams.<id>]`).
73#[derive(Debug, Clone)]
74pub struct TeamConfig {
75    /// Team id.
76    pub id: String,
77    /// This bot's member name.
78    pub member: String,
79    /// Where the manifest is.
80    pub source: ManifestSource,
81    /// Other members may delegate to this bot.
82    pub accept: bool,
83    /// Longest chain accepted.
84    pub max_depth: u32,
85    /// Inbound tasks at once per caller.
86    pub max_concurrent: u32,
87    /// The longest a task delegated by this bot may take.
88    pub task_timeout: Duration,
89}
90
91/// Everything a [`Node`](crate::Node) needs besides its ports.
92#[derive(Debug, Clone)]
93pub struct NodeConfig {
94    /// This bot's key.
95    pub key: SigningKey,
96    /// This bot's X25519 key, for sealed messages through relays.
97    pub box_key: crate::seal::BoxKey,
98    /// This bot's `state/` directory.
99    pub state_dir: PathBuf,
100    /// Where the sockets of this machine's bots are ([`socket_dir`]).
101    pub sockets: PathBuf,
102    /// Teams.
103    pub teams: Vec<TeamConfig>,
104    /// A2A agents and clients that are not okbots, and this bot's name among them.
105    pub external: Option<(String, crate::external::ExternalConfig)>,
106}
107
108/// One team as this bot knows it.
109pub(crate) struct Team {
110    pub(crate) config: TeamConfig,
111    manifest: RwLock<Arc<Manifest>>,
112    /// When a request last made this bot read the manifest again.
113    forced: Mutex<Option<Instant>>,
114}
115
116impl Team {
117    pub(crate) fn manifest(&self) -> Arc<Manifest> {
118        self.manifest
119            .read()
120            .unwrap_or_else(std::sync::PoisonError::into_inner)
121            .clone()
122    }
123
124    pub(crate) fn signed(&self) -> bool {
125        matches!(self.config.source, ManifestSource::Url { .. })
126    }
127
128    /// Whether a request naming a newer manifest may make this bot read it now: at most every
129    /// [`FORCED_REFRESH`], since the claim is read before the signature can be checked.
130    pub(crate) fn may_force_refresh(&self) -> bool {
131        let mut last = lock(&self.forced);
132        if last.is_some_and(|at| at.elapsed() < FORCED_REFRESH) {
133            return false;
134        }
135        *last = Some(Instant::now());
136        true
137    }
138}
139
140/// The shortest time between two reads of a manifest asked for by requests.
141const FORCED_REFRESH: Duration = Duration::from_secs(30);
142
143/// An inbound task waiting for the runtime's answer.
144#[derive(Debug, Clone)]
145pub(crate) struct Running {
146    pub(crate) team: String,
147    pub(crate) caller: String,
148    pub(crate) task: String,
149    pub(crate) deadline: Timestamp,
150}
151
152/// A prompt the runtime showed in a peer conversation.
153#[derive(Debug, Clone)]
154pub(crate) struct OpenPrompt {
155    pub(crate) prompt: Prompt,
156    pub(crate) sent: SentRef,
157}
158
159/// Shared state of a node.
160pub(crate) struct Inner {
161    pub(crate) key: SigningKey,
162    pub(crate) box_key: crate::seal::BoxKey,
163    pub(crate) sockets: PathBuf,
164    pub(crate) state_dir: PathBuf,
165    pub(crate) teams: BTreeMap<String, Team>,
166    pub(crate) store: Arc<dyn ConversationStore>,
167    pub(crate) clock: Arc<dyn Clock>,
168    pub(crate) replays: Replays,
169    pub(crate) http: reqwest::Client,
170    pub(crate) sink: OnceLock<Arc<dyn InboundSink>>,
171    pub(crate) local_server: std::sync::atomic::AtomicBool,
172    /// Inbound tasks by conversation, oldest first.
173    pub(crate) inbound: Mutex<HashMap<ConversationKey, VecDeque<Running>>>,
174    pub(crate) callers: Mutex<HashMap<ConversationKey, PeerCaller>>,
175    /// Open prompts by inbound task id.
176    pub(crate) prompts: Mutex<HashMap<String, OpenPrompt>>,
177    /// Members that did not answer, until when.
178    down: Mutex<HashMap<(String, String), Instant>>,
179    /// Requests for members this bot relays.
180    pub(crate) relay: crate::relay::Relay,
181    /// Tokens reported for inbound tasks, until they end.
182    pub(crate) usage: Mutex<HashMap<String, u64>>,
183    /// `[a2a]` agents and clients.
184    pub(crate) external: Option<crate::external::ExternalConfig>,
185    /// What the turn of each working inbound task is doing.
186    pub(crate) progress: Mutex<HashMap<String, String>>,
187}
188
189pub(crate) fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
190    m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
191}
192
193impl Inner {
194    pub(crate) fn new(
195        config: NodeConfig,
196        store: Arc<dyn ConversationStore>,
197        clock: Arc<dyn Clock>,
198    ) -> Result<Self, Error> {
199        let mut teams = BTreeMap::new();
200        for team in config.teams {
201            let manifest = match &team.source {
202                ManifestSource::File(path) => load_file(path, &team.id)?,
203                // Fetched before the node serves (see `Node::start`); empty until then.
204                ManifestSource::Url { .. } | ManifestSource::Builtin => Manifest::new(&team.id),
205            };
206            teams.insert(
207                team.id.clone(),
208                Team {
209                    config: team,
210                    manifest: RwLock::new(Arc::new(manifest)),
211                    forced: Mutex::new(None),
212                },
213            );
214        }
215        let external = config.external.map(|(me, external)| {
216            teams.insert(
217                crate::external::TEAM.to_string(),
218                Team {
219                    config: TeamConfig {
220                        id: crate::external::TEAM.into(),
221                        member: me.clone(),
222                        source: ManifestSource::Builtin,
223                        accept: !external.clients.is_empty(),
224                        max_depth: 1,
225                        max_concurrent: external.max_concurrent,
226                        task_timeout: external.task_timeout,
227                    },
228                    manifest: RwLock::new(Arc::new(external.manifest(&me))),
229                    forced: Mutex::new(None),
230                },
231            );
232            external
233        });
234        let _ = rustls::crypto::ring::default_provider().install_default();
235        Ok(Self {
236            key: config.key,
237            box_key: config.box_key,
238            sockets: config.sockets,
239            state_dir: config.state_dir,
240            teams,
241            store,
242            clock,
243            replays: Replays::default(),
244            http: reqwest::Client::builder()
245                .timeout(REQUEST_TIMEOUT)
246                .build()
247                .map_err(|e| Error::Key(format!("HTTP client: {e}")))?,
248            sink: OnceLock::new(),
249            local_server: std::sync::atomic::AtomicBool::new(false),
250            inbound: Mutex::default(),
251            callers: Mutex::default(),
252            prompts: Mutex::default(),
253            down: Mutex::default(),
254            relay: crate::relay::Relay::default(),
255            usage: Mutex::default(),
256            external,
257            progress: Mutex::default(),
258        })
259    }
260
261    pub(crate) fn now(&self) -> Timestamp {
262        self.clock.now()
263    }
264
265    /// Checks that this bot is a member of each file-based team under its own key.
266    pub(crate) fn check_membership(&self) -> Vec<String> {
267        let mut problems = Vec::new();
268        for team in self.teams.values() {
269            let manifest = team.manifest();
270            if (team.signed() && manifest.team.version == 0)
271                || team.config.source == ManifestSource::Builtin
272            {
273                continue;
274            }
275            match manifest.members.get(&team.config.member) {
276                None => problems.push(format!(
277                    "team `{}`: this bot is not a member named `{}`",
278                    team.config.id, team.config.member
279                )),
280                Some(m) => {
281                    if member_key(m).ok().as_ref() != Some(&self.key.public()) {
282                        problems.push(format!(
283                            "team `{}`: member `{}` has another key than this bot",
284                            team.config.id, team.config.member
285                        ));
286                    }
287                }
288            }
289        }
290        problems
291    }
292
293    /// Replaces a team's manifest with a newer one.
294    pub(crate) fn install(&self, team: &Team, manifest: Manifest) -> Result<bool, Error> {
295        manifest.check_fresh(self.now(), team.signed())?;
296        let current = team.manifest();
297        if current.team.version > 0 {
298            manifest.may_replace(&current)?;
299        }
300        if manifest == *current {
301            return Ok(false);
302        }
303        tracing::info!(team = %team.config.id, version = manifest.team.version, "team manifest installed");
304        *team
305            .manifest
306            .write()
307            .unwrap_or_else(std::sync::PoisonError::into_inner) = Arc::new(manifest);
308        Ok(true)
309    }
310
311    /// Reads a team's manifest again (from its file or URL).
312    pub(crate) async fn refresh(&self, team: &Team) -> Result<bool, Error> {
313        let manifest = match &team.config.source {
314            ManifestSource::Builtin => return Ok(false),
315            ManifestSource::File(path) => load_file(path, &team.config.id)?,
316            ManifestSource::Url { url, team_key } => {
317                let get = |u: String| async move {
318                    let reply = self
319                        .http
320                        .get(&u)
321                        .send()
322                        .await
323                        .and_then(reqwest::Response::error_for_status)
324                        .map_err(|e| Error::Manifest(format!("cannot fetch {u}: {e}")))?;
325                    reply
326                        .text()
327                        .await
328                        .map_err(|e| Error::Manifest(format!("cannot read {u}: {e}")))
329                };
330                let text = get(url.clone()).await?;
331                let sig = get(format!("{url}.sig")).await?;
332                let manifest = manifest::verify(&text, &sig, team_key)?;
333                if manifest.team.id != team.config.id {
334                    return Err(Error::Manifest(format!(
335                        "{url} is the manifest of team `{}`",
336                        manifest.team.id
337                    )));
338                }
339                manifest
340            }
341        };
342        self.install(team, manifest)
343    }
344
345    /// The public key of a member.
346    pub(crate) fn key_of(manifest: &Manifest, name: &str) -> Result<PublicKey, Error> {
347        manifest
348            .members
349            .get(name)
350            .ok_or_else(|| Error::Token(format!("`{name}` is not a member")))
351            .and_then(member_key)
352    }
353
354    /// The A2A version `member` of `team` speaks: 1.0 for okbots, as configured for agents.
355    pub(crate) fn version_of(&self, team: &Team, member: &str) -> a2a::Version {
356        if team.config.source == ManifestSource::Builtin {
357            return self
358                .external
359                .as_ref()
360                .and_then(|e| e.agent(member))
361                .map_or(a2a::Version::V10, |a| a.version);
362        }
363        a2a::Version::V10
364    }
365
366    pub(crate) fn is_down(&self, team: &str, member: &str) -> bool {
367        lock(&self.down)
368            .get(&(team.to_string(), member.to_string()))
369            .is_some_and(|until| *until > Instant::now())
370    }
371
372    fn mark(&self, team: &str, member: &str, up: bool) {
373        let key = (team.to_string(), member.to_string());
374        let mut down = lock(&self.down);
375        if up {
376            down.remove(&key);
377        } else {
378            down.insert(key, Instant::now() + DOWN_FOR);
379        }
380    }
381
382    /// Sends one JSON-RPC request to a member and returns its verified response.
383    pub(crate) async fn call(
384        &self,
385        team: &Team,
386        member: &str,
387        request: &a2a::Request,
388    ) -> Result<Value, PeerError> {
389        let result = self.exchange(team, member, request).await;
390        self.mark(
391            &team.config.id,
392            member,
393            !matches!(result, Err(PeerError::Unreachable(_))),
394        );
395        let response = result?;
396        if let Some(error) = response.get("error") {
397            let message = error
398                .get("message")
399                .and_then(Value::as_str)
400                .unwrap_or("refused")
401                .to_string();
402            return Err(PeerError::Refused(message));
403        }
404        response
405            .get("result")
406            .cloned()
407            .ok_or_else(|| PeerError::Refused("an empty response".into()))
408    }
409
410    async fn exchange(
411        &self,
412        team: &Team,
413        member: &str,
414        request: &a2a::Request,
415    ) -> Result<Value, PeerError> {
416        if team.config.source == ManifestSource::Builtin {
417            let agent = self
418                .external
419                .as_ref()
420                .and_then(|e| e.agent(member))
421                .ok_or_else(|| PeerError::NoRoute(member.to_string()))?;
422            return crate::external::call(&self.http, agent, request).await;
423        }
424        let manifest = team.manifest();
425        let target = manifest
426            .members
427            .get(member)
428            .ok_or_else(|| PeerError::NoRoute(member.to_string()))?;
429        let callee_key = member_key(target).map_err(|e| PeerError::Unreachable(e.to_string()))?;
430        let body = serde_json::to_vec(request).expect("requests serialize");
431        let claims = Claims::new(
432            &team.config.id,
433            &team.config.member,
434            member,
435            manifest.team.version,
436            &body,
437            self.now(),
438        );
439        let authorization = format!("{SCHEME}{}", token::sign(&self.key, &claims));
440        let (status, signature, reply) = match (&target.dir, &target.url, &target.relay) {
441            (Some(dir), _, _) => {
442                let socket = socket_path(&self.sockets, dir);
443                local_request(&socket, &team.config.id, &authorization, &body).await?
444            }
445            (_, Some(url), _) => self.http_request(url, &authorization, body).await?,
446            (_, _, Some(relay)) => {
447                Box::pin(crate::relay::send(
448                    self,
449                    team,
450                    relay,
451                    member,
452                    &authorization,
453                    &body,
454                ))
455                .await?
456            }
457            _ => return Err(PeerError::NoRoute(member.to_string())),
458        };
459        let Some(signature) = signature else {
460            return Err(PeerError::Refused(format!(
461                "`{member}` answered HTTP {status} without a signature"
462            )));
463        };
464        let claims = token::verify(&signature, &callee_key, &reply, self.now())
465            .map_err(|e| PeerError::Refused(format!("`{member}`'s answer: {e}")))?;
466        if claims.iss != member || claims.aud != team.config.member || claims.team != team.config.id
467        {
468            return Err(PeerError::Refused(format!(
469                "`{member}`'s answer is signed for someone else"
470            )));
471        }
472        serde_json::from_slice(&reply)
473            .map_err(|_| PeerError::Refused(format!("`{member}` sent invalid JSON")))
474    }
475
476    async fn http_request(
477        &self,
478        url: &str,
479        authorization: &str,
480        body: Vec<u8>,
481    ) -> Result<(u16, Option<String>, Vec<u8>), PeerError> {
482        let reply = self
483            .http
484            .post(url)
485            .header("authorization", authorization)
486            .header("content-type", "application/json")
487            .header(
488                crate::a2a::VERSION_HEADER,
489                crate::a2a::Version::V10.header(),
490            )
491            .header(crate::a2a::EXTENSIONS_HEADER, crate::a2a::EXTENSION)
492            .body(body)
493            .send()
494            .await
495            .map_err(|e| PeerError::Unreachable(without_url(e)))?;
496        let status = reply.status().as_u16();
497        let signature = reply
498            .headers()
499            .get(SIGNATURE_HEADER)
500            .and_then(|v| v.to_str().ok())
501            .map(str::to_string);
502        let bytes = reply
503            .bytes()
504            .await
505            .map_err(|e| PeerError::Unreachable(without_url(e)))?;
506        if bytes.len() > MAX_BODY {
507            return Err(PeerError::Refused("the answer is too large".into()));
508        }
509        Ok((status, signature, bytes.to_vec()))
510    }
511
512    /// Signs a response body for `caller`.
513    pub(crate) fn sign_response(&self, team: &Team, caller: &str, body: &[u8]) -> String {
514        let claims = Claims::new(
515            &team.config.id,
516            &team.config.member,
517            caller,
518            team.manifest().team.version,
519            body,
520            self.now(),
521        );
522        token::sign(&self.key, &claims)
523    }
524}
525
526fn without_url(error: reqwest::Error) -> String {
527    if error.is_timeout() {
528        return "timed out".into();
529    }
530    error.without_url().to_string()
531}
532
533/// A member's X25519 key: from the manifest, or from its bot directory.
534pub(crate) fn member_box_key(member: &Member) -> Result<crate::seal::BoxPublic, Error> {
535    match (&member.box_key, &member.dir) {
536        (Some(key), _) => Ok(*key),
537        (None, Some(dir)) => crate::seal::bot_box_key(dir),
538        (None, None) => Err(Error::Key(
539            "the member has no `box_key` (`okbot peer id` prints it)".into(),
540        )),
541    }
542}
543
544/// A member's key: from the manifest, or from its bot directory.
545pub(crate) fn member_key(member: &Member) -> Result<PublicKey, Error> {
546    match (&member.key, &member.dir) {
547        (Some(key), _) => Ok(key.clone()),
548        (None, Some(dir)) => bot_public_key(dir),
549        (None, None) => Err(Error::Key("the member has no key".into())),
550    }
551}
552
553fn load_file(path: &Path, team: &str) -> Result<Manifest, Error> {
554    let text =
555        std::fs::read_to_string(path).map_err(|e| Error::Io(path.display().to_string(), e))?;
556    let manifest = Manifest::parse(&text)?;
557    if manifest.team.id != team {
558        return Err(Error::Manifest(format!(
559            "{} is the manifest of team `{}`, not `{team}`",
560            path.display(),
561            manifest.team.id
562        )));
563    }
564    Ok(manifest)
565}
566
567/// One request over a Unix socket: a JSON line each way.
568#[derive(Debug, Serialize, Deserialize)]
569pub(crate) struct LocalRequest {
570    pub(crate) team: String,
571    pub(crate) authorization: String,
572    pub(crate) body: String,
573}
574
575/// The answer to a [`LocalRequest`].
576#[derive(Debug, Serialize, Deserialize)]
577pub(crate) struct LocalResponse {
578    pub(crate) status: u16,
579    #[serde(default)]
580    pub(crate) signature: Option<String>,
581    pub(crate) body: String,
582}
583
584async fn local_request(
585    socket: &Path,
586    team: &str,
587    authorization: &str,
588    body: &[u8],
589) -> Result<(u16, Option<String>, Vec<u8>), PeerError> {
590    let unreachable = |e: std::io::Error| {
591        PeerError::Unreachable(format!("{}: {e} (is that bot running?)", socket.display()))
592    };
593    let exchange = async {
594        let mut stream = tokio::net::UnixStream::connect(socket)
595            .await
596            .map_err(unreachable)?;
597        let request = LocalRequest {
598            team: team.to_string(),
599            authorization: authorization.to_string(),
600            body: String::from_utf8_lossy(body).into_owned(),
601        };
602        let mut line = serde_json::to_vec(&request).expect("requests serialize");
603        line.push(b'\n');
604        stream.write_all(&line).await.map_err(unreachable)?;
605        let mut reader = BufReader::new(stream).take(MAX_BODY as u64 * 2);
606        let mut answer = String::new();
607        reader.read_line(&mut answer).await.map_err(unreachable)?;
608        let response: LocalResponse = serde_json::from_str(&answer)
609            .map_err(|_| PeerError::Refused("an invalid local answer".into()))?;
610        Ok((
611            response.status,
612            response.signature,
613            response.body.into_bytes(),
614        ))
615    };
616    tokio::time::timeout(REQUEST_TIMEOUT, exchange)
617        .await
618        .map_err(|_| PeerError::Unreachable(format!("{}: timed out", socket.display())))?
619}