blob: ff7cdd2a8921afa612635783e4c500dda7b61a33 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
//! Private, parent-owned desktop worker. There is no listener or reconnect path.
use super::failed;
#[cfg(feature = "native-desktop")]
use super::local_platform;
use crate::protocol::Command;
#[cfg(feature = "native-desktop")]
use crate::{Driver, session::Session};
use maka_agent_facts::tools::ToolError;
use maka_capabilities::computer::BrowserConnection;
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use serde_json::Value;
#[cfg(feature = "native-desktop")]
use std::{collections::HashMap, sync::Arc};
use std::{
io,
path::PathBuf,
process::{ExitCode, Stdio},
time::Duration,
};
#[cfg(feature = "native-desktop")]
use tokio::{
io::AsyncWrite,
sync::{Mutex, mpsc},
};
use tokio::{
io::{AsyncRead, AsyncReadExt, AsyncWriteExt, BufReader},
process::{Child, ChildStdin, ChildStdout},
};
use tokio_util::sync::CancellationToken;
const ENTRY: &str = "__maka-cua-desktop";
const PROTOCOL: u32 = 1;
const STARTUP_TIMEOUT: Duration = Duration::from_secs(15);
const EXIT_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Hello {
protocol: u32,
platform: String,
}
#[derive(Serialize, Deserialize)]
#[serde(tag = "method", rename_all = "snake_case", deny_unknown_fields)]
pub(crate) enum Request {
Invoke {
session: String,
browsers: Vec<BrowserConnection>,
command: Command,
language: maka_agent_facts::language::Language,
},
CloseSession {
session: String,
},
Shutdown,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "kind", content = "message", rename_all = "snake_case")]
enum Failure {
Failed(String),
OutcomeUnknown(String),
CleanupUnconfirmed(String),
}
impl From<ToolError> for Failure {
fn from(error: ToolError) -> Self {
match error {
ToolError::Failed(message) => Self::Failed(message),
ToolError::OutcomeUnknown(message) | ToolError::OperationUnknown { message, .. } => {
Self::OutcomeUnknown(message)
}
ToolError::OperationCleanupUnconfirmed { message, .. }
| ToolError::CleanupUnconfirmed(message)
| ToolError::Persistence(message) => Self::CleanupUnconfirmed(message),
ToolError::Io { message, .. } => Self::Failed(message),
}
}
}
impl From<Failure> for ToolError {
fn from(error: Failure) -> Self {
match error {
Failure::Failed(message) => Self::Failed(message),
Failure::OutcomeUnknown(message) => Self::OutcomeUnknown(message),
Failure::CleanupUnconfirmed(message) => Self::CleanupUnconfirmed(message),
}
}
}
type Response = Result<Value, Failure>;
#[derive(Default)]
pub(crate) struct Worker {
process: Option<Process>,
fault: Option<ToolError>,
closed: bool,
}
struct Process {
child: Child,
pipes: Option<(ChildStdin, BufReader<ChildStdout>)>,
}
impl Worker {
pub(crate) async fn prepare(
&mut self,
cancellation: &CancellationToken,
) -> Result<(), ToolError> {
if self.closed {
return Err(failed("Windows desktop worker is closed"));
}
if let Some(error) = &self.fault {
return Err(error.clone());
}
if self.process.is_none() {
let executable = tokio::select! {
biased;
_ = cancellation.cancelled() => return Err(failed("Computer Use preparation cancelled")),
executable = windows_executable() => executable?,
};
// The worker only advertises its protocol here. Native Cua state
// remains idle until a separately admitted operation is sent.
self.process = Some(Process::start(executable).await?);
}
if cancellation.is_cancelled() {
return Err(failed("Computer Use preparation cancelled"));
}
Ok(())
}
pub(crate) async fn release_session(&mut self, session: &str) -> Result<(), ToolError> {
if self.process.is_some() {
self.call(
Request::CloseSession {
session: session.into(),
},
&CancellationToken::new(),
)
.await?;
}
Ok(())
}
pub(crate) async fn call(
&mut self,
request: Request,
cancellation: &CancellationToken,
) -> Result<Value, ToolError> {
if cancellation.is_cancelled() {
return Err(failed("Computer Use cancelled before dispatch"));
}
let frame = encode(&request).map_err(failed)?;
self.prepare(cancellation).await?;
let process = self.process.as_mut().unwrap();
// Once a frame is sent, wait for settlement. A cancelled REPL must not
// drop native input or infer that an unacknowledged action did not run.
match process.exchange(&frame).await {
Ok(response) => response.map_err(Into::into),
Err(error) => {
let message = format!(
"Windows desktop connection lost: {error}; restart the Host before using Computer Use again"
);
let result = match process.disconnect().await {
Ok(()) => ToolError::OutcomeUnknown(message.clone()),
Err(cleanup) => ToolError::CleanupUnconfirmed(format!("{message}; {cleanup}")),
};
self.fault = Some(match &result {
ToolError::OutcomeUnknown(_) => failed(&message),
_ => result.clone(),
});
Err(result)
}
}
}
pub(crate) async fn close(&mut self) -> Result<(), ToolError> {
let mut failure = None;
if !self.closed
&& self.process.is_some()
&& self.fault.is_none()
&& let Err(error) = self
.call(Request::Shutdown, &CancellationToken::new())
.await
{
failure = Some(error);
}
self.closed = true;
if let Some(process) = &mut self.process {
process
.disconnect()
.await
.map_err(|error| ToolError::CleanupUnconfirmed(error.to_string()))?;
}
self.process = None;
failure.map_or(Ok(()), Err)
}
}
impl Process {
async fn start(executable: PathBuf) -> Result<Self, ToolError> {
let mut command = tokio::process::Command::new(executable);
command
.arg(ENTRY)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.kill_on_drop(true);
#[cfg(unix)]
command.process_group(0);
let mut child = command.spawn().map_err(|error| failed(format!(
"Windows desktop unavailable: {error}; install Windows Maka and enable WSL interoperability"
)))?;
Self {
pipes: Some((
child.stdin.take().unwrap(),
BufReader::new(child.stdout.take().unwrap()),
)),
child,
}
.handshake()
.await
}
async fn handshake(mut self) -> Result<Self, ToolError> {
let hello = tokio::time::timeout(
STARTUP_TIMEOUT,
read::<_, Hello>(&mut self.pipes.as_mut().unwrap().1),
)
.await;
let problem = match hello {
Ok(Ok(Hello {
protocol: PROTOCOL,
platform,
})) if platform == "windows" => return Ok(self),
Ok(Ok(_)) => {
"incompatible desktop worker; install a matching Windows Maka build".to_owned()
}
Ok(Err(error)) => format!("Windows desktop worker handshake failed: {error}"),
Err(_) => "Windows desktop worker startup timed out".to_owned(),
};
self.disconnect()
.await
.map_err(|error| ToolError::CleanupUnconfirmed(format!("{problem}; {error}")))?;
Err(failed(problem))
}
async fn exchange(&mut self, frame: &[u8]) -> io::Result<Response> {
let (input, output) = self
.pipes
.as_mut()
.ok_or_else(|| io::Error::other("desktop channel closed"))?;
input.write_all(frame).await?;
input.flush().await?;
read(output).await
}
async fn disconnect(&mut self) -> io::Result<()> {
// Close both directions so a peer writing a failed response cannot
// stay blocked on its output while we wait for its cleanup.
self.pipes.take();
// WSL's interop process represents a Windows process, so killing the
// Linux PID alone does not prove that native work has stopped.
tokio::time::timeout(EXIT_TIMEOUT, self.child.wait())
.await
.map_err(|_| io::Error::other("Windows desktop worker exit was not confirmed"))??;
Ok(())
}
}
async fn windows_executable() -> Result<PathBuf, ToolError> {
if let Some(path) = std::env::var_os("MAKA_CUA_WINDOWS_EXECUTABLE") {
let path = PathBuf::from(path);
if path.is_absolute() && path.is_file() {
return Ok(path);
}
return Err(failed(
"MAKA_CUA_WINDOWS_EXECUTABLE must be an absolute path to Windows maka.exe, as visible from WSL",
));
}
let resolve = super::WINDOWS_HELPER.get().ok_or_else(|| {
failed("the embedding application has not configured Windows desktop provisioning")
})?;
resolve().await
}
/// Invoke after cursor bootstrap and before starting the embedding application.
pub fn bootstrap() -> Option<ExitCode> {
if std::env::args_os().nth(1).as_deref() != Some(std::ffi::OsStr::new(ENTRY)) {
return None;
}
let result = if std::env::args_os().len() != 2 {
Err(io::Error::other("desktop worker takes no arguments"))
} else {
#[cfg(feature = "native-desktop")]
{
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.and_then(|runtime| runtime.block_on(serve_stdio()))
}
#[cfg(not(feature = "native-desktop"))]
{
Err(io::Error::new(
io::ErrorKind::Unsupported,
"native desktop support is not included in this build; rebuild with the native-desktop feature",
))
}
};
Some(match result {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("Maka desktop: {error}");
ExitCode::FAILURE
}
})
}
#[cfg(feature = "native-desktop")]
async fn serve_stdio() -> io::Result<()> {
let (send, receive) = mpsc::channel(1);
let disconnected = CancellationToken::new();
let eof = disconnected.clone();
// A detached blocking reader avoids Tokio stdin keeping runtime shutdown
// alive when stdout closes while the parent still holds stdin open.
std::thread::spawn(move || {
let mut input = std::io::stdin().lock();
while let Ok(request) = read_sync(&mut input) {
if send.blocking_send(request).is_err() {
break;
}
}
eof.cancel();
});
let driver = Arc::new(Mutex::new(Driver::default()));
serve(receive, tokio::io::stdout(), disconnected, driver).await
}
#[cfg(feature = "native-desktop")]
async fn serve(
mut receive: mpsc::Receiver<Request>,
mut output: impl AsyncWrite + Unpin,
disconnected: CancellationToken,
driver: Arc<Mutex<Driver>>,
) -> io::Result<()> {
let mut sessions = HashMap::<String, Session>::new();
write(
&mut output,
&Hello {
protocol: PROTOCOL,
platform: local_platform().into(),
},
)
.await?;
let mut shutdown = false;
let mut transport_error = None;
while let Some(request) = tokio::select! {
biased;
_ = disconnected.cancelled() => None,
request = receive.recv() => request,
} {
let result = match request {
Request::Invoke {
session,
browsers,
command,
language,
} => {
if session.is_empty() {
Err(failed("invalid desktop Session identity"))
} else if sessions.len() >= 16 && !sessions.contains_key(&session) {
Err(failed("desktop Session capacity reached"))
} else {
let local = sessions
.entry(session.clone())
.or_insert_with(|| Session::new(driver.clone()));
match local.configure_browsers(browsers, language) {
Ok(()) => {
local
.invoke(command, &session, &disconnected, language)
.await
}
Err(error) => Err(error),
}
}
}
Request::CloseSession { session } => {
let result = match sessions.get_mut(&session) {
Some(local) => local.close().await,
None => Ok(()),
};
if result.is_ok() {
sessions.remove(&session);
}
result.map(|()| Value::Null)
}
Request::Shutdown => {
shutdown = true;
break;
}
};
if let Err(error) = write(&mut output, &result.map_err(Failure::from)).await {
transport_error = Some(error);
break;
}
}
let mut cleanup = Ok(Value::Null);
for local in sessions.values_mut() {
if let Err(error) = local.close().await {
cleanup = Err(Failure::from(error));
}
}
if let Err(error) = driver.lock().await.close().await {
cleanup = Err(Failure::from(error));
}
if shutdown {
write(&mut output, &cleanup).await?;
}
if let Some(error) = transport_error {
return Err(error);
}
cleanup
.map(|_| ())
.map_err(|error| io::Error::other(format!("{error:?}")))
}
fn length(length: u32) -> io::Result<usize> {
let length = length as usize;
if length == 0 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"invalid desktop frame length",
));
}
Ok(length)
}
fn encode(value: &impl Serialize) -> io::Result<Vec<u8>> {
let mut bytes = vec![0; 4];
serde_json::to_writer(&mut bytes, value)?;
let size = bytes.len() - 4;
let size = u32::try_from(size).map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidData,
"desktop message is not representable",
)
})?;
if size == 0 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"desktop message too large",
));
}
bytes[..4].copy_from_slice(&size.to_be_bytes());
Ok(bytes)
}
#[cfg(feature = "native-desktop")]
async fn write(output: &mut (impl AsyncWrite + Unpin), value: &impl Serialize) -> io::Result<()> {
output.write_all(&encode(value)?).await?;
output.flush().await
}
async fn read<R: AsyncRead + Unpin, T: DeserializeOwned>(input: &mut R) -> io::Result<T> {
let mut bytes = vec![0; length(input.read_u32().await?)?];
input.read_exact(&mut bytes).await?;
serde_json::from_slice(&bytes).map_err(Into::into)
}
#[cfg(any(feature = "native-desktop", test))]
fn read_sync<T: DeserializeOwned>(input: &mut impl io::Read) -> io::Result<T> {
let mut header = [0; 4];
input.read_exact(&mut header)?;
let mut bytes = vec![0; length(u32::from_be_bytes(header))?];
input.read_exact(&mut bytes)?;
serde_json::from_slice(&bytes).map_err(Into::into)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn request(session: &str, command: Value) -> Request {
Request::Invoke {
session: session.into(),
browsers: Vec::new(),
command: serde_json::from_value(command).unwrap(),
language: maka_agent_facts::language::Language::En,
}
}
#[test]
fn desktop_frames_preserve_invoke_language() {
use maka_agent_facts::language::Language;
for (language, tag) in [
(Language::ZhCn, "zh-CN"),
(Language::ZhTw, "zh-TW"),
(Language::En, "en"),
] {
let request = Request::Invoke {
session: "same-session".into(),
browsers: Vec::new(),
command: Command::Documentation,
language,
};
let framed = encode(&request).unwrap();
let decoded: Request = read_sync(&mut framed.as_slice()).unwrap();
assert_eq!(
serde_json::to_value(decoded).unwrap(),
json!({
"method": "invoke",
"session": "same-session",
"browsers": [],
"command": { "method": "documentation" },
"language": tag,
}),
);
}
}
#[cfg(feature = "native-desktop")]
#[tokio::test]
async fn desktop_sessions_own_targets_and_reset_independently() {
let (send, receive) = mpsc::channel(1);
let (mut output, writer) = tokio::io::duplex(64 * 1024);
let driver = Arc::new(Mutex::new(Driver::default()));
let task = tokio::spawn(serve(receive, writer, CancellationToken::new(), driver));
let hello: Hello = read(&mut output).await.unwrap();
assert_eq!(hello.platform, local_platform());
for (session, label) in [("a", "Alice"), ("b", "Bob")] {
send.send(request(
session,
json!({"method":"configureCursor","options":{"label":label,"enabled":false}}),
))
.await
.unwrap();
let result: Response = read(&mut output).await.unwrap();
assert_eq!(result.unwrap()["settings"]["label"], label);
}
send.send(Request::CloseSession {
session: "a".into(),
})
.await
.unwrap();
assert!(read::<_, Response>(&mut output).await.unwrap().is_ok());
for (session, label) in [("a", "Maka"), ("b", "Bob")] {
send.send(request(
session,
json!({"method":"cursorState","options":{}}),
))
.await
.unwrap();
let result: Response = read(&mut output).await.unwrap();
assert_eq!(result.unwrap()["settings"]["label"], label);
}
send.send(request("a", json!({"method":"observe","handle":{"kind":"app","id":"unbound"},"kind":"ax","options":{}}))).await.unwrap();
let error = read::<_, Response>(&mut output).await.unwrap().unwrap_err();
assert!(
matches!(error, Failure::Failed(message) if message == "stale_target: bind the app again")
);
send.send(Request::Shutdown).await.unwrap();
assert!(read::<_, Response>(&mut output).await.unwrap().is_ok());
task.await.unwrap().unwrap();
}
#[cfg(feature = "native-desktop")]
#[tokio::test]
async fn desktop_transport_keeps_language_per_invoke_not_per_session() {
use maka_agent_facts::language::Language;
let (send, receive) = mpsc::channel(1);
let (mut output, writer) = tokio::io::duplex(64 * 1024);
let task = tokio::spawn(serve(
receive,
writer,
CancellationToken::new(),
Arc::new(Mutex::new(Driver::default())),
));
let _: Hello = read(&mut output).await.unwrap();
for (language, tag, recovery) in [
(Language::ZhCn, "zh-CN", "重新绑定目标"),
(Language::ZhTw, "zh-TW", "重新綁定目標"),
(Language::En, "en", "target rebinding"),
] {
let request = Request::Invoke {
session: "same-session".into(),
browsers: Vec::new(),
command: Command::Documentation,
language,
};
// Exercise the helper's real framed serializer/deserializer before
// dispatch; locale is transport metadata, never model command data.
let framed = encode(&request).unwrap();
let decoded: Request = read_sync(&mut framed.as_slice()).unwrap();
assert_eq!(serde_json::to_value(&decoded).unwrap()["language"], tag);
send.send(decoded).await.unwrap();
let response: Response = read(&mut output).await.unwrap();
let text = response.unwrap();
let text = text.as_str().unwrap();
assert!(
text.contains(recovery)
&& text.contains("cua_reset")
&& text.contains("getAXState")
);
}
send.send(Request::Shutdown).await.unwrap();
assert!(read::<_, Response>(&mut output).await.unwrap().is_ok());
task.await.unwrap().unwrap();
}
#[cfg(feature = "native-desktop")]
#[tokio::test]
async fn disconnected_desktop_discards_queued_work_and_closes_output() {
let (send, receive) = mpsc::channel(1);
send.send(request("queued", json!({"method":"documentation"})))
.await
.unwrap();
let stop = CancellationToken::new();
stop.cancel();
let (mut output, writer) = tokio::io::duplex(1024);
let task = tokio::spawn(serve(
receive,
writer,
stop,
Arc::new(Mutex::new(Driver::default())),
));
let _: Hello = read(&mut output).await.unwrap();
assert_eq!(
read::<_, Response>(&mut output).await.unwrap_err().kind(),
io::ErrorKind::UnexpectedEof
);
task.await.unwrap().unwrap();
}
#[tokio::test]
async fn desktop_frames_reject_empty_and_partial_responses() {
let mut oversize = 0_u32.to_be_bytes().as_slice().to_vec();
assert_eq!(
read::<_, Response>(&mut oversize.as_slice())
.await
.unwrap_err()
.kind(),
io::ErrorKind::InvalidData
);
assert_eq!(
read_sync::<Response>(&mut oversize.as_slice())
.unwrap_err()
.kind(),
io::ErrorKind::InvalidData
);
oversize = encode(&Ok::<_, Failure>(json!({"image":"payload"}))).unwrap();
oversize.pop();
assert_eq!(
read::<_, Response>(&mut oversize.as_slice())
.await
.unwrap_err()
.kind(),
io::ErrorKind::UnexpectedEof
);
}
#[cfg(unix)]
fn child(hello: Hello) -> Process {
let frame = encode(&hello)
.unwrap()
.iter()
.map(|byte| format!("\\{:03o}", byte))
.collect::<String>();
// This disposable peer exits after receiving input, without acknowledging
// it. Exercise the same real pipes and process wait as the WSL client.
let mut child = tokio::process::Command::new("/bin/sh")
.arg("-c")
.arg(format!(
"printf '%b' '{frame}'; dd bs=1 count=1 of=/dev/null 2>/dev/null"
))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.kill_on_drop(true)
.spawn()
.unwrap();
Process {
pipes: Some((
child.stdin.take().unwrap(),
BufReader::new(child.stdout.take().unwrap()),
)),
child,
}
}
#[cfg(unix)]
#[tokio::test]
async fn lost_acknowledgment_is_unknown_and_never_replayed() {
let process = child(Hello {
protocol: PROTOCOL,
platform: "windows".into(),
})
.handshake()
.await
.unwrap();
let mut worker = Worker {
process: Some(process),
..Default::default()
};
let cancelled = CancellationToken::new();
cancelled.cancel();
assert!(
matches!(worker.call(request("a", json!({"method":"documentation"})), &cancelled).await,
Err(ToolError::Failed(message)) if message == "Computer Use cancelled before dispatch")
);
assert!(matches!(
worker
.call(
request("a", json!({"method":"documentation"})),
&CancellationToken::new()
)
.await,
Err(ToolError::OutcomeUnknown(_))
));
assert!(
matches!(worker.call(request("a", json!({"method":"documentation"})), &CancellationToken::new()).await, Err(ToolError::Failed(message)) if message.contains("restart the Host"))
);
worker.close().await.unwrap();
for hello in [
Hello {
protocol: PROTOCOL + 1,
platform: "windows".into(),
},
Hello {
protocol: PROTOCOL,
platform: "linux".into(),
},
] {
assert!(
matches!(child(hello).handshake().await, Err(ToolError::Failed(message)) if message.contains("incompatible desktop worker"))
);
}
}
}