Skip to main content

gate4agent_wake/codex/
desktop.rs

1//! Desktop follower IPC (inspected on Desktop 26.1007.2314.0).
2//! Routes to the existing thread owner. No provider spawn, auth access,
3//! thread creation or UI input. Internal protocol: fail closed on drift.
4use std::io::{Read, Write};
5use std::path::{Path, PathBuf};
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::time::{Duration, Instant};
8use serde_json::{json, Value};
9use super::{WakeError, WakeOutcome};
10
11const MAX_FRAME: usize = 1024 * 1024;
12// Owner snapshots include history: the inspected live snapshot exceeded 1 MiB.
13const MAX_SNAPSHOT_FRAME: usize = 16 * 1024 * 1024;
14const TIMEOUT: Duration = Duration::from_secs(20);
15static NEXT_ID: AtomicU64 = AtomicU64::new(1);
16
17pub fn default_endpoint() -> Option<PathBuf> {
18    #[cfg(windows)]
19    { Some(PathBuf::from(r"\\.\pipe\codex-ipc")) }
20    #[cfg(not(windows))]
21    { None } // Unix requires desktop:///absolute/ipc.sock explicitly.
22}
23
24pub fn probe(path: &Path, thread: &str) -> Result<(), WakeError> {
25    discover(&mut connect(path)?, thread).map(|_| ())
26}
27
28pub fn wake(path: &Path, thread: &str, text: &str) -> Result<WakeOutcome, WakeError> {
29    wake_on(&mut connect(path)?, thread, text)
30}
31
32fn wake_on<S: Read + Write>(stream: &mut S, thread: &str, text: &str) -> Result<WakeOutcome, WakeError> {
33    let (client, owner) = discover(stream, thread)?;
34    // Observe the real owner's state, not our courier's process-level identity.
35    // Busy/unknown states are retryable; never insert another start placeholder.
36    // This is not an atomic owner gate: a human can submit after the snapshot.
37    // Full concurrency safety requires an owner-side prepared-message operation.
38    require_idle(stream, thread, &client, &owner)?;
39    let params = json!({"conversationId": thread, "turnStart": {
40        "request": {"threadId": thread, "input": [{"type": "text", "text": text, "text_elements": []}]},
41        "context": {"inheritThreadSettings": true}
42    }});
43    let reply = call(stream, "thread-follower-start-turn", 2, params, Some(&client), Some(&owner))?;
44    if reply.pointer("/result/result/turn/id").and_then(Value::as_str)
45        .filter(|id| !id.is_empty()).is_none() {
46        return Err(WakeError::Transport("desktop did not return an accepted turn".into()));
47    }
48    Ok(WakeOutcome::Delivered)
49}
50
51fn require_idle<S: Read + Write>(stream: &mut S, thread: &str, client: &str, owner: &str) -> Result<(), WakeError> {
52    write_frame(stream, &json!({"type": "broadcast", "method": "thread-stream-following-changed",
53        "version": 1, "sourceClientId": client, "targetClientIds": [owner],
54        "params": {"conversationId": thread, "hostId": "local", "following": true}}))?;
55    let deadline = Instant::now() + TIMEOUT;
56    loop {
57        let update = read_frame(stream, deadline)?;
58        if update["type"] != "broadcast" || update["method"] != "thread-stream-state-changed"
59            || update["version"] != 11 || update["sourceClientId"] != owner
60            || update.pointer("/params/conversationId").and_then(Value::as_str) != Some(thread)
61            || update.pointer("/params/hostId").and_then(Value::as_str) != Some("local")
62            || update.pointer("/params/change/type").and_then(Value::as_str) != Some("snapshot") { continue; }
63        let state = &update["params"]["change"]["conversationState"];
64        // Fail closed for transient, unloaded and changed protocol states.
65        if state["id"] != thread || state["resumeState"] != "resumed"
66            || state.pointer("/threadRuntimeStatus/type").and_then(Value::as_str) != Some("idle")
67            || state["unconfirmedTurnSubmissions"].as_array().is_some_and(|items| !items.is_empty()) {
68            return Err(WakeError::Unavailable("desktop thread busy or idle state unconfirmed; retry later".into()));
69        }
70        let busy = match state["turnHistory"]["kind"].as_str() {
71            Some("canonical") => state["turnHistory"]["history"]["entitiesByKey"].as_object()
72                .map(|items| items.values().any(|turn| turn["status"] == "inProgress")),
73            _ => state["turns"].as_array().map(|items| items.iter().any(|turn| turn["status"] == "inProgress")),
74        };
75        if busy != Some(false) {
76            return Err(WakeError::Unavailable("desktop turn state busy or unavailable; retry later".into()));
77        }
78        return Ok(());
79    }
80}
81
82fn discover<S: Read + Write>(stream: &mut S, thread: &str) -> Result<(String, String), WakeError> {
83    let hello = call(stream, "initialize", 0, json!({"clientType": "mail4agent"}), None, None)?;
84    let client = hello.pointer("/result/clientId").and_then(Value::as_str)
85        .ok_or_else(|| WakeError::Transport("desktop initialization wire changed".into()))?;
86    let owner = call(stream, "thread-owner-discovery", 1,
87        json!({"hostId": "local", "conversationId": thread}), Some(client), None)?;
88    let owner_id = owner["handledByClientId"].as_str()
89        .ok_or_else(|| WakeError::Transport("desktop owner discovery wire changed".into()))?;
90    if owner.pointer("/result/supportsUntrustedAppInput").and_then(Value::as_bool) != Some(true) {
91        return Err(WakeError::Unavailable("desktop owner protocol unsupported".into()));
92    }
93    Ok((client.into(), owner_id.into()))
94}
95
96fn call<S: Read + Write>(stream: &mut S, method: &str, version: u32, params: Value,
97    client: Option<&str>, owner: Option<&str>) -> Result<Value, WakeError> {
98    let id = format!("m4a-{}-{}", std::process::id(), NEXT_ID.fetch_add(1, Ordering::Relaxed));
99    let mut request = json!({"type": "request", "requestId": id, "method": method,
100        "version": version, "params": params, "timeoutMs": 15000});
101    if let Some(client) = client { request["sourceClientId"] = client.into(); }
102    if let Some(owner) = owner { request["targetClientId"] = owner.into(); }
103    write_frame(stream, &request)?;
104    let deadline = Instant::now() + TIMEOUT;
105    loop {
106        let reply = read_frame(stream, deadline)?;
107        if reply["type"] != "response" || reply["requestId"] != id { continue; }
108        if reply["resultType"] != "success" {
109            // Provider errors may contain prompt text; never echo them.
110            return Err(WakeError::Unavailable(format!("desktop {method} refused")));
111        }
112        if let Some(owner) = owner {
113            if reply["handledByClientId"] != owner {
114                return Err(WakeError::Transport("desktop owner changed during delivery".into()));
115            }
116        }
117        return Ok(reply);
118    }
119}
120
121fn write_frame<S: Write>(stream: &mut S, value: &Value) -> Result<(), WakeError> {
122    let bytes = serde_json::to_vec(value).map_err(|_| transport())?;
123    if bytes.is_empty() || bytes.len() > MAX_FRAME { return Err(transport()); }
124    stream.write_all(&(bytes.len() as u32).to_le_bytes()).map_err(|_| transport())?;
125    stream.write_all(&bytes).map_err(|_| transport())?;
126    stream.flush().map_err(|_| transport())
127}
128
129fn read_frame<S: Read>(stream: &mut S, deadline: Instant) -> Result<Value, WakeError> {
130    let mut header = [0; 4];
131    read_exact_until(stream, &mut header, deadline)?;
132    let size = u32::from_le_bytes(header) as usize;
133    if size == 0 || size > MAX_SNAPSHOT_FRAME { return Err(transport()); }
134    let mut bytes = vec![0; size];
135    read_exact_until(stream, &mut bytes, deadline)?;
136    serde_json::from_slice(&bytes).map_err(|_| transport())
137}
138
139fn read_exact_until<S: Read>(stream: &mut S, mut bytes: &mut [u8], deadline: Instant) -> Result<(), WakeError> {
140    while !bytes.is_empty() {
141        if Instant::now() >= deadline { return Err(WakeError::Transport("desktop IPC timed out".into())); }
142        match stream.read(bytes) {
143            Ok(0) => return Err(transport()),
144            Ok(n) => { bytes = &mut bytes[n..]; }
145            Err(err) if err.kind() == std::io::ErrorKind::Interrupted => {},
146            Err(err) if err.kind() == std::io::ErrorKind::WouldBlock || err.raw_os_error() == Some(232) => {
147                std::thread::sleep(Duration::from_millis(10));
148            }
149            Err(err) => return Err(WakeError::Transport(format!("desktop IPC read failed ({:?}/{})",
150                err.kind(), err.raw_os_error().unwrap_or(0)))),
151        }
152    }
153    Ok(())
154}
155
156fn transport() -> WakeError { WakeError::Transport("desktop IPC frame failed".into()) }
157
158#[cfg(windows)]
159struct Pipe(std::fs::File);
160
161#[cfg(windows)]
162impl Read for Pipe {
163    fn read(&mut self, bytes: &mut [u8]) -> std::io::Result<usize> {
164        use std::os::windows::io::AsRawHandle;
165        #[link(name = "kernel32")]
166        extern "system" {
167            fn PeekNamedPipe(handle: *mut std::ffi::c_void, buffer: *mut std::ffi::c_void,
168                size: u32, read: *mut u32, available: *mut u32, left: *mut u32) -> i32;
169        }
170        let mut available = 0;
171        let ok = unsafe { PeekNamedPipe(self.0.as_raw_handle(), std::ptr::null_mut(), 0,
172            std::ptr::null_mut(), &mut available, std::ptr::null_mut()) };
173        if ok == 0 { return Err(std::io::Error::last_os_error()); }
174        if available == 0 { return Err(std::io::ErrorKind::WouldBlock.into()); }
175        let size = bytes.len().min(available as usize);
176        self.0.read(&mut bytes[..size])
177    }
178}
179
180#[cfg(windows)]
181impl Write for Pipe {
182    fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> { self.0.write(bytes) }
183    fn flush(&mut self) -> std::io::Result<()> { self.0.flush() }
184}
185
186#[cfg(windows)]
187fn connect(path: &Path) -> Result<Pipe, WakeError> {
188    if !path.to_string_lossy().starts_with(r"\\.\pipe\") {
189        return Err(WakeError::Unavailable("desktop requires a local named pipe".into()));
190    }
191    let stream = std::fs::OpenOptions::new().read(true).write(true).open(path)
192        .map_err(|_| WakeError::Unavailable("desktop IPC not available".into()))?;
193    Ok(Pipe(stream))
194}
195
196#[cfg(unix)]
197fn connect(path: &Path) -> Result<std::os::unix::net::UnixStream, WakeError> {
198    let stream = std::os::unix::net::UnixStream::connect(path)
199        .map_err(|_| WakeError::Unavailable("desktop IPC not available".into()))?;
200    stream.set_read_timeout(Some(TIMEOUT)).map_err(|_| transport())?;
201    stream.set_write_timeout(Some(TIMEOUT)).map_err(|_| transport())?;
202    Ok(stream)
203}
204
205#[cfg(not(any(unix, windows)))]
206fn connect(_: &Path) -> Result<std::io::Cursor<Vec<u8>>, WakeError> {
207    Err(WakeError::Unavailable("desktop IPC unsupported on this platform".into()))
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    #[test]
214    fn frames_reject_unbounded_and_truncated_input() {
215        for bytes in [vec![0,0,0,0], vec![255,255,255,255], vec![4,0,0,0,b'{']] {
216            assert!(read_frame(&mut std::io::Cursor::new(bytes), Instant::now()+TIMEOUT).is_err());
217        }
218    }
219    #[test]
220    fn frame_roundtrip() {
221        let mut stream = std::io::Cursor::new(Vec::new());
222        let value = json!({"body": "Тест", "type": "response"});
223        write_frame(&mut stream, &value).unwrap();
224        stream.set_position(0);
225        assert_eq!(read_frame(&mut stream, Instant::now()+TIMEOUT).unwrap(), value);
226    }
227    #[test]
228    fn existing_owner_receives_turn_without_thread_creation() {
229        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
230        let address = listener.local_addr().unwrap();
231        let server = std::thread::spawn(move || {
232            let (mut stream, _) = listener.accept().unwrap();
233            for method in ["initialize", "thread-owner-discovery", "thread-follower-start-turn"] {
234                if method == "thread-follower-start-turn" {
235                    let following = read_frame(&mut stream, Instant::now()+TIMEOUT).unwrap();
236                    assert_eq!(following["method"], "thread-stream-following-changed");
237                    write_frame(&mut stream, &json!({"type": "broadcast", "method": "thread-stream-state-changed",
238                        "version": 11, "sourceClientId": "owner", "params": {"hostId": "local",
239                        "conversationId": "existing-thread", "change": {"type": "snapshot", "conversationState": {
240                            "id": "existing-thread", "resumeState": "resumed", "threadRuntimeStatus": {"type": "idle"}, "turns": []
241                        }}}})).unwrap();
242                }
243                let req = read_frame(&mut stream, Instant::now()+TIMEOUT).unwrap();
244                assert_eq!(req["method"], method);
245                let result = match method {
246                    "initialize" => json!({"clientId": "courier"}),
247                    "thread-owner-discovery" => {
248                        assert_eq!(req["params"]["conversationId"], "existing-thread");
249                        json!({"supportsUntrustedAppInput": true})
250                    }
251                    _ => {
252                        assert_eq!(req["targetClientId"], "owner");
253                        assert_eq!(req["version"], 2);
254                        assert_eq!(req["params"]["turnStart"]["request"]["threadId"], "existing-thread");
255                        assert_eq!(req["params"]["turnStart"]["request"]["input"][0]["text_elements"], json!([]));
256                        json!({"result": {"turn": {"id": "accepted"}}})
257                    }
258                };
259                write_frame(&mut stream, &json!({"type": "response", "requestId": req["requestId"],
260                    "resultType": "success", "handledByClientId": "owner", "result": result})).unwrap();
261            }
262        });
263        let mut stream = std::net::TcpStream::connect(address).unwrap();
264        stream.set_read_timeout(Some(TIMEOUT)).unwrap();
265        assert_eq!(wake_on(&mut stream, "existing-thread", "letter").unwrap(), WakeOutcome::Delivered);
266        server.join().unwrap();
267    }
268
269    #[test]
270    fn busy_or_unknown_owner_never_receives_start() {
271        for status in ["active", "notLoaded", "unknown"] {
272            let update = json!({"type": "broadcast", "method": "thread-stream-state-changed", "version": 11,
273                "sourceClientId": "owner", "params": {"hostId": "local", "conversationId": "thread",
274                "change": {"type": "snapshot", "conversationState": {"id": "thread", "resumeState": "resumed",
275                    "threadRuntimeStatus": {"type": status}, "turns": []}}}});
276            let mut input = std::io::Cursor::new(Vec::new());
277            write_frame(&mut input, &update).unwrap();
278            input.set_position(0);
279            struct Duplex { input: std::io::Cursor<Vec<u8>>, output: Vec<u8> }
280            impl Read for Duplex { fn read(&mut self, bytes: &mut [u8]) -> std::io::Result<usize> { self.input.read(bytes) } }
281            impl Write for Duplex {
282                fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> { self.output.write(bytes) }
283                fn flush(&mut self) -> std::io::Result<()> { Ok(()) }
284            }
285            let mut stream = Duplex { input, output: Vec::new() };
286            assert!(require_idle(&mut stream, "thread", "courier", "owner").is_err());
287            let sent = read_frame(&mut std::io::Cursor::new(stream.output), Instant::now()+TIMEOUT).unwrap();
288            assert_eq!(sent["method"], "thread-stream-following-changed");
289        }
290    }
291}