Skip to content
Merged
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
3 changes: 3 additions & 0 deletions apps/maple-agent/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 11 additions & 1 deletion apps/maple-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,16 @@ compatibility](docs/local-host.md) before configuring an ACP launcher.

### `maple-agent proxy`

The desktop also offers **Settings → Local API**. It starts this computer's
loopback endpoint in the shared local host, including while a remote computer
is selected. Create a key under API keys, enable Local API, then configure your
client with the displayed `/v1` base URL and that key. See
[Local API ownership and lifecycle](docs/local-api.md).

The command below remains an independent foreground server. It cannot share a
port with an enabled desktop listener; an occupied port is reported without
stopping or adopting the other server.

```
--host HOST bind address (default 127.0.0.1, env MAPLE_PROXY_HOST)
--port PORT bind port (default 8080, env MAPLE_PORT)
Expand All @@ -527,7 +537,7 @@ window and its display libraries:
| --- | --- |
| `desktop` | The gpui window. Without it the binary is headless. |
| `acp` | `maple-agent acp` and `maple_agent::acp`. |
| `proxy` | `maple-agent proxy`. |
| `proxy` | `maple-agent proxy` and the local host's optional API service. |

```sh
cargo build --release -p maple-agent-app --no-default-features --features acp
Expand Down
5 changes: 4 additions & 1 deletion apps/maple-agent/app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ desktop = [
# `maple-agent acp`: the Agent Client Protocol server on stdio.
acp = ["maple-agent/acp"]
# `maple-agent proxy`: the OpenAI-compatible HTTP endpoint.
proxy = ["dep:maple-proxy", "dep:axum", "dep:tower-http"]
proxy = ["dep:maple-proxy", "dep:axum", "dep:tower-http", "dep:hyper", "dep:hyper-util", "dep:futures-util"]

[dependencies]
dirs = "6"
Expand Down Expand Up @@ -86,6 +86,9 @@ pake-cpace = { git = "https://github.com/1Password/cpace", rev = "7e2e42c7d35996
zeroize = { workspace = true }
axum = { version = "0.8", optional = true }
tower-http = { version = "0.6", features = ["cors"], optional = true }
hyper = { version = "1", features = ["http1", "server"], optional = true }
hyper-util = { version = "0.1", features = ["tokio", "service"], optional = true }
futures-util = { workspace = true, optional = true }

# Used only to ask the compositor whether it draws window decorations; see
# `ui::decorations`. gpui cannot answer that question.
Expand Down
80 changes: 78 additions & 2 deletions apps/maple-agent/app/src/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ pub use attachments::AttachmentUploadRequest;
pub use attachments::ImportAttachmentRequest;
#[cfg(feature = "desktop")]
pub use paging::*;
#[cfg(all(test, feature = "proxy"))]
mod local_api_tests;
mod mcp;

use crate::host::{RemoteClient, protocol::Request};
Expand Down Expand Up @@ -60,6 +62,40 @@ enum BackendClient {
}

impl AgentBackend {
#[cfg(feature = "proxy")]
fn require_local_api(&self) -> Result<(), String> {
self.ensure_active()?;
if self.remote_host.is_some() {
return Err("Local API settings require this computer's local host".into());
}
Ok(())
}

#[cfg(feature = "proxy")]
pub async fn get_local_api_status(&self) -> Result<crate::local_api::LocalApiStatus, String> {
self.require_local_api()?;
self.call("get_local_api_status", ()).await
}

#[cfg(feature = "proxy")]
// Recovery clients can stop without reading encrypted preferences. The
// normal settings switch uses the durable configure operation instead.
#[allow(dead_code)]
pub async fn stop_local_api(&self) -> Result<crate::local_api::LocalApiStatus, String> {
self.require_local_api()?;
self.call("stop_local_api", ()).await
}

#[cfg(feature = "proxy")]
pub async fn set_local_api_config(
&self,
user_id: &str,
config: crate::local_api::LocalApiConfig,
) -> Result<crate::local_api::LocalApiStatus, String> {
self.require_local_api()?;
self.call("set_local_api_config", (user_id, config)).await
}

#[cfg(feature = "acp")]
pub async fn get_acp_config(
&self,
Expand Down Expand Up @@ -608,6 +644,19 @@ async fn dispatch_special(
"restore_auth" => {
serde_json::to_value(backend.validate_persisted_auth().await).map_err(|e| e.to_string())
}
#[cfg(feature = "proxy")]
"get_local_api_status" => serde_json::to_value(backend.get_local_api_status().await?)
.map_err(|_| "Could not encode Local API status".into()),
#[cfg(feature = "proxy")]
"stop_local_api" => serde_json::to_value(backend.stop_local_api().await?)
.map_err(|_| "Could not encode Local API status".into()),
#[cfg(feature = "proxy")]
"set_local_api_config" => {
let (user_id, config): (String, crate::local_api::LocalApiConfig) =
serde_json::from_value(request.params).map_err(invalid)?;
serde_json::to_value(backend.set_local_api_config(&user_id, config).await?)
.map_err(|_| "Could not encode Local API status".into())
}
"cancel_oauth" => {
backend.cancel_oauth();
Ok(serde_json::Value::Null)
Expand Down Expand Up @@ -672,6 +721,13 @@ pub(crate) async fn dispatch(
backend: &LocalBackend,
request: Request,
) -> Result<serde_json::Value, String> {
#[cfg(feature = "proxy")]
if matches!(
request.method.as_str(),
"get_local_api_status" | "stop_local_api"
) {
return dispatch_inner(backend, request).await;
}
// Account switches cannot interleave with an operation admitted for the
// previous account. Restoring that same account is a reader: its network
// validation must not prevent local history, configuration, or stop calls.
Expand All @@ -691,11 +747,31 @@ pub(crate) async fn dispatch(
| "oauth_complete"
| "confirm_account_deletion"
) {
#[cfg(feature = "proxy")]
backend.local_api.stop().await;
let _account = backend.account_gate.write().await;
dispatch_inner(backend, request).await
#[cfg(feature = "proxy")]
backend.local_api.stop().await;
#[cfg(feature = "proxy")]
let generation = backend.local_api.generation();
let result = dispatch_inner(backend, request).await;
#[cfg(feature = "proxy")]
if result.is_ok() {
let _ = backend.resume_local_api(generation).await;
}
result
} else {
let _account = backend.account_gate.read().await;
dispatch_inner(backend, request).await
#[cfg(feature = "proxy")]
let resume = (request.method == "restore_auth").then(|| backend.local_api.generation());
let result = dispatch_inner(backend, request).await;
#[cfg(feature = "proxy")]
if result.is_ok()
&& let Some(generation) = resume
{
let _ = backend.resume_local_api(generation).await;
}
result
}
}

Expand Down
21 changes: 20 additions & 1 deletion apps/maple-agent/app/src/backend/local.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ use crate::hosted_oauth::{ATTEMPT_TIMEOUT, HandoffListener};
mod deletion;
pub use deletion::AccountDeletionStatus;

#[cfg(feature = "proxy")]
#[path = "local_api.rs"]
mod local_api;
#[path = "project_folders.rs"]
mod project_folders;
#[allow(unused_imports)]
Expand Down Expand Up @@ -107,6 +110,12 @@ pub struct DictionaryPage {
}

pub struct LocalBackend {
#[cfg(feature = "proxy")]
pub(crate) local_api: crate::local_api::LocalApiManager,
#[cfg(feature = "proxy")]
local_api_settings_gate: tokio::sync::Mutex<()>,
#[cfg(feature = "proxy")]
local_api_control: std::sync::Mutex<local_api::Control>,
store: Option<Arc<maple_agent::store::Store>>,
pub(crate) remote_updates: Arc<crate::remote_updates::Broker>,
row_reader: Option<Arc<maple_agent::store::RowReader>>,
Expand Down Expand Up @@ -1053,6 +1062,8 @@ impl LocalBackend {
}

pub(crate) async fn forget_store_session(&self) -> Result<(), String> {
#[cfg(feature = "proxy")]
self.local_api.stop().await;
self.service.finish_store_reset().await?;
self.service.invalidate_store_session();
if let Some(user) = self.persisted_auth.forget_owner() {
Expand Down Expand Up @@ -1138,6 +1149,12 @@ impl LocalBackend {
.as_ref()
.map(|store| Arc::new(maple_agent::store::RowReader::new(store.clone())));
Ok(Self {
#[cfg(feature = "proxy")]
local_api: crate::local_api::LocalApiManager::new(api_url.clone())?,
#[cfg(feature = "proxy")]
local_api_settings_gate: tokio::sync::Mutex::new(()),
#[cfg(feature = "proxy")]
local_api_control: std::sync::Mutex::new(local_api::Control::default()),
remote_updates: Arc::new(crate::remote_updates::Broker::new(store.clone())),
store,
row_reader,
Expand Down Expand Up @@ -1438,7 +1455,7 @@ impl LocalBackend {

/// Validate the saved credentials with the server and install the
/// session on success. Definitive rejections compare-clear the saved record.
pub(super) async fn validate_persisted_auth(&self) -> RestoreOutcome {
pub(crate) async fn validate_persisted_auth(&self) -> RestoreOutcome {
let _restore = self.restore_lock.lock().await;
if let Some(user_id) = self.saved_user_id()
&& self.auth.session_for(&user_id).await.is_ok()
Expand Down Expand Up @@ -1521,6 +1538,8 @@ impl LocalBackend {
/// Drop the live session and the persisted record. `revoke` also sends
/// `POST /logout`; a deleted account has no session left to report.
async fn clear_session(&self, user_id: &str, revoke: bool) -> Result<(), String> {
#[cfg(feature = "proxy")]
self.local_api.stop().await;
let _account = self.restore_lock.lock().await;
let pending = self.pending_deletion_status()?;
if let Some(pending) = pending.as_ref() {
Expand Down
Loading
Loading