@alwith/module-computer 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +94 -0
- package/README.md +36 -2
- package/THIRD_PARTY_NOTICES.md +12 -0
- package/dist/artifacts.d.ts +36 -0
- package/dist/artifacts.js +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +1 -0
- package/licenses/base64-LICENSE-MIT +21 -0
- package/licenses/cua-driver-LICENSE.md +21 -0
- package/licenses/cua-driver-THIRD_PARTY_NOTICES.md +41 -0
- package/licenses/image-LICENSE-MIT +25 -0
- package/licenses/xcap-LICENSE +201 -0
- package/package.json +32 -4
- package/proxy/stdio.mjs +10 -0
- package/rust/Cargo.lock +2651 -0
- package/rust/Cargo.toml +23 -0
- package/rust/src/gateway.rs +774 -0
- package/rust/src/lib.rs +408 -0
- package/rust/src/permissions.rs +58 -0
- package/rust/src/preview.rs +82 -0
- package/rust/src/wire.rs +48 -0
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
//! Session-bound MCP transport and FIFO desktop ownership. No conversation persistence.
|
|
2
|
+
use crate::Connection;
|
|
3
|
+
use serde::Serialize;
|
|
4
|
+
use serde_json::{Value, json};
|
|
5
|
+
use std::{
|
|
6
|
+
collections::{HashMap, HashSet, VecDeque},
|
|
7
|
+
sync::{Arc, Mutex},
|
|
8
|
+
time::{Duration, Instant},
|
|
9
|
+
};
|
|
10
|
+
use tokio::{
|
|
11
|
+
io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader},
|
|
12
|
+
net::{TcpListener, TcpStream},
|
|
13
|
+
process::Command,
|
|
14
|
+
sync::{Notify, watch},
|
|
15
|
+
};
|
|
16
|
+
use uuid::Uuid;
|
|
17
|
+
|
|
18
|
+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, specta::Type)]
|
|
19
|
+
#[serde(rename_all = "camelCase")]
|
|
20
|
+
pub struct Target {
|
|
21
|
+
pub pid: u32,
|
|
22
|
+
pub window_id: u32,
|
|
23
|
+
}
|
|
24
|
+
#[derive(Clone, Debug, Serialize, specta::Type)]
|
|
25
|
+
#[serde(rename_all = "camelCase")]
|
|
26
|
+
pub struct Activity {
|
|
27
|
+
pub session_id: String,
|
|
28
|
+
pub phase: String,
|
|
29
|
+
pub queue_position: usize,
|
|
30
|
+
pub owner_session_id: Option<String>,
|
|
31
|
+
pub action: Option<String>,
|
|
32
|
+
pub target: Option<Target>,
|
|
33
|
+
pub revision: u64,
|
|
34
|
+
}
|
|
35
|
+
struct PreviewCommand {
|
|
36
|
+
revision: u64,
|
|
37
|
+
focus: bool,
|
|
38
|
+
reply: tokio::sync::oneshot::Sender<Result<Value, String>>,
|
|
39
|
+
}
|
|
40
|
+
struct Entry {
|
|
41
|
+
session: Option<String>,
|
|
42
|
+
driver: Connection,
|
|
43
|
+
created: Instant,
|
|
44
|
+
cancel: watch::Sender<bool>,
|
|
45
|
+
connected: bool,
|
|
46
|
+
initialized: bool,
|
|
47
|
+
listed: bool,
|
|
48
|
+
tools: HashSet<String>,
|
|
49
|
+
labeled_tools: HashSet<String>,
|
|
50
|
+
preview: Option<tokio::sync::mpsc::UnboundedSender<PreviewCommand>>,
|
|
51
|
+
}
|
|
52
|
+
#[derive(Default)]
|
|
53
|
+
struct State {
|
|
54
|
+
entries: HashMap<String, Entry>,
|
|
55
|
+
owner: Option<String>,
|
|
56
|
+
queue: VecDeque<String>,
|
|
57
|
+
running: HashSet<String>,
|
|
58
|
+
blocked: HashSet<String>,
|
|
59
|
+
action: Option<String>,
|
|
60
|
+
target: Option<Target>,
|
|
61
|
+
revision: u64,
|
|
62
|
+
in_flight: usize,
|
|
63
|
+
release_pending: bool,
|
|
64
|
+
faulted: bool,
|
|
65
|
+
}
|
|
66
|
+
pub struct Gateway {
|
|
67
|
+
state: Mutex<State>,
|
|
68
|
+
changed: Notify,
|
|
69
|
+
address: Mutex<Option<String>>,
|
|
70
|
+
}
|
|
71
|
+
impl Default for Gateway {
|
|
72
|
+
fn default() -> Self {
|
|
73
|
+
Self { state: Mutex::new(State::default()), changed: Notify::new(), address: Mutex::new(None) }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
impl Gateway {
|
|
77
|
+
pub async fn listen(self: &Arc<Self>) -> Result<(), String> {
|
|
78
|
+
let listener = TcpListener::bind("127.0.0.1:0").await.map_err(|e| e.to_string())?;
|
|
79
|
+
*self.address.lock().unwrap() = Some(listener.local_addr().map_err(|e| e.to_string())?.to_string());
|
|
80
|
+
let weak = Arc::downgrade(self);
|
|
81
|
+
tokio::spawn(async move {
|
|
82
|
+
loop {
|
|
83
|
+
let Ok((stream, _)) = listener.accept().await else { break };
|
|
84
|
+
let Some(gateway) = weak.upgrade() else { break };
|
|
85
|
+
tokio::spawn(async move {
|
|
86
|
+
let _ = gateway.serve(stream).await;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
Ok(())
|
|
91
|
+
}
|
|
92
|
+
pub fn register(&self, driver: Connection) -> Result<(String, String), String> {
|
|
93
|
+
let address = self.address.lock().unwrap().clone().ok_or("Computer gateway is not ready")?;
|
|
94
|
+
let token = Uuid::new_v4().to_string();
|
|
95
|
+
let mut state = self.state.lock().unwrap();
|
|
96
|
+
state.entries.retain(|_, e| e.connected || e.created.elapsed() < Duration::from_secs(300));
|
|
97
|
+
state.entries.insert(
|
|
98
|
+
token.clone(),
|
|
99
|
+
Entry {
|
|
100
|
+
session: None,
|
|
101
|
+
driver,
|
|
102
|
+
created: Instant::now(),
|
|
103
|
+
cancel: watch::channel(false).0,
|
|
104
|
+
connected: false,
|
|
105
|
+
initialized: false,
|
|
106
|
+
listed: false,
|
|
107
|
+
tools: HashSet::new(),
|
|
108
|
+
labeled_tools: HashSet::new(),
|
|
109
|
+
preview: None,
|
|
110
|
+
},
|
|
111
|
+
);
|
|
112
|
+
Ok((token, address))
|
|
113
|
+
}
|
|
114
|
+
pub fn bind(&self, token: &str, session: &str) -> Result<(), String> {
|
|
115
|
+
if session.is_empty() {
|
|
116
|
+
return Err("Missing conversation identity".into());
|
|
117
|
+
}
|
|
118
|
+
let mut s = self.state.lock().unwrap();
|
|
119
|
+
if !s.entries.contains_key(token) {
|
|
120
|
+
return Err("Computer connection expired".into());
|
|
121
|
+
}
|
|
122
|
+
let previous: Vec<_> = s
|
|
123
|
+
.entries
|
|
124
|
+
.iter()
|
|
125
|
+
.filter(|(key, e)| key.as_str() != token && e.session.as_deref() == Some(session))
|
|
126
|
+
.map(|(key, _)| key.clone())
|
|
127
|
+
.collect();
|
|
128
|
+
if previous.iter().any(|key| s.owner.as_ref() == Some(key)) {
|
|
129
|
+
return Err("Cannot replace an active computer connection".into());
|
|
130
|
+
}
|
|
131
|
+
if s.entries[token].session.as_deref().is_some_and(|id| id != session) {
|
|
132
|
+
return Err("Computer connection is already bound".into());
|
|
133
|
+
}
|
|
134
|
+
for key in previous {
|
|
135
|
+
if let Some(e) = s.entries.remove(&key) {
|
|
136
|
+
e.cancel.send_replace(true);
|
|
137
|
+
}
|
|
138
|
+
s.queue.retain(|queued| queued != &key);
|
|
139
|
+
}
|
|
140
|
+
let e = s.entries.get_mut(token).ok_or("Computer connection expired")?;
|
|
141
|
+
if e.session.as_deref().is_some_and(|id| id != session) {
|
|
142
|
+
return Err("Computer connection is already bound".into());
|
|
143
|
+
}
|
|
144
|
+
e.session = Some(session.into());
|
|
145
|
+
drop(s);
|
|
146
|
+
self.changed.notify_waiters();
|
|
147
|
+
Ok(())
|
|
148
|
+
}
|
|
149
|
+
/// Readiness belongs to the actual Codex MCP connection, not a separate probe.
|
|
150
|
+
pub async fn ensure_ready(&self, session: &str) -> Result<(), String> {
|
|
151
|
+
tokio::time::timeout(Duration::from_secs(20), async {
|
|
152
|
+
loop {
|
|
153
|
+
{
|
|
154
|
+
let s = self.state.lock().unwrap();
|
|
155
|
+
if s.faulted {
|
|
156
|
+
return Err("Computer transport needs to restart".into());
|
|
157
|
+
}
|
|
158
|
+
let e = s
|
|
159
|
+
.entries
|
|
160
|
+
.values()
|
|
161
|
+
.find(|e| e.session.as_deref() == Some(session))
|
|
162
|
+
.ok_or("Computer connection closed; retry to reconnect")?;
|
|
163
|
+
if *e.cancel.borrow() {
|
|
164
|
+
return Err("Computer connection revoked".into());
|
|
165
|
+
}
|
|
166
|
+
if e.initialized && e.listed {
|
|
167
|
+
if ["list_windows", "get_window_state", "click", "run_steps"]
|
|
168
|
+
.iter()
|
|
169
|
+
.all(|name| e.tools.contains(*name))
|
|
170
|
+
{
|
|
171
|
+
return Ok(());
|
|
172
|
+
}
|
|
173
|
+
return Err("Computer server is missing required tools".into());
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
tokio::time::sleep(Duration::from_millis(50)).await;
|
|
177
|
+
}
|
|
178
|
+
})
|
|
179
|
+
.await
|
|
180
|
+
.map_err(|_| "Computer MCP initialization timed out; retry to reconnect".to_string())?
|
|
181
|
+
}
|
|
182
|
+
fn observe_discovery(&self, token: &str, request: &Value, response: &Value) {
|
|
183
|
+
let mut s = self.state.lock().unwrap();
|
|
184
|
+
let Some(e) = s.entries.get_mut(token) else { return };
|
|
185
|
+
match request["method"].as_str() {
|
|
186
|
+
Some("initialize") => e.initialized = response["result"]["protocolVersion"].is_string(),
|
|
187
|
+
Some("tools/list") => {
|
|
188
|
+
e.listed = false;
|
|
189
|
+
if let Some(tools) = response["result"]["tools"].as_array() {
|
|
190
|
+
if request["params"]["cursor"].is_null() {
|
|
191
|
+
e.tools.clear();
|
|
192
|
+
e.labeled_tools.clear();
|
|
193
|
+
}
|
|
194
|
+
e.labeled_tools.extend(
|
|
195
|
+
tools
|
|
196
|
+
.iter()
|
|
197
|
+
.filter(|t| t["inputSchema"]["properties"].get("session").is_some())
|
|
198
|
+
.filter_map(|t| t["name"].as_str().map(String::from)),
|
|
199
|
+
);
|
|
200
|
+
e.tools.extend(tools.iter().filter_map(|t| t["name"].as_str().map(String::from)));
|
|
201
|
+
e.listed = response["result"]["nextCursor"].is_null();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
_ => {}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/// Runtime is authoritative. Waiting for user input retains the lease.
|
|
208
|
+
pub fn sync_running(&self, running: HashSet<String>) {
|
|
209
|
+
let mut s = self.state.lock().unwrap();
|
|
210
|
+
s.blocked.retain(|id| running.contains(id));
|
|
211
|
+
s.running = running;
|
|
212
|
+
let entries = &s.entries;
|
|
213
|
+
let active = &s.running;
|
|
214
|
+
let keep: VecDeque<_> = s
|
|
215
|
+
.queue
|
|
216
|
+
.iter()
|
|
217
|
+
.filter(|key| entries.get(*key).and_then(|e| e.session.as_ref()).is_some_and(|id| active.contains(id)))
|
|
218
|
+
.cloned()
|
|
219
|
+
.collect();
|
|
220
|
+
s.queue = keep;
|
|
221
|
+
let finished = s
|
|
222
|
+
.owner
|
|
223
|
+
.as_ref()
|
|
224
|
+
.and_then(|k| s.entries.get(k))
|
|
225
|
+
.and_then(|e| e.session.as_ref())
|
|
226
|
+
.is_some_and(|id| !s.running.contains(id));
|
|
227
|
+
if finished {
|
|
228
|
+
if s.in_flight == 0 { release(&mut s) } else { s.release_pending = true }
|
|
229
|
+
}
|
|
230
|
+
drop(s);
|
|
231
|
+
self.changed.notify_waiters();
|
|
232
|
+
}
|
|
233
|
+
pub fn stop_session(&self, session: &str) {
|
|
234
|
+
let mut s = self.state.lock().unwrap();
|
|
235
|
+
if s.running.contains(session) {
|
|
236
|
+
s.blocked.insert(session.into());
|
|
237
|
+
}
|
|
238
|
+
let keys: Vec<_> =
|
|
239
|
+
s.entries.iter().filter(|(_, e)| e.session.as_deref() == Some(session)).map(|(k, _)| k.clone()).collect();
|
|
240
|
+
for key in keys {
|
|
241
|
+
s.queue.retain(|k| k != &key);
|
|
242
|
+
}
|
|
243
|
+
// An already dispatched native operation must drain before a new owner can enter.
|
|
244
|
+
if s.owner.as_ref().and_then(|k| s.entries.get(k)).and_then(|e| e.session.as_deref()) == Some(session) {
|
|
245
|
+
if s.in_flight == 0 { release(&mut s) } else { s.release_pending = true }
|
|
246
|
+
}
|
|
247
|
+
drop(s);
|
|
248
|
+
self.changed.notify_waiters();
|
|
249
|
+
}
|
|
250
|
+
pub fn stop_all(&self) {
|
|
251
|
+
let sessions: Vec<_> = self.state.lock().unwrap().entries.values().filter_map(|e| e.session.clone()).collect();
|
|
252
|
+
for id in sessions {
|
|
253
|
+
self.stop_session(&id)
|
|
254
|
+
}
|
|
255
|
+
let mut s = self.state.lock().unwrap();
|
|
256
|
+
for e in s.entries.values() {
|
|
257
|
+
e.cancel.send_replace(true);
|
|
258
|
+
}
|
|
259
|
+
*s = State::default();
|
|
260
|
+
drop(s);
|
|
261
|
+
self.changed.notify_waiters();
|
|
262
|
+
}
|
|
263
|
+
pub fn faulted(&self) -> bool {
|
|
264
|
+
self.state.lock().unwrap().faulted
|
|
265
|
+
}
|
|
266
|
+
pub fn activity(&self, session: &str) -> Activity {
|
|
267
|
+
let s = self.state.lock().unwrap();
|
|
268
|
+
let owner = s.owner.as_ref().and_then(|k| s.entries.get(k)).and_then(|e| e.session.clone());
|
|
269
|
+
let position = s
|
|
270
|
+
.queue
|
|
271
|
+
.iter()
|
|
272
|
+
.position(|k| s.entries.get(k).and_then(|e| e.session.as_deref()) == Some(session))
|
|
273
|
+
.map_or(0, |p| p + 1);
|
|
274
|
+
let active = owner.as_deref() == Some(session);
|
|
275
|
+
Activity {
|
|
276
|
+
session_id: session.into(),
|
|
277
|
+
phase: if active {
|
|
278
|
+
"active"
|
|
279
|
+
} else if position > 0 {
|
|
280
|
+
"queued"
|
|
281
|
+
} else {
|
|
282
|
+
"idle"
|
|
283
|
+
}
|
|
284
|
+
.into(),
|
|
285
|
+
queue_position: position,
|
|
286
|
+
owner_session_id: owner,
|
|
287
|
+
action: active.then(|| s.action.clone()).flatten(),
|
|
288
|
+
target: active.then(|| s.target.clone()).flatten(),
|
|
289
|
+
revision: s.revision,
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
async fn preview_command(&self, session: &str, revision: u64, focus: bool) -> Result<Value, String> {
|
|
293
|
+
let (reply, response) = tokio::sync::oneshot::channel();
|
|
294
|
+
{
|
|
295
|
+
let s = self.state.lock().unwrap();
|
|
296
|
+
let e = s.owner.as_ref().and_then(|token| s.entries.get(token)).ok_or("No active computer session")?;
|
|
297
|
+
if e.session.as_deref() != Some(session) || *e.cancel.borrow() || s.revision != revision {
|
|
298
|
+
return Err("Computer preview changed; try again".into());
|
|
299
|
+
}
|
|
300
|
+
e.preview
|
|
301
|
+
.as_ref()
|
|
302
|
+
.ok_or("Computer connection unavailable")?
|
|
303
|
+
.send(PreviewCommand { revision, focus, reply })
|
|
304
|
+
.map_err(|_| "Computer connection closed")?;
|
|
305
|
+
}
|
|
306
|
+
tokio::time::timeout(Duration::from_secs(if focus { 5 } else { 1 }), response)
|
|
307
|
+
.await
|
|
308
|
+
.map_err(|_| "Computer preview request timed out")?
|
|
309
|
+
.map_err(|_| "Computer connection closed")?
|
|
310
|
+
}
|
|
311
|
+
/// Internal reads share the MCP transport that owns the cursor session.
|
|
312
|
+
pub async fn cursor_position(&self, session: &str) -> Option<crate::preview::CursorPosition> {
|
|
313
|
+
let revision = self.activity(session).revision;
|
|
314
|
+
let response = self.preview_command(session, revision, false).await.ok()?;
|
|
315
|
+
let data = &response["structuredContent"];
|
|
316
|
+
if data["enabled"] != true {
|
|
317
|
+
return None;
|
|
318
|
+
}
|
|
319
|
+
let x = data["position"]["x"].as_f64()?;
|
|
320
|
+
let y = data["position"]["y"].as_f64()?;
|
|
321
|
+
(x.is_finite() && y.is_finite()).then_some(crate::preview::CursorPosition { x, y })
|
|
322
|
+
}
|
|
323
|
+
pub async fn focus_preview(&self, session: &str, revision: u64) -> Result<(), String> {
|
|
324
|
+
let response = self.preview_command(session, revision, true).await?;
|
|
325
|
+
if response["isError"] == true || response["structuredContent"]["activated"] != true {
|
|
326
|
+
return Err("The controlled window could not be focused".into());
|
|
327
|
+
}
|
|
328
|
+
Ok(())
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async fn acquire(&self, token: &str, args: &Value, tool: &str) -> Result<(), String> {
|
|
332
|
+
loop {
|
|
333
|
+
let notified = self.changed.notified();
|
|
334
|
+
{
|
|
335
|
+
let mut s = self.state.lock().unwrap();
|
|
336
|
+
if s.faulted {
|
|
337
|
+
return Err("Computer driver transport failed; restarting is required".into());
|
|
338
|
+
}
|
|
339
|
+
let e = s.entries.get(token).ok_or("Computer connection closed")?;
|
|
340
|
+
if *e.cancel.borrow() {
|
|
341
|
+
return Err("Computer operation stopped".into());
|
|
342
|
+
}
|
|
343
|
+
if !e.initialized || !e.listed || !e.tools.contains(tool) {
|
|
344
|
+
return Err("Computer tool is not ready or not registered".into());
|
|
345
|
+
}
|
|
346
|
+
if e.session.is_none() {
|
|
347
|
+
return Err("Computer connection is not bound to a conversation".into());
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if let Some(id) = e.session.clone() {
|
|
351
|
+
if s.blocked.contains(&id) || !s.running.contains(&id) {
|
|
352
|
+
return Err("Computer turn is not running".into());
|
|
353
|
+
}
|
|
354
|
+
if s.owner.as_deref() == Some(token)
|
|
355
|
+
|| (s.owner.is_none() && s.queue.front().is_none_or(|k| k == token))
|
|
356
|
+
{
|
|
357
|
+
s.queue.retain(|k| k != token);
|
|
358
|
+
s.owner = Some(token.into());
|
|
359
|
+
s.in_flight += 1;
|
|
360
|
+
s.action = Some(tool.into());
|
|
361
|
+
let next_target = target(args);
|
|
362
|
+
let changed_app = args
|
|
363
|
+
.get("pid")
|
|
364
|
+
.and_then(Value::as_u64)
|
|
365
|
+
.is_some_and(|pid| s.target.as_ref().is_some_and(|t| u64::from(t.pid) != pid));
|
|
366
|
+
let clears_target = matches!(tool, "launch_app" | "get_desktop_state") || changed_app;
|
|
367
|
+
if (next_target.is_some() || clears_target) && s.target != next_target {
|
|
368
|
+
s.target = next_target;
|
|
369
|
+
s.revision += 1;
|
|
370
|
+
}
|
|
371
|
+
return Ok(());
|
|
372
|
+
}
|
|
373
|
+
if !s.queue.iter().any(|k| k == token) {
|
|
374
|
+
s.queue.push_back(token.into());
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
notified.await;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
fn complete(&self, token: &str, response: &Value) {
|
|
382
|
+
let mut s = self.state.lock().unwrap();
|
|
383
|
+
if s.owner.as_deref() != Some(token) {
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
if let Some(t) = target(&response["result"]["structuredContent"])
|
|
387
|
+
&& s.target.as_ref() != Some(&t)
|
|
388
|
+
{
|
|
389
|
+
s.target = Some(t);
|
|
390
|
+
s.revision += 1;
|
|
391
|
+
}
|
|
392
|
+
s.in_flight = s.in_flight.saturating_sub(1);
|
|
393
|
+
let cancelled = s.entries.get(token).is_none_or(|e| *e.cancel.borrow());
|
|
394
|
+
if s.in_flight == 0 && (s.release_pending || cancelled) {
|
|
395
|
+
release(&mut s)
|
|
396
|
+
}
|
|
397
|
+
drop(s);
|
|
398
|
+
self.changed.notify_waiters();
|
|
399
|
+
}
|
|
400
|
+
fn disconnect(&self, token: &str) {
|
|
401
|
+
let mut s = self.state.lock().unwrap();
|
|
402
|
+
s.queue.retain(|k| k != token);
|
|
403
|
+
// EOF while a call is outstanding is not proof that the native action stopped.
|
|
404
|
+
if s.owner.as_deref() == Some(token) && s.in_flight == 0 {
|
|
405
|
+
release(&mut s)
|
|
406
|
+
}
|
|
407
|
+
if s.owner.as_deref() != Some(token) {
|
|
408
|
+
s.entries.remove(token);
|
|
409
|
+
}
|
|
410
|
+
drop(s);
|
|
411
|
+
self.changed.notify_waiters();
|
|
412
|
+
}
|
|
413
|
+
async fn serve(self: Arc<Self>, stream: TcpStream) -> Result<(), String> {
|
|
414
|
+
let (reader, mut writer) = stream.into_split();
|
|
415
|
+
let mut reader = BufReader::new(reader);
|
|
416
|
+
let token = tokio::time::timeout(Duration::from_secs(5), read_line(&mut reader, 128))
|
|
417
|
+
.await
|
|
418
|
+
.map_err(|_| "Gateway authentication timed out")??;
|
|
419
|
+
let (driver, mut cancel) = {
|
|
420
|
+
let mut s = self.state.lock().unwrap();
|
|
421
|
+
let e = s.entries.get_mut(token.trim()).ok_or("Unknown computer connection")?;
|
|
422
|
+
if e.connected || *e.cancel.borrow() {
|
|
423
|
+
return Err("Computer connection cannot reconnect".into());
|
|
424
|
+
}
|
|
425
|
+
e.connected = true;
|
|
426
|
+
(clone_connection(&e.driver), e.cancel.subscribe())
|
|
427
|
+
};
|
|
428
|
+
let token = token.trim().to_owned();
|
|
429
|
+
let result = self.clone().relay(&token, driver, &mut cancel, &mut reader, &mut writer).await;
|
|
430
|
+
{
|
|
431
|
+
let mut state = self.state.lock().unwrap();
|
|
432
|
+
if state.owner.as_deref() == Some(&token) && state.in_flight > 0 {
|
|
433
|
+
state.faulted = true;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
self.disconnect(&token);
|
|
437
|
+
result
|
|
438
|
+
}
|
|
439
|
+
async fn relay(
|
|
440
|
+
self: Arc<Self>, token: &str, driver: Connection, cancel: &mut watch::Receiver<bool>,
|
|
441
|
+
reader: &mut BufReader<tokio::net::tcp::OwnedReadHalf>, writer: &mut tokio::net::tcp::OwnedWriteHalf,
|
|
442
|
+
) -> Result<(), String> {
|
|
443
|
+
use std::process::Stdio;
|
|
444
|
+
let mut command = Command::new(&driver.command);
|
|
445
|
+
command.env_clear();
|
|
446
|
+
for (key, value) in std::env::vars_os() {
|
|
447
|
+
if crate::inherited_environment(&key.to_string_lossy()) {
|
|
448
|
+
command.env(key, value);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
command
|
|
452
|
+
.args(&driver.args)
|
|
453
|
+
.envs(driver.env.iter().map(|e| (&e.name, &e.value)))
|
|
454
|
+
.stdin(Stdio::piped())
|
|
455
|
+
.stdout(Stdio::piped())
|
|
456
|
+
.stderr(Stdio::null())
|
|
457
|
+
.kill_on_drop(true);
|
|
458
|
+
let mut child = command.spawn().map_err(|e| e.to_string())?;
|
|
459
|
+
let mut input = child.stdin.take().ok_or("Missing MCP stdin")?;
|
|
460
|
+
let mut output = BufReader::new(child.stdout.take().ok_or("Missing MCP stdout")?);
|
|
461
|
+
let (tx, mut rx) = tokio::sync::mpsc::channel::<String>(16);
|
|
462
|
+
// Read ahead while a request waits in the queue, so EOF cancels it immediately.
|
|
463
|
+
let incoming = async {
|
|
464
|
+
loop {
|
|
465
|
+
match read_line(reader, 1024 * 1024).await {
|
|
466
|
+
Ok(line) => {
|
|
467
|
+
if tx.send(line).await.is_err() {
|
|
468
|
+
break;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
Err(_) => {
|
|
472
|
+
let session = self.state.lock().unwrap().entries.get(token).and_then(|e| e.session.clone());
|
|
473
|
+
if let Some(session) = session {
|
|
474
|
+
self.stop_session(&session);
|
|
475
|
+
}
|
|
476
|
+
break;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
drop(tx);
|
|
481
|
+
};
|
|
482
|
+
let (preview_tx, mut preview_rx) = tokio::sync::mpsc::unbounded_channel::<PreviewCommand>();
|
|
483
|
+
self.state.lock().unwrap().entries.get_mut(token).ok_or("Computer connection closed")?.preview =
|
|
484
|
+
Some(preview_tx);
|
|
485
|
+
let forwarding = async {
|
|
486
|
+
loop {
|
|
487
|
+
let line = tokio::select! {
|
|
488
|
+
line = rx.recv() => match line { Some(line) => line, None => break },
|
|
489
|
+
Some(command) = preview_rx.recv() => {
|
|
490
|
+
if command.reply.is_closed() { continue; }
|
|
491
|
+
let args = {
|
|
492
|
+
let mut s = self.state.lock().unwrap();
|
|
493
|
+
if s.owner.as_deref() != Some(token) || s.revision != command.revision || s.release_pending {
|
|
494
|
+
let _ = command.reply.send(Err("Computer preview changed; try again".into()));
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
let mut args = json!({"session":format!("U {}", &token[..8])});
|
|
498
|
+
if command.focus {
|
|
499
|
+
let Some(target) = s.target.as_ref() else {
|
|
500
|
+
let _ = command.reply.send(Err("No controlled window".into()));
|
|
501
|
+
continue;
|
|
502
|
+
};
|
|
503
|
+
args["pid"] = json!(target.pid);
|
|
504
|
+
args["window_id"] = json!(target.window_id);
|
|
505
|
+
}
|
|
506
|
+
s.in_flight += 1;
|
|
507
|
+
args
|
|
508
|
+
};
|
|
509
|
+
let id = format!("preview-{}", Uuid::new_v4());
|
|
510
|
+
let name = if command.focus { "bring_to_front" } else { "get_agent_cursor_state" };
|
|
511
|
+
let request = json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":name,"arguments":args}});
|
|
512
|
+
write_line(&mut input, &request.to_string()).await?;
|
|
513
|
+
loop {
|
|
514
|
+
let line = read_line(&mut output, 16 * 1024 * 1024).await?;
|
|
515
|
+
let response: Value = serde_json::from_str(&line).map_err(|e| e.to_string())?;
|
|
516
|
+
if response["id"] == id {
|
|
517
|
+
self.complete(token, &json!({}));
|
|
518
|
+
let result = response.get("result").cloned().ok_or_else(|| "Computer preview request failed".to_owned());
|
|
519
|
+
let _ = command.reply.send(result);
|
|
520
|
+
break;
|
|
521
|
+
}
|
|
522
|
+
write_line(writer, &line).await?;
|
|
523
|
+
}
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
};
|
|
527
|
+
let mut request: Value = serde_json::from_str(&line).map_err(|e| e.to_string())?;
|
|
528
|
+
let call = request["method"] == "tools/call";
|
|
529
|
+
if call {
|
|
530
|
+
let name = request["params"]["name"].as_str().ok_or("Missing tool name")?.to_owned();
|
|
531
|
+
if let Err(error) = self.acquire(token, &request["params"]["arguments"], &name).await {
|
|
532
|
+
let reply = json!({"jsonrpc":"2.0","id":request["id"],"result":{"isError":true,"content":[{"type":"text","text":error}]}});
|
|
533
|
+
write_line(writer, &reply.to_string()).await?;
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
if call {
|
|
538
|
+
let s = self.state.lock().unwrap();
|
|
539
|
+
if s.entries
|
|
540
|
+
.get(token)
|
|
541
|
+
.is_some_and(|e| e.labeled_tools.contains(request["params"]["name"].as_str().unwrap_or("")))
|
|
542
|
+
{
|
|
543
|
+
let arguments = &mut request["params"]["arguments"];
|
|
544
|
+
if arguments.is_null() {
|
|
545
|
+
*arguments = json!({});
|
|
546
|
+
}
|
|
547
|
+
arguments
|
|
548
|
+
.as_object_mut()
|
|
549
|
+
.ok_or("Tool arguments must be an object")?
|
|
550
|
+
.insert("session".into(), json!(format!("U {}", &token[..8])));
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
write_line(&mut input, &request.to_string()).await?;
|
|
554
|
+
if request.get("id").is_none() {
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
loop {
|
|
558
|
+
let response = read_line(&mut output, 16 * 1024 * 1024).await?;
|
|
559
|
+
let value: Value = serde_json::from_str(&response).map_err(|e| e.to_string())?;
|
|
560
|
+
let matched = value.get("id") == request.get("id");
|
|
561
|
+
if matched {
|
|
562
|
+
self.observe_discovery(token, &request, &value);
|
|
563
|
+
}
|
|
564
|
+
if call && matched {
|
|
565
|
+
self.complete(token, &value)
|
|
566
|
+
}
|
|
567
|
+
write_line(writer, &response).await?;
|
|
568
|
+
if matched {
|
|
569
|
+
break;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
Ok::<(), String>(())
|
|
574
|
+
};
|
|
575
|
+
// EOF stops queued work but lets an outstanding native action return before handoff.
|
|
576
|
+
let drain = async {
|
|
577
|
+
tokio::pin!(incoming, forwarding);
|
|
578
|
+
tokio::select! {
|
|
579
|
+
result = &mut forwarding => result,
|
|
580
|
+
_ = &mut incoming => tokio::time::timeout(Duration::from_secs(120), &mut forwarding).await.map_err(|_| "Native operation did not drain")?,
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
tokio::select! {result=drain=>result,_=cancel.changed()=>Err("Computer connection stopped".into())}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
fn clone_connection(c: &Connection) -> Connection {
|
|
587
|
+
Connection {
|
|
588
|
+
generation: c.generation.clone(),
|
|
589
|
+
command: c.command.clone(),
|
|
590
|
+
args: c.args.clone(),
|
|
591
|
+
env: c.env.iter().map(|e| crate::Environment { name: e.name.clone(), value: e.value.clone() }).collect(),
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
fn release(s: &mut State) {
|
|
595
|
+
s.owner = None;
|
|
596
|
+
s.target = None;
|
|
597
|
+
s.action = None;
|
|
598
|
+
s.revision += 1;
|
|
599
|
+
s.release_pending = false;
|
|
600
|
+
}
|
|
601
|
+
fn target(v: &Value) -> Option<Target> {
|
|
602
|
+
Some(Target {
|
|
603
|
+
pid: u32::try_from(v.get("pid")?.as_u64()?).ok()?,
|
|
604
|
+
window_id: u32::try_from(v.get("window_id")?.as_u64()?).ok()?,
|
|
605
|
+
})
|
|
606
|
+
}
|
|
607
|
+
async fn read_line<R: tokio::io::AsyncBufRead + Unpin>(r: &mut R, limit: u64) -> Result<String, String> {
|
|
608
|
+
let mut line = String::new();
|
|
609
|
+
r.take(limit + 1).read_line(&mut line).await.map_err(|e| e.to_string())?;
|
|
610
|
+
if line.len() as u64 > limit || !line.ends_with('\n') {
|
|
611
|
+
return Err("Invalid MCP framing or closed connection".into());
|
|
612
|
+
}
|
|
613
|
+
Ok(line)
|
|
614
|
+
}
|
|
615
|
+
async fn write_line<W: tokio::io::AsyncWrite + Unpin>(w: &mut W, line: &str) -> Result<(), String> {
|
|
616
|
+
w.write_all(line.trim_end_matches('\n').as_bytes()).await.map_err(|e| e.to_string())?;
|
|
617
|
+
w.write_all(b"\n").await.map_err(|e| e.to_string())
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
#[cfg(test)]
|
|
621
|
+
mod tests {
|
|
622
|
+
use super::*;
|
|
623
|
+
fn gateway() -> Gateway {
|
|
624
|
+
let g = Gateway::default();
|
|
625
|
+
*g.address.lock().unwrap() = Some("127.0.0.1:1".into());
|
|
626
|
+
g
|
|
627
|
+
}
|
|
628
|
+
#[tokio::test]
|
|
629
|
+
async fn preview_commands_use_the_owner_transport_and_reject_stale_or_other_sessions() {
|
|
630
|
+
let g = gateway();
|
|
631
|
+
let token = connection(&g, "a");
|
|
632
|
+
g.sync_running(["a".into()].into());
|
|
633
|
+
g.acquire(&token, &json!({"pid":1,"window_id":2}), "click").await.unwrap();
|
|
634
|
+
g.complete(&token, &json!({}));
|
|
635
|
+
let revision = g.activity("a").revision;
|
|
636
|
+
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
|
637
|
+
g.state.lock().unwrap().entries.get_mut(&token).unwrap().preview = Some(tx);
|
|
638
|
+
assert!(g.focus_preview("b", revision).await.is_err());
|
|
639
|
+
assert!(g.focus_preview("a", revision + 1).await.is_err());
|
|
640
|
+
assert!(rx.try_recv().is_err());
|
|
641
|
+
let (cursor, ()) = tokio::join!(g.cursor_position("a"), async {
|
|
642
|
+
let command = rx.recv().await.unwrap();
|
|
643
|
+
assert!(!command.focus);
|
|
644
|
+
command.reply.send(Ok(json!({"structuredContent":{"enabled":true,"position":{"x":40,"y":50}}}))).unwrap();
|
|
645
|
+
});
|
|
646
|
+
assert_eq!(cursor.unwrap().x, 40.0);
|
|
647
|
+
let (focused, ()) = tokio::join!(g.focus_preview("a", revision), async {
|
|
648
|
+
let command = rx.recv().await.unwrap();
|
|
649
|
+
assert!(command.focus);
|
|
650
|
+
command.reply.send(Ok(json!({"structuredContent":{"activated":true}}))).unwrap();
|
|
651
|
+
});
|
|
652
|
+
focused.unwrap();
|
|
653
|
+
}
|
|
654
|
+
#[tokio::test]
|
|
655
|
+
async fn readiness_and_replacement_are_session_scoped() {
|
|
656
|
+
let g = gateway();
|
|
657
|
+
*g.address.lock().unwrap() = Some("127.0.0.1:1".into());
|
|
658
|
+
let a = connection(&g, "a");
|
|
659
|
+
let b = connection(&g, "b");
|
|
660
|
+
assert!(g.ensure_ready("a").await.is_ok());
|
|
661
|
+
g.observe_discovery(&a, &json!({"method":"tools/list"}), &json!({"result":{"tools":[]}}));
|
|
662
|
+
assert!(g.ensure_ready("a").await.unwrap_err().contains("missing required"));
|
|
663
|
+
assert!(g.ensure_ready("b").await.is_ok());
|
|
664
|
+
let replacement = connection(&g, "a");
|
|
665
|
+
assert!(!g.state.lock().unwrap().entries.contains_key(&a));
|
|
666
|
+
assert!(g.state.lock().unwrap().entries.contains_key(&b));
|
|
667
|
+
assert!(g.state.lock().unwrap().entries.contains_key(&replacement));
|
|
668
|
+
assert!(g.ensure_ready("a").await.is_ok());
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
fn connection(g: &Gateway, id: &str) -> String {
|
|
672
|
+
let (token, _) = g
|
|
673
|
+
.register(Connection { generation: "g".into(), command: "unused".into(), args: vec![], env: vec![] })
|
|
674
|
+
.unwrap();
|
|
675
|
+
g.bind(&token, id).unwrap();
|
|
676
|
+
g.observe_discovery(
|
|
677
|
+
&token,
|
|
678
|
+
&json!({"method":"initialize"}),
|
|
679
|
+
&json!({"result":{"protocolVersion":"2024-11-05"}}),
|
|
680
|
+
);
|
|
681
|
+
g.observe_discovery(&token, &json!({"method":"tools/list"}), &json!({"result":{"tools":[{"name":"click"},{"name":"list_windows"},{"name":"get_window_state"},{"name":"run_steps"},{"name":"get_app_state"}]}}));
|
|
682
|
+
token
|
|
683
|
+
}
|
|
684
|
+
#[tokio::test]
|
|
685
|
+
async fn fifo_ownership_survives_tool_completion_until_runtime_finishes_turn() {
|
|
686
|
+
let g = gateway();
|
|
687
|
+
let a = connection(&g, "a");
|
|
688
|
+
let b = connection(&g, "b");
|
|
689
|
+
g.sync_running(["a".into(), "b".into()].into());
|
|
690
|
+
g.acquire(&a, &json!({"pid":1,"window_id":2}), "click").await.unwrap();
|
|
691
|
+
g.complete(&a, &json!({}));
|
|
692
|
+
assert!(tokio::time::timeout(Duration::from_millis(10), g.acquire(&b, &json!({}), "click")).await.is_err());
|
|
693
|
+
assert_eq!(g.activity("b").queue_position, 1);
|
|
694
|
+
assert!(g.activity("b").target.is_none());
|
|
695
|
+
g.sync_running(["b".into()].into());
|
|
696
|
+
g.acquire(&b, &json!({"pid":3,"window_id":4}), "click").await.unwrap();
|
|
697
|
+
assert_eq!(g.activity("b").target, Some(Target { pid: 3, window_id: 4 }));
|
|
698
|
+
}
|
|
699
|
+
#[tokio::test]
|
|
700
|
+
async fn cancelled_owner_drains_before_handoff_and_is_blocked_for_the_turn() {
|
|
701
|
+
let g = gateway();
|
|
702
|
+
let a = connection(&g, "a");
|
|
703
|
+
let b = connection(&g, "b");
|
|
704
|
+
g.sync_running(["a".into(), "b".into()].into());
|
|
705
|
+
g.acquire(&a, &json!({}), "run_steps").await.unwrap();
|
|
706
|
+
g.stop_session("a");
|
|
707
|
+
assert!(tokio::time::timeout(Duration::from_millis(10), g.acquire(&b, &json!({}), "click")).await.is_err());
|
|
708
|
+
assert!(g.acquire(&a, &json!({}), "click").await.is_err());
|
|
709
|
+
g.complete(&a, &json!({}));
|
|
710
|
+
g.acquire(&b, &json!({}), "click").await.unwrap();
|
|
711
|
+
}
|
|
712
|
+
#[tokio::test]
|
|
713
|
+
async fn cancelling_queued_conversation_never_stops_owner() {
|
|
714
|
+
let g = gateway();
|
|
715
|
+
let a = connection(&g, "a");
|
|
716
|
+
let b = connection(&g, "b");
|
|
717
|
+
g.sync_running(["a".into(), "b".into()].into());
|
|
718
|
+
g.acquire(&a, &json!({}), "click").await.unwrap();
|
|
719
|
+
let _ = tokio::time::timeout(Duration::from_millis(10), g.acquire(&b, &json!({}), "click")).await;
|
|
720
|
+
g.stop_session("b");
|
|
721
|
+
assert_eq!(g.activity("a").phase, "active");
|
|
722
|
+
assert_eq!(g.activity("b").queue_position, 0);
|
|
723
|
+
}
|
|
724
|
+
#[tokio::test]
|
|
725
|
+
async fn closing_an_idle_session_does_not_block_its_next_turn() {
|
|
726
|
+
let g = gateway();
|
|
727
|
+
let a = connection(&g, "a");
|
|
728
|
+
g.stop_session("a");
|
|
729
|
+
g.sync_running(["a".into()].into());
|
|
730
|
+
g.acquire(&a, &json!({}), "click").await.unwrap();
|
|
731
|
+
}
|
|
732
|
+
#[tokio::test]
|
|
733
|
+
async fn switching_apps_clears_the_previous_window_preview() {
|
|
734
|
+
let g = gateway();
|
|
735
|
+
let a = connection(&g, "a");
|
|
736
|
+
g.sync_running(["a".into()].into());
|
|
737
|
+
g.acquire(&a, &json!({"pid":1,"window_id":2}), "get_window_state").await.unwrap();
|
|
738
|
+
g.complete(&a, &json!({}));
|
|
739
|
+
let revision = g.activity("a").revision;
|
|
740
|
+
g.acquire(&a, &json!({"pid":3}), "get_app_state").await.unwrap();
|
|
741
|
+
assert!(g.activity("a").target.is_none());
|
|
742
|
+
assert!(g.activity("a").revision > revision);
|
|
743
|
+
}
|
|
744
|
+
#[test]
|
|
745
|
+
fn binding_is_immutable_and_global_shutdown_invalidates_tokens() {
|
|
746
|
+
let g = gateway();
|
|
747
|
+
let a = connection(&g, "a");
|
|
748
|
+
assert!(g.bind(&a, "b").is_err());
|
|
749
|
+
g.stop_all();
|
|
750
|
+
assert!(g.bind(&a, "a").is_err());
|
|
751
|
+
}
|
|
752
|
+
#[tokio::test]
|
|
753
|
+
async fn runtime_completion_does_not_release_an_in_flight_action() {
|
|
754
|
+
let g = gateway();
|
|
755
|
+
let a = connection(&g, "a");
|
|
756
|
+
g.sync_running(["a".into()].into());
|
|
757
|
+
g.acquire(&a, &json!({}), "click").await.unwrap();
|
|
758
|
+
g.sync_running(HashSet::new());
|
|
759
|
+
assert_eq!(g.activity("a").phase, "active");
|
|
760
|
+
g.complete(&a, &json!({}));
|
|
761
|
+
assert_eq!(g.activity("a").phase, "idle");
|
|
762
|
+
}
|
|
763
|
+
#[tokio::test]
|
|
764
|
+
async fn stale_target_revisions_change_on_handoff() {
|
|
765
|
+
let g = gateway();
|
|
766
|
+
let a = connection(&g, "a");
|
|
767
|
+
g.sync_running(["a".into()].into());
|
|
768
|
+
g.acquire(&a, &json!({"pid":1,"window_id":2}), "click").await.unwrap();
|
|
769
|
+
let old = g.activity("a").revision;
|
|
770
|
+
g.complete(&a, &json!({}));
|
|
771
|
+
g.sync_running(HashSet::new());
|
|
772
|
+
assert_ne!(old, g.activity("a").revision);
|
|
773
|
+
}
|
|
774
|
+
}
|