Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,263 changes: 66 additions & 1,197 deletions apps/maple-agent/Cargo.lock

Large diffs are not rendered by default.

7 changes: 0 additions & 7 deletions apps/maple-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -590,10 +590,3 @@ are replaced by root monorepo CI.
## License

MIT. See `LICENSE`.

## Desktop remote access

See [remote access](docs/remote-access.md) for pairing, host selection, recovery,
local-only account controls and the separate infrastructure/native trial gates.
Remote access is off by default; the initial development configuration has no
implicit public-relay fallback.
11 changes: 0 additions & 11 deletions apps/maple-agent/app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,6 @@ desktop = [
"dep:notify",
"dep:spellbook",
"dep:wayland-client",
"dep:tray-icon",
"dep:zbus",
]
# `maple-agent acp`: the Agent Client Protocol server on stdio.
acp = ["maple-agent/acp"]
Expand Down Expand Up @@ -71,25 +69,16 @@ chrono = "0.4"
clap = { version = "4", features = ["derive", "env"] }
# Hidden password prompt for `maple-agent login`.
rpassword = "7"
hostname = "0.4"
maple-proxy = { workspace = true, optional = true }
reqwest = { workspace = true, features = ["json"] }
percent-encoding = "2"
url = "2.5"
iroh = { version = "=1.3.0", default-features = false, features = ["tls-ring"] }
pake-cpace = { git = "https://github.com/1Password/cpace", rev = "7e2e42c7d359969f4356292c2bd2cf3ff6eeeecc" }
zeroize = "1"
axum = { version = "0.8", optional = true }
tower-http = { version = "0.6", features = ["cors"], optional = true }

# Used only to ask the compositor whether it draws window decorations; see
# `ui::decorations`. gpui cannot answer that question.
[target.'cfg(target_os = "linux")'.dependencies]
wayland-client = { version = "0.31", optional = true }
zbus = { version = "=5.19.0", optional = true }

[target.'cfg(any(target_os = "linux", target_os = "windows"))'.dependencies]
tray-icon = { version = "=0.26.1", default-features = false, features = ["ksni"], optional = true }

[target.'cfg(unix)'.dependencies]
libc = "0.2"
Expand Down
85 changes: 2 additions & 83 deletions apps/maple-agent/app/src/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,6 @@ pub struct AgentBackend {
owned_runtime: Option<Runtime>,
client: BackendClient,
api_url: String,
controller: Option<Arc<AgentBackend>>,
remote_host: Option<String>,
}

impl Drop for AgentBackend {
Expand Down Expand Up @@ -70,7 +68,7 @@ macro_rules! operations {
self.call(stringify!($name), ($($arg,)* )).await
})*
}
pub(crate) async fn dispatch_inner(backend: &LocalBackend, request: Request) -> Result<serde_json::Value, String> {
async fn dispatch_inner(backend: &LocalBackend, request: Request) -> Result<serde_json::Value, String> {
match request.method.as_str() {
$(stringify!($name) => {
let ($($arg,)*): ($(wire_type!($ty),)*) = serde_json::from_value(request.params)
Expand Down Expand Up @@ -127,6 +125,7 @@ operations! {
cancel_queued_message(user_id: (&str), session_id: (&str), queue_id: (&str)) -> AgentDesktopQueueSnapshot;
begin_queued_message_edit(user_id: (&str), session_id: (&str), queue_id: (&str)) -> ();
end_queued_message_edit(user_id: (&str), session_id: (&str), queue_id: (&str)) -> ();
send_message(user_id: (&str), request: (AgentSendMessageRequest)) -> String;
read_image_attachment(user_id: (&str), session_id: (&str), attachment_id: (&str)) -> Vec<u8>;
cancel_run(user_id: (&str), run_id: (&str)) -> ();
available_model_ids(user_id: (&str)) -> Vec<String>;
Expand Down Expand Up @@ -174,8 +173,6 @@ impl AgentBackend {
owned_runtime: Some(runtime),
client: BackendClient::InProcess(Box::new(backend)),
api_url,
controller: None,
remote_host: None,
})
}

Expand All @@ -188,59 +185,14 @@ impl AgentBackend {
owned_runtime: Some(runtime),
client: BackendClient::Host(client),
api_url,
controller: None,
remote_host: None,
})
}

pub async fn remote(self: &Arc<Self>, peer: String) -> Result<Arc<Self>, String> {
let client = RemoteClient::connect_remote(&self.api_url, peer.clone()).await?;
Ok(Arc::new(Self {
runtime: self.runtime.clone(),
owned_runtime: None,
client: BackendClient::Host(client),
api_url: self.api_url.clone(),
controller: Some(self.clone()),
remote_host: Some(peer),
}))
}
pub fn remote_host(&self) -> Option<&str> {
self.remote_host.as_deref()
}
pub async fn remote_setting<R: DeserializeOwned>(
&self,
method: &str,
params: impl Serialize,
) -> Result<R, String> {
if !method.starts_with("remote_") || self.remote_host.is_some() {
return Err("Remote settings require the local desktop".into());
}
self.call(method, params).await
}

async fn call<R: DeserializeOwned>(
&self,
method: &str,
params: impl Serialize,
) -> Result<R, String> {
// Speech capture/playback and account UI belong to the controller.
// All task operations use the selected host's separate connection.
if let Some(controller) = &self.controller
&& matches!(
method,
"audio_capabilities"
| "synthesize_speech"
| "transcribe_audio"
| "plan_usage"
| "account"
)
{
match &controller.client {
BackendClient::Host(client) => return client.call(method, params).await,
#[cfg(test)]
BackendClient::InProcess(_) => {}
}
}
match &self.client {
BackendClient::Host(client) => client.call(method, params).await,
#[cfg(test)]
Expand All @@ -259,27 +211,6 @@ impl AgentBackend {
}
}
}
/// The composer retains this ID through uncertain delivery. Only receipt
/// queries, never another send, use it after a connection loss.
pub async fn send_message_identified(
&self,
user: &str,
request: AgentSendMessageRequest,
id: String,
) -> Result<String, String> {
match &self.client {
BackendClient::Host(client) => {
client
.call_identified("send_message", (user, request), Some(id))
.await
}
#[cfg(test)]
BackendClient::InProcess(backend) => backend.send_message(user, request).await,
}
}
pub async fn send_outcome(&self, id: String) -> Result<crate::host::receipts::Outcome, String> {
self.call("send_outcome", id).await
}
pub fn runtime_handle(&self) -> tokio::runtime::Handle {
self.runtime.clone()
}
Expand Down Expand Up @@ -369,9 +300,6 @@ impl AgentBackend {
});
}
pub fn begin_integration_setup(&self, id: &str) -> Result<AgentIntegrationPermissions, String> {
if self.remote_host.is_some() {
return Err("Open Maple on the execution computer to grant native permissions.".into());
}
// Native permission prompts remain on the signed app's UI thread.
maple_agent::agent::begin_integration_setup(&AgentSetupIntegrationRequest { id: id.into() })
}
Expand Down Expand Up @@ -409,15 +337,6 @@ async fn dispatch_special(
serde_json::to_value(value).map_err(|_| "Could not encode host response".to_string())
};
match request.method.as_str() {
"send_message" => {
let (user, message): (String, AgentSendMessageRequest) =
serde_json::from_value(request.params)
.map_err(|_| "Invalid host request parameters")?;
backend.authorize_account(&user)?;
serde_json::to_value(backend.send_message(&user, message).await?)
.map_err(|_| "Could not encode host response".into())
}

"restore_auth" => {
serde_json::to_value(backend.validate_persisted_auth().await).map_err(|e| e.to_string())
}
Expand Down
150 changes: 4 additions & 146 deletions apps/maple-agent/app/src/backend/local.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ pub struct RecoverySnapshot {
pub struct LocalBackend {
#[cfg(test)]
pub(super) ui_fixture: bool,
pub(crate) account_gate: tokio::sync::RwLock<()>,
pub(super) account_gate: tokio::sync::RwLock<()>,
restore_lock: tokio::sync::Mutex<()>,
service: MapleAgentService,
auth: MapleApiAuthState,
Expand Down Expand Up @@ -978,26 +978,6 @@ impl LocalBackend {
self.auth.session_for(user_id).await
}

/// Snapshot the locally selected account for remote enrollment.
pub(crate) fn remote_storage_root(&self) -> PathBuf {
self.persisted_auth
.path
.parent()
.expect("auth store has a parent")
.to_path_buf()
}
pub(crate) fn remote_api_url(&self) -> &str {
&self.api_url
}

pub(crate) async fn remote_session(
&self,
user_id: &str,
) -> Result<Arc<maple_agent::maple_api::MapleApiSession>, String> {
self.authorize_account(user_id)?;
self.session_for(user_id).await
}

/// Sign out completely: invalidate the live session first, then remove
/// only the persisted credentials that this sign-out observed.
pub async fn logout_and_clear(&self, user_id: &str) -> Result<(), String> {
Expand Down Expand Up @@ -1656,16 +1636,6 @@ impl LocalBackend {
/// holds the lifecycle lock across its network round trips, and these
/// reads would queue behind it.
pub async fn local_bootstrap(&self, user_id: &str) -> Result<LocalBootstrap, String> {
self.read_bootstrap(user_id, true).await
}

/// Navigation and reconnect snapshots need summaries, not an unrelated
/// latest transcript. A controller can observe several computers at once.
async fn read_bootstrap(
&self,
user_id: &str,
load_latest: bool,
) -> Result<LocalBootstrap, String> {
let handle = self.service.handle_for_user(user_id).await?;
let config = handle.load_config().await?;
let project_root = gui_project_root(&config);
Expand All @@ -1687,7 +1657,7 @@ impl LocalBackend {
&& Some(&session.project_root) == project_root.as_ref()
})
.map(|session| session.id.clone());
let latest = match latest_id.filter(|_| load_latest) {
let latest = match latest_id {
Some(id) => handle.load_session(id).await.ok(),
None => None,
};
Expand All @@ -1706,7 +1676,8 @@ impl LocalBackend {
user_id: &str,
session_id: Option<String>,
) -> Result<RecoverySnapshot, String> {
let bootstrap = self.read_bootstrap(user_id, false).await?;
let mut bootstrap = self.local_bootstrap(user_id).await?;
bootstrap.latest = None;
let handle = self.service.handle_for_user(user_id).await?;
let selected = match session_id.filter(|id| bootstrap.sessions.iter().any(|s| &s.id == id))
{
Expand Down Expand Up @@ -2658,119 +2629,6 @@ mod tests {
);
}

#[tokio::test]
async fn checking_remote_status_does_not_create_identity_or_require_network() {
let root = tempfile::tempdir().unwrap();
let backend = Arc::new(LocalBackend::isolated_for_test(root.path()));
backend.persisted_auth.claim_and_persist(&auth_snapshot(
"fixture-user",
"fixture-session",
1,
"fixture-token",
));
let (events, _) = tokio::sync::broadcast::channel(8);
let receipts = Arc::new(
crate::host::receipts::Receipts::open(&root.path().join("receipts.db")).unwrap(),
);
let remote = crate::remote::manager::Manager::new(backend.clone(), events, receipts);
remote.restore().await;
let status = remote
.dispatch(crate::host::protocol::Request {
id: 1,
method: "remote_status".into(),
params: serde_json::json!([]),
send_id: None,
})
.await
.unwrap();
assert_eq!(status["enabled"], false);
assert!(status["endpoint"].is_null());
assert!(!root.path().join("remote").exists());
assert!(!remote.stays_running());
// Naming an offline computer is local even when authentication cannot
// finish. It must not bind an endpoint or enable incoming access.
let _validation = backend.restore_lock.lock().await;
tokio::time::timeout(
std::time::Duration::from_secs(1),
remote.dispatch(crate::host::protocol::Request {
id: 2,
method: "remote_set_name".into(),
params: serde_json::json!(["Offline Mac"]),
send_id: None,
}),
)
.await
.unwrap()
.unwrap();
let status = remote
.dispatch(crate::host::protocol::Request {
id: 3,
method: "remote_status".into(),
params: serde_json::json!([]),
send_id: None,
})
.await
.unwrap();
assert_eq!(status["name"], "Offline Mac");
assert_eq!(status["ready"], false);
assert_eq!(status["enabled"], false);
assert!(!remote.stays_running());
}

#[tokio::test]
async fn disabling_remote_does_not_wait_for_initializing_credentials() {
use std::{future::Future, task::Poll};
let root = tempfile::tempdir().unwrap();
let backend = Arc::new(LocalBackend::isolated_for_test(root.path()));
backend.persisted_auth.claim_and_persist(&auth_snapshot(
"fixture-user",
"fixture-session",
1,
"fixture-token",
));
let (events, _) = tokio::sync::broadcast::channel(8);
let receipts = Arc::new(
crate::host::receipts::Receipts::open(&root.path().join("receipts.db")).unwrap(),
);
let remote = crate::remote::manager::Manager::new(backend.clone(), events, receipts);
let request = |method: &str, params| crate::host::protocol::Request {
id: 1,
method: method.into(),
params,
send_id: None,
};
remote
.dispatch(request("remote_enable", serde_json::json!([true])))
.await
.unwrap();
let validation = backend.restore_lock.lock().await;
let active = remote.active();
tokio::pin!(active);
std::future::poll_fn(|cx| {
assert!(active.as_mut().poll(cx).is_pending());
Poll::Ready(())
})
.await;
tokio::time::timeout(
std::time::Duration::from_secs(1),
remote.dispatch(request("remote_enable", serde_json::json!([false]))),
)
.await
.unwrap()
.unwrap();
assert!(!remote.stays_running());
let status = remote
.dispatch(request("remote_status", serde_json::json!([])))
.await
.unwrap();
assert_eq!(status["enabled"], false);
assert_eq!(status["ready"], false);
drop(validation);
// Invalid fixture credentials cannot create a node after the cancellation.
assert!(active.await.is_err());
remote.shutdown().await;
}

#[tokio::test]
async fn credential_validation_and_acp_attachment_do_not_block_local_operations() {
use std::{future::Future, task::Poll};
Expand Down
Loading
Loading