@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,325 @@
|
|
|
1
|
+
//! Independent watchdog effects. No native-input state-machine evidence is adopted.
|
|
2
|
+
|
|
3
|
+
use std::time::{Duration, Instant};
|
|
4
|
+
|
|
5
|
+
use sha2::{Digest, Sha256};
|
|
6
|
+
|
|
7
|
+
use super::*;
|
|
8
|
+
use crate::lifecycle::pane_input_lock::{acquire_required, PaneInputLockRequest};
|
|
9
|
+
use crate::transport::enter_rescue::{
|
|
10
|
+
EnterRescueContext, EnterRescueOutcome, EnterRescueProof, EnterRescueTarget,
|
|
11
|
+
};
|
|
12
|
+
use crate::transport::native_input::InputFrame;
|
|
13
|
+
|
|
14
|
+
const STABLE: Duration = Duration::from_millis(200);
|
|
15
|
+
const POLL: Duration = Duration::from_millis(50);
|
|
16
|
+
const OBSERVE: Duration = Duration::from_secs(10);
|
|
17
|
+
|
|
18
|
+
fn digest(value: &str) -> String {
|
|
19
|
+
format!("{:x}", Sha256::digest(value.as_bytes()))
|
|
20
|
+
}
|
|
21
|
+
fn marker_count(value: &str, marker: &str) -> usize {
|
|
22
|
+
value
|
|
23
|
+
.lines()
|
|
24
|
+
.map(str::trim)
|
|
25
|
+
.collect::<String>()
|
|
26
|
+
.matches(marker)
|
|
27
|
+
.count()
|
|
28
|
+
}
|
|
29
|
+
fn current_marker_only(context: &EnterRescueContext, frame: &InputFrame) -> bool {
|
|
30
|
+
let composer: String = frame.composer.lines().map(str::trim).collect();
|
|
31
|
+
composer.matches("[team-agent-token:").count() == 1
|
|
32
|
+
&& composer.matches(&context.marker()).count() == 1
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
fn no_effect(reason: &'static str, reobserve: bool) -> EnterRescueOutcome {
|
|
36
|
+
EnterRescueOutcome::NoEffect { reason, reobserve }
|
|
37
|
+
}
|
|
38
|
+
fn refusal(reason: &str) -> TransportError {
|
|
39
|
+
TransportError::NativeInputNotReady {
|
|
40
|
+
reason: reason.into(),
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
fn pending(context: &EnterRescueContext, frame: &InputFrame) -> bool {
|
|
45
|
+
let Some(proof) = context.proof.as_ref().filter(|_| context.valid_proof()) else {
|
|
46
|
+
return false;
|
|
47
|
+
};
|
|
48
|
+
if frame.busy
|
|
49
|
+
|| frame.identity != proof.ui_identity
|
|
50
|
+
|| digest(&frame.footer) != proof.footer_sha256
|
|
51
|
+
{
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
let marker = context.marker();
|
|
55
|
+
if marker_count(&frame.transcript, &marker) != 0 {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
(proof.visible_marker && current_marker_only(context, frame))
|
|
59
|
+
|| proof.owned_capsule.as_ref().is_some_and(|capsule| {
|
|
60
|
+
capsule.valid()
|
|
61
|
+
&& frame.capsule.as_ref() == Some(&capsule.label)
|
|
62
|
+
&& frame.composer.trim() == capsule.label
|
|
63
|
+
&& !frame
|
|
64
|
+
.transcript
|
|
65
|
+
.lines()
|
|
66
|
+
.any(|line| line.trim() == capsule.label)
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
fn accepted(context: &EnterRescueContext, frame: &InputFrame) -> bool {
|
|
71
|
+
let Some(proof) = context.proof.as_ref().filter(|_| context.valid_proof()) else {
|
|
72
|
+
return false;
|
|
73
|
+
};
|
|
74
|
+
// Invisible whitespace is not an empty composer receipt.
|
|
75
|
+
if !frame.composer.is_empty() || frame.identity != proof.ui_identity {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
if proof.visible_marker
|
|
79
|
+
&& proof.transcript_marker_absent
|
|
80
|
+
&& marker_count(&frame.transcript, &context.marker()) == 1
|
|
81
|
+
{
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
proof.owned_capsule.as_ref().is_some_and(|capsule| {
|
|
85
|
+
capsule.valid()
|
|
86
|
+
&& frame
|
|
87
|
+
.transcript
|
|
88
|
+
.lines()
|
|
89
|
+
.filter(|line| line.trim() == capsule.label)
|
|
90
|
+
.count()
|
|
91
|
+
== 1
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
fn same_editor(left: &InputFrame, right: &InputFrame) -> bool {
|
|
96
|
+
left.composer == right.composer
|
|
97
|
+
&& left.identity == right.identity
|
|
98
|
+
&& left.footer == right.footer
|
|
99
|
+
&& left.capsule == right.capsule
|
|
100
|
+
&& left.busy == right.busy
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
impl TmuxBackend {
|
|
104
|
+
fn rescue_binding(
|
|
105
|
+
&self,
|
|
106
|
+
selected: &EnterRescueTarget,
|
|
107
|
+
allow_unset_nonce: bool,
|
|
108
|
+
) -> Result<EnterRescueTarget, TransportError> {
|
|
109
|
+
let argv = self.tmux_argv(&[
|
|
110
|
+
"tmux".into(), "display-message".into(), "-p".into(), "-t".into(), selected.pane_id.clone(),
|
|
111
|
+
"#{socket_path}|#{pane_id}|#{pane_pid}|#{session_name}|#{window_name}|#{@team_agent_pane_binding_nonce}|#{pane_in_mode}|#{pane_height}".into(),
|
|
112
|
+
]);
|
|
113
|
+
let output = self.runner.run(&argv)?;
|
|
114
|
+
if !output.success || output.stdout.lines().count() != 1 {
|
|
115
|
+
return Err(refusal("rescue_binding_query"));
|
|
116
|
+
}
|
|
117
|
+
let fields: Vec<_> = output.stdout.trim_end().split('|').collect();
|
|
118
|
+
if fields.len() != 8
|
|
119
|
+
|| !std::path::Path::new(fields[0]).is_absolute()
|
|
120
|
+
|| fields[1] != selected.pane_id
|
|
121
|
+
|| fields[3].is_empty()
|
|
122
|
+
|| fields[4].is_empty()
|
|
123
|
+
|| (fields[5].is_empty() && !allow_unset_nonce)
|
|
124
|
+
|| fields[6] != "0"
|
|
125
|
+
{
|
|
126
|
+
return Err(refusal("rescue_binding_invalid"));
|
|
127
|
+
}
|
|
128
|
+
let pid = fields[2]
|
|
129
|
+
.parse::<u32>()
|
|
130
|
+
.map_err(|_| refusal("rescue_binding_pid"))?;
|
|
131
|
+
let height = fields[7]
|
|
132
|
+
.parse::<u32>()
|
|
133
|
+
.map_err(|_| refusal("rescue_binding_height"))?;
|
|
134
|
+
if pid <= 1 || height < 10 {
|
|
135
|
+
return Err(refusal("rescue_binding_invalid"));
|
|
136
|
+
}
|
|
137
|
+
let actual = EnterRescueTarget {
|
|
138
|
+
provider: selected.provider,
|
|
139
|
+
endpoint: fields[0].into(),
|
|
140
|
+
pane_id: fields[1].into(),
|
|
141
|
+
pane_pid: pid,
|
|
142
|
+
pane_height: height,
|
|
143
|
+
carrier_birth: team_agent_contract::host::process::process_birth_identity(pid)
|
|
144
|
+
.map_err(|_| refusal("rescue_carrier_birth"))?,
|
|
145
|
+
binding_nonce: fields[5].into(),
|
|
146
|
+
session: fields[3].into(),
|
|
147
|
+
window: fields[4].into(),
|
|
148
|
+
};
|
|
149
|
+
if !actual.same_selection(selected) {
|
|
150
|
+
return Err(refusal("rescue_selection_changed"));
|
|
151
|
+
}
|
|
152
|
+
Ok(actual)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
fn rescue_frame(
|
|
156
|
+
&self,
|
|
157
|
+
expected: &EnterRescueTarget,
|
|
158
|
+
) -> Result<Option<InputFrame>, TransportError> {
|
|
159
|
+
if self.rescue_binding(expected, false)? != *expected {
|
|
160
|
+
return Err(refusal("rescue_binding_changed"));
|
|
161
|
+
}
|
|
162
|
+
let frame = match self.observe_native_input(
|
|
163
|
+
&Target::Pane(PaneId::new(&expected.pane_id)),
|
|
164
|
+
expected.provider,
|
|
165
|
+
) {
|
|
166
|
+
Ok(frame) => frame,
|
|
167
|
+
Err(TransportError::NativeInputNotReady { reason })
|
|
168
|
+
if reason == "native_surface_unknown" =>
|
|
169
|
+
{
|
|
170
|
+
None
|
|
171
|
+
}
|
|
172
|
+
Err(error) => return Err(error),
|
|
173
|
+
};
|
|
174
|
+
if self.rescue_binding(expected, false)? != *expected {
|
|
175
|
+
return Err(refusal("rescue_binding_changed"));
|
|
176
|
+
}
|
|
177
|
+
Ok(frame)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/// Same original delivery boundary only. No late snapshot is taken by fire.
|
|
181
|
+
pub(super) fn snapshot_rescue_context(
|
|
182
|
+
&self,
|
|
183
|
+
target: &Target,
|
|
184
|
+
context: &EnterRescueContext,
|
|
185
|
+
rendered_payload: &str,
|
|
186
|
+
) -> Result<EnterRescueContext, TransportError> {
|
|
187
|
+
let mut result = context.clone();
|
|
188
|
+
if !context.valid_message() || digest(rendered_payload) != context.payload_sha256 {
|
|
189
|
+
return Ok(result);
|
|
190
|
+
}
|
|
191
|
+
let Some(selected) = context.target.as_ref() else {
|
|
192
|
+
return Ok(result);
|
|
193
|
+
};
|
|
194
|
+
if pane_from_target(target).as_str() != selected.pane_id {
|
|
195
|
+
return Ok(result);
|
|
196
|
+
}
|
|
197
|
+
let Some(_lock) = acquire_required(PaneInputLockRequest {
|
|
198
|
+
workspace: self.event_workspace.as_deref(),
|
|
199
|
+
target_key: &selected.pane_id,
|
|
200
|
+
operation: "enter_rescue_snapshot",
|
|
201
|
+
}) else {
|
|
202
|
+
return Ok(result);
|
|
203
|
+
};
|
|
204
|
+
let initial = match self.rescue_binding(selected, true) {
|
|
205
|
+
Ok(value) => value,
|
|
206
|
+
Err(_) => return Ok(result),
|
|
207
|
+
};
|
|
208
|
+
if (!selected.endpoint.is_empty() && initial.endpoint != selected.endpoint)
|
|
209
|
+
|| (!selected.carrier_birth.is_empty()
|
|
210
|
+
&& initial.carrier_birth != selected.carrier_birth)
|
|
211
|
+
|| (!selected.binding_nonce.is_empty()
|
|
212
|
+
&& initial.binding_nonce != selected.binding_nonce)
|
|
213
|
+
{
|
|
214
|
+
return Ok(result);
|
|
215
|
+
}
|
|
216
|
+
let frame = match self.observe_native_input(target, selected.provider) {
|
|
217
|
+
Ok(Some(frame)) => frame,
|
|
218
|
+
_ => return Ok(result),
|
|
219
|
+
};
|
|
220
|
+
if !current_marker_only(context, &frame)
|
|
221
|
+
|| marker_count(&frame.transcript, &context.marker()) != 0
|
|
222
|
+
{
|
|
223
|
+
return Ok(result);
|
|
224
|
+
}
|
|
225
|
+
if self.rescue_binding(selected, true)? != initial {
|
|
226
|
+
return Ok(result);
|
|
227
|
+
}
|
|
228
|
+
let mut expected = initial;
|
|
229
|
+
if expected.binding_nonce.is_empty() {
|
|
230
|
+
let Some(workspace) = self.event_workspace.as_deref() else {
|
|
231
|
+
return Ok(result);
|
|
232
|
+
};
|
|
233
|
+
let pane = PaneId::new(&expected.pane_id);
|
|
234
|
+
let proposed = next_pane_binding_nonce(workspace, &expected.endpoint, &pane);
|
|
235
|
+
expected.binding_nonce = self.set_pane_binding_nonce_if_unset(&pane, &proposed)?;
|
|
236
|
+
}
|
|
237
|
+
if self.rescue_binding(selected, false)? != expected {
|
|
238
|
+
return Ok(result);
|
|
239
|
+
}
|
|
240
|
+
result.target = Some(expected);
|
|
241
|
+
result.proof = Some(EnterRescueProof {
|
|
242
|
+
ui_identity: frame.identity,
|
|
243
|
+
footer_sha256: digest(&frame.footer),
|
|
244
|
+
visible_marker: true,
|
|
245
|
+
transcript_marker_absent: true,
|
|
246
|
+
owned_capsule: None,
|
|
247
|
+
});
|
|
248
|
+
Ok(result)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
pub(super) fn rescue_guarded_enter(
|
|
252
|
+
&self,
|
|
253
|
+
context: &EnterRescueContext,
|
|
254
|
+
authorize: &dyn Fn() -> bool,
|
|
255
|
+
observer: Option<&dyn SubmitObserver>,
|
|
256
|
+
) -> Result<EnterRescueOutcome, TransportError> {
|
|
257
|
+
let Some(expected) = context.target.as_ref().filter(|target| target.bound()) else {
|
|
258
|
+
return Ok(no_effect("binding_missing", false));
|
|
259
|
+
};
|
|
260
|
+
if !context.valid_message() || !context.valid_proof() {
|
|
261
|
+
return Ok(no_effect("proof_missing", false));
|
|
262
|
+
}
|
|
263
|
+
let Some(_lock) = acquire_required(PaneInputLockRequest {
|
|
264
|
+
workspace: self.event_workspace.as_deref(),
|
|
265
|
+
target_key: &expected.pane_id,
|
|
266
|
+
operation: "enter_rescue",
|
|
267
|
+
}) else {
|
|
268
|
+
return Ok(no_effect("input_lock", true));
|
|
269
|
+
};
|
|
270
|
+
let Some(first) = self.rescue_frame(expected)? else {
|
|
271
|
+
return Ok(no_effect("surface_unknown", true));
|
|
272
|
+
};
|
|
273
|
+
if accepted(context, &first) {
|
|
274
|
+
return Ok(if authorize() {
|
|
275
|
+
EnterRescueOutcome::Consumed
|
|
276
|
+
} else {
|
|
277
|
+
no_effect("canonical_changed", false)
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
if first.busy {
|
|
281
|
+
return Ok(no_effect("busy", true));
|
|
282
|
+
}
|
|
283
|
+
if !pending(context, &first) {
|
|
284
|
+
return Ok(no_effect("residue_mismatch", false));
|
|
285
|
+
}
|
|
286
|
+
std::thread::sleep(STABLE);
|
|
287
|
+
let Some(stable) = self.rescue_frame(expected)? else {
|
|
288
|
+
return Ok(no_effect("surface_unknown", true));
|
|
289
|
+
};
|
|
290
|
+
if !same_editor(&first, &stable) || !pending(context, &stable) {
|
|
291
|
+
return Ok(no_effect("composer_changed", false));
|
|
292
|
+
}
|
|
293
|
+
// Last fresh current-message/busy/binding checks are under the same lock.
|
|
294
|
+
let Some(last) = self.rescue_frame(expected)? else {
|
|
295
|
+
return Ok(no_effect("surface_unknown", true));
|
|
296
|
+
};
|
|
297
|
+
if !same_editor(&stable, &last) || !pending(context, &last) {
|
|
298
|
+
return Ok(no_effect("composer_changed", false));
|
|
299
|
+
}
|
|
300
|
+
if !authorize() {
|
|
301
|
+
return Ok(no_effect("canonical_changed", false));
|
|
302
|
+
}
|
|
303
|
+
self.run_inject_stage(
|
|
304
|
+
&tmux_send_submit_argv(&PaneId::new(&expected.pane_id), Key::Enter),
|
|
305
|
+
InjectStage::Submit,
|
|
306
|
+
)?;
|
|
307
|
+
notify_submit_observer(observer);
|
|
308
|
+
// Any failure after the key is uncertain, not a retryable no-effect.
|
|
309
|
+
let start = Instant::now();
|
|
310
|
+
while start.elapsed() < OBSERVE {
|
|
311
|
+
match self.rescue_frame(expected) {
|
|
312
|
+
Ok(Some(frame)) if accepted(context, &frame) => {
|
|
313
|
+
return Ok(EnterRescueOutcome::Entered { accepted: true })
|
|
314
|
+
}
|
|
315
|
+
Err(_) => break,
|
|
316
|
+
_ => std::thread::sleep(POLL),
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
Ok(EnterRescueOutcome::Entered { accepted: false })
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
#[cfg(test)]
|
|
324
|
+
#[path = "enter_rescue_tests.rs"]
|
|
325
|
+
mod tests;
|