@team-agent/installer 0.5.115 → 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.
package/Cargo.lock CHANGED
@@ -727,7 +727,7 @@ dependencies = [
727
727
 
728
728
  [[package]]
729
729
  name = "team-agent"
730
- version = "0.5.115"
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.115"
14
+ version = "0.5.116"
15
15
  license = "AGPL-3.0"
16
16
  rust-version = "1.95"
17
17
 
@@ -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,
@@ -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
+ }
@@ -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
  }