Skip to main content

harn_hostlib/
terminal_session.rs

1//! Default-off typed terminal-session host capability.
2//!
3//! The PTY and VT implementation lives in `harn-terminal`; this module owns
4//! Harn boundary policy, bounded session ids, schema-shaped requests, secret
5//! environment custody, and VM value conversion.
6
7use std::collections::{BTreeMap, BTreeSet};
8use std::sync::{Arc, Mutex};
9use std::time::Duration;
10
11use harn_terminal::{
12    CellRegion, InputEvent, ProcessStatus, SessionOptions, TerminalError, TerminalSession,
13    DEFAULT_RAW_CAPACITY,
14};
15use harn_vm::orchestration::current_execution_policy;
16use harn_vm::VmValue;
17use serde::de::DeserializeOwned;
18use serde::{Deserialize, Serialize};
19
20use crate::error::HostlibError;
21use crate::json::vm_dict_to_json;
22use crate::process::handle::is_sensitive_env_name;
23use crate::registry::{BuiltinRegistry, HostlibCapability, RegisteredBuiltin, SyncHandler};
24use crate::tools::args::{dict_arg, resolve_host_path};
25
26const MODULE: &str = "terminal_session";
27const START: &str = "hostlib_terminal_session_start";
28const SEND_KEYS: &str = "hostlib_terminal_session_send_keys";
29const CAPTURE: &str = "hostlib_terminal_session_capture";
30const RESIZE: &str = "hostlib_terminal_session_resize";
31const WAIT_IDLE: &str = "hostlib_terminal_session_wait_idle";
32const END: &str = "hostlib_terminal_session_end";
33const MAX_SESSIONS: usize = 8;
34const MAX_ENVIRONMENT_ENTRIES: usize = 128;
35const MAX_WAIT_MS: u64 = 30_000;
36
37#[derive(Default)]
38struct SessionManager {
39    sessions: Mutex<BTreeMap<String, Arc<TerminalSession>>>,
40}
41
42impl SessionManager {
43    fn start(&self, request: StartRequest) -> Result<(String, Arc<TerminalSession>), HostlibError> {
44        ensure_unrestricted(START)?;
45        validate_start_request(&request)?;
46        let cwd = request.cwd.map(resolve_host_path);
47        let workspace_roots = current_execution_policy()
48            .map(|policy| policy.workspace_roots)
49            .unwrap_or_else(|| {
50                cwd.as_ref()
51                    .map(|path| vec![path.display().to_string()])
52                    .unwrap_or_default()
53            });
54        if let Some(reason) = harn_vm::orchestration::universal_catastrophic_reason(
55            &request.argv[0],
56            &request.argv[1..],
57            &workspace_roots,
58        ) {
59            return Err(HostlibError::CatastrophicFloor {
60                builtin: START,
61                message: reason,
62            });
63        }
64
65        if let Some(path) = cwd.as_ref() {
66            if !path.is_dir() {
67                return Err(HostlibError::InvalidParameter {
68                    builtin: START,
69                    param: "cwd",
70                    message: format!("working directory does not exist: {}", path.display()),
71                });
72            }
73        }
74
75        let mut sessions = self.sessions.lock().map_err(|_| poisoned(START))?;
76        if sessions.len() >= MAX_SESSIONS {
77            return Err(HostlibError::Backend {
78                builtin: START,
79                message: format!("terminal session limit reached ({MAX_SESSIONS})"),
80            });
81        }
82
83        let mut env = request.env;
84        env.entry("TERM".to_string())
85            .or_insert_with(|| "xterm-256color".to_string());
86        let env_remove = std::env::vars_os()
87            .filter_map(|(key, _)| key.into_string().ok())
88            .filter(|name| is_sensitive_env_name(name))
89            .collect();
90        let terminal = Arc::new(
91            TerminalSession::spawn(SessionOptions {
92                argv: request.argv,
93                rows: request.rows,
94                cols: request.columns,
95                cwd,
96                env,
97                env_remove,
98                raw_capacity: DEFAULT_RAW_CAPACITY,
99            })
100            .map_err(|error| terminal_error(START, "request", error))?,
101        );
102        let session_id = format!("terminal-{}", uuid::Uuid::now_v7());
103        sessions.insert(session_id.clone(), Arc::clone(&terminal));
104        Ok((session_id, terminal))
105    }
106
107    fn get(
108        &self,
109        builtin: &'static str,
110        session_id: &str,
111    ) -> Result<Arc<TerminalSession>, HostlibError> {
112        self.sessions
113            .lock()
114            .map_err(|_| poisoned(builtin))?
115            .get(session_id)
116            .cloned()
117            .ok_or_else(|| HostlibError::InvalidParameter {
118                builtin,
119                param: "session_id",
120                message: format!("unknown terminal session `{session_id}`"),
121            })
122    }
123
124    fn remove(&self, session_id: &str) -> Result<Option<Arc<TerminalSession>>, HostlibError> {
125        Ok(self
126            .sessions
127            .lock()
128            .map_err(|_| poisoned(END))?
129            .remove(session_id))
130    }
131}
132
133/// Capability handle for typed terminal sessions.
134#[derive(Clone, Default)]
135pub struct TerminalSessionCapability {
136    manager: Arc<SessionManager>,
137}
138
139impl TerminalSessionCapability {
140    /// Construct an isolated session manager.
141    pub fn new() -> Self {
142        Self::default()
143    }
144}
145
146impl HostlibCapability for TerminalSessionCapability {
147    fn module_name(&self) -> &'static str {
148        MODULE
149    }
150
151    fn register_builtins(&self, registry: &mut BuiltinRegistry) {
152        register(registry, START, "start", self.manager.clone(), start);
153        register(
154            registry,
155            SEND_KEYS,
156            "send_keys",
157            self.manager.clone(),
158            send_keys,
159        );
160        register(registry, CAPTURE, "capture", self.manager.clone(), capture);
161        register(registry, RESIZE, "resize", self.manager.clone(), resize);
162        register(
163            registry,
164            WAIT_IDLE,
165            "wait_idle",
166            self.manager.clone(),
167            wait_idle,
168        );
169        register(registry, END, "end", self.manager.clone(), end);
170    }
171}
172
173fn register(
174    registry: &mut BuiltinRegistry,
175    name: &'static str,
176    method: &'static str,
177    manager: Arc<SessionManager>,
178    runner: fn(&SessionManager, &[VmValue]) -> Result<VmValue, HostlibError>,
179) {
180    let handler: SyncHandler = Arc::new(move |args| runner(&manager, args));
181    registry.register(RegisteredBuiltin {
182        name,
183        module: MODULE,
184        method,
185        handler,
186    });
187}
188
189#[derive(Deserialize)]
190#[serde(deny_unknown_fields)]
191struct StartRequest {
192    argv: Vec<String>,
193    #[serde(default = "default_rows")]
194    rows: u16,
195    #[serde(default = "default_columns")]
196    columns: u16,
197    cwd: Option<String>,
198    #[serde(default)]
199    env: BTreeMap<String, String>,
200}
201
202#[derive(Deserialize)]
203#[serde(deny_unknown_fields)]
204struct SendRequest {
205    session_id: String,
206    events: Vec<InputEvent>,
207}
208
209#[derive(Deserialize)]
210#[serde(deny_unknown_fields)]
211struct CaptureRequest {
212    session_id: String,
213    region: Option<CellRegion>,
214}
215
216#[derive(Deserialize)]
217#[serde(deny_unknown_fields)]
218struct ResizeRequest {
219    session_id: String,
220    rows: u16,
221    columns: u16,
222}
223
224#[derive(Deserialize)]
225#[serde(deny_unknown_fields)]
226struct WaitIdleRequest {
227    session_id: String,
228    after_revision: Option<u64>,
229    #[serde(default = "default_quiet_ms")]
230    quiet_ms: u64,
231    #[serde(default = "default_timeout_ms")]
232    timeout_ms: u64,
233}
234
235#[derive(Deserialize)]
236#[serde(deny_unknown_fields)]
237struct EndRequest {
238    session_id: String,
239    #[serde(default = "default_end_timeout_ms")]
240    timeout_ms: u64,
241}
242
243fn start(manager: &SessionManager, args: &[VmValue]) -> Result<VmValue, HostlibError> {
244    let request: StartRequest = request(START, args)?;
245    let rows = request.rows;
246    let columns = request.columns;
247    let (session_id, _) = manager.start(request)?;
248    Ok(VmValue::dict([
249        ("session_id", VmValue::string(session_id)),
250        ("rows", VmValue::Int(i64::from(rows))),
251        ("columns", VmValue::Int(i64::from(columns))),
252    ]))
253}
254
255fn send_keys(manager: &SessionManager, args: &[VmValue]) -> Result<VmValue, HostlibError> {
256    let request: SendRequest = request(SEND_KEYS, args)?;
257    let session = manager.get(SEND_KEYS, &request.session_id)?;
258    let bytes_sent = session
259        .send(&request.events)
260        .map_err(|error| terminal_error(SEND_KEYS, "events", error))?;
261    let revision = session
262        .capture(None)
263        .map_err(|error| terminal_error(SEND_KEYS, "session_id", error))?
264        .revision;
265    Ok(VmValue::dict([
266        ("session_id", VmValue::string(request.session_id)),
267        (
268            "bytes_sent",
269            VmValue::Int(i64::try_from(bytes_sent).unwrap_or(i64::MAX)),
270        ),
271        (
272            "revision",
273            VmValue::Int(i64::try_from(revision).unwrap_or(i64::MAX)),
274        ),
275    ]))
276}
277
278fn capture(manager: &SessionManager, args: &[VmValue]) -> Result<VmValue, HostlibError> {
279    let request: CaptureRequest = request(CAPTURE, args)?;
280    let session = manager.get(CAPTURE, &request.session_id)?;
281    let capture = session
282        .capture(request.region)
283        .map_err(|error| terminal_error(CAPTURE, "region", error))?;
284    encode_response(CAPTURE, request.session_id, &capture)
285}
286
287fn resize(manager: &SessionManager, args: &[VmValue]) -> Result<VmValue, HostlibError> {
288    let request: ResizeRequest = request(RESIZE, args)?;
289    let session = manager.get(RESIZE, &request.session_id)?;
290    session
291        .resize(request.rows, request.columns)
292        .map_err(|error| terminal_error(RESIZE, "rows", error))?;
293    let revision = session
294        .capture(None)
295        .map_err(|error| terminal_error(RESIZE, "session_id", error))?
296        .revision;
297    Ok(VmValue::dict([
298        ("session_id", VmValue::string(request.session_id)),
299        ("rows", VmValue::Int(i64::from(request.rows))),
300        ("columns", VmValue::Int(i64::from(request.columns))),
301        (
302            "revision",
303            VmValue::Int(i64::try_from(revision).unwrap_or(i64::MAX)),
304        ),
305    ]))
306}
307
308fn wait_idle(manager: &SessionManager, args: &[VmValue]) -> Result<VmValue, HostlibError> {
309    let request: WaitIdleRequest = request(WAIT_IDLE, args)?;
310    validate_wait(request.quiet_ms, request.timeout_ms)?;
311    let session = manager.get(WAIT_IDLE, &request.session_id)?;
312    let quiet = Duration::from_millis(request.quiet_ms);
313    let timeout = Duration::from_millis(request.timeout_ms);
314    let result = match request.after_revision {
315        Some(revision) => session.wait_idle_after(revision, quiet, timeout),
316        None => session.wait_idle(quiet, timeout),
317    }
318    .map_err(|error| terminal_error(WAIT_IDLE, "timeout_ms", error))?;
319    encode_response(WAIT_IDLE, request.session_id, &result)
320}
321
322fn end(manager: &SessionManager, args: &[VmValue]) -> Result<VmValue, HostlibError> {
323    let request: EndRequest = request(END, args)?;
324    if request.timeout_ms == 0 || request.timeout_ms > MAX_WAIT_MS {
325        return Err(HostlibError::InvalidParameter {
326            builtin: END,
327            param: "timeout_ms",
328            message: format!("must be between 1 and {MAX_WAIT_MS}"),
329        });
330    }
331    let session =
332        manager
333            .remove(&request.session_id)?
334            .ok_or_else(|| HostlibError::InvalidParameter {
335                builtin: END,
336                param: "session_id",
337                message: format!("unknown terminal session `{}`", request.session_id),
338            })?;
339    let status = session
340        .end(Duration::from_millis(request.timeout_ms))
341        .map_err(|error| terminal_error(END, "timeout_ms", error))?;
342    status_response(request.session_id, status)
343}
344
345fn request<T: DeserializeOwned>(
346    builtin: &'static str,
347    args: &[VmValue],
348) -> Result<T, HostlibError> {
349    let dict = dict_arg(builtin, args)?;
350    serde_json::from_value(vm_dict_to_json(&dict)).map_err(|error| HostlibError::InvalidParameter {
351        builtin,
352        param: "request",
353        message: error.to_string(),
354    })
355}
356
357fn status_response(session_id: String, status: ProcessStatus) -> Result<VmValue, HostlibError> {
358    encode_response(END, session_id, &status)
359}
360
361fn encode_response(
362    builtin: &'static str,
363    session_id: String,
364    value: &impl Serialize,
365) -> Result<VmValue, HostlibError> {
366    let mut json = serde_json::to_value(value).map_err(|error| HostlibError::Backend {
367        builtin,
368        message: format!("failed to encode terminal response: {error}"),
369    })?;
370    let object = json.as_object_mut().ok_or_else(|| HostlibError::Backend {
371        builtin,
372        message: "terminal response did not serialize as an object".to_string(),
373    })?;
374    object.insert(
375        "session_id".to_string(),
376        serde_json::Value::String(session_id),
377    );
378    Ok(harn_vm::json_to_vm_value(&json))
379}
380
381fn validate_start_request(request: &StartRequest) -> Result<(), HostlibError> {
382    if request.argv.is_empty() || request.argv[0].is_empty() {
383        return Err(HostlibError::InvalidParameter {
384            builtin: START,
385            param: "argv",
386            message: "must start with a non-empty executable".to_string(),
387        });
388    }
389    if request.env.len() > MAX_ENVIRONMENT_ENTRIES {
390        return Err(HostlibError::InvalidParameter {
391            builtin: START,
392            param: "env",
393            message: format!("must contain at most {MAX_ENVIRONMENT_ENTRIES} entries"),
394        });
395    }
396    let mut normalized = BTreeSet::new();
397    for key in request.env.keys() {
398        if key.is_empty() || key.contains('=') || key.contains('\0') {
399            return Err(HostlibError::InvalidParameter {
400                builtin: START,
401                param: "env",
402                message: format!("invalid environment key `{key}`"),
403            });
404        }
405        if is_sensitive_env_name(key) {
406            return Err(HostlibError::InvalidParameter {
407                builtin: START,
408                param: "env",
409                message: format!("secret-bearing environment key `{key}` is not allowed"),
410            });
411        }
412        let folded = key.to_ascii_uppercase();
413        if !normalized.insert(folded) {
414            return Err(HostlibError::InvalidParameter {
415                builtin: START,
416                param: "env",
417                message: format!("environment key `{key}` is duplicated case-insensitively"),
418            });
419        }
420    }
421    if let Some((key, _)) = request.env.iter().find(|(_, value)| value.contains('\0')) {
422        return Err(HostlibError::InvalidParameter {
423            builtin: START,
424            param: "env",
425            message: format!("environment value for `{key}` contains a NUL byte"),
426        });
427    }
428    Ok(())
429}
430
431fn validate_wait(quiet_ms: u64, timeout_ms: u64) -> Result<(), HostlibError> {
432    if quiet_ms > timeout_ms {
433        return Err(HostlibError::InvalidParameter {
434            builtin: WAIT_IDLE,
435            param: "quiet_ms",
436            message: "must not exceed timeout_ms".to_string(),
437        });
438    }
439    if timeout_ms == 0 || timeout_ms > MAX_WAIT_MS {
440        return Err(HostlibError::InvalidParameter {
441            builtin: WAIT_IDLE,
442            param: "timeout_ms",
443            message: format!("must be between 1 and {MAX_WAIT_MS}"),
444        });
445    }
446    Ok(())
447}
448
449fn ensure_unrestricted(builtin: &'static str) -> Result<(), HostlibError> {
450    let Some(policy) = current_execution_policy() else {
451        return Ok(());
452    };
453    // A PTY spawn cannot carry Harn's path scoping into the child, so any
454    // profile that enforces it must fail closed rather than silently drop it.
455    if !policy.sandbox_profile.enforces_path_scope() {
456        return Ok(());
457    }
458    let profile = policy.sandbox_profile.as_str().to_string();
459    Err(HostlibError::SandboxUnsupported {
460        builtin,
461        profile: profile.clone(),
462        message: format!(
463            "hostlib: {builtin}: terminal PTY spawning cannot preserve the active `{profile}` \
464             sandbox; use an explicitly unrestricted trusted harness"
465        ),
466    })
467}
468
469fn terminal_error(
470    builtin: &'static str,
471    param: &'static str,
472    error: TerminalError,
473) -> HostlibError {
474    match error {
475        TerminalError::InvalidArgument(message) => HostlibError::InvalidParameter {
476            builtin,
477            param,
478            message,
479        },
480        other => HostlibError::Backend {
481            builtin,
482            message: other.to_string(),
483        },
484    }
485}
486
487fn poisoned(builtin: &'static str) -> HostlibError {
488    HostlibError::Backend {
489        builtin,
490        message: "terminal session manager was poisoned".to_string(),
491    }
492}
493
494const fn default_rows() -> u16 {
495    24
496}
497
498const fn default_columns() -> u16 {
499    80
500}
501
502const fn default_quiet_ms() -> u64 {
503    50
504}
505
506const fn default_timeout_ms() -> u64 {
507    10_000
508}
509
510const fn default_end_timeout_ms() -> u64 {
511    2_000
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517
518    struct PolicyGuard;
519
520    impl Drop for PolicyGuard {
521        fn drop(&mut self) {
522            harn_vm::orchestration::pop_execution_policy();
523        }
524    }
525
526    #[test]
527    fn rejects_secret_environment_keys() {
528        let request = StartRequest {
529            argv: vec!["sh".into()],
530            rows: 24,
531            columns: 80,
532            cwd: None,
533            env: BTreeMap::from([("OPENAI_API_KEY".into(), "secret".into())]),
534        };
535        let error = validate_start_request(&request).expect_err("secret must be rejected");
536        assert!(error.to_string().contains("secret-bearing"));
537    }
538
539    #[test]
540    fn idle_timeout_must_cover_quiet_window() {
541        let error = validate_wait(100, 50).expect_err("invalid wait");
542        assert!(error.to_string().contains("must not exceed"));
543    }
544
545    #[test]
546    fn restricted_sandbox_fails_closed_with_typed_error() {
547        harn_vm::orchestration::push_execution_policy(
548            harn_vm::orchestration::CapabilityPolicy::default(),
549        );
550        let _guard = PolicyGuard;
551        let error = ensure_unrestricted(START).expect_err("worktree sandbox must be rejected");
552        assert!(matches!(
553            error,
554            HostlibError::SandboxUnsupported { ref profile, .. } if profile == "worktree"
555        ));
556        let vm_error = harn_vm::VmError::from(error);
557        let harn_vm::VmError::Thrown(VmValue::Dict(payload)) = vm_error else {
558            panic!("expected structured thrown error");
559        };
560        assert_eq!(
561            payload.get("kind").map(VmValue::display),
562            Some("sandbox_unsupported".to_string())
563        );
564        assert_eq!(
565            payload.get("profile").map(VmValue::display),
566            Some("worktree".to_string())
567        );
568    }
569
570    #[test]
571    fn catastrophic_floor_runs_before_pty_allocation() {
572        let request = StartRequest {
573            argv: vec!["rm".into(), "-rf".into(), "/".into()],
574            rows: 24,
575            columns: 80,
576            cwd: None,
577            env: BTreeMap::new(),
578        };
579        let error = match SessionManager::default().start(request) {
580            Err(error) => error,
581            Ok(_) => panic!("catastrophic command must not spawn"),
582        };
583        assert!(matches!(error, HostlibError::CatastrophicFloor { .. }));
584    }
585}