@team-agent/installer 0.5.1 → 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 (37) 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 +74 -1
  9. package/crates/team-agent/src/cli/mod.rs +70 -25
  10. package/crates/team-agent/src/cli/named_address.rs +111 -0
  11. package/crates/team-agent/src/cli/send.rs +98 -3
  12. package/crates/team-agent/src/cli/status_port.rs +44 -6
  13. package/crates/team-agent/src/cli/tests/named_address.rs +131 -0
  14. package/crates/team-agent/src/cli/tests/run_delegation.rs +2 -0
  15. package/crates/team-agent/src/cli/types.rs +17 -0
  16. package/crates/team-agent/src/codex_app_server.rs +488 -0
  17. package/crates/team-agent/src/conpty/backend.rs +710 -0
  18. package/crates/team-agent/src/conpty/mod.rs +32 -0
  19. package/crates/team-agent/src/coordinator/backoff.rs +45 -6
  20. package/crates/team-agent/src/diagnose/orphans.rs +11 -3
  21. package/crates/team-agent/src/leader/lease.rs +144 -7
  22. package/crates/team-agent/src/leader/owner_bind.rs +5 -2
  23. package/crates/team-agent/src/leader/start.rs +18 -5
  24. package/crates/team-agent/src/leader/tests/lease_api.rs +172 -0
  25. package/crates/team-agent/src/lib.rs +23 -0
  26. package/crates/team-agent/src/lifecycle/launch.rs +127 -3
  27. package/crates/team-agent/src/lifecycle/restart/common.rs +70 -0
  28. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +7 -0
  29. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +63 -0
  30. package/crates/team-agent/src/messaging/delivery.rs +329 -9
  31. package/crates/team-agent/src/messaging/leader_receiver.rs +17 -138
  32. package/crates/team-agent/src/messaging/mod.rs +2 -2
  33. package/crates/team-agent/src/messaging/results.rs +78 -21
  34. package/crates/team-agent/src/messaging/tests/runtime.rs +232 -0
  35. package/crates/team-agent/src/tmux_backend.rs +31 -0
  36. package/crates/team-agent/src/transport_factory.rs +632 -0
  37. package/package.json +4 -4
@@ -75,6 +75,38 @@ fn leader_team(session: &str, pane: &str, socket: &str) -> serde_json::Value {
75
75
  })
76
76
  }
77
77
 
78
+ fn app_server_leader_team(
79
+ ws: &Path,
80
+ endpoint: &str,
81
+ thread_id: &str,
82
+ session_id: &str,
83
+ ) -> serde_json::Value {
84
+ json!({
85
+ "session_name": "alpha-session",
86
+ "agents": {},
87
+ "leader_receiver": {
88
+ "mode": "codex_app_server",
89
+ "transport_kind": "codex_app_server",
90
+ "status": "attached",
91
+ "provider": "codex",
92
+ "owner_epoch": 3,
93
+ "app_server": {
94
+ "socket": endpoint,
95
+ "thread_id": thread_id,
96
+ "session_id": session_id,
97
+ "cwd": ws.to_string_lossy(),
98
+ "cli_version": "codex-appserver-team-agent-test/0.139.0",
99
+ "bound_at": "2026-07-05T00:00:00Z"
100
+ }
101
+ },
102
+ "team_owner": {
103
+ "provider": "codex",
104
+ "transport_kind": "codex_app_server",
105
+ "owner_epoch": 3
106
+ }
107
+ })
108
+ }
109
+
78
110
  fn pane(session: &str, window: &str, pane_id: &str) -> PaneInfo {
79
111
  PaneInfo {
80
112
  pane_id: PaneId::new(pane_id),
@@ -323,6 +355,105 @@ fn resolve_leader_name_after_rebind() {
323
355
  let _ = std::fs::remove_dir_all(&ws);
324
356
  }
325
357
 
358
+ #[test]
359
+ fn resolve_app_server_leader_name_uses_typed_receiver_not_tmux_pane() {
360
+ let ws = named_ws("appserver-leader-resolve");
361
+ let fake = crate::app_server_test_support::FakeAppServer::start(
362
+ "named-resolve",
363
+ crate::app_server_test_support::FakeAppServerScript::happy(
364
+ "thread-live",
365
+ "session-live",
366
+ ws.to_str().unwrap(),
367
+ ),
368
+ );
369
+ seed_state(
370
+ &ws,
371
+ state_with_teams(json!({
372
+ "alpha": app_server_leader_team(&ws, fake.endpoint(), "thread-live", "session-live")
373
+ })),
374
+ );
375
+
376
+ let resolved = resolve_name_with_transport(&ws, "alpha/leader", &OfflineTransport::new())
377
+ .expect("app-server leader name resolves by typed receiver");
378
+
379
+ assert_eq!(resolved.target_kind, NamedTargetKind::Leader);
380
+ assert_eq!(resolved.transport_kind.as_deref(), Some("codex_app_server"));
381
+ assert!(
382
+ resolved.pane_id.is_empty(),
383
+ "app-server leader is not pane-addressed"
384
+ );
385
+ assert_eq!(
386
+ resolved
387
+ .app_server
388
+ .as_ref()
389
+ .and_then(|app| app.get("thread_id"))
390
+ .and_then(serde_json::Value::as_str),
391
+ Some("thread-live")
392
+ );
393
+ let _ = std::fs::remove_dir_all(&ws);
394
+ }
395
+
396
+ #[test]
397
+ fn resolve_app_server_leader_name_fails_closed_on_transport_conflict() {
398
+ let ws = named_ws("appserver-leader-conflict");
399
+ let mut team = app_server_leader_team(
400
+ &ws,
401
+ "unix:///tmp/team-agent-should-not-connect.sock",
402
+ "thread-live",
403
+ "session-live",
404
+ );
405
+ team["leader_receiver"]["mode"] = json!("direct_tmux");
406
+ seed_state(
407
+ &ws,
408
+ state_with_teams(json!({
409
+ "alpha": team
410
+ })),
411
+ );
412
+
413
+ let err = resolve_name_with_transport(&ws, "alpha/leader", &OfflineTransport::new())
414
+ .expect_err("conflicting mode/transport_kind must fail closed");
415
+
416
+ assert_eq!(err.kind, NamedAddressErrorKind::NameNotLive);
417
+ assert!(err.log.contains("mode=direct_tmux"), "{err}");
418
+ assert!(err.log.contains("transport_kind=codex_app_server"), "{err}");
419
+ let _ = std::fs::remove_dir_all(&ws);
420
+ }
421
+
422
+ #[test]
423
+ fn send_to_name_app_server_leader_submits_turn_without_tmux_pane() {
424
+ let ws = named_ws("appserver-leader-send");
425
+ let fake = crate::app_server_test_support::FakeAppServer::start(
426
+ "named-send",
427
+ crate::app_server_test_support::FakeAppServerScript::happy(
428
+ "thread-live",
429
+ "session-live",
430
+ ws.to_str().unwrap(),
431
+ ),
432
+ );
433
+ seed_state(
434
+ &ws,
435
+ state_with_teams(json!({
436
+ "alpha": app_server_leader_team(&ws, fake.endpoint(), "thread-live", "session-live")
437
+ })),
438
+ );
439
+ let args = named_send_args(&ws, Some("alpha/leader"), None, None, &["hello appserver"]);
440
+
441
+ let result = cmd_send(&args).expect("named app-server send should succeed");
442
+ let value = match result.output {
443
+ CmdOutput::Json(value) => value,
444
+ other => panic!("expected json output, got {other:?}"),
445
+ };
446
+
447
+ assert_eq!(value["ok"], json!(true));
448
+ assert_eq!(value["transport_kind"], json!("codex_app_server"));
449
+ assert_eq!(fake.received_turns().len(), 1);
450
+ assert_eq!(
451
+ fake.received_turns()[0]["params"]["clientUserMessageId"],
452
+ value["message_id"]
453
+ );
454
+ let _ = std::fs::remove_dir_all(&ws);
455
+ }
456
+
326
457
  #[test]
327
458
  fn resolve_name_invalid_syntax() {
328
459
  let ws = named_ws("invalid");
@@ -206,6 +206,7 @@ fn current_uid() -> Option<String> {
206
206
  team_id: None,
207
207
  yes: true,
208
208
  no_display: false,
209
+ backend: None,
209
210
  json: true,
210
211
  };
211
212
  let _ = cmd_quick_start(&args); // real quick_start compiles the spec before any coordinator/launch step
@@ -240,6 +241,7 @@ fn current_uid() -> Option<String> {
240
241
  team_id: None,
241
242
  yes: false,
242
243
  no_display: false,
244
+ backend: None,
243
245
  json: true,
244
246
  };
245
247
  let text = outcome_text(cmd_quick_start(&args));
@@ -272,6 +272,13 @@ pub struct QuickStartArgs {
272
272
  pub yes: bool,
273
273
  pub no_display: bool,
274
274
  pub json: bool,
275
+ /// 0.5.x Phase 1d Batch 2: explicit backend override
276
+ /// (`--backend <tmux|conpty>`). `None` = default (factory 5-layer
277
+ /// resolution; almost always `tmux` on a cold quick-start).
278
+ /// Explicit `Some("conpty")` on a host without a live shim client
279
+ /// surfaces `MuxUnavailable` honestly (CR C-1 ①) — NEVER silent
280
+ /// downgrade to tmux. Explicit `Some("pty")` refuses (CR C-1 ②).
281
+ pub backend: Option<String>,
275
282
  // Stage QR (quick-start/restart separation, design doc
276
283
  // .team/artifacts/quickstart-restart-separation-design.md): the
277
284
  // `fresh` field is intentionally REMOVED. Reset semantics now live
@@ -440,6 +447,16 @@ pub struct AttachLeaderArgs {
440
447
  pub json: bool,
441
448
  }
442
449
 
450
+ /// Explicit Codex app-server leader binding args.
451
+ #[derive(Debug, Clone, PartialEq, Eq)]
452
+ pub struct AttachAppServerLeaderArgs {
453
+ pub workspace: PathBuf,
454
+ pub team: Option<String>,
455
+ pub socket: String,
456
+ pub thread_id: String,
457
+ pub json: bool,
458
+ }
459
+
443
460
  /// `identity`(`parser.py:256`)。
444
461
  #[derive(Debug, Clone, PartialEq, Eq)]
445
462
  pub struct IdentityArgs {
@@ -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
+ }