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