@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.
- package/Cargo.lock +118 -9
- package/Cargo.toml +2 -2
- package/crates/team-agent/Cargo.toml +1 -0
- package/crates/team-agent/src/app_server_test_support.rs +258 -0
- package/crates/team-agent/src/cli/adapters.rs +32 -3
- package/crates/team-agent/src/cli/attach_app_server_leader.rs +16 -0
- package/crates/team-agent/src/cli/diagnose.rs +14 -5
- package/crates/team-agent/src/cli/emit.rs +111 -3
- package/crates/team-agent/src/cli/mod.rs +72 -25
- package/crates/team-agent/src/cli/named_address.rs +975 -0
- package/crates/team-agent/src/cli/send.rs +200 -1
- package/crates/team-agent/src/cli/status_port.rs +44 -6
- package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
- package/crates/team-agent/src/cli/tests/mod.rs +1 -0
- package/crates/team-agent/src/cli/tests/named_address.rs +586 -0
- package/crates/team-agent/src/cli/tests/run_delegation.rs +2 -0
- package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
- package/crates/team-agent/src/cli/types.rs +21 -0
- package/crates/team-agent/src/codex_app_server.rs +488 -0
- package/crates/team-agent/src/conpty/backend.rs +710 -0
- package/crates/team-agent/src/conpty/mod.rs +32 -0
- package/crates/team-agent/src/coordinator/backoff.rs +45 -6
- package/crates/team-agent/src/diagnose/orphans.rs +11 -3
- package/crates/team-agent/src/leader/lease.rs +144 -7
- package/crates/team-agent/src/leader/owner_bind.rs +5 -2
- package/crates/team-agent/src/leader/start.rs +18 -5
- package/crates/team-agent/src/leader/tests/lease_api.rs +172 -0
- package/crates/team-agent/src/lib.rs +23 -0
- package/crates/team-agent/src/lifecycle/launch.rs +127 -3
- package/crates/team-agent/src/lifecycle/restart/common.rs +70 -0
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +7 -0
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +90 -0
- package/crates/team-agent/src/messaging/delivery.rs +329 -9
- package/crates/team-agent/src/messaging/leader_receiver.rs +17 -138
- package/crates/team-agent/src/messaging/mod.rs +2 -2
- package/crates/team-agent/src/messaging/results.rs +78 -21
- package/crates/team-agent/src/messaging/tests/runtime.rs +232 -0
- package/crates/team-agent/src/tmux_backend.rs +31 -0
- package/crates/team-agent/src/transport_factory.rs +632 -0
- 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
|
+
}
|