Skip to main content

brainos_terminal/
lib.rs

1//! # Brain Terminal Bridge
2//!
3//! gRPC PTY motor cortex — Brain's adapter for spawning and driving
4//! pseudo-terminal sessions across platforms (Unix `openpty`/`forkpty`,
5//! Windows ConPTY via `portable-pty`). Each session exposes split RPCs
6//! (`Open` / `Close` / `Attach` / `Send` / `Resize` / `Signal`) plus a bidi
7//! `Interact` stream for latency-critical use.
8//!
9//! Default port: **19793**.
10
11use std::{collections::HashMap, sync::Arc};
12
13use identity::IdentityStore;
14use observe::Observer;
15use tokio::sync::Mutex;
16
17pub mod error;
18pub mod graph;
19pub mod server;
20pub(crate) mod session;
21pub mod types;
22
23pub use error::TerminalError;
24pub use graph::{MirrorError, TerminalGraphHandles, TerminalGraphSink};
25pub use server::TerminalSvc;
26pub use types::{SessionId, SessionMeta, TermSize};
27
28use session::Session;
29
30/// Default gRPC bind port for the Terminal Bridge.
31pub const DEFAULT_PORT: u16 = 19793;
32
33/// Generated protobuf types and tonic stubs for `brain.terminal.v1`.
34pub mod pb {
35    tonic::include_proto!("brain.terminal.v1");
36}
37
38/// Live PTY session registry. Holds `Arc<Session>` internally; the
39/// implementation-detail `Session` shape (master/child handles, channels)
40/// is kept crate-private. External callers can only read [`SessionMeta`].
41#[derive(Default)]
42pub struct SessionRegistry {
43    inner: Mutex<HashMap<SessionId, Arc<Session>>>,
44}
45
46impl SessionRegistry {
47    pub fn new() -> Self {
48        Self {
49            inner: Mutex::new(HashMap::new()),
50        }
51    }
52
53    pub async fn len(&self) -> usize {
54        self.inner.lock().await.len()
55    }
56
57    pub async fn is_empty(&self) -> bool {
58        self.inner.lock().await.is_empty()
59    }
60
61    pub async fn meta(&self, id: &SessionId) -> Option<SessionMeta> {
62        self.inner.lock().await.get(id).map(|s| s.meta.clone())
63    }
64
65    pub async fn list(&self) -> Vec<SessionMeta> {
66        self.inner
67            .lock()
68            .await
69            .values()
70            .map(|s| s.meta.clone())
71            .collect()
72    }
73
74    pub(crate) async fn get(&self, id: &SessionId) -> Option<Arc<Session>> {
75        self.inner.lock().await.get(id).cloned()
76    }
77
78    pub(crate) async fn insert(&self, session: Arc<Session>) {
79        let id = session.meta.session_id.clone();
80        self.inner.lock().await.insert(id, session);
81    }
82
83    pub(crate) async fn remove(&self, id: &SessionId) -> Option<Arc<Session>> {
84        self.inner.lock().await.remove(id)
85    }
86}
87
88/// Authentication wiring for the Terminal Bridge. When attached, every RPC
89/// resolves a [`identity::Principal`] from request metadata (api-key) and
90/// the identity store gates the action. When absent, the bridge runs
91/// without authentication — sessions still get a `None` principal in
92/// [`SessionMeta`] and audit events, and no gate is enforced.
93#[derive(Clone)]
94pub struct TerminalAuth {
95    pub identity: Arc<dyn IdentityStore>,
96    pub api_keys: Arc<Vec<brain::ApiKeyConfig>>,
97}
98
99impl TerminalAuth {
100    pub fn new(identity: Arc<dyn IdentityStore>, api_keys: Vec<brain::ApiKeyConfig>) -> Self {
101        Self {
102            identity,
103            api_keys: Arc::new(api_keys),
104        }
105    }
106}
107
108/// Terminal Bridge service handle.
109///
110/// Holds the [`SessionRegistry`] plus optional [`TerminalAuth`],
111/// [`Observer`], and [`TerminalGraphSink`] wiring. Cheap to clone
112/// (everything inside is `Arc`-ed).
113#[derive(Clone)]
114pub struct TerminalBridge {
115    sessions: Arc<SessionRegistry>,
116    auth: Option<TerminalAuth>,
117    observer: Option<Arc<dyn Observer>>,
118    graph_sink: Option<Arc<dyn TerminalGraphSink>>,
119}
120
121impl TerminalBridge {
122    pub fn new() -> Self {
123        Self {
124            sessions: Arc::new(SessionRegistry::new()),
125            auth: None,
126            observer: None,
127            graph_sink: None,
128        }
129    }
130
131    pub fn sessions(&self) -> &Arc<SessionRegistry> {
132        &self.sessions
133    }
134
135    /// Attach identity/api-key wiring. Once set, every RPC requires a
136    /// resolvable api-key in metadata and a passing
137    /// [`IdentityStore::check`] for the corresponding `terminal.*` verb.
138    pub fn with_auth(mut self, auth: TerminalAuth) -> Self {
139        self.auth = Some(auth);
140        self
141    }
142
143    /// Attach an [`Observer`] so the bridge can publish
144    /// `BrainEvent::TerminalSessionOpened` / `TerminalSessionClosed` on
145    /// every session lifecycle transition.
146    pub fn with_observer(mut self, observer: Arc<dyn Observer>) -> Self {
147        self.observer = Some(observer);
148        self
149    }
150
151    /// Attach a [`TerminalGraphSink`] so every session lifecycle also
152    /// lands `tool_call → terminal_event(open) → terminal_event(close)`
153    /// nodes/edges in the episodic graph.
154    pub fn with_graph_sink(mut self, sink: Arc<dyn TerminalGraphSink>) -> Self {
155        self.graph_sink = Some(sink);
156        self
157    }
158
159    /// Build the tonic-generated server wrapping a [`TerminalSvc`] backed by
160    /// this bridge's registry. Plug into a `tonic::transport::Server::builder`.
161    pub fn into_server(self) -> pb::terminal_session_server::TerminalSessionServer<TerminalSvc> {
162        pb::terminal_session_server::TerminalSessionServer::new(TerminalSvc::new(
163            self.sessions,
164            self.auth,
165            self.observer,
166            self.graph_sink,
167        ))
168    }
169
170    /// Construct a [`TerminalSvc`] sharing this bridge's wiring, for tests
171    /// or callers that want to drive the trait directly without spinning up
172    /// a tonic transport.
173    pub fn svc(&self) -> TerminalSvc {
174        TerminalSvc::new(
175            self.sessions.clone(),
176            self.auth.clone(),
177            self.observer.clone(),
178            self.graph_sink.clone(),
179        )
180    }
181}
182
183impl Default for TerminalBridge {
184    fn default() -> Self {
185        Self::new()
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[tokio::test]
194    async fn registry_starts_empty() {
195        let bridge = TerminalBridge::new();
196        assert!(bridge.sessions().is_empty().await);
197        assert_eq!(bridge.sessions().len().await, 0);
198    }
199
200    #[test]
201    fn default_port_matches_spec() {
202        assert_eq!(DEFAULT_PORT, 19793);
203    }
204}