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