1use 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#[derive(Clone, Default)]
135pub struct TerminalSessionCapability {
136 manager: Arc<SessionManager>,
137}
138
139impl TerminalSessionCapability {
140 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 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}