@team-agent/installer 0.5.116 → 0.5.118
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/cli/mod.rs +20 -0
- package/crates/team-agent/src/leader/lease.rs +1 -18
- package/crates/team-agent/src/leader/start.rs +27 -47
- package/crates/team-agent/src/lifecycle/pane_input_lock.rs +86 -8
- package/crates/team-agent/src/lifecycle/tests/pi_session_delivery_red.rs +2 -7
- package/crates/team-agent/src/messaging/delivery.rs +102 -69
- package/crates/team-agent/src/messaging/enter_rescue.rs +334 -0
- package/crates/team-agent/src/messaging/enter_rescue_tests.rs +566 -0
- package/crates/team-agent/src/messaging/helpers.rs +1 -0
- package/crates/team-agent/src/messaging/mod.rs +1 -0
- package/crates/team-agent/src/messaging/scheduler.rs +8 -0
- package/crates/team-agent/src/messaging/tests/basic.rs +2 -0
- package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +6 -5
- package/crates/team-agent/src/messaging/tests/spine.rs +27 -28
- package/crates/team-agent/src/messaging/types.rs +1 -0
- package/crates/team-agent/src/state/repository.rs +47 -0
- package/crates/team-agent/src/tmux_backend/native_input.rs +494 -0
- package/crates/team-agent/src/tmux_backend/native_input_tests.rs +918 -0
- package/crates/team-agent/src/tmux_backend.rs +49 -6
- package/crates/team-agent/src/transport/native_input.rs +716 -0
- package/crates/team-agent/src/transport.rs +19 -0
- package/package.json +4 -4
|
@@ -174,6 +174,9 @@ impl Transport for DeliverOkTransport {
|
|
|
174
174
|
fn kind(&self) -> BackendKind {
|
|
175
175
|
BackendKind::Tmux
|
|
176
176
|
}
|
|
177
|
+
fn supports_owned_native_input(&self) -> bool {
|
|
178
|
+
true
|
|
179
|
+
}
|
|
177
180
|
fn spawn_first(
|
|
178
181
|
&self,
|
|
179
182
|
_s: &SessionName,
|
|
@@ -201,6 +204,8 @@ impl Transport for DeliverOkTransport {
|
|
|
201
204
|
_s: Key,
|
|
202
205
|
_b: bool,
|
|
203
206
|
) -> Result<InjectReport, TransportError> {
|
|
207
|
+
assert!(crate::transport::native_input::current_request().is_none(),
|
|
208
|
+
"ordinary sends must use v0.5.113 direct input, not pre-paste idle admission");
|
|
204
209
|
Ok(InjectReport {
|
|
205
210
|
stage_reached: crate::transport::InjectStage::Submit,
|
|
206
211
|
inject_verification: crate::transport::InjectVerification::CaptureContainsToken,
|
|
@@ -344,13 +349,10 @@ fn spine_delivery_skips_queued_statuses() {
|
|
|
344
349
|
);
|
|
345
350
|
}
|
|
346
351
|
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
// gates on `state.agents[recipient].status == "busy"`, NOT on turn-level agent_health=WORKING.
|
|
350
|
-
// (The prior version of this test seeded agent_health=WORKING + status="running" and asserted deferral,
|
|
351
|
-
// misreading golden — that stale lock is the root of the deferred_busy regression; corrected here.)
|
|
352
|
+
// Input is unconditional: native CLIs queue messages while working. Activity
|
|
353
|
+
// metadata must not prevent a pending message from reaching paste + Enter.
|
|
352
354
|
#[test]
|
|
353
|
-
fn
|
|
355
|
+
fn spine_delivery_busy_recipient_is_injected_without_idle_deferral() {
|
|
354
356
|
let ws = tmp_ws("deliver-busy");
|
|
355
357
|
let store = store_for(&ws);
|
|
356
358
|
let log = EventLog::new(&ws);
|
|
@@ -361,33 +363,30 @@ fn spine_delivery_busy_recipient_defers_with_event() {
|
|
|
361
363
|
|
|
362
364
|
let delivered = deliver_pending_messages(&ws, &state, &DeliverOkTransport, &log).unwrap();
|
|
363
365
|
|
|
364
|
-
assert!(
|
|
365
|
-
|
|
366
|
-
"a busy recipient's message must NOT be delivered"
|
|
367
|
-
);
|
|
368
|
-
assert_eq!(
|
|
369
|
-
message_status_of(&store, &mid),
|
|
370
|
-
"accepted",
|
|
371
|
-
"the row must stay 'accepted' (no-drop, left for a later tick)"
|
|
372
|
-
);
|
|
366
|
+
assert!(delivered.contains(&mid));
|
|
367
|
+
assert_eq!(message_status_of(&store, &mid), "delivered");
|
|
373
368
|
let events = read_event_log(&ws);
|
|
374
369
|
let deferred: Vec<_> = events
|
|
375
370
|
.iter()
|
|
376
371
|
.filter(|e| e.get("event").and_then(|v| v.as_str()) == Some("send.deferred_busy"))
|
|
377
372
|
.collect();
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
373
|
+
assert!(deferred.is_empty());
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
#[test]
|
|
377
|
+
fn spine_delivery_pi_cursor_uses_v113_direct_input_without_idle_scope() {
|
|
378
|
+
for provider in ["pi", "cursor_agent"] {
|
|
379
|
+
let ws = tmp_ws(&format!("direct-input-{provider}"));
|
|
380
|
+
let store = store_for(&ws);
|
|
381
|
+
let log = EventLog::new(&ws);
|
|
382
|
+
let mid = store
|
|
383
|
+
.create_message(Some("t"), "leader", "w1", "hi", None, true, None)
|
|
384
|
+
.unwrap();
|
|
385
|
+
let state = serde_json::json!({"agents": {"w1": {"provider": provider, "status": "running"}}});
|
|
386
|
+
let delivered = deliver_pending_messages(&ws, &state, &DeliverOkTransport, &log).unwrap();
|
|
387
|
+
assert!(delivered.contains(&mid), "{provider}");
|
|
388
|
+
assert_eq!(message_status_of(&store, &mid), "delivered");
|
|
389
|
+
}
|
|
391
390
|
}
|
|
392
391
|
|
|
393
392
|
// CONTRACT (shared-root, real-machine-driven; golden = correct-behavior baseline): an ALIVE worker
|
|
@@ -185,6 +185,53 @@ impl<'a> StateRepository<'a> {
|
|
|
185
185
|
bounded_team_view(&committed, team_key)
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
+
/// Commit a messaging delta against its exact canonical owner under state-save.
|
|
189
|
+
/// Unlike ordinary compatibility writes, owner loss cannot select raw/default
|
|
190
|
+
/// state, and a failed receiver check cannot be reapplied to another team.
|
|
191
|
+
pub(crate) fn commit_messaging_checked<F>(
|
|
192
|
+
&self,
|
|
193
|
+
intent: StateWriteIntent<'_>,
|
|
194
|
+
update: F,
|
|
195
|
+
) -> Result<(), StateError>
|
|
196
|
+
where
|
|
197
|
+
F: FnOnce(&mut Value) -> Result<(), StateError>,
|
|
198
|
+
{
|
|
199
|
+
let StateWriteIntent::MessagingDeliveryState { owner_team_id: Some(team) } = intent else {
|
|
200
|
+
return Err(StateError::SaveFailed("checked messaging requires an owner".into()));
|
|
201
|
+
};
|
|
202
|
+
#[cfg(test)]
|
|
203
|
+
test_support::before_commit();
|
|
204
|
+
super::persist::update_runtime_state(self.workspace, |latest| {
|
|
205
|
+
if team.is_empty() || !matches!(
|
|
206
|
+
super::projection::resolve_owner_team_id(latest, team),
|
|
207
|
+
super::projection::OwnerTeamResolution::Canonical(key) if key == team
|
|
208
|
+
) {
|
|
209
|
+
return Err(StateError::SaveConflict("messaging owner changed before commit".into()));
|
|
210
|
+
}
|
|
211
|
+
let nested = latest.get("teams").and_then(Value::as_object)
|
|
212
|
+
.is_some_and(|teams| teams.contains_key(team));
|
|
213
|
+
let mut selected = if nested {
|
|
214
|
+
super::projection::project_top_level_view(latest, team)
|
|
215
|
+
} else if legacy_single_team_state(latest)
|
|
216
|
+
&& (latest.get("active_team_key").and_then(Value::as_str) == Some(team)
|
|
217
|
+
|| super::projection::team_state_key(latest) == team
|
|
218
|
+
|| latest.get("session_name").and_then(Value::as_str)
|
|
219
|
+
.and_then(|session| session.strip_prefix("team-")) == Some(team))
|
|
220
|
+
{
|
|
221
|
+
latest.clone()
|
|
222
|
+
} else {
|
|
223
|
+
return Err(StateError::SaveConflict("messaging owner no longer exists".into()));
|
|
224
|
+
};
|
|
225
|
+
update(&mut selected)?;
|
|
226
|
+
Ok(if nested {
|
|
227
|
+
super::projection::merge_committed_team(latest, &selected, team)
|
|
228
|
+
} else {
|
|
229
|
+
selected
|
|
230
|
+
})
|
|
231
|
+
})?;
|
|
232
|
+
Ok(())
|
|
233
|
+
}
|
|
234
|
+
|
|
188
235
|
/// Atomically replace only the fork target row in the selected team.
|
|
189
236
|
pub(crate) fn commit_fork_agent(
|
|
190
237
|
&self,
|
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
//! Guarded legacy native-input effects. Shared contract parsers remain observation-only.
|
|
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::model::enums::Provider;
|
|
10
|
+
use crate::transport::native_input::{
|
|
11
|
+
inspect_input, pi_paste_capsule, record_evidence, EnterRescueOutcome, InputFrame,
|
|
12
|
+
NativeInputBinding, NativeInputEvidence, NativeInputPhase, NativeInputRequest,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
#[cfg(test)]
|
|
16
|
+
#[path = "native_input_tests.rs"]
|
|
17
|
+
mod tests;
|
|
18
|
+
|
|
19
|
+
const STABLE: Duration = Duration::from_millis(200);
|
|
20
|
+
const OBSERVE: Duration = Duration::from_secs(10);
|
|
21
|
+
const POLL: Duration = Duration::from_millis(50);
|
|
22
|
+
|
|
23
|
+
fn refused(reason: &str) -> TransportError {
|
|
24
|
+
TransportError::NativeInputNotReady {
|
|
25
|
+
reason: reason.to_string(),
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
fn surface_unknown(error: &TransportError) -> bool {
|
|
30
|
+
matches!(
|
|
31
|
+
error,
|
|
32
|
+
TransportError::NativeInputNotReady { reason } if reason == "native_surface_unknown"
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
pub(crate) fn digest(text: &str) -> String {
|
|
37
|
+
format!("{:x}", Sha256::digest(text.as_bytes()))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
impl TmuxBackend {
|
|
41
|
+
fn native_binding(&self, target: &Target) -> Result<NativeInputBinding, TransportError> {
|
|
42
|
+
self.query_native_binding(target, false)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
fn query_native_binding(
|
|
46
|
+
&self,
|
|
47
|
+
target: &Target,
|
|
48
|
+
allow_unset_nonce: bool,
|
|
49
|
+
) -> Result<NativeInputBinding, TransportError> {
|
|
50
|
+
let pane = pane_from_target(target);
|
|
51
|
+
let argv = self.tmux_argv(&[
|
|
52
|
+
"tmux".into(), "display-message".into(), "-p".into(), "-t".into(), pane.as_str().into(),
|
|
53
|
+
"#{socket_path}|#{pane_id}|#{pane_pid}|#{session_name}|#{window_name}|#{@team_agent_pane_binding_nonce}|#{pane_in_mode}|#{pane_height}".into(),
|
|
54
|
+
]);
|
|
55
|
+
let output = self.runner.run(&argv)?;
|
|
56
|
+
if !output.success || output.stdout.lines().count() != 1 {
|
|
57
|
+
return Err(refused("binding_query"));
|
|
58
|
+
}
|
|
59
|
+
let fields: Vec<_> = output.stdout.trim_end().split('|').collect();
|
|
60
|
+
if fields.len() != 8 {
|
|
61
|
+
return Err(refused("binding_invalid:field_count"));
|
|
62
|
+
}
|
|
63
|
+
for (invalid, field) in [
|
|
64
|
+
(!std::path::Path::new(fields[0]).is_absolute(), "endpoint"),
|
|
65
|
+
(fields[1] != pane.as_str(), "pane"),
|
|
66
|
+
(fields[3].is_empty(), "session_empty"),
|
|
67
|
+
(fields[4].is_empty(), "window_empty"),
|
|
68
|
+
(fields[5].is_empty() && !allow_unset_nonce, "nonce_empty"),
|
|
69
|
+
(fields[6] != "0", "pane_in_mode"),
|
|
70
|
+
] {
|
|
71
|
+
if invalid {
|
|
72
|
+
// Field class only: no argv, environment, business text or raw
|
|
73
|
+
// native metadata is copied into the existing refusal event.
|
|
74
|
+
return Err(refused(&format!("binding_invalid:{field}")));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
let pane_height = fields[7]
|
|
78
|
+
.parse::<u32>()
|
|
79
|
+
.map_err(|_| refused("binding_height"))?;
|
|
80
|
+
if pane_height < 10 {
|
|
81
|
+
return Err(refused("binding_height"));
|
|
82
|
+
}
|
|
83
|
+
let pid = fields[2]
|
|
84
|
+
.parse::<u32>()
|
|
85
|
+
.map_err(|_| refused("binding_pid"))?;
|
|
86
|
+
let birth = team_agent_contract::host::process::process_birth_identity(pid)
|
|
87
|
+
.map_err(|_| refused("carrier_birth"))?;
|
|
88
|
+
Ok(NativeInputBinding {
|
|
89
|
+
endpoint: fields[0].into(),
|
|
90
|
+
pane_id: fields[1].into(),
|
|
91
|
+
pane_pid: pid,
|
|
92
|
+
pane_height,
|
|
93
|
+
carrier_birth: birth,
|
|
94
|
+
session: fields[3].into(),
|
|
95
|
+
window: fields[4].into(),
|
|
96
|
+
binding_nonce: fields[5].into(),
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Called only under the required input lock. A newly launched legacy worker
|
|
101
|
+
// has no Leader lease nonce producer: initialize its exact current carrier,
|
|
102
|
+
// never overwrite an existing nonce or waive the subsequent strict reads.
|
|
103
|
+
fn prepare_native_binding(
|
|
104
|
+
&self,
|
|
105
|
+
target: &Target,
|
|
106
|
+
request: &NativeInputRequest,
|
|
107
|
+
) -> Result<NativeInputBinding, TransportError> {
|
|
108
|
+
let initial = self.query_native_binding(target, true)?;
|
|
109
|
+
if initial.pane_id != request.pane_id
|
|
110
|
+
|| initial.pane_pid != request.pane_pid
|
|
111
|
+
|| initial.session != request.session
|
|
112
|
+
|| initial.window != request.window
|
|
113
|
+
{
|
|
114
|
+
return Err(refused("selected_binding_changed"));
|
|
115
|
+
}
|
|
116
|
+
if !initial.binding_nonce.is_empty() {
|
|
117
|
+
return Ok(initial);
|
|
118
|
+
}
|
|
119
|
+
let pane = PaneId::new(&initial.pane_id);
|
|
120
|
+
let workspace = self
|
|
121
|
+
.event_workspace
|
|
122
|
+
.as_deref()
|
|
123
|
+
.ok_or_else(|| refused("workspace_missing"))?;
|
|
124
|
+
let proposed = next_pane_binding_nonce(workspace, &initial.endpoint, &pane);
|
|
125
|
+
let nonce = self.set_pane_binding_nonce_if_unset(&pane, &proposed)?;
|
|
126
|
+
let expected = NativeInputBinding {
|
|
127
|
+
binding_nonce: nonce,
|
|
128
|
+
..initial
|
|
129
|
+
};
|
|
130
|
+
let observed = self.native_binding(target)?;
|
|
131
|
+
if observed != expected {
|
|
132
|
+
return Err(refused("binding_changed_after_nonce"));
|
|
133
|
+
}
|
|
134
|
+
Ok(observed)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
fn native_frame(&self, evidence: &NativeInputEvidence) -> Result<InputFrame, TransportError> {
|
|
138
|
+
let target = Target::Pane(PaneId::new(&evidence.binding.pane_id));
|
|
139
|
+
if self.native_binding(&target)? != evidence.binding {
|
|
140
|
+
return Err(refused("binding_changed"));
|
|
141
|
+
}
|
|
142
|
+
let capture = self.capture(&target, CaptureRange::Tail(200))?;
|
|
143
|
+
let frame = inspect_input(
|
|
144
|
+
evidence.request.provider,
|
|
145
|
+
&capture.text,
|
|
146
|
+
evidence.binding.pane_height,
|
|
147
|
+
)
|
|
148
|
+
.ok_or_else(|| refused("native_surface_unknown"))?;
|
|
149
|
+
if self.native_binding(&target)? != evidence.binding {
|
|
150
|
+
return Err(refused("binding_changed"));
|
|
151
|
+
}
|
|
152
|
+
Ok(frame)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
fn native_record(&self, evidence: &NativeInputEvidence) -> Result<(), TransportError> {
|
|
156
|
+
record_evidence(evidence);
|
|
157
|
+
let workspace = self
|
|
158
|
+
.event_workspace
|
|
159
|
+
.as_deref()
|
|
160
|
+
.ok_or_else(|| refused("workspace_missing"))?;
|
|
161
|
+
crate::event_log::EventLog::new(workspace)
|
|
162
|
+
.write(
|
|
163
|
+
"native_input.evidence",
|
|
164
|
+
serde_json::to_value(evidence).map_err(std::io::Error::other)?,
|
|
165
|
+
)
|
|
166
|
+
.map(|_| ())
|
|
167
|
+
.map_err(|error| TransportError::Io(std::io::Error::other(error)))
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
fn stable_frame(
|
|
171
|
+
&self,
|
|
172
|
+
evidence: &NativeInputEvidence,
|
|
173
|
+
empty: bool,
|
|
174
|
+
) -> Result<InputFrame, TransportError> {
|
|
175
|
+
let start = Instant::now();
|
|
176
|
+
let stable_window = if empty {
|
|
177
|
+
STABLE
|
|
178
|
+
} else {
|
|
179
|
+
STABLE.max(current_paste_to_submit_floor())
|
|
180
|
+
};
|
|
181
|
+
let mut eligible: Option<(InputFrame, Instant)> = None;
|
|
182
|
+
while start.elapsed() < OBSERVE {
|
|
183
|
+
let frame = match self.native_frame(evidence) {
|
|
184
|
+
Ok(frame) => frame,
|
|
185
|
+
Err(error) if surface_unknown(&error) => {
|
|
186
|
+
// A partial redraw grants nothing and resets stability.
|
|
187
|
+
// Binding/process/capture failures still refuse immediately.
|
|
188
|
+
eligible = None;
|
|
189
|
+
std::thread::sleep(POLL);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
Err(error) => return Err(error),
|
|
193
|
+
};
|
|
194
|
+
let valid = if empty {
|
|
195
|
+
frame.empty() && !frame.busy
|
|
196
|
+
} else {
|
|
197
|
+
frame.pending(evidence)
|
|
198
|
+
};
|
|
199
|
+
if valid {
|
|
200
|
+
if let Some((previous, since)) = &eligible {
|
|
201
|
+
if previous.composer == frame.composer
|
|
202
|
+
&& previous.identity == frame.identity
|
|
203
|
+
&& previous.capsule == frame.capsule
|
|
204
|
+
&& since.elapsed() >= stable_window
|
|
205
|
+
{
|
|
206
|
+
return Ok(frame);
|
|
207
|
+
}
|
|
208
|
+
if previous.composer != frame.composer
|
|
209
|
+
|| previous.identity != frame.identity
|
|
210
|
+
|| previous.capsule != frame.capsule
|
|
211
|
+
{
|
|
212
|
+
eligible = Some((frame, Instant::now()));
|
|
213
|
+
}
|
|
214
|
+
} else {
|
|
215
|
+
eligible = Some((frame, Instant::now()));
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
eligible = None;
|
|
219
|
+
}
|
|
220
|
+
std::thread::sleep(POLL);
|
|
221
|
+
}
|
|
222
|
+
Err(refused(if empty {
|
|
223
|
+
"composer_not_ready"
|
|
224
|
+
} else {
|
|
225
|
+
"owned_paste_not_stable"
|
|
226
|
+
}))
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
fn observe_native_acceptance(
|
|
230
|
+
&self,
|
|
231
|
+
evidence: &NativeInputEvidence,
|
|
232
|
+
) -> Result<bool, TransportError> {
|
|
233
|
+
let start = Instant::now();
|
|
234
|
+
let mut last_unknown = false;
|
|
235
|
+
while start.elapsed() < OBSERVE {
|
|
236
|
+
match self.native_frame(evidence) {
|
|
237
|
+
Ok(frame) => {
|
|
238
|
+
last_unknown = false;
|
|
239
|
+
if frame.accepted(evidence) {
|
|
240
|
+
return Ok(true);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
Err(error) if surface_unknown(&error) => last_unknown = true,
|
|
244
|
+
Err(error) => return Err(error),
|
|
245
|
+
}
|
|
246
|
+
std::thread::sleep(POLL);
|
|
247
|
+
}
|
|
248
|
+
if last_unknown {
|
|
249
|
+
Err(refused("native_surface_unknown"))
|
|
250
|
+
} else {
|
|
251
|
+
Ok(false)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
pub(super) fn inject_native_input(
|
|
256
|
+
&self,
|
|
257
|
+
target: &Target,
|
|
258
|
+
text: &str,
|
|
259
|
+
bracketed: bool,
|
|
260
|
+
request: NativeInputRequest,
|
|
261
|
+
observer: Option<&dyn SubmitObserver>,
|
|
262
|
+
) -> Result<InjectReport, TransportError> {
|
|
263
|
+
let pane = pane_from_target(target);
|
|
264
|
+
let _lock = acquire_required(PaneInputLockRequest {
|
|
265
|
+
workspace: self.event_workspace.as_deref(),
|
|
266
|
+
target_key: pane.as_str(),
|
|
267
|
+
operation: "native_inject",
|
|
268
|
+
})
|
|
269
|
+
.ok_or_else(|| refused("input_lock"))?;
|
|
270
|
+
if !request.supported() {
|
|
271
|
+
return Err(refused("unsupported_profile"));
|
|
272
|
+
}
|
|
273
|
+
let binding = self.prepare_native_binding(target, &request)?;
|
|
274
|
+
let mut evidence = NativeInputEvidence {
|
|
275
|
+
request,
|
|
276
|
+
binding,
|
|
277
|
+
payload_sha256: digest(text),
|
|
278
|
+
ui_identity: String::new(),
|
|
279
|
+
footer_sha256: String::new(),
|
|
280
|
+
baseline_sha256: String::new(),
|
|
281
|
+
baseline_capsules: Vec::new(),
|
|
282
|
+
phase: NativeInputPhase::Ready,
|
|
283
|
+
capsule: None,
|
|
284
|
+
};
|
|
285
|
+
if evidence.binding.pane_id != evidence.request.pane_id
|
|
286
|
+
|| evidence.binding.pane_pid != evidence.request.pane_pid
|
|
287
|
+
|| evidence.binding.session != evidence.request.session
|
|
288
|
+
|| evidence.binding.window != evidence.request.window
|
|
289
|
+
{
|
|
290
|
+
return Err(refused("selected_binding_changed"));
|
|
291
|
+
}
|
|
292
|
+
let baseline = self.stable_frame(&evidence, true)?;
|
|
293
|
+
if baseline.transcript.contains(&evidence.request.marker()) {
|
|
294
|
+
return Err(refused("marker_in_baseline"));
|
|
295
|
+
}
|
|
296
|
+
evidence.ui_identity = baseline.identity;
|
|
297
|
+
evidence.footer_sha256 = digest(&baseline.footer);
|
|
298
|
+
evidence.baseline_sha256 = digest(&baseline.transcript);
|
|
299
|
+
evidence.baseline_capsules =
|
|
300
|
+
capsule_labels(evidence.request.provider, &baseline.transcript);
|
|
301
|
+
self.native_record(&evidence)?;
|
|
302
|
+
let buffer = owned_buffer(&evidence);
|
|
303
|
+
for argv in tmux_inject_text_argv(&pane, &buffer, text, bracketed) {
|
|
304
|
+
let stage = inject_stage_for_argv(&argv);
|
|
305
|
+
if stage == InjectStage::PasteBuffer {
|
|
306
|
+
let frame = self.native_frame(&evidence)?;
|
|
307
|
+
if !frame.empty()
|
|
308
|
+
|| frame.busy
|
|
309
|
+
|| frame.identity != evidence.ui_identity
|
|
310
|
+
|| digest(&frame.footer) != evidence.footer_sha256
|
|
311
|
+
{
|
|
312
|
+
return Err(refused("composer_changed_before_paste"));
|
|
313
|
+
}
|
|
314
|
+
evidence.phase = NativeInputPhase::MayHavePasted;
|
|
315
|
+
self.native_record(&evidence)?;
|
|
316
|
+
}
|
|
317
|
+
if stage == InjectStage::LoadBuffer {
|
|
318
|
+
self.run_inject_stage_with_stdin(&argv, stage, text)?;
|
|
319
|
+
} else {
|
|
320
|
+
self.run_inject_stage(&argv, stage)?;
|
|
321
|
+
}
|
|
322
|
+
if stage == InjectStage::PasteBuffer {
|
|
323
|
+
evidence.phase = NativeInputPhase::Pasted;
|
|
324
|
+
self.native_record(&evidence)?;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
// A capsule becomes owned only after this empty-baseline, single-paste transition.
|
|
328
|
+
let start = Instant::now();
|
|
329
|
+
while start.elapsed() < OBSERVE {
|
|
330
|
+
let frame = match self.native_frame(&evidence) {
|
|
331
|
+
Ok(frame) => frame,
|
|
332
|
+
Err(error) if surface_unknown(&error) => {
|
|
333
|
+
std::thread::sleep(POLL);
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
Err(error) => return Err(error),
|
|
337
|
+
};
|
|
338
|
+
if frame.identity == evidence.ui_identity
|
|
339
|
+
&& digest(&frame.footer) == evidence.footer_sha256
|
|
340
|
+
&& !frame.busy
|
|
341
|
+
{
|
|
342
|
+
if let Some(capsule) = &frame.capsule {
|
|
343
|
+
if evidence.capsule.is_none() && !evidence.baseline_capsules.contains(capsule) {
|
|
344
|
+
evidence.capsule = Some(capsule.clone());
|
|
345
|
+
// Bind the empty-baseline, single-owned-paste transition
|
|
346
|
+
// before stability waiting can fail. This is still
|
|
347
|
+
// Pasted, not Seen or permission to submit a key.
|
|
348
|
+
self.native_record(&evidence)?;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if frame.pending(&evidence) {
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
std::thread::sleep(POLL);
|
|
356
|
+
}
|
|
357
|
+
self.stable_frame(&evidence, false)?;
|
|
358
|
+
evidence.phase = NativeInputPhase::Seen;
|
|
359
|
+
self.native_record(&evidence)?;
|
|
360
|
+
self.native_submit(&mut evidence, observer)?;
|
|
361
|
+
let accepted = self.observe_native_acceptance(&evidence)?;
|
|
362
|
+
if accepted {
|
|
363
|
+
evidence.phase = NativeInputPhase::Accepted;
|
|
364
|
+
self.native_record(&evidence)?;
|
|
365
|
+
}
|
|
366
|
+
Ok(InjectReport {
|
|
367
|
+
stage_reached: InjectStage::Submit,
|
|
368
|
+
inject_verification: if evidence.capsule.is_some() {
|
|
369
|
+
InjectVerification::CaptureContainsNewPastedContentPrompt
|
|
370
|
+
} else {
|
|
371
|
+
InjectVerification::CaptureContainsToken
|
|
372
|
+
},
|
|
373
|
+
submit_verification: if accepted {
|
|
374
|
+
SubmitVerification::EnterSentWithoutPlaceholderCheck
|
|
375
|
+
} else {
|
|
376
|
+
SubmitVerification::SubmitConsumptionUnverified
|
|
377
|
+
},
|
|
378
|
+
turn_verification: TurnVerification::NotRequired,
|
|
379
|
+
attempts: 1,
|
|
380
|
+
submit_diagnostics: None,
|
|
381
|
+
})
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
fn native_submit(
|
|
385
|
+
&self,
|
|
386
|
+
evidence: &mut NativeInputEvidence,
|
|
387
|
+
observer: Option<&dyn SubmitObserver>,
|
|
388
|
+
) -> Result<(), TransportError> {
|
|
389
|
+
let frame = self.native_frame(evidence)?;
|
|
390
|
+
if !frame.pending(evidence) {
|
|
391
|
+
return Err(refused("composer_changed_before_enter"));
|
|
392
|
+
}
|
|
393
|
+
evidence.phase = NativeInputPhase::MayHaveSubmitted;
|
|
394
|
+
self.native_record(evidence)?;
|
|
395
|
+
self.run_inject_stage(
|
|
396
|
+
&tmux_send_submit_argv(&PaneId::new(&evidence.binding.pane_id), Key::Enter),
|
|
397
|
+
InjectStage::Submit,
|
|
398
|
+
)?;
|
|
399
|
+
notify_submit_observer(observer);
|
|
400
|
+
evidence.phase = NativeInputPhase::Submitted;
|
|
401
|
+
self.native_record(evidence)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
pub(super) fn rescue_native_input(
|
|
405
|
+
&self,
|
|
406
|
+
evidence: &NativeInputEvidence,
|
|
407
|
+
authorize: &dyn Fn() -> bool,
|
|
408
|
+
observer: Option<&dyn SubmitObserver>,
|
|
409
|
+
) -> Result<EnterRescueOutcome, TransportError> {
|
|
410
|
+
let _lock = match acquire_required(PaneInputLockRequest {
|
|
411
|
+
workspace: self.event_workspace.as_deref(),
|
|
412
|
+
target_key: &evidence.binding.pane_id,
|
|
413
|
+
operation: "enter_rescue",
|
|
414
|
+
}) {
|
|
415
|
+
Some(lock) => lock,
|
|
416
|
+
None => {
|
|
417
|
+
return Ok(EnterRescueOutcome::NoEffect {
|
|
418
|
+
reason: "input_lock",
|
|
419
|
+
reobserve: true,
|
|
420
|
+
})
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
if !evidence.request.supported() || evidence.phase < NativeInputPhase::Pasted {
|
|
424
|
+
return Ok(EnterRescueOutcome::NoEffect {
|
|
425
|
+
reason: "proof_missing",
|
|
426
|
+
reobserve: false,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
let frame = self.native_frame(evidence)?;
|
|
430
|
+
if frame.accepted(evidence) {
|
|
431
|
+
return Ok(EnterRescueOutcome::Consumed);
|
|
432
|
+
}
|
|
433
|
+
if frame.busy {
|
|
434
|
+
return Ok(EnterRescueOutcome::NoEffect {
|
|
435
|
+
reason: "busy",
|
|
436
|
+
reobserve: true,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
if !frame.pending(evidence) {
|
|
440
|
+
return Ok(EnterRescueOutcome::NoEffect {
|
|
441
|
+
reason: "residue_mismatch",
|
|
442
|
+
reobserve: false,
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
self.stable_frame(evidence, false)?;
|
|
446
|
+
if !authorize() {
|
|
447
|
+
return Ok(EnterRescueOutcome::NoEffect {
|
|
448
|
+
reason: "canonical_changed",
|
|
449
|
+
reobserve: false,
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
let mut current = evidence.clone();
|
|
453
|
+
current.phase = NativeInputPhase::Seen;
|
|
454
|
+
self.native_record(¤t)?;
|
|
455
|
+
self.native_submit(&mut current, observer)?;
|
|
456
|
+
let accepted = self.observe_native_acceptance(¤t)?;
|
|
457
|
+
if accepted {
|
|
458
|
+
current.phase = NativeInputPhase::Accepted;
|
|
459
|
+
self.native_record(¤t)?;
|
|
460
|
+
}
|
|
461
|
+
Ok(EnterRescueOutcome::Entered { accepted })
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
fn owned_buffer(evidence: &NativeInputEvidence) -> String {
|
|
466
|
+
format!(
|
|
467
|
+
"ta-owned-{}-{}",
|
|
468
|
+
evidence.request.message_id,
|
|
469
|
+
digest(&format!(
|
|
470
|
+
"{}:{}:{}",
|
|
471
|
+
evidence.binding.binding_nonce, evidence.binding.pane_id, evidence.payload_sha256
|
|
472
|
+
))
|
|
473
|
+
)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
fn capsule_labels(provider: Provider, text: &str) -> Vec<String> {
|
|
477
|
+
if provider == Provider::Pi {
|
|
478
|
+
return text
|
|
479
|
+
.match_indices("[paste #")
|
|
480
|
+
.filter_map(|(start, _)| {
|
|
481
|
+
let end = text[start..].find(']')? + start;
|
|
482
|
+
pi_paste_capsule(&text[start..=end]).map(str::to_owned)
|
|
483
|
+
})
|
|
484
|
+
.collect();
|
|
485
|
+
}
|
|
486
|
+
text.lines()
|
|
487
|
+
.filter_map(|line| {
|
|
488
|
+
let start = line.find("[Pasted text #")?;
|
|
489
|
+
let end = line[start..].find(']')? + start;
|
|
490
|
+
let label = &line[start..=end];
|
|
491
|
+
(label.len() <= 80).then(|| label.to_string())
|
|
492
|
+
})
|
|
493
|
+
.collect()
|
|
494
|
+
}
|