@team-agent/installer 0.5.75 → 0.5.77

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.
Files changed (30) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/README.md +9 -0
  4. package/README.zh.md +8 -0
  5. package/crates/team-agent/src/cli/adapters.rs +23 -96
  6. package/crates/team-agent/src/cli/emit.rs +35 -7
  7. package/crates/team-agent/src/cli/mod.rs +63 -42
  8. package/crates/team-agent/src/cli/models.rs +138 -27
  9. package/crates/team-agent/src/cli/status_port/brief.rs +1137 -0
  10. package/crates/team-agent/src/cli/status_port.rs +8 -0
  11. package/crates/team-agent/src/cli/tests/mod.rs +1 -0
  12. package/crates/team-agent/src/cli/tests/run_delegation.rs +29 -24
  13. package/crates/team-agent/src/cli/tests/status_brief.rs +691 -0
  14. package/crates/team-agent/src/cli/tests/status_send.rs +8 -2
  15. package/crates/team-agent/src/leader/lease.rs +46 -8
  16. package/crates/team-agent/src/leader/types.rs +4 -0
  17. package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +81 -4
  18. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +11 -4
  19. package/crates/team-agent/src/lifecycle/tests/b0_reader_hideone_audit_contract.rs +45 -14
  20. package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +1 -0
  21. package/crates/team-agent/src/lifecycle/tests/harvest2_a_batch_red.rs +1 -1
  22. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +24 -28
  23. package/crates/team-agent/src/lifecycle/tests/upgrade_compat_0211_red.rs +22 -10
  24. package/crates/team-agent/src/lifecycle/types.rs +4 -0
  25. package/crates/team-agent/src/messaging/watchers.rs +48 -4
  26. package/crates/team-agent/src/state/paths.rs +17 -2
  27. package/crates/team-agent/src/state/projection.rs +18 -2
  28. package/crates/team-agent/src/state/selector.rs +23 -3
  29. package/crates/team-agent/src/test_support/models_timeout_fixture.rs +122 -0
  30. package/package.json +4 -4
@@ -0,0 +1,1137 @@
1
+ //! Seven-field, read-only status projection backed by one bounded nodeprobe sample.
2
+ //!
3
+ //! This intentionally does not reuse RuntimeSnapshot: the legacy snapshot reads
4
+ //! coordinator/db/history and carries diagnostic fields that are outside the
5
+ //! status brief contract.
6
+
7
+ use serde::Deserialize;
8
+ use serde_json::{json, Map, Value};
9
+ use sha2::{Digest, Sha256};
10
+ use std::collections::BTreeMap;
11
+ use std::fs::{File, OpenOptions};
12
+ use std::io::Read;
13
+ use std::path::{Path, PathBuf};
14
+ use std::process::{Child, Command, ExitStatus, Stdio};
15
+ use std::sync::atomic::{AtomicBool, Ordering};
16
+ use std::sync::mpsc;
17
+ #[cfg(test)]
18
+ use std::sync::Arc;
19
+ use std::thread;
20
+ use std::time::{Duration, Instant};
21
+
22
+ const NODEPROBE_TIMEOUT: Duration = Duration::from_secs(2);
23
+ const NODEPROBE_OUTPUT_LIMIT: u64 = 1024 * 1024;
24
+ const NODEPROBE_KILL_GRACE: Duration = Duration::from_millis(100);
25
+ const NODEPROBE_RECEIPT_LIMIT: u64 = 64 * 1024;
26
+ const NODEPROBE_BINARY_LIMIT: u64 = 64 * 1024 * 1024;
27
+ const NODEPROBE_READ_CHUNK: usize = 64 * 1024;
28
+ const NODEPROBE_NAME: &str = "nodeprobe";
29
+ const NODEPROBE_RECEIPT_SUFFIX: &str = ".capability.json";
30
+ const NODEPROBE_RECEIPT_SCHEMA: &str = "nodeprobe-capability-v1";
31
+ const NODEPROBE_SOURCE_REPO: &str = "Florious95/team-agent-scratch/nodeprobe";
32
+ const NODEPROBE_SOURCE_COMMIT: &str = "ff316dc0afe8ab280e61d30934e7624579be6224";
33
+ const NODEPROBE_SOURCE_TREE: &str = "5217a41aa914ddcb72c27f39f1b4af9ead68b1b6";
34
+ const NODEPROBE_REPORT_SCHEMA: u64 = 1;
35
+ const NODEPROBE_CAPABILITIES: &[&str] = &["tmux.list-panes", "ps.pid_ppid_stat_comm"];
36
+ const NODEPROBE_FORBIDDEN: &[&str] = &[
37
+ "tmux.capture-pane",
38
+ "tmux.attach",
39
+ "tmux.send-keys",
40
+ "process.argv",
41
+ "pane_body",
42
+ ];
43
+
44
+ #[cfg(test)]
45
+ thread_local! {
46
+ static TEST_NODEPROBE: std::cell::RefCell<Option<Vec<PathBuf>>> = const { std::cell::RefCell::new(None) };
47
+ static TEST_RESOLVER_DELAY: std::cell::RefCell<Option<TestResolverDelay>> = const { std::cell::RefCell::new(None) };
48
+ }
49
+
50
+ static NODEPROBE_RESOLVER_ACTIVE: AtomicBool = AtomicBool::new(false);
51
+
52
+ #[derive(Clone, Debug, PartialEq, Eq)]
53
+ struct RegisteredNode {
54
+ name: String,
55
+ provider: String,
56
+ endpoint: Option<String>,
57
+ session: Option<String>,
58
+ window: Option<String>,
59
+ pane: Option<String>,
60
+ lifecycle: Option<String>,
61
+ }
62
+
63
+ #[derive(Clone, Debug, PartialEq, Eq)]
64
+ struct ProbeNode {
65
+ socket: String,
66
+ session: String,
67
+ window: String,
68
+ pane: String,
69
+ provider: String,
70
+ activity: String,
71
+ health: String,
72
+ session_name: Option<String>,
73
+ evidence_method: Option<String>,
74
+ }
75
+
76
+ #[derive(Clone, Debug, PartialEq, Eq)]
77
+ struct BriefNode {
78
+ name: String,
79
+ provider: String,
80
+ runtime_status: String,
81
+ activity: String,
82
+ health: String,
83
+ session_name: Option<String>,
84
+ tmux_command: Option<String>,
85
+ }
86
+
87
+ #[derive(Debug)]
88
+ struct ProbeResult {
89
+ nodes: Vec<ProbeNode>,
90
+ }
91
+
92
+ #[derive(Debug, Deserialize)]
93
+ struct NodeprobeCapabilityReceipt {
94
+ schema: String,
95
+ binary: String,
96
+ binary_sha256: String,
97
+ source_repo: String,
98
+ source_commit: String,
99
+ source_tree: String,
100
+ target: String,
101
+ report_schema: u64,
102
+ capabilities: Vec<String>,
103
+ forbidden: Vec<String>,
104
+ }
105
+
106
+ enum MatchResult<'a> {
107
+ Missing,
108
+ Unique(&'a ProbeNode),
109
+ Ambiguous,
110
+ }
111
+
112
+ /// The CLI's concise JSON projection. `agent` filters the registered list but
113
+ /// never synthesizes an unregistered node.
114
+ pub(crate) fn status_brief_scoped(
115
+ _workspace: &Path,
116
+ state: &Value,
117
+ agent: Option<&str>,
118
+ ) -> Value {
119
+ let registered = registered_nodes(state, agent);
120
+ let probes = probe_registered_endpoints(&registered);
121
+ let nodes = registered
122
+ .iter()
123
+ .map(|node| project_node(node, probes.get(node.endpoint.as_deref().unwrap_or(""))))
124
+ .collect::<Vec<_>>();
125
+ json!({
126
+ "nodes": nodes.into_iter().map(BriefNode::into_json).collect::<Vec<_>>()
127
+ })
128
+ }
129
+
130
+ /// Human output is the exact same seven-field projection as JSON, rendered in
131
+ /// stable field order. Null values remain explicit rather than becoming a
132
+ /// diagnostic or an inferred value.
133
+ pub(crate) fn format_status_brief(
134
+ workspace: &Path,
135
+ state: &Value,
136
+ agent: Option<&str>,
137
+ ) -> String {
138
+ let value = status_brief_scoped(workspace, state, agent);
139
+ value
140
+ .get("nodes")
141
+ .and_then(Value::as_array)
142
+ .into_iter()
143
+ .flatten()
144
+ .filter_map(format_brief_node)
145
+ .collect::<Vec<_>>()
146
+ .join("\n")
147
+ }
148
+
149
+ pub(crate) fn registered_agent_exists(state: &Value, agent: &str) -> bool {
150
+ registered_nodes(state, None)
151
+ .iter()
152
+ .any(|node| node.name == agent)
153
+ }
154
+
155
+ #[cfg(test)]
156
+ pub(crate) fn with_test_nodeprobe<R>(path: PathBuf, func: impl FnOnce() -> R) -> R {
157
+ with_test_nodeprobe_candidates(vec![path], func)
158
+ }
159
+
160
+ #[cfg(test)]
161
+ pub(crate) fn with_test_nodeprobe_unbound<R>(path: PathBuf, func: impl FnOnce() -> R) -> R {
162
+ with_test_nodeprobe_candidate(vec![path], func)
163
+ }
164
+
165
+ #[cfg(test)]
166
+ pub(crate) fn with_test_nodeprobe_candidates<R>(
167
+ paths: Vec<PathBuf>,
168
+ func: impl FnOnce() -> R,
169
+ ) -> R {
170
+ for path in &paths {
171
+ write_test_capability_receipt(path);
172
+ }
173
+ with_test_nodeprobe_candidate(paths, func)
174
+ }
175
+
176
+ #[cfg(test)]
177
+ #[derive(Clone)]
178
+ pub(crate) struct ResolverDelaySignal {
179
+ entered: Arc<AtomicBool>,
180
+ finished: Arc<AtomicBool>,
181
+ }
182
+
183
+ #[cfg(test)]
184
+ impl ResolverDelaySignal {
185
+ pub(crate) fn entered(&self) -> bool {
186
+ self.entered.load(Ordering::Acquire)
187
+ }
188
+
189
+ pub(crate) fn finished(&self) -> bool {
190
+ self.finished.load(Ordering::Acquire)
191
+ }
192
+ }
193
+
194
+ #[cfg(test)]
195
+ #[derive(Clone)]
196
+ struct TestResolverDelay {
197
+ delay: Duration,
198
+ signal: ResolverDelaySignal,
199
+ }
200
+
201
+ #[cfg(test)]
202
+ pub(crate) fn with_test_nodeprobe_resolver_delay<R>(
203
+ delay: Duration,
204
+ func: impl FnOnce(&ResolverDelaySignal) -> R,
205
+ ) -> R {
206
+ struct Guard(Option<TestResolverDelay>);
207
+ impl Drop for Guard {
208
+ fn drop(&mut self) {
209
+ TEST_RESOLVER_DELAY.with(|slot| *slot.borrow_mut() = self.0.take());
210
+ }
211
+ }
212
+ let config = TestResolverDelay {
213
+ delay,
214
+ signal: ResolverDelaySignal {
215
+ entered: Arc::new(AtomicBool::new(false)),
216
+ finished: Arc::new(AtomicBool::new(false)),
217
+ },
218
+ };
219
+ let signal = config.signal.clone();
220
+ let previous = TEST_RESOLVER_DELAY.with(|slot| slot.replace(Some(config)));
221
+ let _guard = Guard(previous);
222
+ func(&signal)
223
+ }
224
+
225
+ #[cfg(test)]
226
+ fn with_test_nodeprobe_candidate<R>(
227
+ paths: Vec<PathBuf>,
228
+ func: impl FnOnce() -> R,
229
+ ) -> R {
230
+ struct Guard(Option<Vec<PathBuf>>);
231
+ impl Drop for Guard {
232
+ fn drop(&mut self) {
233
+ TEST_NODEPROBE.with(|slot| *slot.borrow_mut() = self.0.take());
234
+ }
235
+ }
236
+ let previous = TEST_NODEPROBE.with(|slot| slot.replace(Some(paths)));
237
+ let _guard = Guard(previous);
238
+ let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(func));
239
+ let deadline = Instant::now() + NODEPROBE_TIMEOUT;
240
+ while NODEPROBE_RESOLVER_ACTIVE.load(Ordering::Acquire) && Instant::now() < deadline {
241
+ thread::yield_now();
242
+ }
243
+ let resolver_idle = !NODEPROBE_RESOLVER_ACTIVE.load(Ordering::Acquire);
244
+ match outcome {
245
+ Ok(result) => {
246
+ assert!(
247
+ resolver_idle,
248
+ "test nodeprobe fixture ended while resolver worker was still active"
249
+ );
250
+ result
251
+ }
252
+ Err(payload) => std::panic::resume_unwind(payload),
253
+ }
254
+ }
255
+
256
+ #[cfg(test)]
257
+ fn write_test_capability_receipt(path: &Path) {
258
+ let binary_sha256 =
259
+ sha256_file(path, Instant::now() + NODEPROBE_TIMEOUT).expect("test nodeprobe fixture hash");
260
+ let receipt = json!({
261
+ "schema": NODEPROBE_RECEIPT_SCHEMA,
262
+ "binary": NODEPROBE_NAME,
263
+ "binary_sha256": binary_sha256,
264
+ "source_repo": NODEPROBE_SOURCE_REPO,
265
+ "source_commit": NODEPROBE_SOURCE_COMMIT,
266
+ "source_tree": NODEPROBE_SOURCE_TREE,
267
+ "target": current_nodeprobe_target().expect("test target"),
268
+ "report_schema": NODEPROBE_REPORT_SCHEMA,
269
+ "capabilities": NODEPROBE_CAPABILITIES,
270
+ "forbidden": NODEPROBE_FORBIDDEN,
271
+ });
272
+ std::fs::write(nodeprobe_receipt_path(path), serde_json::to_vec(&receipt).unwrap())
273
+ .expect("write test nodeprobe capability receipt");
274
+ }
275
+
276
+ fn format_brief_node(value: &Value) -> Option<String> {
277
+ Some(format!(
278
+ "name: {} provider: {} runtime_status: {} activity: {} health: {} session_name: {} tmux_command: {}",
279
+ value.get("name")?.as_str()?,
280
+ value.get("provider")?.as_str()?,
281
+ value.get("runtime_status")?.as_str()?,
282
+ value.get("activity")?.as_str()?,
283
+ value.get("health")?.as_str()?,
284
+ display_optional(value.get("session_name")?),
285
+ display_optional(value.get("tmux_command")?),
286
+ ))
287
+ }
288
+
289
+ fn display_optional(value: &Value) -> String {
290
+ value
291
+ .as_str()
292
+ .map(|value| value.to_string())
293
+ .unwrap_or_else(|| "null".to_string())
294
+ }
295
+
296
+ fn registered_nodes(state: &Value, selected: Option<&str>) -> Vec<RegisteredNode> {
297
+ let agents = state
298
+ .get("agents")
299
+ .and_then(Value::as_object)
300
+ .or_else(|| {
301
+ let key = state.get("active_team_key").and_then(Value::as_str)?;
302
+ state
303
+ .get("teams")
304
+ .and_then(|teams| teams.get(key))
305
+ .and_then(|team| team.get("agents"))
306
+ .and_then(Value::as_object)
307
+ });
308
+ let Some(agents) = agents else {
309
+ return Vec::new();
310
+ };
311
+ agents
312
+ .iter()
313
+ .filter(|(name, _)| selected.is_none_or(|selected| selected == name.as_str()))
314
+ .map(|(name, value)| registered_node(name, value, state))
315
+ .collect()
316
+ }
317
+
318
+ fn registered_node(name: &str, value: &Value, state: &Value) -> RegisteredNode {
319
+ let display = value.pointer("/display").unwrap_or(&Value::Null);
320
+ let target = value.pointer("/target").unwrap_or(&Value::Null);
321
+ let provider = string_at(value, &["provider"])
322
+ .or_else(|| string_at(state, &["provider"]))
323
+ .unwrap_or_else(|| "unknown".to_string());
324
+ RegisteredNode {
325
+ name: name.to_string(),
326
+ provider,
327
+ endpoint: endpoint(value).or_else(|| endpoint(state)),
328
+ session: first_string(value, &["session_name", "session"])
329
+ .or_else(|| {
330
+ first_string(
331
+ display,
332
+ &["target_worker_session", "display_session", "linked_session"],
333
+ )
334
+ })
335
+ .or_else(|| first_string(target, &["session_name", "session"]))
336
+ .or_else(|| first_string(state, &["session_name"])),
337
+ window: first_string(value, &["window_name", "window", "layout_window"])
338
+ .or_else(|| first_string(display, &["window", "window_name", "layout_window"]))
339
+ .or_else(|| first_string(target, &["window_name", "window", "layout_window"])),
340
+ pane: first_string(value, &["pane_id"])
341
+ .or_else(|| first_string(display, &["pane_id"]))
342
+ .or_else(|| first_string(target, &["pane_id"])),
343
+ lifecycle: string_at(value, &["status"]).map(|status| status.to_ascii_lowercase()),
344
+ }
345
+ }
346
+
347
+ fn endpoint(value: &Value) -> Option<String> {
348
+ first_string(value, &["tmux_endpoint", "tmux_socket", "socket"]).or_else(|| {
349
+ first_string(
350
+ value.pointer("/transport").unwrap_or(&Value::Null),
351
+ &["tmux_endpoint", "tmux_socket", "endpoint", "socket"],
352
+ )
353
+ })
354
+ }
355
+
356
+ fn string_at(value: &Value, keys: &[&str]) -> Option<String> {
357
+ first_string(value, keys)
358
+ }
359
+
360
+ fn first_string(value: &Value, keys: &[&str]) -> Option<String> {
361
+ keys.iter().find_map(|key| {
362
+ value
363
+ .get(*key)
364
+ .and_then(Value::as_str)
365
+ .filter(|value| !value.is_empty())
366
+ .map(str::to_string)
367
+ })
368
+ }
369
+
370
+ fn probe_registered_endpoints(nodes: &[RegisteredNode]) -> BTreeMap<String, Option<ProbeResult>> {
371
+ let deadline = Instant::now() + NODEPROBE_TIMEOUT;
372
+ let candidates = nodeprobe_candidates();
373
+ let resolver_options = resolver_options();
374
+ let mut endpoints = BTreeMap::new();
375
+ let mut selected_binary = None;
376
+ let mut resolved = false;
377
+ for endpoint in nodes.iter().filter_map(|node| node.endpoint.as_deref()) {
378
+ if endpoints.contains_key(endpoint) {
379
+ continue;
380
+ }
381
+ if !resolved {
382
+ selected_binary = resolve_nodeprobe_binary(
383
+ candidates.clone(),
384
+ deadline,
385
+ resolver_options.clone(),
386
+ );
387
+ resolved = true;
388
+ }
389
+ let sampled = selected_binary.as_deref().and_then(|binary| {
390
+ run_nodeprobe(binary, endpoint, deadline)
391
+ .and_then(|bytes| parse_probe(&bytes, endpoint).ok())
392
+ });
393
+ endpoints.insert(endpoint.to_string(), sampled);
394
+ }
395
+ endpoints
396
+ }
397
+
398
+ fn run_nodeprobe(binary: &Path, endpoint: &str, deadline: Instant) -> Option<Vec<u8>> {
399
+ if Instant::now() >= deadline {
400
+ return None;
401
+ }
402
+ let mut child = spawn_nodeprobe(binary, endpoint)?;
403
+ let stdout = match child.stdout.take() {
404
+ Some(stdout) => stdout,
405
+ None => {
406
+ terminate_nodeprobe(&mut child);
407
+ return None;
408
+ }
409
+ };
410
+ let (tx, rx) = mpsc::channel();
411
+ thread::spawn(move || {
412
+ let mut bytes = Vec::new();
413
+ let read = stdout
414
+ .take(NODEPROBE_OUTPUT_LIMIT + 1)
415
+ .read_to_end(&mut bytes);
416
+ let _ = tx.send(read.map(|_| bytes));
417
+ });
418
+ loop {
419
+ let now = Instant::now();
420
+ if now >= deadline {
421
+ terminate_nodeprobe(&mut child);
422
+ let _ = rx.recv_timeout(NODEPROBE_KILL_GRACE);
423
+ return None;
424
+ }
425
+ let slice = deadline
426
+ .saturating_duration_since(now)
427
+ .min(Duration::from_millis(20));
428
+ match rx.recv_timeout(slice) {
429
+ Ok(Ok(bytes)) => {
430
+ let status = wait_child_bounded(&mut child, deadline)?;
431
+ if !status.success() || bytes.len() as u64 > NODEPROBE_OUTPUT_LIMIT {
432
+ terminate_nodeprobe(&mut child);
433
+ return None;
434
+ }
435
+ return Some(bytes);
436
+ }
437
+ Ok(Err(_)) => {
438
+ terminate_nodeprobe(&mut child);
439
+ return None;
440
+ }
441
+ Err(mpsc::RecvTimeoutError::Timeout) => match child.try_wait() {
442
+ Ok(Some(status)) => {
443
+ if !status.success() {
444
+ terminate_nodeprobe(&mut child);
445
+ let leftover = deadline.saturating_duration_since(Instant::now());
446
+ let _ = rx.recv_timeout(leftover.min(NODEPROBE_KILL_GRACE));
447
+ return None;
448
+ }
449
+ }
450
+ Ok(None) => {}
451
+ Err(_) => {
452
+ terminate_nodeprobe(&mut child);
453
+ return None;
454
+ }
455
+ },
456
+ Err(mpsc::RecvTimeoutError::Disconnected) => {
457
+ terminate_nodeprobe(&mut child);
458
+ return None;
459
+ }
460
+ }
461
+ }
462
+ }
463
+
464
+ fn wait_child_bounded(child: &mut Child, deadline: Instant) -> Option<ExitStatus> {
465
+ loop {
466
+ match child.try_wait() {
467
+ Ok(Some(status)) => return Some(status),
468
+ Ok(None) if Instant::now() < deadline => thread::sleep(Duration::from_millis(5)),
469
+ Ok(None) => {
470
+ terminate_nodeprobe(child);
471
+ return None;
472
+ }
473
+ Err(_) => {
474
+ terminate_nodeprobe(child);
475
+ return None;
476
+ }
477
+ }
478
+ }
479
+ }
480
+
481
+ fn terminate_nodeprobe(child: &mut Child) {
482
+ #[cfg(unix)]
483
+ {
484
+ let pid = child.id() as i32;
485
+ let _ = unsafe { libc::killpg(pid, libc::SIGKILL) };
486
+ }
487
+ let _ = child.kill();
488
+ let deadline = Instant::now() + NODEPROBE_KILL_GRACE;
489
+ while Instant::now() < deadline {
490
+ match child.try_wait() {
491
+ Ok(Some(_)) | Err(_) => return,
492
+ Ok(None) => thread::sleep(Duration::from_millis(5)),
493
+ }
494
+ }
495
+ }
496
+
497
+ fn spawn_nodeprobe(binary: &Path, endpoint: &str) -> Option<Child> {
498
+ let flag = if Path::new(endpoint).is_absolute() {
499
+ "-S"
500
+ } else {
501
+ "-L"
502
+ };
503
+ let mut command = Command::new(binary);
504
+ command.arg(flag).arg(endpoint);
505
+ configure_nodeprobe_command(&mut command);
506
+ command.spawn().ok()
507
+ }
508
+
509
+ #[derive(Clone, Default)]
510
+ struct ResolverOptions {
511
+ #[cfg(test)]
512
+ delay_after_selection: Option<TestResolverDelay>,
513
+ }
514
+
515
+ fn resolver_options() -> ResolverOptions {
516
+ #[cfg(test)]
517
+ {
518
+ return ResolverOptions {
519
+ delay_after_selection: TEST_RESOLVER_DELAY.with(|slot| slot.borrow().clone()),
520
+ };
521
+ }
522
+ #[cfg(not(test))]
523
+ {
524
+ ResolverOptions::default()
525
+ }
526
+ }
527
+
528
+ // A resolver that is stuck in an uninterruptible filesystem syscall may outlive
529
+ // the caller deadline. It owns only its candidate work; the active gate prevents
530
+ // repeated status calls from accumulating more detached resolver threads.
531
+ struct ResolverActiveGuard {
532
+ #[cfg(test)]
533
+ finished_signal: Option<ResolverDelaySignal>,
534
+ }
535
+
536
+ impl Drop for ResolverActiveGuard {
537
+ fn drop(&mut self) {
538
+ NODEPROBE_RESOLVER_ACTIVE.store(false, Ordering::Release);
539
+ #[cfg(test)]
540
+ if let Some(signal) = self.finished_signal.take() {
541
+ signal.finished.store(true, Ordering::Release);
542
+ }
543
+ }
544
+ }
545
+
546
+ fn resolve_nodeprobe_binary(
547
+ candidates: Vec<PathBuf>,
548
+ deadline: Instant,
549
+ options: ResolverOptions,
550
+ ) -> Option<PathBuf> {
551
+ #[cfg(not(test))]
552
+ let _ = options;
553
+ if Instant::now() >= deadline
554
+ || NODEPROBE_RESOLVER_ACTIVE.swap(true, Ordering::AcqRel)
555
+ {
556
+ return None;
557
+ }
558
+ let (tx, rx) = mpsc::sync_channel(1);
559
+ let worker = thread::Builder::new()
560
+ .name("team-agent-nodeprobe-resolver".to_string())
561
+ .spawn(move || {
562
+ #[cfg(test)]
563
+ let delay_signal = options
564
+ .delay_after_selection
565
+ .as_ref()
566
+ .map(|config| config.signal.clone());
567
+ let _guard = ResolverActiveGuard {
568
+ #[cfg(test)]
569
+ finished_signal: delay_signal,
570
+ };
571
+ let result = select_nodeprobe_binary(candidates, deadline);
572
+ if result.is_some() {
573
+ #[cfg(test)]
574
+ if let Some(config) = options.delay_after_selection {
575
+ config.signal.entered.store(true, Ordering::Release);
576
+ thread::sleep(config.delay);
577
+ }
578
+ }
579
+ let _ = tx.send(result);
580
+ });
581
+ if worker.is_err() {
582
+ NODEPROBE_RESOLVER_ACTIVE.store(false, Ordering::Release);
583
+ return None;
584
+ }
585
+ let timeout = deadline.saturating_duration_since(Instant::now());
586
+ // Only the caller may turn a timely result into a child process. A late
587
+ // resolver result is dropped with the receiver and can never spawn probe.
588
+ match rx.recv_timeout(timeout) {
589
+ Ok(Some(binary)) if Instant::now() < deadline => Some(binary),
590
+ Ok(_) | Err(_) => None,
591
+ }
592
+ }
593
+
594
+ fn select_nodeprobe_binary(
595
+ candidates: Vec<PathBuf>,
596
+ deadline: Instant,
597
+ ) -> Option<PathBuf> {
598
+ for candidate in candidates {
599
+ if Instant::now() >= deadline {
600
+ return None;
601
+ }
602
+ if let Some(binary) = validate_nodeprobe_candidate(&candidate, deadline) {
603
+ return Some(binary);
604
+ }
605
+ }
606
+ None
607
+ }
608
+
609
+ fn nodeprobe_candidates() -> Vec<PathBuf> {
610
+ #[cfg(test)]
611
+ {
612
+ let override_bins = TEST_NODEPROBE.with(|slot| slot.borrow().clone());
613
+ if let Some(paths) = override_bins {
614
+ return paths;
615
+ }
616
+ }
617
+ let mut candidates = std::env::var_os("PATH")
618
+ .map(|path| {
619
+ std::env::split_paths(&path)
620
+ .map(|entry| entry.join(NODEPROBE_NAME))
621
+ .collect::<Vec<_>>()
622
+ })
623
+ .unwrap_or_default();
624
+ if let Some(home) = std::env::var_os("HOME") {
625
+ candidates.push(PathBuf::from(home).join(".local/bin").join(NODEPROBE_NAME));
626
+ }
627
+ candidates
628
+ }
629
+
630
+ fn validate_nodeprobe_candidate(path: &Path, deadline: Instant) -> Option<PathBuf> {
631
+ if Instant::now() >= deadline {
632
+ return None;
633
+ }
634
+ let metadata = std::fs::metadata(path).ok()?;
635
+ if !metadata.is_file() || !is_executable(&metadata) {
636
+ return None;
637
+ }
638
+ let receipt_path = nodeprobe_receipt_path(path);
639
+ let receipt_metadata = std::fs::metadata(&receipt_path).ok()?;
640
+ if !receipt_metadata.is_file() || receipt_metadata.len() > NODEPROBE_RECEIPT_LIMIT {
641
+ return None;
642
+ }
643
+ let receipt: NodeprobeCapabilityReceipt =
644
+ serde_json::from_slice(&read_bounded_file(
645
+ &receipt_path,
646
+ NODEPROBE_RECEIPT_LIMIT,
647
+ deadline,
648
+ )?)
649
+ .ok()?;
650
+ if !valid_nodeprobe_receipt(&receipt, path) || Instant::now() >= deadline {
651
+ return None;
652
+ }
653
+ let digest = sha256_file(path, deadline)?;
654
+ (digest == receipt.binary_sha256).then_some(path.to_path_buf())
655
+ }
656
+
657
+ fn valid_nodeprobe_receipt(receipt: &NodeprobeCapabilityReceipt, binary: &Path) -> bool {
658
+ receipt.schema == NODEPROBE_RECEIPT_SCHEMA
659
+ && binary.file_name().and_then(|name| name.to_str()) == Some(NODEPROBE_NAME)
660
+ && receipt.binary == NODEPROBE_NAME
661
+ && valid_sha256(&receipt.binary_sha256)
662
+ && receipt.source_repo == NODEPROBE_SOURCE_REPO
663
+ && receipt.source_commit == NODEPROBE_SOURCE_COMMIT
664
+ && receipt.source_tree == NODEPROBE_SOURCE_TREE
665
+ && current_nodeprobe_target().is_some_and(|target| receipt.target == target)
666
+ && receipt.report_schema == NODEPROBE_REPORT_SCHEMA
667
+ && same_string_set(&receipt.capabilities, NODEPROBE_CAPABILITIES)
668
+ && same_string_set(&receipt.forbidden, NODEPROBE_FORBIDDEN)
669
+ }
670
+
671
+ fn valid_sha256(value: &str) -> bool {
672
+ value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
673
+ }
674
+
675
+ fn same_string_set(actual: &[String], expected: &[&str]) -> bool {
676
+ let mut actual = actual.to_vec();
677
+ actual.sort_unstable();
678
+ let mut expected = expected
679
+ .iter()
680
+ .map(|value| (*value).to_string())
681
+ .collect::<Vec<_>>();
682
+ expected.sort_unstable();
683
+ actual == expected
684
+ }
685
+
686
+ fn nodeprobe_receipt_path(binary: &Path) -> PathBuf {
687
+ let name = binary
688
+ .file_name()
689
+ .and_then(|name| name.to_str())
690
+ .unwrap_or(NODEPROBE_NAME);
691
+ binary.with_file_name(format!("{name}{NODEPROBE_RECEIPT_SUFFIX}"))
692
+ }
693
+
694
+ fn read_bounded_file(path: &Path, limit: u64, deadline: Instant) -> Option<Vec<u8>> {
695
+ let metadata = std::fs::metadata(path).ok()?;
696
+ if !metadata.is_file() || metadata.len() > limit {
697
+ return None;
698
+ }
699
+ let mut file = open_readonly_nonblocking(path)?;
700
+ let mut bytes = Vec::with_capacity(metadata.len() as usize);
701
+ let mut chunk = [0u8; NODEPROBE_READ_CHUNK];
702
+ loop {
703
+ if Instant::now() >= deadline {
704
+ return None;
705
+ }
706
+ let read = file.read(&mut chunk).ok()?;
707
+ if read == 0 {
708
+ return Some(bytes);
709
+ }
710
+ bytes.extend_from_slice(&chunk[..read]);
711
+ if bytes.len() as u64 > limit {
712
+ return None;
713
+ }
714
+ }
715
+ }
716
+
717
+ fn sha256_file(path: &Path, deadline: Instant) -> Option<String> {
718
+ let metadata = std::fs::metadata(path).ok()?;
719
+ if !metadata.is_file() || metadata.len() > NODEPROBE_BINARY_LIMIT {
720
+ return None;
721
+ }
722
+ let mut file = open_readonly_nonblocking(path)?;
723
+ let mut hasher = Sha256::new();
724
+ let mut chunk = [0u8; NODEPROBE_READ_CHUNK];
725
+ let mut total = 0u64;
726
+ loop {
727
+ if Instant::now() >= deadline {
728
+ return None;
729
+ }
730
+ let read = file.read(&mut chunk).ok()?;
731
+ if read == 0 {
732
+ return Some(format!("{:x}", hasher.finalize()));
733
+ }
734
+ total = total.saturating_add(read as u64);
735
+ if total > NODEPROBE_BINARY_LIMIT {
736
+ return None;
737
+ }
738
+ hasher.update(&chunk[..read]);
739
+ }
740
+ }
741
+
742
+ fn open_readonly_nonblocking(path: &Path) -> Option<File> {
743
+ let mut options = OpenOptions::new();
744
+ options.read(true);
745
+ #[cfg(unix)]
746
+ {
747
+ use std::os::unix::fs::OpenOptionsExt;
748
+ options.custom_flags(libc::O_NONBLOCK);
749
+ }
750
+ options.open(path).ok()
751
+ }
752
+
753
+ fn is_executable(metadata: &std::fs::Metadata) -> bool {
754
+ #[cfg(unix)]
755
+ {
756
+ use std::os::unix::fs::PermissionsExt;
757
+ metadata.permissions().mode() & 0o111 != 0
758
+ }
759
+ #[cfg(not(unix))]
760
+ {
761
+ let _ = metadata;
762
+ true
763
+ }
764
+ }
765
+
766
+ fn current_nodeprobe_target() -> Option<&'static str> {
767
+ match (std::env::consts::OS, std::env::consts::ARCH) {
768
+ ("macos", "aarch64") => Some("aarch64-apple-darwin"),
769
+ ("macos", "x86_64") => Some("x86_64-apple-darwin"),
770
+ ("linux", "aarch64") => Some("aarch64-unknown-linux-gnu"),
771
+ ("linux", "x86_64") => Some("x86_64-unknown-linux-gnu"),
772
+ ("windows", "x86_64") => Some("x86_64-pc-windows-msvc"),
773
+ _ => None,
774
+ }
775
+ }
776
+
777
+ fn configure_nodeprobe_command(command: &mut Command) {
778
+ command
779
+ .stdin(Stdio::null())
780
+ .stdout(Stdio::piped())
781
+ .stderr(Stdio::null());
782
+ #[cfg(unix)]
783
+ {
784
+ use std::os::unix::process::CommandExt;
785
+ command.process_group(0);
786
+ }
787
+ }
788
+
789
+ fn parse_probe(bytes: &[u8], requested_endpoint: &str) -> Result<ProbeResult, ()> {
790
+ let value: Value = serde_json::from_slice(bytes).map_err(|_| ())?;
791
+ if value.get("schema_version").and_then(Value::as_u64) != Some(1) {
792
+ return Err(());
793
+ }
794
+ if probe_has_error(&value) {
795
+ return Err(());
796
+ }
797
+ let report_socket = value.get("socket").and_then(Value::as_str).ok_or(())?;
798
+ if report_socket != requested_endpoint {
799
+ return Err(());
800
+ }
801
+ let nodes = value
802
+ .get("nodes")
803
+ .and_then(Value::as_array)
804
+ .ok_or(())?
805
+ .iter()
806
+ .filter_map(parse_probe_node)
807
+ .collect();
808
+ Ok(ProbeResult { nodes })
809
+ }
810
+
811
+ fn probe_has_error(value: &Value) -> bool {
812
+ match value.get("error") {
813
+ None | Some(Value::Null) => false,
814
+ Some(Value::String(text)) => !text.is_empty(),
815
+ Some(Value::Array(items)) => !items.is_empty(),
816
+ Some(Value::Object(map)) => !map.is_empty(),
817
+ Some(_) => true,
818
+ }
819
+ }
820
+
821
+ fn parse_probe_node(value: &Value) -> Option<ProbeNode> {
822
+ Some(ProbeNode {
823
+ socket: non_empty(value, "socket")?,
824
+ session: non_empty(value, "session")?,
825
+ window: non_empty(value, "window_name")?,
826
+ pane: non_empty(value, "pane_id")?,
827
+ provider: non_empty(value, "provider")?,
828
+ activity: non_empty(value, "activity")?,
829
+ health: non_empty(value, "health")?,
830
+ session_name: value
831
+ .get("session_name")
832
+ .and_then(Value::as_str)
833
+ .filter(|value| !value.is_empty())
834
+ .map(str::to_string),
835
+ evidence_method: value
836
+ .pointer("/evidence/method")
837
+ .and_then(Value::as_str)
838
+ .map(str::to_string),
839
+ })
840
+ }
841
+
842
+ fn non_empty(value: &Value, key: &str) -> Option<String> {
843
+ value
844
+ .get(key)
845
+ .and_then(Value::as_str)
846
+ .filter(|value| !value.is_empty())
847
+ .map(str::to_string)
848
+ }
849
+
850
+ fn project_node(node: &RegisteredNode, probe: Option<&Option<ProbeResult>>) -> BriefNode {
851
+ let stopped = matches!(
852
+ node.lifecycle.as_deref(),
853
+ Some("stopped" | "done" | "failed" | "error" | "terminated")
854
+ );
855
+ let matched = match_result(node, probe);
856
+ if stopped {
857
+ return match matched {
858
+ MatchResult::Missing => BriefNode {
859
+ name: node.name.clone(),
860
+ provider: node.provider.clone(),
861
+ runtime_status: "stopped".to_string(),
862
+ activity: "unknown".to_string(),
863
+ health: "unknown".to_string(),
864
+ session_name: None,
865
+ tmux_command: None,
866
+ },
867
+ MatchResult::Unique(_) | MatchResult::Ambiguous => BriefNode::unknown(node),
868
+ };
869
+ }
870
+ let MatchResult::Unique(observed) = matched else {
871
+ return BriefNode::unknown(node);
872
+ };
873
+ if !node.provider.eq_ignore_ascii_case("unknown")
874
+ && !node.provider.eq_ignore_ascii_case(&observed.provider)
875
+ {
876
+ return BriefNode::unknown(node);
877
+ }
878
+ let pi_channel = observed.provider.eq_ignore_ascii_case("pi")
879
+ && observed.evidence_method.as_deref() == Some("pi_activity_channel");
880
+ let activity = if observed.provider.eq_ignore_ascii_case("pi") && !pi_channel {
881
+ "unknown"
882
+ } else {
883
+ valid_activity(&observed.activity)
884
+ };
885
+ let health = if observed.provider.eq_ignore_ascii_case("pi") && !pi_channel {
886
+ "unknown"
887
+ } else {
888
+ valid_health(&observed.health)
889
+ };
890
+ BriefNode {
891
+ name: node.name.clone(),
892
+ provider: if node.provider.eq_ignore_ascii_case("unknown") {
893
+ observed.provider.clone()
894
+ } else {
895
+ node.provider.clone()
896
+ },
897
+ runtime_status: "running".to_string(),
898
+ activity: activity.to_string(),
899
+ health: health.to_string(),
900
+ session_name: observed.session_name.clone(),
901
+ tmux_command: tmux_command(node, observed),
902
+ }
903
+ }
904
+
905
+ fn match_result<'a>(
906
+ node: &RegisteredNode,
907
+ probe: Option<&'a Option<ProbeResult>>,
908
+ ) -> MatchResult<'a> {
909
+ let Some(probe) = probe.and_then(|probe| probe.as_ref()) else {
910
+ return MatchResult::Missing;
911
+ };
912
+ let mut matches = probe
913
+ .nodes
914
+ .iter()
915
+ .filter(|observed| matches_registered(node, observed));
916
+ let Some(first) = matches.next() else {
917
+ return MatchResult::Missing;
918
+ };
919
+ if matches.next().is_some() {
920
+ MatchResult::Ambiguous
921
+ } else {
922
+ MatchResult::Unique(first)
923
+ }
924
+ }
925
+
926
+ fn matches_registered(node: &RegisteredNode, observed: &ProbeNode) -> bool {
927
+ node.endpoint.as_deref() == Some(observed.socket.as_str())
928
+ && node.session.as_deref() == Some(observed.session.as_str())
929
+ && node.window.as_deref() == Some(observed.window.as_str())
930
+ && node.pane.as_deref() == Some(observed.pane.as_str())
931
+ }
932
+
933
+ fn valid_activity(value: &str) -> &str {
934
+ match value {
935
+ "working" | "idle" => value,
936
+ _ => "unknown",
937
+ }
938
+ }
939
+
940
+ fn valid_health(value: &str) -> &str {
941
+ match value {
942
+ "normal" | "abnormal" | "unknown" => value,
943
+ _ => "unknown",
944
+ }
945
+ }
946
+
947
+ fn tmux_command(node: &RegisteredNode, observed: &ProbeNode) -> Option<String> {
948
+ let endpoint = node.endpoint.as_deref()?;
949
+ let target = format!("{}:{}.{}", observed.session, observed.window, observed.pane);
950
+ let flag = if Path::new(endpoint).is_absolute() {
951
+ "-S"
952
+ } else {
953
+ "-L"
954
+ };
955
+ Some(format!(
956
+ "tmux {flag} {} attach -t {}",
957
+ shell_quote(endpoint),
958
+ shell_quote(&target)
959
+ ))
960
+ }
961
+
962
+ fn shell_quote(value: &str) -> String {
963
+ format!("'{}'", value.replace('\'', "'\\''"))
964
+ }
965
+
966
+ impl BriefNode {
967
+ fn unknown(node: &RegisteredNode) -> Self {
968
+ Self {
969
+ name: node.name.clone(),
970
+ provider: node.provider.clone(),
971
+ runtime_status: "unknown".to_string(),
972
+ activity: "unknown".to_string(),
973
+ health: "unknown".to_string(),
974
+ session_name: None,
975
+ tmux_command: None,
976
+ }
977
+ }
978
+
979
+ fn into_json(self) -> Value {
980
+ let mut object = Map::new();
981
+ object.insert("name".to_string(), json!(self.name));
982
+ object.insert("provider".to_string(), json!(self.provider));
983
+ object.insert("runtime_status".to_string(), json!(self.runtime_status));
984
+ object.insert("activity".to_string(), json!(self.activity));
985
+ object.insert("health".to_string(), json!(self.health));
986
+ object.insert(
987
+ "session_name".to_string(),
988
+ self.session_name.map(Value::String).unwrap_or(Value::Null),
989
+ );
990
+ object.insert(
991
+ "tmux_command".to_string(),
992
+ self.tmux_command.map(Value::String).unwrap_or(Value::Null),
993
+ );
994
+ Value::Object(object)
995
+ }
996
+ }
997
+
998
+ #[cfg(test)]
999
+ mod tests {
1000
+ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
1001
+ use super::*;
1002
+
1003
+ fn registered() -> RegisteredNode {
1004
+ RegisteredNode {
1005
+ name: "worker".to_string(),
1006
+ provider: "pi".to_string(),
1007
+ endpoint: Some("/tmp/tmux socket/o'k".to_string()),
1008
+ session: Some("sess".to_string()),
1009
+ window: Some("win".to_string()),
1010
+ pane: Some("%7".to_string()),
1011
+ lifecycle: None,
1012
+ }
1013
+ }
1014
+
1015
+ fn probe(method: &str) -> ProbeNode {
1016
+ ProbeNode {
1017
+ socket: "/tmp/tmux socket/o'k".to_string(),
1018
+ session: "sess".to_string(),
1019
+ window: "win".to_string(),
1020
+ pane: "%7".to_string(),
1021
+ provider: "pi".to_string(),
1022
+ activity: "idle".to_string(),
1023
+ health: "normal".to_string(),
1024
+ session_name: Some("pi-session".to_string()),
1025
+ evidence_method: Some(method.to_string()),
1026
+ }
1027
+ }
1028
+
1029
+ fn producer_error_envelope() -> &'static [u8] {
1030
+ br#"{"schema_version":1,"socket":"/tmp/sock","sampled_at":"2026-01-01T00:00:00Z","nodes":[],"error":{"kind":"tmux_inventory","message":"missing"}}"#
1031
+ }
1032
+
1033
+ #[test]
1034
+ fn pi_pane_title_is_unknown_not_idle() {
1035
+ let node = registered();
1036
+ let observed = probe("pane_title");
1037
+ let projected = project_node(&node, Some(&Some(ProbeResult { nodes: vec![observed] })));
1038
+ assert_eq!(projected.runtime_status, "running");
1039
+ assert_eq!(projected.activity, "unknown");
1040
+ assert_eq!(projected.health, "unknown");
1041
+ }
1042
+
1043
+ #[test]
1044
+ fn exact_probe_generates_quoted_window_pane_command() {
1045
+ let node = registered();
1046
+ let observed = probe("pi_activity_channel");
1047
+ let projected = project_node(&node, Some(&Some(ProbeResult { nodes: vec![observed] })));
1048
+ assert_eq!(projected.activity, "idle");
1049
+ assert_eq!(
1050
+ projected.tmux_command.as_deref(),
1051
+ Some("tmux -S '/tmp/tmux socket/o'\\''k' attach -t 'sess:win.%7'"),
1052
+ );
1053
+ }
1054
+
1055
+ #[test]
1056
+ fn stopped_registration_is_retained_without_sample() {
1057
+ let mut node = registered();
1058
+ node.lifecycle = Some("stopped".to_string());
1059
+ let projected = project_node(&node, None);
1060
+ assert_eq!(projected.runtime_status, "stopped");
1061
+ assert_eq!(projected.activity, "unknown");
1062
+ assert!(projected.tmux_command.is_none());
1063
+ }
1064
+
1065
+ #[test]
1066
+ fn stopped_with_live_sample_is_unknown_conflict() {
1067
+ let mut node = registered();
1068
+ node.lifecycle = Some("stopped".to_string());
1069
+ let observed = probe("pi_activity_channel");
1070
+ let projected = project_node(&node, Some(&Some(ProbeResult { nodes: vec![observed] })));
1071
+ assert_eq!(projected.runtime_status, "unknown");
1072
+ assert!(projected.tmux_command.is_none());
1073
+ }
1074
+
1075
+ #[test]
1076
+ fn stopped_with_duplicate_live_samples_is_unknown_conflict() {
1077
+ let mut node = registered();
1078
+ node.lifecycle = Some("stopped".to_string());
1079
+ let projected = project_node(
1080
+ &node,
1081
+ Some(&Some(ProbeResult {
1082
+ nodes: vec![probe("pi_activity_channel"), probe("pi_activity_channel")],
1083
+ })),
1084
+ );
1085
+ assert_eq!(projected.runtime_status, "unknown");
1086
+ assert!(projected.tmux_command.is_none());
1087
+ }
1088
+
1089
+ #[test]
1090
+ fn zero_matches_is_unknown_not_panic() {
1091
+ let node = registered();
1092
+ let mut observed = probe("pi_activity_channel");
1093
+ observed.pane = "%9".to_string();
1094
+ let projected = project_node(&node, Some(&Some(ProbeResult { nodes: vec![observed] })));
1095
+ assert_eq!(projected.runtime_status, "unknown");
1096
+ }
1097
+
1098
+ #[test]
1099
+ fn multiple_matches_is_unknown_not_indexed() {
1100
+ let node = registered();
1101
+ let projected = project_node(
1102
+ &node,
1103
+ Some(&Some(ProbeResult {
1104
+ nodes: vec![probe("pi_activity_channel"), probe("pi_activity_channel")],
1105
+ })),
1106
+ );
1107
+ assert_eq!(projected.runtime_status, "unknown");
1108
+ }
1109
+
1110
+ // Frozen 1257e8e3 used `br"{\"error\":\"missing\"}"`, which is not a valid
1111
+ // raw byte string (VERIFY compile gate: unknown start of token `\\`).
1112
+ // Keep that failure as history; this regression uses a real producer envelope.
1113
+ #[test]
1114
+ fn parse_probe_rejects_producer_error_envelope() {
1115
+ assert!(parse_probe(producer_error_envelope(), "/tmp/sock").is_err());
1116
+ }
1117
+
1118
+ #[test]
1119
+ fn parse_probe_rejects_exit_zero_error_with_nodes() {
1120
+ let bytes = br#"{"schema_version":1,"socket":"/tmp/sock","sampled_at":"2026-01-01T00:00:00Z","nodes":[{"socket":"/tmp/sock","session":"sess","window_name":"win","pane_id":"%7","provider":"pi","activity":"idle","health":"normal","evidence":{"method":"pi_activity_channel"}}],"error":{"kind":"corpus_error","message":"providers corpus failed"}}"#;
1121
+ assert!(parse_probe(bytes, "/tmp/sock").is_err());
1122
+ }
1123
+
1124
+ #[test]
1125
+ fn parse_probe_rejects_wrong_report_socket() {
1126
+ let bytes = br#"{"schema_version":1,"socket":"/tmp/other","sampled_at":"2026-01-01T00:00:00Z","nodes":[]}"#;
1127
+ assert!(parse_probe(bytes, "/tmp/sock").is_err());
1128
+ }
1129
+
1130
+ #[test]
1131
+ fn parse_probe_accepts_matching_producer_report() {
1132
+ let bytes = br#"{"schema_version":1,"socket":"/tmp/sock","sampled_at":"2026-01-01T00:00:00Z","nodes":[{"socket":"/tmp/sock","session":"sess","window_name":"win","pane_id":"%7","provider":"pi","activity":"idle","health":"normal","session_name":"pi-session","evidence":{"method":"pi_activity_channel"}}]}"#;
1133
+ let parsed = parse_probe(bytes, "/tmp/sock").expect("valid producer report");
1134
+ assert_eq!(parsed.nodes.len(), 1);
1135
+ assert_eq!(parsed.nodes[0].pane, "%7");
1136
+ }
1137
+ }