@team-agent/installer 0.5.114 → 0.5.116

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 (33) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/emit.rs +2 -2
  4. package/crates/team-agent/src/cli/mod.rs +155 -19
  5. package/crates/team-agent/src/cli/models.rs +52 -1
  6. package/crates/team-agent/src/cli/shutdown_socket.rs +288 -0
  7. package/crates/team-agent/src/cli/spec.rs +1 -1
  8. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +11 -4
  9. package/crates/team-agent/src/contract_runtime/backend.rs +490 -97
  10. package/crates/team-agent/src/contract_runtime/commands.rs +326 -0
  11. package/crates/team-agent/src/contract_runtime/config.rs +39 -8
  12. package/crates/team-agent/src/contract_runtime/config_tests.rs +56 -0
  13. package/crates/team-agent/src/contract_runtime/discovery.rs +218 -40
  14. package/crates/team-agent/src/contract_runtime/framework.rs +38 -5
  15. package/crates/team-agent/src/contract_runtime/mcp.rs +3 -0
  16. package/crates/team-agent/src/contract_runtime/mod.rs +2 -0
  17. package/crates/team-agent/src/contract_runtime/pump.rs +97 -33
  18. package/crates/team-agent/src/contract_runtime/registry.rs +248 -4
  19. package/crates/team-agent/src/contract_runtime/workspace.rs +228 -0
  20. package/crates/team-agent/src/leader/start.rs +93 -5
  21. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +99 -1
  22. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +3 -5
  23. package/crates/team-agent/src/lifecycle/launch.rs +6 -0
  24. package/crates/team-agent/src/lifecycle/restart/agent.rs +4 -0
  25. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +109 -58
  26. package/crates/team-agent/src/lifecycle/restart/selection.rs +63 -7
  27. package/crates/team-agent/src/lifecycle/role_config.rs +17 -1
  28. package/crates/team-agent/src/provider/session/capture.rs +24 -1
  29. package/crates/team-agent/src/state/persist.rs +72 -2
  30. package/crates/team-agent/src/state/projection.rs +29 -0
  31. package/crates/team-agent/src/state/repository/tests.rs +70 -0
  32. package/crates/team-agent/src/state/repository.rs +5 -1
  33. package/package.json +4 -4
package/Cargo.lock CHANGED
@@ -727,7 +727,7 @@ dependencies = [
727
727
 
728
728
  [[package]]
729
729
  name = "team-agent"
730
- version = "0.5.114"
730
+ version = "0.5.116"
731
731
  dependencies = [
732
732
  "anyhow",
733
733
  "chrono",
package/Cargo.toml CHANGED
@@ -11,7 +11,7 @@ exclude = ["crates/team-agent-contract"]
11
11
 
12
12
  [workspace.package]
13
13
  edition = "2021"
14
- version = "0.5.114"
14
+ version = "0.5.116"
15
15
  license = "AGPL-3.0"
16
16
  rust-version = "1.95"
17
17
 
@@ -448,8 +448,8 @@ pub(super) fn command_help(command: Option<&str>) -> String {
448
448
  "team-agent inbox leader -n 3\nteam-agent inbox worker --limit 5\nteam-agent inbox leader --workspace . --team help-demo --json", "Wait for a natural reply, then check team-agent status if needed. Do not inspect worker terminal content."),
449
449
  "status" => ("Optional <agent> selects one agent. --summary/--detail retain compatibility and do not add fields.\nNine fields: name/provider/model/effort/runtime_status/activity/health/session_name/tmux_command.\nRuntime, activity and health are distinct. unknown means insufficient evidence; null means unset. No default model is guessed.",
450
450
  "team-agent status\nteam-agent status worker\nteam-agent status --workspace . --team help-demo --json", "Read replies with team-agent inbox leader -n 3; diagnose problems with team-agent doctor --workspace ."),
451
- "models" => ("--provider selects the provider, default pi. QUERY or --search performs keyword search; they are mutually exclusive.\nUse cursor_agent for Cursor. Copilot has no model discovery entrypoint here. Results come from the native catalog, not guesses.",
452
- "team-agent models --provider pi\nteam-agent models --provider pi --search luna\nteam-agent models --provider cursor_agent --json", "Use the exact returned model name in a role file or add-agent --model before launch."),
451
+ "models" => ("--provider selects the provider, default pi. QUERY or --search performs keyword search; they are mutually exclusive.\nUse cursor for the contract-native Cursor catalog; cursor_agent retains the legacy entrypoint. Copilot has no model discovery entrypoint here. Results come from the native catalog, not guesses.",
452
+ "team-agent models --provider pi\nteam-agent models --provider pi --search luna\nteam-agent models --provider cursor --json", "Use the exact returned model name in a role file or add-agent --model before launch."),
453
453
  "restart" => ("WORKSPACE defaults to the current project; saved sessions are preferred.\nUse --allow-fresh only after explicit authorization to lose saved context. --session-converge-deadline sets the wait in seconds.",
454
454
  "team-agent restart .\nteam-agent restart . --team help-demo --json", "Check status after restart. Obtain authorization before creating a fresh session; takeover is not a substitute for restart."),
455
455
  "shutdown" => ("Shuts down only the selected workspace/team. Logs are retained by default; --keep-logs remains compatible.\n--json reports scope, degradation and owned residuals. Success does not mean other teams were shut down.",
@@ -57,6 +57,8 @@ pub(crate) const STATUS_REMINDER: &str =
57
57
  "Next: use team-agent inbox leader -n 3 for replies. Status is not proof of task completion; do not inspect worker terminal content.";
58
58
 
59
59
  pub mod adapters;
60
+ #[cfg(unix)]
61
+ mod shutdown_socket;
60
62
  pub mod attach_app_server_leader;
61
63
  pub mod diagnose;
62
64
  pub mod emit;
@@ -1193,13 +1195,17 @@ pub mod lifecycle_port {
1193
1195
  }
1194
1196
  let workspace_socket = crate::tmux_backend::socket_name_for_workspace(workspace);
1195
1197
  let source = state.get("tmux_socket_source").and_then(Value::as_str);
1196
- let owned = source == Some("workspace")
1197
- || source.is_none()
1198
- && endpoint_matches_workspace_socket(&endpoint, workspace, &workspace_socket);
1198
+ let owned = (source == Some("workspace") || source.is_none())
1199
+ && endpoint_matches_workspace_socket(&endpoint, workspace, &workspace_socket);
1199
1200
  if !owned {
1200
1201
  return None;
1201
1202
  }
1202
1203
  let socket_file = socket_file_for_endpoint(&endpoint);
1204
+ if socket_file.as_ref().and_then(|path| path.file_name())
1205
+ != Some(std::ffi::OsStr::new(&workspace_socket))
1206
+ {
1207
+ return None;
1208
+ }
1203
1209
  Some(OwnedShutdownEndpoint {
1204
1210
  endpoint,
1205
1211
  socket_file,
@@ -1234,9 +1240,18 @@ pub mod lifecycle_port {
1234
1240
  event_log: &crate::event_log::EventLog,
1235
1241
  ) -> OwnedEndpointCleanup {
1236
1242
  let mut cleanup = OwnedEndpointCleanup::default();
1237
- cleanup.remaining_sessions = transport
1238
- .list_targets()
1239
- .unwrap_or_default()
1243
+ let targets = match transport.list_targets() {
1244
+ Ok(targets) => targets,
1245
+ Err(error) => {
1246
+ cleanup.error = Some(format!("owned endpoint session probe failed: {error}"));
1247
+ if let Some(path) = endpoint.socket_file.as_ref() {
1248
+ cleanup.skipped_files.push(path.display().to_string());
1249
+ cleanup.residual_files.push(path.display().to_string());
1250
+ }
1251
+ return cleanup;
1252
+ }
1253
+ };
1254
+ cleanup.remaining_sessions = targets
1240
1255
  .into_iter()
1241
1256
  .map(|pane| pane.session.as_str().to_string())
1242
1257
  .collect::<std::collections::BTreeSet<_>>()
@@ -1257,20 +1272,136 @@ pub mod lifecycle_port {
1257
1272
  );
1258
1273
  return cleanup;
1259
1274
  }
1260
- // Endpoint ownership is not sufficient authority to tear down a tmux
1261
- // server: an endpoint can be shared after launch. Leave both the
1262
- // server and its socket file intact; only explicitly owned sessions
1263
- // are removed above.
1275
+ // Empty enumeration is not server-death proof. Never kill-server:
1276
+ // remove only the exact workspace socket after an independent refused
1277
+ // nonblocking connection and fresh owned parent/socket inode fences.
1278
+ #[cfg(unix)]
1279
+ if let Some(path) = endpoint.socket_file.as_ref() {
1280
+ use super::shutdown_socket::{remove_dead_socket, SocketCleanup};
1281
+ match remove_dead_socket(path) {
1282
+ Ok(SocketCleanup::Removed) => {
1283
+ cleanup.removed_files.push(path.display().to_string());
1284
+ }
1285
+ Ok(SocketCleanup::Absent) => {}
1286
+ Ok(SocketCleanup::Preserved) => {
1287
+ cleanup.skipped_files.push(path.display().to_string());
1288
+ cleanup.residual_files.push(path.display().to_string());
1289
+ }
1290
+ Err(error) => {
1291
+ cleanup.skipped_files.push(path.display().to_string());
1292
+ cleanup.residual_files.push(path.display().to_string());
1293
+ cleanup.error = Some(format!("owned socket cleanup refused: {error}"));
1294
+ }
1295
+ }
1296
+ }
1264
1297
  let _ = event_log.write(
1265
- "shutdown.owned_endpoint_cleanup_skipped",
1298
+ "shutdown.owned_endpoint_cleanup",
1266
1299
  json!({
1267
1300
  "endpoint": endpoint.endpoint,
1268
- "reason": "server_and_socket_cleanup_disabled_fail_closed",
1301
+ "removed_files": cleanup.removed_files,
1302
+ "skipped_files": cleanup.skipped_files,
1303
+ "residual_files": cleanup.residual_files,
1304
+ "error": cleanup.error,
1269
1305
  }),
1270
1306
  );
1271
1307
  cleanup
1272
1308
  }
1273
1309
 
1310
+ #[cfg(all(test, unix))]
1311
+ #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
1312
+ mod shutdown_socket_tests {
1313
+ use super::*;
1314
+ use crate::transport::test_support::OfflineTransport;
1315
+ use crate::transport::{PaneId, PaneInfo};
1316
+ use std::os::unix::fs::PermissionsExt;
1317
+ use std::os::unix::net::UnixListener;
1318
+
1319
+ #[test]
1320
+ fn shutdown_socket_selection_rejects_shared_external_and_false_workspace_sources() {
1321
+ let workspace = Path::new("/tmp").join(format!("ta-shutdown-select-{}-{}",
1322
+ std::process::id(), chrono::Utc::now().timestamp_nanos_opt().unwrap()));
1323
+ std::fs::create_dir(&workspace).unwrap();
1324
+ let workspace = workspace.canonicalize().unwrap();
1325
+ let name = crate::tmux_backend::socket_name_for_workspace(&workspace);
1326
+ let transport = OfflineTransport::new();
1327
+ let selected = json!({"tmux_socket": name, "tmux_socket_source": "workspace"});
1328
+ assert!(owned_shutdown_endpoint(&workspace, &selected, &transport).is_some());
1329
+ for delta in [
1330
+ json!({"is_external_leader": true}),
1331
+ json!({"tmux_socket_source": "leader_env"}),
1332
+ json!({"tmux_socket": "default"}),
1333
+ json!({"tmux_socket": "/foreign/ta-not-this-workspace"}),
1334
+ json!({"tmux_socket_source": "shared"}),
1335
+ ] {
1336
+ let mut state = selected.clone();
1337
+ state.as_object_mut().unwrap().extend(delta.as_object().unwrap().clone());
1338
+ assert!(owned_shutdown_endpoint(&workspace, &state, &transport).is_none());
1339
+ }
1340
+ std::fs::remove_dir(workspace).unwrap();
1341
+ }
1342
+
1343
+ #[test]
1344
+ fn shutdown_socket_cleanup_removes_only_dead_socket_and_reports_live_residual() {
1345
+ let root = Path::new("/tmp").join(format!("ta-root-shutdown-{}-{}",
1346
+ std::process::id(), chrono::Utc::now().timestamp_nanos_opt().unwrap()));
1347
+ std::fs::create_dir(&root).unwrap();
1348
+ std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap();
1349
+ let root = root.canonicalize().unwrap();
1350
+ let path = root.join("owned.sock");
1351
+ let endpoint = OwnedShutdownEndpoint {
1352
+ endpoint: path.display().to_string(), socket_file: Some(path.clone()),
1353
+ };
1354
+ let log = crate::event_log::EventLog::new(&root);
1355
+ let transport = OfflineTransport::new();
1356
+ let listener = UnixListener::bind(&path).unwrap();
1357
+ let live = cleanup_owned_empty_endpoint(&transport, &endpoint, &log);
1358
+ assert_eq!(live.residual_files, vec![path.display().to_string()]);
1359
+ assert!(live.removed_files.is_empty());
1360
+ assert!(path.exists());
1361
+ drop(listener);
1362
+ let dead = cleanup_owned_empty_endpoint(&transport, &endpoint, &log);
1363
+ assert_eq!(dead.removed_files, vec![path.display().to_string()]);
1364
+ assert!(dead.residual_files.is_empty());
1365
+ assert!(dead.error.is_none());
1366
+ assert!(!path.exists());
1367
+ assert!(!transport.calls().contains(&"kill_server"));
1368
+ std::fs::remove_dir_all(root).unwrap();
1369
+ }
1370
+
1371
+ #[test]
1372
+ fn shutdown_socket_cleanup_preserves_sibling_and_unknown_session_probe() {
1373
+ let root = Path::new("/tmp").join(format!("ta-shutdown-sibling-{}-{}",
1374
+ std::process::id(), chrono::Utc::now().timestamp_nanos_opt().unwrap()));
1375
+ std::fs::create_dir(&root).unwrap();
1376
+ std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap();
1377
+ let root = root.canonicalize().unwrap();
1378
+ let path = root.join("owned.sock");
1379
+ drop(UnixListener::bind(&path).unwrap());
1380
+ let endpoint = OwnedShutdownEndpoint {
1381
+ endpoint: path.display().to_string(), socket_file: Some(path.clone()),
1382
+ };
1383
+ let log = crate::event_log::EventLog::new(&root);
1384
+ let sibling = OfflineTransport::new().with_targets(vec![PaneInfo {
1385
+ pane_id: PaneId::new("%1"), session: crate::transport::SessionName::new("foreign"),
1386
+ window_index: None, window_name: None, pane_index: None, tty: None,
1387
+ current_command: None, current_path: None, active: true, pane_pid: None,
1388
+ leader_env: std::collections::BTreeMap::new(),
1389
+ }]);
1390
+ let kept = cleanup_owned_empty_endpoint(&sibling, &endpoint, &log);
1391
+ assert_eq!(kept.remaining_sessions, vec!["foreign".to_string()]);
1392
+ assert!(kept.removed_files.is_empty());
1393
+ assert!(path.exists());
1394
+ let failed = OfflineTransport::new().with_list_targets_error("unknown");
1395
+ let refused = cleanup_owned_empty_endpoint(&failed, &endpoint, &log);
1396
+ assert!(refused.error.is_some());
1397
+ assert_eq!(refused.residual_files, vec![path.display().to_string()]);
1398
+ assert!(path.exists());
1399
+ assert!(!sibling.calls().contains(&"kill_server"));
1400
+ assert!(!failed.calls().contains(&"kill_server"));
1401
+ std::fs::remove_dir_all(root).unwrap();
1402
+ }
1403
+ }
1404
+
1274
1405
  pub fn shutdown_with_transport(
1275
1406
  workspace: &Path,
1276
1407
  keep_logs: bool,
@@ -3126,9 +3257,9 @@ pub mod lifecycle_port {
3126
3257
  session_converge_deadline_ms: Option<u64>,
3127
3258
  ) -> Result<Value, CliError> {
3128
3259
  #[cfg(unix)]
3129
- crate::contract_runtime::framework::require_legacy_lifecycle(
3130
- workspace, team, None, "restart",
3131
- )?;
3260
+ if let Some(receipt) = crate::contract_runtime::commands::restart(workspace, team)? {
3261
+ return Ok(receipt);
3262
+ }
3132
3263
  match crate::lifecycle::restart_with_session_convergence_deadline(
3133
3264
  workspace,
3134
3265
  allow_fresh,
@@ -3149,6 +3280,10 @@ pub mod lifecycle_port {
3149
3280
  team: Option<&str>,
3150
3281
  role_config: &crate::lifecycle::role_config::RoleConfigPatch,
3151
3282
  ) -> Result<Value, CliError> {
3283
+ #[cfg(unix)]
3284
+ if let Some(receipt) = crate::contract_runtime::commands::start_agent(workspace, team, agent, role_config)? {
3285
+ return Ok(receipt);
3286
+ }
3152
3287
  #[cfg(unix)]
3153
3288
  {
3154
3289
  crate::contract_runtime::framework::require_legacy_lifecycle(
@@ -3228,13 +3363,14 @@ pub mod lifecycle_port {
3228
3363
  agent: &str,
3229
3364
  team: Option<&str>,
3230
3365
  ) -> Result<Value, CliError> {
3366
+ #[cfg(unix)]
3367
+ if let Some(receipt) = crate::contract_runtime::commands::stop_agent(workspace, team, agent)? {
3368
+ return Ok(receipt);
3369
+ }
3231
3370
  let agent_id = crate::model::ids::AgentId::new(agent);
3232
3371
  #[cfg(unix)]
3233
3372
  crate::contract_runtime::framework::require_legacy_lifecycle(
3234
- workspace,
3235
- team,
3236
- Some(agent),
3237
- "stop-agent",
3373
+ workspace, team, Some(agent), "stop-agent",
3238
3374
  )?;
3239
3375
  match crate::lifecycle::stop_agent(workspace, &agent_id, team) {
3240
3376
  Ok(report) => Ok(json!({"ok": true, "agent_id": agent, "stopped": report.stopped})),
@@ -438,6 +438,57 @@ mod tests {
438
438
  assert_eq!(models[0]["default"], true);
439
439
  }
440
440
 
441
+ #[cfg(unix)]
442
+ #[test]
443
+ fn cursor_contract_catalog_uses_public_launcher_metadata_then_exact_ids() {
444
+ let program = fixture_program("cursor-agent", r#"
445
+ test "$#" = 1 || exit 9
446
+ case "$1" in
447
+ --version) printf '%s\n' '2026.10.01-a1b2c3' ;;
448
+ --list-models) exec /bin/cat "$TEAM_AGENT_MODELS_FIXTURE" ;;
449
+ *) exit 9 ;;
450
+ esac"#);
451
+ let mut cursor = CliFixture::new(program);
452
+ let response = cursor.program.with_extension("stdout");
453
+ std::fs::write(&response, include_bytes!("../provider/testdata/cursor-list-models-native.stdout")).unwrap();
454
+ cursor.response = Some(response);
455
+ let output = run_public_models_cli(&cursor.program, cursor.response.as_deref(),
456
+ &["models", "--provider", "cursor", "--search", "gpt-5.6-luna-xhigh", "--json"]);
457
+ let text = public_cli_text(&output);
458
+ let calls = std::fs::read_to_string(cursor.program.with_extension("count")).unwrap();
459
+ assert!(output.status.success(), "{text}");
460
+ assert_eq!(calls, "1:--version\n1:--list-models\n");
461
+ let value: Value = serde_json::from_str(text.trim()).unwrap();
462
+ assert_eq!(value["provider"], "cursor");
463
+ assert_eq!(value["auth"], "unknown");
464
+ assert_eq!(value["source"]["schema"], "cursor-list-models-text-v1");
465
+ assert_eq!(value["source"]["identity_scope"], "catalog_only");
466
+ assert!(value["models"].as_array().unwrap().iter().any(|row| row["model_id"] == "gpt-5.6-luna-xhigh"));
467
+ assert!(value["models"].as_array().unwrap().iter().all(|row| row["role_model"] == row["model_id"]));
468
+ assert!(value["current_role_model"].is_null());
469
+ }
470
+
471
+ #[cfg(unix)]
472
+ #[test]
473
+ fn cursor_contract_bad_version_has_no_legacy_entrypoint_fallback() {
474
+ use std::os::unix::fs::PermissionsExt;
475
+ let cursor = CliFixture::new(fixture_program("cursor-agent", "printf '%s\\n' 'Cursor release unknown'"));
476
+ let legacy = cursor.program.parent().unwrap().join("agent");
477
+ std::fs::write(&legacy, "#!/bin/sh\nprintf 'called' > \"$0.called\"\nexit 9\n").unwrap();
478
+ std::fs::set_permissions(&legacy, std::fs::Permissions::from_mode(0o700)).unwrap();
479
+ let output = run_public_models_cli(&cursor.program, None,
480
+ &["models", "--provider", "cursor", "--json"]);
481
+ let text = public_cli_text(&output);
482
+ assert!(!output.status.success(), "{text}");
483
+ assert_eq!(std::fs::read_to_string(cursor.program.with_extension("count")).unwrap(), "1:--version\n");
484
+ assert!(!legacy.with_extension("called").exists());
485
+ let value: Value = serde_json::from_str(text.trim()).unwrap();
486
+ assert_eq!(value["provider"], "cursor");
487
+ assert_eq!(value["ok"], false);
488
+ assert_eq!(value["models"], json!([]));
489
+ assert!(value["error"].as_str().unwrap().contains("Cursor release unknown"));
490
+ }
491
+
441
492
  #[cfg(unix)]
442
493
  #[test]
443
494
  fn grok_public_cli_uses_native_catalog_and_keeps_shared_projection() {
@@ -1322,7 +1373,7 @@ mod tests {
1322
1373
  ),
1323
1374
  ExitCode::Error
1324
1375
  );
1325
- let usage = "team-agent models [--provider pi|cursor_agent|codex|claude|claude_code|grok|kiro] [QUERY|--search TEXT] [--json]";
1376
+ let usage = "team-agent models [--provider pi|cursor|cursor_agent|codex|claude|claude_code|grok|kiro] [QUERY|--search TEXT] [--json]";
1326
1377
  assert_eq!(crate::cli::spec::command_spec("models").unwrap().usage, usage);
1327
1378
  assert!(crate::cli::emit::command_help(Some("models")).contains(usage));
1328
1379
  }
@@ -0,0 +1,288 @@
1
+ //! Remove only a positively dead, owned Unix socket; never stop its server.
2
+ use std::ffi::CString;
3
+ use std::fs::{File, Metadata, OpenOptions};
4
+ use std::io;
5
+ use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
6
+ use std::os::unix::ffi::OsStrExt;
7
+ use std::os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt};
8
+ use std::path::Path;
9
+
10
+ #[derive(Debug, PartialEq, Eq)]
11
+ pub(super) enum SocketCleanup {
12
+ Absent,
13
+ Removed,
14
+ Preserved,
15
+ }
16
+
17
+ pub(super) fn remove_dead_socket(path: &Path) -> io::Result<SocketCleanup> {
18
+ remove_with_probe(path, listener_refused)
19
+ }
20
+
21
+ fn identity(metadata: &Metadata) -> (u64, u64, u32, u32, u64) {
22
+ (
23
+ metadata.dev(),
24
+ metadata.ino(),
25
+ metadata.mode(),
26
+ metadata.uid(),
27
+ metadata.nlink(),
28
+ )
29
+ }
30
+
31
+ fn invalid(reason: &str) -> io::Error {
32
+ io::Error::new(io::ErrorKind::PermissionDenied, reason)
33
+ }
34
+
35
+ fn remove_with_probe(
36
+ path: &Path,
37
+ probe: impl FnOnce(&Path) -> io::Result<bool>,
38
+ ) -> io::Result<SocketCleanup> {
39
+ let socket = match std::fs::symlink_metadata(path) {
40
+ Ok(metadata) => metadata,
41
+ Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(SocketCleanup::Absent),
42
+ Err(error) => return Err(error),
43
+ };
44
+ // SAFETY: geteuid has no pointer arguments or ownership effects.
45
+ let uid = unsafe { libc::geteuid() };
46
+ if !socket.file_type().is_socket() || socket.uid() != uid || socket.nlink() != 1 {
47
+ return Err(invalid("shutdown socket is not an owned single-link socket"));
48
+ }
49
+ let parent = path
50
+ .parent()
51
+ .ok_or_else(|| invalid("socket parent missing"))?;
52
+ let metadata = std::fs::symlink_metadata(parent)?;
53
+ if !metadata.is_dir()
54
+ || metadata.uid() != uid
55
+ || metadata.mode() & 0o077 != 0
56
+ || parent.canonicalize()? != parent
57
+ {
58
+ return Err(invalid(
59
+ "shutdown socket parent is not a canonical private owned directory",
60
+ ));
61
+ }
62
+ let directory: File = OpenOptions::new()
63
+ .read(true)
64
+ .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW)
65
+ .open(parent)?;
66
+ if identity(&directory.metadata()?) != identity(&metadata) {
67
+ return Err(invalid("shutdown socket parent changed"));
68
+ }
69
+ if !probe(path)? {
70
+ return Ok(SocketCleanup::Preserved);
71
+ }
72
+ if identity(&std::fs::symlink_metadata(parent)?) != identity(&metadata) {
73
+ return Err(invalid("shutdown socket parent changed after death probe"));
74
+ }
75
+ let name = CString::new(
76
+ path.file_name()
77
+ .ok_or_else(|| invalid("socket name missing"))?
78
+ .as_bytes(),
79
+ )
80
+ .map_err(|_| invalid("socket name contains NUL"))?;
81
+ // SAFETY: stat is an all-integer C record; directory/name are valid for the call.
82
+ let mut fresh: libc::stat = unsafe { std::mem::zeroed() };
83
+ let result = unsafe {
84
+ libc::fstatat(
85
+ directory.as_raw_fd(),
86
+ name.as_ptr(),
87
+ &mut fresh,
88
+ libc::AT_SYMLINK_NOFOLLOW,
89
+ )
90
+ };
91
+ if result != 0 {
92
+ let error = io::Error::last_os_error();
93
+ return if error.kind() == io::ErrorKind::NotFound {
94
+ Ok(SocketCleanup::Absent)
95
+ } else {
96
+ Err(error)
97
+ };
98
+ }
99
+ if fresh.st_dev as u64 != socket.dev()
100
+ || fresh.st_ino as u64 != socket.ino()
101
+ || fresh.st_mode as u32 != socket.mode()
102
+ || fresh.st_uid != socket.uid()
103
+ || fresh.st_nlink as u64 != socket.nlink()
104
+ {
105
+ return Err(invalid("shutdown socket identity changed after death probe"));
106
+ }
107
+ // SAFETY: unlink is relative to the pinned owned directory and freshly fenced socket.
108
+ if unsafe { libc::unlinkat(directory.as_raw_fd(), name.as_ptr(), 0) } != 0 {
109
+ return Err(io::Error::last_os_error());
110
+ }
111
+ Ok(SocketCleanup::Removed)
112
+ }
113
+
114
+ fn listener_refused(path: &Path) -> io::Result<bool> {
115
+ // SAFETY: socket has no borrowed pointer arguments; successful fd ownership moves below.
116
+ let fd = unsafe { libc::socket(libc::AF_UNIX, libc::SOCK_STREAM, 0) };
117
+ if fd < 0 {
118
+ return Err(io::Error::last_os_error());
119
+ }
120
+ // SAFETY: fd is newly created and exclusively owned here.
121
+ let fd = unsafe { OwnedFd::from_raw_fd(fd) };
122
+ // SAFETY: fcntl acts on our owned fd; no pointer arguments.
123
+ if unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_SETFD, libc::FD_CLOEXEC) } < 0 {
124
+ return Err(io::Error::last_os_error());
125
+ }
126
+ let flags = unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_GETFL) };
127
+ if flags < 0 || unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_SETFL, flags | libc::O_NONBLOCK) } < 0 {
128
+ return Err(io::Error::last_os_error());
129
+ }
130
+ // SAFETY: sockaddr_un is a C integer/byte record; zero initializes all fields/padding.
131
+ let mut address: libc::sockaddr_un = unsafe { std::mem::zeroed() };
132
+ address.sun_family = libc::AF_UNIX as libc::sa_family_t;
133
+ let bytes = path.as_os_str().as_bytes();
134
+ if bytes.len() >= address.sun_path.len() || bytes.contains(&0) {
135
+ return Err(io::Error::new(
136
+ io::ErrorKind::InvalidInput,
137
+ "shutdown socket path exceeds Unix address bounds",
138
+ ));
139
+ }
140
+ for (target, byte) in address.sun_path.iter_mut().zip(bytes) {
141
+ *target = *byte as libc::c_char;
142
+ }
143
+ #[cfg(any(
144
+ target_os = "macos",
145
+ target_os = "ios",
146
+ target_os = "freebsd",
147
+ target_os = "openbsd",
148
+ target_os = "netbsd",
149
+ target_os = "dragonfly"
150
+ ))]
151
+ {
152
+ address.sun_len = std::mem::size_of_val(&address) as u8;
153
+ }
154
+ // SAFETY: address points to the fully initialized sockaddr_un of the supplied size.
155
+ let result = unsafe {
156
+ libc::connect(
157
+ fd.as_raw_fd(),
158
+ (&address as *const libc::sockaddr_un).cast(),
159
+ std::mem::size_of_val(&address) as libc::socklen_t,
160
+ )
161
+ };
162
+ if result == 0 {
163
+ return Ok(false);
164
+ }
165
+ match io::Error::last_os_error().raw_os_error() {
166
+ Some(libc::ECONNREFUSED) => Ok(true),
167
+ Some(libc::EINPROGRESS | libc::EAGAIN | libc::ENOENT) => Ok(false),
168
+ _ => Err(io::Error::last_os_error()),
169
+ }
170
+ }
171
+
172
+ #[cfg(test)]
173
+ #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
174
+ mod tests {
175
+ use super::*;
176
+ use std::os::unix::fs::{symlink, PermissionsExt};
177
+ use std::os::unix::net::UnixListener;
178
+
179
+ struct Fixture(std::path::PathBuf);
180
+ impl Fixture {
181
+ fn new() -> Self {
182
+ let path = Path::new("/tmp").join(format!(
183
+ "ta-shutdown-{}-{}",
184
+ std::process::id(),
185
+ std::time::SystemTime::now()
186
+ .duration_since(std::time::UNIX_EPOCH)
187
+ .unwrap()
188
+ .as_nanos()
189
+ ));
190
+ std::fs::create_dir(&path).unwrap();
191
+ std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap();
192
+ Self(path.canonicalize().unwrap())
193
+ }
194
+ fn socket(&self) -> std::path::PathBuf {
195
+ self.0.join("owned.sock")
196
+ }
197
+ }
198
+ impl Drop for Fixture {
199
+ fn drop(&mut self) {
200
+ let _ = std::fs::remove_dir_all(&self.0);
201
+ }
202
+ }
203
+
204
+ #[test]
205
+ fn shutdown_socket_removes_dead_inode_and_repeated_cleanup_is_absent() {
206
+ let fixture = Fixture::new();
207
+ let path = fixture.socket();
208
+ drop(UnixListener::bind(&path).unwrap());
209
+ assert_eq!(remove_dead_socket(&path).unwrap(), SocketCleanup::Removed);
210
+ assert!(!path.exists());
211
+ assert_eq!(remove_dead_socket(&path).unwrap(), SocketCleanup::Absent);
212
+ }
213
+
214
+ #[test]
215
+ fn shutdown_socket_preserves_live_listener_and_unsafe_objects() {
216
+ let fixture = Fixture::new();
217
+ let path = fixture.socket();
218
+ let listener = UnixListener::bind(&path).unwrap();
219
+ assert_eq!(remove_dead_socket(&path).unwrap(), SocketCleanup::Preserved);
220
+ assert!(path.exists());
221
+ drop(listener);
222
+ std::fs::remove_file(&path).unwrap();
223
+ std::fs::write(&path, b"not a socket").unwrap();
224
+ assert!(remove_dead_socket(&path).is_err());
225
+ std::fs::remove_file(&path).unwrap();
226
+ std::fs::create_dir(&path).unwrap();
227
+ assert!(remove_dead_socket(&path).is_err());
228
+ std::fs::remove_dir(&path).unwrap();
229
+ let target = fixture.0.join("target.sock");
230
+ drop(UnixListener::bind(&target).unwrap());
231
+ symlink(&target, &path).unwrap();
232
+ assert!(remove_dead_socket(&path).is_err());
233
+ assert!(target.exists());
234
+ std::fs::remove_file(&path).unwrap();
235
+ std::fs::hard_link(&target, &path).unwrap();
236
+ assert!(remove_dead_socket(&path).is_err());
237
+ std::fs::remove_file(&path).unwrap();
238
+ std::fs::set_permissions(&fixture.0, std::fs::Permissions::from_mode(0o755)).unwrap();
239
+ assert!(remove_dead_socket(&target).is_err());
240
+ assert!(target.exists());
241
+ }
242
+
243
+ #[test]
244
+ fn shutdown_socket_refuses_failed_probe_and_inode_or_parent_replacement() {
245
+ let fixture = Fixture::new();
246
+ let path = fixture.socket();
247
+ drop(UnixListener::bind(&path).unwrap());
248
+ assert!(remove_with_probe(&path, |_| Err(io::Error::new(
249
+ io::ErrorKind::PermissionDenied,
250
+ "probe failed"
251
+ )))
252
+ .is_err());
253
+ assert!(path.exists());
254
+ let original = fixture.0.join("original.sock");
255
+ assert!(remove_with_probe(&path, |_| {
256
+ std::fs::rename(&path, &original)?;
257
+ drop(UnixListener::bind(&path)?);
258
+ Ok(true)
259
+ })
260
+ .is_err());
261
+ assert!(path.exists());
262
+ assert_ne!(
263
+ std::fs::symlink_metadata(&path).unwrap().ino(),
264
+ std::fs::symlink_metadata(&original).unwrap().ino()
265
+ );
266
+ std::fs::remove_file(&path).unwrap();
267
+ drop(UnixListener::bind(&path).unwrap());
268
+ assert!(remove_with_probe(&path, |_| {
269
+ std::fs::remove_file(&path)?;
270
+ std::fs::write(&path, b"replacement")?;
271
+ Ok(true)
272
+ })
273
+ .is_err());
274
+ assert_eq!(std::fs::read(&path).unwrap(), b"replacement");
275
+ std::fs::remove_file(&path).unwrap();
276
+ drop(UnixListener::bind(&path).unwrap());
277
+ let moved = fixture.0.with_extension("old");
278
+ assert!(remove_with_probe(&path, |_| {
279
+ std::fs::rename(&fixture.0, &moved)?;
280
+ std::fs::create_dir(&fixture.0)?;
281
+ std::fs::set_permissions(&fixture.0, std::fs::Permissions::from_mode(0o700))?;
282
+ Ok(true)
283
+ })
284
+ .is_err());
285
+ assert!(moved.join("owned.sock").exists());
286
+ std::fs::remove_dir_all(moved).unwrap();
287
+ }
288
+ }
@@ -113,7 +113,7 @@ pub(crate) const COMMAND_SPECS: &[CommandSpec] = &[
113
113
  CommandSpec { name: "quick-start", tier: CommandTier::Core, category: CommandCategory::Start, kind: CommandKind::Dispatch(DispatchKind::QuickStart), summary: "Create a team from role files; use restart for an initialized team", usage: "team-agent quick-start [TEAMDIR] [--workspace WORKSPACE] [--team TEAM] [--name NAME] [--team-id TEAM] [--yes] [--backend tmux|conpty] [--json] [--detail]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, sunset: None, action: None, governance: None },
114
114
  CommandSpec { name: "send", tier: CommandTier::Core, category: CommandCategory::Start, kind: CommandKind::Dispatch(DispatchKind::Send), summary: "Send a task to a specified agent", usage: "team-agent send <agent> MESSAGE... [--workspace WORKSPACE] [--team TEAM] [--mailbox] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, sunset: Some("a future compatibility release"), action: Some("Use team-agent send <agent> 'task message'; select scope with --workspace and --team"), governance: None },
115
115
  CommandSpec { name: "status", tier: CommandTier::Core, category: CommandCategory::Start, kind: CommandKind::Dispatch(DispatchKind::Status), summary: "Check agent status without reading conversation content", usage: "team-agent status [<agent>] [--workspace WORKSPACE] [--team TEAM] [--json] [--summary|--detail]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, sunset: None, action: None, governance: None },
116
- CommandSpec { name: "models", tier: CommandTier::Core, category: CommandCategory::Start, kind: CommandKind::Dispatch(DispatchKind::Models), summary: "Discover model names supported by the native provider", usage: "team-agent models [--provider pi|cursor_agent|codex|claude|claude_code|grok|kiro] [QUERY|--search TEXT] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, sunset: None, action: None, governance: None },
116
+ CommandSpec { name: "models", tier: CommandTier::Core, category: CommandCategory::Start, kind: CommandKind::Dispatch(DispatchKind::Models), summary: "Discover model names supported by the native provider", usage: "team-agent models [--provider pi|cursor|cursor_agent|codex|claude|claude_code|grok|kiro] [QUERY|--search TEXT] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, sunset: None, action: None, governance: None },
117
117
  CommandSpec { name: "inbox", tier: CommandTier::Core, category: CommandCategory::Start, kind: CommandKind::Dispatch(DispatchKind::Inbox), summary: "Read agent replies", usage: "team-agent inbox <agent> [-n N|--limit N] [--workspace WORKSPACE] [--team TEAM] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, sunset: None, action: None, governance: None },
118
118
  CommandSpec { name: "restart", tier: CommandTier::Core, category: CommandCategory::TeamLifecycle, kind: CommandKind::Dispatch(DispatchKind::Restart), summary: "Restart an existing team using saved sessions when available", usage: "team-agent restart [WORKSPACE] [--team TEAM] [--allow-fresh] [--session-converge-deadline SECONDS] [--json] [--detail]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, sunset: None, action: None, governance: None },
119
119
  CommandSpec { name: "shutdown", tier: CommandTier::Core, category: CommandCategory::TeamLifecycle, kind: CommandKind::Dispatch(DispatchKind::Shutdown), summary: "Shut down the selected team and report residual resources", usage: "team-agent shutdown [--workspace WORKSPACE] [--team TEAM] [--keep-logs] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, sunset: None, action: None, governance: None },
@@ -720,11 +720,18 @@ fn shutdown_invocation_diagnostic(
720
720
  );
721
721
  }
722
722
  let terminal = &appended[terminals[0]];
723
- let expected_socket = socket.to_string_lossy();
724
- if terminal["owned_endpoint"].as_str() != Some(expected_socket.as_ref()) {
723
+ // These diagnostics use deliberately unverified fixture paths, not the
724
+ // computed workspace endpoint. A source label cannot grant ownership.
725
+ let workspace_socket = crate::tmux_backend::socket_name_for_workspace(ws);
726
+ assert_ne!(
727
+ socket.file_name(),
728
+ Some(std::ffi::OsStr::new(&workspace_socket)),
729
+ "diagnostic fixture unexpectedly uses the owned workspace socket"
730
+ );
731
+ if !terminal["owned_endpoint"].is_null() {
725
732
  panic!(
726
- "shutdown diagnostic endpoint mismatch: iteration={iteration} socket={} \
727
- {result_summary}; terminal={terminal}",
733
+ "shutdown diagnostic falsely claimed fixture endpoint ownership: \
734
+ iteration={iteration} socket={} {result_summary}; terminal={terminal}",
728
735
  socket.display()
729
736
  );
730
737
  }