From 152679f973cf44697e97381b38090cde6fb69843 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:21:32 +0000 Subject: [PATCH 1/2] feat(agent): connect Vana through native local MCP OAuth --- apps/maple-agent/Cargo.lock | 24 + apps/maple-agent/README.md | 21 +- .../app/assets/icons/vana-mark.svg | 2 + apps/maple-agent/app/src/assets.rs | 2 + apps/maple-agent/app/src/backend.rs | 87 ++ apps/maple-agent/app/src/backend/local.rs | 91 ++ apps/maple-agent/app/src/ui/settings.rs | 106 +- .../app/src/ui/settings/navigation.rs | 79 +- apps/maple-agent/app/src/ui/settings/vana.rs | 472 +++++++ .../maple-agent/crates/maple-agent/Cargo.toml | 4 +- .../crates/maple-agent/src/agent/mcp.rs | 1 + .../maple-agent/src/agent/mcp/connection.rs | 48 +- .../crates/maple-agent/src/agent/mcp/oauth.rs | 998 ++++++++++++++ .../maple-agent/src/agent/mcp/oauth/tests.rs | 618 +++++++++ .../crates/maple-agent/src/agent/mcp/task.rs | 7 + .../crates/maple-agent/src/agent/runtime.rs | 3 + .../crates/maple-agent/src/store/gc.rs | 45 + .../crates/maple-agent/src/store/gc_tests.rs | 108 ++ .../crates/maple-agent/src/store/integrity.rs | 3 + .../src/store/integrity_constraints.rs | 14 + .../src/store/integrity_constraints_tests.rs | 117 +- .../src/store/integrity_salvage.rs | 9 +- .../src/store/integrity_salvage_tests.rs | 174 +++ .../crates/maple-agent/src/store/records.rs | 13 + .../maple-agent/src/store/records/oauth.rs | 526 ++++++++ .../crates/maple-agent/src/store/schema.rs | 20 +- apps/maple-agent/scripts/macos-debug-app.sh | 4 +- apps/maple-agent/scripts/vana-poc/.gitignore | 1 + apps/maple-agent/scripts/vana-poc/README.md | 78 ++ .../scripts/vana-poc/demo-data/README.md | 43 + .../vana-poc/demo-data/chatgpt-export.json | 1181 +++++++++++++++++ .../demo-data/chatgpt.conversations.json | 1071 +++++++++++++++ .../vana-poc/demo-data/chatgpt.memories.json | 89 ++ .../vana-poc/demo-data/fixtures.test.mjs | 41 + .../scripts/vana-poc/demo-data/generate.mjs | 141 ++ .../vana-poc/demo-data/oura-export.json | 774 +++++++++++ .../vana-poc/demo-data/oura.readiness.json | 256 ++++ .../vana-poc/demo-data/oura.sleep.json | 496 +++++++ .../scripts/vana-poc/demo-data/validate.mjs | 84 ++ 39 files changed, 7806 insertions(+), 45 deletions(-) create mode 100644 apps/maple-agent/app/assets/icons/vana-mark.svg create mode 100644 apps/maple-agent/app/src/ui/settings/vana.rs create mode 100644 apps/maple-agent/crates/maple-agent/src/agent/mcp/oauth.rs create mode 100644 apps/maple-agent/crates/maple-agent/src/agent/mcp/oauth/tests.rs create mode 100644 apps/maple-agent/crates/maple-agent/src/store/records/oauth.rs create mode 100644 apps/maple-agent/scripts/vana-poc/.gitignore create mode 100644 apps/maple-agent/scripts/vana-poc/README.md create mode 100644 apps/maple-agent/scripts/vana-poc/demo-data/README.md create mode 100644 apps/maple-agent/scripts/vana-poc/demo-data/chatgpt-export.json create mode 100644 apps/maple-agent/scripts/vana-poc/demo-data/chatgpt.conversations.json create mode 100644 apps/maple-agent/scripts/vana-poc/demo-data/chatgpt.memories.json create mode 100644 apps/maple-agent/scripts/vana-poc/demo-data/fixtures.test.mjs create mode 100644 apps/maple-agent/scripts/vana-poc/demo-data/generate.mjs create mode 100644 apps/maple-agent/scripts/vana-poc/demo-data/oura-export.json create mode 100644 apps/maple-agent/scripts/vana-poc/demo-data/oura.readiness.json create mode 100644 apps/maple-agent/scripts/vana-poc/demo-data/oura.sleep.json create mode 100644 apps/maple-agent/scripts/vana-poc/demo-data/validate.mjs diff --git a/apps/maple-agent/Cargo.lock b/apps/maple-agent/Cargo.lock index 54670da25..9d30543fb 100644 --- a/apps/maple-agent/Cargo.lock +++ b/apps/maple-agent/Cargo.lock @@ -5804,9 +5804,11 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", + "sse-stream", "tempfile", "tokio", "tokio-util", + "tracing", "unicode-normalization", "uuid", "windows-sys 0.61.2", @@ -6819,6 +6821,25 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "oauth2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" +dependencies = [ + "base64 0.22.1", + "chrono", + "getrandom 0.2.17", + "http", + "rand 0.8.7", + "serde", + "serde_json", + "serde_path_to_error", + "sha2 0.10.9", + "thiserror 1.0.69", + "url", +] + [[package]] name = "objc" version = "0.2.7" @@ -8989,12 +9010,14 @@ version = "3.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b6317cd8c13e3ec9033cf2aa5aa92cd743f0f4f8a93cddc42ea0ae6ce3b8898" dependencies = [ + "async-trait", "base64 0.23.1", "bytes", "chrono", "futures", "http", "indexmap 2.14.0", + "oauth2", "pin-project-lite", "reqwest 0.13.4", "serde", @@ -9005,6 +9028,7 @@ dependencies = [ "tokio-stream", "tokio-util", "tracing", + "url", ] [[package]] diff --git a/apps/maple-agent/README.md b/apps/maple-agent/README.md index c1eef9d67..9e3d3197b 100644 --- a/apps/maple-agent/README.md +++ b/apps/maple-agent/README.md @@ -205,8 +205,25 @@ A stdio server runs in the task's folder with the login shell's PATH, in its own process group; it is stopped by closing its input, then SIGTERM, then SIGKILL. For an HTTP server, `$NAME` and `${NAME}` in the URL and in header values are filled in from the server's environment variables, and redirects -are not followed. Sign-in to servers (OAuth), and MCP resources and prompts, -are not supported yet. +are not followed. Generic remote-server OAuth, MCP resources and prompts are +not supported yet. + +#### Vana on this computer + +The Vana card in Settings > Integrations connects to the **Local MCP endpoint** +copied from Vana Desktop's Settings > Server > Connect apps. The port is chosen +by Vana; Maple accepts its exact `http://127.0.0.1:/mcp` URL and performs +native OAuth with PKCE. Choose the shared data in Vana's approval screen, then +select Vana in Maple's per-task integration menu. Setup requires This Computer; +it cannot be forwarded through a remote execution computer. + +The local host keeps account- and endpoint-bound OAuth credentials and renews +access tokens. No wallet key, developer controller, Node bridge, public MCP +URL or remote tunnel is required for the MCP data connection. Vana Desktop must +remain running. Disconnect the client in Vana to revoke access, then reconnect +in Maple when you want to authorize it again. Local MCP does not imply offline +Vana account/grant services or on-device model processing. See the +[integration guide](scripts/vana-poc/README.md). #### Claude Code diff --git a/apps/maple-agent/app/assets/icons/vana-mark.svg b/apps/maple-agent/app/assets/icons/vana-mark.svg new file mode 100644 index 000000000..1a288d58e --- /dev/null +++ b/apps/maple-agent/app/assets/icons/vana-mark.svg @@ -0,0 +1,2 @@ + + diff --git a/apps/maple-agent/app/src/assets.rs b/apps/maple-agent/app/src/assets.rs index bbb74b98c..6020e1d04 100644 --- a/apps/maple-agent/app/src/assets.rs +++ b/apps/maple-agent/app/src/assets.rs @@ -58,6 +58,8 @@ assets!( "icons/trash-2.svg", "icons/undo-2.svg", "icons/users.svg", + // Official wordmark from https://vana.org/ (2026-10-13), for the Vana MCP catalog card. + "icons/vana-mark.svg", "icons/volume-2.svg", "icons/x.svg", "icons/zap.svg", diff --git a/apps/maple-agent/app/src/backend.rs b/apps/maple-agent/app/src/backend.rs index a1261c08e..fbbc2550f 100644 --- a/apps/maple-agent/app/src/backend.rs +++ b/apps/maple-agent/app/src/backend.rs @@ -454,6 +454,25 @@ impl AgentBackend { } } + /// Vana's loopback endpoint belongs to this computer, never the selected + /// remote execution host. This is presentation only; dispatch also enforces + /// the local transport boundary before decoding the request. + pub fn vana_local_available(&self) -> bool { + self.remote_host.is_none() && !self.retired_after_update.load(Ordering::Acquire) + } + + pub async fn connect_vana_local( + &self, + user_id: &str, + endpoint: String, + ) -> Result, String> { + self.ensure_active()?; + if self.remote_host.is_some() { + return Err("Connect Vana from This Computer, where Vana Desktop is running.".into()); + } + self.call("connect_vana_local", (user_id, endpoint)).await + } + pub async fn remote_setting( &self, method: &str, @@ -650,6 +669,12 @@ async fn dispatch_special( serde_json::to_value(value).map_err(|_| "Could not encode host response".to_string()) } match request.method.as_str() { + "connect_vana_local" => { + let (user, endpoint): (String, String) = + serde_json::from_value(request.params).map_err(invalid)?; + backend.authorize_account(&user)?; + encode(backend.connect_vana_local(&user, endpoint).await?) + } "save_mcp_servers" => { let (user, servers): (String, Vec) = serde_json::from_value(request.params).map_err(invalid)?; @@ -782,6 +807,11 @@ pub(crate) async fn dispatch_remote_inner( backend: &LocalBackend, request: Request, ) -> Result { + // An authenticated remote request must not open a browser or approve + // access to this computer's Personal Server, even with malformed params. + if request.method == "connect_vana_local" { + return Err("Vana connection setup requires the local desktop.".into()); + } if paging::is_paging(&request.method) { return paging::dispatch(backend, request, maple_agent::store::PageTransport::Remote).await; } @@ -802,6 +832,63 @@ mod tests { Arc::new(AgentBackend::for_test("http://127.0.0.1:1".into(), String::new()).unwrap()) } + #[test] + fn vana_local_setup_refuses_remote_and_retired_facades_before_dispatch() { + let controller = fixture(); + assert!(controller.vana_local_available()); + let runtime = controller.runtime_handle(); + runtime.block_on(async { + let remote = controller + .with_remote_connection("fixture-peer".into(), || async { + Ok(in_process_client(&controller)) + }) + .await + .unwrap(); + assert!(!remote.vana_local_available()); + assert_eq!( + remote + .connect_vana_local("fixture-user", "http://127.0.0.1:12345/mcp".into()) + .await + .err(), + Some("Connect Vana from This Computer, where Vana Desktop is running.".into()) + ); + controller.retire_after_update(); + assert!(!controller.vana_local_available()); + assert_eq!( + controller + .connect_vana_local("fixture-user", "http://127.0.0.1:12345/mcp".into()) + .await + .err(), + Some(RETIRED_AFTER_UPDATE.into()) + ); + }); + } + + #[tokio::test] + async fn vana_local_setup_refuses_remote_dispatch_before_parameter_decoding() { + let root = tempfile::tempdir().unwrap(); + let backend = LocalBackend::isolated_for_test(root.path()); + for params in [ + serde_json::Value::Null, + serde_json::json!(["fixture-user", "http://127.0.0.1:12345/mcp"]), + ] { + let result = dispatch_remote_inner( + &backend, + Request { + id: 1, + method: "connect_vana_local".into(), + send_id: None, + params, + }, + ) + .await; + assert_eq!( + result.err(), + Some("Vana connection setup requires the local desktop.".into()) + ); + } + } + fn in_process_client(controller: &AgentBackend) -> BackendClient { let _runtime = controller.runtime.enter(); let mut backend = LocalBackend::new(controller.api_url.clone(), String::new()).unwrap(); diff --git a/apps/maple-agent/app/src/backend/local.rs b/apps/maple-agent/app/src/backend/local.rs index e789f0aff..e5dc41238 100644 --- a/apps/maple-agent/app/src/backend/local.rs +++ b/apps/maple-agent/app/src/backend/local.rs @@ -2571,6 +2571,25 @@ impl LocalBackend { .await } + /// The local host owns the entire OAuth attempt and its credentials. Only + /// public MCP metadata returns to the desktop after the approved setup is + /// installed by the account-bound runtime handle. + pub async fn connect_vana_local( + &self, + user_id: &str, + endpoint: String, + ) -> Result, String> { + self.authorize_account(user_id)?; + let handle = self.service.handle_for_user(user_id).await?; + let browser_endpoint = endpoint.clone(); + handle + .connect_vana_local( + endpoint, + Arc::new(move |url| open_vana_authorization(&browser_endpoint, &url)), + ) + .await + } + pub async fn list_project_mcp_servers( &self, user_id: &str, @@ -3049,6 +3068,45 @@ pub fn open_in_browser(url: &str, what: &str) -> Result<(), String> { }) } +/// OAuth metadata is untrusted even when it comes from a user-selected local +/// endpoint. The browser may only open its exact loopback origin; never launch +/// arbitrary protocols or disclose the authorization query in an error/log. +fn validate_vana_authorization(endpoint: &str, address: &str) -> Result<(), String> { + const INVALID: &str = + "Vana returned an unexpected sign-in address. Copy its Local MCP endpoint again."; + if endpoint.len() > 4096 || address.len() > 16 * 1024 { + return Err(INVALID.into()); + } + let endpoint = reqwest::Url::parse(endpoint.trim()).map_err(|_| INVALID.to_string())?; + let address = reqwest::Url::parse(address).map_err(|_| INVALID.to_string())?; + if endpoint.scheme() != "http" + || endpoint.host_str() != Some("127.0.0.1") + || !endpoint.port().is_some_and(|port| port != 0) + || !endpoint.username().is_empty() + || endpoint.password().is_some() + || endpoint.path() != "/mcp" + || endpoint.query().is_some() + || endpoint.fragment().is_some() + || address.origin() != endpoint.origin() + || !address.username().is_empty() + || address.password().is_some() + || address.fragment().is_some() + { + return Err(INVALID.into()); + } + Ok(()) +} + +fn open_vana_authorization(endpoint: &str, address: &str) -> Result<(), String> { + validate_vana_authorization(endpoint, address)?; + // This runs in the local host's IPC worker, never the GPUI thread. A + // webbrowser error may contain the full URL; keep it out of logs and IPC. + webbrowser::open(address).map_err(|_| { + "Could not open your browser for Vana sign-in. Check your default browser and try again." + .to_string() + }) +} + /// The server's API key name rule: 1 to 50 characters after trimming. pub const MAX_API_KEY_NAME_LENGTH: usize = 50; @@ -3116,6 +3174,39 @@ mod project_names_tests; mod tests { use super::*; + #[test] + fn vana_local_browser_only_opens_the_requested_loopback_origin() { + let endpoint = "http://127.0.0.1:47321/mcp"; + assert!( + validate_vana_authorization( + endpoint, + "http://127.0.0.1:47321/mcp/oauth/authorize?client_id=fixture&state=synthetic-state", + ) + .is_ok() + ); + for address in [ + "https://example.com/authorize?state=synthetic-state", + "http://127.0.0.1:47322/mcp/oauth/authorize", + "http://localhost:47321/mcp/oauth/authorize", + "http://user:password@127.0.0.1:47321/mcp/oauth/authorize", + "http://127.0.0.1:47321/mcp/oauth/authorize#unexpected", + "vana://mcp-authorization?fixture=synthetic", + ] { + assert!(validate_vana_authorization(endpoint, address).is_err()); + } + for endpoint in [ + "https://example.com/mcp", + "http://127.0.0.1:0/mcp", + "http://127.0.0.1:47321/mcp?unexpected=true", + "http://127.0.0.1:47321/mcp#unexpected", + "http://127.0.0.1:47321/another-resource", + ] { + assert!( + validate_vana_authorization(endpoint, "http://127.0.0.1:47321/authorize").is_err() + ); + } + } + #[tokio::test] async fn remote_mcp_host_boundary_accepts_write_only_values_and_keeps_account_fences() { let root = tempfile::tempdir().unwrap(); diff --git a/apps/maple-agent/app/src/ui/settings.rs b/apps/maple-agent/app/src/ui/settings.rs index f651f3dd9..478340491 100644 --- a/apps/maple-agent/app/src/ui/settings.rs +++ b/apps/maple-agent/app/src/ui/settings.rs @@ -38,6 +38,7 @@ mod billing; mod licenses; mod mcp; mod navigation; +mod vana; use self::account::AccountState; use self::api_keys::ApiKeysState; use self::billing::BillingState; @@ -155,6 +156,12 @@ pub struct SettingsScreen { mcp_editor: Option, mcp_notice: Option, mcp_saving: bool, + /// This computer can authorize Vana; saved OAuth values never enter the UI. + vana_local_available: bool, + vana_details_open: bool, + vana_connecting: bool, + vana_notice: Option, + vana_endpoint: Entity, /// Device-local integrations discovered by the runtime; None until loaded. integrations: Option>, integration_notice: Option, @@ -411,6 +418,14 @@ impl SettingsScreen { let application_anchor = ScrollAnchor::for_handle(pane_scroll.clone()); let application_vim = SettingsApplicationVimState::new(section); let application_focus_pending = settings.application_vim_enabled; + let vana_local_available = integrations_backend.vana_local_available(); + let vana_application_focus = application_focus.clone(); + let vana_endpoint = cx.new(move |cx| { + TextInput::new("http://127.0.0.1:PORT/mcp", cx) + .with_tab_index(3) + .application_vim(application_vim_enabled) + .on_application_escape(move |window, cx| window.focus(&vana_application_focus, cx)) + }); let this = Self { bridged_tasks: std::cell::RefCell::new(Vec::new()), backend, @@ -430,6 +445,11 @@ impl SettingsScreen { mcp_editor: None, mcp_notice: None, mcp_saving: false, + vana_local_available, + vana_details_open: false, + vana_connecting: false, + vana_notice: None, + vana_endpoint, integrations: None, integration_notice: None, integration_saving: HashSet::new(), @@ -658,8 +678,19 @@ impl SettingsScreen { ); } + /// Avoid applying an old settings list after the OAuth flow publishes its definition. + fn mcp_change_blocked(&mut self, cx: &mut Context) -> bool { + if !self.vana_connecting { + return false; + } + self.mcp_notice = + Some("Wait for Vana to finish connecting before changing MCP servers.".to_string()); + cx.notify(); + true + } + fn save_mcp_servers(&mut self, servers: Vec, cx: &mut Context) { - if self.mcp_saving { + if self.mcp_change_blocked(cx) || self.mcp_saving { return; } self.mcp_saving = true; @@ -688,6 +719,9 @@ impl SettingsScreen { } fn open_mcp_editor(&mut self, existing: Option, cx: &mut Context) { + if self.mcp_change_blocked(cx) { + return; + } let application_focus = self.application_focus.clone(); let application_vim_enabled = self.settings.application_vim_enabled; let mut field = |placeholder: &str, value: &str, index: isize, secret: bool| { @@ -777,6 +811,9 @@ impl SettingsScreen { } fn submit_mcp_editor(&mut self, cx: &mut Context) { + if self.mcp_change_blocked(cx) { + return; + } let Some(editor) = self.mcp_editor.as_ref() else { return; }; @@ -865,6 +902,9 @@ impl SettingsScreen { /// Open the editor for the server called `name`, looked up on click so /// the list rows do not clone a server per frame. fn edit_mcp_server(&mut self, name: &str, cx: &mut Context) { + if self.mcp_change_blocked(cx) { + return; + } let server = self .mcp_servers .as_deref() @@ -878,6 +918,9 @@ impl SettingsScreen { } fn toggle_mcp_server(&mut self, name: &str, cx: &mut Context) { + if self.mcp_change_blocked(cx) { + return; + } let mut servers: Vec<_> = self .mcp_servers .as_deref() @@ -892,6 +935,9 @@ impl SettingsScreen { } fn remove_mcp_server(&mut self, name: &str, cx: &mut Context) { + if self.mcp_change_blocked(cx) { + return; + } let mut servers: Vec<_> = self .mcp_servers .as_deref() @@ -1515,6 +1561,13 @@ impl SettingsScreen { } fn close(&mut self, cx: &mut Context) { + if self.vana_connecting { + self.vana_notice = + Some("Finish connecting in Vana before leaving Settings.".to_string()); + self.vana_details_open = true; + self.select_section(Section::Integrations, cx); + return; + } if self.password_operation_busy(cx) { return; } @@ -2417,9 +2470,20 @@ impl SettingsScreen { div() .text_sm() .text_color(gpui::rgb(theme::text_muted())) - .child("Manage integrations here. Enable coding agents to make them available in the composer, then select them for each task."), + .child("Give Maple the tools and context you choose. Connect an integration here, then select it for each chat."), ); + pane = pane.child(if self.vana_details_open { + self.render_vana_details(cx) + } else { + self.render_vana_catalog_card(cx) + }); + pane = pane.child( + div() + .mt_2() + .child(subsection_title("Tools & coding agents")), + ); + if let Some(notice) = &self.integration_notice { pane = pane.child(widgets::notice( "integration-notice-close", @@ -2474,6 +2538,7 @@ impl SettingsScreen { self.application_target( || SettingsTarget::McpAdd, widgets::primary_button("mcp-add") + .when(self.vana_connecting, |button| button.opacity(0.5)) .on_click(cx.listener(|this, _event, _window, cx| { this.open_mcp_editor(None, cx); })) @@ -2551,19 +2616,24 @@ impl SettingsScreen { .child(summary), ), ) - .child(pill_button( - format!("mcp-toggle-{name}"), - if server.enabled { - "Enabled" - } else { - "Disabled" - }, - server.enabled, - cx.listener({ - let name = name.clone(); - move |this, _event, _window, cx| this.toggle_mcp_server(&name, cx) - }), - )) + .child( + pill_button( + format!("mcp-toggle-{name}"), + if server.enabled { + "Enabled" + } else { + "Disabled" + }, + server.enabled, + cx.listener({ + let name = name.clone(); + move |this, _event, _window, cx| { + this.toggle_mcp_server(&name, cx) + } + }), + ) + .when(self.vana_connecting, |button| button.opacity(0.5)), + ) .child( widgets::icon_button( gpui::SharedString::from(format!("mcp-edit-{name}")), @@ -2572,6 +2642,7 @@ impl SettingsScreen { widgets::ROW_ICON, theme::text_secondary(), ) + .when(self.vana_connecting, |button| button.opacity(0.5)) .on_click(cx.listener({ let name = name.clone(); move |this, _event, _window, cx| this.edit_mcp_server(&name, cx) @@ -2585,6 +2656,7 @@ impl SettingsScreen { widgets::ROW_ICON, theme::status_error(), ) + .when(self.vana_connecting, |button| button.opacity(0.5)) .on_click(cx.listener( move |this, _event, _window, cx| this.remove_mcp_server(&name, cx), )), @@ -2926,7 +2998,9 @@ impl SettingsScreen { .pt_1() .child( widgets::primary_button("mcp-save") - .when(self.mcp_saving, |el| el.opacity(0.5)) + .when(self.mcp_saving || self.vana_connecting, |el| { + el.opacity(0.5) + }) .on_click(cx.listener(|this, _event, _window, cx| { this.submit_mcp_editor(cx); })) diff --git a/apps/maple-agent/app/src/ui/settings/navigation.rs b/apps/maple-agent/app/src/ui/settings/navigation.rs index 3c1e1a2ea..c3494b561 100644 --- a/apps/maple-agent/app/src/ui/settings/navigation.rs +++ b/apps/maple-agent/app/src/ui/settings/navigation.rs @@ -51,6 +51,10 @@ pub(super) enum SettingsTarget { PromptReset, Integration(String), IntegrationSetup(String), + VanaDetails, + VanaConnect, + VanaToggle, + VanaEndpoint, McpAdd, McpServer(String), } @@ -84,7 +88,11 @@ impl SettingsScreen { } pub(super) fn set_application_vim_enabled(&mut self, enabled: bool, cx: &mut Context) { - let mut inputs = vec![self.prompt_editor.clone(), self.shortcut_search.clone()]; + let mut inputs = vec![ + self.prompt_editor.clone(), + self.shortcut_search.clone(), + self.vana_endpoint.clone(), + ]; inputs.extend(self.account.inputs()); inputs.push(self.api_keys.name.clone()); if let Some(editor) = &self.mcp_editor { @@ -157,22 +165,37 @@ impl SettingsScreen { SettingsTarget::PromptSave, SettingsTarget::PromptReset, ], - Section::Integrations => self - .integrations - .as_deref() - .unwrap_or_default() - .iter() - .filter(|integration| integration_is_visible(integration)) - .flat_map(|integration| { - [ - integration_can_setup(integration) - .then(|| SettingsTarget::IntegrationSetup(integration.id.clone())), - integration_can_toggle(integration) - .then(|| SettingsTarget::Integration(integration.id.clone())), - ] - .into_iter() - .flatten() - }) + Section::Integrations => std::iter::once(SettingsTarget::VanaDetails) + .chain( + self.vana_details_open + .then_some(SettingsTarget::VanaConnect), + ) + .chain( + (self.vana_details_open && self.vana_server().is_some()) + .then_some(SettingsTarget::VanaToggle), + ) + .chain( + (self.vana_details_open && self.vana_local_available) + .then_some(SettingsTarget::VanaEndpoint), + ) + .chain( + self.integrations + .as_deref() + .unwrap_or_default() + .iter() + .filter(|integration| integration_is_visible(integration)) + .flat_map(|integration| { + [ + integration_can_setup(integration).then(|| { + SettingsTarget::IntegrationSetup(integration.id.clone()) + }), + integration_can_toggle(integration) + .then(|| SettingsTarget::Integration(integration.id.clone())), + ] + .into_iter() + .flatten() + }), + ) .chain(std::iter::once(SettingsTarget::McpAdd)) .chain( self.mcp_servers @@ -364,6 +387,17 @@ impl SettingsScreen { Some(SettingsTarget::PromptReset) => self.reset_prompt(cx), Some(SettingsTarget::Integration(id)) => self.toggle_integration(&id, cx), Some(SettingsTarget::IntegrationSetup(id)) => self.setup_integration(&id, cx), + Some(SettingsTarget::VanaDetails) => self.open_vana_details(cx), + Some(SettingsTarget::VanaConnect) => self.start_vana_connection(cx), + Some(SettingsTarget::VanaToggle) => { + if let Some(name) = self.vana_server().map(|server| server.name.clone()) { + self.toggle_mcp_server(&name, cx); + } + } + Some(SettingsTarget::VanaEndpoint) => { + let handle = self.vana_endpoint.read(cx).focus_handle(cx); + window.focus(&handle, cx); + } Some(SettingsTarget::McpAdd) => self.open_mcp_editor(None, cx), Some(SettingsTarget::McpServer(name)) => self.edit_mcp_server(&name, cx), None => {} @@ -414,6 +448,10 @@ impl SettingsScreen { cx.notify(); return; } + if self.vana_details_open { + self.open_vana_details(cx); + return; + } if self.shortcut_recorder.is_some() { self.stop_shortcut_recording(); cx.notify(); @@ -424,7 +462,11 @@ impl SettingsScreen { fn application_text_input_focused(&self, window: &Window, cx: &gpui::App) -> bool { let focused = window.focused(cx); - let mut inputs = vec![&self.prompt_editor, &self.shortcut_search]; + let mut inputs = vec![ + &self.prompt_editor, + &self.shortcut_search, + &self.vana_endpoint, + ]; if let Some(editor) = &self.mcp_editor { inputs.extend([ &editor.name, @@ -740,6 +782,7 @@ mod tests { assert_eq!( this.visible_application_targets(), vec![ + SettingsTarget::VanaDetails, SettingsTarget::IntegrationSetup("cua-driver".to_string()), SettingsTarget::Integration("cua-driver".to_string()), SettingsTarget::McpAdd, diff --git a/apps/maple-agent/app/src/ui/settings/vana.rs b/apps/maple-agent/app/src/ui/settings/vana.rs new file mode 100644 index 000000000..86dc56d21 --- /dev/null +++ b/apps/maple-agent/app/src/ui/settings/vana.rs @@ -0,0 +1,472 @@ +//! Vana's local MCP setup. Vana owns consent; Maple only displays public +//! endpoint and definition metadata, never OAuth credentials. + +use gpui::{Context, Div, FontWeight, div, prelude::*, px}; + +use super::{SettingsScreen, SettingsTarget}; +use crate::ui::{icons::icon, theme, widgets}; +use maple_agent::store::records::{McpDefinition, McpTarget}; + +fn chip(label: &'static str) -> Div { + div() + .px_2() + .py_1() + .rounded_full() + .bg(gpui::rgb(theme::bg_sidebar_pill())) + .text_xs() + .text_color(gpui::rgb(theme::text_secondary())) + .child(label) +} + +fn vana_wordmark() -> gpui::Svg { + gpui::svg() + .path("icons/vana-mark.svg") + .w(px(100.)) + .h(px(100. * 478. / 1716.)) + .text_color(gpui::rgb(theme::text_primary())) +} + +impl SettingsScreen { + /// A curated card identifies the exact saved Vana MCP name. Other MCP + /// servers, including user-provided lookalike descriptions, stay custom. + pub(super) fn vana_server(&self) -> Option<&McpDefinition> { + self.mcp_servers + .as_deref() + .unwrap_or_default() + .iter() + .find(|server| server.name.eq_ignore_ascii_case("vana")) + } + + pub(super) fn open_vana_details(&mut self, cx: &mut Context) { + if self.vana_connecting { + return; + } + self.vana_details_open = !self.vana_details_open; + if self.vana_details_open + && let Some(McpDefinition { + target: McpTarget::StreamableHttp { url }, + .. + }) = self.vana_server() + && validate_local_endpoint(url).is_ok() + { + let endpoint = url.clone(); + self.vana_endpoint + .update(cx, |input, cx| input.set_text(&endpoint, cx)); + } + self.mcp_editor = None; + self.vana_notice = None; + self.reconcile_application_vim_target(); + cx.notify(); + } + + pub(super) fn render_vana_catalog_card(&self, cx: &mut Context) -> Div { + let server = self.vana_server(); + let (status, status_color) = match (self.mcp_servers.is_some(), server) { + (false, _) => ("Loading…", theme::text_muted()), + (_, Some(server)) if server.enabled => { + ("Default on for new chats", theme::status_success()) + } + (_, Some(_)) => ("Configured · choose per chat", theme::text_muted()), + (_, None) => ("Not connected", theme::text_muted()), + }; + div() + .flex() + .flex_col() + .gap_4() + .p_5() + .rounded(theme::RADIUS_LG) + .bg(gpui::rgb(theme::bg_sidebar_card())) + .border_1() + .border_color(gpui::rgb(theme::border())) + .child( + div() + .flex() + .items_center() + .justify_between() + .child( + div() + .flex() + .items_center() + .gap_2() + .text_xs() + .font_weight(FontWeight::SEMIBOLD) + .text_color(gpui::rgb(theme::text_muted())) + .child(icon("puzzle", px(14.), theme::text_muted())) + .child("FEATURED INTEGRATION"), + ) + .child( + div() + .flex() + .items_center() + .gap_1p5() + .text_xs() + .text_color(gpui::rgb(status_color)) + .child(div().size(px(6.)).rounded_full().bg(gpui::rgb(status_color))) + .child(status), + ), + ) + .child( + div() + .flex() + .items_center() + .gap_5() + .child( + div() + .flex_none() + .w(px(136.)) + .h(px(88.)) + .flex() + .items_center() + .justify_center() + .rounded(theme::RADIUS_MD) + .bg(gpui::rgb(theme::bg_elevated())) + .border_1() + .border_color(gpui::rgb(theme::border_subtle())) + .child(vana_wordmark()), + ) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .gap_2() + .child( + div() + .text_lg() + .font_weight(FontWeight::SEMIBOLD) + .text_color(gpui::rgb(theme::text_heading())) + .child("Your context. Your permissions."), + ) + .child( + div() + .text_sm() + .text_color(gpui::rgb(theme::text_secondary())) + .child("Bring context from your personal Vana vault into Maple. Get answers that understand your interests, goals, and routines."), + ), + ), + ) + .child( + div() + .flex() + .items_center() + .justify_between() + .gap_3() + .child( + div() + .flex() + .flex_wrap() + .gap_1p5() + .child(chip("Local connection")) + .child(chip("Vana permissions")) + .child(chip("MCP")), + ) + .child(self.application_target( + || SettingsTarget::VanaDetails, + widgets::secondary_button("vana-catalog-details") + .tab_index(0) + .on_click(cx.listener(|this, _event, _window, cx| { + this.open_vana_details(cx); + })) + .child(if server.is_some() { "Manage Vana" } else { "Connect Vana" }) + .child(icon("chevron-right", px(14.), theme::text_primary())), + )), + ) + } + + pub(super) fn render_vana_details(&self, cx: &mut Context) -> Div { + let server = self.vana_server(); + let mut content = div() + .flex() + .flex_col() + .gap_3() + .p_4() + .rounded(theme::RADIUS_LG) + .bg(gpui::rgb(theme::bg_elevated())) + .border_1() + .border_color(gpui::rgb(theme::border())) + .child( + div() + .flex() + .items_center() + .justify_between() + .child( + div() + .flex() + .items_center() + .gap_4() + .child(vana_wordmark()) + .child( + div() + .text_base() + .font_weight(FontWeight::SEMIBOLD) + .text_color(gpui::rgb(theme::text_heading())) + .child("Connect your local vault"), + ), + ) + .child( + widgets::icon_button( + "vana-details-close", + "x", + "Close Vana details", + px(16.), + theme::text_muted(), + ) + .tab_index(0) + .when(self.vana_connecting, |button| button.opacity(0.5)) + .on_click(cx.listener(|this, _event, _window, cx| { + this.open_vana_details(cx); + })), + ), + ) + .child( + div() + .text_sm() + .text_color(gpui::rgb(theme::text_secondary())) + .child("Keep Vana Desktop open on this computer. In Vana, go to Settings → Server → Connect apps and copy the Local MCP endpoint."), + ); + if self.vana_local_available { + content = content.child( + self.application_target( + || SettingsTarget::VanaEndpoint, + div() + .flex() + .flex_col() + .gap_1p5() + .child( + div() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(gpui::rgb(theme::text_primary())) + .child("Local MCP endpoint"), + ) + .child(if self.vana_connecting { + widgets::input_frame() + .text_sm() + .text_color(gpui::rgb(theme::text_muted())) + .child("Waiting for Vana authorization…") + } else { + widgets::input_frame() + .text_sm() + .child(self.vana_endpoint.clone()) + }) + .child( + div() + .text_xs() + .text_color(gpui::rgb(theme::text_muted())) + .child( + "Paste the complete address from Vana. Its port can change.", + ), + ), + ), + ); + } else { + content = content.child( + div() + .p_3() + .rounded(theme::RADIUS_SM) + .bg(gpui::rgb(theme::bg_sidebar())) + .text_sm() + .text_color(gpui::rgb(theme::text_secondary())) + .child("Choose This computer before connecting Vana. A local vault cannot be connected from a remote computer."), + ); + } + content = content + .child(vault_row( + "shield-check", + "Choose what Maple can read in Vana", + "Vana will show the requested access. Select the sources you want to share, then choose Approve and return.", + )) + .child(vault_row( + "lock", + "A direct connection on this computer", + "MCP reads use the local endpoint and work with Vana’s remote tunnel off. Account and authorization services may still require internet access.", + )) + .child( + div() + .p_3() + .rounded(theme::RADIUS_SM) + .bg(gpui::rgb(theme::bg_sidebar())) + .text_xs() + .text_color(gpui::rgb(theme::text_muted())) + .child("Choose Vana in each chat’s Integrations menu. To revoke access, open Vana Settings → Server → Connected clients and select Disconnect."), + ); + if let Some(notice) = &self.vana_notice { + content = content.child( + div() + .text_sm() + .text_color(gpui::rgb(theme::text_secondary())) + .child(notice.clone()), + ); + } + let mut actions = div().flex().items_center().gap_2(); + if let Some(server) = server { + let name = server.name.clone(); + actions = actions.child( + self.application_target( + || SettingsTarget::VanaToggle, + widgets::secondary_button("vana-default-toggle") + .tab_index(0) + .when(self.mcp_saving || self.vana_connecting, |button| { + button.opacity(0.5) + }) + .on_click(cx.listener(move |this, _event, _window, cx| { + if !this.mcp_saving { + this.toggle_mcp_server(&name, cx); + } + })) + .child(if server.enabled { + "Turn off for new chats" + } else { + "Use by default in new chats" + }), + ), + ); + } + actions = actions.child( + self.application_target( + || SettingsTarget::VanaConnect, + widgets::primary_button("vana-connect") + .tab_index(0) + .when( + self.vana_connecting || self.mcp_saving || !self.vana_local_available, + |button| button.opacity(0.5), + ) + .on_click(cx.listener(|this, _event, _window, cx| { + this.start_vana_connection(cx); + })) + .child(if self.vana_connecting { + "Connecting…" + } else if server.is_some() { + "Reconnect Vana" + } else { + "Connect Vana" + }), + ), + ); + content.child(actions) + } + + pub(super) fn start_vana_connection(&mut self, cx: &mut Context) { + if self.vana_connecting { + return; + } + if !self.vana_local_available { + self.vana_notice = Some("Choose This computer before connecting Vana.".to_string()); + cx.notify(); + return; + } + if self.mcp_saving { + self.vana_notice = + Some("Wait for the current MCP change to finish, then connect Vana.".to_string()); + cx.notify(); + return; + } + let endpoint = self.vana_endpoint.read(cx).text().trim().to_string(); + if let Err(message) = validate_local_endpoint(&endpoint) { + self.vana_notice = Some(message.to_string()); + cx.notify(); + return; + } + self.vana_connecting = true; + self.vana_notice = Some("Your browser will open Vana. Choose the sources to share and approve the connection there.".to_string()); + cx.notify(); + let backend = self.integrations_backend.clone(); + let user_id = self.user_id.clone(); + self.call( + async move { backend.connect_vana_local(&user_id, endpoint).await }, + cx, + |this, result, cx| { + this.vana_connecting = false; + match result { + Ok(servers) => { + this.mcp_servers = Some(servers); + this.vana_notice = Some("Vana is configured. Choose it in your chat’s Integrations menu when you want to use the access you approved.".to_string()); + } + Err(message) => this.vana_notice = Some(message), + } + this.reconcile_application_vim_target(); + cx.notify(); + }, + ); + } +} + +/// A UI hint only. The host repeats endpoint validation before making requests. +fn validate_local_endpoint(endpoint: &str) -> Result<(), &'static str> { + const MESSAGE: &str = "Paste Vana’s Local MCP endpoint: http://127.0.0.1:PORT/mcp."; + let url = reqwest::Url::parse(endpoint).map_err(|_| MESSAGE)?; + if url.scheme() != "http" + || url.host_str() != Some("127.0.0.1") + || url.port().is_none_or(|port| port == 0) + || url.path() != "/mcp" + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Err(MESSAGE); + } + Ok(()) +} + +fn vault_row(mark: &'static str, title: &'static str, description: &'static str) -> Div { + div() + .flex() + .items_start() + .gap_3() + .child( + div() + .flex_none() + .size(px(32.)) + .rounded(theme::RADIUS_SM) + .bg(gpui::rgb(theme::bg_sidebar_card())) + .flex() + .items_center() + .justify_center() + .child(icon(mark, px(18.), theme::text_secondary())), + ) + .child( + div() + .flex() + .flex_col() + .gap_0p5() + .child( + div() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(gpui::rgb(theme::text_primary())) + .child(title), + ) + .child( + div() + .text_xs() + .text_color(gpui::rgb(theme::text_muted())) + .child(description), + ), + ) +} + +#[cfg(test)] +mod tests { + use super::validate_local_endpoint; + + #[test] + fn local_endpoint_keeps_the_copied_port_and_rejects_remote_or_credential_urls() { + assert!(validate_local_endpoint("http://127.0.0.1:47321/mcp").is_ok()); + assert!(validate_local_endpoint("http://127.0.0.1:57123/mcp").is_ok()); + for endpoint in [ + "https://0x123.server.vana.org/mcp", + "http://example.com:47321/mcp", + "http://localhost:47321/mcp", + "http://127.0.0.1/mcp", + "http://127.0.0.1:0/mcp", + "http://user:secret@127.0.0.1:47321/mcp", + "http://127.0.0.1:47321/mcp?token=secret", + "http://127.0.0.1:47321/mcp#fragment", + "http://127.0.0.1:47321/other", + ] { + assert!(validate_local_endpoint(endpoint).is_err(), "{endpoint}"); + } + } +} diff --git a/apps/maple-agent/crates/maple-agent/Cargo.toml b/apps/maple-agent/crates/maple-agent/Cargo.toml index 7b649d3ad..4a0cdb4a0 100644 --- a/apps/maple-agent/crates/maple-agent/Cargo.toml +++ b/apps/maple-agent/crates/maple-agent/Cargo.toml @@ -49,7 +49,9 @@ maple-proxy = { workspace = true } pulldown-cmark = { version = "0.13", default-features = false } image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp"] } # MCP clients for the account's servers: stdio pipes Maple spawns itself, and streamable HTTP. -rmcp = { version = "=3.4.1", default-features = false, features = ["client", "transport-async-rw", "transport-streamable-http-client-reqwest"] } +rmcp = { version = "=3.4.1", default-features = false, features = ["client", "auth", "transport-async-rw", "transport-streamable-http-client-reqwest"] } +tracing = { version = "0.1", default-features = false, features = ["std"] } +sse-stream = "=0.2.6" pi-ai = { path = "../pi-ai", default-features = false } pi-agent-core = { path = "../pi-agent-core" } pi-coding-agent = { path = "../pi-coding-agent" } diff --git a/apps/maple-agent/crates/maple-agent/src/agent/mcp.rs b/apps/maple-agent/crates/maple-agent/src/agent/mcp.rs index ae83bf4f0..2ebf9b16b 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/mcp.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/mcp.rs @@ -18,6 +18,7 @@ use crate::store::records::{ mod connection; #[cfg(test)] pub(super) mod fake_server; +pub(crate) mod oauth; mod task; mod tool; diff --git a/apps/maple-agent/crates/maple-agent/src/agent/mcp/connection.rs b/apps/maple-agent/crates/maple-agent/src/agent/mcp/connection.rs index ca57c63ab..9a901d93e 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/mcp/connection.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/mcp/connection.rs @@ -188,6 +188,7 @@ struct Connection { progress: Progress, listing: Listing, stdio: Option, + oauth_generation: Option, } /// One MCP server of a task. @@ -199,6 +200,7 @@ pub(crate) struct McpServer { tools_changed: ToolsChanged, connection: tokio::sync::Mutex>, shutdown: CancellationToken, + oauth: Option, } impl McpServer { @@ -215,9 +217,18 @@ impl McpServer { tools_changed, connection: tokio::sync::Mutex::new(None), shutdown: CancellationToken::new(), + oauth: None, } } + pub(crate) fn with_oauth_context( + mut self, + oauth: Option, + ) -> Self { + self.oauth = oauth; + self + } + pub(crate) fn name(&self) -> &str { &self.config.name } @@ -258,9 +269,15 @@ impl McpServer { if self.shutdown.is_cancelled() { return Err("it was stopped".to_string()); } + let generation = match (&self.oauth, &self.config.transport) { + (Some(context), AgentMcpTransport::StreamableHttp { url, .. }) => { + context.active_generation(&super::name_to_key(&self.config.name), url)? + } + _ => None, + }; if connection .as_ref() - .is_some_and(|open| !open.service.is_closed()) + .is_some_and(|open| !open.service.is_closed() && open.oauth_generation == generation) { return Ok(()); } @@ -315,6 +332,7 @@ impl McpServer { progress, listing, stdio: Some(stdio), + oauth_generation: None, }), Err(error) => Err(stdio.failed(error).await), } @@ -324,6 +342,33 @@ impl McpServer { environment, headers, } => { + if let Some(context) = &self.oauth + && let Some(client) = super::oauth::runtime_client( + context, + &super::name_to_key(&self.config.name), + url, + ) + .await? + { + if !environment.is_empty() || !headers.is_empty() { + return Err( + "OAuth MCP endpoints cannot also carry custom credentials".into() + ); + } + let oauth_generation = client.generation(); + let transport = StreamableHttpClientTransport::with_client( + client, + StreamableHttpClientTransportConfig::with_uri(url.as_str()), + ); + let (service, listing) = self.handshake(transport, &progress).await?; + return Ok(Connection { + service, + progress, + listing, + stdio: None, + oauth_generation, + }); + } let mut custom = HashMap::new(); for header in headers { let key = HeaderName::try_from(header.key.as_str()) @@ -350,6 +395,7 @@ impl McpServer { progress, listing, stdio: None, + oauth_generation: None, }) } } diff --git a/apps/maple-agent/crates/maple-agent/src/agent/mcp/oauth.rs b/apps/maple-agent/crates/maple-agent/src/agent/mcp/oauth.rs new file mode 100644 index 000000000..cb471094f --- /dev/null +++ b/apps/maple-agent/crates/maple-agent/src/agent/mcp/oauth.rs @@ -0,0 +1,998 @@ +//! OAuth for a Vana server on this computer. No owner key, public relay, or +//! developer grant service participates in this client flow. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock, Weak}; +use std::time::Duration; + +use async_trait::async_trait; +use futures_util::stream::BoxStream; +use http::{HeaderName, HeaderValue}; +use rmcp::ServiceExt; +use rmcp::model::{CallToolRequestParams, ClientJsonRpcMessage}; +use rmcp::transport::auth::{ + AuthClient, AuthError, AuthorizationManager, AuthorizationMetadata, AuthorizationRequest, + AuthorizationSession, CredentialRefreshGuard, CredentialStore, InMemoryCredentialStore, + OAuthHttpClient, OAuthHttpClientFuture, OAuthHttpRequest, StoredCredentials, +}; +use rmcp::transport::streamable_http_client::{ + SseError, StreamableHttpClient, StreamableHttpClientTransport, + StreamableHttpClientTransportConfig, StreamableHttpError, StreamableHttpPostResponse, +}; +use sse_stream::Sse; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tracing::instrument::WithSubscriber; + +use super::super::AgentRuntimeHandle; +use super::super::config::account_store; +use crate::store::records::{ + McpDefinition, McpOAuthCredentials, McpServerEdit, McpTarget, SecretString, +}; +use crate::store::{AccountId, Store}; + +const AUTH_TIMEOUT: Duration = Duration::from_secs(180); +const HTTP_TIMEOUT: Duration = Duration::from_secs(15); +const MAX_HTTP_BODY: usize = 1024 * 1024; +const MAX_CREDENTIAL_BYTES: usize = 64 * 1024; +const CALLBACK_BYTES: usize = 8 * 1024; +const AUTH_FAILURE: &str = "Vana sign-in did not complete. Check Vana Desktop and try again."; +const STORAGE_FAILURE: &str = + "Vana credentials changed or their local store is unavailable. Connect Vana again."; + +/// The browser launcher is injected by the local host, never by MCP output. +pub type LocalMcpBrowser = Arc Result<(), String> + Send + Sync>; + +pub(crate) fn local_endpoint(value: &str) -> Result { + let url = reqwest::Url::parse(value.trim()).map_err(|_| endpoint_error())?; + if url.scheme() != "http" + || url.host_str() != Some("127.0.0.1") + || url.port().is_none_or(|port| port == 0) + || url.path() != "/mcp" + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Err(endpoint_error()); + } + Ok(url) +} + +fn endpoint_error() -> String { + "Copy the Local MCP endpoint from Vana Desktop Settings → Server. It must be http://127.0.0.1:/mcp.".into() +} + +fn auth_error(_: impl std::fmt::Debug) -> String { + // OAuth/server errors can contain codes, request bodies and callback URLs. + AUTH_FAILURE.into() +} + +fn store_auth_error(_: impl std::fmt::Debug) -> AuthError { + AuthError::CredentialStoreError(STORAGE_FAILURE.into()) +} + +/// Every OAuth operation is constrained to the exact local listener. In +/// particular metadata cannot redirect credential-bearing registration/token +/// operations to a public or different loopback endpoint. +#[derive(Clone)] +struct LocalOAuthHttp { + endpoint: reqwest::Url, + client: reqwest::Client, +} + +fn local_http_builder() -> reqwest::ClientBuilder { + reqwest::Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(HTTP_TIMEOUT) +} + +/// MCP owns request deadlines (including progress extensions) above the HTTP +/// client. An exchange-wide timeout here would also terminate healthy SSE +/// streams and long-running tools after the OAuth exchange deadline. +fn local_mcp_client() -> Result { + local_http_builder().build().map_err(auth_error) +} + +impl LocalOAuthHttp { + fn new(endpoint: reqwest::Url) -> Result { + let client = local_http_builder() + .timeout(HTTP_TIMEOUT) + .build() + .map_err(auth_error)?; + Ok(Self { endpoint, client }) + } + + fn check_url(&self, url: &reqwest::Url) -> Result<(), AuthError> { + if url.origin() != self.endpoint.origin() + || !url.username().is_empty() + || url.password().is_some() + || url.fragment().is_some() + { + return Err(AuthError::MetadataError( + "Vana OAuth must use its exact local listener.".into(), + )); + } + Ok(()) + } +} + +impl OAuthHttpClient for LocalOAuthHttp { + fn execute(&self, operation: OAuthHttpRequest) -> OAuthHttpClientFuture<'_> { + Box::pin(async move { + let (parts, body) = operation.request.into_parts(); + let url = reqwest::Url::parse(&parts.uri.to_string())?; + self.check_url(&url)?; + let mut response = self + .client + .request(parts.method, url) + .headers(parts.headers) + .body(body) + .send() + .await?; + let mut builder = http::Response::builder().status(response.status()); + *builder.headers_mut().ok_or("Invalid OAuth response")? = response.headers().clone(); + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await? { + if bytes.len().saturating_add(chunk.len()) > MAX_HTTP_BODY { + return Err("Vana OAuth response is too large".into()); + } + bytes.extend_from_slice(&chunk); + } + Ok(builder.body(bytes)?) + }) + } +} + +fn validate_metadata( + http: &LocalOAuthHttp, + metadata: &AuthorizationMetadata, +) -> Result<(), String> { + for value in [ + Some(metadata.authorization_endpoint.as_str()), + Some(metadata.token_endpoint.as_str()), + metadata.registration_endpoint.as_deref(), + metadata.issuer.as_deref(), + ] + .into_iter() + .flatten() + { + let url = reqwest::Url::parse(value).map_err(auth_error)?; + http.check_url(&url).map_err(auth_error)?; + } + if metadata.issuer.is_none() + || metadata.registration_endpoint.is_none() + || !metadata + .code_challenge_methods_supported + .as_ref() + .is_some_and(|methods| methods.iter().any(|m| m == "S256")) + { + return Err( + "Vana's local endpoint did not advertise the required OAuth and PKCE support.".into(), + ); + } + Ok(()) +} + +fn credentials_secret(credentials: &StoredCredentials) -> Result { + let value = serde_json::to_string(credentials).map_err(store_auth_error)?; + if value.len() > MAX_CREDENTIAL_BYTES { + return Err(store_auth_error("oversized credentials")); + } + Ok(SecretString::new(value)) +} + +fn decode_credentials(record: &McpOAuthCredentials) -> Result { + if record.payload.expose().len() > MAX_CREDENTIAL_BYTES { + return Err(store_auth_error("oversized credentials")); + } + serde_json::from_str(record.payload.expose()).map_err(store_auth_error) +} + +type RefreshLocks = HashMap>>; +static REFRESH_LOCKS: OnceLock> = OnceLock::new(); + +fn refresh_lock(key: String) -> Arc> { + let mut locks = REFRESH_LOCKS + .get_or_init(Default::default) + .lock() + .unwrap_or_else(|p| p.into_inner()); + locks.retain(|_, lock| lock.strong_count() != 0); + if let Some(existing) = locks.get(&key).and_then(Weak::upgrade) { + return existing; + } + let lock = Arc::new(tokio::sync::Mutex::new(())); + locks.insert(key, Arc::downgrade(&lock)); + lock +} + +/// This context never crosses GUI IPC. A task receives only its own host store +/// and account, and the credential adapter additionally captures an auth-row +/// generation so a late refresh cannot resurrect a disconnected connection. +#[derive(Clone)] +pub(crate) struct OAuthAccountContext { + store: Arc, + account: AccountId, + owner: Arc, +} + +impl OAuthAccountContext { + pub(crate) fn new(owner: Arc) -> Self { + Self { + store: owner.store().clone(), + account: owner.account(), + owner, + } + } + + fn ensure_current(&self) -> Result<(), AuthError> { + self.owner.ensure_current().map_err(store_auth_error) + } + + fn record_for( + &self, + key: &str, + target: &McpTarget, + ) -> Result, String> { + self.ensure_current() + .map_err(|_| STORAGE_FAILURE.to_string())?; + // ACP may supply transient HTTP bindings that were never saved in this + // host's catalog. They retain their ordinary static-header path. + let definitions = self + .store + .mcp_definitions(self.account) + .map_err(|_| STORAGE_FAILURE.to_string())?; + let Some(definition) = definitions + .into_iter() + .find(|definition| definition.key == key) + else { + return Ok(None); + }; + if &definition.target != target { + if self + .store + .mcp_oauth_managed(self.account, key, &definition.target) + .map_err(|_| STORAGE_FAILURE.to_string())? + { + return Err(STORAGE_FAILURE.into()); + } + return Ok(None); + } + let record = self + .store + .mcp_oauth_credentials(self.account, key, target) + .map_err(|_| STORAGE_FAILURE.to_string())?; + if record.is_none() + && self + .store + .mcp_oauth_managed(self.account, key, target) + .map_err(|_| STORAGE_FAILURE.to_string())? + { + return Err("Vana access was disconnected. Connect Vana again in Integrations.".into()); + } + Ok(record) + } + + pub(crate) fn active_generation( + &self, + key: &str, + endpoint: &str, + ) -> Result, String> { + let target = McpTarget::StreamableHttp { + url: endpoint.into(), + }; + let record = self.record_for(key, &target)?; + if let Some(record) = record { + return Ok(Some(record.generation)); + } + Ok(None) + } +} + +#[derive(Clone)] +struct HostCredentialStore { + context: OAuthAccountContext, + key: String, + target: McpTarget, + generation: String, + current: Arc>, + refresh: Arc>, +} + +impl HostCredentialStore { + fn new( + context: OAuthAccountContext, + key: &str, + target: McpTarget, + record: McpOAuthCredentials, + ) -> Self { + let lock_key = format!( + "{:p}:{:?}:{key}:{:?}:{}", + Arc::as_ptr(&context.store), + context.account, + target, + record.generation + ); + Self { + context, + key: key.into(), + target, + generation: record.generation.clone(), + current: Arc::new(tokio::sync::Mutex::new(record)), + refresh: refresh_lock(lock_key), + } + } +} + +#[async_trait] +impl CredentialStore for HostCredentialStore { + async fn load(&self) -> Result, AuthError> { + self.context.ensure_current()?; + let record = self + .context + .store + .mcp_oauth_credentials(self.context.account, &self.key, &self.target) + .map_err(store_auth_error)?; + let Some(record) = record else { + return Err(store_auth_error("authorization removed")); + }; + if record.generation != self.generation { + return Err(store_auth_error("authorization replaced")); + } + let credentials = decode_credentials(&record)?; + let mut current = self.current.lock().await; + self.context.ensure_current()?; + *current = record; + Ok(Some(credentials)) + } + + async fn save(&self, credentials: StoredCredentials) -> Result<(), AuthError> { + self.context.ensure_current()?; + let mut current = self.current.lock().await; + self.context.ensure_current()?; + let next = self + .context + .store + .rotate_mcp_oauth_credentials( + self.context.account, + &self.key, + &self.target, + ¤t, + credentials_secret(&credentials)?, + ) + .map_err(store_auth_error)?; + *current = next; + Ok(()) + } + + async fn clear(&self) -> Result<(), AuthError> { + self.context.ensure_current()?; + let current = self.current.lock().await; + self.context.ensure_current()?; + self.context + .store + .clear_mcp_oauth_credentials(self.context.account, &self.key, &self.target, ¤t) + .map_err(store_auth_error)?; + Ok(()) + } + + async fn acquire_refresh_guard(&self) -> Result, AuthError> { + Ok(Some(CredentialRefreshGuard::new( + self.refresh.clone().lock_owned().await, + ))) + } +} + +/// rmcp's OAuth module logs token exchange details at DEBUG. Scope every call +/// into that module to a no-op subscriber, including later runtime refreshes. +/// Normal Maple diagnostics remain available; codes/tokens never become logs. +#[derive(Clone)] +pub(crate) struct QuietAuthClient { + inner: AuthClient, + endpoint: reqwest::Url, + generation: Option, + credentials: Option, + token_operation: Arc>, +} + +impl QuietAuthClient { + pub(crate) fn generation(&self) -> Option { + self.generation.clone() + } + + async fn clear_rejected(&self) -> Result<(), AuthError> { + if let Some(credentials) = &self.credentials { + credentials.clear().await?; + } + Ok(()) + } + + async fn clear_rejected_token(&self, rejected: &str) -> Result<(), AuthError> { + let _operation = self.token_operation.lock().await; + let Some(credentials) = &self.credentials else { + return Ok(()); + }; + credentials.context.ensure_current()?; + let current = credentials + .context + .store + .mcp_oauth_credentials( + credentials.context.account, + &credentials.key, + &credentials.target, + ) + .map_err(store_auth_error)?; + let Some(current) = current else { + return Ok(()); + }; + if current.generation != credentials.generation { + return Ok(()); + } + let payload: serde_json::Value = + serde_json::from_str(current.payload.expose()).map_err(store_auth_error)?; + let actual = payload + .get("token_response") + .and_then(|response| response.get("access_token")) + .and_then(serde_json::Value::as_str); + if actual == Some(rejected) { + // A delayed verdict about an older token must never clear one + // another task has since refreshed. The final CAS also fences a + // concurrent explicit reconnect or store mutation. + credentials + .context + .store + .clear_mcp_oauth_credentials( + credentials.context.account, + &credentials.key, + &credentials.target, + ¤t, + ) + .map_err(store_auth_error)?; + } + Ok(()) + } + + /// Separate managers belonging to loaded tasks share this lock. Check + /// current persisted expiry/token *inside* it, so a waiter uses a token + /// another task has already refreshed instead of rotating it again. + async fn access_token(&self, rejected: Option<&str>) -> Result { + let _operation = self.token_operation.lock().await; + let manager = self.inner.auth_manager.lock().await; + let result = async { + let current = manager.get_access_token().await?; + if rejected == Some(current.as_str()) { + manager.refresh_token().await?; + manager.get_access_token().await + } else { + Ok(current) + } + } + .with_subscriber(tracing::subscriber::NoSubscriber::default()) + .await; + match result { + Err(AuthError::AuthorizationRequired | AuthError::TokenRefreshRejected(_)) => { + self.clear_rejected().await?; + Err(AuthError::AuthorizationRequired) + } + other => other, + } + } + + async fn authorized(&self, call: F) -> Result> + where + F: Fn(Option) -> Fut, + Fut: std::future::Future>>, + { + let sent = self.access_token(None).await.map_err(sanitized_auth)?; + match call(Some(sent.clone())).await { + Err(StreamableHttpError::AuthRequired(_)) => { + let refreshed = self + .access_token(Some(&sent)) + .await + .map_err(sanitized_auth)?; + match call(Some(refreshed.clone())).await { + Err(StreamableHttpError::AuthRequired(_)) => { + self.clear_rejected_token(&refreshed) + .await + .map_err(sanitized_auth)?; + Err(sanitized_auth(AuthError::AuthorizationRequired)) + } + result => result.map_err(sanitized_transport), + } + } + result => result.map_err(sanitized_transport), + } + } + + fn check_uri(&self, uri: &str) -> Result<(), StreamableHttpError> { + if uri != self.endpoint.as_str() { + return Err(StreamableHttpError::UnexpectedServerResponse( + "Vana target changed".into(), + )); + } + Ok(()) + } +} + +fn sanitized_auth(error: AuthError) -> StreamableHttpError { + match error { + AuthError::AuthorizationRequired | AuthError::TokenRefreshRejected(_) => { + StreamableHttpError::Auth(AuthError::AuthorizationRequired) + } + _ => StreamableHttpError::Auth(AuthError::AuthorizationFailed(AUTH_FAILURE.into())), + } +} + +fn sanitized_transport( + error: StreamableHttpError, +) -> StreamableHttpError { + match error { + StreamableHttpError::Auth(error) => sanitized_auth(error), + StreamableHttpError::AuthRequired(_) => sanitized_auth(AuthError::AuthorizationRequired), + StreamableHttpError::InsufficientScope(_) => StreamableHttpError::Auth( + AuthError::InvalidScope("Vana did not approve the requested scope.".into()), + ), + StreamableHttpError::UnexpectedServerResponse(_) => { + StreamableHttpError::UnexpectedServerResponse( + "Vana returned an unexpected response.".into(), + ) + } + StreamableHttpError::Client(error) => StreamableHttpError::Client(error.without_url()), + _ => { + StreamableHttpError::UnexpectedServerResponse("Vana's local MCP request failed.".into()) + } + } +} + +impl StreamableHttpClient for QuietAuthClient { + type Error = reqwest::Error; + + async fn post_message( + &self, + uri: Arc, + message: ClientJsonRpcMessage, + session_id: Option>, + auth_header: Option, + custom_headers: HashMap, + ) -> Result> { + self.post_message_with_max_sse_event_size( + uri, + message, + session_id, + auth_header, + custom_headers, + 4 * 1024 * 1024, + ) + .await + } + + async fn post_message_with_max_sse_event_size( + &self, + uri: Arc, + message: ClientJsonRpcMessage, + session_id: Option>, + auth_header: Option, + custom_headers: HashMap, + max: usize, + ) -> Result> { + self.check_uri(&uri)?; + if auth_header.is_some() { + return Err(sanitized_auth(AuthError::AuthorizationRequired)); + } + self.authorized(|token| { + self.inner.http_client.post_message_with_max_sse_event_size( + uri.clone(), + message.clone(), + session_id.clone(), + token, + custom_headers.clone(), + max, + ) + }) + .await + } + + async fn delete_session( + &self, + uri: Arc, + session_id: Arc, + auth_header: Option, + custom_headers: HashMap, + ) -> Result<(), StreamableHttpError> { + self.check_uri(&uri)?; + if auth_header.is_some() { + return Err(sanitized_auth(AuthError::AuthorizationRequired)); + } + self.authorized(|token| { + self.inner.http_client.delete_session( + uri.clone(), + session_id.clone(), + token, + custom_headers.clone(), + ) + }) + .await + } + + async fn get_stream( + &self, + uri: Arc, + session_id: Option>, + last_event_id: Option, + auth_header: Option, + custom_headers: HashMap, + ) -> Result>, StreamableHttpError> { + self.get_stream_with_max_sse_event_size( + uri, + session_id, + last_event_id, + auth_header, + custom_headers, + 4 * 1024 * 1024, + ) + .await + } + + async fn get_stream_with_max_sse_event_size( + &self, + uri: Arc, + session_id: Option>, + last_event_id: Option, + auth_header: Option, + custom_headers: HashMap, + max: usize, + ) -> Result>, StreamableHttpError> { + self.check_uri(&uri)?; + if auth_header.is_some() { + return Err(sanitized_auth(AuthError::AuthorizationRequired)); + } + self.authorized(|token| { + self.inner.http_client.get_stream_with_max_sse_event_size( + uri.clone(), + session_id.clone(), + last_event_id.clone(), + token, + custom_headers.clone(), + max, + ) + }) + .await + } +} + +pub(crate) async fn runtime_client( + context: &OAuthAccountContext, + key: &str, + value: &str, +) -> Result, String> { + let target = McpTarget::StreamableHttp { url: value.into() }; + let Some(record) = context.record_for(key, &target)? else { + return Ok(None); + }; + let endpoint = local_endpoint(value)?; + let http = LocalOAuthHttp::new(endpoint.clone())?; + let mut manager = + AuthorizationManager::new_with_oauth_http_client(endpoint.as_str(), Arc::new(http.clone())) + .await + .map_err(auth_error)?; + let resolution = manager + .resolve_metadata() + .with_subscriber(tracing::subscriber::NoSubscriber::default()) + .await + .map_err(auth_error)?; + if !resolution.source.is_discovered() { + return Err(AUTH_FAILURE.into()); + } + validate_metadata(&http, &resolution.metadata)?; + manager.set_metadata(resolution.metadata); + let generation = Some(record.generation.clone()); + let token_operation = refresh_lock(format!( + "token-operation:{:p}:{:?}:{key}:{}", + Arc::as_ptr(&context.store), + context.account, + record.generation + )); + let credentials = HostCredentialStore::new(context.clone(), key, target, record); + manager.set_credential_store(credentials.clone()); + if !manager + .initialize_from_store() + .with_subscriber(tracing::subscriber::NoSubscriber::default()) + .await + .map_err(auth_error)? + { + return Err(AUTH_FAILURE.into()); + } + Ok(Some(QuietAuthClient { + inner: AuthClient::new(local_mcp_client()?, manager), + endpoint, + generation, + credentials: Some(credentials), + token_operation, + })) +} + +struct Callback { + code: String, + state: String, + issuer: Option, +} + +fn parse_callback( + request: &str, + callback: &reqwest::Url, + expected_state: &str, +) -> Result { + let mut lines = request.split("\r\n"); + let parts: Vec<_> = lines + .next() + .unwrap_or_default() + .split_whitespace() + .collect(); + if parts.len() != 3 || parts[0] != "GET" || parts[2] != "HTTP/1.1" { + return Err(AUTH_FAILURE.into()); + } + let mut hosts = lines + .filter_map(|line| line.split_once(':')) + .filter(|(key, _)| key.eq_ignore_ascii_case("host")); + let Some((_, host)) = hosts.next() else { + return Err(AUTH_FAILURE.into()); + }; + if hosts.next().is_some() + || host.trim() != format!("127.0.0.1:{}", callback.port().unwrap_or_default()) + { + return Err(AUTH_FAILURE.into()); + } + let url = callback.join(parts[1]).map_err(auth_error)?; + if url.origin() != callback.origin() + || url.path() != callback.path() + || url.fragment().is_some() + { + return Err(AUTH_FAILURE.into()); + } + let mut values = HashMap::new(); + for (key, value) in url.query_pairs() { + if values + .insert(key.into_owned(), value.into_owned()) + .is_some() + { + return Err(AUTH_FAILURE.into()); + } + } + let state = values + .remove("state") + .ok_or_else(|| AUTH_FAILURE.to_string())?; + if state != expected_state { + return Err(AUTH_FAILURE.into()); + } + if values.remove("error").is_some() { + return Err("Vana access was not approved.".into()); + } + let code = values + .remove("code") + .filter(|code| !code.is_empty() && code.len() <= 4096) + .ok_or_else(|| AUTH_FAILURE.to_string())?; + Ok(Callback { + code, + state, + issuer: values.remove("iss"), + }) +} + +async fn receive_callback( + listener: TcpListener, + callback: &reqwest::Url, + state: &str, +) -> Result { + loop { + let (mut stream, peer) = listener.accept().await.map_err(auth_error)?; + if !peer.ip().is_loopback() { + continue; + } + let parsed = tokio::time::timeout( + Duration::from_secs(5), + read_callback(&mut stream, callback, state), + ) + .await; + let (status, text) = match &parsed { + Ok(Ok(_)) => ( + "200 OK", + "Sign-in response received. You can return to Maple.", + ), + _ => ( + "400 Bad Request", + "This sign-in response was not accepted. Return to Vana or Maple.", + ), + }; + let reply = format!( + "HTTP/1.1 {status}\r\nContent-Type: text/plain; charset=utf-8\r\nCache-Control: no-store\r\nContent-Security-Policy: default-src 'none'\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{text}", + text.len() + ); + let _ = + tokio::time::timeout(Duration::from_secs(1), stream.write_all(reply.as_bytes())).await; + match parsed { + Ok(Ok(callback)) => return Ok(callback), + Ok(Err(error)) if error == "Vana access was not approved." => return Err(error), + _ => {} + } + } +} + +async fn read_callback( + stream: &mut TcpStream, + callback: &reqwest::Url, + state: &str, +) -> Result { + let mut bytes = Vec::new(); + let mut chunk = [0u8; 1024]; + while bytes.len() < CALLBACK_BYTES { + let count = stream.read(&mut chunk).await.map_err(auth_error)?; + if count == 0 { + break; + } + bytes.extend_from_slice(&chunk[..count]); + if bytes.len() > CALLBACK_BYTES { + return Err(AUTH_FAILURE.into()); + } + if bytes.windows(4).any(|w| w == b"\r\n\r\n") { + let request = std::str::from_utf8(&bytes).map_err(auth_error)?; + return parse_callback(request, callback, state); + } + } + Err(AUTH_FAILURE.into()) +} + +impl AgentRuntimeHandle { + /// Authorize the copied local endpoint through Vana's own consent UI. + /// Only public configuration/presence DTOs return to the desktop. + pub async fn connect_vana_local( + &self, + endpoint: String, + open_browser: LocalMcpBrowser, + ) -> Result, String> { + self.verify_generation().await?; + self.ensure_accepting_new_work()?; + let endpoint = local_endpoint(&endpoint)?; + let (store, account) = + account_store(self.paths(), &self.user_id).map_err(|_| STORAGE_FAILURE.to_string())?; + let previous = store + .mcp_definitions(account) + .map_err(|_| STORAGE_FAILURE.to_string())? + .into_iter() + .find(|d| d.key == "vana"); + if let Some(previous) = &previous + && !store + .mcp_oauth_managed(account, "vana", &previous.target) + .map_err(|_| STORAGE_FAILURE.to_string())? + { + return Err("A custom server named vana already exists. Remove or rename it before connecting Vana.".into()); + } + let lock = refresh_lock(format!("setup:{:p}:{account:?}", store)); + let _guard = lock + .try_lock() + .map_err(|_| "A Vana connection is already in progress.".to_string())?; + tokio::time::timeout(AUTH_TIMEOUT, async { + let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .await + .map_err(auth_error)?; + let port = listener.local_addr().map_err(auth_error)?.port(); + let callback = reqwest::Url::parse(&format!( + "http://127.0.0.1:{port}/oauth/callback/{}", + uuid::Uuid::new_v4() + )) + .map_err(auth_error)?; + let http = LocalOAuthHttp::new(endpoint.clone())?; + let mut manager = AuthorizationManager::new_with_oauth_http_client( + endpoint.as_str(), + Arc::new(http.clone()), + ) + .await + .map_err(auth_error)?; + let memory = InMemoryCredentialStore::new(); + manager.set_credential_store(memory.clone()); + let resolution = manager + .resolve_metadata() + .with_subscriber(tracing::subscriber::NoSubscriber::default()) + .await + .map_err(auth_error)?; + if !resolution.source.is_discovered() { + return Err(AUTH_FAILURE.into()); + } + validate_metadata(&http, &resolution.metadata)?; + manager.set_metadata(resolution.metadata); + let request = AuthorizationRequest::new(callback.as_str()) + .with_client_name("Maple Agent") + .with_scopes(["vana:read"]); + let session = AuthorizationSession::new(manager, request) + .with_subscriber(tracing::subscriber::NoSubscriber::default()) + .await + .map_err(|(_, error)| auth_error(error))?; + let authorization = reqwest::Url::parse(&session.auth_url).map_err(auth_error)?; + http.check_url(&authorization).map_err(auth_error)?; + let expected_state = authorization + .query_pairs() + .find(|(key, _)| key == "state") + .map(|(_, value)| value.into_owned()) + .ok_or_else(|| AUTH_FAILURE.to_string())?; + self.verify_generation().await?; + self.ensure_accepting_new_work()?; + open_browser(authorization.to_string())?; + let answer = receive_callback(listener, &callback, &expected_state).await?; + self.verify_generation().await?; + session + .handle_callback_with_issuer(&answer.code, &answer.state, answer.issuer.as_deref()) + .with_subscriber(tracing::subscriber::NoSubscriber::default()) + .await + .map_err(auth_error)?; + let client = QuietAuthClient { + inner: AuthClient::new(local_mcp_client()?, session.auth_manager), + endpoint: endpoint.clone(), + generation: None, + credentials: None, + token_operation: Arc::new(tokio::sync::Mutex::new(())), + }; + // Verify the actual authenticated MCP server before publishing a definition. + let transport = StreamableHttpClientTransport::with_client( + client, + StreamableHttpClientTransportConfig::with_uri(endpoint.as_str()), + ); + let mut service = tokio::time::timeout(HTTP_TIMEOUT, ().serve(transport)) + .await + .map_err(auth_error)? + .map_err(auth_error)?; + let probe = tokio::time::timeout( + HTTP_TIMEOUT, + service + .peer() + // Vana's MCP SDK validates this tool's empty object schema; + // omitted arguments are not equivalent to an empty object. + .call_tool( + CallToolRequestParams::new("list_granted_scopes") + .with_arguments(serde_json::Map::new()), + ), + ) + .await; + let _ = service.close_with_timeout(Duration::from_millis(500)).await; + let result = probe.map_err(auth_error)?.map_err(auth_error)?; + if result.is_error == Some(true) { + return Err("Vana did not confirm the approved scopes.".into()); + } + let credentials = memory + .load() + .await + .map_err(auth_error)? + .ok_or_else(|| AUTH_FAILURE.to_string())?; + let _settings = self.lock_settings().await; + self.verify_generation().await?; + self.ensure_accepting_new_work()?; + let edit = McpServerEdit { + key: "vana".into(), + name: "vana".into(), + description: "Personal context from Vana on this computer".into(), + enabled: false, + timeout_seconds: 30, + target: McpTarget::StreamableHttp { + url: endpoint.to_string(), + }, + environment: Vec::new(), + headers: Vec::new(), + }; + store + .install_mcp_oauth( + account, + previous.as_ref(), + &edit, + credentials_secret(&credentials).map_err(auth_error)?, + ) + .map_err(|_| STORAGE_FAILURE.to_string())?; + store + .mcp_definitions(account) + .map_err(|_| STORAGE_FAILURE.to_string()) + }) + .await + .map_err(|_| "Vana sign-in timed out. Open Vana Desktop and connect again.".to_string())? + } +} + +#[cfg(test)] +mod tests; diff --git a/apps/maple-agent/crates/maple-agent/src/agent/mcp/oauth/tests.rs b/apps/maple-agent/crates/maple-agent/src/agent/mcp/oauth/tests.rs new file mode 100644 index 000000000..4ec9be8aa --- /dev/null +++ b/apps/maple-agent/crates/maple-agent/src/agent/mcp/oauth/tests.rs @@ -0,0 +1,618 @@ +use super::*; + +use axum::{ + Router, + body::Bytes, + extract::State, + http::{HeaderMap, Method, StatusCode, Uri}, + response::{IntoResponse, Response}, + routing::any, +}; +use base64::Engine; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + +#[test] +fn rejects_nonlocal_or_ambiguous_endpoints() { + for value in [ + "https://example.com/mcp", + "http://localhost:1234/mcp", + "http://127.0.0.2:1234/mcp", + "http://0.0.0.0:1234/mcp", + "http://127.0.0.1/mcp", + "http://127.0.0.1:1234/mcp?token=x", + "http://user@127.0.0.1:1234/mcp", + "http://127.0.0.1:1234/mcp#x", + "http://127.0.0.1:1234/other", + ] { + assert!(local_endpoint(value).is_err()); + } + assert_eq!( + local_endpoint("http://127.0.0.1:49123/mcp").unwrap().port(), + Some(49123) + ); +} + +#[test] +fn callback_rejects_wrong_state_origin_path_host_and_duplicate_parameters() { + let callback = reqwest::Url::parse("http://127.0.0.1:1234/oauth/callback/nonce").unwrap(); + let request = + |target: &str, host: &str| format!("GET {target} HTTP/1.1\r\nHost: {host}\r\n\r\n"); + for (target, host) in [ + ("/oauth/callback/nonce?code=c&state=wrong", "127.0.0.1:1234"), + ("/oauth/callback/other?code=c&state=valid", "127.0.0.1:1234"), + ( + "http://evil.example/oauth/callback/nonce?code=c&state=valid", + "127.0.0.1:1234", + ), + ("/oauth/callback/nonce?code=c&state=valid", "evil.example"), + ( + "/oauth/callback/nonce?code=c&state=valid&state=valid", + "127.0.0.1:1234", + ), + ] { + assert!(parse_callback(&request(target, host), &callback, "valid").is_err()); + } + let answer = parse_callback( + &request( + "/oauth/callback/nonce?code=c&state=valid&iss=http%3A%2F%2F127.0.0.1%3A4567", + "127.0.0.1:1234", + ), + &callback, + "valid", + ) + .unwrap(); + assert_eq!(answer.code, "c"); + assert_eq!(answer.issuer.as_deref(), Some("http://127.0.0.1:4567")); +} + +#[tokio::test] +async fn timeout_drops_the_callback_listener() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let callback = + reqwest::Url::parse(&format!("http://127.0.0.1:{port}/oauth/callback/nonce")).unwrap(); + assert!( + tokio::time::timeout( + Duration::from_millis(20), + receive_callback(listener, &callback, "state") + ) + .await + .is_err() + ); + assert!( + TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, port)) + .await + .is_ok() + ); +} + +#[test] +fn oauth_http_cannot_follow_metadata_to_another_listener_or_public_service() { + let http = LocalOAuthHttp::new(local_endpoint("http://127.0.0.1:1234/mcp").unwrap()).unwrap(); + for value in [ + "http://127.0.0.1:1235/token", + "https://account.vana.org/token", + "http://localhost:1234/token", + ] { + assert!( + http.check_url(&reqwest::Url::parse(value).unwrap()) + .is_err() + ); + } + assert!( + http.check_url(&reqwest::Url::parse("http://127.0.0.1:1234/mcp/oauth/token").unwrap()) + .is_ok() + ); +} + +#[tokio::test] +async fn local_mcp_stream_can_outlive_the_oauth_exchange_timeout() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let first = "data: ready\n\n"; + let last = "data: done\n\n"; + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + while !request.windows(4).any(|bytes| bytes == b"\r\n\r\n") { + let mut chunk = [0; 1024]; + let count = stream.read(&mut chunk).await.unwrap(); + assert_ne!(count, 0); + request.extend_from_slice(&chunk[..count]); + } + let header = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{first}", + first.len() + last.len(), + ); + stream.write_all(header.as_bytes()).await.unwrap(); + // Deliberately cross the OAuth client's total timeout. MCP may retain + // streams longer and applies its own request/progress deadlines. + tokio::time::sleep(HTTP_TIMEOUT + Duration::from_millis(250)).await; + stream.write_all(last.as_bytes()).await.unwrap(); + }); + let response = local_mcp_client() + .unwrap() + .get(format!("http://{address}/mcp")) + .send() + .await + .unwrap(); + let body = tokio::time::timeout(HTTP_TIMEOUT + Duration::from_secs(5), response.text()) + .await + .unwrap() + .unwrap(); + assert_eq!(body, format!("{first}{last}")); + server.await.unwrap(); +} + +struct FakeOAuth { + base: String, + exchanges: AtomicUsize, + refreshes: AtomicUsize, + revoked: AtomicBool, + challenge: Mutex>, +} + +struct Server { + state: Arc, + task: tokio::task::JoinHandle<()>, +} + +impl Drop for Server { + fn drop(&mut self) { + self.task.abort(); + } +} + +impl Server { + async fn start() -> Self { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let state = Arc::new(FakeOAuth { + base: format!("http://{}", listener.local_addr().unwrap()), + exchanges: AtomicUsize::new(0), + refreshes: AtomicUsize::new(0), + revoked: AtomicBool::new(false), + challenge: Mutex::new(None), + }); + let app = Router::new() + .fallback(any(fake_http)) + .with_state(state.clone()); + let task = tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + Self { state, task } + } + + fn endpoint(&self) -> String { + format!("{}/mcp", self.state.base) + } + + fn browser(&self) -> LocalMcpBrowser { + self.browser_with_issuer(None) + } + + fn browser_with_issuer(&self, overridden_issuer: Option) -> LocalMcpBrowser { + let state = self.state.clone(); + Arc::new(move |authorization| { + let url = reqwest::Url::parse(&authorization).unwrap(); + assert_eq!(url.origin().ascii_serialization(), state.base); + let params: HashMap<_, _> = url + .query_pairs() + .map(|(k, v)| (k.into_owned(), v.into_owned())) + .collect(); + assert_eq!( + params.get("code_challenge_method").map(String::as_str), + Some("S256") + ); + *state.challenge.lock().unwrap() = params.get("code_challenge").cloned(); + let mut callback = reqwest::Url::parse(params.get("redirect_uri").unwrap()).unwrap(); + let csrf = params.get("state").unwrap().clone(); + let issuer = overridden_issuer + .clone() + .unwrap_or_else(|| state.base.clone()); + tokio::spawn(async move { + let client = reqwest::Client::builder().no_proxy().build().unwrap(); + callback + .query_pairs_mut() + .append_pair("code", "synthetic-approval-code") + .append_pair("state", "wrong") + .append_pair("iss", &issuer); + assert_eq!( + client.get(callback.clone()).send().await.unwrap().status(), + StatusCode::BAD_REQUEST + ); + callback.set_query(None); + callback + .query_pairs_mut() + .append_pair("code", "synthetic-approval-code") + .append_pair("state", &csrf) + .append_pair("iss", &issuer); + assert_eq!( + client.get(callback).send().await.unwrap().status(), + StatusCode::OK + ); + }); + Ok(()) + }) + } +} + +fn json_response(value: Value) -> Response { + ([("content-type", "application/json")], value.to_string()).into_response() +} + +fn challenge(state: &FakeOAuth) -> Response { + (StatusCode::UNAUTHORIZED, [("www-authenticate", format!("Bearer resource_metadata=\"{}/.well-known/oauth-protected-resource/mcp\", scope=\"vana:read\"", state.base))]).into_response() +} + +async fn fake_http( + State(state): State>, + method: Method, + uri: Uri, + headers: HeaderMap, + body: Bytes, +) -> Response { + match (method, uri.path()) { + (Method::GET, "/.well-known/oauth-protected-resource/mcp") => json_response( + json!({"resource":format!("{}/mcp",state.base),"authorization_servers":[state.base],"scopes_supported":["vana:read"]}), + ), + (Method::GET, "/.well-known/oauth-authorization-server") => json_response( + json!({"issuer":state.base,"authorization_endpoint":format!("{}/mcp/oauth/authorize",state.base),"token_endpoint":format!("{}/mcp/oauth/token",state.base),"registration_endpoint":format!("{}/mcp/oauth/register",state.base),"scopes_supported":["vana:read"],"response_types_supported":["code"],"grant_types_supported":["authorization_code","refresh_token"],"code_challenge_methods_supported":["S256"],"token_endpoint_auth_methods_supported":["none"]}), + ), + (Method::POST, "/mcp/oauth/register") => { + let input: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(input["client_name"], "Maple Agent"); + json_response( + json!({"client_id":"synthetic-maple-client","redirect_uris":input["redirect_uris"],"token_endpoint_auth_method":"none"}), + ) + } + (Method::POST, "/mcp/oauth/token") => { + let encoded = format!("http://unused/?{}", String::from_utf8_lossy(&body)); + let url = reqwest::Url::parse(&encoded).unwrap(); + let params: HashMap<_, _> = url + .query_pairs() + .map(|(k, v)| (k.into_owned(), v.into_owned())) + .collect(); + if params.get("grant_type").map(String::as_str) == Some("refresh_token") { + state.refreshes.fetch_add(1, Ordering::SeqCst); + if state.revoked.load(Ordering::SeqCst) { + return ( + StatusCode::BAD_REQUEST, + [("content-type", "application/json")], + "{\"error\":\"invalid_grant\"}", + ) + .into_response(); + } + } else { + assert_eq!( + params.get("code").map(String::as_str), + Some("synthetic-approval-code") + ); + let verifier = params.get("code_verifier").unwrap(); + let challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(Sha256::digest(verifier)); + assert_eq!(state.challenge.lock().unwrap().as_ref(), Some(&challenge)); + state.exchanges.fetch_add(1, Ordering::SeqCst); + } + let n = state.exchanges.load(Ordering::SeqCst) + state.refreshes.load(Ordering::SeqCst); + json_response( + json!({"access_token":format!("synthetic-access-{n}"),"token_type":"Bearer","expires_in":3600,"refresh_token":format!("synthetic-refresh-{n}"),"scope":"vana:read"}), + ) + } + (Method::GET, "/mcp") => challenge(&state), + (Method::POST, "/mcp") => { + if state.revoked.load(Ordering::SeqCst) + || !headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v.starts_with("Bearer synthetic-access-")) + { + return challenge(&state); + } + let input: Value = serde_json::from_slice(&body).unwrap(); + let result = match input["method"].as_str().unwrap_or_default() { + "initialize" => { + json!({"protocolVersion":input["params"]["protocolVersion"],"capabilities":{"tools":{}},"serverInfo":{"name":"synthetic-vana","version":"1"}}) + } + "notifications/initialized" => return StatusCode::ACCEPTED.into_response(), + "tools/list" => { + json!({"tools":[{"name":"list_granted_scopes","inputSchema":{"type":"object"}}]}) + } + "tools/call" => { + assert_eq!(input["params"]["arguments"], json!({})); + json!({"content":[{"type":"text","text":"{\"scopes\":[{\"scope\":\"chatgpt.memories\",\"dataStatus\":\"ready\"}]}"}],"isError":false}) + } + _ => return StatusCode::NOT_FOUND.into_response(), + }; + json_response(json!({"jsonrpc":"2.0","id":input["id"],"result":result})) + } + (Method::DELETE, "/mcp") => StatusCode::NO_CONTENT.into_response(), + _ => StatusCode::NOT_FOUND.into_response(), + } +} + +#[tokio::test] +async fn native_pkce_callback_persists_refreshes_and_revocation_requires_reconnect() { + let harness = crate::agent::tests::Harness::new().await; + let server = Server::start().await; + let definitions = harness + .handle + .connect_vana_local(server.endpoint(), server.browser()) + .await + .unwrap(); + assert_eq!(definitions.len(), 1); + assert_eq!(definitions[0].name, "vana"); + assert!(!definitions[0].enabled); + assert!(definitions[0].headers.is_empty()); + assert!(definitions[0].environment.is_empty()); + assert_eq!(server.state.exchanges.load(Ordering::SeqCst), 1); + let context = OAuthAccountContext::new(harness.handle.store().unwrap()); + let target = McpTarget::StreamableHttp { + url: server.endpoint(), + }; + let record = context + .store + .mcp_oauth_credentials(context.account, "vana", &target) + .unwrap() + .unwrap(); + let mut expired = decode_credentials(&record).unwrap(); + expired.token_received_at = Some(0); + context + .store + .rotate_mcp_oauth_credentials( + context.account, + "vana", + &target, + &record, + credentials_secret(&expired).unwrap(), + ) + .unwrap(); + // Two independently opened tasks coordinate the same persisted refresh. + let first = runtime_client(&context, "vana", &server.endpoint()) + .await + .unwrap() + .unwrap(); + let second = runtime_client(&context, "vana", &server.endpoint()) + .await + .unwrap() + .unwrap(); + let (a, b) = tokio::join!(first.access_token(None), second.access_token(None)); + assert_eq!(a.unwrap(), b.unwrap()); + assert_eq!(server.state.refreshes.load(Ordering::SeqCst), 1); + let refreshed = context + .store + .mcp_oauth_credentials(context.account, "vana", &target) + .unwrap() + .unwrap(); + assert!( + decode_credentials(&refreshed) + .unwrap() + .token_received_at + .unwrap() + > 0 + ); + // Revoked credentials are cleared, not retried indefinitely or sent to a + // public listener; reconnect remains an explicit user operation. + server.state.revoked.store(true, Ordering::SeqCst); + let mut expired = decode_credentials(&refreshed).unwrap(); + expired.token_received_at = Some(0); + context + .store + .rotate_mcp_oauth_credentials( + context.account, + "vana", + &target, + &refreshed, + credentials_secret(&expired).unwrap(), + ) + .unwrap(); + assert!( + runtime_client(&context, "vana", &server.endpoint()) + .await + .unwrap() + .unwrap() + .access_token(None) + .await + .is_err() + ); + assert!( + context + .store + .mcp_oauth_credentials(context.account, "vana", &target) + .unwrap() + .is_none() + ); + assert!( + runtime_client(&context, "vana", &server.endpoint()) + .await + .is_err() + ); + assert!( + context + .store + .mcp_oauth_managed(context.account, "vana", &target) + .unwrap() + ); + server.state.revoked.store(false, Ordering::SeqCst); + assert!( + harness + .handle + .connect_vana_local(server.endpoint(), server.browser()) + .await + .is_ok() + ); +} + +#[tokio::test] +async fn forged_issuer_never_redeems_the_code_or_publishes_a_definition() { + let harness = crate::agent::tests::Harness::new().await; + let server = Server::start().await; + assert!( + harness + .handle + .connect_vana_local( + server.endpoint(), + server.browser_with_issuer(Some("http://127.0.0.1:1".into())) + ) + .await + .is_err() + ); + assert_eq!(server.state.exchanges.load(Ordering::SeqCst), 0); + assert!( + harness + .handle + .list_mcp_definitions() + .await + .unwrap() + .is_empty() + ); +} + +#[tokio::test] +async fn transient_http_bindings_keep_their_static_header_path() { + let harness = crate::agent::tests::Harness::new().await; + let server = crate::agent::mcp::fake_server::FakeServer::start(None).await; + let context = OAuthAccountContext::new(harness.handle.store().unwrap()); + let config = crate::agent::AgentMcpServer { + name: "transient".into(), + description: String::new(), + enabled: true, + timeout_seconds: 5, + transport: crate::agent::AgentMcpTransport::StreamableHttp { + url: server.url.clone(), + environment: Vec::new(), + headers: vec![crate::agent::AgentMcpKeyValue { + key: "Authorization".into(), + value: "Bearer synthetic-transient".into(), + }], + }, + }; + let client = crate::agent::mcp::connection::McpServer::new( + config, + &std::env::temp_dir(), + None, + Arc::new(|| {}), + ) + .with_oauth_context(Some(context)); + assert!(client.connect().await.is_ok()); + assert!( + server + .authorization() + .iter() + .any(|value| value.as_deref() == Some("Bearer synthetic-transient")) + ); +} + +#[tokio::test] +async fn credential_adapter_rechecks_revocation_after_waiting_on_its_mutex() { + for operation in ["save", "clear", "load"] { + let harness = crate::agent::tests::Harness::new().await; + let server = Server::start().await; + harness + .handle + .connect_vana_local(server.endpoint(), server.browser()) + .await + .unwrap(); + let context = OAuthAccountContext::new(harness.handle.store().unwrap()); + let target = McpTarget::StreamableHttp { + url: server.endpoint(), + }; + let record = context + .store + .mcp_oauth_credentials(context.account, "vana", &target) + .unwrap() + .unwrap(); + let payload = decode_credentials(&record).unwrap(); + let adapter = + HostCredentialStore::new(context.clone(), "vana", target.clone(), record.clone()); + let held = adapter.current.lock().await; + let future: std::pin::Pin< + Box> + Send + '_>, + > = match operation { + "save" => Box::pin(adapter.save(payload)), + "clear" => Box::pin(adapter.clear()), + _ => Box::pin(async { adapter.load().await.map(|_| ()) }), + }; + tokio::pin!(future); + assert!(futures_util::poll!(future.as_mut()).is_pending()); + context.owner.revoke(); + drop(held); + assert!(future.await.is_err()); + let after = context + .store + .mcp_oauth_credentials(context.account, "vana", &target) + .unwrap() + .unwrap(); + assert_eq!(after.generation, record.generation); + assert_eq!(after.revision, record.revision); + assert_eq!(after.payload, record.payload); + } +} + +#[test] +fn oauth_errors_do_not_echo_token_or_callback_material() { + let marker = "synthetic-private-oauth-value"; + let auth = sanitized_auth(AuthError::TokenExchangeFailed(marker.into())); + assert!(!format!("{auth:?}").contains(marker)); + let transport = sanitized_transport(StreamableHttpError::UnexpectedContentType(Some( + marker.into(), + ))); + assert!(!format!("{transport:?}").contains(marker)); +} + +#[tokio::test] +async fn a_delayed_rejection_does_not_clear_a_newer_persisted_token() { + let harness = crate::agent::tests::Harness::new().await; + let server = Server::start().await; + harness + .handle + .connect_vana_local(server.endpoint(), server.browser()) + .await + .unwrap(); + let context = OAuthAccountContext::new(harness.handle.store().unwrap()); + let target = McpTarget::StreamableHttp { + url: server.endpoint(), + }; + let old_client = runtime_client(&context, "vana", &server.endpoint()) + .await + .unwrap() + .unwrap(); + let rejected = old_client.access_token(None).await.unwrap(); + let old_record = context + .store + .mcp_oauth_credentials(context.account, "vana", &target) + .unwrap() + .unwrap(); + let mut expired = decode_credentials(&old_record).unwrap(); + expired.token_received_at = Some(0); + context + .store + .rotate_mcp_oauth_credentials( + context.account, + "vana", + &target, + &old_record, + credentials_secret(&expired).unwrap(), + ) + .unwrap(); + let new_client = runtime_client(&context, "vana", &server.endpoint()) + .await + .unwrap() + .unwrap(); + let fresh = new_client.access_token(None).await.unwrap(); + assert_ne!(rejected, fresh); + let refreshed = context + .store + .mcp_oauth_credentials(context.account, "vana", &target) + .unwrap() + .unwrap(); + old_client.clear_rejected_token(&rejected).await.unwrap(); + let retained = context + .store + .mcp_oauth_credentials(context.account, "vana", &target) + .unwrap() + .unwrap(); + assert_eq!(retained.revision, refreshed.revision); + assert_eq!(retained.payload, refreshed.payload); +} diff --git a/apps/maple-agent/crates/maple-agent/src/agent/mcp/task.rs b/apps/maple-agent/crates/maple-agent/src/agent/mcp/task.rs index f9fedf726..b481ab833 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/mcp/task.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/mcp/task.rs @@ -238,6 +238,7 @@ pub(crate) struct TaskMcp { changed: Notify, last_used: Mutex>, weak: Weak, + oauth: OnceLock, } impl TaskMcp { @@ -261,9 +262,14 @@ impl TaskMcp { changed: Notify::new(), last_used: Mutex::new(None), weak: weak.clone(), + oauth: OnceLock::new(), }) } + pub(crate) fn set_oauth_context(&self, context: super::oauth::OAuthAccountContext) { + let _ = self.oauth.set(context); + } + /// The extension the task's session loads: it registers the servers' /// tools, gives the prompt their instructions, and stops the servers /// when the session shuts down. @@ -331,6 +337,7 @@ impl TaskMcp { } }); McpServer::new(config, &self.cwd, self.search_path.clone(), tools_changed) + .with_oauth_context(self.oauth.get().cloned()) }) } diff --git a/apps/maple-agent/crates/maple-agent/src/agent/runtime.rs b/apps/maple-agent/crates/maple-agent/src/agent/runtime.rs index 020328c28..f2e83bf66 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/runtime.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/runtime.rs @@ -744,6 +744,9 @@ impl AgentRuntime { search_path, Some(output_dir), ); + mcp.set_oauth_context(super::mcp::oauth::OAuthAccountContext::new( + self.store.clone(), + )); options.extensions.push(mcp.extension()); let task_cua = TaskCua::new(&self.account_scope, &row.id, self.models.clone()); options.extensions.push(task_cua.extension()); diff --git a/apps/maple-agent/crates/maple-agent/src/store/gc.rs b/apps/maple-agent/crates/maple-agent/src/store/gc.rs index 93f234935..f496ec402 100644 --- a/apps/maple-agent/crates/maple-agent/src/store/gc.rs +++ b/apps/maple-agent/crates/maple-agent/src/store/gc.rs @@ -209,6 +209,7 @@ impl Store { if has_tasks { return Err(StoreError::Busy); } if let Some(progress) = delete_mcp_secret_batch(tx, account)? { return Ok(progress); } + if let Some(progress) = delete_mcp_oauth_batch(tx, account)? { return Ok(progress); } for (table, size) in [ ("account_settings", "length(CAST(value AS BLOB))+length(CAST(key AS BLOB))"), ("projects", "length(CAST(root AS BLOB))+coalesce(length(CAST(display_name AS BLOB)),0)+length(CAST(mcp_defaults AS BLOB))"), @@ -374,6 +375,50 @@ fn delete_mcp_secret_batch( ))) } +/// Precollect OAuth payloads before deleting their MCP server owners. Otherwise +/// the server cascade could remove an unaccounted credential payload batch. +fn delete_mcp_oauth_batch( + tx: &Transaction<'_>, + account: AccountId, +) -> StoreResult> { + // Include both INTEGER fields as well as the variable-sized credential data. + let mut statement = tx.prepare_cached( + "SELECT c.server_id, + 16+length(c.target)+length(CAST(c.generation AS BLOB))+coalesce(length(c.payload),0) + FROM mcp_oauth_credentials c JOIN mcp_servers m ON m.id=c.server_id + WHERE m.account_id=?1 ORDER BY c.server_id LIMIT ?2", + )?; + let mut candidates = statement.query(params![account.0, ROW_CAP as i64])?; + let mut ids = Vec::new(); + let mut bytes = 0_u64; + while let Some(row) = candidates.next()? { + let size = u64::try_from(row.get::<_, i64>(1)?).map_err(|_| StoreError::InvalidData)?; + if !ids.is_empty() && bytes.saturating_add(size) > BYTE_CAP { + break; + } + ids.push(row.get::<_, i64>(0)?); + bytes = bytes.saturating_add(size); + if bytes > BYTE_CAP { + break; + } + } + drop(candidates); + drop(statement); + if ids.is_empty() { + return Ok(None); + } + let mut deletion = tx.prepare_cached("DELETE FROM mcp_oauth_credentials WHERE server_id=?1")?; + let mut count = 0; + for server in ids { + count += deletion.execute([server])?; + } + Ok(Some(CollectionProgress::new( + CollectionStage::AccountMetadata, + count, + bytes, + ))) +} + #[cfg(test)] #[path = "gc_tests.rs"] mod tests; diff --git a/apps/maple-agent/crates/maple-agent/src/store/gc_tests.rs b/apps/maple-agent/crates/maple-agent/src/store/gc_tests.rs index cb5c0e6c8..a524d669a 100644 --- a/apps/maple-agent/crates/maple-agent/src/store/gc_tests.rs +++ b/apps/maple-agent/crates/maple-agent/src/store/gc_tests.rs @@ -239,6 +239,7 @@ fn account_children(store: &Store, account: AccountId) -> i64 { (SELECT count(*) FROM projects WHERE account_id=?1) + (SELECT count(*) FROM mcp_servers WHERE account_id=?1) + (SELECT count(*) FROM mcp_server_secrets s JOIN mcp_servers m ON m.id=s.server_id WHERE m.account_id=?1) + + (SELECT count(*) FROM mcp_oauth_credentials c JOIN mcp_servers m ON m.id=c.server_id WHERE m.account_id=?1) + (SELECT count(*) FROM credentials WHERE account_id=?1) + (SELECT count(*) FROM send_receipts WHERE account_id=?1) + (SELECT count(*) FROM remote_identities WHERE account_id=?1)", @@ -340,3 +341,110 @@ fn confirmed_account_collection_precollects_metadata_and_preserves_other_account ); assert!(store.confirmed_account_deletions(10).unwrap().is_empty()); } + +#[test] +fn confirmed_account_collection_precollects_oauth_with_byte_and_row_caps_in_plain_and_keyed_stores() +{ + use crate::store::keys::{KdfParams, Password}; + + const PAYLOAD_BYTES: usize = 65_536; + const FOREIGN_PAYLOAD: &[u8] = b"GC-OAUTH-FOREIGN-CANARY"; + const GENERATION: &str = "0123456789abcdef0123456789abcdef"; + for encrypted in [false, true] { + for (active, revoked) in [(32, 1), (0, ROW_CAP + 1)] { + let (_directory, store, alice, bob) = fixture(); + store.write(Durability::Durable, |tx| { + for position in 0..active + revoked { + tx.execute("INSERT INTO mcp_servers(account_id,key,position,definition) VALUES (?1,?2,?3,'{}')", + params![alice.0,format!("oauth-{position}"),position as i64])?; + let server = tx.last_insert_rowid(); + let payload = (position < active).then(|| vec![0_u8; PAYLOAD_BYTES]); + tx.execute("INSERT INTO mcp_oauth_credentials(server_id,target,generation,revision,payload) VALUES (?1,zeroblob(32),?2,0,?3)", + params![server,GENERATION,payload])?; + } + tx.execute("INSERT INTO mcp_servers(account_id,key,position,definition) VALUES (?1,'foreign-oauth',0,'{}')", [bob.0])?; + let server = tx.last_insert_rowid(); + tx.execute("INSERT INTO mcp_oauth_credentials(server_id,target,generation,revision,payload) VALUES (?1,zeroblob(32),?2,7,?3)", + params![server,GENERATION,FOREIGN_PAYLOAD])?; + Ok(()) + }).unwrap(); + if encrypted { + let password = Password::normalize("Z7jv!9qP#3nMx@8Ks^2dH").unwrap(); + store + .set_store_password(&password, &[], KdfParams::floor()) + .unwrap(); + } + let oauth_state = |account: AccountId| { + store.read(|tx| { + Ok(tx.query_row("SELECT count(*),coalesce(sum(16+length(c.target)+length(CAST(c.generation AS BLOB))+coalesce(length(c.payload),0)),0) + FROM mcp_oauth_credentials c JOIN mcp_servers m ON m.id=c.server_id WHERE m.account_id=?1", + [account.0], |row| Ok((row.get::<_,i64>(0)?,row.get::<_,i64>(1)?)))?) + }).unwrap() + }; + let initial = oauth_state(alice); + assert_eq!(initial.0, (active + revoked) as i64); + let foreign = oauth_state(bob); + assert_eq!(store.collect_account_step(alice), Err(StoreError::Conflict)); + store + .begin_account_deletion(alice, "oauth-delete", 2) + .unwrap(); + assert_eq!(store.collect_account_step(alice), Err(StoreError::Conflict)); + assert_eq!(oauth_state(alice), initial); + store + .confirm_account_deletion(alice, "oauth-delete") + .unwrap(); + + let mut batches = 0; + let mut deleted_bytes = 0; + while oauth_state(alice).0 != 0 { + let before = oauth_state(alice); + let progress = store.collect_account_step(alice).unwrap(); + let after = oauth_state(alice); + assert_bounded(&progress); + assert_eq!(progress.stage, CollectionStage::AccountMetadata); + assert!(!progress.done); + assert_eq!(progress.rows as i64, before.0 - after.0); + assert_eq!(progress.bytes, u64::try_from(before.1 - after.1).unwrap()); + if batches == 0 { + let row_bytes = 80 + if active == 0 { 0 } else { PAYLOAD_BYTES }; + assert_eq!(progress.rows, ROW_CAP.min(BYTE_CAP as usize / row_bytes),); + } + let servers = store + .read(|tx| { + Ok(tx.query_row( + "SELECT count(*) FROM mcp_servers WHERE account_id=?1", + [alice.0], + |row| row.get::<_, i64>(0), + )?) + }) + .unwrap(); + assert_eq!(servers, (active + revoked) as i64); + assert_eq!(oauth_state(bob), foreign); + batches += 1; + deleted_bytes += progress.bytes; + } + assert!(batches >= 2); + assert_eq!(deleted_bytes, u64::try_from(initial.1).unwrap()); + let mut completed = false; + for _ in 0..16 { + let before = account_children(&store, alice); + let progress = store.collect_account_step(alice).unwrap(); + assert_bounded(&progress); + if progress.done { + assert_eq!(before, 0); + completed = true; + break; + } + } + assert!(completed); + assert_eq!(store.account_id("alice").unwrap(), None); + assert_eq!(store.account_id("bob").unwrap(), Some(bob)); + assert_eq!(oauth_state(bob), foreign); + store.read(|tx| { + assert_eq!(tx.query_row::,_,_>("SELECT c.payload FROM mcp_oauth_credentials c JOIN mcp_servers m ON m.id=c.server_id WHERE m.account_id=?1", + [bob.0], |row| row.get(0))?, FOREIGN_PAYLOAD); + Ok(()) + }).unwrap(); + } + } +} diff --git a/apps/maple-agent/crates/maple-agent/src/store/integrity.rs b/apps/maple-agent/crates/maple-agent/src/store/integrity.rs index 9ffc0ad17..7533ad366 100644 --- a/apps/maple-agent/crates/maple-agent/src/store/integrity.rs +++ b/apps/maple-agent/crates/maple-agent/src/store/integrity.rs @@ -104,6 +104,8 @@ pub enum IntegrityTable { Projects, McpServers, McpServerSecrets, + #[serde(rename = "mcp_oauth_credentials")] + McpOAuthCredentials, Tasks, Entries, TimelineRows, @@ -929,6 +931,7 @@ fn classify(message: &str, cipher: bool) -> IntegrityFinding { ("projects", IntegrityTable::Projects), ("mcp_servers", IntegrityTable::McpServers), ("mcp_server_secrets", IntegrityTable::McpServerSecrets), + ("mcp_oauth_credentials", IntegrityTable::McpOAuthCredentials), ("tasks", IntegrityTable::Tasks), ("entries", IntegrityTable::Entries), ("timeline_rows", IntegrityTable::TimelineRows), diff --git a/apps/maple-agent/crates/maple-agent/src/store/integrity_constraints.rs b/apps/maple-agent/crates/maple-agent/src/store/integrity_constraints.rs index 0d587a52e..85ca418aa 100644 --- a/apps/maple-agent/crates/maple-agent/src/store/integrity_constraints.rs +++ b/apps/maple-agent/crates/maple-agent/src/store/integrity_constraints.rs @@ -65,6 +65,20 @@ const CONSTRAINTS: &[Constraints] = &[ removed: None, checks: &[(1, "kind IN ('env', 'header')"), (1, "length(target) = 32")], }, + Constraints { + table: "mcp_oauth_credentials", + finding: IntegrityTable::McpOAuthCredentials, + removed: None, + checks: &[ + (5, "length(target) = 32"), + (5, "length(generation) = 32"), + (5, "revision >= 0"), + ( + 5, + "payload IS NULL OR (length(payload) > 0 AND length(payload) <= 65536)", + ), + ], + }, Constraints { table: "tasks", finding: IntegrityTable::Tasks, diff --git a/apps/maple-agent/crates/maple-agent/src/store/integrity_constraints_tests.rs b/apps/maple-agent/crates/maple-agent/src/store/integrity_constraints_tests.rs index 45e1a711f..5369911c3 100644 --- a/apps/maple-agent/crates/maple-agent/src/store/integrity_constraints_tests.rs +++ b/apps/maple-agent/crates/maple-agent/src/store/integrity_constraints_tests.rs @@ -12,6 +12,7 @@ use super::super::{ open_readonly, }; use super::*; +use crate::store::records::{McpServerEdit, McpTarget, SecretString}; use crate::store::{Store, StorePaths, keys}; fn fixture(encrypted: bool) -> (tempfile::TempDir, Store) { @@ -186,6 +187,114 @@ fn readonly_plain_and_keyed_checks_detect_json_and_integer_constraints() { } } +#[test] +fn readonly_plain_and_keyed_checks_cover_oauth_boundaries_and_tombstones() { + for encrypted in [false, true] { + let (_directory, store) = fixture(encrypted); + let account = store.upsert_account("oauth-constraint-fixture", 1).unwrap(); + let payload = "{\"syntheticToken\":\"not-a-real-credential\"}"; + let original = store + .install_mcp_oauth( + account, + None, + &McpServerEdit { + key: "vana".into(), + name: "Vana".into(), + description: "Synthetic local OAuth fixture".into(), + enabled: false, + timeout_seconds: 30, + target: McpTarget::StreamableHttp { + url: "http://127.0.0.1:47321/mcp".into(), + }, + environment: vec![], + headers: vec![], + }, + SecretString::new(payload.into()), + ) + .unwrap(); + let target: Vec = store + .writer + .lock() + .unwrap() + .as_ref() + .unwrap() + .query_row("SELECT target FROM mcp_oauth_credentials", [], |row| { + row.get(0) + }) + .unwrap(); + for (assignment, damaged) in [ + ("target = zeroblob(31)", true), + ("generation = substr(generation, 1, 31)", true), + ("revision = -1", true), + ("payload = zeroblob(0)", true), + ("payload = zeroblob(65537)", true), + ("payload = NULL", false), + ("payload = zeroblob(1)", false), + ("payload = zeroblob(65536)", false), + ] { + { + let guard = store.writer.lock().unwrap(); + let writer = guard.as_ref().unwrap(); + writer + .pragma_update(None, "ignore_check_constraints", true) + .unwrap(); + writer + .execute( + "UPDATE mcp_oauth_credentials SET target=?1,generation=?2,revision=?3,payload=?4", + rusqlite::params![target, original.generation, original.revision, payload.as_bytes()], + ) + .unwrap(); + writer + .execute( + &format!("UPDATE mcp_oauth_credentials SET {assignment}"), + [], + ) + .unwrap(); + writer + .pragma_update(None, "ignore_check_constraints", false) + .unwrap(); + } + store + .with_authenticated_store_key(|key| { + let connection = open_readonly(&store.paths, key)?; + let report = check_connection( + &connection, + IntegrityKind::Full, + encrypted, + Duration::from_secs(5), + &IntegrityCancellation::default(), + &store.paths, + ); + assert_eq!( + report.outcome, + if damaged { + IntegrityOutcome::Damaged + } else { + IntegrityOutcome::Clean + }, + "{assignment}: {report:?}" + ); + assert_eq!( + report.findings.iter().any(|finding| { + finding.kind == FindingKind::Constraint + && finding.table == Some(IntegrityTable::McpOAuthCredentials) + }), + damaged, + "{assignment}: {report:?}" + ); + let encoded = serde_json::to_string(&report).unwrap(); + if damaged { + assert!(encoded.contains("\"table\":\"mcp_oauth_credentials\"")); + } + assert!(!encoded.contains("not-a-real-credential")); + assert!(!encoded.contains(&original.generation)); + Ok(()) + }) + .unwrap(); + } + } +} + #[test] fn readonly_checks_accept_nullable_checks_and_version_one_without_new_column() { for version in [1, 2] { @@ -330,6 +439,10 @@ fn readonly_plain_and_keyed_checks_detect_orphan_foreign_keys() { "INSERT INTO account_settings(account_id,key,value,updated_ms) VALUES (-1,'orphan-fixture','{}',1)", [], ).unwrap(); + writer.execute( + "INSERT INTO mcp_oauth_credentials(server_id,target,generation,revision,payload) VALUES (-1,zeroblob(32),'0123456789abcdef0123456789abcdef',0,NULL)", + [], + ).unwrap(); writer.pragma_update(None, "foreign_keys", true).unwrap(); } store.with_authenticated_store_key(|key| { @@ -346,9 +459,9 @@ fn readonly_plain_and_keyed_checks_detect_orphan_foreign_keys() { &store.paths, ); assert_eq!(report.outcome, IntegrityOutcome::Damaged, "{report:?}"); - assert!(report.findings.iter().any(|finding| + assert!(report.findings.iter().filter(|finding| finding.kind == FindingKind::ForeignKey - )); + ).count() >= 2); assert!(report.receipt.is_none()); Ok(()) }).unwrap(); diff --git a/apps/maple-agent/crates/maple-agent/src/store/integrity_salvage.rs b/apps/maple-agent/crates/maple-agent/src/store/integrity_salvage.rs index 227fc7f24..e0455a5d3 100644 --- a/apps/maple-agent/crates/maple-agent/src/store/integrity_salvage.rs +++ b/apps/maple-agent/crates/maple-agent/src/store/integrity_salvage.rs @@ -40,6 +40,8 @@ pub enum SalvageTable { Projects, McpServers, McpServerSecrets, + #[serde(rename = "mcp_oauth_credentials")] + McpOAuthCredentials, Tasks, Entries, TimelineRows, @@ -61,6 +63,7 @@ impl SalvageTable { Self::Projects => "projects", Self::McpServers => "mcp_servers", Self::McpServerSecrets => "mcp_server_secrets", + Self::McpOAuthCredentials => "mcp_oauth_credentials", Self::Tasks => "tasks", Self::Entries => "entries", Self::TimelineRows => "timeline_rows", @@ -91,6 +94,7 @@ impl SalvageTable { Self::AccountSettings => "account_settings NOT INDEXED", Self::Projects => "projects NOT INDEXED", Self::McpServers => "mcp_servers NOT INDEXED", + Self::McpOAuthCredentials => "mcp_oauth_credentials NOT INDEXED", Self::Tasks => "tasks NOT INDEXED", Self::Entries => "entries NOT INDEXED", Self::TimelineRows => "timeline_rows NOT INDEXED", @@ -109,7 +113,7 @@ impl SalvageTable { } // Derived timeline rows never determine whether authoritative source is readable. -const TABLES: [SalvageTable; 15] = [ +const TABLES: [SalvageTable; 16] = [ SalvageTable::StoreMeta, SalvageTable::Accounts, SalvageTable::Credentials, @@ -119,6 +123,7 @@ const TABLES: [SalvageTable; 15] = [ SalvageTable::Projects, SalvageTable::McpServers, SalvageTable::McpServerSecrets, + SalvageTable::McpOAuthCredentials, SalvageTable::Tasks, SalvageTable::Entries, SalvageTable::ToolSummaries, @@ -447,6 +452,7 @@ fn validate_metadata(source: &Connection) -> StoreResult<()> { "SELECT EXISTS(SELECT 1 FROM mcp_servers NOT INDEXED WHERE NOT json_valid(definition))", "SELECT EXISTS(SELECT 1 FROM remote_identities NOT INDEXED WHERE NOT json_valid(record) OR length(secret)!=32)", "SELECT EXISTS(SELECT 1 FROM mcp_server_secrets INDEXED BY sqlite_autoindex_mcp_server_secrets_1 WHERE length(target)!=32)", + "SELECT EXISTS(SELECT 1 FROM mcp_oauth_credentials NOT INDEXED WHERE length(target)!=32 OR length(generation)!=32 OR revision<0 OR (payload IS NOT NULL AND (length(payload)=0 OR length(payload)>65536)))", "SELECT EXISTS(SELECT 1 FROM send_receipts NOT INDEXED WHERE result IS NOT NULL AND NOT json_valid(result))", "SELECT EXISTS(SELECT 1 FROM store_meta WHERE key NOT IN ('store_id','created_ms','schema_epoch','last_integrity_ms','setup_operation_id','integrity_incomplete_attempts','integrity_retry_after_ms'))", ]; @@ -695,6 +701,7 @@ fn copy_rows(source: &Connection, destination: &mut Connection, scan: &Scan) -> SalvageTable::Projects, SalvageTable::McpServers, SalvageTable::McpServerSecrets, + SalvageTable::McpOAuthCredentials, SalvageTable::Tasks, SalvageTable::Entries, SalvageTable::ToolSummaries, diff --git a/apps/maple-agent/crates/maple-agent/src/store/integrity_salvage_tests.rs b/apps/maple-agent/crates/maple-agent/src/store/integrity_salvage_tests.rs index 4bc504bfd..bcfadc0b6 100644 --- a/apps/maple-agent/crates/maple-agent/src/store/integrity_salvage_tests.rs +++ b/apps/maple-agent/crates/maple-agent/src/store/integrity_salvage_tests.rs @@ -419,6 +419,180 @@ fn candidate(source: &Connection) -> (Scan, Connection) { (scanned, destination) } +#[test] +fn salvage_catalog_covers_every_authoritative_owned_table() { + let mut reference = Connection::open_in_memory().unwrap(); + reference + .pragma_update(None, "application_id", schema::APPLICATION_ID) + .unwrap(); + schema::migrate(&mut reference, 1).unwrap(); + let expected = reference + .prepare("SELECT name FROM sqlite_schema WHERE type='table' AND name NOT LIKE 'sqlite_%'") + .unwrap() + .query_map([], |row| row.get::<_, String>(0)) + .unwrap() + .collect::>>() + .unwrap(); + let mut actual = TABLES + .iter() + .map(|table| table.name().to_owned()) + .collect::>(); + // Timeline rows are deliberately derived from retained source entries; + // every other owned table must participate in review and recovery. + assert!(actual.insert(SalvageTable::TimelineRows.name().to_owned())); + assert_eq!(actual, expected); + assert_eq!( + serde_json::to_string(&SalvageTable::McpOAuthCredentials).unwrap(), + "\"mcp_oauth_credentials\"" + ); +} + +fn oauth_rows(store: &Store, account: AccountId) -> i64 { + store + .write(Durability::Durable, |transaction| { + let mut active = 0; + for (position, name, revision, payload) in [ + ( + 0, + "oauth-active", + 7, + Some([PRIVATE_MARKER.as_bytes(), b"\x00\xff"].concat()), + ), + (1, "oauth-revoked", 11, None), + ] { + transaction.execute( + "INSERT INTO mcp_servers(account_id,key,position,definition) VALUES (?1,?2,?3,'{}')", + params![account.0, name, position], + )?; + let server = transaction.last_insert_rowid(); + if position == 0 { + active = server; + } + transaction.execute( + "INSERT INTO mcp_oauth_credentials(server_id,target,generation,revision,payload) VALUES (?1,?2,?3,?4,?5)", + params![server, [0xa5_u8; 32].as_slice(), "a".repeat(32), revision, payload], + )?; + } + Ok(active) + }) + .unwrap() +} + +#[test] +fn oauth_credentials_and_revocation_tombstones_are_retained_plain_and_keyed() { + use crate::store::keys::{KdfParams, Password}; + + for encrypted in [false, true] { + let (_directory, store, account) = fixture(); + let active = oauth_rows(&store, account); + if encrypted { + let password = Password::normalize("Z7jv!9qP#3nMx@8Ks^2dH").unwrap(); + store + .set_store_password(&password, &[], KdfParams::floor()) + .unwrap(); + } + let fingerprint = store + .with_authenticated_store_key(|key| { + let source = super::super::open_readonly(store.paths(), key)?; + let (scanned, destination) = candidate(&source); + let rows = |connection: &Connection| -> rusqlite::Result> { + connection + .prepare("SELECT server_id,target,generation,revision,payload FROM mcp_oauth_credentials ORDER BY server_id")? + .query_map([], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, Vec>(1)?, + row.get::<_, String>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, Option>>(4)?, + )) + })? + .collect() + }; + let original = rows(&source)?; + assert_eq!(original.len(), 2); + assert!(original[0].4.is_some()); + assert!(original[1].4.is_none()); + assert_eq!(rows(&destination)?, original); + assert!(scanned.preview.report.dropped_rows.is_empty()); + assert!( + !serde_json::to_string(&scanned.preview.report) + .unwrap() + .contains(PRIVATE_MARKER) + ); + Ok(scanned.preview.fingerprint) + }) + .unwrap(); + store + .write(Durability::Durable, |transaction| { + transaction.execute( + "UPDATE mcp_oauth_credentials SET revision=revision+1 WHERE server_id=?1", + [active], + )?; + Ok(()) + }) + .unwrap(); + store + .with_authenticated_store_key(|key| { + let source = super::super::open_readonly(store.paths(), key)?; + assert_ne!(scan(&source)?.preview.fingerprint, fingerprint); + Ok(()) + }) + .unwrap(); + } +} + +#[test] +fn malformed_or_orphan_oauth_metadata_is_refused_plain_and_keyed() { + use crate::store::keys::{KdfParams, Password}; + + for encrypted in [false, true] { + for update in [ + "target=zeroblob(31)", + "generation='too-short'", + "revision=-1", + "payload=zeroblob(0)", + "payload=zeroblob(65537)", + "server_id=999", + ] { + let (_directory, store, account) = fixture(); + let active = oauth_rows(&store, account); + if encrypted { + let password = Password::normalize("Z7jv!9qP#3nMx@8Ks^2dH").unwrap(); + store + .set_store_password(&password, &[], KdfParams::floor()) + .unwrap(); + } + { + let guard = store.writer.lock().unwrap(); + let writer = guard.as_ref().unwrap(); + writer.pragma_update(None, "foreign_keys", false).unwrap(); + writer + .pragma_update(None, "ignore_check_constraints", true) + .unwrap(); + writer + .execute( + &format!("UPDATE mcp_oauth_credentials SET {update} WHERE server_id=?1"), + [active], + ) + .unwrap(); + writer + .pragma_update(None, "ignore_check_constraints", false) + .unwrap(); + writer.pragma_update(None, "foreign_keys", true).unwrap(); + } + store + .with_authenticated_store_key(|key| { + let source = super::super::open_readonly(store.paths(), key)?; + assert!(matches!(scan(&source), Err(StoreError::InvalidData))); + Ok(()) + }) + .unwrap(); + assert!(!store.paths().root().join("db.tmp").exists()); + } + } +} + fn corrupt_fixture_root(store: &Store, object: &str) -> Vec { let root: i64 = store .read(|connection| { diff --git a/apps/maple-agent/crates/maple-agent/src/store/records.rs b/apps/maple-agent/crates/maple-agent/src/store/records.rs index 821ca0790..2e8abba26 100644 --- a/apps/maple-agent/crates/maple-agent/src/store/records.rs +++ b/apps/maple-agent/crates/maple-agent/src/store/records.rs @@ -14,6 +14,9 @@ use zeroize::Zeroizing; use super::{AccountId, Durability, Store, StoreError, StoreResult, tasks}; +mod oauth; +pub use oauth::McpOAuthCredentials; + const MAX_RECORD_BYTES: usize = 1024 * 1024; // Match the host's bounded 160 MiB request envelope, which admits image-heavy // sends. The smaller terminal-record limit must not shrink send admission. @@ -798,6 +801,16 @@ impl Store { let old_server = mcp_server_tx(tx,account,&edit.key)?; let old_values = if let Some((id,_)) = &old_server { mcp_values_tx(tx,*id)? } else { BTreeMap::new() }; let target = mcp_target_hash(&definition.target)?; + // Ordinary metadata/default toggles retain OAuth. Changing its + // destination or adding generic credential inputs cannot reuse + // the dedicated OAuth grant. + if (old_server.as_ref().is_some_and(|(_,old)| old.target != definition.target) + || !definition.environment.is_empty() + || !definition.headers.is_empty()) + && let Some((id,_)) = &old_server + { + tx.execute("DELETE FROM mcp_oauth_credentials WHERE server_id=?1",[id])?; + } if origin == McpEditOrigin::Remote && old_server.as_ref().is_some_and(|(_,old)| old.target != definition.target) { let explicit: BTreeSet<_> = edit.environment.iter() .filter(|value| !matches!(&value.update, McpSecretUpdate::Keep)) diff --git a/apps/maple-agent/crates/maple-agent/src/store/records/oauth.rs b/apps/maple-agent/crates/maple-agent/src/store/records/oauth.rs new file mode 100644 index 000000000..f3cffa40b --- /dev/null +++ b/apps/maple-agent/crates/maple-agent/src/store/records/oauth.rs @@ -0,0 +1,526 @@ +//! Dedicated OAuth material never enters MCP headers, environment or IPC. + +use super::*; + +/// Host-only credential snapshot. Its generation and revision must both match +/// before a refresh or clear can mutate the current grant. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct McpOAuthCredentials { + pub generation: String, + pub revision: i64, + pub payload: SecretString, +} + +fn check_payload(payload: &SecretString) -> StoreResult<()> { + if payload.expose().is_empty() + || payload.expose().len() > MAX_SECRET_BYTES + || !serde_json::from_str::(payload.expose()).is_ok_and(|value| value.is_object()) + { + return Err(StoreError::InvalidInput); + } + Ok(()) +} + +fn bound_server( + tx: &Transaction<'_>, + account: AccountId, + key: &str, + target: &[u8; 32], +) -> StoreResult { + tasks::writable_account(tx, account)?; + let (id, definition) = mcp_server_tx(tx, account, key)?.ok_or(StoreError::NotFound)?; + if mcp_target_hash(&definition.target)? != *target { + return Err(StoreError::Conflict); + } + Ok(id) +} + +fn credential_tx( + tx: &Transaction<'_>, + id: i64, + target: &[u8; 32], +) -> StoreResult> { + let row = tx + .query_row( + "SELECT target,generation,revision,payload FROM mcp_oauth_credentials WHERE server_id=?1", + [id], + |row| Ok((row.get::<_,Vec>(0)?,row.get::<_,String>(1)?,row.get::<_,i64>(2)?,row.get::<_,Option>>(3)?)), + ) + .optional()?; + row.map(|(saved_target, generation, revision, bytes)| { + if saved_target.as_slice() != target.as_slice() || generation.len() != 32 || revision < 0 { + return Err(StoreError::InvalidData); + } + let Some(bytes) = bytes else { + return Ok(None); + }; + let payload = + SecretString::new(String::from_utf8(bytes).map_err(|_| StoreError::InvalidData)?); + check_payload(&payload).map_err(|_| StoreError::InvalidData)?; + Ok(Some(McpOAuthCredentials { + generation, + revision, + payload, + })) + }) + .transpose() + .map(Option::flatten) +} + +impl Store { + /// A cleared grant retains its managed marker so explicit reconnect can + /// distinguish it from an unrelated custom server using the same name. + pub fn mcp_oauth_managed( + &self, + account: AccountId, + key: &str, + expected_target: &McpTarget, + ) -> StoreResult { + let target = mcp_target_hash(expected_target)?; + self.read(|tx| { + let id = bound_server(tx, account, key, &target)?; + Ok(tx.query_row( + "SELECT EXISTS(SELECT 1 FROM mcp_oauth_credentials WHERE server_id=?1 AND target=?2)", + params![id,target.as_slice()], + |row|row.get(0), + )?) + }) + } + + /// Read only after revalidating account and canonical MCP destination. + pub fn mcp_oauth_credentials( + &self, + account: AccountId, + key: &str, + expected_target: &McpTarget, + ) -> StoreResult> { + let target = mcp_target_hash(expected_target)?; + self.read(|tx| { + let id = bound_server(tx, account, key, &target)?; + credential_tx(tx, id, &target) + }) + } + + /// Install only a newly authorized definition or reconnect a definition + /// already managed by OAuth. A concurrent edit or unrelated named server + /// cannot be overwritten. Other definitions are untouched. + pub fn install_mcp_oauth( + &self, + account: AccountId, + expected_existing: Option<&McpDefinition>, + edit: &McpServerEdit, + payload: SecretString, + ) -> StoreResult { + check_payload(&payload)?; + let definition = mcp_definition(edit)?; + if definition.enabled + || !definition.environment.is_empty() + || !definition.headers.is_empty() + || !matches!(definition.target, McpTarget::StreamableHttp { .. }) + { + return Err(StoreError::InvalidInput); + } + let target = mcp_target_hash(&definition.target)?; + let next = McpOAuthCredentials { + generation: uuid::Uuid::new_v4().simple().to_string(), + revision: 0, + payload, + }; + self.write(Durability::Durable, |tx| { + tasks::writable_account(tx, account)?; + let current = mcp_server_tx(tx, account, &edit.key)?; + match (¤t, expected_existing) { + (None, None) => { + let count: i64 = tx.query_row("SELECT count(*) FROM mcp_servers WHERE account_id=?1", [account.0], |row| row.get(0))?; + if count >= MAX_MCP_SERVERS as i64 { return Err(StoreError::InvalidInput); } + } + (Some((id, current)), Some(expected)) if current == expected => { + let old_target = mcp_target_hash(¤t.target)?; + let managed:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM mcp_oauth_credentials WHERE server_id=?1 AND target=?2)",params![id,old_target.as_slice()],|row|row.get(0))?; + if !managed { return Err(StoreError::Conflict); } + } + _ => return Err(StoreError::Conflict), + } + let json = serde_json::to_string(&definition).map_err(|_| StoreError::InvalidInput)?; + let id: i64 = if let Some((id, _)) = current { + tx.execute("UPDATE mcp_servers SET definition=?1 WHERE id=?2 AND account_id=?3", params![json,id,account.0])?; + tx.execute("DELETE FROM mcp_server_secrets WHERE server_id=?1", [id])?; + id + } else { + tx.query_row( + "INSERT INTO mcp_servers(account_id,key,position,definition) SELECT ?1,?2,coalesce(max(position),-1)+1,?3 FROM mcp_servers WHERE account_id=?1 RETURNING id", + params![account.0,edit.key,json], |row| row.get(0), + )? + }; + tx.execute( + "INSERT INTO mcp_oauth_credentials(server_id,target,generation,revision,payload) VALUES (?1,?2,?3,?4,?5) ON CONFLICT(server_id) DO UPDATE SET target=excluded.target,generation=excluded.generation,revision=excluded.revision,payload=excluded.payload", + params![id,target.as_slice(),next.generation,next.revision,next.payload.expose().as_bytes()], + )?; + Ok(next.clone()) + }) + } + + /// Compare-and-swap prevents stale refresh work from replacing a new grant. + pub fn rotate_mcp_oauth_credentials( + &self, + account: AccountId, + key: &str, + expected_target: &McpTarget, + expected: &McpOAuthCredentials, + payload: SecretString, + ) -> StoreResult { + check_payload(&payload)?; + let target = mcp_target_hash(expected_target)?; + let revision = expected + .revision + .checked_add(1) + .ok_or(StoreError::Conflict)?; + self.write(Durability::Durable, |tx| { + let id = bound_server(tx, account, key, &target)?; + let updated = tx.execute( + "UPDATE mcp_oauth_credentials SET revision=?1,payload=?2 WHERE server_id=?3 AND target=?4 AND generation=?5 AND revision=?6 AND payload IS NOT NULL", + params![revision,payload.expose().as_bytes(),id,target.as_slice(),expected.generation,expected.revision], + )?; + if updated != 1 { return Err(StoreError::Conflict); } + Ok(McpOAuthCredentials { generation:expected.generation.clone(), revision,payload:payload.clone() }) + }) + } + + /// Clear only the exact grant/rotation the caller observed. + pub fn clear_mcp_oauth_credentials( + &self, + account: AccountId, + key: &str, + expected_target: &McpTarget, + expected: &McpOAuthCredentials, + ) -> StoreResult { + let target = mcp_target_hash(expected_target)?; + self.write(Durability::Durable, |tx| { + let id = bound_server(tx, account, key, &target)?; + Ok(tx.execute( + "UPDATE mcp_oauth_credentials SET payload=NULL,revision=revision+1 WHERE server_id=?1 AND target=?2 AND generation=?3 AND revision=?4 AND payload IS NOT NULL", + params![id,target.as_slice(),expected.generation,expected.revision], + )? == 1) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::store::{StorePaths, StoreState, engine, keys}; + + fn fixture(keyed: bool) -> (tempfile::TempDir, Store, AccountId, AccountId) { + let dir = tempfile::tempdir().unwrap(); + let store = + Store::unavailable(StorePaths::new(dir.path()), StoreState::Maintenance).unwrap(); + if keyed { + store + .initialize_key_material( + keys::LockedKey::from_bytes([0xa1; 32]).unwrap(), + engine::key_connection, + ) + .unwrap(); + } else { + store.initialize_plain().unwrap(); + } + let alice = store.upsert_account("Alice", 1).unwrap(); + let bob = store.upsert_account("Bob", 1).unwrap(); + (dir, store, alice, bob) + } + + fn edit(port: u16) -> McpServerEdit { + McpServerEdit { + key: "vana".into(), + name: "Vana".into(), + description: "Local personal context".into(), + enabled: false, + timeout_seconds: 30, + target: McpTarget::StreamableHttp { + url: format!("http://127.0.0.1:{port}/mcp"), + }, + environment: vec![], + headers: vec![], + } + } + + fn payload(label: &str) -> SecretString { + SecretString::new(serde_json::json!({"syntheticRefreshToken":label}).to_string()) + } + + #[test] + fn oauth_is_private_account_target_bound_and_durable_in_plain_and_keyed_stores() { + for keyed in [false, true] { + let (_dir, store, alice, bob) = fixture(keyed); + let edit = edit(47321); + let saved = store + .install_mcp_oauth(alice, None, &edit, payload("never-in-definitions")) + .unwrap(); + assert_eq!( + store + .mcp_oauth_credentials(alice, "vana", &edit.target) + .unwrap(), + Some(saved.clone()) + ); + assert_eq!( + store.mcp_oauth_credentials(bob, "vana", &edit.target), + Err(StoreError::NotFound) + ); + assert_eq!( + store.mcp_oauth_credentials(alice, "vana", &self::edit(47322).target), + Err(StoreError::Conflict) + ); + let definitions = + serde_json::to_string(&store.mcp_definitions(alice).unwrap()).unwrap(); + assert!(!definitions.contains("never-in-definitions")); + assert!(!definitions.contains("syntheticRefreshToken")); + assert!(!format!("{saved:?}").contains("never-in-definitions")); + let launch = store + .mcp_launch_secrets(alice, "vana", &edit.target) + .unwrap(); + assert!(launch.environment.is_empty() && launch.headers.is_empty()); + let paths = store.paths().clone(); + store.close().unwrap(); + drop(store); + let reopened = Store::unavailable(paths, StoreState::Maintenance).unwrap(); + if keyed { + reopened + .initialize_key_material( + keys::LockedKey::from_bytes([0xa1; 32]).unwrap(), + engine::key_connection, + ) + .unwrap(); + } else { + reopened.initialize_plain().unwrap(); + } + assert_eq!( + reopened + .mcp_oauth_credentials(alice, "vana", &edit.target) + .unwrap(), + Some(saved) + ); + } + } + + #[test] + fn oauth_rotations_and_reconnect_generations_refuse_late_callbacks() { + for keyed in [false, true] { + let (_dir, store, alice, _bob) = fixture(keyed); + let edit = edit(47321); + let first = store + .install_mcp_oauth(alice, None, &edit, payload("first")) + .unwrap(); + let rotated = store + .rotate_mcp_oauth_credentials( + alice, + "vana", + &edit.target, + &first, + payload("rotated"), + ) + .unwrap(); + assert_eq!(rotated.revision, 1); + assert_eq!( + store.rotate_mcp_oauth_credentials( + alice, + "vana", + &edit.target, + &first, + payload("stale") + ), + Err(StoreError::Conflict) + ); + assert!( + !store + .clear_mcp_oauth_credentials(alice, "vana", &edit.target, &first) + .unwrap() + ); + let expected = store.mcp_definitions(alice).unwrap().remove(0); + let reconnected = store + .install_mcp_oauth(alice, Some(&expected), &edit, payload("reconnected")) + .unwrap(); + assert_ne!(reconnected.generation, rotated.generation); + assert_eq!(reconnected.revision, 0); + assert_eq!( + store.rotate_mcp_oauth_credentials( + alice, + "vana", + &edit.target, + &rotated, + payload("resurrect") + ), + Err(StoreError::Conflict) + ); + assert!( + !store + .clear_mcp_oauth_credentials(alice, "vana", &edit.target, &rotated) + .unwrap() + ); + assert!( + store + .clear_mcp_oauth_credentials(alice, "vana", &edit.target, &reconnected) + .unwrap() + ); + assert_eq!( + store.rotate_mcp_oauth_credentials( + alice, + "vana", + &edit.target, + &reconnected, + payload("after-clear") + ), + Err(StoreError::Conflict) + ); + assert_eq!( + store + .mcp_oauth_credentials(alice, "vana", &edit.target) + .unwrap(), + None + ); + let replacement = store + .install_mcp_oauth(alice, Some(&expected), &edit, payload("after-revocation")) + .unwrap(); + assert_ne!(replacement.generation, reconnected.generation); + } + } + + #[test] + fn oauth_install_preserves_siblings_and_rejects_changed_or_custom_definitions() { + let (_dir, store, alice, _bob) = fixture(false); + let mut sibling = edit(47111); + sibling.key = "sibling".into(); + sibling.name = "Sibling".into(); + store + .save_mcp_definitions(alice, &[sibling.clone()], McpEditOrigin::Local) + .unwrap(); + let vana = edit(47321); + store + .install_mcp_oauth(alice, None, &vana, payload("first")) + .unwrap(); + let all = store.mcp_definitions(alice).unwrap(); + assert_eq!(all.len(), 2); + assert_eq!(all[0].key, "sibling"); + let expected = all[1].clone(); + let mut changed = vana.clone(); + changed.enabled = true; + store + .save_mcp_definitions(alice, &[sibling.clone(), changed], McpEditOrigin::Local) + .unwrap(); + assert_eq!( + store.install_mcp_oauth(alice, Some(&expected), &vana, payload("stale-install")), + Err(StoreError::Conflict) + ); + assert_eq!( + store.install_mcp_oauth(alice, None, &sibling, payload("overwrite-unrelated")), + Err(StoreError::Conflict) + ); + let expected_sibling = store.mcp_definitions(alice).unwrap().remove(0); + assert_eq!( + store.install_mcp_oauth( + alice, + Some(&expected_sibling), + &sibling, + payload("overwrite-unrelated") + ), + Err(StoreError::Conflict) + ); + assert_eq!(store.mcp_definitions(alice).unwrap().len(), 2); + } + + #[test] + fn generic_destination_or_credential_changes_clear_oauth_but_defaults_retain_it() { + for keyed in [false, true] { + let (_dir, store, alice, _bob) = fixture(keyed); + let mut vana = edit(47321); + let first = store + .install_mcp_oauth(alice, None, &vana, payload("first")) + .unwrap(); + vana.enabled = true; + store + .save_mcp_definitions(alice, &[vana.clone()], McpEditOrigin::Local) + .unwrap(); + assert_eq!( + store + .mcp_oauth_credentials(alice, "vana", &vana.target) + .unwrap(), + Some(first) + ); + vana.target = self::edit(47322).target; + store + .save_mcp_definitions(alice, &[vana.clone()], McpEditOrigin::Local) + .unwrap(); + assert_eq!( + store + .mcp_oauth_credentials(alice, "vana", &vana.target) + .unwrap(), + None + ); + store + .save_mcp_definitions(alice, &[], McpEditOrigin::Local) + .unwrap(); + vana.enabled = false; + let oauth = store + .install_mcp_oauth(alice, None, &vana, payload("second")) + .unwrap(); + vana.headers.push(McpSecretEdit { + name: "Authorization".into(), + update: McpSecretUpdate::Set(SecretString::new("synthetic-header".into())), + }); + store + .save_mcp_definitions(alice, &[vana.clone()], McpEditOrigin::Local) + .unwrap(); + assert_eq!( + store + .mcp_oauth_credentials(alice, "vana", &vana.target) + .unwrap(), + None + ); + assert_eq!( + store.rotate_mcp_oauth_credentials( + alice, + "vana", + &vana.target, + &oauth, + payload("stale") + ), + Err(StoreError::Conflict) + ); + } + } + + #[test] + fn oauth_rows_cascade_on_definition_and_account_deletion() { + let (_dir, store, alice, _bob) = fixture(false); + let vana = edit(47321); + store + .install_mcp_oauth(alice, None, &vana, payload("first")) + .unwrap(); + store + .save_mcp_definitions(alice, &[], McpEditOrigin::Local) + .unwrap(); + store + .install_mcp_oauth(alice, None, &vana, payload("second")) + .unwrap(); + store + .read(|tx| { + let count: i64 = + tx.query_row("SELECT count(*) FROM mcp_oauth_credentials", [], |row| { + row.get(0) + })?; + assert_eq!(count, 1); + Ok(()) + }) + .unwrap(); + store + .write(Durability::Durable, |tx| { + tx.execute("DELETE FROM accounts WHERE id=?1", [alice.0])?; + let count: i64 = + tx.query_row("SELECT count(*) FROM mcp_oauth_credentials", [], |row| { + row.get(0) + })?; + assert_eq!(count, 0); + Ok(()) + }) + .unwrap(); + } +} diff --git a/apps/maple-agent/crates/maple-agent/src/store/schema.rs b/apps/maple-agent/crates/maple-agent/src/store/schema.rs index 3e9d3e80d..b3726fa88 100644 --- a/apps/maple-agent/crates/maple-agent/src/store/schema.rs +++ b/apps/maple-agent/crates/maple-agent/src/store/schema.rs @@ -6,7 +6,7 @@ use super::{StoreError, StoreResult}; pub(super) const APPLICATION_ID: i64 = 1_296_126_028; pub(super) const SCHEMA_EPOCH: i64 = 1; -pub(super) const VERSION: i64 = 4; +pub(super) const VERSION: i64 = 5; /// Original migration schema, including partial indexes and constraints. /// Validation executes the complete append-only migration chain as its reference. @@ -294,6 +294,18 @@ DROP TABLE blobs; DELETE FROM store_meta WHERE key IN ('attachment_migration_phase','attachment_migration_cursor'); "#; +// OAuth credentials have a distinct host-only record. A generation fences +// clients retained across disconnect/reconnect; revision fences token rotation. +const MCP_OAUTH_DDL: &str = r#" +CREATE TABLE mcp_oauth_credentials ( + server_id INTEGER PRIMARY KEY REFERENCES mcp_servers(id) ON DELETE CASCADE, + target BLOB NOT NULL CHECK (length(target) = 32), + generation TEXT NOT NULL CHECK (length(generation) = 32), + revision INTEGER NOT NULL CHECK (revision >= 0), + payload BLOB CHECK (payload IS NULL OR (length(payload) > 0 AND length(payload) <= 65536)) +) STRICT; +"#; + const MIGRATIONS: &[Migration] = &[ Migration { version: 1, @@ -311,6 +323,10 @@ const MIGRATIONS: &[Migration] = &[ version: 4, sql: RETIRE_BLOBS_DDL, }, + Migration { + version: 5, + sql: MCP_OAUTH_DDL, + }, ]; /// Check a declared schema using only reads before writer tuning or migrations. @@ -506,7 +522,7 @@ mod tests { migrate(&mut connection, 42).unwrap(); validate(&connection).unwrap(); let tables: i64 = connection.query_row("SELECT count(*) FROM sqlite_schema WHERE type='table' AND name NOT LIKE 'sqlite_%'", [], |r| r.get(0)).unwrap(); - assert_eq!(tables, 16); + assert_eq!(tables, 17); assert_eq!( connection .pragma_query_value::(None, "user_version", |r| r.get(0)) diff --git a/apps/maple-agent/scripts/macos-debug-app.sh b/apps/maple-agent/scripts/macos-debug-app.sh index e6eb8f1b1..a5e400192 100755 --- a/apps/maple-agent/scripts/macos-debug-app.sh +++ b/apps/maple-agent/scripts/macos-debug-app.sh @@ -66,8 +66,8 @@ python3 "$repo_root/scripts/stage-third-party-notices.py" "$contents_dir/Resourc # compatibility libraries in the app for older systems. Reversing these paths # can load two Swift runtimes when Apple frameworks select the system copy. load_commands="$(/usr/bin/otool -l "$binary_destination")" -system_rpath_line="$(/usr/bin/awk '$1 == "path" && $2 == "/usr/lib/swift" { print NR; exit }' <<<"$load_commands")" -bundle_rpath_line="$(/usr/bin/awk '$1 == "path" && $2 == "@executable_path/../Frameworks" { print NR; exit }' <<<"$load_commands")" +system_rpath_line="$(printf '%s\n' "$load_commands" | /usr/bin/awk '$1 == "path" && $2 == "/usr/lib/swift" && !line { line = NR } END { if (line) print line }')" +bundle_rpath_line="$(printf '%s\n' "$load_commands" | /usr/bin/awk '$1 == "path" && $2 == "@executable_path/../Frameworks" && !line { line = NR } END { if (line) print line }')" if [[ -z "$system_rpath_line" || -z "$bundle_rpath_line" ]]; then echo "debug binary is missing its Swift runtime paths; rebuild Maple before staging" >&2 exit 1 diff --git a/apps/maple-agent/scripts/vana-poc/.gitignore b/apps/maple-agent/scripts/vana-poc/.gitignore new file mode 100644 index 000000000..c2658d7d1 --- /dev/null +++ b/apps/maple-agent/scripts/vana-poc/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/apps/maple-agent/scripts/vana-poc/README.md b/apps/maple-agent/scripts/vana-poc/README.md new file mode 100644 index 000000000..a7bf89528 --- /dev/null +++ b/apps/maple-agent/scripts/vana-poc/README.md @@ -0,0 +1,78 @@ +# Vana local MCP integration + +Maple Agent connects directly to the **Local MCP endpoint** served by Vana +Desktop, using native OAuth with PKCE and Vana's own scope approval screen. +There is no Maple Vana wallet key, capability file, Node bridge, hard-coded +server port, or public MCP endpoint in the new connection path. + +## Connect + +1. Keep an up-to-date Vana Desktop running. In **Settings → Server → Connect + apps**, copy **Local MCP endpoint**. +2. In Maple Agent on **This Computer**, open **Settings → Integrations → Vana**. + Paste the endpoint and click **Connect Vana**. +3. In Vana Desktop, select the scopes Maple may read and approve the request. + The native Vana consent screen is the authority; Maple does not simulate it. +4. Return to Maple and select Vana in the task's **Integrations** menu. The + setting for using Vana by default in new chats is a separate choice. +5. Ask Maple to list granted scopes, then answer a question using approved data. + +Copy the actual URL each time rather than assuming a port. This integration +accepts only `http://127.0.0.1:/mcp`; public hosts, other interfaces, +credentials in URLs, query strings and fragments are rejected. Setup is local +to the selected computer and cannot be started through a remote-control caller. + +OAuth discovery, registration, token requests and data requests are bound to +that same loopback origin, with HTTP redirects and inherited proxies disabled. +The browser callback is bounded and uses PKCE/state validation. OAuth credentials +are held by Maple's authoritative local host and target/account-bound store; +secret values are not sent to the settings UI. Ordinary token refresh is native. +Removing/changing a definition or losing its account/store generation prevents +late refreshes from silently restoring stale credentials. + +In Vana, **Settings → Server → Connected clients → Disconnect** revokes access. +A revoked session must be connected again. Vana Desktop must stay running. + +## Privacy boundary + +The Vana-to-Maple MCP data path uses loopback and does not require Vana's remote +tunnel. This is not a claim that every Vana account/grant/storage operation is +offline. Retrieved records enter Maple's configured model context and history. +Maple's store follows its normal local-store mode and OS permission policy; +plain mode is not application encryption or Keychain storage. + +Current official instructions: +https://docs.vana.org/applications/connect-a-local-agent + +## Synthetic fixture + +[demo-data](demo-data/README.md) preserves the fictional Alex Rivera ChatGPT and +Oura fixture. No real ChatGPT, wearable or GitHub source account was connected. +Do not reimport or reset an existing Vana vault merely to test the new transport. +Approve ChatGPT alone for the main test, and confirm an Oura read is denied. + +During the macOS smoke, an external-wallet test identity left Vana Desktop reporting +"Timed out waiting for external-wallet registration signature", even while its +loopback listener is healthy. In that state the native approval screen stays +loading. A normal **Server → Retry** restored the real consent screen during +this test. The test identity's registration still needs repair for repeatable +startup; preserve the imported vault and do not replace native consent with a +manually installed bearer. + + +## Validation + +Focused tests cover endpoint/callback/origin boundaries, native OAuth and +refresh, target/account/store generation persistence, and remote setup rejection. +Use the component's pinned Nix environment and normal validation workflow. +Live acceptance requires the exact native Maple app, Vana approval, an approved +scope read with the tunnel disabled, a denied ungranted scope, and revocation. +The macOS smoke against Vana 1.63.0 completed those steps with the synthetic +fixture, plus explicit reconnect in the existing chat and a fresh approved read +after a full Maple restart. Token-expiry refresh is covered by automated tests; +a live expiry cycle and Linux/Windows runtime were not exercised. +Run the automated checks from `apps/maple-agent`: + +```sh +nix develop --no-update-lock-file -c just ci +``` diff --git a/apps/maple-agent/scripts/vana-poc/demo-data/README.md b/apps/maple-agent/scripts/vana-poc/demo-data/README.md new file mode 100644 index 000000000..95320d3ce --- /dev/null +++ b/apps/maple-agent/scripts/vana-poc/demo-data/README.md @@ -0,0 +1,43 @@ +# Synthetic Alex Rivera vault fixtures + +Every record is fabricated. No real ChatGPT, wearable, personal, work, or GitHub account was used. Alex Rivera is a fictional privacy and AI engineer preparing an eight-minute DC talk on October 15, 2026. The demo is about portability and permissioned reads; the wearable fixture supports descriptive summaries, not medical claims. + +| Import body | Fixture scope schema | Records | +| --- | --- | --- | +| `chatgpt.conversations.json` | ChatGPT Conversations 2.0.0 | 26 threads, 104 messages | +| `chatgpt.memories.json` | ChatGPT Memories 2.0.0 | 12 saved memories | +| `oura.sleep.json` | Oura Sleep 1.0.0 | 14 daily scores, 14 sleep periods | +| `oura.readiness.json` | Oura Readiness 1.0.0 | 14 daily scores | + +Oura spans September 30 through October 13. Sleep stages sum to total sleep, sleep plus awake time equals time in bed, timestamps match durations, and ChatGPT counts match the message arrays. The fictional recent sleep pattern does not establish diagnoses or causes. + +`chatgpt-export.json` and `oura-export.json` follow the bundled collectors' canonical result envelope, with synthetic disclosure in permitted `exportSummary.details`. They are optional native export artifacts; the four raw scope bodies are for `POST /v1/data/{scope}`. + +On October 13, 2026 at 4:30 PM PDT, the four authenticated owner API imports each returned HTTP 201 after server health and identity checks. The actual Vana native Home screen automatically showed **68 Memories, 3 Sources, and 1 App**. The ChatGPT source exposed two data tabs with the imported JSON, and the Oura source card appeared. No native export registry mutation was needed. These observations verify native visibility of the imported synthetic data; they do not imply a real ChatGPT or Oura account connection. + +These fixtures were validated against Vana 1.53.1's ChatGPT and Oura connector manifests and scope schemas. No Whoop scope schema was found in that bundle. WHOOP was nevertheless offered by the native Sources catalog. This is historical schema evidence, not a claim about the currently installed Vana version; revalidate against its actual schemas before a new import. The new local OAuth integration does not reimport or reset this data. + +## Historical fixture maintenance (manual) + +These commands were originally run with Node 24.12.0 against the matching +Vana 1.53.1 schemas. They are not required for connecting Maple to Vana and +are not part of Agent CI. The validator uses the older bundle layout; supply +the matching original schema files before running it. + +From this fixture directory: + +```sh +node generate.mjs +node validate.mjs +node --test fixtures.test.mjs +``` + +Validation uses Ajv and ajv-formats from the Vana Personal Server bundle and requires the matching connector schema files from the original fixture version. Newer Vana releases can change their location or schema. No live API, signing key, or account token is touched by these commands. + +Suggested main demo prompt: + +> Use my Vana ChatGPT conversations and memories to help me prepare my eight-minute DC privacy talk in two days. Give me three priorities in my preferred style, mention the local transport caveat, and cite the vault records you used. Do not access health data. + +Optional separately granted health prompt: + +> Using the explicitly shared Oura sleep and readiness fixtures, summarize the recorded date range and recent sleep duration trend. Connect it only to my stated scheduling preferences; do not make medical conclusions. Cite the scopes. diff --git a/apps/maple-agent/scripts/vana-poc/demo-data/chatgpt-export.json b/apps/maple-agent/scripts/vana-poc/demo-data/chatgpt-export.json new file mode 100644 index 000000000..48a8d213c --- /dev/null +++ b/apps/maple-agent/scripts/vana-poc/demo-data/chatgpt-export.json @@ -0,0 +1,1181 @@ +{ + "requestedScopes": [ + "chatgpt.conversations", + "chatgpt.memories" + ], + "timestamp": "2026-10-13T18:30:00.000Z", + "version": "4.0.0-playwright", + "platform": "chatgpt", + "exportSummary": { + "count": 26, + "label": "conversations", + "details": { + "synthetic": true, + "persona": "Alex Rivera (fictional)", + "conversations": 26, + "memories": 12, + "messages": 104 + } + }, + "errors": [], + "chatgpt.conversations": { + "conversations": [ + { + "id": "synthetic-alex-chat-01", + "title": "Synthetic demo persona: Alex Rivera", + "create_time": "2026-09-18T16:00:00.000Z", + "update_time": "2026-09-18T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-01-message-1", + "role": "user", + "content": "For this fictional demo, I am Alex Rivera, a privacy and AI engineer based in San Francisco. I am preparing an eight-minute talk in Washington, DC on October 15, 2026. None of these conversations or metrics belong to a real person.", + "content_type": "text", + "create_time": "2026-09-18T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-01-message-2", + "role": "assistant", + "content": "Understood. I will treat Alex Rivera and every record in this demo as synthetic. Your talk can show useful personalization while being clear about where the data comes from.", + "content_type": "text", + "create_time": "2026-09-18T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-01-message-3", + "role": "user", + "content": "My theme is: personal context should be permissioned, portable, and useful. Keep my answers concise and cite the vault records you use.", + "content_type": "text", + "create_time": "2026-09-18T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-01-message-4", + "role": "assistant", + "content": "I will lead with the action, use short bullets, and identify the conversation or memory that supports each personalized recommendation.", + "content_type": "text", + "create_time": "2026-09-18T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-02", + "title": "An eight-minute privacy talk", + "create_time": "2026-09-19T16:00:00.000Z", + "update_time": "2026-09-19T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-02-message-1", + "role": "user", + "content": "I have eight minutes for my DC privacy talk. How should I structure it?", + "content_type": "text", + "create_time": "2026-09-19T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-02-message-2", + "role": "assistant", + "content": "Try four beats: a one-minute problem, a two-minute live vault demo, a three-minute explanation of permission boundaries, and a two-minute closing with questions.", + "content_type": "text", + "create_time": "2026-09-19T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-02-message-3", + "role": "user", + "content": "I want one concrete outcome instead of a tour of technical features.", + "content_type": "text", + "create_time": "2026-09-19T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-02-message-4", + "role": "assistant", + "content": "Show an assistant producing a personalized talk preparation plan from your own prior chats, with sources and an explicit grant.", + "content_type": "text", + "create_time": "2026-09-19T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-03", + "title": "My working style", + "create_time": "2026-09-20T16:00:00.000Z", + "update_time": "2026-09-20T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-03-message-1", + "role": "user", + "content": "When helping me plan, I prefer a short prioritized list over a long essay. I want at most three priorities, each with a reason.", + "content_type": "text", + "create_time": "2026-09-20T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-03-message-2", + "role": "assistant", + "content": "I will keep plans to three priorities, make the next action concrete, and put additional detail behind a follow-up.", + "content_type": "text", + "create_time": "2026-09-20T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-03-message-3", + "role": "user", + "content": "Please cite the actual vault item. Do not invent studies or make guesses sound like stored facts.", + "content_type": "text", + "create_time": "2026-09-20T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-03-message-4", + "role": "assistant", + "content": "I will distinguish retrieved facts from suggestions and cite the record title or scope for each retrieved fact.", + "content_type": "text", + "create_time": "2026-09-20T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-04", + "title": "Portable context across assistants", + "create_time": "2026-09-21T16:00:00.000Z", + "update_time": "2026-09-21T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-04-message-1", + "role": "user", + "content": "My conversations are spread across tools. I want to carry my preferences into Maple without retyping a personal biography.", + "content_type": "text", + "create_time": "2026-09-21T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-04-message-2", + "role": "assistant", + "content": "A personal vault can expose selected conversations and saved memories through MCP. Maple can read approved scopes and keep the resulting answer grounded in those records.", + "content_type": "text", + "create_time": "2026-09-21T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-04-message-3", + "role": "user", + "content": "That is the part I want to demonstrate: my context is portable but I still choose the app and the scope.", + "content_type": "text", + "create_time": "2026-09-21T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-04-message-4", + "role": "assistant", + "content": "Use a clear consent step and show the selected data categories before a read.", + "content_type": "text", + "create_time": "2026-09-21T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-05", + "title": "MCP permissions for a native app", + "create_time": "2026-09-22T16:00:00.000Z", + "update_time": "2026-09-22T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-05-message-1", + "role": "user", + "content": "How should the permission screen for my personal vault feel?", + "content_type": "text", + "create_time": "2026-09-22T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-05-message-2", + "role": "assistant", + "content": "Name the app, list the exact scopes, explain the purpose, and show whether the request is read-only. A user should be able to decline or revoke the grant.", + "content_type": "text", + "create_time": "2026-09-22T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-05-message-3", + "role": "user", + "content": "For the first demo I only need ChatGPT conversations and saved memories. Health metrics should stay outside that grant.", + "content_type": "text", + "create_time": "2026-09-22T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-05-message-4", + "role": "assistant", + "content": "Keep the first grant limited to chatgpt.conversations and chatgpt.memories. Add Oura only in a separate, deliberate request if the demo needs it.", + "content_type": "text", + "create_time": "2026-09-22T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-06", + "title": "Maple demo success criteria", + "create_time": "2026-09-23T16:00:00.000Z", + "update_time": "2026-09-23T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-06-message-1", + "role": "user", + "content": "What would prove the Maple and Vana integration actually works?", + "content_type": "text", + "create_time": "2026-09-23T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-06-message-2", + "role": "assistant", + "content": "A real MCP tool call should read a schema-valid block from the Personal Server, then Maple should answer using a fact available only in that block. A rejected ungranted scope is useful boundary evidence.", + "content_type": "text", + "create_time": "2026-09-23T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-06-message-3", + "role": "user", + "content": "Avoid calling a screenshot or a mocked answer a completed integration.", + "content_type": "text", + "create_time": "2026-09-23T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-06-message-4", + "role": "assistant", + "content": "Capture the tool call and the resulting answer separately, and label synthetic fixture content clearly.", + "content_type": "text", + "create_time": "2026-09-23T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-07", + "title": "The question for the live demo", + "create_time": "2026-09-24T16:00:00.000Z", + "update_time": "2026-09-24T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-07-message-1", + "role": "user", + "content": "I need a question that makes context portability obvious.", + "content_type": "text", + "create_time": "2026-09-24T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-07-message-2", + "role": "assistant", + "content": "Ask: Based on my previous chats, help me prepare my eight-minute DC privacy talk in two days. Give me three priorities and cite the relevant vault records.", + "content_type": "text", + "create_time": "2026-09-24T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-07-message-3", + "role": "user", + "content": "Add my preferred speaking style and the local-data caveat.", + "content_type": "text", + "create_time": "2026-09-24T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-07-message-4", + "role": "assistant", + "content": "The answer should reflect your preference for concrete examples, concise wording, and an honest statement that relay transport can still matter even when storage is local.", + "content_type": "text", + "create_time": "2026-09-24T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-08", + "title": "Privacy language without overclaiming", + "create_time": "2026-09-25T16:00:00.000Z", + "update_time": "2026-09-25T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-08-message-1", + "role": "user", + "content": "Can I say all data stays on device if the MCP endpoint is a public HTTPS relay?", + "content_type": "text", + "create_time": "2026-09-25T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-08-message-2", + "role": "assistant", + "content": "Be precise: local vault storage and network transport are separate claims. Describe the actual route and avoid promising that payloads never pass through a service until a direct local connection is verified.", + "content_type": "text", + "create_time": "2026-09-25T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-08-message-3", + "role": "user", + "content": "I would rather say less than oversell this.", + "content_type": "text", + "create_time": "2026-09-25T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-08-message-4", + "role": "assistant", + "content": "Use: Your vault is on your computer; you choose which context Maple may read. Then describe the demo transport accurately.", + "content_type": "text", + "create_time": "2026-09-25T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-09", + "title": "A short opening story", + "create_time": "2026-09-26T16:00:00.000Z", + "update_time": "2026-09-26T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-09-message-1", + "role": "user", + "content": "Draft a simple opening for my talk. I dislike sweeping claims and buzzwords.", + "content_type": "text", + "create_time": "2026-09-26T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-09-message-2", + "role": "assistant", + "content": "Every assistant starts by asking me who I am. But my past conversations already contain my preferences, projects, and deadlines. What if I could bring that context with me, and decide exactly what an app is allowed to see?", + "content_type": "text", + "create_time": "2026-09-26T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-09-message-3", + "role": "user", + "content": "That is close. End with a concrete promise.", + "content_type": "text", + "create_time": "2026-09-26T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-09-message-4", + "role": "assistant", + "content": "Today I will show one permissioned connection, one useful answer, and the records that answer came from.", + "content_type": "text", + "create_time": "2026-09-26T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-10", + "title": "The single slide architecture", + "create_time": "2026-09-27T16:00:00.000Z", + "update_time": "2026-09-27T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-10-message-1", + "role": "user", + "content": "I only want one architecture slide.", + "content_type": "text", + "create_time": "2026-09-27T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-10-message-2", + "role": "assistant", + "content": "Use three labeled boxes: Personal vault, approved MCP connection, Maple Agent. Put the permission decision at the connection and show context flowing into the answer.", + "content_type": "text", + "create_time": "2026-09-27T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-10-message-3", + "role": "user", + "content": "Keep the labels understandable to a policy audience.", + "content_type": "text", + "create_time": "2026-09-27T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-10-message-4", + "role": "assistant", + "content": "Call the scopes data categories on the slide, and show the precise scope names only in the product permission screen.", + "content_type": "text", + "create_time": "2026-09-27T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-11", + "title": "A local endpoint roadmap", + "create_time": "2026-09-28T16:00:00.000Z", + "update_time": "2026-09-28T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-11-message-1", + "role": "user", + "content": "My future preference is a direct localhost MCP connection so a desktop user can choose a local transport.", + "content_type": "text", + "create_time": "2026-09-28T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-11-message-2", + "role": "assistant", + "content": "Treat that as a separate product path to verify: endpoint binding, authorization, consent, metadata, token renewal, and revocation all need to work with the local origin.", + "content_type": "text", + "create_time": "2026-09-28T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-11-message-3", + "role": "user", + "content": "Do not imply changing a URL is all it takes.", + "content_type": "text", + "create_time": "2026-09-28T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-11-message-4", + "role": "assistant", + "content": "The demo can identify local transport as a next step while showing only the path actually tested.", + "content_type": "text", + "create_time": "2026-09-28T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-12", + "title": "Preparing the screen recording", + "create_time": "2026-09-29T16:00:00.000Z", + "update_time": "2026-09-29T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-12-message-1", + "role": "user", + "content": "What should my product recording show?", + "content_type": "text", + "create_time": "2026-09-29T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-12-message-2", + "role": "assistant", + "content": "Start with the vault contents, open Maple integrations, connect Vana with a clear permission choice, enable it for the chat, and ask a question that needs stored context.", + "content_type": "text", + "create_time": "2026-09-29T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-12-message-3", + "role": "user", + "content": "I want a real product flow, not a montage of terminal commands.", + "content_type": "text", + "create_time": "2026-09-29T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-12-message-4", + "role": "assistant", + "content": "Use clean framing and a few readable captions. Capture the actual app behavior and keep tokens or account keys out of the recording.", + "content_type": "text", + "create_time": "2026-09-29T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-13", + "title": "Synthetic data disclosure", + "create_time": "2026-09-30T16:00:00.000Z", + "update_time": "2026-09-30T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-13-message-1", + "role": "user", + "content": "How do I make the demo compelling while being honest about fake data?", + "content_type": "text", + "create_time": "2026-09-30T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-13-message-2", + "role": "assistant", + "content": "Use a cohesive fictional persona and a small visible Synthetic demo data label. The integration and tool calls can be real even when the imported records are fabricated.", + "content_type": "text", + "create_time": "2026-09-30T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-13-message-3", + "role": "user", + "content": "Keep that label visible at the start and in the closing.", + "content_type": "text", + "create_time": "2026-09-30T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-13-message-4", + "role": "assistant", + "content": "Then say: Synthetic history, live permissioned MCP reads. Avoid implying the fictional persona represents an actual account owner.", + "content_type": "text", + "create_time": "2026-09-30T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-14", + "title": "Talk draft: three ideas", + "create_time": "2026-10-01T16:00:00.000Z", + "update_time": "2026-10-01T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-14-message-1", + "role": "user", + "content": "My three ideas are user choice, context portability, and useful personalization. Is that enough?", + "content_type": "text", + "create_time": "2026-10-01T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-14-message-2", + "role": "assistant", + "content": "Yes. Map each idea to one action: choose scopes, bring prior chat context into a different assistant, and receive a preparation plan grounded in that context.", + "content_type": "text", + "create_time": "2026-10-01T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-14-message-3", + "role": "user", + "content": "My policy audience does not need token exchange details.", + "content_type": "text", + "create_time": "2026-10-01T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-14-message-4", + "role": "assistant", + "content": "Save protocol details for the appendix; use the live consent screen to make the boundary visible.", + "content_type": "text", + "create_time": "2026-10-01T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-15", + "title": "Questions I expect from the audience", + "create_time": "2026-10-02T16:00:00.000Z", + "update_time": "2026-10-02T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-15-message-1", + "role": "user", + "content": "What questions should I prepare for in DC?", + "content_type": "text", + "create_time": "2026-10-02T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-15-message-2", + "role": "assistant", + "content": "Expect: Can I revoke access? What leaves my device? Who operates the endpoint? Can an app ask for more data later? Is the answer based on real sources?", + "content_type": "text", + "create_time": "2026-10-02T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-15-message-3", + "role": "user", + "content": "For anything untested, I want to say we are still verifying it.", + "content_type": "text", + "create_time": "2026-10-02T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-15-message-4", + "role": "assistant", + "content": "Prepare a tested-versus-planned slide or note, and answer only with evidence from this build.", + "content_type": "text", + "create_time": "2026-10-02T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-16", + "title": "Source citations in the final answer", + "create_time": "2026-10-03T16:00:00.000Z", + "update_time": "2026-10-03T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-16-message-1", + "role": "user", + "content": "What should citations look like when Maple uses my own chats?", + "content_type": "text", + "create_time": "2026-10-03T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-16-message-2", + "role": "assistant", + "content": "Use the conversation title and scope, plus a record identifier when available. Make it clear that these are personal vault records rather than public research sources.", + "content_type": "text", + "create_time": "2026-10-03T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-16-message-3", + "role": "user", + "content": "Please avoid citation spam after every sentence.", + "content_type": "text", + "create_time": "2026-10-03T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-16-message-4", + "role": "assistant", + "content": "Group related claims and cite the supporting record once at the end of each short priority.", + "content_type": "text", + "create_time": "2026-10-03T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-17", + "title": "Protecting my rehearsal time", + "create_time": "2026-10-04T16:00:00.000Z", + "update_time": "2026-10-04T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-17-message-1", + "role": "user", + "content": "My strongest focus is around 9 to 11 AM. I prefer meetings after lunch. Please keep that in mind when planning my rehearsal.", + "content_type": "text", + "create_time": "2026-10-04T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-17-message-2", + "role": "assistant", + "content": "Reserve the morning for a full talk run-through and put logistics or review after lunch.", + "content_type": "text", + "create_time": "2026-10-04T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-17-message-3", + "role": "user", + "content": "I want one rehearsal out loud, not another evening rewriting slides.", + "content_type": "text", + "create_time": "2026-10-04T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-17-message-4", + "role": "assistant", + "content": "Put a complete spoken rehearsal before slide polishing, then set a stopping time for edits.", + "content_type": "text", + "create_time": "2026-10-04T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-18", + "title": "Travel to Washington", + "create_time": "2026-10-05T16:00:00.000Z", + "update_time": "2026-10-05T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-18-message-1", + "role": "user", + "content": "For the fictional schedule, I travel to Washington on October 14 and speak on October 15. I want the demo and the talk outline ready before I leave.", + "content_type": "text", + "create_time": "2026-10-05T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-18-message-2", + "role": "assistant", + "content": "Finish the core flow and a backup recording on October 13. Use October 14 for travel and one short rehearsal, then avoid making the live talk depend on a last-minute setup.", + "content_type": "text", + "create_time": "2026-10-05T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-18-message-3", + "role": "user", + "content": "Include a fallback for unstable venue Wi-Fi.", + "content_type": "text", + "create_time": "2026-10-05T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-18-message-4", + "role": "assistant", + "content": "Keep a local recording and screenshots of the tested flow, clearly identified as recorded evidence.", + "content_type": "text", + "create_time": "2026-10-05T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-19", + "title": "A recovery-aware work plan", + "create_time": "2026-10-06T16:00:00.000Z", + "update_time": "2026-10-06T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-19-message-1", + "role": "user", + "content": "I have been staying up late to polish the demo. If I choose to share wearable data, I only want a descriptive summary that helps me schedule work, not a diagnosis.", + "content_type": "text", + "create_time": "2026-10-06T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-19-message-2", + "role": "assistant", + "content": "A planning answer can summarize recorded sleep duration and your own stated preferences. It should avoid diagnoses, causal claims, or treatment advice.", + "content_type": "text", + "create_time": "2026-10-06T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-19-message-3", + "role": "user", + "content": "Please do not read health scopes by default.", + "content_type": "text", + "create_time": "2026-10-06T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-19-message-4", + "role": "assistant", + "content": "Use chat history first. Health records require a separate scope grant and a question that explicitly asks for them.", + "content_type": "text", + "create_time": "2026-10-06T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-20", + "title": "Less late-night slide polishing", + "create_time": "2026-10-07T16:00:00.000Z", + "update_time": "2026-10-07T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-20-message-1", + "role": "user", + "content": "Late-night polishing is rarely useful for me. My own goal is to stop work by 10 PM on the two nights before the talk.", + "content_type": "text", + "create_time": "2026-10-07T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-20-message-2", + "role": "assistant", + "content": "Put that goal in the preparation plan and make the final afternoon review small enough to finish before your stopping time.", + "content_type": "text", + "create_time": "2026-10-07T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-20-message-3", + "role": "user", + "content": "That is a preference, not a medical rule.", + "content_type": "text", + "create_time": "2026-10-07T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-20-message-4", + "role": "assistant", + "content": "I will present it as your stated working preference and avoid prescribing sleep targets from wearable data.", + "content_type": "text", + "create_time": "2026-10-07T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-21", + "title": "Read-only means read-only", + "create_time": "2026-10-08T16:00:00.000Z", + "update_time": "2026-10-08T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-21-message-1", + "role": "user", + "content": "The integration in this demo should read vault records. It should not post messages or modify my source accounts.", + "content_type": "text", + "create_time": "2026-10-08T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-21-message-2", + "role": "assistant", + "content": "Describe the demo as a read-only use of approved vault scopes. Check the tools available to the assistant and do not include external account actions in the recording.", + "content_type": "text", + "create_time": "2026-10-08T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-21-message-3", + "role": "user", + "content": "I also want health excluded from the initial ChatGPT demo.", + "content_type": "text", + "create_time": "2026-10-08T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-21-message-4", + "role": "assistant", + "content": "A denied Oura read under the chat-only grant can show that the boundary is real without sharing health data.", + "content_type": "text", + "create_time": "2026-10-08T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-22", + "title": "Permission revocation wording", + "create_time": "2026-10-09T16:00:00.000Z", + "update_time": "2026-10-09T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-22-message-1", + "role": "user", + "content": "Draft a line that explains revocation without promising untested behavior.", + "content_type": "text", + "create_time": "2026-10-09T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-22-message-2", + "role": "assistant", + "content": "You can choose the data categories this connection may read. Revocation is part of the permission model; the demo notes should identify which revocation behavior this build actually verifies.", + "content_type": "text", + "create_time": "2026-10-09T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-22-message-3", + "role": "user", + "content": "Good. I do not want the narration to get ahead of the proof of concept.", + "content_type": "text", + "create_time": "2026-10-09T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-22-message-4", + "role": "assistant", + "content": "Use the current tested grant checks as evidence and label further lifecycle checks as remaining work.", + "content_type": "text", + "create_time": "2026-10-09T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-23", + "title": "What goes into the backup recording", + "create_time": "2026-10-10T16:00:00.000Z", + "update_time": "2026-10-10T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-23-message-1", + "role": "user", + "content": "My backup recording should be no more than two minutes.", + "content_type": "text", + "create_time": "2026-10-10T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-23-message-2", + "role": "assistant", + "content": "Spend 20 seconds on the vault, 35 seconds on connection and permissions, 15 seconds enabling Vana in the chat, 40 seconds on a sourced answer, and 10 seconds on the closing disclosure.", + "content_type": "text", + "create_time": "2026-10-10T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-23-message-3", + "role": "user", + "content": "Use a gentle pace and keep each label readable.", + "content_type": "text", + "create_time": "2026-10-10T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-23-message-4", + "role": "assistant", + "content": "Leave a short pause when permissions and source citations appear so viewers can read them.", + "content_type": "text", + "create_time": "2026-10-10T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-24", + "title": "Final talk title", + "create_time": "2026-10-11T16:00:00.000Z", + "update_time": "2026-10-11T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-24-message-1", + "role": "user", + "content": "Pick a grounded title for the DC privacy talk.", + "content_type": "text", + "create_time": "2026-10-11T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-24-message-2", + "role": "assistant", + "content": "Your context, your choice: personal vaults meet useful AI.", + "content_type": "text", + "create_time": "2026-10-11T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-24-message-3", + "role": "user", + "content": "I like that. Do not use a futuristic title.", + "content_type": "text", + "create_time": "2026-10-11T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-24-message-4", + "role": "assistant", + "content": "Keep this title and use the live preparation plan as the concrete example under it.", + "content_type": "text", + "create_time": "2026-10-11T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-25", + "title": "Final demo priorities for October 13", + "create_time": "2026-10-12T16:00:00.000Z", + "update_time": "2026-10-12T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-25-message-1", + "role": "user", + "content": "It is October 13. My talk is in two days. What matters most today?", + "content_type": "text", + "create_time": "2026-10-12T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-25-message-2", + "role": "assistant", + "content": "Finish the live chat-only grant and sourced Maple answer; capture a readable backup recording; do one eight-minute rehearsal out loud. Leave cosmetic polish until those three work.", + "content_type": "text", + "create_time": "2026-10-12T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-25-message-3", + "role": "user", + "content": "Use my preference for a short prioritized answer when I ask Maple later.", + "content_type": "text", + "create_time": "2026-10-12T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-25-message-4", + "role": "assistant", + "content": "I will give three priorities with clear reasons and citations to the relevant saved conversations.", + "content_type": "text", + "create_time": "2026-10-12T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-26", + "title": "A health-aware variant, only after permission", + "create_time": "2026-10-13T16:00:00.000Z", + "update_time": "2026-10-13T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-26-message-1", + "role": "user", + "content": "For an optional second demo, I might explicitly grant Oura sleep and readiness and ask for a descriptive summary alongside my talk schedule.", + "content_type": "text", + "create_time": "2026-10-13T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-26-message-2", + "role": "assistant", + "content": "Use the exact approved scopes, report the recorded date range and averages, and state that synthetic wearable records support planning rather than medical conclusions.", + "content_type": "text", + "create_time": "2026-10-13T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-26-message-3", + "role": "user", + "content": "Keep the main video centered on portable ChatGPT context. The health variant is secondary.", + "content_type": "text", + "create_time": "2026-10-13T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-26-message-4", + "role": "assistant", + "content": "The main story stays focused on your prior chats and memories, with Oura available only as an optional separately granted example.", + "content_type": "text", + "create_time": "2026-10-13T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + } + ], + "total": 26 + }, + "chatgpt.memories": { + "memories": [ + { + "id": "synthetic-alex-memory-01", + "content": "SYNTHETIC DEMO PERSONA: Alex Rivera is a fictional privacy and AI engineer. Every conversation, preference, and wearable metric in this vault fixture is fabricated for the Maple + Vana proof of concept.", + "created_at": "2026-09-18T16:00:00.000Z", + "updated_at": "2026-10-13T16:30:00.000Z", + "type": "memory" + }, + { + "id": "synthetic-alex-memory-02", + "content": "Alex Rivera is preparing an eight-minute Washington, DC privacy talk on October 15, 2026, titled Your context, your choice: personal vaults meet useful AI.", + "created_at": "2026-09-19T16:00:00.000Z", + "updated_at": "2026-10-13T16:30:00.000Z", + "type": "memory" + }, + { + "id": "synthetic-alex-memory-03", + "content": "Alex prefers concise answers with at most three priorities, concrete next actions, and citations to the exact personal vault records used.", + "created_at": "2026-09-20T16:00:00.000Z", + "updated_at": "2026-10-13T16:30:00.000Z", + "type": "memory" + }, + { + "id": "synthetic-alex-memory-04", + "content": "Alex prefers plain language, concrete examples, and an honest description of tested behavior over sweeping privacy claims or buzzwords.", + "created_at": "2026-09-21T16:00:00.000Z", + "updated_at": "2026-10-13T16:30:00.000Z", + "type": "memory" + }, + { + "id": "synthetic-alex-memory-05", + "content": "The talk theme is personal context that is permissioned, portable, and useful. The live example is a preparation plan grounded in prior ChatGPT conversations.", + "created_at": "2026-09-22T16:00:00.000Z", + "updated_at": "2026-10-13T16:30:00.000Z", + "type": "memory" + }, + { + "id": "synthetic-alex-memory-06", + "content": "Alex wants the initial Maple grant to include only chatgpt.conversations and chatgpt.memories. Oura sleep and readiness require a separate explicit permission choice.", + "created_at": "2026-09-23T16:00:00.000Z", + "updated_at": "2026-10-13T16:30:00.000Z", + "type": "memory" + }, + { + "id": "synthetic-alex-memory-07", + "content": "Alex has their best focus from 9 to 11 AM and prefers meetings and logistics after lunch. A spoken rehearsal comes before further slide polishing.", + "created_at": "2026-09-24T16:00:00.000Z", + "updated_at": "2026-10-13T16:30:00.000Z", + "type": "memory" + }, + { + "id": "synthetic-alex-memory-08", + "content": "Alex travels to Washington on October 14 and wants the demo, talk outline, and backup recording ready on October 13.", + "created_at": "2026-09-25T16:00:00.000Z", + "updated_at": "2026-10-13T16:30:00.000Z", + "type": "memory" + }, + { + "id": "synthetic-alex-memory-09", + "content": "Alex wants to stop work by 10 PM on the two nights before the talk. This is a personal scheduling preference, not a medical recommendation.", + "created_at": "2026-09-26T16:00:00.000Z", + "updated_at": "2026-10-13T16:30:00.000Z", + "type": "memory" + }, + { + "id": "synthetic-alex-memory-10", + "content": "Alex wants one simple architecture slide and a backup recording no longer than two minutes in case venue Wi-Fi is unreliable.", + "created_at": "2026-09-27T16:00:00.000Z", + "updated_at": "2026-10-13T16:30:00.000Z", + "type": "memory" + }, + { + "id": "synthetic-alex-memory-11", + "content": "Alex wants source citations to distinguish private vault conversation records from public research references. Retrieved facts and suggestions should be separate.", + "created_at": "2026-09-28T16:00:00.000Z", + "updated_at": "2026-10-13T16:30:00.000Z", + "type": "memory" + }, + { + "id": "synthetic-alex-memory-12", + "content": "All Oura fixtures are synthetic. Alex only wants descriptive date ranges and metric summaries if health scopes are deliberately granted; no diagnoses or treatment advice.", + "created_at": "2026-09-29T16:00:00.000Z", + "updated_at": "2026-10-13T16:30:00.000Z", + "type": "memory" + } + ], + "total": 12 + } +} diff --git a/apps/maple-agent/scripts/vana-poc/demo-data/chatgpt.conversations.json b/apps/maple-agent/scripts/vana-poc/demo-data/chatgpt.conversations.json new file mode 100644 index 000000000..70bf6bab6 --- /dev/null +++ b/apps/maple-agent/scripts/vana-poc/demo-data/chatgpt.conversations.json @@ -0,0 +1,1071 @@ +{ + "conversations": [ + { + "id": "synthetic-alex-chat-01", + "title": "Synthetic demo persona: Alex Rivera", + "create_time": "2026-09-18T16:00:00.000Z", + "update_time": "2026-09-18T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-01-message-1", + "role": "user", + "content": "For this fictional demo, I am Alex Rivera, a privacy and AI engineer based in San Francisco. I am preparing an eight-minute talk in Washington, DC on October 15, 2026. None of these conversations or metrics belong to a real person.", + "content_type": "text", + "create_time": "2026-09-18T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-01-message-2", + "role": "assistant", + "content": "Understood. I will treat Alex Rivera and every record in this demo as synthetic. Your talk can show useful personalization while being clear about where the data comes from.", + "content_type": "text", + "create_time": "2026-09-18T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-01-message-3", + "role": "user", + "content": "My theme is: personal context should be permissioned, portable, and useful. Keep my answers concise and cite the vault records you use.", + "content_type": "text", + "create_time": "2026-09-18T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-01-message-4", + "role": "assistant", + "content": "I will lead with the action, use short bullets, and identify the conversation or memory that supports each personalized recommendation.", + "content_type": "text", + "create_time": "2026-09-18T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-02", + "title": "An eight-minute privacy talk", + "create_time": "2026-09-19T16:00:00.000Z", + "update_time": "2026-09-19T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-02-message-1", + "role": "user", + "content": "I have eight minutes for my DC privacy talk. How should I structure it?", + "content_type": "text", + "create_time": "2026-09-19T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-02-message-2", + "role": "assistant", + "content": "Try four beats: a one-minute problem, a two-minute live vault demo, a three-minute explanation of permission boundaries, and a two-minute closing with questions.", + "content_type": "text", + "create_time": "2026-09-19T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-02-message-3", + "role": "user", + "content": "I want one concrete outcome instead of a tour of technical features.", + "content_type": "text", + "create_time": "2026-09-19T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-02-message-4", + "role": "assistant", + "content": "Show an assistant producing a personalized talk preparation plan from your own prior chats, with sources and an explicit grant.", + "content_type": "text", + "create_time": "2026-09-19T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-03", + "title": "My working style", + "create_time": "2026-09-20T16:00:00.000Z", + "update_time": "2026-09-20T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-03-message-1", + "role": "user", + "content": "When helping me plan, I prefer a short prioritized list over a long essay. I want at most three priorities, each with a reason.", + "content_type": "text", + "create_time": "2026-09-20T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-03-message-2", + "role": "assistant", + "content": "I will keep plans to three priorities, make the next action concrete, and put additional detail behind a follow-up.", + "content_type": "text", + "create_time": "2026-09-20T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-03-message-3", + "role": "user", + "content": "Please cite the actual vault item. Do not invent studies or make guesses sound like stored facts.", + "content_type": "text", + "create_time": "2026-09-20T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-03-message-4", + "role": "assistant", + "content": "I will distinguish retrieved facts from suggestions and cite the record title or scope for each retrieved fact.", + "content_type": "text", + "create_time": "2026-09-20T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-04", + "title": "Portable context across assistants", + "create_time": "2026-09-21T16:00:00.000Z", + "update_time": "2026-09-21T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-04-message-1", + "role": "user", + "content": "My conversations are spread across tools. I want to carry my preferences into Maple without retyping a personal biography.", + "content_type": "text", + "create_time": "2026-09-21T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-04-message-2", + "role": "assistant", + "content": "A personal vault can expose selected conversations and saved memories through MCP. Maple can read approved scopes and keep the resulting answer grounded in those records.", + "content_type": "text", + "create_time": "2026-09-21T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-04-message-3", + "role": "user", + "content": "That is the part I want to demonstrate: my context is portable but I still choose the app and the scope.", + "content_type": "text", + "create_time": "2026-09-21T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-04-message-4", + "role": "assistant", + "content": "Use a clear consent step and show the selected data categories before a read.", + "content_type": "text", + "create_time": "2026-09-21T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-05", + "title": "MCP permissions for a native app", + "create_time": "2026-09-22T16:00:00.000Z", + "update_time": "2026-09-22T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-05-message-1", + "role": "user", + "content": "How should the permission screen for my personal vault feel?", + "content_type": "text", + "create_time": "2026-09-22T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-05-message-2", + "role": "assistant", + "content": "Name the app, list the exact scopes, explain the purpose, and show whether the request is read-only. A user should be able to decline or revoke the grant.", + "content_type": "text", + "create_time": "2026-09-22T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-05-message-3", + "role": "user", + "content": "For the first demo I only need ChatGPT conversations and saved memories. Health metrics should stay outside that grant.", + "content_type": "text", + "create_time": "2026-09-22T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-05-message-4", + "role": "assistant", + "content": "Keep the first grant limited to chatgpt.conversations and chatgpt.memories. Add Oura only in a separate, deliberate request if the demo needs it.", + "content_type": "text", + "create_time": "2026-09-22T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-06", + "title": "Maple demo success criteria", + "create_time": "2026-09-23T16:00:00.000Z", + "update_time": "2026-09-23T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-06-message-1", + "role": "user", + "content": "What would prove the Maple and Vana integration actually works?", + "content_type": "text", + "create_time": "2026-09-23T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-06-message-2", + "role": "assistant", + "content": "A real MCP tool call should read a schema-valid block from the Personal Server, then Maple should answer using a fact available only in that block. A rejected ungranted scope is useful boundary evidence.", + "content_type": "text", + "create_time": "2026-09-23T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-06-message-3", + "role": "user", + "content": "Avoid calling a screenshot or a mocked answer a completed integration.", + "content_type": "text", + "create_time": "2026-09-23T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-06-message-4", + "role": "assistant", + "content": "Capture the tool call and the resulting answer separately, and label synthetic fixture content clearly.", + "content_type": "text", + "create_time": "2026-09-23T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-07", + "title": "The question for the live demo", + "create_time": "2026-09-24T16:00:00.000Z", + "update_time": "2026-09-24T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-07-message-1", + "role": "user", + "content": "I need a question that makes context portability obvious.", + "content_type": "text", + "create_time": "2026-09-24T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-07-message-2", + "role": "assistant", + "content": "Ask: Based on my previous chats, help me prepare my eight-minute DC privacy talk in two days. Give me three priorities and cite the relevant vault records.", + "content_type": "text", + "create_time": "2026-09-24T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-07-message-3", + "role": "user", + "content": "Add my preferred speaking style and the local-data caveat.", + "content_type": "text", + "create_time": "2026-09-24T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-07-message-4", + "role": "assistant", + "content": "The answer should reflect your preference for concrete examples, concise wording, and an honest statement that relay transport can still matter even when storage is local.", + "content_type": "text", + "create_time": "2026-09-24T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-08", + "title": "Privacy language without overclaiming", + "create_time": "2026-09-25T16:00:00.000Z", + "update_time": "2026-09-25T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-08-message-1", + "role": "user", + "content": "Can I say all data stays on device if the MCP endpoint is a public HTTPS relay?", + "content_type": "text", + "create_time": "2026-09-25T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-08-message-2", + "role": "assistant", + "content": "Be precise: local vault storage and network transport are separate claims. Describe the actual route and avoid promising that payloads never pass through a service until a direct local connection is verified.", + "content_type": "text", + "create_time": "2026-09-25T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-08-message-3", + "role": "user", + "content": "I would rather say less than oversell this.", + "content_type": "text", + "create_time": "2026-09-25T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-08-message-4", + "role": "assistant", + "content": "Use: Your vault is on your computer; you choose which context Maple may read. Then describe the demo transport accurately.", + "content_type": "text", + "create_time": "2026-09-25T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-09", + "title": "A short opening story", + "create_time": "2026-09-26T16:00:00.000Z", + "update_time": "2026-09-26T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-09-message-1", + "role": "user", + "content": "Draft a simple opening for my talk. I dislike sweeping claims and buzzwords.", + "content_type": "text", + "create_time": "2026-09-26T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-09-message-2", + "role": "assistant", + "content": "Every assistant starts by asking me who I am. But my past conversations already contain my preferences, projects, and deadlines. What if I could bring that context with me, and decide exactly what an app is allowed to see?", + "content_type": "text", + "create_time": "2026-09-26T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-09-message-3", + "role": "user", + "content": "That is close. End with a concrete promise.", + "content_type": "text", + "create_time": "2026-09-26T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-09-message-4", + "role": "assistant", + "content": "Today I will show one permissioned connection, one useful answer, and the records that answer came from.", + "content_type": "text", + "create_time": "2026-09-26T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-10", + "title": "The single slide architecture", + "create_time": "2026-09-27T16:00:00.000Z", + "update_time": "2026-09-27T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-10-message-1", + "role": "user", + "content": "I only want one architecture slide.", + "content_type": "text", + "create_time": "2026-09-27T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-10-message-2", + "role": "assistant", + "content": "Use three labeled boxes: Personal vault, approved MCP connection, Maple Agent. Put the permission decision at the connection and show context flowing into the answer.", + "content_type": "text", + "create_time": "2026-09-27T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-10-message-3", + "role": "user", + "content": "Keep the labels understandable to a policy audience.", + "content_type": "text", + "create_time": "2026-09-27T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-10-message-4", + "role": "assistant", + "content": "Call the scopes data categories on the slide, and show the precise scope names only in the product permission screen.", + "content_type": "text", + "create_time": "2026-09-27T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-11", + "title": "A local endpoint roadmap", + "create_time": "2026-09-28T16:00:00.000Z", + "update_time": "2026-09-28T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-11-message-1", + "role": "user", + "content": "My future preference is a direct localhost MCP connection so a desktop user can choose a local transport.", + "content_type": "text", + "create_time": "2026-09-28T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-11-message-2", + "role": "assistant", + "content": "Treat that as a separate product path to verify: endpoint binding, authorization, consent, metadata, token renewal, and revocation all need to work with the local origin.", + "content_type": "text", + "create_time": "2026-09-28T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-11-message-3", + "role": "user", + "content": "Do not imply changing a URL is all it takes.", + "content_type": "text", + "create_time": "2026-09-28T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-11-message-4", + "role": "assistant", + "content": "The demo can identify local transport as a next step while showing only the path actually tested.", + "content_type": "text", + "create_time": "2026-09-28T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-12", + "title": "Preparing the screen recording", + "create_time": "2026-09-29T16:00:00.000Z", + "update_time": "2026-09-29T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-12-message-1", + "role": "user", + "content": "What should my product recording show?", + "content_type": "text", + "create_time": "2026-09-29T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-12-message-2", + "role": "assistant", + "content": "Start with the vault contents, open Maple integrations, connect Vana with a clear permission choice, enable it for the chat, and ask a question that needs stored context.", + "content_type": "text", + "create_time": "2026-09-29T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-12-message-3", + "role": "user", + "content": "I want a real product flow, not a montage of terminal commands.", + "content_type": "text", + "create_time": "2026-09-29T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-12-message-4", + "role": "assistant", + "content": "Use clean framing and a few readable captions. Capture the actual app behavior and keep tokens or account keys out of the recording.", + "content_type": "text", + "create_time": "2026-09-29T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-13", + "title": "Synthetic data disclosure", + "create_time": "2026-09-30T16:00:00.000Z", + "update_time": "2026-09-30T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-13-message-1", + "role": "user", + "content": "How do I make the demo compelling while being honest about fake data?", + "content_type": "text", + "create_time": "2026-09-30T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-13-message-2", + "role": "assistant", + "content": "Use a cohesive fictional persona and a small visible Synthetic demo data label. The integration and tool calls can be real even when the imported records are fabricated.", + "content_type": "text", + "create_time": "2026-09-30T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-13-message-3", + "role": "user", + "content": "Keep that label visible at the start and in the closing.", + "content_type": "text", + "create_time": "2026-09-30T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-13-message-4", + "role": "assistant", + "content": "Then say: Synthetic history, live permissioned MCP reads. Avoid implying the fictional persona represents an actual account owner.", + "content_type": "text", + "create_time": "2026-09-30T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-14", + "title": "Talk draft: three ideas", + "create_time": "2026-10-01T16:00:00.000Z", + "update_time": "2026-10-01T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-14-message-1", + "role": "user", + "content": "My three ideas are user choice, context portability, and useful personalization. Is that enough?", + "content_type": "text", + "create_time": "2026-10-01T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-14-message-2", + "role": "assistant", + "content": "Yes. Map each idea to one action: choose scopes, bring prior chat context into a different assistant, and receive a preparation plan grounded in that context.", + "content_type": "text", + "create_time": "2026-10-01T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-14-message-3", + "role": "user", + "content": "My policy audience does not need token exchange details.", + "content_type": "text", + "create_time": "2026-10-01T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-14-message-4", + "role": "assistant", + "content": "Save protocol details for the appendix; use the live consent screen to make the boundary visible.", + "content_type": "text", + "create_time": "2026-10-01T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-15", + "title": "Questions I expect from the audience", + "create_time": "2026-10-02T16:00:00.000Z", + "update_time": "2026-10-02T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-15-message-1", + "role": "user", + "content": "What questions should I prepare for in DC?", + "content_type": "text", + "create_time": "2026-10-02T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-15-message-2", + "role": "assistant", + "content": "Expect: Can I revoke access? What leaves my device? Who operates the endpoint? Can an app ask for more data later? Is the answer based on real sources?", + "content_type": "text", + "create_time": "2026-10-02T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-15-message-3", + "role": "user", + "content": "For anything untested, I want to say we are still verifying it.", + "content_type": "text", + "create_time": "2026-10-02T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-15-message-4", + "role": "assistant", + "content": "Prepare a tested-versus-planned slide or note, and answer only with evidence from this build.", + "content_type": "text", + "create_time": "2026-10-02T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-16", + "title": "Source citations in the final answer", + "create_time": "2026-10-03T16:00:00.000Z", + "update_time": "2026-10-03T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-16-message-1", + "role": "user", + "content": "What should citations look like when Maple uses my own chats?", + "content_type": "text", + "create_time": "2026-10-03T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-16-message-2", + "role": "assistant", + "content": "Use the conversation title and scope, plus a record identifier when available. Make it clear that these are personal vault records rather than public research sources.", + "content_type": "text", + "create_time": "2026-10-03T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-16-message-3", + "role": "user", + "content": "Please avoid citation spam after every sentence.", + "content_type": "text", + "create_time": "2026-10-03T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-16-message-4", + "role": "assistant", + "content": "Group related claims and cite the supporting record once at the end of each short priority.", + "content_type": "text", + "create_time": "2026-10-03T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-17", + "title": "Protecting my rehearsal time", + "create_time": "2026-10-04T16:00:00.000Z", + "update_time": "2026-10-04T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-17-message-1", + "role": "user", + "content": "My strongest focus is around 9 to 11 AM. I prefer meetings after lunch. Please keep that in mind when planning my rehearsal.", + "content_type": "text", + "create_time": "2026-10-04T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-17-message-2", + "role": "assistant", + "content": "Reserve the morning for a full talk run-through and put logistics or review after lunch.", + "content_type": "text", + "create_time": "2026-10-04T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-17-message-3", + "role": "user", + "content": "I want one rehearsal out loud, not another evening rewriting slides.", + "content_type": "text", + "create_time": "2026-10-04T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-17-message-4", + "role": "assistant", + "content": "Put a complete spoken rehearsal before slide polishing, then set a stopping time for edits.", + "content_type": "text", + "create_time": "2026-10-04T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-18", + "title": "Travel to Washington", + "create_time": "2026-10-05T16:00:00.000Z", + "update_time": "2026-10-05T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-18-message-1", + "role": "user", + "content": "For the fictional schedule, I travel to Washington on October 14 and speak on October 15. I want the demo and the talk outline ready before I leave.", + "content_type": "text", + "create_time": "2026-10-05T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-18-message-2", + "role": "assistant", + "content": "Finish the core flow and a backup recording on October 13. Use October 14 for travel and one short rehearsal, then avoid making the live talk depend on a last-minute setup.", + "content_type": "text", + "create_time": "2026-10-05T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-18-message-3", + "role": "user", + "content": "Include a fallback for unstable venue Wi-Fi.", + "content_type": "text", + "create_time": "2026-10-05T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-18-message-4", + "role": "assistant", + "content": "Keep a local recording and screenshots of the tested flow, clearly identified as recorded evidence.", + "content_type": "text", + "create_time": "2026-10-05T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-19", + "title": "A recovery-aware work plan", + "create_time": "2026-10-06T16:00:00.000Z", + "update_time": "2026-10-06T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-19-message-1", + "role": "user", + "content": "I have been staying up late to polish the demo. If I choose to share wearable data, I only want a descriptive summary that helps me schedule work, not a diagnosis.", + "content_type": "text", + "create_time": "2026-10-06T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-19-message-2", + "role": "assistant", + "content": "A planning answer can summarize recorded sleep duration and your own stated preferences. It should avoid diagnoses, causal claims, or treatment advice.", + "content_type": "text", + "create_time": "2026-10-06T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-19-message-3", + "role": "user", + "content": "Please do not read health scopes by default.", + "content_type": "text", + "create_time": "2026-10-06T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-19-message-4", + "role": "assistant", + "content": "Use chat history first. Health records require a separate scope grant and a question that explicitly asks for them.", + "content_type": "text", + "create_time": "2026-10-06T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-20", + "title": "Less late-night slide polishing", + "create_time": "2026-10-07T16:00:00.000Z", + "update_time": "2026-10-07T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-20-message-1", + "role": "user", + "content": "Late-night polishing is rarely useful for me. My own goal is to stop work by 10 PM on the two nights before the talk.", + "content_type": "text", + "create_time": "2026-10-07T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-20-message-2", + "role": "assistant", + "content": "Put that goal in the preparation plan and make the final afternoon review small enough to finish before your stopping time.", + "content_type": "text", + "create_time": "2026-10-07T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-20-message-3", + "role": "user", + "content": "That is a preference, not a medical rule.", + "content_type": "text", + "create_time": "2026-10-07T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-20-message-4", + "role": "assistant", + "content": "I will present it as your stated working preference and avoid prescribing sleep targets from wearable data.", + "content_type": "text", + "create_time": "2026-10-07T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-21", + "title": "Read-only means read-only", + "create_time": "2026-10-08T16:00:00.000Z", + "update_time": "2026-10-08T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-21-message-1", + "role": "user", + "content": "The integration in this demo should read vault records. It should not post messages or modify my source accounts.", + "content_type": "text", + "create_time": "2026-10-08T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-21-message-2", + "role": "assistant", + "content": "Describe the demo as a read-only use of approved vault scopes. Check the tools available to the assistant and do not include external account actions in the recording.", + "content_type": "text", + "create_time": "2026-10-08T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-21-message-3", + "role": "user", + "content": "I also want health excluded from the initial ChatGPT demo.", + "content_type": "text", + "create_time": "2026-10-08T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-21-message-4", + "role": "assistant", + "content": "A denied Oura read under the chat-only grant can show that the boundary is real without sharing health data.", + "content_type": "text", + "create_time": "2026-10-08T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-22", + "title": "Permission revocation wording", + "create_time": "2026-10-09T16:00:00.000Z", + "update_time": "2026-10-09T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-22-message-1", + "role": "user", + "content": "Draft a line that explains revocation without promising untested behavior.", + "content_type": "text", + "create_time": "2026-10-09T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-22-message-2", + "role": "assistant", + "content": "You can choose the data categories this connection may read. Revocation is part of the permission model; the demo notes should identify which revocation behavior this build actually verifies.", + "content_type": "text", + "create_time": "2026-10-09T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-22-message-3", + "role": "user", + "content": "Good. I do not want the narration to get ahead of the proof of concept.", + "content_type": "text", + "create_time": "2026-10-09T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-22-message-4", + "role": "assistant", + "content": "Use the current tested grant checks as evidence and label further lifecycle checks as remaining work.", + "content_type": "text", + "create_time": "2026-10-09T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-23", + "title": "What goes into the backup recording", + "create_time": "2026-10-10T16:00:00.000Z", + "update_time": "2026-10-10T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-23-message-1", + "role": "user", + "content": "My backup recording should be no more than two minutes.", + "content_type": "text", + "create_time": "2026-10-10T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-23-message-2", + "role": "assistant", + "content": "Spend 20 seconds on the vault, 35 seconds on connection and permissions, 15 seconds enabling Vana in the chat, 40 seconds on a sourced answer, and 10 seconds on the closing disclosure.", + "content_type": "text", + "create_time": "2026-10-10T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-23-message-3", + "role": "user", + "content": "Use a gentle pace and keep each label readable.", + "content_type": "text", + "create_time": "2026-10-10T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-23-message-4", + "role": "assistant", + "content": "Leave a short pause when permissions and source citations appear so viewers can read them.", + "content_type": "text", + "create_time": "2026-10-10T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-24", + "title": "Final talk title", + "create_time": "2026-10-11T16:00:00.000Z", + "update_time": "2026-10-11T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-24-message-1", + "role": "user", + "content": "Pick a grounded title for the DC privacy talk.", + "content_type": "text", + "create_time": "2026-10-11T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-24-message-2", + "role": "assistant", + "content": "Your context, your choice: personal vaults meet useful AI.", + "content_type": "text", + "create_time": "2026-10-11T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-24-message-3", + "role": "user", + "content": "I like that. Do not use a futuristic title.", + "content_type": "text", + "create_time": "2026-10-11T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-24-message-4", + "role": "assistant", + "content": "Keep this title and use the live preparation plan as the concrete example under it.", + "content_type": "text", + "create_time": "2026-10-11T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-25", + "title": "Final demo priorities for October 13", + "create_time": "2026-10-12T16:00:00.000Z", + "update_time": "2026-10-12T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-25-message-1", + "role": "user", + "content": "It is October 13. My talk is in two days. What matters most today?", + "content_type": "text", + "create_time": "2026-10-12T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-25-message-2", + "role": "assistant", + "content": "Finish the live chat-only grant and sourced Maple answer; capture a readable backup recording; do one eight-minute rehearsal out loud. Leave cosmetic polish until those three work.", + "content_type": "text", + "create_time": "2026-10-12T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-25-message-3", + "role": "user", + "content": "Use my preference for a short prioritized answer when I ask Maple later.", + "content_type": "text", + "create_time": "2026-10-12T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-25-message-4", + "role": "assistant", + "content": "I will give three priorities with clear reasons and citations to the relevant saved conversations.", + "content_type": "text", + "create_time": "2026-10-12T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + }, + { + "id": "synthetic-alex-chat-26", + "title": "A health-aware variant, only after permission", + "create_time": "2026-10-13T16:00:00.000Z", + "update_time": "2026-10-13T16:09:00.000Z", + "message_count": 4, + "messages": [ + { + "id": "synthetic-alex-chat-26-message-1", + "role": "user", + "content": "For an optional second demo, I might explicitly grant Oura sleep and readiness and ask for a descriptive summary alongside my talk schedule.", + "content_type": "text", + "create_time": "2026-10-13T16:00:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-26-message-2", + "role": "assistant", + "content": "Use the exact approved scopes, report the recorded date range and averages, and state that synthetic wearable records support planning rather than medical conclusions.", + "content_type": "text", + "create_time": "2026-10-13T16:03:00.000Z", + "model": "synthetic-demo-assistant" + }, + { + "id": "synthetic-alex-chat-26-message-3", + "role": "user", + "content": "Keep the main video centered on portable ChatGPT context. The health variant is secondary.", + "content_type": "text", + "create_time": "2026-10-13T16:06:00.000Z", + "model": null + }, + { + "id": "synthetic-alex-chat-26-message-4", + "role": "assistant", + "content": "The main story stays focused on your prior chats and memories, with Oura available only as an optional separately granted example.", + "content_type": "text", + "create_time": "2026-10-13T16:09:00.000Z", + "model": "synthetic-demo-assistant" + } + ] + } + ], + "total": 26 +} diff --git a/apps/maple-agent/scripts/vana-poc/demo-data/chatgpt.memories.json b/apps/maple-agent/scripts/vana-poc/demo-data/chatgpt.memories.json new file mode 100644 index 000000000..7b9e9a860 --- /dev/null +++ b/apps/maple-agent/scripts/vana-poc/demo-data/chatgpt.memories.json @@ -0,0 +1,89 @@ +{ + "memories": [ + { + "id": "synthetic-alex-memory-01", + "content": "SYNTHETIC DEMO PERSONA: Alex Rivera is a fictional privacy and AI engineer. Every conversation, preference, and wearable metric in this vault fixture is fabricated for the Maple + Vana proof of concept.", + "created_at": "2026-09-18T16:00:00.000Z", + "updated_at": "2026-10-13T16:30:00.000Z", + "type": "memory" + }, + { + "id": "synthetic-alex-memory-02", + "content": "Alex Rivera is preparing an eight-minute Washington, DC privacy talk on October 15, 2026, titled Your context, your choice: personal vaults meet useful AI.", + "created_at": "2026-09-19T16:00:00.000Z", + "updated_at": "2026-10-13T16:30:00.000Z", + "type": "memory" + }, + { + "id": "synthetic-alex-memory-03", + "content": "Alex prefers concise answers with at most three priorities, concrete next actions, and citations to the exact personal vault records used.", + "created_at": "2026-09-20T16:00:00.000Z", + "updated_at": "2026-10-13T16:30:00.000Z", + "type": "memory" + }, + { + "id": "synthetic-alex-memory-04", + "content": "Alex prefers plain language, concrete examples, and an honest description of tested behavior over sweeping privacy claims or buzzwords.", + "created_at": "2026-09-21T16:00:00.000Z", + "updated_at": "2026-10-13T16:30:00.000Z", + "type": "memory" + }, + { + "id": "synthetic-alex-memory-05", + "content": "The talk theme is personal context that is permissioned, portable, and useful. The live example is a preparation plan grounded in prior ChatGPT conversations.", + "created_at": "2026-09-22T16:00:00.000Z", + "updated_at": "2026-10-13T16:30:00.000Z", + "type": "memory" + }, + { + "id": "synthetic-alex-memory-06", + "content": "Alex wants the initial Maple grant to include only chatgpt.conversations and chatgpt.memories. Oura sleep and readiness require a separate explicit permission choice.", + "created_at": "2026-09-23T16:00:00.000Z", + "updated_at": "2026-10-13T16:30:00.000Z", + "type": "memory" + }, + { + "id": "synthetic-alex-memory-07", + "content": "Alex has their best focus from 9 to 11 AM and prefers meetings and logistics after lunch. A spoken rehearsal comes before further slide polishing.", + "created_at": "2026-09-24T16:00:00.000Z", + "updated_at": "2026-10-13T16:30:00.000Z", + "type": "memory" + }, + { + "id": "synthetic-alex-memory-08", + "content": "Alex travels to Washington on October 14 and wants the demo, talk outline, and backup recording ready on October 13.", + "created_at": "2026-09-25T16:00:00.000Z", + "updated_at": "2026-10-13T16:30:00.000Z", + "type": "memory" + }, + { + "id": "synthetic-alex-memory-09", + "content": "Alex wants to stop work by 10 PM on the two nights before the talk. This is a personal scheduling preference, not a medical recommendation.", + "created_at": "2026-09-26T16:00:00.000Z", + "updated_at": "2026-10-13T16:30:00.000Z", + "type": "memory" + }, + { + "id": "synthetic-alex-memory-10", + "content": "Alex wants one simple architecture slide and a backup recording no longer than two minutes in case venue Wi-Fi is unreliable.", + "created_at": "2026-09-27T16:00:00.000Z", + "updated_at": "2026-10-13T16:30:00.000Z", + "type": "memory" + }, + { + "id": "synthetic-alex-memory-11", + "content": "Alex wants source citations to distinguish private vault conversation records from public research references. Retrieved facts and suggestions should be separate.", + "created_at": "2026-09-28T16:00:00.000Z", + "updated_at": "2026-10-13T16:30:00.000Z", + "type": "memory" + }, + { + "id": "synthetic-alex-memory-12", + "content": "All Oura fixtures are synthetic. Alex only wants descriptive date ranges and metric summaries if health scopes are deliberately granted; no diagnoses or treatment advice.", + "created_at": "2026-09-29T16:00:00.000Z", + "updated_at": "2026-10-13T16:30:00.000Z", + "type": "memory" + } + ], + "total": 12 +} diff --git a/apps/maple-agent/scripts/vana-poc/demo-data/fixtures.test.mjs b/apps/maple-agent/scripts/vana-poc/demo-data/fixtures.test.mjs new file mode 100644 index 000000000..63140de24 --- /dev/null +++ b/apps/maple-agent/scripts/vana-poc/demo-data/fixtures.test.mjs @@ -0,0 +1,41 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { buildFixtures, buildExportEnvelopes } from './generate.mjs'; +import { compileValidators, loadAndValidateFixtures, validateConsistency } from './validate.mjs'; + +test('all saved fixtures validate against installed Vana schemas and coherent dates/counts/durations', async () => { + const fixtures = await loadAndValidateFixtures(); + assert.deepEqual(fixtures, buildFixtures()); +}); +test('ChatGPT schemas reject missing message content and extra fields', async () => { + const validate = await compileValidators(); + const fixtures = buildFixtures(); + const chats = structuredClone(fixtures['chatgpt.conversations']); + delete chats.conversations[0].messages[0].content; + assert.equal(validate['chatgpt.conversations'](chats), false); + const memories = structuredClone(fixtures['chatgpt.memories']); + memories.memories[0].privateKey = 'this-field-must-not-be-allowed'; + assert.equal(validate['chatgpt.memories'](memories), false); +}); +test('Oura schemas reject invalid dates, out of range scores, and invalid sleep types', async () => { + const validate = await compileValidators(); const fixtures = buildFixtures(); + const readiness = structuredClone(fixtures['oura.readiness']); + readiness.days[0].score = 101; assert.equal(validate['oura.readiness'](readiness), false); + readiness.days[0].score = 80; readiness.days[0].day = '2026-02-31'; assert.equal(validate['oura.readiness'](readiness), false); + const sleep = structuredClone(fixtures['oura.sleep']); sleep.sleepPeriods[0].type = 'invented'; + assert.equal(validate['oura.sleep'](sleep), false); +}); +test('consistency validation catches plausible-looking corrupted record counts and stage totals', () => { + const fixtures = buildFixtures(); fixtures['chatgpt.conversations'].conversations[0].message_count = 6; + assert.throws(() => validateConsistency(fixtures)); + const other = buildFixtures(); other['oura.sleep'].sleepPeriods[0].deepSleepDuration += 60; + assert.throws(() => validateConsistency(other)); +}); +test('optional native export envelopes contain canonical metadata and only declared scopes', () => { + const fixtures = buildFixtures(); const envelopes = buildExportEnvelopes(fixtures); + for (const envelope of Object.values(envelopes)) { + assert.deepEqual(Object.keys(envelope).sort(), ['requestedScopes', 'timestamp', 'version', 'platform', 'exportSummary', 'errors', ...envelope.requestedScopes].sort()); + assert.equal(envelope.exportSummary.details.synthetic, true); + for (const scope of envelope.requestedScopes) assert.deepEqual(envelope[scope], fixtures[scope]); + } +}); diff --git a/apps/maple-agent/scripts/vana-poc/demo-data/generate.mjs b/apps/maple-agent/scripts/vana-poc/demo-data/generate.mjs new file mode 100644 index 000000000..6a8bea1dc --- /dev/null +++ b/apps/maple-agent/scripts/vana-poc/demo-data/generate.mjs @@ -0,0 +1,141 @@ +// Entirely fictional fixtures. No account export, patient record, or real chat was used. +import { writeFile, mkdir } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const threads = [ + ['Synthetic demo persona: Alex Rivera', 'For this fictional demo, I am Alex Rivera, a privacy and AI engineer based in San Francisco. I am preparing an eight-minute talk in Washington, DC on October 15, 2026. None of these conversations or metrics belong to a real person.', 'Understood. I will treat Alex Rivera and every record in this demo as synthetic. Your talk can show useful personalization while being clear about where the data comes from.', 'My theme is: personal context should be permissioned, portable, and useful. Keep my answers concise and cite the vault records you use.', 'I will lead with the action, use short bullets, and identify the conversation or memory that supports each personalized recommendation.'], + ['An eight-minute privacy talk', 'I have eight minutes for my DC privacy talk. How should I structure it?', 'Try four beats: a one-minute problem, a two-minute live vault demo, a three-minute explanation of permission boundaries, and a two-minute closing with questions.', 'I want one concrete outcome instead of a tour of technical features.', 'Show an assistant producing a personalized talk preparation plan from your own prior chats, with sources and an explicit grant.'], + ['My working style', 'When helping me plan, I prefer a short prioritized list over a long essay. I want at most three priorities, each with a reason.', 'I will keep plans to three priorities, make the next action concrete, and put additional detail behind a follow-up.', 'Please cite the actual vault item. Do not invent studies or make guesses sound like stored facts.', 'I will distinguish retrieved facts from suggestions and cite the record title or scope for each retrieved fact.'], + ['Portable context across assistants', 'My conversations are spread across tools. I want to carry my preferences into Maple without retyping a personal biography.', 'A personal vault can expose selected conversations and saved memories through MCP. Maple can read approved scopes and keep the resulting answer grounded in those records.', 'That is the part I want to demonstrate: my context is portable but I still choose the app and the scope.', 'Use a clear consent step and show the selected data categories before a read.'], + ['MCP permissions for a native app', 'How should the permission screen for my personal vault feel?', 'Name the app, list the exact scopes, explain the purpose, and show whether the request is read-only. A user should be able to decline or revoke the grant.', 'For the first demo I only need ChatGPT conversations and saved memories. Health metrics should stay outside that grant.', 'Keep the first grant limited to chatgpt.conversations and chatgpt.memories. Add Oura only in a separate, deliberate request if the demo needs it.'], + ['Maple demo success criteria', 'What would prove the Maple and Vana integration actually works?', 'A real MCP tool call should read a schema-valid block from the Personal Server, then Maple should answer using a fact available only in that block. A rejected ungranted scope is useful boundary evidence.', 'Avoid calling a screenshot or a mocked answer a completed integration.', 'Capture the tool call and the resulting answer separately, and label synthetic fixture content clearly.'], + ['The question for the live demo', 'I need a question that makes context portability obvious.', 'Ask: Based on my previous chats, help me prepare my eight-minute DC privacy talk in two days. Give me three priorities and cite the relevant vault records.', 'Add my preferred speaking style and the local-data caveat.', 'The answer should reflect your preference for concrete examples, concise wording, and an honest statement that relay transport can still matter even when storage is local.'], + ['Privacy language without overclaiming', 'Can I say all data stays on device if the MCP endpoint is a public HTTPS relay?', 'Be precise: local vault storage and network transport are separate claims. Describe the actual route and avoid promising that payloads never pass through a service until a direct local connection is verified.', 'I would rather say less than oversell this.', 'Use: Your vault is on your computer; you choose which context Maple may read. Then describe the demo transport accurately.'], + ['A short opening story', 'Draft a simple opening for my talk. I dislike sweeping claims and buzzwords.', 'Every assistant starts by asking me who I am. But my past conversations already contain my preferences, projects, and deadlines. What if I could bring that context with me, and decide exactly what an app is allowed to see?', 'That is close. End with a concrete promise.', 'Today I will show one permissioned connection, one useful answer, and the records that answer came from.'], + ['The single slide architecture', 'I only want one architecture slide.', 'Use three labeled boxes: Personal vault, approved MCP connection, Maple Agent. Put the permission decision at the connection and show context flowing into the answer.', 'Keep the labels understandable to a policy audience.', 'Call the scopes data categories on the slide, and show the precise scope names only in the product permission screen.'], + ['A local endpoint roadmap', 'My future preference is a direct localhost MCP connection so a desktop user can choose a local transport.', 'Treat that as a separate product path to verify: endpoint binding, authorization, consent, metadata, token renewal, and revocation all need to work with the local origin.', 'Do not imply changing a URL is all it takes.', 'The demo can identify local transport as a next step while showing only the path actually tested.'], + ['Preparing the screen recording', 'What should my product recording show?', 'Start with the vault contents, open Maple integrations, connect Vana with a clear permission choice, enable it for the chat, and ask a question that needs stored context.', 'I want a real product flow, not a montage of terminal commands.', 'Use clean framing and a few readable captions. Capture the actual app behavior and keep tokens or account keys out of the recording.'], + ['Synthetic data disclosure', 'How do I make the demo compelling while being honest about fake data?', 'Use a cohesive fictional persona and a small visible Synthetic demo data label. The integration and tool calls can be real even when the imported records are fabricated.', 'Keep that label visible at the start and in the closing.', 'Then say: Synthetic history, live permissioned MCP reads. Avoid implying the fictional persona represents an actual account owner.'], + ['Talk draft: three ideas', 'My three ideas are user choice, context portability, and useful personalization. Is that enough?', 'Yes. Map each idea to one action: choose scopes, bring prior chat context into a different assistant, and receive a preparation plan grounded in that context.', 'My policy audience does not need token exchange details.', 'Save protocol details for the appendix; use the live consent screen to make the boundary visible.'], + ['Questions I expect from the audience', 'What questions should I prepare for in DC?', 'Expect: Can I revoke access? What leaves my device? Who operates the endpoint? Can an app ask for more data later? Is the answer based on real sources?', 'For anything untested, I want to say we are still verifying it.', 'Prepare a tested-versus-planned slide or note, and answer only with evidence from this build.'], + ['Source citations in the final answer', 'What should citations look like when Maple uses my own chats?', 'Use the conversation title and scope, plus a record identifier when available. Make it clear that these are personal vault records rather than public research sources.', 'Please avoid citation spam after every sentence.', 'Group related claims and cite the supporting record once at the end of each short priority.'], + ['Protecting my rehearsal time', 'My strongest focus is around 9 to 11 AM. I prefer meetings after lunch. Please keep that in mind when planning my rehearsal.', 'Reserve the morning for a full talk run-through and put logistics or review after lunch.', 'I want one rehearsal out loud, not another evening rewriting slides.', 'Put a complete spoken rehearsal before slide polishing, then set a stopping time for edits.'], + ['Travel to Washington', 'For the fictional schedule, I travel to Washington on October 14 and speak on October 15. I want the demo and the talk outline ready before I leave.', 'Finish the core flow and a backup recording on October 13. Use October 14 for travel and one short rehearsal, then avoid making the live talk depend on a last-minute setup.', 'Include a fallback for unstable venue Wi-Fi.', 'Keep a local recording and screenshots of the tested flow, clearly identified as recorded evidence.'], + ['A recovery-aware work plan', 'I have been staying up late to polish the demo. If I choose to share wearable data, I only want a descriptive summary that helps me schedule work, not a diagnosis.', 'A planning answer can summarize recorded sleep duration and your own stated preferences. It should avoid diagnoses, causal claims, or treatment advice.', 'Please do not read health scopes by default.', 'Use chat history first. Health records require a separate scope grant and a question that explicitly asks for them.'], + ['Less late-night slide polishing', 'Late-night polishing is rarely useful for me. My own goal is to stop work by 10 PM on the two nights before the talk.', 'Put that goal in the preparation plan and make the final afternoon review small enough to finish before your stopping time.', 'That is a preference, not a medical rule.', 'I will present it as your stated working preference and avoid prescribing sleep targets from wearable data.'], + ['Read-only means read-only', 'The integration in this demo should read vault records. It should not post messages or modify my source accounts.', 'Describe the demo as a read-only use of approved vault scopes. Check the tools available to the assistant and do not include external account actions in the recording.', 'I also want health excluded from the initial ChatGPT demo.', 'A denied Oura read under the chat-only grant can show that the boundary is real without sharing health data.'], + ['Permission revocation wording', 'Draft a line that explains revocation without promising untested behavior.', 'You can choose the data categories this connection may read. Revocation is part of the permission model; the demo notes should identify which revocation behavior this build actually verifies.', 'Good. I do not want the narration to get ahead of the proof of concept.', 'Use the current tested grant checks as evidence and label further lifecycle checks as remaining work.'], + ['What goes into the backup recording', 'My backup recording should be no more than two minutes.', 'Spend 20 seconds on the vault, 35 seconds on connection and permissions, 15 seconds enabling Vana in the chat, 40 seconds on a sourced answer, and 10 seconds on the closing disclosure.', 'Use a gentle pace and keep each label readable.', 'Leave a short pause when permissions and source citations appear so viewers can read them.'], + ['Final talk title', 'Pick a grounded title for the DC privacy talk.', 'Your context, your choice: personal vaults meet useful AI.', 'I like that. Do not use a futuristic title.', 'Keep this title and use the live preparation plan as the concrete example under it.'], + ['Final demo priorities for October 13', 'It is October 13. My talk is in two days. What matters most today?', 'Finish the live chat-only grant and sourced Maple answer; capture a readable backup recording; do one eight-minute rehearsal out loud. Leave cosmetic polish until those three work.', 'Use my preference for a short prioritized answer when I ask Maple later.', 'I will give three priorities with clear reasons and citations to the relevant saved conversations.'], + ['A health-aware variant, only after permission', 'For an optional second demo, I might explicitly grant Oura sleep and readiness and ask for a descriptive summary alongside my talk schedule.', 'Use the exact approved scopes, report the recorded date range and averages, and state that synthetic wearable records support planning rather than medical conclusions.', 'Keep the main video centered on portable ChatGPT context. The health variant is secondary.', 'The main story stays focused on your prior chats and memories, with Oura available only as an optional separately granted example.'], +]; + +const memoryText = [ + 'SYNTHETIC DEMO PERSONA: Alex Rivera is a fictional privacy and AI engineer. Every conversation, preference, and wearable metric in this vault fixture is fabricated for the Maple + Vana proof of concept.', + 'Alex Rivera is preparing an eight-minute Washington, DC privacy talk on October 15, 2026, titled Your context, your choice: personal vaults meet useful AI.', + 'Alex prefers concise answers with at most three priorities, concrete next actions, and citations to the exact personal vault records used.', + 'Alex prefers plain language, concrete examples, and an honest description of tested behavior over sweeping privacy claims or buzzwords.', + 'The talk theme is personal context that is permissioned, portable, and useful. The live example is a preparation plan grounded in prior ChatGPT conversations.', + 'Alex wants the initial Maple grant to include only chatgpt.conversations and chatgpt.memories. Oura sleep and readiness require a separate explicit permission choice.', + 'Alex has their best focus from 9 to 11 AM and prefers meetings and logistics after lunch. A spoken rehearsal comes before further slide polishing.', + 'Alex travels to Washington on October 14 and wants the demo, talk outline, and backup recording ready on October 13.', + 'Alex wants to stop work by 10 PM on the two nights before the talk. This is a personal scheduling preference, not a medical recommendation.', + 'Alex wants one simple architecture slide and a backup recording no longer than two minutes in case venue Wi-Fi is unreliable.', + 'Alex wants source citations to distinguish private vault conversation records from public research references. Retrieved facts and suggestions should be separate.', + 'All Oura fixtures are synthetic. Alex only wants descriptive date ranges and metric summaries if health scopes are deliberately granted; no diagnoses or treatment advice.', +]; + +export function buildFixtures() { + const start = Date.parse('2026-09-18T16:00:00.000Z'); + const conversations = threads.map(([title, ...text], index) => { + const created = start + index * 86400000; + const id = `synthetic-alex-chat-${String(index + 1).padStart(2, '0')}`; + const messages = text.map((content, messageIndex) => ({ + id: `${id}-message-${messageIndex + 1}`, + role: messageIndex % 2 === 0 ? 'user' : 'assistant', + content, + content_type: 'text', + create_time: new Date(created + messageIndex * 180000).toISOString(), + model: messageIndex % 2 === 0 ? null : 'synthetic-demo-assistant', + })); + return { id, title, create_time: messages[0].create_time, update_time: messages.at(-1).create_time, message_count: messages.length, messages }; + }); + const memories = memoryText.map((content, index) => ({ + id: `synthetic-alex-memory-${String(index + 1).padStart(2, '0')}`, + content, + created_at: new Date(start + index * 86400000).toISOString(), + updated_at: '2026-10-13T16:30:00.000Z', + type: 'memory', + })); + // 14 nights ending Oct 13; a gentle fictional late-work pattern, no clinical claims. + const minutes = [470, 455, 462, 448, 475, 460, 450, 440, 425, 410, 402, 395, 415, 430]; + const sleepScores = [87, 85, 86, 83, 89, 86, 84, 82, 79, 77, 75, 74, 78, 81]; + const readinessScores = [86, 84, 85, 82, 88, 86, 83, 81, 79, 77, 76, 75, 78, 80]; + const dayStart = Date.parse('2026-09-30T00:00:00.000Z'); + const dailyScores = []; + const sleepPeriods = []; + const days = []; + minutes.forEach((durationMinutes, index) => { + const day = new Date(dayStart + index * 86400000).toISOString().slice(0, 10); + const total = durationMinutes * 60; + const awake = [30, 32, 28, 35, 25, 29, 31, 34, 36, 40, 41, 42, 37, 33][index] * 60; + const bedEnd = Date.parse(`${day}T14:15:00.000Z`); // 07:15 PDT + const bedtimeStart = new Date(bedEnd - (total + awake) * 1000).toISOString(); + const bedtimeEnd = new Date(bedEnd).toISOString(); + const deep = Math.round(total * 0.18 / 60) * 60; + const rem = Math.round(total * 0.24 / 60) * 60; + const light = total - deep - rem; + const score = sleepScores[index]; + dailyScores.push({ id: `synthetic-oura-sleep-score-${day}`, day, score, timestamp: bedtimeEnd, contributors: { + deep_sleep: score - 1, efficiency: Math.round(total / (total + awake) * 100), latency: 87, rem_sleep: score + 2, + restfulness: score - 3, timing: score - 2, total_sleep: score, + } }); + sleepPeriods.push({ id: `synthetic-oura-sleep-period-${day}`, day, type: 'long_sleep', bedtimeStart, bedtimeEnd, + totalSleepDuration: total, timeInBed: total + awake, deepSleepDuration: deep, lightSleepDuration: light, + remSleepDuration: rem, awakeTime: awake, efficiency: Math.round(total / (total + awake) * 100), latency: 720, + averageHeartRate: index > 7 ? 59.2 : 56.8, averageHrv: index > 7 ? 43 : 51, lowestHeartRate: index > 7 ? 52 : 49, + averageBreath: 14.1, restlessPeriods: index > 7 ? 9 : 6, + }); + const ready = readinessScores[index]; + days.push({ id: `synthetic-oura-readiness-${day}`, day, score: ready, timestamp: bedtimeEnd, + temperatureDeviation: 0.0, temperatureTrendDeviation: 0.0, contributors: { + activity_balance: 85, body_temperature: 95, hrv_balance: ready - 2, previous_day_activity: 86, + previous_night: score, recovery_index: ready - 1, resting_heart_rate: ready + 1, sleep_balance: ready, + }, + }); + }); + return { + 'chatgpt.conversations': { conversations, total: conversations.length }, + 'chatgpt.memories': { memories, total: memories.length }, + 'oura.sleep': { dailyScores, sleepPeriods }, + 'oura.readiness': { days }, + }; +} + +export function buildExportEnvelopes(fixtures) { + const timestamp = '2026-10-13T18:30:00.000Z'; + const c = fixtures['chatgpt.conversations']; + const m = fixtures['chatgpt.memories']; + const s = fixtures['oura.sleep']; + const r = fixtures['oura.readiness']; + return { + 'chatgpt-export': { requestedScopes: ['chatgpt.conversations', 'chatgpt.memories'], timestamp, version: '4.0.0-playwright', platform: 'chatgpt', + exportSummary: { count: c.total, label: 'conversations', details: { synthetic: true, persona: 'Alex Rivera (fictional)', conversations: c.total, memories: m.total, messages: c.conversations.reduce((sum, item) => sum + item.message_count, 0) } }, + errors: [], 'chatgpt.conversations': c, 'chatgpt.memories': m }, + 'oura-export': { requestedScopes: ['oura.sleep', 'oura.readiness'], timestamp, version: '3.1.2', platform: 'oura', + exportSummary: { count: r.days.length + s.dailyScores.length + s.sleepPeriods.length, label: 'days of Oura data', details: { synthetic: true, persona: 'Alex Rivera (fictional)', readiness: r.days.length, sleepScores: s.dailyScores.length, sleepPeriods: s.sleepPeriods.length, activity: 0 } }, + errors: [], 'oura.sleep': s, 'oura.readiness': r }, + }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const destination = dirname(fileURLToPath(import.meta.url)); + await mkdir(destination, { recursive: true }); + const fixtures = buildFixtures(); + for (const [name, data] of Object.entries({ ...fixtures, ...buildExportEnvelopes(fixtures) })) { + await writeFile(join(destination, `${name}.json`), `${JSON.stringify(data, null, 2)}\n`); + } + process.stdout.write('Generated synthetic fixtures: 26 chats, 12 memories, 14 days of Oura metrics.\n'); +} diff --git a/apps/maple-agent/scripts/vana-poc/demo-data/oura-export.json b/apps/maple-agent/scripts/vana-poc/demo-data/oura-export.json new file mode 100644 index 000000000..501779154 --- /dev/null +++ b/apps/maple-agent/scripts/vana-poc/demo-data/oura-export.json @@ -0,0 +1,774 @@ +{ + "requestedScopes": [ + "oura.sleep", + "oura.readiness" + ], + "timestamp": "2026-10-13T18:30:00.000Z", + "version": "3.1.2", + "platform": "oura", + "exportSummary": { + "count": 42, + "label": "days of Oura data", + "details": { + "synthetic": true, + "persona": "Alex Rivera (fictional)", + "readiness": 14, + "sleepScores": 14, + "sleepPeriods": 14, + "activity": 0 + } + }, + "errors": [], + "oura.sleep": { + "dailyScores": [ + { + "id": "synthetic-oura-sleep-score-2026-09-30", + "day": "2026-09-30", + "score": 87, + "timestamp": "2026-09-30T14:15:00.000Z", + "contributors": { + "deep_sleep": 86, + "efficiency": 94, + "latency": 87, + "rem_sleep": 89, + "restfulness": 84, + "timing": 85, + "total_sleep": 87 + } + }, + { + "id": "synthetic-oura-sleep-score-2026-10-01", + "day": "2026-10-01", + "score": 85, + "timestamp": "2026-10-01T14:15:00.000Z", + "contributors": { + "deep_sleep": 84, + "efficiency": 93, + "latency": 87, + "rem_sleep": 87, + "restfulness": 82, + "timing": 83, + "total_sleep": 85 + } + }, + { + "id": "synthetic-oura-sleep-score-2026-10-02", + "day": "2026-10-02", + "score": 86, + "timestamp": "2026-10-02T14:15:00.000Z", + "contributors": { + "deep_sleep": 85, + "efficiency": 94, + "latency": 87, + "rem_sleep": 88, + "restfulness": 83, + "timing": 84, + "total_sleep": 86 + } + }, + { + "id": "synthetic-oura-sleep-score-2026-10-03", + "day": "2026-10-03", + "score": 83, + "timestamp": "2026-10-03T14:15:00.000Z", + "contributors": { + "deep_sleep": 82, + "efficiency": 93, + "latency": 87, + "rem_sleep": 85, + "restfulness": 80, + "timing": 81, + "total_sleep": 83 + } + }, + { + "id": "synthetic-oura-sleep-score-2026-10-04", + "day": "2026-10-04", + "score": 89, + "timestamp": "2026-10-04T14:15:00.000Z", + "contributors": { + "deep_sleep": 88, + "efficiency": 95, + "latency": 87, + "rem_sleep": 91, + "restfulness": 86, + "timing": 87, + "total_sleep": 89 + } + }, + { + "id": "synthetic-oura-sleep-score-2026-10-05", + "day": "2026-10-05", + "score": 86, + "timestamp": "2026-10-05T14:15:00.000Z", + "contributors": { + "deep_sleep": 85, + "efficiency": 94, + "latency": 87, + "rem_sleep": 88, + "restfulness": 83, + "timing": 84, + "total_sleep": 86 + } + }, + { + "id": "synthetic-oura-sleep-score-2026-10-06", + "day": "2026-10-06", + "score": 84, + "timestamp": "2026-10-06T14:15:00.000Z", + "contributors": { + "deep_sleep": 83, + "efficiency": 94, + "latency": 87, + "rem_sleep": 86, + "restfulness": 81, + "timing": 82, + "total_sleep": 84 + } + }, + { + "id": "synthetic-oura-sleep-score-2026-10-07", + "day": "2026-10-07", + "score": 82, + "timestamp": "2026-10-07T14:15:00.000Z", + "contributors": { + "deep_sleep": 81, + "efficiency": 93, + "latency": 87, + "rem_sleep": 84, + "restfulness": 79, + "timing": 80, + "total_sleep": 82 + } + }, + { + "id": "synthetic-oura-sleep-score-2026-10-08", + "day": "2026-10-08", + "score": 79, + "timestamp": "2026-10-08T14:15:00.000Z", + "contributors": { + "deep_sleep": 78, + "efficiency": 92, + "latency": 87, + "rem_sleep": 81, + "restfulness": 76, + "timing": 77, + "total_sleep": 79 + } + }, + { + "id": "synthetic-oura-sleep-score-2026-10-09", + "day": "2026-10-09", + "score": 77, + "timestamp": "2026-10-09T14:15:00.000Z", + "contributors": { + "deep_sleep": 76, + "efficiency": 91, + "latency": 87, + "rem_sleep": 79, + "restfulness": 74, + "timing": 75, + "total_sleep": 77 + } + }, + { + "id": "synthetic-oura-sleep-score-2026-10-10", + "day": "2026-10-10", + "score": 75, + "timestamp": "2026-10-10T14:15:00.000Z", + "contributors": { + "deep_sleep": 74, + "efficiency": 91, + "latency": 87, + "rem_sleep": 77, + "restfulness": 72, + "timing": 73, + "total_sleep": 75 + } + }, + { + "id": "synthetic-oura-sleep-score-2026-10-11", + "day": "2026-10-11", + "score": 74, + "timestamp": "2026-10-11T14:15:00.000Z", + "contributors": { + "deep_sleep": 73, + "efficiency": 90, + "latency": 87, + "rem_sleep": 76, + "restfulness": 71, + "timing": 72, + "total_sleep": 74 + } + }, + { + "id": "synthetic-oura-sleep-score-2026-10-12", + "day": "2026-10-12", + "score": 78, + "timestamp": "2026-10-12T14:15:00.000Z", + "contributors": { + "deep_sleep": 77, + "efficiency": 92, + "latency": 87, + "rem_sleep": 80, + "restfulness": 75, + "timing": 76, + "total_sleep": 78 + } + }, + { + "id": "synthetic-oura-sleep-score-2026-10-13", + "day": "2026-10-13", + "score": 81, + "timestamp": "2026-10-13T14:15:00.000Z", + "contributors": { + "deep_sleep": 80, + "efficiency": 93, + "latency": 87, + "rem_sleep": 83, + "restfulness": 78, + "timing": 79, + "total_sleep": 81 + } + } + ], + "sleepPeriods": [ + { + "id": "synthetic-oura-sleep-period-2026-09-30", + "day": "2026-09-30", + "type": "long_sleep", + "bedtimeStart": "2026-09-30T05:55:00.000Z", + "bedtimeEnd": "2026-09-30T14:15:00.000Z", + "totalSleepDuration": 28200, + "timeInBed": 30000, + "deepSleepDuration": 5100, + "lightSleepDuration": 16320, + "remSleepDuration": 6780, + "awakeTime": 1800, + "efficiency": 94, + "latency": 720, + "averageHeartRate": 56.8, + "averageHrv": 51, + "lowestHeartRate": 49, + "averageBreath": 14.1, + "restlessPeriods": 6 + }, + { + "id": "synthetic-oura-sleep-period-2026-10-01", + "day": "2026-10-01", + "type": "long_sleep", + "bedtimeStart": "2026-10-01T06:08:00.000Z", + "bedtimeEnd": "2026-10-01T14:15:00.000Z", + "totalSleepDuration": 27300, + "timeInBed": 29220, + "deepSleepDuration": 4920, + "lightSleepDuration": 15840, + "remSleepDuration": 6540, + "awakeTime": 1920, + "efficiency": 93, + "latency": 720, + "averageHeartRate": 56.8, + "averageHrv": 51, + "lowestHeartRate": 49, + "averageBreath": 14.1, + "restlessPeriods": 6 + }, + { + "id": "synthetic-oura-sleep-period-2026-10-02", + "day": "2026-10-02", + "type": "long_sleep", + "bedtimeStart": "2026-10-02T06:05:00.000Z", + "bedtimeEnd": "2026-10-02T14:15:00.000Z", + "totalSleepDuration": 27720, + "timeInBed": 29400, + "deepSleepDuration": 4980, + "lightSleepDuration": 16080, + "remSleepDuration": 6660, + "awakeTime": 1680, + "efficiency": 94, + "latency": 720, + "averageHeartRate": 56.8, + "averageHrv": 51, + "lowestHeartRate": 49, + "averageBreath": 14.1, + "restlessPeriods": 6 + }, + { + "id": "synthetic-oura-sleep-period-2026-10-03", + "day": "2026-10-03", + "type": "long_sleep", + "bedtimeStart": "2026-10-03T06:12:00.000Z", + "bedtimeEnd": "2026-10-03T14:15:00.000Z", + "totalSleepDuration": 26880, + "timeInBed": 28980, + "deepSleepDuration": 4860, + "lightSleepDuration": 15540, + "remSleepDuration": 6480, + "awakeTime": 2100, + "efficiency": 93, + "latency": 720, + "averageHeartRate": 56.8, + "averageHrv": 51, + "lowestHeartRate": 49, + "averageBreath": 14.1, + "restlessPeriods": 6 + }, + { + "id": "synthetic-oura-sleep-period-2026-10-04", + "day": "2026-10-04", + "type": "long_sleep", + "bedtimeStart": "2026-10-04T05:55:00.000Z", + "bedtimeEnd": "2026-10-04T14:15:00.000Z", + "totalSleepDuration": 28500, + "timeInBed": 30000, + "deepSleepDuration": 5160, + "lightSleepDuration": 16500, + "remSleepDuration": 6840, + "awakeTime": 1500, + "efficiency": 95, + "latency": 720, + "averageHeartRate": 56.8, + "averageHrv": 51, + "lowestHeartRate": 49, + "averageBreath": 14.1, + "restlessPeriods": 6 + }, + { + "id": "synthetic-oura-sleep-period-2026-10-05", + "day": "2026-10-05", + "type": "long_sleep", + "bedtimeStart": "2026-10-05T06:06:00.000Z", + "bedtimeEnd": "2026-10-05T14:15:00.000Z", + "totalSleepDuration": 27600, + "timeInBed": 29340, + "deepSleepDuration": 4980, + "lightSleepDuration": 16020, + "remSleepDuration": 6600, + "awakeTime": 1740, + "efficiency": 94, + "latency": 720, + "averageHeartRate": 56.8, + "averageHrv": 51, + "lowestHeartRate": 49, + "averageBreath": 14.1, + "restlessPeriods": 6 + }, + { + "id": "synthetic-oura-sleep-period-2026-10-06", + "day": "2026-10-06", + "type": "long_sleep", + "bedtimeStart": "2026-10-06T06:14:00.000Z", + "bedtimeEnd": "2026-10-06T14:15:00.000Z", + "totalSleepDuration": 27000, + "timeInBed": 28860, + "deepSleepDuration": 4860, + "lightSleepDuration": 15660, + "remSleepDuration": 6480, + "awakeTime": 1860, + "efficiency": 94, + "latency": 720, + "averageHeartRate": 56.8, + "averageHrv": 51, + "lowestHeartRate": 49, + "averageBreath": 14.1, + "restlessPeriods": 6 + }, + { + "id": "synthetic-oura-sleep-period-2026-10-07", + "day": "2026-10-07", + "type": "long_sleep", + "bedtimeStart": "2026-10-07T06:21:00.000Z", + "bedtimeEnd": "2026-10-07T14:15:00.000Z", + "totalSleepDuration": 26400, + "timeInBed": 28440, + "deepSleepDuration": 4740, + "lightSleepDuration": 15300, + "remSleepDuration": 6360, + "awakeTime": 2040, + "efficiency": 93, + "latency": 720, + "averageHeartRate": 56.8, + "averageHrv": 51, + "lowestHeartRate": 49, + "averageBreath": 14.1, + "restlessPeriods": 6 + }, + { + "id": "synthetic-oura-sleep-period-2026-10-08", + "day": "2026-10-08", + "type": "long_sleep", + "bedtimeStart": "2026-10-08T06:34:00.000Z", + "bedtimeEnd": "2026-10-08T14:15:00.000Z", + "totalSleepDuration": 25500, + "timeInBed": 27660, + "deepSleepDuration": 4620, + "lightSleepDuration": 14760, + "remSleepDuration": 6120, + "awakeTime": 2160, + "efficiency": 92, + "latency": 720, + "averageHeartRate": 59.2, + "averageHrv": 43, + "lowestHeartRate": 52, + "averageBreath": 14.1, + "restlessPeriods": 9 + }, + { + "id": "synthetic-oura-sleep-period-2026-10-09", + "day": "2026-10-09", + "type": "long_sleep", + "bedtimeStart": "2026-10-09T06:45:00.000Z", + "bedtimeEnd": "2026-10-09T14:15:00.000Z", + "totalSleepDuration": 24600, + "timeInBed": 27000, + "deepSleepDuration": 4440, + "lightSleepDuration": 14280, + "remSleepDuration": 5880, + "awakeTime": 2400, + "efficiency": 91, + "latency": 720, + "averageHeartRate": 59.2, + "averageHrv": 43, + "lowestHeartRate": 52, + "averageBreath": 14.1, + "restlessPeriods": 9 + }, + { + "id": "synthetic-oura-sleep-period-2026-10-10", + "day": "2026-10-10", + "type": "long_sleep", + "bedtimeStart": "2026-10-10T06:52:00.000Z", + "bedtimeEnd": "2026-10-10T14:15:00.000Z", + "totalSleepDuration": 24120, + "timeInBed": 26580, + "deepSleepDuration": 4320, + "lightSleepDuration": 14040, + "remSleepDuration": 5760, + "awakeTime": 2460, + "efficiency": 91, + "latency": 720, + "averageHeartRate": 59.2, + "averageHrv": 43, + "lowestHeartRate": 52, + "averageBreath": 14.1, + "restlessPeriods": 9 + }, + { + "id": "synthetic-oura-sleep-period-2026-10-11", + "day": "2026-10-11", + "type": "long_sleep", + "bedtimeStart": "2026-10-11T06:58:00.000Z", + "bedtimeEnd": "2026-10-11T14:15:00.000Z", + "totalSleepDuration": 23700, + "timeInBed": 26220, + "deepSleepDuration": 4260, + "lightSleepDuration": 13740, + "remSleepDuration": 5700, + "awakeTime": 2520, + "efficiency": 90, + "latency": 720, + "averageHeartRate": 59.2, + "averageHrv": 43, + "lowestHeartRate": 52, + "averageBreath": 14.1, + "restlessPeriods": 9 + }, + { + "id": "synthetic-oura-sleep-period-2026-10-12", + "day": "2026-10-12", + "type": "long_sleep", + "bedtimeStart": "2026-10-12T06:43:00.000Z", + "bedtimeEnd": "2026-10-12T14:15:00.000Z", + "totalSleepDuration": 24900, + "timeInBed": 27120, + "deepSleepDuration": 4500, + "lightSleepDuration": 14400, + "remSleepDuration": 6000, + "awakeTime": 2220, + "efficiency": 92, + "latency": 720, + "averageHeartRate": 59.2, + "averageHrv": 43, + "lowestHeartRate": 52, + "averageBreath": 14.1, + "restlessPeriods": 9 + }, + { + "id": "synthetic-oura-sleep-period-2026-10-13", + "day": "2026-10-13", + "type": "long_sleep", + "bedtimeStart": "2026-10-13T06:32:00.000Z", + "bedtimeEnd": "2026-10-13T14:15:00.000Z", + "totalSleepDuration": 25800, + "timeInBed": 27780, + "deepSleepDuration": 4620, + "lightSleepDuration": 15000, + "remSleepDuration": 6180, + "awakeTime": 1980, + "efficiency": 93, + "latency": 720, + "averageHeartRate": 59.2, + "averageHrv": 43, + "lowestHeartRate": 52, + "averageBreath": 14.1, + "restlessPeriods": 9 + } + ] + }, + "oura.readiness": { + "days": [ + { + "id": "synthetic-oura-readiness-2026-09-30", + "day": "2026-09-30", + "score": 86, + "timestamp": "2026-09-30T14:15:00.000Z", + "temperatureDeviation": 0, + "temperatureTrendDeviation": 0, + "contributors": { + "activity_balance": 85, + "body_temperature": 95, + "hrv_balance": 84, + "previous_day_activity": 86, + "previous_night": 87, + "recovery_index": 85, + "resting_heart_rate": 87, + "sleep_balance": 86 + } + }, + { + "id": "synthetic-oura-readiness-2026-10-01", + "day": "2026-10-01", + "score": 84, + "timestamp": "2026-10-01T14:15:00.000Z", + "temperatureDeviation": 0, + "temperatureTrendDeviation": 0, + "contributors": { + "activity_balance": 85, + "body_temperature": 95, + "hrv_balance": 82, + "previous_day_activity": 86, + "previous_night": 85, + "recovery_index": 83, + "resting_heart_rate": 85, + "sleep_balance": 84 + } + }, + { + "id": "synthetic-oura-readiness-2026-10-02", + "day": "2026-10-02", + "score": 85, + "timestamp": "2026-10-02T14:15:00.000Z", + "temperatureDeviation": 0, + "temperatureTrendDeviation": 0, + "contributors": { + "activity_balance": 85, + "body_temperature": 95, + "hrv_balance": 83, + "previous_day_activity": 86, + "previous_night": 86, + "recovery_index": 84, + "resting_heart_rate": 86, + "sleep_balance": 85 + } + }, + { + "id": "synthetic-oura-readiness-2026-10-03", + "day": "2026-10-03", + "score": 82, + "timestamp": "2026-10-03T14:15:00.000Z", + "temperatureDeviation": 0, + "temperatureTrendDeviation": 0, + "contributors": { + "activity_balance": 85, + "body_temperature": 95, + "hrv_balance": 80, + "previous_day_activity": 86, + "previous_night": 83, + "recovery_index": 81, + "resting_heart_rate": 83, + "sleep_balance": 82 + } + }, + { + "id": "synthetic-oura-readiness-2026-10-04", + "day": "2026-10-04", + "score": 88, + "timestamp": "2026-10-04T14:15:00.000Z", + "temperatureDeviation": 0, + "temperatureTrendDeviation": 0, + "contributors": { + "activity_balance": 85, + "body_temperature": 95, + "hrv_balance": 86, + "previous_day_activity": 86, + "previous_night": 89, + "recovery_index": 87, + "resting_heart_rate": 89, + "sleep_balance": 88 + } + }, + { + "id": "synthetic-oura-readiness-2026-10-05", + "day": "2026-10-05", + "score": 86, + "timestamp": "2026-10-05T14:15:00.000Z", + "temperatureDeviation": 0, + "temperatureTrendDeviation": 0, + "contributors": { + "activity_balance": 85, + "body_temperature": 95, + "hrv_balance": 84, + "previous_day_activity": 86, + "previous_night": 86, + "recovery_index": 85, + "resting_heart_rate": 87, + "sleep_balance": 86 + } + }, + { + "id": "synthetic-oura-readiness-2026-10-06", + "day": "2026-10-06", + "score": 83, + "timestamp": "2026-10-06T14:15:00.000Z", + "temperatureDeviation": 0, + "temperatureTrendDeviation": 0, + "contributors": { + "activity_balance": 85, + "body_temperature": 95, + "hrv_balance": 81, + "previous_day_activity": 86, + "previous_night": 84, + "recovery_index": 82, + "resting_heart_rate": 84, + "sleep_balance": 83 + } + }, + { + "id": "synthetic-oura-readiness-2026-10-07", + "day": "2026-10-07", + "score": 81, + "timestamp": "2026-10-07T14:15:00.000Z", + "temperatureDeviation": 0, + "temperatureTrendDeviation": 0, + "contributors": { + "activity_balance": 85, + "body_temperature": 95, + "hrv_balance": 79, + "previous_day_activity": 86, + "previous_night": 82, + "recovery_index": 80, + "resting_heart_rate": 82, + "sleep_balance": 81 + } + }, + { + "id": "synthetic-oura-readiness-2026-10-08", + "day": "2026-10-08", + "score": 79, + "timestamp": "2026-10-08T14:15:00.000Z", + "temperatureDeviation": 0, + "temperatureTrendDeviation": 0, + "contributors": { + "activity_balance": 85, + "body_temperature": 95, + "hrv_balance": 77, + "previous_day_activity": 86, + "previous_night": 79, + "recovery_index": 78, + "resting_heart_rate": 80, + "sleep_balance": 79 + } + }, + { + "id": "synthetic-oura-readiness-2026-10-09", + "day": "2026-10-09", + "score": 77, + "timestamp": "2026-10-09T14:15:00.000Z", + "temperatureDeviation": 0, + "temperatureTrendDeviation": 0, + "contributors": { + "activity_balance": 85, + "body_temperature": 95, + "hrv_balance": 75, + "previous_day_activity": 86, + "previous_night": 77, + "recovery_index": 76, + "resting_heart_rate": 78, + "sleep_balance": 77 + } + }, + { + "id": "synthetic-oura-readiness-2026-10-10", + "day": "2026-10-10", + "score": 76, + "timestamp": "2026-10-10T14:15:00.000Z", + "temperatureDeviation": 0, + "temperatureTrendDeviation": 0, + "contributors": { + "activity_balance": 85, + "body_temperature": 95, + "hrv_balance": 74, + "previous_day_activity": 86, + "previous_night": 75, + "recovery_index": 75, + "resting_heart_rate": 77, + "sleep_balance": 76 + } + }, + { + "id": "synthetic-oura-readiness-2026-10-11", + "day": "2026-10-11", + "score": 75, + "timestamp": "2026-10-11T14:15:00.000Z", + "temperatureDeviation": 0, + "temperatureTrendDeviation": 0, + "contributors": { + "activity_balance": 85, + "body_temperature": 95, + "hrv_balance": 73, + "previous_day_activity": 86, + "previous_night": 74, + "recovery_index": 74, + "resting_heart_rate": 76, + "sleep_balance": 75 + } + }, + { + "id": "synthetic-oura-readiness-2026-10-12", + "day": "2026-10-12", + "score": 78, + "timestamp": "2026-10-12T14:15:00.000Z", + "temperatureDeviation": 0, + "temperatureTrendDeviation": 0, + "contributors": { + "activity_balance": 85, + "body_temperature": 95, + "hrv_balance": 76, + "previous_day_activity": 86, + "previous_night": 78, + "recovery_index": 77, + "resting_heart_rate": 79, + "sleep_balance": 78 + } + }, + { + "id": "synthetic-oura-readiness-2026-10-13", + "day": "2026-10-13", + "score": 80, + "timestamp": "2026-10-13T14:15:00.000Z", + "temperatureDeviation": 0, + "temperatureTrendDeviation": 0, + "contributors": { + "activity_balance": 85, + "body_temperature": 95, + "hrv_balance": 78, + "previous_day_activity": 86, + "previous_night": 81, + "recovery_index": 79, + "resting_heart_rate": 81, + "sleep_balance": 80 + } + } + ] + } +} diff --git a/apps/maple-agent/scripts/vana-poc/demo-data/oura.readiness.json b/apps/maple-agent/scripts/vana-poc/demo-data/oura.readiness.json new file mode 100644 index 000000000..856bb5060 --- /dev/null +++ b/apps/maple-agent/scripts/vana-poc/demo-data/oura.readiness.json @@ -0,0 +1,256 @@ +{ + "days": [ + { + "id": "synthetic-oura-readiness-2026-09-30", + "day": "2026-09-30", + "score": 86, + "timestamp": "2026-09-30T14:15:00.000Z", + "temperatureDeviation": 0, + "temperatureTrendDeviation": 0, + "contributors": { + "activity_balance": 85, + "body_temperature": 95, + "hrv_balance": 84, + "previous_day_activity": 86, + "previous_night": 87, + "recovery_index": 85, + "resting_heart_rate": 87, + "sleep_balance": 86 + } + }, + { + "id": "synthetic-oura-readiness-2026-10-01", + "day": "2026-10-01", + "score": 84, + "timestamp": "2026-10-01T14:15:00.000Z", + "temperatureDeviation": 0, + "temperatureTrendDeviation": 0, + "contributors": { + "activity_balance": 85, + "body_temperature": 95, + "hrv_balance": 82, + "previous_day_activity": 86, + "previous_night": 85, + "recovery_index": 83, + "resting_heart_rate": 85, + "sleep_balance": 84 + } + }, + { + "id": "synthetic-oura-readiness-2026-10-02", + "day": "2026-10-02", + "score": 85, + "timestamp": "2026-10-02T14:15:00.000Z", + "temperatureDeviation": 0, + "temperatureTrendDeviation": 0, + "contributors": { + "activity_balance": 85, + "body_temperature": 95, + "hrv_balance": 83, + "previous_day_activity": 86, + "previous_night": 86, + "recovery_index": 84, + "resting_heart_rate": 86, + "sleep_balance": 85 + } + }, + { + "id": "synthetic-oura-readiness-2026-10-03", + "day": "2026-10-03", + "score": 82, + "timestamp": "2026-10-03T14:15:00.000Z", + "temperatureDeviation": 0, + "temperatureTrendDeviation": 0, + "contributors": { + "activity_balance": 85, + "body_temperature": 95, + "hrv_balance": 80, + "previous_day_activity": 86, + "previous_night": 83, + "recovery_index": 81, + "resting_heart_rate": 83, + "sleep_balance": 82 + } + }, + { + "id": "synthetic-oura-readiness-2026-10-04", + "day": "2026-10-04", + "score": 88, + "timestamp": "2026-10-04T14:15:00.000Z", + "temperatureDeviation": 0, + "temperatureTrendDeviation": 0, + "contributors": { + "activity_balance": 85, + "body_temperature": 95, + "hrv_balance": 86, + "previous_day_activity": 86, + "previous_night": 89, + "recovery_index": 87, + "resting_heart_rate": 89, + "sleep_balance": 88 + } + }, + { + "id": "synthetic-oura-readiness-2026-10-05", + "day": "2026-10-05", + "score": 86, + "timestamp": "2026-10-05T14:15:00.000Z", + "temperatureDeviation": 0, + "temperatureTrendDeviation": 0, + "contributors": { + "activity_balance": 85, + "body_temperature": 95, + "hrv_balance": 84, + "previous_day_activity": 86, + "previous_night": 86, + "recovery_index": 85, + "resting_heart_rate": 87, + "sleep_balance": 86 + } + }, + { + "id": "synthetic-oura-readiness-2026-10-06", + "day": "2026-10-06", + "score": 83, + "timestamp": "2026-10-06T14:15:00.000Z", + "temperatureDeviation": 0, + "temperatureTrendDeviation": 0, + "contributors": { + "activity_balance": 85, + "body_temperature": 95, + "hrv_balance": 81, + "previous_day_activity": 86, + "previous_night": 84, + "recovery_index": 82, + "resting_heart_rate": 84, + "sleep_balance": 83 + } + }, + { + "id": "synthetic-oura-readiness-2026-10-07", + "day": "2026-10-07", + "score": 81, + "timestamp": "2026-10-07T14:15:00.000Z", + "temperatureDeviation": 0, + "temperatureTrendDeviation": 0, + "contributors": { + "activity_balance": 85, + "body_temperature": 95, + "hrv_balance": 79, + "previous_day_activity": 86, + "previous_night": 82, + "recovery_index": 80, + "resting_heart_rate": 82, + "sleep_balance": 81 + } + }, + { + "id": "synthetic-oura-readiness-2026-10-08", + "day": "2026-10-08", + "score": 79, + "timestamp": "2026-10-08T14:15:00.000Z", + "temperatureDeviation": 0, + "temperatureTrendDeviation": 0, + "contributors": { + "activity_balance": 85, + "body_temperature": 95, + "hrv_balance": 77, + "previous_day_activity": 86, + "previous_night": 79, + "recovery_index": 78, + "resting_heart_rate": 80, + "sleep_balance": 79 + } + }, + { + "id": "synthetic-oura-readiness-2026-10-09", + "day": "2026-10-09", + "score": 77, + "timestamp": "2026-10-09T14:15:00.000Z", + "temperatureDeviation": 0, + "temperatureTrendDeviation": 0, + "contributors": { + "activity_balance": 85, + "body_temperature": 95, + "hrv_balance": 75, + "previous_day_activity": 86, + "previous_night": 77, + "recovery_index": 76, + "resting_heart_rate": 78, + "sleep_balance": 77 + } + }, + { + "id": "synthetic-oura-readiness-2026-10-10", + "day": "2026-10-10", + "score": 76, + "timestamp": "2026-10-10T14:15:00.000Z", + "temperatureDeviation": 0, + "temperatureTrendDeviation": 0, + "contributors": { + "activity_balance": 85, + "body_temperature": 95, + "hrv_balance": 74, + "previous_day_activity": 86, + "previous_night": 75, + "recovery_index": 75, + "resting_heart_rate": 77, + "sleep_balance": 76 + } + }, + { + "id": "synthetic-oura-readiness-2026-10-11", + "day": "2026-10-11", + "score": 75, + "timestamp": "2026-10-11T14:15:00.000Z", + "temperatureDeviation": 0, + "temperatureTrendDeviation": 0, + "contributors": { + "activity_balance": 85, + "body_temperature": 95, + "hrv_balance": 73, + "previous_day_activity": 86, + "previous_night": 74, + "recovery_index": 74, + "resting_heart_rate": 76, + "sleep_balance": 75 + } + }, + { + "id": "synthetic-oura-readiness-2026-10-12", + "day": "2026-10-12", + "score": 78, + "timestamp": "2026-10-12T14:15:00.000Z", + "temperatureDeviation": 0, + "temperatureTrendDeviation": 0, + "contributors": { + "activity_balance": 85, + "body_temperature": 95, + "hrv_balance": 76, + "previous_day_activity": 86, + "previous_night": 78, + "recovery_index": 77, + "resting_heart_rate": 79, + "sleep_balance": 78 + } + }, + { + "id": "synthetic-oura-readiness-2026-10-13", + "day": "2026-10-13", + "score": 80, + "timestamp": "2026-10-13T14:15:00.000Z", + "temperatureDeviation": 0, + "temperatureTrendDeviation": 0, + "contributors": { + "activity_balance": 85, + "body_temperature": 95, + "hrv_balance": 78, + "previous_day_activity": 86, + "previous_night": 81, + "recovery_index": 79, + "resting_heart_rate": 81, + "sleep_balance": 80 + } + } + ] +} diff --git a/apps/maple-agent/scripts/vana-poc/demo-data/oura.sleep.json b/apps/maple-agent/scripts/vana-poc/demo-data/oura.sleep.json new file mode 100644 index 000000000..e8647e5cf --- /dev/null +++ b/apps/maple-agent/scripts/vana-poc/demo-data/oura.sleep.json @@ -0,0 +1,496 @@ +{ + "dailyScores": [ + { + "id": "synthetic-oura-sleep-score-2026-09-30", + "day": "2026-09-30", + "score": 87, + "timestamp": "2026-09-30T14:15:00.000Z", + "contributors": { + "deep_sleep": 86, + "efficiency": 94, + "latency": 87, + "rem_sleep": 89, + "restfulness": 84, + "timing": 85, + "total_sleep": 87 + } + }, + { + "id": "synthetic-oura-sleep-score-2026-10-01", + "day": "2026-10-01", + "score": 85, + "timestamp": "2026-10-01T14:15:00.000Z", + "contributors": { + "deep_sleep": 84, + "efficiency": 93, + "latency": 87, + "rem_sleep": 87, + "restfulness": 82, + "timing": 83, + "total_sleep": 85 + } + }, + { + "id": "synthetic-oura-sleep-score-2026-10-02", + "day": "2026-10-02", + "score": 86, + "timestamp": "2026-10-02T14:15:00.000Z", + "contributors": { + "deep_sleep": 85, + "efficiency": 94, + "latency": 87, + "rem_sleep": 88, + "restfulness": 83, + "timing": 84, + "total_sleep": 86 + } + }, + { + "id": "synthetic-oura-sleep-score-2026-10-03", + "day": "2026-10-03", + "score": 83, + "timestamp": "2026-10-03T14:15:00.000Z", + "contributors": { + "deep_sleep": 82, + "efficiency": 93, + "latency": 87, + "rem_sleep": 85, + "restfulness": 80, + "timing": 81, + "total_sleep": 83 + } + }, + { + "id": "synthetic-oura-sleep-score-2026-10-04", + "day": "2026-10-04", + "score": 89, + "timestamp": "2026-10-04T14:15:00.000Z", + "contributors": { + "deep_sleep": 88, + "efficiency": 95, + "latency": 87, + "rem_sleep": 91, + "restfulness": 86, + "timing": 87, + "total_sleep": 89 + } + }, + { + "id": "synthetic-oura-sleep-score-2026-10-05", + "day": "2026-10-05", + "score": 86, + "timestamp": "2026-10-05T14:15:00.000Z", + "contributors": { + "deep_sleep": 85, + "efficiency": 94, + "latency": 87, + "rem_sleep": 88, + "restfulness": 83, + "timing": 84, + "total_sleep": 86 + } + }, + { + "id": "synthetic-oura-sleep-score-2026-10-06", + "day": "2026-10-06", + "score": 84, + "timestamp": "2026-10-06T14:15:00.000Z", + "contributors": { + "deep_sleep": 83, + "efficiency": 94, + "latency": 87, + "rem_sleep": 86, + "restfulness": 81, + "timing": 82, + "total_sleep": 84 + } + }, + { + "id": "synthetic-oura-sleep-score-2026-10-07", + "day": "2026-10-07", + "score": 82, + "timestamp": "2026-10-07T14:15:00.000Z", + "contributors": { + "deep_sleep": 81, + "efficiency": 93, + "latency": 87, + "rem_sleep": 84, + "restfulness": 79, + "timing": 80, + "total_sleep": 82 + } + }, + { + "id": "synthetic-oura-sleep-score-2026-10-08", + "day": "2026-10-08", + "score": 79, + "timestamp": "2026-10-08T14:15:00.000Z", + "contributors": { + "deep_sleep": 78, + "efficiency": 92, + "latency": 87, + "rem_sleep": 81, + "restfulness": 76, + "timing": 77, + "total_sleep": 79 + } + }, + { + "id": "synthetic-oura-sleep-score-2026-10-09", + "day": "2026-10-09", + "score": 77, + "timestamp": "2026-10-09T14:15:00.000Z", + "contributors": { + "deep_sleep": 76, + "efficiency": 91, + "latency": 87, + "rem_sleep": 79, + "restfulness": 74, + "timing": 75, + "total_sleep": 77 + } + }, + { + "id": "synthetic-oura-sleep-score-2026-10-10", + "day": "2026-10-10", + "score": 75, + "timestamp": "2026-10-10T14:15:00.000Z", + "contributors": { + "deep_sleep": 74, + "efficiency": 91, + "latency": 87, + "rem_sleep": 77, + "restfulness": 72, + "timing": 73, + "total_sleep": 75 + } + }, + { + "id": "synthetic-oura-sleep-score-2026-10-11", + "day": "2026-10-11", + "score": 74, + "timestamp": "2026-10-11T14:15:00.000Z", + "contributors": { + "deep_sleep": 73, + "efficiency": 90, + "latency": 87, + "rem_sleep": 76, + "restfulness": 71, + "timing": 72, + "total_sleep": 74 + } + }, + { + "id": "synthetic-oura-sleep-score-2026-10-12", + "day": "2026-10-12", + "score": 78, + "timestamp": "2026-10-12T14:15:00.000Z", + "contributors": { + "deep_sleep": 77, + "efficiency": 92, + "latency": 87, + "rem_sleep": 80, + "restfulness": 75, + "timing": 76, + "total_sleep": 78 + } + }, + { + "id": "synthetic-oura-sleep-score-2026-10-13", + "day": "2026-10-13", + "score": 81, + "timestamp": "2026-10-13T14:15:00.000Z", + "contributors": { + "deep_sleep": 80, + "efficiency": 93, + "latency": 87, + "rem_sleep": 83, + "restfulness": 78, + "timing": 79, + "total_sleep": 81 + } + } + ], + "sleepPeriods": [ + { + "id": "synthetic-oura-sleep-period-2026-09-30", + "day": "2026-09-30", + "type": "long_sleep", + "bedtimeStart": "2026-09-30T05:55:00.000Z", + "bedtimeEnd": "2026-09-30T14:15:00.000Z", + "totalSleepDuration": 28200, + "timeInBed": 30000, + "deepSleepDuration": 5100, + "lightSleepDuration": 16320, + "remSleepDuration": 6780, + "awakeTime": 1800, + "efficiency": 94, + "latency": 720, + "averageHeartRate": 56.8, + "averageHrv": 51, + "lowestHeartRate": 49, + "averageBreath": 14.1, + "restlessPeriods": 6 + }, + { + "id": "synthetic-oura-sleep-period-2026-10-01", + "day": "2026-10-01", + "type": "long_sleep", + "bedtimeStart": "2026-10-01T06:08:00.000Z", + "bedtimeEnd": "2026-10-01T14:15:00.000Z", + "totalSleepDuration": 27300, + "timeInBed": 29220, + "deepSleepDuration": 4920, + "lightSleepDuration": 15840, + "remSleepDuration": 6540, + "awakeTime": 1920, + "efficiency": 93, + "latency": 720, + "averageHeartRate": 56.8, + "averageHrv": 51, + "lowestHeartRate": 49, + "averageBreath": 14.1, + "restlessPeriods": 6 + }, + { + "id": "synthetic-oura-sleep-period-2026-10-02", + "day": "2026-10-02", + "type": "long_sleep", + "bedtimeStart": "2026-10-02T06:05:00.000Z", + "bedtimeEnd": "2026-10-02T14:15:00.000Z", + "totalSleepDuration": 27720, + "timeInBed": 29400, + "deepSleepDuration": 4980, + "lightSleepDuration": 16080, + "remSleepDuration": 6660, + "awakeTime": 1680, + "efficiency": 94, + "latency": 720, + "averageHeartRate": 56.8, + "averageHrv": 51, + "lowestHeartRate": 49, + "averageBreath": 14.1, + "restlessPeriods": 6 + }, + { + "id": "synthetic-oura-sleep-period-2026-10-03", + "day": "2026-10-03", + "type": "long_sleep", + "bedtimeStart": "2026-10-03T06:12:00.000Z", + "bedtimeEnd": "2026-10-03T14:15:00.000Z", + "totalSleepDuration": 26880, + "timeInBed": 28980, + "deepSleepDuration": 4860, + "lightSleepDuration": 15540, + "remSleepDuration": 6480, + "awakeTime": 2100, + "efficiency": 93, + "latency": 720, + "averageHeartRate": 56.8, + "averageHrv": 51, + "lowestHeartRate": 49, + "averageBreath": 14.1, + "restlessPeriods": 6 + }, + { + "id": "synthetic-oura-sleep-period-2026-10-04", + "day": "2026-10-04", + "type": "long_sleep", + "bedtimeStart": "2026-10-04T05:55:00.000Z", + "bedtimeEnd": "2026-10-04T14:15:00.000Z", + "totalSleepDuration": 28500, + "timeInBed": 30000, + "deepSleepDuration": 5160, + "lightSleepDuration": 16500, + "remSleepDuration": 6840, + "awakeTime": 1500, + "efficiency": 95, + "latency": 720, + "averageHeartRate": 56.8, + "averageHrv": 51, + "lowestHeartRate": 49, + "averageBreath": 14.1, + "restlessPeriods": 6 + }, + { + "id": "synthetic-oura-sleep-period-2026-10-05", + "day": "2026-10-05", + "type": "long_sleep", + "bedtimeStart": "2026-10-05T06:06:00.000Z", + "bedtimeEnd": "2026-10-05T14:15:00.000Z", + "totalSleepDuration": 27600, + "timeInBed": 29340, + "deepSleepDuration": 4980, + "lightSleepDuration": 16020, + "remSleepDuration": 6600, + "awakeTime": 1740, + "efficiency": 94, + "latency": 720, + "averageHeartRate": 56.8, + "averageHrv": 51, + "lowestHeartRate": 49, + "averageBreath": 14.1, + "restlessPeriods": 6 + }, + { + "id": "synthetic-oura-sleep-period-2026-10-06", + "day": "2026-10-06", + "type": "long_sleep", + "bedtimeStart": "2026-10-06T06:14:00.000Z", + "bedtimeEnd": "2026-10-06T14:15:00.000Z", + "totalSleepDuration": 27000, + "timeInBed": 28860, + "deepSleepDuration": 4860, + "lightSleepDuration": 15660, + "remSleepDuration": 6480, + "awakeTime": 1860, + "efficiency": 94, + "latency": 720, + "averageHeartRate": 56.8, + "averageHrv": 51, + "lowestHeartRate": 49, + "averageBreath": 14.1, + "restlessPeriods": 6 + }, + { + "id": "synthetic-oura-sleep-period-2026-10-07", + "day": "2026-10-07", + "type": "long_sleep", + "bedtimeStart": "2026-10-07T06:21:00.000Z", + "bedtimeEnd": "2026-10-07T14:15:00.000Z", + "totalSleepDuration": 26400, + "timeInBed": 28440, + "deepSleepDuration": 4740, + "lightSleepDuration": 15300, + "remSleepDuration": 6360, + "awakeTime": 2040, + "efficiency": 93, + "latency": 720, + "averageHeartRate": 56.8, + "averageHrv": 51, + "lowestHeartRate": 49, + "averageBreath": 14.1, + "restlessPeriods": 6 + }, + { + "id": "synthetic-oura-sleep-period-2026-10-08", + "day": "2026-10-08", + "type": "long_sleep", + "bedtimeStart": "2026-10-08T06:34:00.000Z", + "bedtimeEnd": "2026-10-08T14:15:00.000Z", + "totalSleepDuration": 25500, + "timeInBed": 27660, + "deepSleepDuration": 4620, + "lightSleepDuration": 14760, + "remSleepDuration": 6120, + "awakeTime": 2160, + "efficiency": 92, + "latency": 720, + "averageHeartRate": 59.2, + "averageHrv": 43, + "lowestHeartRate": 52, + "averageBreath": 14.1, + "restlessPeriods": 9 + }, + { + "id": "synthetic-oura-sleep-period-2026-10-09", + "day": "2026-10-09", + "type": "long_sleep", + "bedtimeStart": "2026-10-09T06:45:00.000Z", + "bedtimeEnd": "2026-10-09T14:15:00.000Z", + "totalSleepDuration": 24600, + "timeInBed": 27000, + "deepSleepDuration": 4440, + "lightSleepDuration": 14280, + "remSleepDuration": 5880, + "awakeTime": 2400, + "efficiency": 91, + "latency": 720, + "averageHeartRate": 59.2, + "averageHrv": 43, + "lowestHeartRate": 52, + "averageBreath": 14.1, + "restlessPeriods": 9 + }, + { + "id": "synthetic-oura-sleep-period-2026-10-10", + "day": "2026-10-10", + "type": "long_sleep", + "bedtimeStart": "2026-10-10T06:52:00.000Z", + "bedtimeEnd": "2026-10-10T14:15:00.000Z", + "totalSleepDuration": 24120, + "timeInBed": 26580, + "deepSleepDuration": 4320, + "lightSleepDuration": 14040, + "remSleepDuration": 5760, + "awakeTime": 2460, + "efficiency": 91, + "latency": 720, + "averageHeartRate": 59.2, + "averageHrv": 43, + "lowestHeartRate": 52, + "averageBreath": 14.1, + "restlessPeriods": 9 + }, + { + "id": "synthetic-oura-sleep-period-2026-10-11", + "day": "2026-10-11", + "type": "long_sleep", + "bedtimeStart": "2026-10-11T06:58:00.000Z", + "bedtimeEnd": "2026-10-11T14:15:00.000Z", + "totalSleepDuration": 23700, + "timeInBed": 26220, + "deepSleepDuration": 4260, + "lightSleepDuration": 13740, + "remSleepDuration": 5700, + "awakeTime": 2520, + "efficiency": 90, + "latency": 720, + "averageHeartRate": 59.2, + "averageHrv": 43, + "lowestHeartRate": 52, + "averageBreath": 14.1, + "restlessPeriods": 9 + }, + { + "id": "synthetic-oura-sleep-period-2026-10-12", + "day": "2026-10-12", + "type": "long_sleep", + "bedtimeStart": "2026-10-12T06:43:00.000Z", + "bedtimeEnd": "2026-10-12T14:15:00.000Z", + "totalSleepDuration": 24900, + "timeInBed": 27120, + "deepSleepDuration": 4500, + "lightSleepDuration": 14400, + "remSleepDuration": 6000, + "awakeTime": 2220, + "efficiency": 92, + "latency": 720, + "averageHeartRate": 59.2, + "averageHrv": 43, + "lowestHeartRate": 52, + "averageBreath": 14.1, + "restlessPeriods": 9 + }, + { + "id": "synthetic-oura-sleep-period-2026-10-13", + "day": "2026-10-13", + "type": "long_sleep", + "bedtimeStart": "2026-10-13T06:32:00.000Z", + "bedtimeEnd": "2026-10-13T14:15:00.000Z", + "totalSleepDuration": 25800, + "timeInBed": 27780, + "deepSleepDuration": 4620, + "lightSleepDuration": 15000, + "remSleepDuration": 6180, + "awakeTime": 1980, + "efficiency": 93, + "latency": 720, + "averageHeartRate": 59.2, + "averageHrv": 43, + "lowestHeartRate": 52, + "averageBreath": 14.1, + "restlessPeriods": 9 + } + ] +} diff --git a/apps/maple-agent/scripts/vana-poc/demo-data/validate.mjs b/apps/maple-agent/scripts/vana-poc/demo-data/validate.mjs new file mode 100644 index 000000000..086c475ef --- /dev/null +++ b/apps/maple-agent/scripts/vana-poc/demo-data/validate.mjs @@ -0,0 +1,84 @@ +// Offline validation against the actual installed Vana connector schemas. +import assert from 'node:assert/strict'; +import { readFile, lstat } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +const bundle = '/Applications/Vana.app/Contents/Resources/personal-server/dist/node_modules'; +const { default: Ajv } = await import(pathToFileURL(`${bundle}/ajv/dist/ajv.js`)); +const { default: addFormats } = await import(pathToFileURL(`${bundle}/ajv-formats/dist/index.js`)); +const dataDirectory = dirname(fileURLToPath(import.meta.url)); +export const scopes = Object.freeze(['chatgpt.conversations', 'chatgpt.memories', 'oura.sleep', 'oura.readiness']); +export const schemaFiles = Object.freeze({ + 'chatgpt.conversations': 'openai/chatgpt.conversations.json', + 'chatgpt.memories': 'openai/chatgpt.memories.json', + 'oura.sleep': 'oura/oura.sleep.json', + 'oura.readiness': 'oura/oura.readiness.json', +}); +export async function compileValidators() { + const ajv = new Ajv({ strict: false, allErrors: true }); + addFormats(ajv); + const validators = {}; + for (const scope of scopes) { + const wrapper = JSON.parse(await readFile(join('/Applications/Vana.app/Contents/Resources/connectors', schemaFiles[scope]), 'utf8')); + assert.equal(wrapper.scope, scope); + assert.equal(wrapper.version, scope.startsWith('chatgpt.') ? '2.0.0' : '1.0.0'); + validators[scope] = ajv.compile(wrapper.schema); + } + return validators; +} +export function validateConsistency(fixtures) { + const { conversations, total } = fixtures['chatgpt.conversations']; + assert.equal(total, 26); + assert.equal(total, conversations.length); + const ids = new Set(); + for (const chat of conversations) { + assert.equal(chat.message_count, chat.messages.length); + assert.equal(chat.messages.length, 4); + assert.ok(Date.parse(chat.create_time) <= Date.parse(chat.update_time)); + assert.ok(chat.id.startsWith('synthetic-')); + assert.ok(!ids.has(chat.id)); ids.add(chat.id); + chat.messages.forEach((message, index) => { + assert.equal(message.role, index % 2 === 0 ? 'user' : 'assistant'); + assert.ok(!ids.has(message.id)); ids.add(message.id); + assert.ok(Date.parse(message.create_time) >= Date.parse(chat.create_time)); + assert.ok(Date.parse(message.create_time) <= Date.parse(chat.update_time)); + }); + } + assert.match(conversations[0].messages[0].content, /fictional demo/); + const memory = fixtures['chatgpt.memories']; + assert.equal(memory.total, 12); assert.equal(memory.total, memory.memories.length); + assert.match(memory.memories[0].content, /SYNTHETIC DEMO PERSONA/); + assert.ok(memory.memories.every(m => m.id.startsWith('synthetic-') && Date.parse(m.created_at) <= Date.parse(m.updated_at))); + const sleep = fixtures['oura.sleep']; const readiness = fixtures['oura.readiness']; + assert.equal(sleep.dailyScores.length, 14); assert.equal(sleep.sleepPeriods.length, 14); assert.equal(readiness.days.length, 14); + sleep.sleepPeriods.forEach((period, index) => { + assert.equal(period.day, sleep.dailyScores[index].day); + assert.equal(period.day, readiness.days[index].day); + assert.ok(period.id.startsWith('synthetic-')); + assert.equal(period.deepSleepDuration + period.lightSleepDuration + period.remSleepDuration, period.totalSleepDuration); + assert.equal(period.totalSleepDuration + period.awakeTime, period.timeInBed); + assert.equal((Date.parse(period.bedtimeEnd) - Date.parse(period.bedtimeStart)) / 1000, period.timeInBed); + assert.equal(period.efficiency, Math.round(period.totalSleepDuration / period.timeInBed * 100)); + assert.ok(period.totalSleepDuration >= 6 * 3600 && period.totalSleepDuration <= 9 * 3600); + if (index > 0) assert.equal(Date.parse(`${period.day}T00:00:00Z`) - Date.parse(`${sleep.sleepPeriods[index - 1].day}T00:00:00Z`), 86400000); + }); + assert.equal(sleep.sleepPeriods[0].day, '2026-09-30'); + assert.equal(sleep.sleepPeriods.at(-1).day, '2026-10-13'); +} +export async function loadAndValidateFixtures() { + const validators = await compileValidators(); const fixtures = {}; + for (const scope of scopes) { + const path = join(dataDirectory, `${scope}.json`); + const stat = await lstat(path); + assert.ok(stat.isFile() && !stat.isSymbolicLink() && stat.size < 262144); + const item = JSON.parse(await readFile(path, 'utf8')); + assert.ok(validators[scope](item), `Schema validation failed: ${scope}`); + fixtures[scope] = item; + } + validateConsistency(fixtures); + return fixtures; +} +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await loadAndValidateFixtures(); + process.stdout.write('Installed Vana schemas and cross-record consistency passed for all four scopes.\n'); +} From 1362f21d55062c3c657d10e9fbdc24671cd8e93f Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 27 Oct 2026 04:55:07 +0000 Subject: [PATCH 2/2] refactor(agent): group Vana with supported MCP integrations --- .../app/assets/icons/external-link.svg | 1 + apps/maple-agent/app/src/assets.rs | 1 + apps/maple-agent/app/src/ui/settings.rs | 38 ++- .../app/src/ui/settings/navigation.rs | 104 ++++-- apps/maple-agent/app/src/ui/settings/vana.rs | 296 ++++++++++++------ apps/maple-agent/scripts/vana-poc/README.md | 8 +- 6 files changed, 306 insertions(+), 142 deletions(-) create mode 100644 apps/maple-agent/app/assets/icons/external-link.svg diff --git a/apps/maple-agent/app/assets/icons/external-link.svg b/apps/maple-agent/app/assets/icons/external-link.svg new file mode 100644 index 000000000..4d8c0e433 --- /dev/null +++ b/apps/maple-agent/app/assets/icons/external-link.svg @@ -0,0 +1 @@ + diff --git a/apps/maple-agent/app/src/assets.rs b/apps/maple-agent/app/src/assets.rs index 6020e1d04..69d5c599f 100644 --- a/apps/maple-agent/app/src/assets.rs +++ b/apps/maple-agent/app/src/assets.rs @@ -29,6 +29,7 @@ assets!( "icons/cua-mark-black.svg", "icons/cua-mark-white.svg", "icons/ellipsis.svg", + "icons/external-link.svg", "icons/folder-open.svg", "icons/folder-plus.svg", "icons/folder.svg", diff --git a/apps/maple-agent/app/src/ui/settings.rs b/apps/maple-agent/app/src/ui/settings.rs index 478340491..bf598acae 100644 --- a/apps/maple-agent/app/src/ui/settings.rs +++ b/apps/maple-agent/app/src/ui/settings.rs @@ -2473,11 +2473,6 @@ impl SettingsScreen { .child("Give Maple the tools and context you choose. Connect an integration here, then select it for each chat."), ); - pane = pane.child(if self.vana_details_open { - self.render_vana_details(cx) - } else { - self.render_vana_catalog_card(cx) - }); pane = pane.child( div() .mt_2() @@ -2553,10 +2548,14 @@ impl SettingsScreen { .text_color(gpui::rgb(theme::text_muted())) .child("Connect additional tools over MCP."), ); - if let Some(notice) = &self.mcp_notice { + if let Some(notice) = &self.mcp_notice + && !self.vana_editor_open() + { pane = pane.child(widgets::banner(theme::status_warning()).child(notice.clone())); } - if let Some(editor) = &self.mcp_editor { + if let Some(editor) = &self.mcp_editor + && !self.vana_editor_open() + { pane = pane.child(self.render_mcp_editor(editor, cx)); } match &self.mcp_servers { @@ -2567,7 +2566,11 @@ impl SettingsScreen { .child("Loading custom MCP servers…"), ); } - Some(servers) if servers.is_empty() => { + Some(servers) + if servers + .iter() + .all(|server| server.name.eq_ignore_ascii_case("vana")) => + { pane = pane.child( div() .text_sm() @@ -2576,7 +2579,10 @@ impl SettingsScreen { ); } Some(servers) => { - pane = pane.children(servers.iter().map(|server| { + let custom_servers = servers + .iter() + .filter(|server| !server.name.eq_ignore_ascii_case("vana")); + pane = pane.children(custom_servers.map(|server| { let name = server.name.clone(); let summary = match &server.target { McpTarget::Stdio { command } => format!("STDIO · {command}"), @@ -2665,7 +2671,19 @@ impl SettingsScreen { })); } } - pane + pane = pane.child( + div() + .mt_2() + .pt_5() + .border_t_1() + .border_color(gpui::rgb(theme::border_subtle())) + .child(subsection_title("Supported MCP integrations")), + ); + pane.child(if self.vana_details_open { + self.render_vana_details(cx) + } else { + self.render_vana_catalog_card(cx) + }) } fn render_integration_card( diff --git a/apps/maple-agent/app/src/ui/settings/navigation.rs b/apps/maple-agent/app/src/ui/settings/navigation.rs index c3494b561..430d9a009 100644 --- a/apps/maple-agent/app/src/ui/settings/navigation.rs +++ b/apps/maple-agent/app/src/ui/settings/navigation.rs @@ -51,10 +51,14 @@ pub(super) enum SettingsTarget { PromptReset, Integration(String), IntegrationSetup(String), + VanaGetApp, + VanaSetupGuide, VanaDetails, VanaConnect, VanaToggle, VanaEndpoint, + VanaEdit, + VanaRemove, McpAdd, McpServer(String), } @@ -165,45 +169,32 @@ impl SettingsScreen { SettingsTarget::PromptSave, SettingsTarget::PromptReset, ], - Section::Integrations => std::iter::once(SettingsTarget::VanaDetails) - .chain( - self.vana_details_open - .then_some(SettingsTarget::VanaConnect), - ) - .chain( - (self.vana_details_open && self.vana_server().is_some()) - .then_some(SettingsTarget::VanaToggle), - ) - .chain( - (self.vana_details_open && self.vana_local_available) - .then_some(SettingsTarget::VanaEndpoint), - ) - .chain( - self.integrations - .as_deref() - .unwrap_or_default() - .iter() - .filter(|integration| integration_is_visible(integration)) - .flat_map(|integration| { - [ - integration_can_setup(integration).then(|| { - SettingsTarget::IntegrationSetup(integration.id.clone()) - }), - integration_can_toggle(integration) - .then(|| SettingsTarget::Integration(integration.id.clone())), - ] - .into_iter() - .flatten() - }), - ) + Section::Integrations => self + .integrations + .as_deref() + .unwrap_or_default() + .iter() + .filter(|integration| integration_is_visible(integration)) + .flat_map(|integration| { + [ + integration_can_setup(integration) + .then(|| SettingsTarget::IntegrationSetup(integration.id.clone())), + integration_can_toggle(integration) + .then(|| SettingsTarget::Integration(integration.id.clone())), + ] + .into_iter() + .flatten() + }) .chain(std::iter::once(SettingsTarget::McpAdd)) .chain( self.mcp_servers .as_deref() .unwrap_or_default() .iter() + .filter(|server| !server.name.eq_ignore_ascii_case("vana")) .map(|server| SettingsTarget::McpServer(server.name.clone())), ) + .chain(self.vana_application_targets()) .collect(), Section::Remote | Section::Usage | Section::About | Section::Advanced => Vec::new(), } @@ -387,6 +378,8 @@ impl SettingsScreen { Some(SettingsTarget::PromptReset) => self.reset_prompt(cx), Some(SettingsTarget::Integration(id)) => self.toggle_integration(&id, cx), Some(SettingsTarget::IntegrationSetup(id)) => self.setup_integration(&id, cx), + Some(SettingsTarget::VanaGetApp) => self.open_vana_app(), + Some(SettingsTarget::VanaSetupGuide) => self.open_vana_setup_guide(), Some(SettingsTarget::VanaDetails) => self.open_vana_details(cx), Some(SettingsTarget::VanaConnect) => self.start_vana_connection(cx), Some(SettingsTarget::VanaToggle) => { @@ -398,6 +391,16 @@ impl SettingsScreen { let handle = self.vana_endpoint.read(cx).focus_handle(cx); window.focus(&handle, cx); } + Some(SettingsTarget::VanaRemove) => { + if let Some(name) = self.vana_server().map(|server| server.name.clone()) { + self.remove_mcp_server(&name, cx); + } + } + Some(SettingsTarget::VanaEdit) => { + if let Some(name) = self.vana_server().map(|server| server.name.clone()) { + self.edit_mcp_server(&name, cx); + } + } Some(SettingsTarget::McpAdd) => self.open_mcp_editor(None, cx), Some(SettingsTarget::McpServer(name)) => self.edit_mcp_server(&name, cx), None => {} @@ -728,7 +731,7 @@ mod tests { ) }); - settings.update(cx, |this, _cx| { + settings.update(cx, |this, cx| { this.integrations = Some(vec![ maple_agent::agent::AgentIntegration { id: "cua-driver".to_string(), @@ -782,13 +785,48 @@ mod tests { assert_eq!( this.visible_application_targets(), vec![ - SettingsTarget::VanaDetails, SettingsTarget::IntegrationSetup("cua-driver".to_string()), SettingsTarget::Integration("cua-driver".to_string()), SettingsTarget::McpAdd, SettingsTarget::McpServer("custom".to_string()), + SettingsTarget::VanaGetApp, + SettingsTarget::VanaSetupGuide, + SettingsTarget::VanaDetails, ] ); + + // The supported card owns Vana's controls; its saved definition + // must not introduce a second custom-server navigation target. + let mut vana = this.mcp_servers.as_ref().unwrap()[0].clone(); + vana.name = "VaNa".to_string(); + this.mcp_servers.as_mut().unwrap().push(vana); + assert!(!this.visible_application_targets().iter().any(|target| { + matches!(target, SettingsTarget::McpServer(name) if name.eq_ignore_ascii_case("vana")) + })); + + this.vana_details_open = true; + this.vana_local_available = true; + assert_eq!( + this.vana_application_targets(), + vec![ + SettingsTarget::VanaDetails, + SettingsTarget::VanaGetApp, + SettingsTarget::VanaSetupGuide, + SettingsTarget::VanaEndpoint, + SettingsTarget::VanaToggle, + SettingsTarget::VanaConnect, + SettingsTarget::VanaEdit, + SettingsTarget::VanaRemove, + ] + ); + this.vana_local_available = false; + assert!(!this.vana_application_targets().contains(&SettingsTarget::VanaEndpoint)); + this.edit_mcp_server("VaNa", cx); + assert!(this.vana_editor_open()); + assert_eq!( + this.mcp_editor.as_ref().unwrap().original_name.as_deref(), + Some("VaNa") + ); }); } diff --git a/apps/maple-agent/app/src/ui/settings/vana.rs b/apps/maple-agent/app/src/ui/settings/vana.rs index 86dc56d21..761194354 100644 --- a/apps/maple-agent/app/src/ui/settings/vana.rs +++ b/apps/maple-agent/app/src/ui/settings/vana.rs @@ -7,16 +7,8 @@ use super::{SettingsScreen, SettingsTarget}; use crate::ui::{icons::icon, theme, widgets}; use maple_agent::store::records::{McpDefinition, McpTarget}; -fn chip(label: &'static str) -> Div { - div() - .px_2() - .py_1() - .rounded_full() - .bg(gpui::rgb(theme::bg_sidebar_pill())) - .text_xs() - .text_color(gpui::rgb(theme::text_secondary())) - .child(label) -} +const VANA_APP_URL: &str = "https://vana.org/use"; +const VANA_SETUP_URL: &str = "https://docs.vana.org/applications/connect-a-local-agent"; fn vana_wordmark() -> gpui::Svg { gpui::svg() @@ -27,6 +19,39 @@ fn vana_wordmark() -> gpui::Svg { } impl SettingsScreen { + pub(super) fn vana_application_targets(&self) -> Vec { + let links = [SettingsTarget::VanaGetApp, SettingsTarget::VanaSetupGuide]; + if !self.vana_details_open { + return links + .into_iter() + .chain([SettingsTarget::VanaDetails]) + .collect(); + } + std::iter::once(SettingsTarget::VanaDetails) + .chain(links) + .chain( + self.vana_local_available + .then_some(SettingsTarget::VanaEndpoint), + ) + .chain( + self.vana_server() + .is_some() + .then_some(SettingsTarget::VanaToggle), + ) + .chain([SettingsTarget::VanaConnect]) + .chain( + self.vana_server() + .is_some() + .then_some(SettingsTarget::VanaEdit), + ) + .chain( + self.vana_server() + .is_some() + .then_some(SettingsTarget::VanaRemove), + ) + .collect() + } + /// A curated card identifies the exact saved Vana MCP name. Other MCP /// servers, including user-provided lookalike descriptions, stay custom. pub(super) fn vana_server(&self) -> Option<&McpDefinition> { @@ -67,110 +92,138 @@ impl SettingsScreen { ("Default on for new chats", theme::status_success()) } (_, Some(_)) => ("Configured · choose per chat", theme::text_muted()), - (_, None) => ("Not connected", theme::text_muted()), + (_, None) => ("Not configured", theme::text_muted()), }; - div() + widgets::card_row() .flex() - .flex_col() - .gap_4() - .p_5() - .rounded(theme::RADIUS_LG) - .bg(gpui::rgb(theme::bg_sidebar_card())) - .border_1() - .border_color(gpui::rgb(theme::border())) + .items_start() + .gap_3() .child( div() + .size(px(44.)) + .flex_none() .flex() .items_center() - .justify_between() - .child( - div() - .flex() - .items_center() - .gap_2() - .text_xs() - .font_weight(FontWeight::SEMIBOLD) - .text_color(gpui::rgb(theme::text_muted())) - .child(icon("puzzle", px(14.), theme::text_muted())) - .child("FEATURED INTEGRATION"), - ) - .child( - div() - .flex() - .items_center() - .gap_1p5() - .text_xs() - .text_color(gpui::rgb(status_color)) - .child(div().size(px(6.)).rounded_full().bg(gpui::rgb(status_color))) - .child(status), - ), + .justify_center() + .rounded(theme::RADIUS_SM) + .bg(gpui::rgb(theme::bg_sidebar_card())) + .border_1() + .border_color(gpui::rgb(theme::border_subtle())) + .child(vana_wordmark().w(px(34.)).h(px(34. * 478. / 1716.))), ) .child( div() + .flex_1() + .min_w_0() .flex() - .items_center() - .gap_5() + .flex_col() + .gap_1p5() .child( div() - .flex_none() - .w(px(136.)) - .h(px(88.)) .flex() + .flex_wrap() .items_center() - .justify_center() - .rounded(theme::RADIUS_MD) - .bg(gpui::rgb(theme::bg_elevated())) - .border_1() - .border_color(gpui::rgb(theme::border_subtle())) - .child(vana_wordmark()), - ) - .child( - div() - .flex_1() - .min_w_0() - .flex() - .flex_col() .gap_2() - .child( - div() - .text_lg() - .font_weight(FontWeight::SEMIBOLD) - .text_color(gpui::rgb(theme::text_heading())) - .child("Your context. Your permissions."), - ) .child( div() .text_sm() - .text_color(gpui::rgb(theme::text_secondary())) - .child("Bring context from your personal Vana vault into Maple. Get answers that understand your interests, goals, and routines."), - ), - ), - ) - .child( - div() - .flex() - .items_center() - .justify_between() - .gap_3() + .font_weight(FontWeight::MEDIUM) + .text_color(gpui::rgb(theme::text_primary())) + .child("Vana"), + ) + .child(super::integration_status_badge(status, status_color)), + ) .child( div() - .flex() - .flex_wrap() - .gap_1p5() - .child(chip("Local connection")) - .child(chip("Vana permissions")) - .child(chip("MCP")), + .text_xs() + .text_color(gpui::rgb(theme::text_secondary())) + .child("Connect selected data from Vana Desktop on this computer."), ) - .child(self.application_target( - || SettingsTarget::VanaDetails, - widgets::secondary_button("vana-catalog-details") - .tab_index(0) - .on_click(cx.listener(|this, _event, _window, cx| { - this.open_vana_details(cx); - })) - .child(if server.is_some() { "Manage Vana" } else { "Connect Vana" }) - .child(icon("chevron-right", px(14.), theme::text_primary())), - )), + .child( + div() + .text_xs() + .text_color(gpui::rgb(theme::text_muted())) + .child("Requires Vana Desktop."), + ) + .child(self.render_vana_links(cx)), + ) + .child( + self.application_target( + || SettingsTarget::VanaDetails, + widgets::secondary_button("vana-catalog-details") + .aria_label(if server.is_some() { + "Manage Vana" + } else { + "Connect Vana" + }) + .tab_index(0) + .on_click(cx.listener(|this, _event, _window, cx| { + this.open_vana_details(cx); + })) + .child(if server.is_some() { + "Manage" + } else { + "Connect" + }), + ), + ) + } + + pub(super) fn open_vana_app(&self) { + crate::ui::rich_text::open_link(VANA_APP_URL); + } + + pub(super) fn open_vana_setup_guide(&self) { + crate::ui::rich_text::open_link(VANA_SETUP_URL); + } + + pub(super) fn vana_editor_open(&self) -> bool { + self.vana_details_open + && self.mcp_editor.as_ref().is_some_and(|editor| { + editor + .original_name + .as_deref() + .is_some_and(|name| name.eq_ignore_ascii_case("vana")) + }) + } + + fn render_vana_links(&self, cx: &mut Context) -> Div { + div() + .flex() + .flex_wrap() + .items_center() + .gap_4() + .child( + self.application_target( + || SettingsTarget::VanaGetApp, + widgets::ghost_button("vana-get-app") + .aria_label("Get Vana") + .tab_index(0) + .px_0() + .py_0() + .text_xs() + .text_color(gpui::rgb(theme::link())) + .on_click(cx.listener(|this, _event, _window, _cx| this.open_vana_app())) + .child("Get Vana") + .child(icon("external-link", px(12.), theme::link())), + ), + ) + .child( + self.application_target( + || SettingsTarget::VanaSetupGuide, + widgets::ghost_button("vana-setup-guide") + .aria_label("Vana setup guide") + .tab_index(0) + .px_0() + .py_0() + .text_xs() + .text_color(gpui::rgb(theme::link())) + .on_click( + cx.listener(|this, _event, _window, _cx| this.open_vana_setup_guide()), + ) + .child("Setup guide") + .child(icon("external-link", px(12.), theme::link())), + ), ) } @@ -204,7 +257,8 @@ impl SettingsScreen { .child("Connect your local vault"), ), ) - .child( + .child(self.application_target( + || SettingsTarget::VanaDetails, widgets::icon_button( "vana-details-close", "x", @@ -217,14 +271,15 @@ impl SettingsScreen { .on_click(cx.listener(|this, _event, _window, cx| { this.open_vana_details(cx); })), - ), + )), ) .child( div() .text_sm() .text_color(gpui::rgb(theme::text_secondary())) .child("Keep Vana Desktop open on this computer. In Vana, go to Settings → Server → Connect apps and copy the Local MCP endpoint."), - ); + ) + .child(self.render_vana_links(cx)); if self.vana_local_available { content = content.child( self.application_target( @@ -344,7 +399,54 @@ impl SettingsScreen { }), ), ); - content.child(actions) + content = content.child(actions); + if let Some(server) = server { + let name = server.name.clone(); + let manage_actions = div().flex().flex_wrap().items_center().gap_2().child( + self.application_target( + || SettingsTarget::VanaEdit, + widgets::ghost_button("vana-advanced-settings") + .aria_label("Vana advanced settings") + .tab_index(0) + .when(self.mcp_saving || self.vana_connecting, |button| { + button.opacity(0.5) + }) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.edit_mcp_server(&name, cx); + })) + .child("Advanced settings"), + ), + ); + let name = server.name.clone(); + content = content.child( + manage_actions.child( + self.application_target( + || SettingsTarget::VanaRemove, + widgets::ghost_button("vana-remove") + .aria_label("Remove Vana from Maple") + .tab_index(0) + .text_color(gpui::rgb(theme::status_error())) + .when(self.mcp_saving || self.vana_connecting, |button| { + button.opacity(0.5) + }) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.remove_mcp_server(&name, cx); + })) + .child("Remove from Maple"), + ), + ), + ); + } + if self.vana_editor_open() { + if let Some(notice) = &self.mcp_notice { + content = + content.child(widgets::banner(theme::status_warning()).child(notice.clone())); + } + if let Some(editor) = &self.mcp_editor { + content = content.child(self.render_mcp_editor(editor, cx)); + } + } + content } pub(super) fn start_vana_connection(&mut self, cx: &mut Context) { diff --git a/apps/maple-agent/scripts/vana-poc/README.md b/apps/maple-agent/scripts/vana-poc/README.md index a7bf89528..d79e0616c 100644 --- a/apps/maple-agent/scripts/vana-poc/README.md +++ b/apps/maple-agent/scripts/vana-poc/README.md @@ -9,8 +9,10 @@ server port, or public MCP endpoint in the new connection path. 1. Keep an up-to-date Vana Desktop running. In **Settings → Server → Connect apps**, copy **Local MCP endpoint**. -2. In Maple Agent on **This Computer**, open **Settings → Integrations → Vana**. - Paste the endpoint and click **Connect Vana**. +2. In Maple Agent on **This Computer**, open **Settings → Integrations → + Supported MCP integrations → Vana → Connect**. The card's **Get Vana** and + **Setup guide** links cover installing Vana Desktop and preparing the local + endpoint. Paste the endpoint and click **Connect Vana**. 3. In Vana Desktop, select the scopes Maple may read and approve the request. The native Vana consent screen is the authority; Maple does not simulate it. 4. Return to Maple and select Vana in the task's **Integrations** menu. The @@ -32,6 +34,8 @@ late refreshes from silently restoring stale credentials. In Vana, **Settings → Server → Connected clients → Disconnect** revokes access. A revoked session must be connected again. Vana Desktop must stay running. +The Vana card's **Manage → Remove from Maple** removes Maple's saved connection; +revoke the grant in Vana separately if you no longer want Maple to have access. ## Privacy boundary