@team-agent/installer 0.3.0 → 0.3.1

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.
@@ -332,6 +332,9 @@ pub fn apply_first_time_leader_binding(
332
332
  r.insert("leader_session_uuid".to_string(), id_uuid.clone());
333
333
  r.insert("machine_fingerprint".to_string(), id_fp.clone());
334
334
  r.insert("owner_epoch".to_string(), json!(0));
335
+ if let Some(socket) = crate::tmux_backend::socket_name_from_tmux_env() {
336
+ r.insert("tmux_socket".to_string(), json!(socket));
337
+ }
335
338
  }
336
339
  let owner = json!({
337
340
  "pane_id": receiver.get("pane_id").cloned().unwrap_or(Value::Null),
@@ -98,6 +98,185 @@
98
98
  items.iter().map(|s| (*s).to_string()).collect()
99
99
  }
100
100
 
101
+ struct EnvGuard {
102
+ saved: Vec<(String, Option<String>)>,
103
+ }
104
+
105
+ impl EnvGuard {
106
+ fn apply(vars: &[(&str, Option<&str>)]) -> Self {
107
+ let saved = vars.iter().map(|(k, _)| ((*k).to_string(), std::env::var(k).ok())).collect();
108
+ for (k, v) in vars {
109
+ match v {
110
+ Some(val) => std::env::set_var(k, val),
111
+ None => std::env::remove_var(k),
112
+ }
113
+ }
114
+ Self { saved }
115
+ }
116
+ }
117
+
118
+ impl Drop for EnvGuard {
119
+ fn drop(&mut self) {
120
+ for (k, v) in &self.saved {
121
+ match v {
122
+ Some(val) => std::env::set_var(k, val),
123
+ None => std::env::remove_var(k),
124
+ }
125
+ }
126
+ }
127
+ }
128
+
129
+ #[test]
130
+ #[serial_test::serial(env)]
131
+ fn leader_receiver_endpoint_from_tmux_env_preserves_full_socket_path() {
132
+ let leader_socket = "/tmp/ta-leader-root/tmux-501/dl2f";
133
+ let _env = EnvGuard::apply(&[
134
+ ("TMUX", Some("/tmp/ta-leader-root/tmux-501/dl2f,12345,0")),
135
+ ("TMUX_TMPDIR", Some("/tmp/ta-coordinator-root")),
136
+ ]);
137
+
138
+ assert_eq!(
139
+ super::socket_name_from_tmux_env().as_deref(),
140
+ Some(leader_socket),
141
+ "leader receivers must persist the exact tmux endpoint from $TMUX; a short -L socket \
142
+ name is re-rooted under the coordinator's TMUX_TMPDIR and cannot reach an external \
143
+ leader pane"
144
+ );
145
+ }
146
+
147
+ #[test]
148
+ #[serial_test::serial(env)]
149
+ fn leader_receiver_endpoint_from_tmux_env_rejects_short_socket_name() {
150
+ let _env = EnvGuard::apply(&[
151
+ ("TMUX", Some("dl9aa40c88,12345,0")),
152
+ ("TMUX_TMPDIR", Some("/tmp/ta-coordinator-root")),
153
+ ]);
154
+
155
+ assert_eq!(
156
+ super::socket_name_from_tmux_env(),
157
+ None,
158
+ "leader_receiver.tmux_socket is a durable physical endpoint: a short socket name from \
159
+ $TMUX must not be persisted because tmux -L <short> is re-rooted under the coordinator"
160
+ );
161
+ }
162
+
163
+ #[test]
164
+ fn leader_receiver_delivery_uses_full_socket_endpoint_not_short_l_reconstruction() {
165
+ let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
166
+ let delivery = std::fs::read_to_string(manifest.join("src/messaging/delivery.rs")).unwrap();
167
+ let leader_receiver =
168
+ std::fs::read_to_string(manifest.join("src/messaging/leader_receiver.rs")).unwrap();
169
+ let tmux_backend = std::fs::read_to_string(manifest.join("src/tmux_backend.rs")).unwrap();
170
+
171
+ assert!(
172
+ tmux_backend.contains("\"-S\""),
173
+ "tmux backend must support `tmux -S <full-socket-path>` for persisted external leader \
174
+ endpoints; `-L <short-name>` is not enough when leader and coordinator TMUX_TMPDIR differ"
175
+ );
176
+ assert!(
177
+ !delivery.contains("TmuxBackend::for_socket_name(socket)"),
178
+ "worker->leader delivery must not reconstruct an external leader endpoint with \
179
+ `tmux -L <short-name>`; it must use the persisted full socket path endpoint"
180
+ );
181
+ assert!(
182
+ !leader_receiver.contains("TmuxBackend::for_socket_name(socket)"),
183
+ "leader_receiver live checks must verify the same full socket endpoint used by delivery, \
184
+ not a short socket name resolved under the coordinator's socket root"
185
+ );
186
+ }
187
+
188
+ #[test]
189
+ fn leader_receiver_full_endpoint_liveness_list_and_inject_use_s_path_command_shape() {
190
+ let endpoint = "/private/tmp/tmux-501/default";
191
+ let stdout = "%7\tteam-x\t0\tleader\t0\t/dev/ttys003\tbash\t1\t/Users/me/work\t1\t0\n";
192
+ let (be, rec, _stdin) = {
193
+ let recorded = Arc::new(Mutex::new(Vec::new()));
194
+ let stdin_recorded = Arc::new(Mutex::new(Vec::new()));
195
+ let runner = MockCommandRunner {
196
+ recorded: Arc::clone(&recorded),
197
+ stdin_recorded: Arc::clone(&stdin_recorded),
198
+ queue: Mutex::new(
199
+ vec![
200
+ MockResp::Out(ok(stdout)),
201
+ MockResp::Out(ok("%7\n")),
202
+ MockResp::Out(ok("")),
203
+ MockResp::Out(ok("")),
204
+ MockResp::Out(ok("")),
205
+ ]
206
+ .into_iter()
207
+ .collect(),
208
+ ),
209
+ default: MockResp::Out(ok("")),
210
+ };
211
+ (
212
+ TmuxBackend::with_runner_for_tmux_endpoint(Box::new(runner), endpoint),
213
+ recorded,
214
+ stdin_recorded,
215
+ )
216
+ };
217
+
218
+ let _ = be.list_targets().expect("list_targets via endpoint");
219
+ let _ = be.liveness(&PaneId::new("%7")).expect("liveness via endpoint");
220
+ let _ = be
221
+ .inject(
222
+ &Target::Pane(PaneId::new("%7")),
223
+ &InjectPayload::Text("hello leader".to_string()),
224
+ Key::Enter,
225
+ true,
226
+ )
227
+ .expect("inject via endpoint");
228
+
229
+ let calls = rec.lock().unwrap().clone();
230
+ assert!(
231
+ calls.len() >= 5,
232
+ "fixture must exercise list-panes, display-message, buffer/paste, and send-keys; got {calls:?}"
233
+ );
234
+ for call in &calls {
235
+ assert!(
236
+ call.starts_with(&["tmux".to_string(), "-S".to_string(), endpoint.to_string()]),
237
+ "leader receiver list/liveness/inject must use tmux -S <full socket path>; got {call:?}"
238
+ );
239
+ assert!(
240
+ !call.windows(2).any(|w| w == ["-L".to_string(), endpoint.to_string()]),
241
+ "leader receiver full endpoint must never be reconstructed with -L; got {call:?}"
242
+ );
243
+ }
244
+ assert!(
245
+ calls.iter().any(|call| call.iter().any(|arg| arg == "list-panes"))
246
+ && calls.iter().any(|call| call.iter().any(|arg| arg == "display-message"))
247
+ && calls.iter().any(|call| call.iter().any(|arg| arg == "paste-buffer"))
248
+ && calls.iter().any(|call| call.iter().any(|arg| arg == "send-keys")),
249
+ "contract must cover liveness/list/inject command shapes; got {calls:?}"
250
+ );
251
+ }
252
+
253
+ #[test]
254
+ fn leader_receiver_short_endpoint_must_not_reconstruct_tmux_l_socket() {
255
+ let endpoint = "dl9aa40c88";
256
+ let (be, rec) = {
257
+ let recorded = Arc::new(Mutex::new(Vec::new()));
258
+ let runner = MockCommandRunner {
259
+ recorded: Arc::clone(&recorded),
260
+ stdin_recorded: Arc::new(Mutex::new(Vec::new())),
261
+ queue: Mutex::new(vec![MockResp::Out(ok(""))].into_iter().collect()),
262
+ default: MockResp::Out(ok("")),
263
+ };
264
+ (
265
+ TmuxBackend::with_runner_for_tmux_endpoint(Box::new(runner), endpoint),
266
+ recorded,
267
+ )
268
+ };
269
+
270
+ let _ = be.list_targets().expect("short endpoint should not become -L");
271
+
272
+ let calls = rec.lock().unwrap().clone();
273
+ assert!(
274
+ calls.iter().all(|call| !call.windows(2).any(|w| w == ["-L".to_string(), endpoint.to_string()])),
275
+ "non-canonical leader endpoints must be rejected or left unbound, never reconstructed as \
276
+ tmux -L <short> under the coordinator socket root; calls={calls:?}"
277
+ );
278
+ }
279
+
101
280
  // ── 1. has_session: exit 0 -> true, exit 1 -> false; argv = `tmux has-session -t <s>` ──────────
102
281
  #[test]
103
282
  fn has_session_argv_and_exit_code_maps_to_bool() {
@@ -161,7 +161,12 @@ pub struct TmuxBackend {
161
161
  runner: Box<dyn CommandRunner>,
162
162
  /// `Some(name)` for a per-team socket -> every `tmux` argv gets `-L <name>` injected after the
163
163
  /// leading "tmux" token; `None` (default) -> bare `tmux` on the shared default socket.
164
- socket: Option<String>,
164
+ socket: Option<TmuxSocketEndpoint>,
165
+ }
166
+
167
+ enum TmuxSocketEndpoint {
168
+ Name(String),
169
+ Path(String),
165
170
  }
166
171
 
167
172
  impl TmuxBackend {
@@ -177,7 +182,25 @@ impl TmuxBackend {
177
182
  pub fn for_workspace(workspace: &Path) -> Self {
178
183
  Self {
179
184
  runner: Box::new(RealCommandRunner),
180
- socket: Some(socket_name_for_workspace(workspace)),
185
+ socket: Some(TmuxSocketEndpoint::Name(socket_name_for_workspace(workspace))),
186
+ }
187
+ }
188
+
189
+ pub(crate) fn for_socket_name(socket: &str) -> Self {
190
+ if socket.is_empty() || socket == "default" {
191
+ Self::new()
192
+ } else {
193
+ Self { runner: Box::new(RealCommandRunner), socket: Some(TmuxSocketEndpoint::Name(socket.to_string())) }
194
+ }
195
+ }
196
+
197
+ pub(crate) fn for_tmux_endpoint(endpoint: &str) -> Self {
198
+ if endpoint.is_empty() || endpoint == "default" {
199
+ Self::new()
200
+ } else if Path::new(endpoint).is_absolute() {
201
+ Self { runner: Box::new(RealCommandRunner), socket: Some(TmuxSocketEndpoint::Path(endpoint.to_string())) }
202
+ } else {
203
+ Self::new()
181
204
  }
182
205
  }
183
206
 
@@ -189,7 +212,17 @@ impl TmuxBackend {
189
212
  /// Backend with an injected runner bound to a per-workspace socket (tests: assert the `-L` is in
190
213
  /// the recorded argv for a workspace-bound backend).
191
214
  pub fn with_runner_for_workspace(runner: Box<dyn CommandRunner>, workspace: &Path) -> Self {
192
- Self { runner, socket: Some(socket_name_for_workspace(workspace)) }
215
+ Self { runner, socket: Some(TmuxSocketEndpoint::Name(socket_name_for_workspace(workspace))) }
216
+ }
217
+
218
+ pub(crate) fn with_runner_for_tmux_endpoint(runner: Box<dyn CommandRunner>, endpoint: &str) -> Self {
219
+ if Path::new(endpoint).is_absolute() {
220
+ Self { runner, socket: Some(TmuxSocketEndpoint::Path(endpoint.to_string())) }
221
+ } else if endpoint.is_empty() || endpoint == "default" {
222
+ Self { runner, socket: None }
223
+ } else {
224
+ Self { runner, socket: None }
225
+ }
193
226
  }
194
227
 
195
228
  /// THE RUN CHOKEPOINT: every executed `tmux` argv is funneled through here. When a per-team
@@ -197,11 +230,19 @@ impl TmuxBackend {
197
230
  /// through unchanged. Non-`tmux` argv (e.g. the spawned provider command) is never rewritten.
198
231
  fn tmux_argv(&self, argv: &[String]) -> Vec<String> {
199
232
  match &self.socket {
200
- Some(socket) if argv.first().map(String::as_str) == Some("tmux") => {
233
+ Some(endpoint) if argv.first().map(String::as_str) == Some("tmux") => {
201
234
  let mut out = Vec::with_capacity(argv.len() + 2);
202
235
  out.push("tmux".to_string());
203
- out.push("-L".to_string());
204
- out.push(socket.clone());
236
+ match endpoint {
237
+ TmuxSocketEndpoint::Name(socket) => {
238
+ out.push("-L".to_string());
239
+ out.push(socket.clone());
240
+ }
241
+ TmuxSocketEndpoint::Path(socket) => {
242
+ out.push("-S".to_string());
243
+ out.push(socket.clone());
244
+ }
245
+ }
205
246
  out.extend(argv.iter().skip(1).cloned());
206
247
  out
207
248
  }
@@ -237,6 +278,17 @@ pub(crate) fn socket_name_for_workspace(workspace: &Path) -> String {
237
278
  format!("ta-{:012x}", hasher.finish() & 0xffff_ffff_ffff)
238
279
  }
239
280
 
281
+ pub(crate) fn socket_name_from_tmux_env() -> Option<String> {
282
+ let tmux = std::env::var("TMUX")
283
+ .ok()
284
+ .filter(|value| !value.is_empty())?;
285
+ let socket_path = tmux.split(',').next().unwrap_or("").trim();
286
+ if socket_path.is_empty() || !Path::new(socket_path).is_absolute() {
287
+ return None;
288
+ }
289
+ Some(socket_path.to_string())
290
+ }
291
+
240
292
  /// Deterministic FNV-1a (64-bit) — std `DefaultHasher` is NOT stable across releases, so a fixed
241
293
  /// FNV keeps the socket identical for the CLI, the daemon, and every later op on the same workspace.
242
294
  struct Fnv1a(u64);
package/npm/install.mjs CHANGED
@@ -10,6 +10,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
10
  const packageRoot = path.resolve(__dirname, "..");
11
11
  const require = createRequire(import.meta.url);
12
12
  const packageJson = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"));
13
+ const DOCTOR_TIMEOUT_MS = 5000;
13
14
 
14
15
  const command = process.argv[2] || "install";
15
16
  const args = process.argv.slice(3);
@@ -87,11 +88,20 @@ function install(argv) {
87
88
  console.log("skill: installed for Codex and Claude");
88
89
  console.log(`PATH: ensure ${binDir} is on PATH`);
89
90
 
90
- const doctor = spawnSync(teamAgent, ["doctor", "--json"], { text: true, encoding: "utf8" });
91
- if (doctor.status === 0) {
92
- console.log("doctor: ok");
93
- } else {
94
- console.log("doctor: has blockers; run `team-agent doctor` after updating PATH");
91
+ const doctorWorkspace = makeDoctorWorkspace();
92
+ try {
93
+ const doctor = spawnSync(teamAgent, ["doctor", "--json", "--workspace", doctorWorkspace], {
94
+ text: true,
95
+ encoding: "utf8",
96
+ timeout: DOCTOR_TIMEOUT_MS,
97
+ });
98
+ if (doctor.status === 0) {
99
+ console.log("doctor: ok");
100
+ } else {
101
+ console.log("doctor: has blockers; run `team-agent doctor` after updating PATH");
102
+ }
103
+ } finally {
104
+ fs.rmSync(doctorWorkspace, { recursive: true, force: true });
95
105
  }
96
106
  }
97
107
 
@@ -103,8 +113,16 @@ function runDoctor(argv) {
103
113
  console.error(`team-agent wrapper not found: ${teamAgent}`);
104
114
  process.exit(1);
105
115
  }
106
- const proc = spawnSync(teamAgent, ["doctor"], { stdio: "inherit" });
107
- process.exit(proc.status ?? 1);
116
+ const doctorWorkspace = makeDoctorWorkspace();
117
+ try {
118
+ const proc = spawnSync(teamAgent, ["doctor", "--workspace", doctorWorkspace], {
119
+ stdio: "inherit",
120
+ timeout: DOCTOR_TIMEOUT_MS,
121
+ });
122
+ process.exit(proc.status ?? 1);
123
+ } finally {
124
+ fs.rmSync(doctorWorkspace, { recursive: true, force: true });
125
+ }
108
126
  }
109
127
 
110
128
  function uninstall(argv) {
@@ -217,6 +235,10 @@ function skillDestinations() {
217
235
  ];
218
236
  }
219
237
 
238
+ function makeDoctorWorkspace() {
239
+ return fs.mkdtempSync(path.join(os.tmpdir(), "team-agent-doctor-"));
240
+ }
241
+
220
242
  function copyTree(src, dest) {
221
243
  const stat = fs.lstatSync(src);
222
244
  if (stat.isDirectory()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "npx installer for Team Agent",
5
5
  "keywords": [
6
6
  "codex",
@@ -20,9 +20,9 @@
20
20
  "team-agent-installer": "npm/install.mjs"
21
21
  },
22
22
  "optionalDependencies": {
23
- "@team-agent/cli-darwin-arm64": "0.3.0",
24
- "@team-agent/cli-darwin-x64": "0.3.0",
25
- "@team-agent/cli-linux-x64": "0.3.0"
23
+ "@team-agent/cli-darwin-arm64": "0.3.1",
24
+ "@team-agent/cli-darwin-x64": "0.3.1",
25
+ "@team-agent/cli-linux-x64": "0.3.1"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",