@team-agent/installer 0.5.82 → 0.5.84

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.
@@ -367,8 +367,8 @@ pub fn restore_entry_if_current_matches(
367
367
  if receipt.path.parent() != Some(dir) {
368
368
  return Err(RegistryRollbackError::InvalidReceipt);
369
369
  }
370
- let current = std::fs::read(&receipt.path)
371
- .map_err(|_| RegistryRollbackError::ReadFailed)?;
370
+ let current =
371
+ std::fs::read(&receipt.path).map_err(|_| RegistryRollbackError::ReadFailed)?;
372
372
  if current != receipt.bytes {
373
373
  return Ok(RegistryRollback::Superseded);
374
374
  }
@@ -377,33 +377,117 @@ pub fn restore_entry_if_current_matches(
377
377
  Ok(RegistryRollback::Restored)
378
378
  }
379
379
  Some(_) => Err(RegistryRollbackError::RestoreFailed),
380
- None if std::fs::remove_file(&receipt.path).is_ok() => {
381
- Ok(RegistryRollback::Restored)
382
- }
380
+ None if std::fs::remove_file(&receipt.path).is_ok() => Ok(RegistryRollback::Restored),
383
381
  None => Err(RegistryRollbackError::DeleteFailed),
384
382
  }
385
383
  })
386
384
  }
387
385
 
388
- fn remove_entry_if_matches(path: &Path, expected: &LeaderRegistryEntry) -> bool {
389
- with_registry_lock(|_| {
390
- let Ok(bytes) = std::fs::read(path) else {
391
- return false;
392
- };
393
- let Ok(current) = serde_json::from_slice::<LeaderRegistryEntry>(&bytes) else {
394
- return false;
395
- };
396
- current == *expected && std::fs::remove_file(path).is_ok()
397
- })
398
- .unwrap_or(false)
386
+ #[derive(Debug, Clone, PartialEq, Eq)]
387
+ pub struct RegistryPruneItem {
388
+ pub workspace: PathBuf,
389
+ pub team_key: String,
390
+ pub reason: String,
391
+ }
392
+
393
+ #[derive(Debug, Clone, PartialEq, Eq)]
394
+ pub struct RegistryPruneReport {
395
+ pub dry_run: bool,
396
+ pub candidates: Vec<RegistryPruneItem>,
397
+ pub removed: Vec<RegistryPruneItem>,
398
+ pub kept: Vec<RegistryPruneItem>,
399
+ pub skipped: Vec<RegistryPruneItem>,
400
+ pub errors: Vec<RegistryPruneItem>,
401
+ }
402
+
403
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
404
+ enum PruneAttempt {
405
+ Removed,
406
+ Skipped(&'static str),
407
+ Error,
408
+ }
409
+
410
+ fn prune_item(entry: &LeaderRegistryEntry, reason: impl Into<String>) -> RegistryPruneItem {
411
+ RegistryPruneItem {
412
+ workspace: entry.workspace.clone(),
413
+ team_key: entry.team_key.clone(),
414
+ reason: reason.into(),
415
+ }
416
+ }
417
+
418
+ fn prune_reason_from_state(entry: &LeaderRegistryEntry, state: &Value) -> Option<&'static str> {
419
+ let (status, reason) = classify_loaded(entry, state);
420
+ if status != "STALE" {
421
+ return None;
422
+ }
423
+ match reason.as_deref() {
424
+ Some("team_key_not_found") => Some("team_key_not_found"),
425
+ Some("team_not_alive") => Some("team_not_alive"),
426
+ _ => None,
427
+ }
428
+ }
429
+
430
+ fn prune_reason(
431
+ entry: &LeaderRegistryEntry,
432
+ status: &str,
433
+ reason: Option<&str>,
434
+ ) -> Option<&'static str> {
435
+ if status != "STALE" || !crate::state::persist::runtime_state_path(&entry.workspace).is_file() {
436
+ // A missing state file is unavailable rather than proof that the
437
+ // workspace was retired. Only an existing, readable canonical state
438
+ // can authorize the team-key cleanup below.
439
+ return None;
440
+ }
441
+ match reason {
442
+ Some("team_key_not_found") => Some("team_key_not_found"),
443
+ Some("team_not_alive") => Some("team_not_alive"),
444
+ _ => None,
445
+ }
446
+ }
447
+
448
+ fn prune_entry_if_current(
449
+ path: &Path,
450
+ expected: &LeaderRegistryEntry,
451
+ snapshot: &[u8],
452
+ ) -> PruneAttempt {
453
+ let result = crate::state::persist::with_runtime_state_lock_without_migrations(
454
+ &expected.workspace,
455
+ |state| {
456
+ let Some(state) = state.as_ref() else {
457
+ return PruneAttempt::Skipped("canonical_state_unavailable");
458
+ };
459
+ // Lock ordering is canonical state -> host registry. The state
460
+ // remains unchanged while the exact registry bytes are checked.
461
+ with_registry_lock(|_| {
462
+ let Ok(bytes) = std::fs::read(path) else {
463
+ return PruneAttempt::Skipped("registry_entry_missing");
464
+ };
465
+ if bytes != snapshot {
466
+ return PruneAttempt::Skipped("registry_entry_changed");
467
+ }
468
+ let Ok(current) = serde_json::from_slice::<LeaderRegistryEntry>(&bytes) else {
469
+ return PruneAttempt::Skipped("registry_entry_changed");
470
+ };
471
+ if current != *expected {
472
+ return PruneAttempt::Skipped("registry_entry_changed");
473
+ }
474
+ if prune_reason_from_state(&current, state).is_none() {
475
+ return PruneAttempt::Skipped("canonical_state_changed");
476
+ }
477
+ if std::fs::remove_file(path).is_ok() {
478
+ PruneAttempt::Removed
479
+ } else {
480
+ PruneAttempt::Error
481
+ }
482
+ })
483
+ .unwrap_or(PruneAttempt::Skipped("registry_lock_unavailable"))
484
+ },
485
+ );
486
+ result.unwrap_or(PruneAttempt::Skipped("canonical_state_unavailable"))
399
487
  }
400
488
 
401
489
  fn replace_entry_bytes_locked(path: &Path, bytes: &[u8]) -> bool {
402
- let tmp = path.with_extension(format!(
403
- "rollback-{}-{}",
404
- std::process::id(),
405
- rand_suffix()
406
- ));
490
+ let tmp = path.with_extension(format!("rollback-{}-{}", std::process::id(), rand_suffix()));
407
491
  if std::fs::write(&tmp, bytes).is_err() {
408
492
  let _ = std::fs::remove_file(&tmp);
409
493
  return false;
@@ -497,7 +581,7 @@ pub fn unregister_entry(workspace: &Path, team_key: &str) -> Option<PathBuf> {
497
581
  /// registry is a derived discovery index and unreadable files are the
498
582
  /// "STALE / UNREADABLE" dirty class rather than an error.
499
583
  #[must_use]
500
- fn read_all_entries() -> Vec<(PathBuf, LeaderRegistryEntry)> {
584
+ fn read_all_entries_with_bytes() -> Vec<(PathBuf, LeaderRegistryEntry, Vec<u8>)> {
501
585
  let Some(dir) = registry_dir() else {
502
586
  return Vec::new();
503
587
  };
@@ -517,18 +601,26 @@ fn read_all_entries() -> Vec<(PathBuf, LeaderRegistryEntry)> {
517
601
  if path.extension().and_then(|s| s.to_str()) != Some("json") {
518
602
  continue;
519
603
  }
520
- let Ok(text) = std::fs::read_to_string(&path) else {
604
+ let Ok(bytes) = std::fs::read(&path) else {
521
605
  continue;
522
606
  };
523
- let Ok(entry) = serde_json::from_str::<LeaderRegistryEntry>(&text) else {
607
+ let Ok(entry) = serde_json::from_slice::<LeaderRegistryEntry>(&bytes) else {
524
608
  continue;
525
609
  };
526
- out.push((path, entry));
610
+ out.push((path, entry, bytes));
527
611
  }
528
612
  out.sort_by(|a, b| a.0.cmp(&b.0));
529
613
  out
530
614
  }
531
615
 
616
+ #[must_use]
617
+ fn read_all_entries() -> Vec<(PathBuf, LeaderRegistryEntry)> {
618
+ read_all_entries_with_bytes()
619
+ .into_iter()
620
+ .map(|(path, entry, _bytes)| (path, entry))
621
+ .collect()
622
+ }
623
+
532
624
  /// Validate a registry entry against its canonical workspace state.
533
625
  /// Returns the wire status label — LIVE when canonical `leader_receiver`
534
626
  /// matches, STALE otherwise. This is the pruning gate for GC and the
@@ -538,11 +630,17 @@ pub fn classify(entry: &LeaderRegistryEntry) -> (&'static str, Option<String>) {
538
630
  let Ok(state) = crate::state::persist::load_runtime_state(&entry.workspace) else {
539
631
  return ("STALE", Some("workspace_no_state".to_string()));
540
632
  };
541
- let team = state
542
- .get("teams")
543
- .and_then(|v| v.as_object())
544
- .and_then(|teams| teams.get(&entry.team_key));
545
- let team = match team {
633
+ classify_loaded(entry, &state)
634
+ }
635
+
636
+ fn classify_loaded(entry: &LeaderRegistryEntry, state: &Value) -> (&'static str, Option<String>) {
637
+ // A missing or malformed `teams` root is an unavailable/legacy state,
638
+ // not proof that this registry entry's team was retired. Keep it out of
639
+ // the prune candidate set unless a valid map was independently found.
640
+ let Some(teams) = state.get("teams").and_then(Value::as_object) else {
641
+ return ("STALE", Some("teams_invalid".to_string()));
642
+ };
643
+ let team = match teams.get(&entry.team_key) {
546
644
  Some(t) => t,
547
645
  None => return ("STALE", Some("team_key_not_found".to_string())),
548
646
  };
@@ -599,47 +697,57 @@ fn tmux_pane_live(socket: &str, pane_id: &str) -> bool {
599
697
  }
600
698
  }
601
699
 
602
- /// Read all entries, classify each, and prune terminal-stale entries
603
- /// (`workspace_no_state`, `team_key_not_found`). Live entries are never
604
- /// touched. Returns the surviving (LIVE + STALE-not-yet-pruned) entries
605
- /// classified for the `leaders` command output.
606
- ///
607
- /// Design §14 step 6: "Successful scoped shutdown removes matching registry
608
- /// entry. Failed/degraded shutdown leaves entry STALE, not deleted."
609
- /// The pruning here is the GC arm — it only removes entries whose
610
- /// canonical workspace/team has no state at all, i.e. the target has been
611
- /// permanently removed. Leader-detached-but-team-alive stays STALE and
612
- /// visible so the operator can decide.
700
+ /// Explicitly prune only entries whose readable canonical state proves that
701
+ /// the team is gone or terminal. Dead panes, old epochs, unattached leaders,
702
+ /// missing/unreadable state, and probe failures remain untouched.
613
703
  #[must_use]
614
- pub fn list_validated_with_gc() -> Vec<(LeaderRegistryEntry, &'static str, Option<String>)> {
615
- let mut out = Vec::new();
616
- for (path, entry) in read_all_entries() {
617
- let (status, reason) = classify(&entry);
618
- // Terminal stale reasons that indicate the entry can never be
619
- // useful again: canonical workspace/team gone, or the leader
620
- // binding has been fully released (no receiver at all). These
621
- // are safe to prune; leader-attached-with-dead-pane and
622
- // epoch-mismatch stay visible so the operator can see them.
623
- if status == "STALE"
624
- && reason.as_deref().is_some_and(|r| {
625
- r == "workspace_no_state"
626
- || r == "team_key_not_found"
627
- || r == "leader_not_attached"
628
- || r == "team_not_alive"
629
- })
630
- {
631
- let _ = remove_entry_if_matches(&path, &entry);
704
+ pub fn prune_registry(dry_run: bool) -> RegistryPruneReport {
705
+ let mut report = RegistryPruneReport {
706
+ dry_run,
707
+ candidates: Vec::new(),
708
+ removed: Vec::new(),
709
+ kept: Vec::new(),
710
+ skipped: Vec::new(),
711
+ errors: Vec::new(),
712
+ };
713
+ let mut registry_lock_unavailable = false;
714
+ for (path, entry, snapshot) in read_all_entries_with_bytes() {
715
+ let (status, classified_reason) = classify(&entry);
716
+ let reason = prune_reason(&entry, status, classified_reason.as_deref());
717
+ let kept_reason = classified_reason.as_deref().unwrap_or("LIVE");
718
+ let Some(reason) = reason else {
719
+ report.kept.push(prune_item(&entry, kept_reason));
720
+ continue;
721
+ };
722
+ let candidate = prune_item(&entry, reason);
723
+ report.candidates.push(candidate.clone());
724
+ if dry_run {
725
+ continue;
726
+ }
727
+ if registry_lock_unavailable {
728
+ report
729
+ .skipped
730
+ .push(prune_item(&entry, "registry_lock_unavailable"));
632
731
  continue;
633
732
  }
634
- out.push((entry, status, reason));
733
+ match prune_entry_if_current(&path, &entry, &snapshot) {
734
+ PruneAttempt::Removed => report.removed.push(candidate),
735
+ PruneAttempt::Skipped(reason) => {
736
+ if reason == "registry_lock_unavailable" {
737
+ registry_lock_unavailable = true;
738
+ }
739
+ report.skipped.push(prune_item(&entry, reason));
740
+ }
741
+ PruneAttempt::Error => report.errors.push(prune_item(&entry, "remove_failed")),
742
+ }
635
743
  }
636
- out
744
+ report
637
745
  }
638
746
 
639
- /// Same as `list_validated_with_gc` but preserves all entries. `send
640
- /// --to-leader` needs to see stale entries so it can refuse with
747
+ /// Read all entries and classify each without mutating the derived index.
748
+ /// `send --to-leader` needs to see stale entries so it can refuse with
641
749
  /// `registry_stale` (and never silently fall through to
642
- /// `leader_name_not_found` just because GC ran first).
750
+ /// `leader_name_not_found` just because a listing ran first).
643
751
  #[must_use]
644
752
  pub fn list_validated_no_gc() -> Vec<(LeaderRegistryEntry, &'static str, Option<String>)> {
645
753
  read_all_entries()
@@ -650,3 +758,126 @@ pub fn list_validated_no_gc() -> Vec<(LeaderRegistryEntry, &'static str, Option<
650
758
  })
651
759
  .collect()
652
760
  }
761
+
762
+ #[cfg(test)]
763
+ mod tests {
764
+ #![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
765
+
766
+ use super::*;
767
+ use serde_json::json;
768
+ use serial_test::serial;
769
+ use std::sync::atomic::{AtomicU64, Ordering};
770
+
771
+ static TEST_SEQ: AtomicU64 = AtomicU64::new(0);
772
+
773
+ struct HomeGuard(Option<std::ffi::OsString>);
774
+
775
+ impl Drop for HomeGuard {
776
+ fn drop(&mut self) {
777
+ if let Some(home) = self.0.take() {
778
+ std::env::set_var("HOME", home);
779
+ } else {
780
+ std::env::remove_var("HOME");
781
+ }
782
+ }
783
+ }
784
+
785
+ fn test_home(label: &str) -> (HomeGuard, PathBuf, PathBuf) {
786
+ let n = TEST_SEQ.fetch_add(1, Ordering::Relaxed);
787
+ let home =
788
+ std::env::temp_dir().join(format!("ta-registry-{label}-{}-{n}", std::process::id()));
789
+ let workspace = home.join("workspace");
790
+ std::fs::create_dir_all(&workspace).expect("create workspace");
791
+ let previous = std::env::var_os("HOME");
792
+ std::env::set_var("HOME", &home);
793
+ (HomeGuard(previous), home, workspace)
794
+ }
795
+
796
+ fn write_state(workspace: &Path, state: Value) {
797
+ let path = crate::state::persist::runtime_state_path(workspace);
798
+ std::fs::create_dir_all(path.parent().expect("runtime parent")).expect("create runtime");
799
+ std::fs::write(
800
+ path,
801
+ serde_json::to_vec_pretty(&state).expect("serialize state"),
802
+ )
803
+ .expect("write state");
804
+ }
805
+
806
+ fn entry(workspace: &Path, team_key: &str) -> LeaderRegistryEntry {
807
+ build_entry(
808
+ workspace,
809
+ team_key,
810
+ "direct_tmux",
811
+ json!({"pane_id": "%fixture"}),
812
+ 1,
813
+ "test",
814
+ "2026-09-16T00:00:00Z".to_string(),
815
+ )
816
+ }
817
+
818
+ #[test]
819
+ #[serial]
820
+ fn prune_removes_team_proven_terminal() {
821
+ let (_home_guard, _home, workspace) = test_home("terminal");
822
+ write_state(
823
+ &workspace,
824
+ json!({"teams": {"retired": {"status": "stopped"}}}),
825
+ );
826
+ let registered = entry(&workspace, "retired");
827
+ let path = write_entry_best_effort(&registered).expect("register terminal team");
828
+
829
+ let report = prune_registry(false);
830
+ assert_eq!(report.candidates.len(), 1);
831
+ assert_eq!(report.removed.len(), 1);
832
+ assert!(report.skipped.is_empty());
833
+ assert!(!path.exists(), "terminal registry entry must be removed");
834
+ }
835
+
836
+ #[test]
837
+ #[serial]
838
+ fn prune_removes_missing_key_only_from_valid_teams_map() {
839
+ let (_home_guard, _home, workspace) = test_home("missing-key");
840
+ write_state(&workspace, json!({"teams": {"other": {"status": "alive"}}}));
841
+ let registered = entry(&workspace, "gone");
842
+ let path = write_entry_best_effort(&registered).expect("register missing team");
843
+
844
+ let report = prune_registry(false);
845
+ assert_eq!(report.removed.len(), 1);
846
+ assert!(
847
+ !path.exists(),
848
+ "missing key in valid teams map is removable"
849
+ );
850
+ }
851
+
852
+ #[test]
853
+ #[serial]
854
+ fn prune_keeps_null_nonobject_and_legacy_teams_shapes() {
855
+ for (label, state) in [
856
+ ("null", json!({"teams": null})),
857
+ ("array", json!({"teams": []})),
858
+ ("string", json!({"teams": "legacy"})),
859
+ (
860
+ "single",
861
+ json!({"team_key": "retired", "status": "stopped"}),
862
+ ),
863
+ ] {
864
+ let (_home_guard, _home, workspace) = test_home(label);
865
+ write_state(&workspace, state);
866
+ let registered = entry(&workspace, "retired");
867
+ let path = write_entry_best_effort(&registered).expect("register malformed state");
868
+
869
+ let report = prune_registry(false);
870
+ assert!(
871
+ report.candidates.is_empty(),
872
+ "malformed shape became candidate: {label}"
873
+ );
874
+ assert_eq!(
875
+ report.kept.len(),
876
+ 1,
877
+ "malformed shape must be retained: {label}"
878
+ );
879
+ assert_eq!(report.kept[0].reason, "teams_invalid");
880
+ assert!(path.exists(), "malformed state entry must remain: {label}");
881
+ }
882
+ }
883
+ }
@@ -163,30 +163,28 @@ fn verify_cleanup_surviving_receipt(
163
163
  assert!(!receipt.exists(), "cleanup must remove only the exact receipt path");
164
164
  }
165
165
 
166
- // ── #36 report_result setdefault: populated envelope keys WIN over args ─────
167
- // GOLDEN (probe_setdefault.py): envelope {agent_id:env-agent, task_id:env-task,...}
168
- // + explicit args agent_id=ARG-agent, task_id=ARG-task → returned dict keeps
169
- // env-agent / env-task (setdefault). Rust unconditionally insert-overrides.
166
+ // ── #36 report_result identity: neither envelope nor args can override capture ─
170
167
  #[test]
171
- fn report_result_setdefault_envelope_wins_over_args() {
168
+ fn report_result_rejects_envelope_and_argument_identity_mismatch() {
172
169
  let tools = TeamOrchestratorTools::with_identity(
173
170
  &unique_ws("report-setdefault"),
174
171
  Some(AgentId::new("env-id")),
175
172
  None,
176
173
  );
177
- let ok = tools.report_result(
178
- Some(&json!({
179
- "agent_id": "env-agent", "task_id": "env-task",
180
- "status": "blocked", "summary": "env summary"
181
- })),
182
- Some("ARG summary"), ResultStatus::Success,
183
- None, None, None, None, None,
184
- Some("ARG-task"), Some("ARG-agent"),
185
- ).expect("report ok");
186
- let v = serde_json::to_value(&ok).unwrap();
187
- // setdefault: the pre-populated envelope values win.
188
- assert_eq!(v.get("agent_id"), Some(&json!("env-agent")), "envelope agent_id wins (setdefault)");
189
- assert_eq!(v.get("task_id"), Some(&json!("env-task")), "envelope task_id wins (setdefault)");
174
+ let error = tools
175
+ .report_result(
176
+ Some(&json!({
177
+ "agent_id": "env-agent", "task_id": "env-task",
178
+ "status": "blocked", "summary": "env summary"
179
+ })),
180
+ Some("ARG summary"), ResultStatus::Success,
181
+ None, None, None, None, None,
182
+ Some("ARG-task"), Some("ARG-agent"),
183
+ )
184
+ .expect_err("envelope and argument identities must be bound to the worker capture");
185
+ assert_eq!(error.reason, ToolErrorReason::McpScopeRefused);
186
+ assert!(error.message.contains("identity_mismatch"));
187
+ assert_eq!(error.extra.get("expected_agent_id"), Some(&json!("env-id")));
190
188
  }
191
189
 
192
190
  // ── #44 report_result task_id inference from state (_latest_task_for_assignee)
@@ -25,31 +25,42 @@
25
25
  }
26
26
 
27
27
  #[test]
28
- fn report_result_explicit_agent_overrides_env() {
28
+ fn report_result_rejects_explicit_agent_mismatch() {
29
29
  let tools = TeamOrchestratorTools::with_identity(
30
- &unique_ws("report-explicit"),
30
+ &unique_ws("report-explicit-mismatch"),
31
31
  Some(AgentId::new("worker-7")),
32
32
  Some(TeamKey::new("teamA")),
33
33
  );
34
- let ok = tools.report_result(
35
- None, Some("done"), ResultStatus::Success,
36
- None, None, None, None, None,
37
- Some("task-9"), Some("explicit-agent"),
38
- ).expect("report ok");
39
- let v = serde_json::to_value(&ok).unwrap();
40
- // explicit > env: both task_id and agent_id are ok-whitelist keys
41
- // (normalize.py:45-46) echoed by runtime.report_result, so present on success.
42
- // UNCONDITIONAL asserts — the override must be proven, not silently skipped.
43
- assert_eq!(
44
- v.get("agent_id"),
45
- Some(&json!("explicit-agent")),
46
- "explicit agent_id overrides env"
47
- );
48
- assert_eq!(
49
- v.get("task_id"),
50
- Some(&json!("task-9")),
51
- "explicit task_id flows through"
34
+ let error = tools
35
+ .report_result(
36
+ None, Some("done"), ResultStatus::Success,
37
+ None, None, None, None, None,
38
+ Some("task-9"), Some("explicit-agent"),
39
+ )
40
+ .expect_err("a request agent_id cannot override the captured identity");
41
+ assert_eq!(error.reason, ToolErrorReason::McpScopeRefused);
42
+ assert!(error.message.contains("identity_mismatch"));
43
+ assert_eq!(error.extra.get("expected_agent_id"), Some(&json!("worker-7")));
44
+ assert_eq!(error.extra.get("provided_agent_id"), Some(&json!("explicit-agent")));
45
+ }
46
+
47
+ #[test]
48
+ fn report_result_accepts_explicit_captured_agent() {
49
+ let tools = TeamOrchestratorTools::with_identity(
50
+ &unique_ws("report-explicit-match"),
51
+ Some(AgentId::new("worker-7")),
52
+ Some(TeamKey::new("teamA")),
52
53
  );
54
+ let ok = tools
55
+ .report_result(
56
+ None, Some("done"), ResultStatus::Success,
57
+ None, None, None, None, None,
58
+ Some("task-9"), Some("worker-7"),
59
+ )
60
+ .expect("matching explicit agent_id remains compatible");
61
+ let v = serde_json::to_value(&ok).unwrap();
62
+ assert_eq!(v.get("agent_id"), Some(&json!("worker-7")));
63
+ assert_eq!(v.get("task_id"), Some(&json!("task-9")));
53
64
  }
54
65
 
55
66
  #[test]
@@ -264,6 +264,36 @@ fn dispatch_unknown_tool_returns_unknown_tool_error() {
264
264
  assert_eq!(err.message, "unknown tool 'nope'");
265
265
  }
266
266
 
267
+ #[test]
268
+ fn worker_identity_is_bound_before_send_or_report_dispatch() {
269
+ let tools = TeamOrchestratorTools::with_identity(
270
+ Path::new("/tmp/ws"),
271
+ Some(AgentId::new("worker-7")),
272
+ None,
273
+ );
274
+ let send = dispatch(
275
+ &tools,
276
+ &json!({
277
+ "tool": "send_message",
278
+ "arguments": {"to": "leader", "content": "spoof", "agent_id": "in-dev2"}
279
+ }),
280
+ )
281
+ .expect_err("send_message must reject a spoofed request identity");
282
+ assert!(send.message.contains("identity_mismatch"));
283
+ assert_eq!(send.extra.get("expected_agent_id"), Some(&json!("worker-7")));
284
+
285
+ let report = dispatch(
286
+ &tools,
287
+ &json!({
288
+ "tool": "report_result",
289
+ "arguments": {"envelope": {"agent_id": "in-dev2", "summary": "spoof"}}
290
+ }),
291
+ )
292
+ .expect_err("report_result envelope must not override captured identity");
293
+ assert!(report.message.contains("identity_mismatch"));
294
+ assert_eq!(report.extra.get("provided_agent_id"), Some(&json!("in-dev2")));
295
+ }
296
+
267
297
  // ════════════════════════════════════════════════════════════════════════
268
298
  // requires_ack_for_target — leader-only → false (tools.py:16)
269
299
  // ════════════════════════════════════════════════════════════════════════