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