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