返回 CodeWhale
terminal.rs
根目录 / crates / tui / src / core / runtime_contract / terminal.rs
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
157 lines RUST