@team-agent/installer 0.5.0 → 0.5.2

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 (40) hide show
  1. package/Cargo.lock +118 -9
  2. package/Cargo.toml +2 -2
  3. package/crates/team-agent/Cargo.toml +1 -0
  4. package/crates/team-agent/src/app_server_test_support.rs +258 -0
  5. package/crates/team-agent/src/cli/adapters.rs +32 -3
  6. package/crates/team-agent/src/cli/attach_app_server_leader.rs +16 -0
  7. package/crates/team-agent/src/cli/diagnose.rs +14 -5
  8. package/crates/team-agent/src/cli/emit.rs +111 -3
  9. package/crates/team-agent/src/cli/mod.rs +72 -25
  10. package/crates/team-agent/src/cli/named_address.rs +975 -0
  11. package/crates/team-agent/src/cli/send.rs +200 -1
  12. package/crates/team-agent/src/cli/status_port.rs +44 -6
  13. package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
  14. package/crates/team-agent/src/cli/tests/mod.rs +1 -0
  15. package/crates/team-agent/src/cli/tests/named_address.rs +586 -0
  16. package/crates/team-agent/src/cli/tests/run_delegation.rs +2 -0
  17. package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
  18. package/crates/team-agent/src/cli/types.rs +21 -0
  19. package/crates/team-agent/src/codex_app_server.rs +488 -0
  20. package/crates/team-agent/src/conpty/backend.rs +710 -0
  21. package/crates/team-agent/src/conpty/mod.rs +32 -0
  22. package/crates/team-agent/src/coordinator/backoff.rs +45 -6
  23. package/crates/team-agent/src/diagnose/orphans.rs +11 -3
  24. package/crates/team-agent/src/leader/lease.rs +144 -7
  25. package/crates/team-agent/src/leader/owner_bind.rs +5 -2
  26. package/crates/team-agent/src/leader/start.rs +18 -5
  27. package/crates/team-agent/src/leader/tests/lease_api.rs +172 -0
  28. package/crates/team-agent/src/lib.rs +23 -0
  29. package/crates/team-agent/src/lifecycle/launch.rs +127 -3
  30. package/crates/team-agent/src/lifecycle/restart/common.rs +70 -0
  31. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +7 -0
  32. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +90 -0
  33. package/crates/team-agent/src/messaging/delivery.rs +329 -9
  34. package/crates/team-agent/src/messaging/leader_receiver.rs +17 -138
  35. package/crates/team-agent/src/messaging/mod.rs +2 -2
  36. package/crates/team-agent/src/messaging/results.rs +78 -21
  37. package/crates/team-agent/src/messaging/tests/runtime.rs +232 -0
  38. package/crates/team-agent/src/tmux_backend.rs +31 -0
  39. package/crates/team-agent/src/transport_factory.rs +632 -0
  40. package/package.json +4 -4
@@ -0,0 +1,488 @@
1
+ //! Minimal Codex app-server Unix-socket WebSocket client for leader delivery.
2
+
3
+ use serde_json::{json, Value};
4
+ use std::io::{Read, Write};
5
+ use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt};
6
+ use std::os::unix::net::UnixStream;
7
+ use std::path::{Path, PathBuf};
8
+ use std::time::Duration;
9
+
10
+ pub const APP_SERVER_PROTOCOL_FIXTURE_CLI_MIN: &str = "0.139.0";
11
+ pub const APP_SERVER_PROTOCOL_FIXTURE_CLI_MAX: &str = "0.139.x";
12
+ pub const APP_SERVER_PROTOCOL_REQUIRES_USER_AGENT: bool = true;
13
+
14
+ #[derive(Debug, Clone, PartialEq, Eq)]
15
+ pub struct AppServerBinding {
16
+ pub socket: String,
17
+ pub thread_id: String,
18
+ pub session_id: String,
19
+ pub cwd: String,
20
+ pub cli_version: String,
21
+ pub bound_at: String,
22
+ }
23
+
24
+ #[derive(Debug, Clone, PartialEq, Eq)]
25
+ pub struct AppServerSubmit {
26
+ pub turn_id: String,
27
+ pub turn_status: String,
28
+ }
29
+
30
+ #[derive(Debug, Clone, PartialEq, Eq)]
31
+ pub enum AppServerError {
32
+ SocketUnreachable(String),
33
+ SocketOwnershipInvalid(String),
34
+ ThreadNotLive(String),
35
+ ThreadStale { expected: Value, actual: Value },
36
+ LeaderBusy(String),
37
+ ApprovalUnsupported(String),
38
+ ProtocolMismatch(String),
39
+ MissingUserAgent,
40
+ Io(String),
41
+ Json(String),
42
+ }
43
+
44
+ impl AppServerError {
45
+ pub fn code(&self) -> &'static str {
46
+ match self {
47
+ Self::SocketUnreachable(_) => "socket_unreachable",
48
+ Self::SocketOwnershipInvalid(_) => "socket_ownership_invalid",
49
+ Self::ThreadNotLive(_) => "thread_not_live",
50
+ Self::ThreadStale { .. } => "app_server_thread_stale",
51
+ Self::LeaderBusy(_) => "leader_busy",
52
+ Self::ApprovalUnsupported(_) => "approval_unsupported",
53
+ Self::ProtocolMismatch(_) => "protocol_mismatch",
54
+ Self::MissingUserAgent => "protocol_mismatch_missing_user_agent",
55
+ Self::Io(_) => "io_error",
56
+ Self::Json(_) => "json_error",
57
+ }
58
+ }
59
+ }
60
+
61
+ impl std::fmt::Display for AppServerError {
62
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63
+ match self {
64
+ Self::SocketUnreachable(msg)
65
+ | Self::SocketOwnershipInvalid(msg)
66
+ | Self::ThreadNotLive(msg)
67
+ | Self::LeaderBusy(msg)
68
+ | Self::ApprovalUnsupported(msg)
69
+ | Self::ProtocolMismatch(msg)
70
+ | Self::Io(msg)
71
+ | Self::Json(msg) => write!(f, "{}:{msg}", self.code()),
72
+ Self::ThreadStale { expected, actual } => {
73
+ write!(f, "{}:expected={expected},actual={actual}", self.code())
74
+ }
75
+ Self::MissingUserAgent => f.write_str(self.code()),
76
+ }
77
+ }
78
+ }
79
+
80
+ impl std::error::Error for AppServerError {}
81
+
82
+ pub fn attach_probe(endpoint: &str, thread_id: &str) -> Result<AppServerBinding, AppServerError> {
83
+ check_socket_ownership(endpoint)?;
84
+ let socket = socket_path(endpoint)?;
85
+ let mut client = AppServerClient::connect(&socket)?;
86
+ let user_agent = client.initialize()?;
87
+ let resume = client.resume(thread_id)?;
88
+ Ok(AppServerBinding {
89
+ socket: endpoint.to_string(),
90
+ thread_id: resume.thread_id,
91
+ session_id: resume.session_id,
92
+ cwd: resume.cwd,
93
+ cli_version: user_agent,
94
+ bound_at: chrono::Utc::now().to_rfc3339(),
95
+ })
96
+ }
97
+
98
+ pub fn submit_to_bound_thread(
99
+ binding: &AppServerBinding,
100
+ message_id: &str,
101
+ rendered: &str,
102
+ ) -> Result<AppServerSubmit, AppServerError> {
103
+ let socket = socket_path(&binding.socket)?;
104
+ let mut client = AppServerClient::connect(&socket)?;
105
+ let user_agent = client.initialize()?;
106
+ let resume = client.resume(&binding.thread_id)?;
107
+ validate_tuple(binding, &user_agent, &resume)?;
108
+ client.turn_start(&binding.thread_id, message_id, rendered)
109
+ }
110
+
111
+ pub fn binding_from_receiver(receiver: &Value) -> Result<AppServerBinding, AppServerError> {
112
+ let app = receiver
113
+ .get("app_server")
114
+ .ok_or_else(|| AppServerError::ProtocolMismatch("missing app_server".to_string()))?;
115
+ Ok(AppServerBinding {
116
+ socket: required_str(app, "socket")?.to_string(),
117
+ thread_id: required_str(app, "thread_id")?.to_string(),
118
+ session_id: required_str(app, "session_id")?.to_string(),
119
+ cwd: required_str(app, "cwd")?.to_string(),
120
+ cli_version: required_str(app, "cli_version")?.to_string(),
121
+ bound_at: required_str(app, "bound_at")?.to_string(),
122
+ })
123
+ }
124
+
125
+ pub fn receiver_is_app_server(receiver: &Value) -> bool {
126
+ receiver.get("transport_kind").and_then(Value::as_str) == Some("codex_app_server")
127
+ || receiver.get("mode").and_then(Value::as_str) == Some("codex_app_server")
128
+ }
129
+
130
+ fn required_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, AppServerError> {
131
+ value
132
+ .get(field)
133
+ .and_then(Value::as_str)
134
+ .filter(|s| !s.is_empty())
135
+ .ok_or_else(|| AppServerError::ProtocolMismatch(format!("missing {field}")))
136
+ }
137
+
138
+ fn validate_tuple(
139
+ binding: &AppServerBinding,
140
+ user_agent: &str,
141
+ resume: &ThreadResume,
142
+ ) -> Result<(), AppServerError> {
143
+ if user_agent != binding.cli_version
144
+ || resume.thread_id != binding.thread_id
145
+ || resume.session_id != binding.session_id
146
+ || resume.cwd != binding.cwd
147
+ {
148
+ return Err(AppServerError::ThreadStale {
149
+ expected: json!({
150
+ "cli_version": binding.cli_version,
151
+ "thread_id": binding.thread_id,
152
+ "session_id": binding.session_id,
153
+ "cwd": binding.cwd,
154
+ }),
155
+ actual: json!({
156
+ "cli_version": user_agent,
157
+ "thread_id": resume.thread_id,
158
+ "session_id": resume.session_id,
159
+ "cwd": resume.cwd,
160
+ }),
161
+ });
162
+ }
163
+ Ok(())
164
+ }
165
+
166
+ fn check_socket_ownership(endpoint: &str) -> Result<(), AppServerError> {
167
+ let path = socket_path(endpoint)?;
168
+ let meta = std::fs::metadata(&path)
169
+ .map_err(|e| AppServerError::SocketUnreachable(format!("{}:{e}", path.display())))?;
170
+ if !meta.file_type().is_socket() {
171
+ return Err(AppServerError::SocketOwnershipInvalid(format!(
172
+ "not a unix socket: {}",
173
+ path.display()
174
+ )));
175
+ }
176
+ if meta.uid() != unsafe { libc::geteuid() } {
177
+ return Err(AppServerError::SocketOwnershipInvalid(format!(
178
+ "socket owner uid {} != current uid {}",
179
+ meta.uid(),
180
+ unsafe { libc::geteuid() }
181
+ )));
182
+ }
183
+ if meta.permissions().mode() & 0o002 != 0 {
184
+ return Err(AppServerError::SocketOwnershipInvalid(format!(
185
+ "socket is world-writable: {}",
186
+ path.display()
187
+ )));
188
+ }
189
+ Ok(())
190
+ }
191
+
192
+ fn socket_path(endpoint: &str) -> Result<PathBuf, AppServerError> {
193
+ let raw = endpoint.strip_prefix("unix://").unwrap_or(endpoint);
194
+ if raw.is_empty() {
195
+ return Err(AppServerError::SocketUnreachable(
196
+ "missing unix socket path".to_string(),
197
+ ));
198
+ }
199
+ Ok(PathBuf::from(raw))
200
+ }
201
+
202
+ struct ThreadResume {
203
+ thread_id: String,
204
+ session_id: String,
205
+ cwd: String,
206
+ }
207
+
208
+ struct AppServerClient {
209
+ stream: UnixStream,
210
+ next_id: u64,
211
+ }
212
+
213
+ impl AppServerClient {
214
+ fn connect(path: &Path) -> Result<Self, AppServerError> {
215
+ let mut stream = UnixStream::connect(path)
216
+ .map_err(|e| AppServerError::SocketUnreachable(format!("{}:{e}", path.display())))?;
217
+ stream
218
+ .set_read_timeout(Some(Duration::from_secs(10)))
219
+ .map_err(|e| AppServerError::Io(e.to_string()))?;
220
+ stream
221
+ .set_write_timeout(Some(Duration::from_secs(10)))
222
+ .map_err(|e| AppServerError::Io(e.to_string()))?;
223
+ let request = concat!(
224
+ "GET / HTTP/1.1\r\n",
225
+ "Host: localhost\r\n",
226
+ "Upgrade: websocket\r\n",
227
+ "Connection: Upgrade\r\n",
228
+ "Sec-WebSocket-Key: dGVhbS1hZ2VudC1jb2RleA==\r\n",
229
+ "Sec-WebSocket-Version: 13\r\n",
230
+ "\r\n"
231
+ );
232
+ stream
233
+ .write_all(request.as_bytes())
234
+ .map_err(|e| AppServerError::Io(e.to_string()))?;
235
+ let response = read_http_response(&mut stream)?;
236
+ if !response.starts_with("HTTP/1.1 101") && !response.starts_with("HTTP/1.0 101") {
237
+ return Err(AppServerError::ProtocolMismatch(format!(
238
+ "websocket upgrade failed: {}",
239
+ response.lines().next().unwrap_or("")
240
+ )));
241
+ }
242
+ Ok(Self { stream, next_id: 1 })
243
+ }
244
+
245
+ fn initialize(&mut self) -> Result<String, AppServerError> {
246
+ let result = self.request(json!({
247
+ "method": "initialize",
248
+ "params": {
249
+ "clientInfo": {
250
+ "name": "codex-appserver-team-agent",
251
+ "title": "Team Agent",
252
+ "version": env!("CARGO_PKG_VERSION")
253
+ },
254
+ "capabilities": {
255
+ "experimentalApi": true,
256
+ "requestAttestation": false
257
+ }
258
+ }
259
+ }))?;
260
+ let _ = self.notify(json!({"method": "initialized"}));
261
+ let user_agent = result
262
+ .get("userAgent")
263
+ .and_then(Value::as_str)
264
+ .filter(|value| !value.is_empty())
265
+ .ok_or(AppServerError::MissingUserAgent)?;
266
+ if !user_agent.to_ascii_lowercase().contains("codex") {
267
+ return Err(AppServerError::ProtocolMismatch(format!(
268
+ "unexpected userAgent: {user_agent}"
269
+ )));
270
+ }
271
+ Ok(user_agent.to_string())
272
+ }
273
+
274
+ fn resume(&mut self, thread_id: &str) -> Result<ThreadResume, AppServerError> {
275
+ let result = self.request(json!({
276
+ "method": "thread/resume",
277
+ "params": {"threadId": thread_id}
278
+ }))?;
279
+ let thread = result
280
+ .get("thread")
281
+ .ok_or_else(|| AppServerError::ProtocolMismatch("missing thread".to_string()))?;
282
+ let actual_thread_id = required_str(thread, "id")?.to_string();
283
+ let session_id = required_str(thread, "sessionId")?.to_string();
284
+ let cwd = result
285
+ .get("cwd")
286
+ .and_then(Value::as_str)
287
+ .or_else(|| thread.get("cwd").and_then(Value::as_str))
288
+ .filter(|s| !s.is_empty())
289
+ .ok_or_else(|| AppServerError::ProtocolMismatch("missing cwd".to_string()))?
290
+ .to_string();
291
+ Ok(ThreadResume {
292
+ thread_id: actual_thread_id,
293
+ session_id,
294
+ cwd,
295
+ })
296
+ }
297
+
298
+ fn turn_start(
299
+ &mut self,
300
+ thread_id: &str,
301
+ message_id: &str,
302
+ rendered: &str,
303
+ ) -> Result<AppServerSubmit, AppServerError> {
304
+ let result = self.request(json!({
305
+ "method": "turn/start",
306
+ "params": {
307
+ "threadId": thread_id,
308
+ "clientUserMessageId": message_id,
309
+ "input": [{
310
+ "type": "text",
311
+ "text": rendered,
312
+ "text_elements": []
313
+ }]
314
+ }
315
+ }))?;
316
+ let turn = result
317
+ .get("turn")
318
+ .ok_or_else(|| AppServerError::ProtocolMismatch("missing turn".to_string()))?;
319
+ let turn_id = required_str(turn, "id")?.to_string();
320
+ let status = required_str(turn, "status")?.to_string();
321
+ if status != "inProgress" {
322
+ return Err(AppServerError::ProtocolMismatch(format!(
323
+ "turn/start returned status {status}"
324
+ )));
325
+ }
326
+ Ok(AppServerSubmit {
327
+ turn_id,
328
+ turn_status: status,
329
+ })
330
+ }
331
+
332
+ fn notify(&mut self, mut payload: Value) -> Result<(), AppServerError> {
333
+ if let Some(obj) = payload.as_object_mut() {
334
+ obj.remove("id");
335
+ }
336
+ write_ws_text(&mut self.stream, &payload.to_string())
337
+ }
338
+
339
+ fn request(&mut self, mut payload: Value) -> Result<Value, AppServerError> {
340
+ let method = payload
341
+ .get("method")
342
+ .and_then(Value::as_str)
343
+ .unwrap_or("unknown")
344
+ .to_string();
345
+ let id = self.next_id;
346
+ self.next_id = self.next_id.saturating_add(1);
347
+ if let Some(obj) = payload.as_object_mut() {
348
+ obj.insert("id".to_string(), json!(id));
349
+ }
350
+ write_ws_text(&mut self.stream, &payload.to_string())?;
351
+ loop {
352
+ let frame = read_ws_text(&mut self.stream)?;
353
+ let value: Value = serde_json::from_str(&frame)
354
+ .map_err(|e| AppServerError::Json(format!("{e}: {frame}")))?;
355
+ if let Some(method) = value.get("method").and_then(Value::as_str) {
356
+ if is_approval_method(method) {
357
+ return Err(AppServerError::ApprovalUnsupported(method.to_string()));
358
+ }
359
+ continue;
360
+ }
361
+ if value.get("id").and_then(Value::as_u64) != Some(id) {
362
+ continue;
363
+ }
364
+ if let Some(error) = value.get("error") {
365
+ let message = error
366
+ .get("message")
367
+ .and_then(Value::as_str)
368
+ .unwrap_or("app-server request failed")
369
+ .to_string();
370
+ return match method.as_str() {
371
+ "thread/resume" => Err(AppServerError::ThreadNotLive(message)),
372
+ "turn/start" if looks_busy(&message) => {
373
+ Err(AppServerError::LeaderBusy(message))
374
+ }
375
+ "turn/start" => Err(AppServerError::ThreadNotLive(message)),
376
+ _ => Err(AppServerError::ProtocolMismatch(format!(
377
+ "{method} failed: {message}"
378
+ ))),
379
+ }
380
+ }
381
+ return value
382
+ .get("result")
383
+ .cloned()
384
+ .ok_or_else(|| AppServerError::ProtocolMismatch("missing result".to_string()));
385
+ }
386
+ }
387
+ }
388
+
389
+ fn is_approval_method(method: &str) -> bool {
390
+ method.contains("requestApproval")
391
+ || method.contains("requestUserInput")
392
+ || method.contains("elicitation/request")
393
+ }
394
+
395
+ fn looks_busy(message: &str) -> bool {
396
+ let lower = message.to_ascii_lowercase();
397
+ lower.contains("active turn") || lower.contains("already has") || lower.contains("busy")
398
+ }
399
+
400
+ fn read_http_response(stream: &mut UnixStream) -> Result<String, AppServerError> {
401
+ let mut data = Vec::new();
402
+ let mut buf = [0u8; 1];
403
+ while !data.ends_with(b"\r\n\r\n") {
404
+ stream
405
+ .read_exact(&mut buf)
406
+ .map_err(|e| AppServerError::Io(e.to_string()))?;
407
+ data.push(buf[0]);
408
+ if data.len() > 64 * 1024 {
409
+ return Err(AppServerError::ProtocolMismatch(
410
+ "oversized websocket handshake".to_string(),
411
+ ));
412
+ }
413
+ }
414
+ Ok(String::from_utf8_lossy(&data).to_string())
415
+ }
416
+
417
+ fn write_ws_text(stream: &mut UnixStream, text: &str) -> Result<(), AppServerError> {
418
+ let payload = text.as_bytes();
419
+ let mut frame = vec![0x81];
420
+ if payload.len() < 126 {
421
+ frame.push(0x80 | payload.len() as u8);
422
+ } else if payload.len() <= u16::MAX as usize {
423
+ frame.push(0x80 | 126);
424
+ frame.extend_from_slice(&(payload.len() as u16).to_be_bytes());
425
+ } else {
426
+ frame.push(0x80 | 127);
427
+ frame.extend_from_slice(&(payload.len() as u64).to_be_bytes());
428
+ }
429
+ let mask = [0x54, 0x41, 0x47, 0x54];
430
+ frame.extend_from_slice(&mask);
431
+ for (idx, byte) in payload.iter().enumerate() {
432
+ frame.push(*byte ^ mask[idx % 4]);
433
+ }
434
+ stream
435
+ .write_all(&frame)
436
+ .map_err(|e| AppServerError::Io(e.to_string()))
437
+ }
438
+
439
+ fn read_ws_text(stream: &mut UnixStream) -> Result<String, AppServerError> {
440
+ loop {
441
+ let mut header = [0u8; 2];
442
+ stream
443
+ .read_exact(&mut header)
444
+ .map_err(|e| AppServerError::Io(e.to_string()))?;
445
+ let opcode = header[0] & 0x0f;
446
+ if opcode == 0x8 {
447
+ return Err(AppServerError::ProtocolMismatch(
448
+ "websocket closed".to_string(),
449
+ ));
450
+ }
451
+ let masked = header[1] & 0x80 != 0;
452
+ let mut len = u64::from(header[1] & 0x7f);
453
+ if len == 126 {
454
+ let mut ext = [0u8; 2];
455
+ stream
456
+ .read_exact(&mut ext)
457
+ .map_err(|e| AppServerError::Io(e.to_string()))?;
458
+ len = u64::from(u16::from_be_bytes(ext));
459
+ } else if len == 127 {
460
+ let mut ext = [0u8; 8];
461
+ stream
462
+ .read_exact(&mut ext)
463
+ .map_err(|e| AppServerError::Io(e.to_string()))?;
464
+ len = u64::from_be_bytes(ext);
465
+ }
466
+ let mut mask = [0u8; 4];
467
+ if masked {
468
+ stream
469
+ .read_exact(&mut mask)
470
+ .map_err(|e| AppServerError::Io(e.to_string()))?;
471
+ }
472
+ let size = usize::try_from(len).map_err(|_| {
473
+ AppServerError::ProtocolMismatch("websocket frame too large".to_string())
474
+ })?;
475
+ let mut payload = vec![0u8; size];
476
+ stream
477
+ .read_exact(&mut payload)
478
+ .map_err(|e| AppServerError::Io(e.to_string()))?;
479
+ if masked {
480
+ for (idx, byte) in payload.iter_mut().enumerate() {
481
+ *byte ^= mask[idx % 4];
482
+ }
483
+ }
484
+ if opcode == 0x1 {
485
+ return Ok(String::from_utf8_lossy(&payload).to_string());
486
+ }
487
+ }
488
+ }