@team-agent/installer 0.5.118 → 0.5.119

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,432 @@
1
+ #![allow(clippy::unwrap_used, clippy::panic)]
2
+ use super::*;
3
+ use crate::model::enums::Provider;
4
+ use std::sync::atomic::{AtomicUsize, Ordering};
5
+ use std::sync::{Arc, Mutex};
6
+
7
+ #[derive(Clone, Copy)]
8
+ enum Mode {
9
+ Normal,
10
+ Busy,
11
+ Unknown,
12
+ Capsule,
13
+ History,
14
+ Changed,
15
+ UnknownAfterEnter,
16
+ UnknownWithReplacement,
17
+ }
18
+ struct State {
19
+ mode: Mode,
20
+ nonce: String,
21
+ endpoint: String,
22
+ pid: u32,
23
+ captures: usize,
24
+ entered: usize,
25
+ calls: Vec<Vec<String>>,
26
+ }
27
+ struct Runner(Arc<Mutex<State>>);
28
+ fn pi(composer: &str, history: &str, busy: bool) -> String {
29
+ format!("{history}\n{}\n{composer}\n────────────────────────\ngpt-6-luna · max · 0%/272k · 0% · $0.00\n",if busy{"── ⠋ Working ──────────────────"}else{"────────────────────"})
30
+ }
31
+ impl CommandRunner for Runner {
32
+ fn run(&self, argv: &[String]) -> Result<CommandOutput, std::io::Error> {
33
+ let mut s = self.0.lock().unwrap();
34
+ s.calls.push(argv.to_vec());
35
+ let stdout = if argv.iter().any(|a| a == "display-message") {
36
+ let format = argv.last().unwrap();
37
+ if format.contains("socket_path") {
38
+ let nonce = if (matches!(s.mode, Mode::UnknownAfterEnter) && s.entered > 0)
39
+ || (matches!(s.mode, Mode::UnknownWithReplacement) && s.captures > 0)
40
+ {
41
+ "replaced"
42
+ } else {
43
+ &s.nonce
44
+ };
45
+ format!("{}|%1|{}|session|worker|{nonce}|0|120\n", s.endpoint, s.pid)
46
+ } else if format == "#{pane_height}" {
47
+ "120\n".into()
48
+ } else if format == "#{pane_pid}" {
49
+ format!("{}\n", s.pid)
50
+ } else {
51
+ "0\n".into()
52
+ }
53
+ } else if argv.iter().any(|a| a == "set-option") {
54
+ assert!(s.nonce.is_empty());
55
+ assert!(argv.iter().any(|a| a == "-o"));
56
+ s.nonce = argv.last().unwrap().clone();
57
+ String::new()
58
+ } else if argv.iter().any(|a| a == "show-options") {
59
+ format!("{}\n", s.nonce)
60
+ } else if argv.iter().any(|a| a == "capture-pane") {
61
+ s.captures += 1;
62
+ let marker = "[team-agent-token:msg_one]";
63
+ match s.mode {
64
+ Mode::Unknown | Mode::UnknownWithReplacement => "unknown native screen".into(),
65
+ Mode::Capsule => pi("[paste #1 +30 lines]", "", false),
66
+ Mode::History => pi("", marker, false),
67
+ Mode::Changed if s.captures >= 3 => pi("different draft", "", false),
68
+ Mode::Busy => pi(marker, "", true),
69
+ _ if s.entered > 0 => pi("", marker, false),
70
+ _ => pi(marker, "", false),
71
+ }
72
+ } else if argv.iter().any(|a| a == "send-keys") {
73
+ assert_eq!(argv.last().unwrap(), "Enter");
74
+ s.entered += 1;
75
+ String::new()
76
+ } else {
77
+ panic!("unexpected transport operation {argv:?}")
78
+ };
79
+ Ok(CommandOutput {
80
+ success: true,
81
+ code: Some(0),
82
+ stdout,
83
+ stderr: String::new(),
84
+ })
85
+ }
86
+ }
87
+ struct Fixture {
88
+ path: std::path::PathBuf,
89
+ backend: TmuxBackend,
90
+ state: Arc<Mutex<State>>,
91
+ }
92
+ impl Fixture {
93
+ fn new(mode: Mode) -> Self {
94
+ static NEXT: AtomicUsize = AtomicUsize::new(0);
95
+ let path = std::env::temp_dir().join(format!(
96
+ "ta-independent-rescue-backend-{}-{}",
97
+ std::process::id(),
98
+ NEXT.fetch_add(1, Ordering::SeqCst)
99
+ ));
100
+ std::fs::create_dir(&path).unwrap();
101
+ let state = Arc::new(Mutex::new(State {
102
+ mode,
103
+ nonce: "original-nonce".into(),
104
+ endpoint: "/tmp/owned.sock".into(),
105
+ pid: std::process::id(),
106
+ captures: 0,
107
+ entered: 0,
108
+ calls: vec![],
109
+ }));
110
+ let backend =
111
+ TmuxBackend::with_runner_for_workspace(Box::new(Runner(state.clone())), &path);
112
+ Self {
113
+ path,
114
+ backend,
115
+ state,
116
+ }
117
+ }
118
+ fn context(&self) -> EnterRescueContext {
119
+ EnterRescueContext {
120
+ message_id: "msg_one".into(),
121
+ owner_team_id: "team".into(),
122
+ recipient: "worker".into(),
123
+ payload_sha256: digest("rendered payload"),
124
+ target: Some(EnterRescueTarget::selected(
125
+ Provider::Pi,
126
+ "%1".into(),
127
+ std::process::id(),
128
+ "session".into(),
129
+ "worker".into(),
130
+ )),
131
+ proof: None,
132
+ }
133
+ }
134
+ fn snapshot(&self) -> EnterRescueContext {
135
+ self.backend
136
+ .snapshot_rescue_context(
137
+ &Target::Pane(PaneId::new("%1")),
138
+ &self.context(),
139
+ "rendered payload",
140
+ )
141
+ .unwrap()
142
+ }
143
+ fn entered(&self) -> usize {
144
+ self.state.lock().unwrap().entered
145
+ }
146
+ }
147
+ impl Drop for Fixture {
148
+ fn drop(&mut self) {
149
+ let _ = std::fs::remove_dir_all(&self.path);
150
+ }
151
+ }
152
+
153
+ #[test]
154
+ fn original_boundary_snapshot_records_only_exact_visible_current_marker() {
155
+ let f = Fixture::new(Mode::Normal);
156
+ let c = f.snapshot();
157
+ assert!(c.target.as_ref().unwrap().bound());
158
+ let proof = c.proof.unwrap();
159
+ assert!(proof.visible_marker && proof.transcript_marker_absent);
160
+ assert!(proof.owned_capsule.is_none());
161
+ assert_eq!(f.entered(), 0);
162
+ for mode in [Mode::Unknown, Mode::Capsule, Mode::History] {
163
+ let f = Fixture::new(mode);
164
+ let c = f.snapshot();
165
+ assert!(c.proof.is_none());
166
+ assert_eq!(f.entered(), 0);
167
+ }
168
+ }
169
+
170
+ #[test]
171
+ fn snapshot_rejects_wrong_payload_or_selected_carrier_and_creates_nonce_only_when_owned() {
172
+ let f = Fixture::new(Mode::Normal);
173
+ let c = f.context();
174
+ let t = Target::Pane(PaneId::new("%1"));
175
+ assert!(f
176
+ .backend
177
+ .snapshot_rescue_context(&t, &c, "edited")
178
+ .unwrap()
179
+ .proof
180
+ .is_none());
181
+ assert!(f.state.lock().unwrap().calls.is_empty());
182
+ let mut c = f.context();
183
+ c.target.as_mut().unwrap().pane_pid += 1;
184
+ assert!(f
185
+ .backend
186
+ .snapshot_rescue_context(&t, &c, "rendered payload")
187
+ .unwrap()
188
+ .proof
189
+ .is_none());
190
+ let f = Fixture::new(Mode::Capsule);
191
+ f.state.lock().unwrap().nonce.clear();
192
+ assert!(f.snapshot().proof.is_none());
193
+ assert!(f.state.lock().unwrap().nonce.is_empty());
194
+ let f = Fixture::new(Mode::Normal);
195
+ f.state.lock().unwrap().nonce.clear();
196
+ assert!(f.snapshot().target.unwrap().bound());
197
+ assert!(!f.state.lock().unwrap().nonce.is_empty());
198
+ assert_eq!(f.entered(), 0);
199
+ }
200
+
201
+ #[test]
202
+ fn watchdog_rechecks_carrier_and_never_adopts_replacement_or_missing_receipt() {
203
+ for changed in ["endpoint", "nonce", "birth", "pid"] {
204
+ let f = Fixture::new(Mode::Normal);
205
+ let mut c = f.snapshot();
206
+ match changed {
207
+ "endpoint" => f.state.lock().unwrap().endpoint = "/tmp/other.sock".into(),
208
+ "nonce" => f.state.lock().unwrap().nonce = "other".into(),
209
+ "birth" => c.target.as_mut().unwrap().carrier_birth = "reused".into(),
210
+ _ => c.target.as_mut().unwrap().pane_pid += 1,
211
+ };
212
+ let _ = f.backend.rescue_guarded_enter(&c, &|| true, None);
213
+ assert_eq!(f.entered(), 0, "{changed}");
214
+ }
215
+ let f = Fixture::new(Mode::Normal);
216
+ assert!(matches!(
217
+ f.backend
218
+ .rescue_guarded_enter(&f.context(), &|| true, None)
219
+ .unwrap(),
220
+ EnterRescueOutcome::NoEffect { .. }
221
+ ));
222
+ assert_eq!(f.entered(), 0);
223
+ }
224
+
225
+ #[test]
226
+ fn busy_unstable_unknown_or_final_authorization_failure_sends_no_key() {
227
+ for mode in [Mode::Busy, Mode::Changed] {
228
+ let f = Fixture::new(mode);
229
+ let c = f.snapshot();
230
+ assert!(matches!(
231
+ f.backend.rescue_guarded_enter(&c, &|| true, None).unwrap(),
232
+ EnterRescueOutcome::NoEffect { .. }
233
+ ));
234
+ assert_eq!(f.entered(), 0);
235
+ }
236
+ let f = Fixture::new(Mode::Normal);
237
+ let c = f.snapshot();
238
+ f.state.lock().unwrap().mode = Mode::Unknown;
239
+ assert_eq!(
240
+ f.backend.rescue_guarded_enter(&c, &|| true, None).unwrap(),
241
+ no_effect("surface_unknown", true)
242
+ );
243
+ assert_eq!(f.entered(), 0);
244
+ let f = Fixture::new(Mode::Normal);
245
+ let c = f.snapshot();
246
+ assert!(matches!(
247
+ f.backend.rescue_guarded_enter(&c, &|| false, None).unwrap(),
248
+ EnterRescueOutcome::NoEffect { .. }
249
+ ));
250
+ assert_eq!(f.entered(), 0);
251
+ }
252
+
253
+ #[test]
254
+ fn unknown_observation_still_checks_original_carrier_before_zero_effect_refusal() {
255
+ let f = Fixture::new(Mode::Normal);
256
+ let c = f.snapshot();
257
+ {
258
+ let mut state = f.state.lock().unwrap();
259
+ state.mode = Mode::UnknownWithReplacement;
260
+ state.captures = 0;
261
+ }
262
+ assert!(matches!(
263
+ f.backend.rescue_guarded_enter(&c, &|| true, None),
264
+ Err(TransportError::NativeInputNotReady { reason }) if reason == "rescue_binding_changed"
265
+ ));
266
+ assert_eq!(f.entered(), 0);
267
+ }
268
+
269
+ #[test]
270
+ fn required_input_lock_cannot_be_omitted_or_bypassed() {
271
+ let f = Fixture::new(Mode::Normal);
272
+ let c = f.snapshot();
273
+ let _lock = acquire_required(PaneInputLockRequest {
274
+ workspace: Some(&f.path),
275
+ target_key: "%1",
276
+ operation: "fixture-held",
277
+ })
278
+ .unwrap();
279
+ assert_eq!(
280
+ f.backend.rescue_guarded_enter(&c, &|| true, None).unwrap(),
281
+ no_effect("input_lock", true)
282
+ );
283
+ assert_eq!(f.entered(), 0);
284
+ let f = Fixture::new(Mode::Normal);
285
+ let c = f.snapshot();
286
+ let backend = TmuxBackend::with_runner(Box::new(Runner(f.state.clone())));
287
+ assert_eq!(
288
+ backend.rescue_guarded_enter(&c, &|| true, None).unwrap(),
289
+ no_effect("input_lock", true)
290
+ );
291
+ assert_eq!(f.entered(), 0);
292
+ }
293
+
294
+ struct Observer(AtomicUsize);
295
+ impl SubmitObserver for Observer {
296
+ fn after_physical_submit(&self) {
297
+ self.0.fetch_add(1, Ordering::SeqCst);
298
+ }
299
+ }
300
+ #[test]
301
+ fn one_logical_enter_notifies_before_verification_and_never_pastes() {
302
+ let f = Fixture::new(Mode::Normal);
303
+ let c = f.snapshot();
304
+ let observer = Observer(AtomicUsize::new(0));
305
+ assert_eq!(
306
+ f.backend
307
+ .rescue_guarded_enter(&c, &|| true, Some(&observer))
308
+ .unwrap(),
309
+ EnterRescueOutcome::Entered { accepted: true }
310
+ );
311
+ assert_eq!(f.entered(), 1);
312
+ assert_eq!(observer.0.load(Ordering::SeqCst), 1);
313
+ let s = f.state.lock().unwrap();
314
+ assert!(!s
315
+ .calls
316
+ .iter()
317
+ .flatten()
318
+ .any(|a| matches!(a.as_str(), "paste-buffer" | "load-buffer" | "set-buffer")));
319
+ }
320
+
321
+ #[test]
322
+ fn already_consumed_transition_never_sends_another_enter_or_fakes_observer() {
323
+ let f = Fixture::new(Mode::Normal);
324
+ let c = f.snapshot();
325
+ f.state.lock().unwrap().mode = Mode::History;
326
+ let observer = Observer(AtomicUsize::new(0));
327
+ assert_eq!(
328
+ f.backend
329
+ .rescue_guarded_enter(&c, &|| true, Some(&observer))
330
+ .unwrap(),
331
+ EnterRescueOutcome::Consumed
332
+ );
333
+ assert_eq!(f.entered(), 0);
334
+ assert_eq!(observer.0.load(Ordering::SeqCst), 0);
335
+ }
336
+
337
+ #[test]
338
+ fn foreign_token_or_bare_capsule_is_not_current_message_key_authority() {
339
+ let f = Fixture::new(Mode::Normal);
340
+ let mut c = f.snapshot();
341
+ let mut frame = f
342
+ .backend
343
+ .rescue_frame(c.target.as_ref().unwrap())
344
+ .unwrap()
345
+ .unwrap();
346
+ frame.composer.push_str("\n[team-agent-token:msg_foreign]");
347
+ assert!(!pending(&c, &frame));
348
+ frame.composer = "[paste #1 +30 lines]".into();
349
+ frame.capsule = Some(frame.composer.clone());
350
+ assert!(!pending(&c, &frame));
351
+ let scope_sha256 = c.scope_sha256().unwrap();
352
+ let proof = c.proof.as_mut().unwrap();
353
+ proof.visible_marker = false;
354
+ proof.owned_capsule = Some(crate::transport::enter_rescue::OwnedCapsule {
355
+ scope_sha256,
356
+ label: frame.composer.clone(),
357
+ baseline_sha256: digest("original baseline"),
358
+ baseline_capsules: vec![],
359
+ observed_after_owned_paste: true,
360
+ });
361
+ assert!(pending(&c, &frame));
362
+ for field in [
363
+ "message",
364
+ "payload",
365
+ "owner",
366
+ "recipient",
367
+ "nonce",
368
+ "birth",
369
+ "endpoint",
370
+ ] {
371
+ let mut reassigned = c.clone();
372
+ match field {
373
+ "message" => reassigned.message_id = "msg_other".into(),
374
+ "payload" => reassigned.payload_sha256 = digest("other payload"),
375
+ "owner" => reassigned.owner_team_id = "other".into(),
376
+ "recipient" => reassigned.recipient = "other".into(),
377
+ "nonce" => reassigned.target.as_mut().unwrap().binding_nonce = "other".into(),
378
+ "birth" => reassigned.target.as_mut().unwrap().carrier_birth = "other".into(),
379
+ _ => reassigned.target.as_mut().unwrap().endpoint = "/tmp/other.sock".into(),
380
+ }
381
+ assert!(!pending(&reassigned, &frame), "{field}");
382
+ }
383
+ c.proof
384
+ .as_mut()
385
+ .unwrap()
386
+ .owned_capsule
387
+ .as_mut()
388
+ .unwrap()
389
+ .baseline_capsules
390
+ .push(frame.composer.clone());
391
+ assert!(!pending(&c, &frame));
392
+ }
393
+
394
+ #[test]
395
+ fn unknown_after_enter_is_terminal_uncertainty_not_no_effect() {
396
+ let f = Fixture::new(Mode::UnknownAfterEnter);
397
+ let c = f.snapshot();
398
+ let observer = Observer(AtomicUsize::new(0));
399
+ assert_eq!(
400
+ f.backend
401
+ .rescue_guarded_enter(&c, &|| true, Some(&observer))
402
+ .unwrap(),
403
+ EnterRescueOutcome::Entered { accepted: false }
404
+ );
405
+ assert_eq!(f.entered(), 1);
406
+ assert_eq!(observer.0.load(Ordering::SeqCst), 1);
407
+ }
408
+
409
+ #[test]
410
+ fn transcript_transfer_requires_empty_not_whitespace_composer_and_unique_marker() {
411
+ let f = Fixture::new(Mode::Normal);
412
+ let c = f.snapshot();
413
+ let proof = c.proof.as_ref().unwrap();
414
+ let mut frame = InputFrame {
415
+ composer: "".into(),
416
+ transcript: c.marker(),
417
+ identity: proof.ui_identity.clone(),
418
+ footer: "changed while working".into(),
419
+ busy: true,
420
+ capsule: None,
421
+ };
422
+ assert!(accepted(&c, &frame));
423
+ frame.composer = " ".into();
424
+ assert!(!accepted(&c, &frame));
425
+ frame.composer.clear();
426
+ frame.transcript = format!("{}{}", c.marker(), c.marker());
427
+ assert!(!accepted(&c, &frame));
428
+ frame.transcript = c.marker();
429
+ let mut c = c;
430
+ c.proof.as_mut().unwrap().transcript_marker_absent = false;
431
+ assert!(!accepted(&c, &frame));
432
+ }
@@ -38,6 +38,25 @@ pub(crate) fn digest(text: &str) -> String {
38
38
  }
39
39
 
40
40
  impl TmuxBackend {
41
+ pub(super) fn observe_current_native_input(
42
+ &self,
43
+ target: &Target,
44
+ provider: Provider,
45
+ ) -> Result<Option<InputFrame>, TransportError> {
46
+ if !crate::transport::native_input::supports_input_observation(provider) {
47
+ return Ok(None);
48
+ }
49
+ // Pure observation: no nonce initialization, paste, or key.
50
+ let binding = self.query_native_binding(target, true)?;
51
+ let capture = self.capture(target, CaptureRange::Tail(200))?;
52
+ let frame = inspect_input(provider, &capture.text, binding.pane_height)
53
+ .ok_or_else(|| refused("native_surface_unknown"))?;
54
+ if self.query_native_binding(target, true)? != binding {
55
+ return Err(refused("binding_changed"));
56
+ }
57
+ Ok(Some(frame))
58
+ }
59
+
41
60
  fn native_binding(&self, target: &Target) -> Result<NativeInputBinding, TransportError> {
42
61
  self.query_native_binding(target, false)
43
62
  }
@@ -73,6 +73,7 @@ use crate::transport::{
73
73
  SubmitVerification, Target, Transport, TransportError, TurnVerification, WindowName,
74
74
  };
75
75
 
76
+ mod enter_rescue;
76
77
  mod native_input;
77
78
 
78
79
  pub const PANE_BINDING_NONCE_METADATA_KEY: &str = "TEAM_AGENT_PANE_BINDING_NONCE";
@@ -3848,6 +3849,32 @@ impl Transport for TmuxBackend {
3848
3849
  })
3849
3850
  }
3850
3851
 
3852
+ fn observe_native_input(
3853
+ &self,
3854
+ target: &Target,
3855
+ provider: crate::model::enums::Provider,
3856
+ ) -> Result<Option<crate::transport::native_input::InputFrame>, TransportError> {
3857
+ self.observe_current_native_input(target, provider)
3858
+ }
3859
+
3860
+ fn snapshot_enter_rescue(
3861
+ &self,
3862
+ target: &Target,
3863
+ context: &crate::transport::enter_rescue::EnterRescueContext,
3864
+ rendered_payload: &str,
3865
+ ) -> Result<crate::transport::enter_rescue::EnterRescueContext, TransportError> {
3866
+ self.snapshot_rescue_context(target, context, rendered_payload)
3867
+ }
3868
+
3869
+ fn rescue_enter_guarded(
3870
+ &self,
3871
+ context: &crate::transport::enter_rescue::EnterRescueContext,
3872
+ authorize: &dyn Fn() -> bool,
3873
+ observer: Option<&dyn SubmitObserver>,
3874
+ ) -> Result<crate::transport::enter_rescue::EnterRescueOutcome, TransportError> {
3875
+ self.rescue_guarded_enter(context, authorize, observer)
3876
+ }
3877
+
3851
3878
  fn rescue_enter(&self, evidence: &crate::transport::native_input::NativeInputEvidence, authorize: &dyn Fn() -> bool, observer: Option<&dyn SubmitObserver>)
3852
3879
  -> Result<crate::transport::native_input::EnterRescueOutcome, TransportError>
3853
3880
  {
@@ -0,0 +1,166 @@
1
+ //! Independent watchdog receipts. Registration is unconditional; key authority is not.
2
+
3
+ use serde::{Deserialize, Serialize};
4
+ use sha2::{Digest, Sha256};
5
+
6
+ use crate::model::enums::Provider;
7
+
8
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9
+ pub struct EnterRescueContext {
10
+ pub message_id: String,
11
+ pub owner_team_id: String,
12
+ pub recipient: String,
13
+ pub payload_sha256: String,
14
+ pub target: Option<EnterRescueTarget>,
15
+ pub proof: Option<EnterRescueProof>,
16
+ }
17
+
18
+ impl EnterRescueContext {
19
+ pub fn marker(&self) -> String {
20
+ format!("[team-agent-token:{}]", self.message_id)
21
+ }
22
+
23
+ /// Binds an opaque-paste receipt to this message and this original carrier.
24
+ pub fn scope_sha256(&self) -> Option<String> {
25
+ let bytes = serde_json::to_vec(&(
26
+ &self.message_id,
27
+ &self.owner_team_id,
28
+ &self.recipient,
29
+ &self.payload_sha256,
30
+ &self.target,
31
+ ))
32
+ .ok()?;
33
+ Some(format!("{:x}", Sha256::digest(bytes)))
34
+ }
35
+
36
+ pub fn valid_proof(&self) -> bool {
37
+ self.proof.as_ref().is_some_and(|proof| {
38
+ proof.valid()
39
+ && proof.owned_capsule.as_ref().is_none_or(|capsule| {
40
+ capsule.valid() && self.scope_sha256().as_ref() == Some(&capsule.scope_sha256)
41
+ })
42
+ })
43
+ }
44
+
45
+ pub fn valid_message(&self) -> bool {
46
+ self.message_id.starts_with("msg_")
47
+ && self
48
+ .message_id
49
+ .bytes()
50
+ .all(|b| b.is_ascii_alphanumeric() || b == b'_')
51
+ && !self.owner_team_id.is_empty()
52
+ && !self.recipient.is_empty()
53
+ && self.recipient != "leader"
54
+ && valid_digest(&self.payload_sha256)
55
+ }
56
+ }
57
+
58
+ /// `selected` records canonical original routing, not a late adoption of a pane.
59
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60
+ pub struct EnterRescueTarget {
61
+ pub provider: Provider,
62
+ pub endpoint: String,
63
+ pub pane_id: String,
64
+ pub pane_pid: u32,
65
+ pub pane_height: u32,
66
+ pub carrier_birth: String,
67
+ pub binding_nonce: String,
68
+ pub session: String,
69
+ pub window: String,
70
+ }
71
+
72
+ impl EnterRescueTarget {
73
+ pub fn selected(
74
+ provider: Provider,
75
+ pane_id: String,
76
+ pane_pid: u32,
77
+ session: String,
78
+ window: String,
79
+ ) -> Self {
80
+ Self {
81
+ provider,
82
+ pane_id,
83
+ pane_pid,
84
+ session,
85
+ window,
86
+ endpoint: String::new(),
87
+ pane_height: 0,
88
+ carrier_birth: String::new(),
89
+ binding_nonce: String::new(),
90
+ }
91
+ }
92
+
93
+ pub fn same_selection(&self, other: &Self) -> bool {
94
+ self.provider == other.provider
95
+ && self.pane_id == other.pane_id
96
+ && self.pane_pid == other.pane_pid
97
+ && self.session == other.session
98
+ && self.window == other.window
99
+ }
100
+
101
+ pub fn bound(&self) -> bool {
102
+ std::path::Path::new(&self.endpoint).is_absolute()
103
+ && self.pane_id.starts_with('%')
104
+ && self.pane_pid > 1
105
+ && self.pane_height >= 10
106
+ && !self.carrier_birth.is_empty()
107
+ && !self.binding_nonce.is_empty()
108
+ && !self.session.is_empty()
109
+ && !self.window.is_empty()
110
+ }
111
+ }
112
+
113
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114
+ pub struct EnterRescueProof {
115
+ pub ui_identity: String,
116
+ pub footer_sha256: String,
117
+ pub visible_marker: bool,
118
+ pub transcript_marker_absent: bool,
119
+ pub owned_capsule: Option<OwnedCapsule>,
120
+ }
121
+
122
+ impl EnterRescueProof {
123
+ pub fn valid(&self) -> bool {
124
+ !self.ui_identity.is_empty()
125
+ && valid_digest(&self.footer_sha256)
126
+ && (self.visible_marker || self.owned_capsule.as_ref().is_some_and(OwnedCapsule::valid))
127
+ }
128
+ }
129
+
130
+ /// Only a prior empty-baseline/single-owned-paste transition may create this.
131
+ /// Snapshotting a visible capsule label alone never creates ownership.
132
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133
+ pub struct OwnedCapsule {
134
+ pub scope_sha256: String,
135
+ pub label: String,
136
+ pub baseline_sha256: String,
137
+ pub baseline_capsules: Vec<String>,
138
+ pub observed_after_owned_paste: bool,
139
+ }
140
+
141
+ impl OwnedCapsule {
142
+ pub fn valid(&self) -> bool {
143
+ self.observed_after_owned_paste
144
+ && valid_digest(&self.scope_sha256)
145
+ && !self.label.is_empty()
146
+ && self.label.len() <= 80
147
+ && valid_digest(&self.baseline_sha256)
148
+ && !self.baseline_capsules.contains(&self.label)
149
+ }
150
+ }
151
+
152
+ fn valid_digest(value: &str) -> bool {
153
+ value.len() == 64 && value.bytes().all(|b| b.is_ascii_hexdigit())
154
+ }
155
+
156
+ #[derive(Debug, Clone, PartialEq, Eq)]
157
+ pub enum EnterRescueOutcome {
158
+ Consumed,
159
+ NoEffect {
160
+ reason: &'static str,
161
+ reobserve: bool,
162
+ },
163
+ Entered {
164
+ accepted: bool,
165
+ },
166
+ }
@@ -131,7 +131,7 @@ pub(crate) fn record_evidence(evidence: &NativeInputEvidence) {
131
131
  }
132
132
 
133
133
  #[derive(Debug, Clone, PartialEq, Eq)]
134
- pub(crate) struct InputFrame {
134
+ pub struct InputFrame {
135
135
  pub composer: String,
136
136
  pub transcript: String,
137
137
  pub identity: String,
@@ -195,6 +195,10 @@ fn input_digest(text: &str) -> String {
195
195
  format!("{:x}", Sha256::digest(text.as_bytes()))
196
196
  }
197
197
 
198
+ pub(crate) fn supports_input_observation(provider: Provider) -> bool {
199
+ matches!(provider, Provider::Pi | Provider::CursorAgent)
200
+ }
201
+
198
202
  pub(crate) fn inspect_input(
199
203
  provider: Provider,
200
204
  text: &str,