| 1 | use std::collections::BTreeSet; |
| 2 | use std::path::PathBuf; |
| 3 | |
| 4 | use serde::{Deserialize, Serialize}; |
| 5 | |
| 6 | /// Process-continuity policy selected by a runtime profile. |
| 7 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 8 | #[serde(rename_all = "snake_case")] |
| 9 | pub enum TerminalProcessPolicy { |
| 10 | /// Every command starts from an explicit cwd/environment. |
| 11 | Isolated, |
| 12 | /// Commands share cwd/environment and may keep one live process. |
| 13 | Stateful, |
| 14 | /// Isolated by default; stateful sessions are explicitly requested. |
| 15 | Hybrid, |
| 16 | } |
| 17 | |
| 18 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 19 | pub struct TerminalSessionIdentity { |
| 20 | pub session_id: String, |
| 21 | pub host_fingerprint: String, |
| 22 | pub cwd: PathBuf, |
| 23 | /// Environment names only. Values are deliberately excluded from durable |
| 24 | /// metadata so secrets cannot leak into manifests. |
| 25 | pub environment_keys: BTreeSet<String>, |
| 26 | pub shell: String, |
| 27 | } |
| 28 | |
| 29 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 30 | #[serde(rename_all = "snake_case")] |
| 31 | pub enum TerminalSessionRecovery { |
| 32 | Reattached, |
| 33 | RestartRequired, |
| 34 | Stale, |
| 35 | Unsupported, |
| 36 | } |
| 37 | |
| 38 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 39 | pub struct TerminalBackendCapabilities { |
| 40 | pub interactive: bool, |
| 41 | pub background: bool, |
| 42 | pub tty: bool, |
| 43 | pub stateful: bool, |
| 44 | pub restart_reattach: bool, |
| 45 | } |
| 46 | |
| 47 | impl TerminalBackendCapabilities { |
| 48 | pub const LOCAL: Self = Self { |
| 49 | interactive: true, |
| 50 | background: true, |
| 51 | tty: true, |
| 52 | stateful: true, |
| 53 | restart_reattach: false, |
| 54 | }; |
| 55 | } |
| 56 | |
| 57 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 58 | pub struct TerminalRequest { |
| 59 | pub policy: TerminalProcessPolicy, |
| 60 | pub interactive: bool, |
| 61 | pub background: bool, |
| 62 | pub tty: bool, |
| 63 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 64 | pub session_id: Option<String>, |
| 65 | } |
| 66 | |
| 67 | /// Fail before spawning when a backend cannot honor the declared terminal |
| 68 | /// contract. This avoids silently degrading interactive/background work. |
| 69 | pub fn validate_terminal_request( |
| 70 | request: &TerminalRequest, |
| 71 | backend: TerminalBackendCapabilities, |
| 72 | ) -> Result<(), String> { |
| 73 | if request.interactive && !backend.interactive { |
| 74 | return Err("terminal backend does not support interactive input".to_string()); |
| 75 | } |
| 76 | if request.background && !backend.background { |
| 77 | return Err("terminal backend does not support background processes".to_string()); |
| 78 | } |
| 79 | if request.tty && !backend.tty { |
| 80 | return Err("terminal backend does not support a TTY".to_string()); |
| 81 | } |
| 82 | if matches!(request.policy, TerminalProcessPolicy::Stateful) && !backend.stateful { |
| 83 | return Err("terminal backend does not support stateful sessions".to_string()); |
| 84 | } |
| 85 | if request.session_id.is_some() && matches!(request.policy, TerminalProcessPolicy::Isolated) { |
| 86 | return Err("isolated terminal requests cannot name a shared session".to_string()); |
| 87 | } |
| 88 | Ok(()) |
| 89 | } |
| 90 | |
| 91 | impl TerminalSessionIdentity { |
| 92 | /// A persisted session is safe to reattach only when both the logical ID |
| 93 | /// and host fingerprint match. PIDs alone are intentionally insufficient. |
| 94 | #[must_use] |
| 95 | pub fn recovery_on_host( |
| 96 | &self, |
| 97 | session_id: &str, |
| 98 | host_fingerprint: &str, |
| 99 | backend: TerminalBackendCapabilities, |
| 100 | ) -> TerminalSessionRecovery { |
| 101 | if !backend.stateful { |
| 102 | return TerminalSessionRecovery::Unsupported; |
| 103 | } |
| 104 | if self.session_id != session_id || self.host_fingerprint != host_fingerprint { |
| 105 | return TerminalSessionRecovery::Stale; |
| 106 | } |
| 107 | if backend.restart_reattach { |
| 108 | TerminalSessionRecovery::Reattached |
| 109 | } else { |
| 110 | TerminalSessionRecovery::RestartRequired |
| 111 | } |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | #[cfg(test)] |
| 116 | mod tests { |
| 117 | use super::*; |
| 118 | |
| 119 | #[test] |
| 120 | fn external_backend_fails_before_unsupported_interactive_spawn() { |
| 121 | let request = TerminalRequest { |
| 122 | policy: TerminalProcessPolicy::Stateful, |
| 123 | interactive: true, |
| 124 | background: false, |
| 125 | tty: false, |
| 126 | session_id: Some("term-1".to_string()), |
| 127 | }; |
| 128 | let backend = TerminalBackendCapabilities { |
| 129 | interactive: false, |
| 130 | background: false, |
| 131 | tty: false, |
| 132 | stateful: false, |
| 133 | restart_reattach: false, |
| 134 | }; |
| 135 | assert!( |
| 136 | validate_terminal_request(&request, backend) |
| 137 | .unwrap_err() |
| 138 | .contains("interactive") |
| 139 | ); |
| 140 | } |
| 141 | |
| 142 | #[test] |
| 143 | fn host_fingerprint_prevents_pid_style_false_reattach() { |
| 144 | let identity = TerminalSessionIdentity { |
| 145 | session_id: "term-1".to_string(), |
| 146 | host_fingerprint: "host-a".to_string(), |
| 147 | cwd: PathBuf::from("/workspace"), |
| 148 | environment_keys: BTreeSet::new(), |
| 149 | shell: "zsh".to_string(), |
| 150 | }; |
| 151 | assert_eq!( |
| 152 | identity.recovery_on_host("term-1", "host-b", TerminalBackendCapabilities::LOCAL), |
| 153 | TerminalSessionRecovery::Stale |
| 154 | ); |
| 155 | } |
| 156 | } |
| 157 |