@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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/db/message_store.rs +10 -3
- package/crates/team-agent/src/messaging/delivery.rs +132 -39
- package/crates/team-agent/src/messaging/delivery_consumption_tests.rs +223 -0
- package/crates/team-agent/src/messaging/enter_rescue.rs +182 -154
- package/crates/team-agent/src/messaging/enter_rescue_tests.rs +344 -277
- package/crates/team-agent/src/messaging/mod.rs +1 -1
- package/crates/team-agent/src/tmux_backend/enter_rescue.rs +325 -0
- package/crates/team-agent/src/tmux_backend/enter_rescue_tests.rs +432 -0
- package/crates/team-agent/src/tmux_backend/native_input.rs +19 -0
- package/crates/team-agent/src/tmux_backend.rs +27 -0
- package/crates/team-agent/src/transport/enter_rescue.rs +166 -0
- package/crates/team-agent/src/transport/native_input.rs +5 -1
- package/crates/team-agent/src/transport/test_support.rs +25 -0
- package/crates/team-agent/src/transport.rs +34 -0
- package/package.json +4 -4
|
@@ -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
|
|
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,
|