@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.
- 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 +74 -1
- package/crates/team-agent/src/cli/mod.rs +70 -25
- package/crates/team-agent/src/cli/named_address.rs +111 -0
- package/crates/team-agent/src/cli/send.rs +98 -3
- package/crates/team-agent/src/cli/status_port.rs +44 -6
- package/crates/team-agent/src/cli/tests/named_address.rs +131 -0
- package/crates/team-agent/src/cli/tests/run_delegation.rs +2 -0
- package/crates/team-agent/src/cli/types.rs +17 -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 +63 -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
|
@@ -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
|
+
}
|