From e645719d5f72ebf0abd9a26bf73ea8acd3d6faeb Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:37:52 -0700 Subject: [PATCH 01/35] feat: embed native web readers on Linux and Windows --- .github/workflows/embedded-browser.yml | 76 + Cargo.lock | 45 + Cargo.toml | 1 + README.md | 4 + README.zh-CN.md | 3 + crates/browser-smoke/Cargo.toml | 10 + crates/browser-smoke/src/main.rs | 198 ++ crates/browser-smoke/src/policy_tests.rs | 108 + crates/oauth-service/README.md | 11 +- crates/oauth-service/README.zh-CN.md | 9 +- desktop/README.md | 4 + desktop/README.zh-CN.md | 4 + desktop/src/main.rs | 22 + docs/adr/0012-app-host-api-discovery.md | 5 +- docs/adr/0012-app-host-api-discovery.zh-CN.md | 5 +- docs/desktop-embedded-browser.md | 99 + docs/desktop-embedded-browser.zh-CN.md | 80 + runtime-patches.lock.json | 10 +- tools/browser-smoke.py | 217 ++ tools/browser_smoke_x11.py | 57 + tools/ci_local.py | 2 + tools/ci_local_merge.py | 26 +- .../makepad-desktop-webview.patch | 2198 +++++++++++++++++ tools/test_ci_local.py | 17 + 24 files changed, 3192 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/embedded-browser.yml create mode 100644 crates/browser-smoke/Cargo.toml create mode 100644 crates/browser-smoke/src/main.rs create mode 100644 crates/browser-smoke/src/policy_tests.rs create mode 100644 docs/desktop-embedded-browser.md create mode 100644 docs/desktop-embedded-browser.zh-CN.md create mode 100644 tools/browser-smoke.py create mode 100644 tools/browser_smoke_x11.py create mode 100644 tools/runtime-patches/makepad-desktop-webview.patch diff --git a/.github/workflows/embedded-browser.yml b/.github/workflows/embedded-browser.yml new file mode 100644 index 00000000..04a0aac8 --- /dev/null +++ b/.github/workflows/embedded-browser.yml @@ -0,0 +1,76 @@ +name: Embedded browser + +on: + pull_request: + paths: + - 'crates/browser-smoke/**' + - 'tools/browser-smoke.py' + - 'tools/browser_smoke_x11.py' + - 'tools/runtime-patches/**' + - 'runtime-patches.lock.json' + - 'native-runtime.lock.json' + - 'Cargo.toml' + - 'Cargo.lock' + - '.github/workflows/embedded-browser.yml' + push: + branches: [main] + paths: + - 'crates/browser-smoke/**' + - 'tools/browser-smoke.py' + - 'tools/browser_smoke_x11.py' + - 'tools/runtime-patches/**' + - 'runtime-patches.lock.json' + - 'native-runtime.lock.json' + - 'Cargo.toml' + - 'Cargo.lock' + - '.github/workflows/embedded-browser.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: embedded-browser-${{ github.ref }} + cancel-in-progress: true + +jobs: + windows: + runs-on: windows-2022 + timeout-minutes: 40 + env: + PYTHONUTF8: '1' + steps: + - name: Preserve runtime patch bytes + run: git config --global core.autocrlf false + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - uses: dtolnay/rust-toolchain@stable + - name: Prepare the exact native runtime + run: python tools/setup.py + - uses: Swatinem/rust-cache@v2 + with: + shared-key: embedded-browser-windows + - name: Build the native acceptance host + run: cargo build --locked --release -p octosense-browser-smoke + - name: Navigation and callback policy tests + run: cargo test --locked -p octosense-browser-smoke + - name: Real embedded WebView2 acceptance + # The runner must already provide WebView2. No installer, fake engine + # or external-browser fallback converts a missing runtime into a pass. + run: python tools/browser-smoke.py --binary target/release/octosense-browser-smoke.exe --out "$env:RUNNER_TEMP/browser-smoke" --require-snapshot + - name: Native fixture diagnostics on failure + if: failure() + run: | + $log = Join-Path $env:RUNNER_TEMP 'browser-smoke/native.log' + if (Test-Path $log) { Get-Content $log -Tail 100 } + - uses: actions/upload-artifact@v7 + if: always() + with: + name: embedded-browser-windows + if-no-files-found: ignore + path: | + ${{ runner.temp }}/browser-smoke/receipt.json + ${{ runner.temp }}/browser-smoke/inspect-*.json + ${{ runner.temp }}/browser-smoke/snapshot-*.png diff --git a/Cargo.lock b/Cargo.lock index c8706702..5b803215 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5669,10 +5669,13 @@ dependencies = [ "ohos-sys", "rqrr", "smallvec 1.15.1", + "url", "wayland-client", "wayland-egl", "wayland-protocols", + "webview2-com", "windows 0.62.2", + "windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)", "windows-core 0.62.2", "windows-targets 0.52.6", ] @@ -7729,6 +7732,14 @@ dependencies = [ "serde_json", ] +[[package]] +name = "octosense-browser-smoke" +version = "0.1.0" +dependencies = [ + "makepad-widgets", + "serde_json", +] + [[package]] name = "octosense-calendar-service" version = "0.1.0" @@ -12260,6 +12271,40 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webview2-com" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f89fca7a704cee10dcb3654c1dbb8941d1783132f1917358af75bec37a7d7e6" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)", + "windows-core 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "webview2-com-sys" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a07132775117d6065853d9d1178157b8c90e228de47129d6bce2c7edebedfb" +dependencies = [ + "thiserror 2.0.20", + "windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)", + "windows-core 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "weezl" version = "0.1.12" diff --git a/Cargo.toml b/Cargo.toml index 0b8b9d17..9f5109a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ members = [ "crates/l0-chat", "crates/oauth-service", "crates/markdown-editor", + "crates/browser-smoke", "apps/reference", "apps/news/host-service", "apps/mail/host-service", diff --git a/README.md b/README.md index 97255c98..b40b4407 100644 --- a/README.md +++ b/README.md @@ -363,6 +363,10 @@ A phone runs the shell, the kernel, up to a dozen app agents and their apps at t The shell exists once, in `crates/shell` ([ADR 0001](docs/adr/0001-one-octosense-repository.md)): desktop and phone differ by target and features, not by copies of the source. CI fails if a shell source file appears in two crates. +Desktop source builds also include [embedded web readers](docs/desktop-embedded-browser.md) +for Linux X11/XWayland and Windows, with separate engine requirements and native +acceptance gates. This does not change provider sign-in or native approval support. + ## What it depends on Pinned exactly once, in the root `Cargo.toml` and the runtime locks: diff --git a/README.zh-CN.md b/README.zh-CN.md index d90d0562..8cc0a866 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -363,6 +363,9 @@ Agent 的 `ask_user_question` 出现在这一轮的来处:用户和应用自 Shell 只有一份,位于 `crates/shell`([ADR 0001(英文)](docs/adr/0001-one-octosense-repository.md)):桌面端与手机端以目标平台和 feature 区分,而不是各持一份源码副本。若某个 Shell 源文件同时出现在两个 crate 中,CI 会失败。 +桌面源码构建新增 Linux X11/XWayland 和 Windows 的[嵌入式网页阅读器](docs/desktop-embedded-browser.zh-CN.md), +引擎运行条件与原生验收独立说明;不改变提供方登录和原生审批支持范围。 + ## 依赖 只在根目录 `Cargo.toml` 和运行时锁文件中锁定一次: diff --git a/crates/browser-smoke/Cargo.toml b/crates/browser-smoke/Cargo.toml new file mode 100644 index 00000000..d923da22 --- /dev/null +++ b/crates/browser-smoke/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "octosense-browser-smoke" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[dependencies] +makepad-widgets.workspace = true +serde_json.workspace = true diff --git a/crates/browser-smoke/src/main.rs b/crates/browser-smoke/src/main.rs new file mode 100644 index 00000000..ab428696 --- /dev/null +++ b/crates/browser-smoke/src/main.rs @@ -0,0 +1,198 @@ +//! Native-engine acceptance host. Its local control file exists only in this +//! explicit test binary; no script app or production shell exposes this route. +use makepad_widgets::*; +use serde_json::{json, Value}; +use std::{ + fs::{self, OpenOptions}, + io::{Read, Write}, + path::PathBuf, +}; + +#[cfg(test)] +mod policy_tests; + +app_main!(App); + +script_mod! { + use mod.prelude.widgets.* + use mod.widgets.* + startup() do #(App::script_component(vm)) { + ui: Root { main_window := Window { + window.inner_size: vec2(800, 620) + body +: { flow: Down padding: 16 spacing: 12 + Label {text: "OctoSense embedded browser acceptance"} + browser := View {width: Fill height: Fill} + } + }} + } +} + +#[derive(Script, ScriptHook)] +struct App { + #[live] + ui: WidgetRef, + #[rust] + root: PathBuf, + #[rust] + timer: Option, + #[rust] + last_command: u64, + #[rust] + open: bool, + #[rust] + visible: bool, +} + +fn browser_id() -> SystemBrowserId { + SystemBrowserId(live_id!(octosense_browser_smoke)) +} + +impl App { + fn record(&self, event: Value) { + if self.root.as_os_str().is_empty() { + return; + } + if let Ok(mut file) = OpenOptions::new() + .append(true) + .create(true) + .open(self.root.join("events.jsonl")) + { + let _ = writeln!(file, "{event}"); + } + } + + fn commands(&mut self, cx: &mut Cx) { + let Some(command) = fs::File::open(self.root.join("command.json")) + .ok() + .and_then(|file| { + let mut bytes = Vec::new(); + file.take(65_537).read_to_end(&mut bytes).ok()?; + (bytes.len() <= 65_536) + .then(|| serde_json::from_slice::(&bytes).ok()) + .flatten() + }) + else { + return; + }; + let Some(id) = command["id"].as_u64().filter(|id| *id > self.last_command) else { + return; + }; + self.last_command = id; + let operation = command["op"].as_str().unwrap_or(""); + let mut accepted = true; + match operation { + "open" => { + if let Some(url) = command["url"].as_str().filter(|url| url.len() <= 16_384) { + if self.open { + cx.system_browser(browser_id()).close(); + } + if command["navigable"].as_bool().unwrap_or(false) { + cx.system_browser(browser_id()).spawn_navigable(url); + } else { + cx.system_browser(browser_id()).spawn(url); + } + self.open = true; + self.visible = true; + } else { + accepted = false; + } + } + "eval" => { + if let Some(script) = command["script"] + .as_str() + .filter(|script| script.len() <= 32_768) + { + cx.system_browser(browser_id()).eval_js(script); + } else { + accepted = false; + } + } + "hide" => { + self.visible = false; + cx.system_browser(browser_id()).detach(); + } + "show" => self.visible = true, + "close" => { + cx.system_browser(browser_id()).detach(); + cx.system_browser(browser_id()).close(); + self.open = false; + } + "inspect" => { + #[cfg(any(target_os = "macos", target_os = "windows"))] + cx.system_browser(browser_id()).inspect( + self.root + .join(format!("inspect-{id}.json")) + .to_string_lossy() + .into_owned(), + Some( + self.root + .join(format!("snapshot-{id}.png")) + .to_string_lossy() + .into_owned(), + ), + None, + ); + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + accepted = false; + } + } + "quit" => { + cx.system_browser(browser_id()).close(); + cx.quit(); + } + _ => accepted = false, + } + self.record(json!({"kind":"command", "id":id, "operation":operation,"accepted":accepted})); + self.ui.redraw(cx); + } +} + +impl AppMain for App { + fn script_mod(vm: &mut ScriptVm) -> ScriptValue { + makepad_widgets::theme_mod(vm); + script_eval!(vm, {mod.theme = mod.themes.light}); + makepad_widgets::widgets_mod(vm); + self::script_mod(vm) + } + + fn handle_event(&mut self, cx: &mut Cx, event: &Event) { + if matches!(event, Event::Startup) { + self.root = std::env::args() + .find_map(|arg| arg.strip_prefix("--control-root=").map(PathBuf::from)) + .filter(|path| path.is_absolute() && path.is_dir()) + .expect("Pass an existing, isolated absolute --control-root directory"); + self.timer = Some(cx.start_interval(0.05)); + self.record(json!({"kind":"started", "platform":std::env::consts::OS})); + } + if self + .timer + .is_some_and(|timer| timer.is_event(event).is_some()) + { + self.commands(cx); + } + if let Event::Actions(actions) = event { + for action in actions { + if let Some(nav) = + action.downcast_ref::() + { + if nav.browser_id == browser_id().0 .0 { + self.record(json!({"kind":"navigation", "loading":nav.loading,"title":nav.title,"url":nav.url})); + } + } + if let Some(error) = + action.downcast_ref::() + { + if error.browser_id == browser_id().0 .0 { + self.record(json!({"kind":"page_error", "code":error.code,"description":error.description})); + } + } + } + } + self.ui.handle_event(cx, event, &mut Scope::empty()); + if self.open && matches!(event, Event::Draw(_)) { + let area = self.ui.widget(cx, ids!(browser)).area(); + cx.system_browser(browser_id()).update(area, self.visible); + } + } +} diff --git a/crates/browser-smoke/src/policy_tests.rs b/crates/browser-smoke/src/policy_tests.rs new file mode 100644 index 00000000..fe79021b --- /dev/null +++ b/crates/browser-smoke/src/policy_tests.rs @@ -0,0 +1,108 @@ +use makepad_widgets::makepad_platform::system_browser::{ + BrowserPolicy, + NavigationDecision::{Allow, Block, Callback}, +}; + +#[test] +fn restricted_document_does_not_grant_its_sibling_pages() { + let policy = BrowserPolicy::reader("http://127.0.0.1:8765/page?q=one", false).unwrap(); + assert_eq!( + policy.decide("http://127.0.0.1:8765/page?q=one#section", true), + Allow + ); + for url in [ + "http://127.0.0.1:8765/other", + "http://127.0.0.1:8765/page?q=two", + "https://example.com/", + "file:///etc/passwd", + ] { + assert_eq!(policy.decide(url, true), Block, "{url}"); + } +} + +#[test] +fn navigable_readers_allow_public_https_and_reject_private_destinations() { + let policy = BrowserPolicy::reader("https://news.example/start", true).unwrap(); + assert_eq!(policy.decide("https://article.example/story", true), Allow); + for url in [ + "http://article.example/", + "https://127.0.0.1/", + "https://2130706433/", + "https://0x7f000001/", + "https://10.0.0.1/", + "https://192.168.1.1/", + "https://169.254.169.254/", + "https://100.64.0.1/", + "https://[::1]/", + "https://[::ffff:127.0.0.1]/", + "https://[fd00::1]/", + "https://[2001:db8::1]/", + "https://printer.local/", + "https://localhost./", + "file:///tmp/file", + "javascript:alert(1)", + "data:text/html,hello", + ] { + assert_eq!(policy.decide(url, true), Block, "{url}"); + } +} + +#[test] +fn malformed_or_credentialed_urls_are_not_normalized_into_authority() { + let policy = BrowserPolicy::reader("https://example.com/", true).unwrap(); + for url in [ + "https://name:password@example.com/", + " https://example.com/", + "https://example.com/\n", + "https://example.com\\@127.0.0.1/", + ] { + assert_eq!(policy.decide(url, true), Block, "{url:?}"); + } + assert!(BrowserPolicy::reader("javascript:alert(1)", false).is_err()); + assert!(BrowserPolicy::auth("about:blank", "https://octosense.invalid/auth/callback").is_err()); +} + +#[test] +fn authentication_callback_is_exact_and_never_a_subframe_navigation() { + let policy = BrowserPolicy::auth( + "https://backend.example/login", + "https://octosense.invalid/auth/callback", + ) + .unwrap(); + assert_eq!( + policy.decide("https://backend.example/register", true), + Allow + ); + assert_eq!( + policy.decide( + "https://octosense.invalid/auth/callback?code=synthetic&state=fixture", + true + ), + Callback + ); + for (url, main) in [ + ( + "https://octosense.invalid/auth/callback?code=synthetic", + false, + ), + ( + "https://octosense.invalid/auth/callback?code=synthetic#fragment", + true, + ), + ( + "https://octosense.invalid/auth%2fcallback?code=synthetic", + true, + ), + ( + "https://octosense.invalid/auth/callback/extra?code=synthetic", + true, + ), + ( + "https://octosense.invalid:8443/auth/callback?code=synthetic", + true, + ), + ("https://backend.example.evil.invalid/login", true), + ] { + assert_eq!(policy.decide(url, main), Block, "{url}"); + } +} diff --git a/crates/oauth-service/README.md b/crates/oauth-service/README.md index 097dc7fa..9b1a8ed0 100644 --- a/crates/oauth-service/README.md +++ b/crates/oauth-service/README.md @@ -117,12 +117,11 @@ GitHub and Google retain their existing provider authorization flows. A backend login that needs to visit another provider's origin must use the desktop browser option; the embedded mode does not silently open external sites. Windows/Linux keep desktop browser login, and iOS backend login remains unavailable. -On Linux/Windows, ordinary `WebReader.open` also refuses explicitly because the -pinned runtime has no embedded SystemBrowser adapter. The separate optional CEF -Browser widget is not a drop-in authentication adapter: its current global -persistent profile and disabled Chromium sandbox do not provide our per-app -session and navigation boundaries. Shipping embedded support still requires an -isolated adapter and native OS acceptance; an external browser does not count. +Ordinary `WebReader` now has separate Linux X11/XWayland (WebKitGTK) and Windows +(WebView2) adapters in the compatible source host. Their runtime requirements and +native acceptance scope are described in the [desktop browser guide](../../docs/desktop-embedded-browser.md). +This does not enable embedded backend authentication on either platform; that +host-owned route remains disabled. Native Wayland still reports unsupported. ## Developer backend contract diff --git a/crates/oauth-service/README.zh-CN.md b/crates/oauth-service/README.zh-CN.md index aa62f4d1..18ff515b 100644 --- a/crates/oauth-service/README.zh-CN.md +++ b/crates/oauth-service/README.zh-CN.md @@ -87,11 +87,10 @@ WKWebView 存储;Android 9+ 使用不可导出的独立进程 Activity 及唯 GitHub 和 Google 保留现有提供方授权流程。需要访问其他提供方来源的后端登录应 选择桌面浏览器模式;嵌入模式不会悄悄打开外站。Windows/Linux 保留桌面浏览器登录, -iOS 后端登录仍不可用。Linux/Windows 的普通 `WebReader.open` 也会明确拒绝, -因为当前固定版本没有嵌入式 SystemBrowser 适配器。另一个可选 CEF Browser 控件 -不能直接替代认证适配器:它目前使用全局持久 profile,并关闭 Chromium 沙箱, -不具备这里要求的每应用会话和导航边界。嵌入支持仍需隔离适配器及原生系统验收, -外部浏览器不能计作嵌入支持。 +iOS 后端登录仍不可用。兼容源码宿主为普通 `WebReader` 新增 Linux X11/XWayland +(WebKitGTK)和 Windows(WebView2)适配器,运行条件及原生验收范围见 +[桌面浏览器指南](../../docs/desktop-embedded-browser.zh-CN.md)。这不会启用两平台的 +嵌入式后端认证;该宿主入口仍关闭。原生 Wayland 仍明确报不支持。 ## 开发者后端接口约定 diff --git a/desktop/README.md b/desktop/README.md index 188b330d..267d925d 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -128,6 +128,10 @@ App Hub's modules have no process form and always open in-process. | `OCTOSENSE_REDUCE_MOTION=1` | Keep the glance panel and its toasts still instead of sliding them in and out ([The glance panel](#the-glance-panel)). | | `MAKEPAD_REMOTE`, `MAKEPAD_HIDE_WINDOWS` | Remote-control bridge; hidden windows (see [Demos](#demos)). | +Embedded pages on Linux X11/XWayland and Windows use native WebKitGTK/WebView2 +child views. See [runtime requirements and acceptance](../docs/desktop-embedded-browser.md); +missing engines and native Wayland report an explicit error. + ## Application icons Both `octosense` and `octosense-home` use OctoSense's green eight-petal mark. diff --git a/desktop/README.zh-CN.md b/desktop/README.zh-CN.md index 4d71de3b..93b450c1 100644 --- a/desktop/README.zh-CN.md +++ b/desktop/README.zh-CN.md @@ -128,6 +128,10 @@ App Hub 的模块没有进程形态,总是在进程内打开。 | `OCTOSENSE_REDUCE_MOTION=1` | 一览面板及其 toast 不再滑入滑出,保持静止(见[一览面板](#一览面板))。 | | `MAKEPAD_REMOTE`、`MAKEPAD_HIDE_WINDOWS` | 远程控制桥;隐藏窗口(见[演示](#演示))。 | +Linux X11/XWayland 和 Windows 的嵌入页面使用原生 WebKitGTK/WebView2 子视图。 +见[运行条件与验收](../docs/desktop-embedded-browser.zh-CN.md);引擎缺失或原生 Wayland +会明确报错。 + ## 应用图标 `octosense` 与 `octosense-home` 都使用 OctoSense 的绿色八瓣标识。 diff --git a/desktop/src/main.rs b/desktop/src/main.rs index fd048dbf..b4e91864 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -5,4 +5,26 @@ use octosense_shell::makepad_widgets::*; use octosense_shell::App; +#[cfg(not(target_os = "linux"))] octosense_shell::octosense_main!(); + +// GTK's native embedded child uses XEmbed. Prefer the session's existing +// XWayland display before Makepad selects a backend; explicit CLI overrides +// and Vulkan builds retain their own backend requirements. +#[cfg(target_os = "linux")] +mod linux_entry { + use super::*; + octosense_shell::octosense_main!(); + pub fn start() { + main(); + } +} + +#[cfg(target_os = "linux")] +pub use linux_entry::app_main; + +#[cfg(target_os = "linux")] +fn main() { + Cx::prefer_x11_for_embedded_browser(); + linux_entry::start(); +} diff --git a/docs/adr/0012-app-host-api-discovery.md b/docs/adr/0012-app-host-api-discovery.md index e829db49..11921f69 100644 --- a/docs/adr/0012-app-host-api-discovery.md +++ b/docs/adr/0012-app-host-api-discovery.md @@ -137,8 +137,9 @@ these requirements. A successful bundle check alone does not prove services run. Embedded backend login is implemented on macOS/Android; Windows/Linux keep the separate external-browser authentication path, with platform execution still -requiring acceptance. Embedded `WebReader` is unsupported on Windows/Linux and -must fail visibly. Google Android login remains unsupported. The new device +requiring acceptance. The subsequent [desktop browser extension](../desktop-embedded-browser.md) adds +ordinary `WebReader` on Windows and Linux X11/XWayland; native Wayland and missing +engines fail visibly. This does not enable embedded backend authentication. Google Android login remains unsupported. The new device adapter does not advertise Windows/Linux/iOS support. No broad OS access, arbitrary Rust/native library execution, or Wasm loading is added. diff --git a/docs/adr/0012-app-host-api-discovery.zh-CN.md b/docs/adr/0012-app-host-api-discovery.zh-CN.md index a10c24f3..d5035d60 100644 --- a/docs/adr/0012-app-host-api-discovery.zh-CN.md +++ b/docs/adr/0012-app-host-api-discovery.zh-CN.md @@ -111,8 +111,9 @@ Android/macOS 提供摄像头、麦克风和位置权限的查询、申请与撤 宿主支持这些要求。通过应用包检查本身不证明服务能够运行。 macOS/Android 实现嵌入式后端登录;Windows/Linux 保留独立的外部浏览器认证 -路径,仍需平台执行验收。Windows/Linux 的嵌入式 `WebReader` 不受支持,必须 -明确报错。Google Android 登录仍不受支持。新设备适配器不宣称支持 +路径,仍需平台执行验收。后续[桌面浏览器扩展](../desktop-embedded-browser.zh-CN.md)为 Windows 和 +Linux X11/XWayland 提供普通 `WebReader`;原生 Wayland 或引擎缺失时明确报错。 +这不启用嵌入式后端认证。Google Android 登录仍不受支持。新设备适配器不宣称支持 Windows/Linux/iOS。不增加广泛系统访问、任意 Rust/原生动态库执行或 Wasm 加载。 ## 证据与待完成验收 diff --git a/docs/desktop-embedded-browser.md b/docs/desktop-embedded-browser.md new file mode 100644 index 00000000..0a64ab39 --- /dev/null +++ b/docs/desktop-embedded-browser.md @@ -0,0 +1,99 @@ +# Desktop embedded browser + +English | [简体中文](desktop-embedded-browser.zh-CN.md) + +The source build includes native `WebReader` adapters for Linux X11/XWayland +(WebKitGTK) and Windows (Microsoft Edge WebView2). These are child views inside +the Makepad window. They do not launch an external browser or use CEF. A release +binary must contain the new runtime overlay before it can offer this feature; +installing an app bundle alone cannot upgrade an older host. + +## Runtime requirements + +| Platform | Requirement and behavior | +| --- | --- | +| Linux X11 / XWayland | GTK 3 and WebKitGTK 4.1, with 4.0 as a compatible fallback. Libraries are loaded at runtime, so ordinary host compilation does not require WebKit development headers. The engine sandbox stays enabled. | +| Native Wayland | Embedded child views are not implemented. The view reports an explicit error. OctoSense's desktop entry point prefers an existing X11/XWayland display when available, unless an explicit backend option or Vulkan build requires another backend. It does not start an X server. | +| Windows | An installed Microsoft Edge WebView2 Runtime exposing `ICoreWebView2_27`. Missing engines or older runtimes fail explicitly; the host does not download an engine. The adapter uses `webview2-com` 0.39.1. | +| macOS / Android | Existing native reader adapters are unchanged by this desktop work. | + +A Linux package maintainer must include the appropriate distribution runtime +dependency, usually `libwebkit2gtk-4.1-0` and GTK 3. Windows deployment should +provide the supported WebView2 Runtime. This source change does not bundle either +engine. The Windows adapter deliberately requires the interfaces used to deny +native Save As and screen capture instead of silently omitting those gates. + +## App and host boundary + +Apps continue to use the existing `WebReader` widget and its capability checks. +A restricted reader can stay on its admitted document, including fragment changes. +A reader with the existing `web` grant can navigate to public HTTPS pages. Every +navigation is checked again, including redirects and subframes. Local files, +external-app schemes, credentialed URLs and malformed addresses are refused. +An explicitly host-admitted initial HTTP document remains usable; this permits +the isolated loopback acceptance fixture without granting arbitrary local URLs. + +Each opened view gets a fresh private browser session. Closing destroys its +native controller and page execution; reopening starts another session. Hiding +or clipping only detaches the visible overlay and preserves the current page. +The native child keeps its full document dimensions inside a clipping parent, +so scrolling the host does not reflow the page to the clipped rectangle. + +These views provide no OctoSense tool bridge. Browser-originated permission +requests, downloads and popups are denied. Ordinary pages cannot use a native +browser prompt to acquire the app's camera, microphone or filesystem authority. +URL validation is not a DNS or network sandbox: a permitted public hostname can +resolve to a private address. Do not treat a reader as an SSRF isolation boundary. + +Backend sign-in is a separate host-owned flow. **Linux and Windows still use the +existing external-browser authentication route**; ordinary reader support does +not enable embedded OAuth or expose login cookies to apps. Linux WebKitGTK cannot +reliably prove a navigation is the main frame before requesting an authentication +callback, so the adapter refuses embedded authentication. The Windows adapter +contains callback interception, but production embedded sign-in remains disabled +until its complete host-owned authentication flow is accepted. Google and GitHub +retain their existing provider flows. See the [connected-account guide](../crates/oauth-service/README.md). + +## Source walkthrough + +- `desktop/src/main.rs` opts the Linux desktop into X11/XWayland selection before + Makepad chooses a windowing backend; an explicit backend selection wins. +- `tools/runtime-patches/makepad-desktop-webview.patch` contains the reviewed + runtime extension. `runtime-patches.lock.json` pins both its digest and the + resulting Makepad tree. `tools/setup.py` applies it over the existing overlays. +- Makepad's `system_browser::BrowserPolicy` parses and checks URLs with `url`. + `linux_webkit.rs` embeds a GTK plug in an X11 child socket and pumps bounded GTK + work on the existing UI thread. `windows_webview.rs` embeds a WebView2 controller + in a clipping child HWND and receives asynchronous callbacks on the UI STA. +- `WebReader` retains its existing overlay lifecycle and converts native loading, + navigation and failure events into widget state. A missing engine reaches this + error path instead of leaving an invisible successful view. + +## Acceptance + +`crates/browser-smoke` is an unpublished native test host, separate from the +production shell. `tools/browser-smoke.py` serves synthetic HTML on loopback and +drives that host through a private control directory. It checks real page +JavaScript, DOM editing, navigation rejection before a forbidden HTTP request, +view detach/reattach, stopped execution after close, fresh cookies after reopen, +and native network errors. It never uses personal accounts or downloads engines. +The Windows workflow also requires an engine-owned PNG snapshot and metadata. + +The shared URL-policy tests run with `cargo test --locked -p octosense-browser-smoke` +and cover restricted documents, public HTTPS, malformed/private URLs and exact +main-frame callbacks. They do not prove native navigation interception by themselves. +The native driver uses automated DOM input; it does not certify physical typing, +accessibility, visual quality, full shell UX, or OAuth. + +Linux native acceptance passed eight checks using WebKitGTK 2.52.6 and Xvfb +21.1.22 with the engine sandbox enabled. An XQueryTree probe additionally proved +that the GTK plug is a child of the socket inside the Makepad window, with valid +geometry; it did not capture the display. Windows native acceptance remains +**unverified** until its CI receipt passes. Detailed receipts belong to the PR +and CI artifacts. The `.github/workflows/embedded-browser.yml` Windows job fails +when its installed engine is absent or incompatible; it never converts that into +a skipped success. Only synthetic receipts and engine-owned captures are uploaded. + +The local CI merge helper also requires the native Windows workflow to pass on +the exact PR head whenever these browser files change. A local macOS/Linux pass +cannot replace that evidence. diff --git a/docs/desktop-embedded-browser.zh-CN.md b/docs/desktop-embedded-browser.zh-CN.md new file mode 100644 index 00000000..81246000 --- /dev/null +++ b/docs/desktop-embedded-browser.zh-CN.md @@ -0,0 +1,80 @@ +# 桌面嵌入式浏览器 + +[English](desktop-embedded-browser.md) | 简体中文 + +源码构建为 Linux X11/XWayland(WebKitGTK)和 Windows(Microsoft Edge WebView2) +增加了原生 `WebReader` 适配器。网页作为子视图嵌入 Makepad 窗口,不会启动外部 +浏览器,也不使用 CEF。发布二进制必须包含新的运行时补丁才能提供此功能; +仅安装应用包不能升级旧宿主。 + +## 运行条件 + +| 平台 | 条件与行为 | +| --- | --- | +| Linux X11 / XWayland | GTK 3 与 WebKitGTK 4.1;兼容的 4.0 为后备。运行时动态加载库,普通宿主编译不需要 WebKit 开发头文件。引擎沙箱保持开启。 | +| 原生 Wayland | 尚未实现嵌入子视图,控件明确报错。桌面入口在现有 X11/XWayland 显示可用时优先选择它;显式后端选项和 Vulkan 构建的要求优先。不会自行启动 X 服务。 | +| Windows | 需要已安装且提供 `ICoreWebView2_27` 的 Microsoft Edge WebView2 Runtime。引擎缺失或版本过旧会明确失败,宿主不会下载引擎。适配器使用 `webview2-com` 0.39.1。 | +| macOS / Android | 本次桌面改动不改变已有原生阅读器适配器。 | + +Linux 打包维护者需要声明对应发行版的运行库依赖,通常为 `libwebkit2gtk-4.1-0` +与 GTK 3。Windows 部署需要提供受支持的 WebView2 Runtime。本次源码改动不打包 +任何引擎。Windows 适配器要求用于阻止原生“另存为”和屏幕捕获的接口, +不会在旧引擎上悄悄跳过这些限制。 + +## 应用与宿主边界 + +应用继续使用已有 `WebReader` 控件及权限检查。受限阅读器只能停留在已准入的 +文档(可改变片段位置)。拥有现有 `web` 授权的阅读器可以访问公开 HTTPS 页面。 +每次导航都重新检查,包括重定向和子框架。拒绝本地文件、外部应用协议、含账号 +凭据的 URL 与畸形地址。宿主明确准入的初始 HTTP 文档仍可使用,因此隔离的 +回环测试页面不会顺带授予任意本地地址访问权。 + +每个新视图使用新的私有浏览器会话。关闭会销毁原生控制器并停止页面执行; +再次打开使用新会话。隐藏或裁剪只移开可见覆盖层,保留当前页面。原生子视图 +在裁剪父窗口中保持完整文档尺寸,避免宿主滚动时网页按可见小块重新排版。 + +网页没有 OctoSense 工具桥。拒绝网页发起的原生权限请求、下载及弹窗,不能借 +浏览器权限框取得应用的摄像头、麦克风或文件权限。URL 校验不是 DNS 或网络 +沙箱:获准的公开域名仍可能解析到私有地址,因此不能把阅读器当作 SSRF 隔离边界。 + +后端登录是独立的宿主流程。**Linux 和 Windows 仍使用已有外部浏览器认证路径**; +普通阅读器支持不会启用嵌入式 OAuth,也不会把登录 Cookie 交给应用。Linux +WebKitGTK 不能在请求认证回调前可靠证明导航属于主框架,因此拒绝嵌入式认证。 +Windows 适配器包含回调拦截,但完整宿主认证流程通过验收前,生产入口仍不启用 +该模式。Google、GitHub 保留现有提供方流程。见[已连接账户指南](../crates/oauth-service/README.zh-CN.md)。 + +## 代码导读 + +- `desktop/src/main.rs` 在 Makepad 选择窗口后端之前启用 Linux 桌面的 X11/XWayland + 优先策略;显式后端选择始终优先。 +- `tools/runtime-patches/makepad-desktop-webview.patch` 保存运行时扩展。 + `runtime-patches.lock.json` 固定补丁摘要与最终 Makepad 树,`tools/setup.py` + 将它叠加在现有补丁上。 +- Makepad 的 `system_browser::BrowserPolicy` 使用 `url` 解析和检查地址。 + `linux_webkit.rs` 将 GTK plug 嵌入 X11 子 socket,在已有 UI 线程中有界处理 GTK + 事件。`windows_webview.rs` 把 WebView2 控制器嵌入裁剪用子 HWND,并在 UI STA + 接收异步回调。 +- `WebReader` 保留已有覆盖层生命周期,把原生加载、导航和失败事件转换为控件 + 状态。引擎缺失会进入错误状态,不会留下声称成功的不可见视图。 + +## 验收 + +`crates/browser-smoke` 是不发布的原生测试宿主,与生产 Shell 分开。 +`tools/browser-smoke.py` 在回环地址提供合成 HTML,通过私有控制目录驱动测试宿主。 +检查真实页面 JavaScript、DOM 编辑、禁止导航是否在 HTTP 请求前被拦截、视图隐藏与 +恢复、关闭后停止执行、重开后全新 Cookie,以及原生网络错误。不使用个人账户, +也不下载引擎。Windows 工作流还要求浏览器引擎自行产生 PNG 快照和元数据。 + +共享 URL 策略测试使用 `cargo test --locked -p octosense-browser-smoke`,覆盖受限 +文档、公开 HTTPS、畸形/私有地址及精确主框架回调;它们本身不证明原生导航拦截。 +原生驱动使用自动 DOM 输入,不代表物理键盘输入、无障碍、视觉质量、完整 Shell +UX 或 OAuth 已验收。 + +Linux 已使用 WebKitGTK 2.52.6 和 Xvfb 21.1.22 通过八项原生检查,引擎沙箱 +保持开启。XQueryTree 探针证明 GTK plug 位于 Makepad 窗口中的子 socket 内, +且几何尺寸有效,没有捕获整个显示器。Windows 原生验收在 CI 回执通过前仍为 +**未验证**;详细回执记录在 PR 和 CI 工件中。`.github/workflows/embedded-browser.yml` 的 Windows 任务在 +引擎缺失或不兼容时失败,不把它算作跳过后通过。只上传合成回执与引擎自身的捕获。 + +修改浏览器文件时,本地 CI 合并工具同样要求 Windows 原生工作流在精确的 PR +提交上通过。macOS/Linux 本地通过不能代替这项证据。 diff --git a/runtime-patches.lock.json b/runtime-patches.lock.json index be927939..1503e1f7 100644 --- a/runtime-patches.lock.json +++ b/runtime-patches.lock.json @@ -4,7 +4,7 @@ "base_revision": "68d1f4ecc111daa90c50530e77df3245f05fc2cf", "patch": "tools/runtime-patches/makepad-settings.patch", "sha256": "b17352ebf59e50ab3db6ff1cb825c4e006bcdff47d83522e372b8fe585e57570", - "tree": "96ea6f774be9edc7c26c895883daa4b81b333d33", + "tree": "408d6742481ec063b4c152bd8cd381b32faef3f8", "product_fixes": [ "Settings integration: Android IME synchronization, accessibility and reduced motion, GLES program-cache validation, obscured-gesture cancellation and scroll-position observation.", "Contained-app isolation and secret-input restrictions come from the pinned upstream runtime; the Settings overlay preserves those gates.", @@ -25,10 +25,10 @@ "Label keeps its native wrapping default across retained theme reapplication; named labels populated after mounting stay wrapped, while explicit app no-wrap flows remain unchanged.", "Android native Camera checks each startup stage, cleans partially initialized resources, reports missing first frames after a bounded wait, and retries only after an explicit user action; unrelated video events cannot change its status.", "Host API v1 apps opt into host-owned device consent before source evaluation. Cached host grants gate camera, microphone and location paths; camera revocation stops an active preview and cached GPS reads fail closed. Older bundles keep their existing capability behavior.", - "WebReader refuses unsupported Linux/Windows platforms before queuing native browser operations instead of reporting success for an invisible view; this does not add embedded browser support.", "Opted-in camera and microphone check OS grants before prompting; background/agent requests retain nonprompt authority through callbacks and cannot use desktop timeout approval. Passive location reads remain available; legacy GPS requests require foreground prompt authority.", "Host request JSON and pre-drain queues are bounded before native allocation; delayed host callbacks retain their nonprompting origin after an agent tool completes or is cancelled.", - "Agent/background prompt denial follows detached timers, tasks, paused continuations, widget callbacks and HTTP/WebSocket callbacks, including native device helpers. A fresh foreground entry retains its own authority; scoped denial restores on exit or unwind." + "Agent/background prompt denial follows detached timers, tasks, paused continuations, widget callbacks and HTTP/WebSocket callbacks, including native device helpers. A fresh foreground entry retains its own authority; scoped denial restores on exit or unwind.", + "Ordinary WebReader embeds sandboxed WebKitGTK in X11/XWayland and private WebView2 controllers on Windows, with bounded UI integration, URL policy, no app bridge and explicit engine errors. Native Wayland and Linux/Windows production embedded authentication remain unsupported." ], "stacked": [ { @@ -110,6 +110,10 @@ { "patch": "tools/runtime-patches/makepad-async-prompt-provenance.patch", "sha256": "46bb7620ccb4f6a76a08d0333c0249d2619123da2a2649f55294fd4d35f0d905" + }, + { + "patch": "tools/runtime-patches/makepad-desktop-webview.patch", + "sha256": "f477e4e955a343ae37843db463cf5a3c897f27a62da7a6568939658dc915038b" } ] } diff --git a/tools/browser-smoke.py b/tools/browser-smoke.py new file mode 100644 index 00000000..c453279e --- /dev/null +++ b/tools/browser-smoke.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +"""Exercise the real desktop browser adapter against a disposable local page. + +Requires an already built octosense-browser-smoke and the platform's installed +browser runtime. This never downloads or installs a runtime, opens personal +accounts, or uses a simulated renderer. Keep the output directory private. +""" +import argparse +import hashlib +import http.server +import json +import os +from pathlib import Path +import socket +import subprocess +import threading +import time + + +HTML = b'''Embedded browser fixture + +

Embedded browser fixture

A real native browser renders this page. +It contains no account, password or personal information.

+ +

Ready

''' + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--binary', type=Path, required=True) + parser.add_argument('--out', type=Path, required=True) + parser.add_argument('--host-arg', action='append', default=[]) + parser.add_argument('--require-snapshot', action='store_true') + parser.add_argument('--require-xembed', action='store_true', help='Probe geometry on an isolated, owned Linux X display only') + args = parser.parse_args() + binary = args.binary.resolve(strict=True) + root = args.out.resolve() + root.mkdir(mode=0o700, parents=True, exist_ok=False) + requests = [] + requests_lock = threading.Lock() + checks = [] + + class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, *_): + pass + + def do_GET(self): + with requests_lock: + requests.append({'method': 'GET', 'path': self.path, + 'cookie_present': bool(self.headers.get('Cookie'))}) + self.send_response(200) + self.send_header('Content-Type', 'text/html; charset=utf-8') + self.send_header('Content-Length', str(len(HTML))) + self.send_header('Set-Cookie', 'fixture=synthetic; HttpOnly; SameSite=Strict; Path=/') + self.end_headers() + self.wfile.write(HTML) + + def do_POST(self): + length = min(int(self.headers.get('Content-Length', 0)), 4096) + try: + body = json.loads(self.rfile.read(length)) + except (ValueError, OSError): + body = {'invalid': True} + with requests_lock: + requests.append({'method': 'POST', 'path': self.path, 'body': body, + 'cookie_present': bool(self.headers.get('Cookie'))}) + self.send_response(204) + self.end_headers() + + server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + page = f'http://127.0.0.1:{server.server_port}/page' + env = os.environ.copy() + env['MAKEPAD_HIDE_WINDOWS'] = '1' + process = None + sequence = 0 + failure = None + + def events(): + path = root / 'events.jsonl' + if not path.is_file(): + return [] + result = [] + for line in path.read_text(encoding='utf-8').splitlines(): + try: + result.append(json.loads(line)) + except ValueError: + pass # A writer can be between write() and its newline. + return result + + def wait(predicate, label, timeout=30): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return + if process is not None and process.poll() is not None: + raise AssertionError(f'Native host exited during {label} (status {process.returncode})') + time.sleep(.1) + raise AssertionError(f'Timed out: {label}') + + def command(operation, **fields): + nonlocal sequence + sequence += 1 + staged = root / 'command.next' + staged.write_text(json.dumps({'id': sequence, 'op': operation, **fields}), encoding='utf-8') + staged.replace(root / 'command.json') + wait(lambda: any(e.get('kind') == 'command' and e.get('id') == sequence for e in events()), operation) + assert any(e.get('id') == sequence and e.get('accepted') for e in events()), operation + return sequence + + def server_events(): + with requests_lock: + return list(requests) + + try: + with (root / 'native.log').open('wb') as log: + process = subprocess.Popen([str(binary), f'--control-root={root}', '--remote', *args.host_arg], + env=env, stdout=log, stderr=subprocess.STDOUT) + wait(lambda: any(e.get('kind') == 'started' for e in events()), 'native host startup') + command('open', url=page) + wait(lambda: any(e.get('body', {}).get('kind') == 'loaded' for e in server_events()), 'real document JavaScript') + loaded = next(e for e in server_events() if e.get('body', {}).get('kind') == 'loaded') + assert loaded['body']['width'] > 100 and loaded['body']['height'] > 100, loaded['body'] + assert not loaded['body']['bridge'], 'Ordinary page received a native bridge' + assert loaded['cookie_present'], 'Browser did not retain its own HttpOnly fixture cookie' + checks.append('native_document_loaded_without_bridge') + if args.require_xembed: + from browser_smoke_x11 import inspect_embedding + (root / 'embedding.json').write_text(json.dumps(inspect_embedding(), indent=2) + '\n', encoding='utf-8') + checks.append('real_xembed_child_hierarchy') + + command('eval', script="document.querySelector('#message').value='Reviewed locally';document.querySelector('#submit').click()") + wait(lambda: any(e.get('body', {}).get('value') == 'Reviewed locally' for e in server_events()), 'real DOM edit and form action') + checks.append('real_dom_edit_and_action') + + if args.require_snapshot: + capture = command('inspect') + wait(lambda: (root / f'inspect-{capture}.json').is_file(), 'native engine snapshot') + meta = json.loads((root / f'inspect-{capture}.json').read_text(encoding='utf-8')) + assert not meta.get('error'), meta.get('error') + assert meta.get('title') == 'Saved: Reviewed locally' + assert meta.get('viewportWidth', 0) > 100 and meta.get('viewportHeight', 0) > 100 + assert not meta.get('hostBridgePresent'), 'Inspection found a native bridge' + snapshot = (root / f'snapshot-{capture}.png').read_bytes() + assert len(snapshot) > 100 and snapshot.startswith(bytes([137, 80, 78, 71, 13, 10, 26, 10])) + checks.append('native_engine_snapshot_saved') + + before = len(events()) + command('eval', script="location.href='/forbidden'") + wait(lambda: any(e.get('kind') == 'page_error' for e in events()[before:]), 'restricted navigation rejection') + assert not any(e['path'] == '/forbidden' for e in server_events()), 'Forbidden navigation reached server' + checks.append('restricted_navigation_blocked_before_request') + command('hide') + command('show') + checks.append('detach_and_reattach_commands_accepted') + command('close') + time.sleep(.7) + stopped = len(server_events()) + time.sleep(1) + assert len(server_events()) == stopped, 'Closed native page kept making requests' + checks.append('close_stops_page_execution') + + before = len(server_events()) + command('open', url=page) + wait(lambda: any(e.get('body', {}).get('kind') == 'loaded' for e in server_events()[before:]), 'fresh browser after close') + reopened = next(e for e in server_events()[before:] if e['method'] == 'GET' and e['path'] == '/page') + assert not reopened['cookie_present'], 'Reopened view reused the previous profile' + checks.append('reopen_uses_fresh_profile') + + with socket.socket() as unused: + unused.bind(('127.0.0.1', 0)) + closed_port = unused.getsockname()[1] + before = len(events()) + command('open', url=f'http://127.0.0.1:{closed_port}/unavailable') + wait(lambda: any(e.get('kind') == 'page_error' for e in events()[before:]), 'native network failure') + checks.append('native_network_error_reported') + command('quit') + process.wait(timeout=10) + assert process.returncode == 0 + except Exception as error: + failure = str(error) + finally: + if process is not None and process.poll() is None: + try: + command('quit') + process.wait(timeout=5) + except Exception: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + server.shutdown() + server.server_close() + thread.join(timeout=2) + receipt = {'schema': 1, 'platform': os.name, 'binary_sha256': hashlib.sha256(binary.read_bytes()).hexdigest(), + 'checks': checks, 'passed': failure is None, 'failure': failure, + 'scope': 'real native engine; DOM actions are automated, not physical typing or visual UX approval', + 'owned_process_exited': process is None or process.poll() is not None} + (root / 'requests.json').write_text(json.dumps(server_events(), indent=2) + '\n', encoding='utf-8') + (root / 'receipt.json').write_text(json.dumps(receipt, indent=2) + '\n', encoding='utf-8') + print(json.dumps(receipt, indent=2)) + return 1 if failure else 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tools/browser_smoke_x11.py b/tools/browser_smoke_x11.py new file mode 100644 index 00000000..71483dd0 --- /dev/null +++ b/tools/browser_smoke_x11.py @@ -0,0 +1,57 @@ +"""Inspect the owned Xvfb fixture's actual XEmbed hierarchy; no screen capture.""" +import ctypes as c +import os + + +def inspect_embedding(): + lib = c.CDLL('libX11.so.6') + display_t = c.c_void_p + window_t = c.c_ulong + lib.XOpenDisplay.argtypes = [c.c_char_p] + lib.XOpenDisplay.restype = display_t + lib.XDefaultRootWindow.argtypes = [display_t] + lib.XDefaultRootWindow.restype = window_t + lib.XQueryTree.argtypes = [display_t, window_t, c.POINTER(window_t), c.POINTER(window_t), c.POINTER(c.POINTER(window_t)), c.POINTER(c.c_uint)] + lib.XInternAtom.argtypes = [display_t, c.c_char_p, c.c_int] + lib.XInternAtom.restype = c.c_ulong + lib.XGetWindowProperty.argtypes = [display_t, window_t, c.c_ulong, c.c_long, c.c_long, c.c_int, c.c_ulong, c.POINTER(c.c_ulong), c.POINTER(c.c_int), c.POINTER(c.c_ulong), c.POINTER(c.c_ulong), c.POINTER(c.c_void_p)] + lib.XGetGeometry.argtypes = [display_t, window_t, c.POINTER(window_t), c.POINTER(c.c_int), c.POINTER(c.c_int), c.POINTER(c.c_uint), c.POINTER(c.c_uint), c.POINTER(c.c_uint), c.POINTER(c.c_uint)] + lib.XFree.argtypes = [c.c_void_p] + lib.XCloseDisplay.argtypes = [display_t] + display = lib.XOpenDisplay(os.environ['DISPLAY'].encode()) + assert display, 'Owned X display is unavailable' + try: + root = lib.XDefaultRootWindow(display) + atom = lib.XInternAtom(display, b'_XEMBED_INFO', 0) + parents, plugs = {}, [] + queue = [root] + while queue: + window = queue.pop() + tree_root, parent = window_t(), window_t() + children, count = c.POINTER(window_t)(), c.c_uint() + if not lib.XQueryTree(display, window, c.byref(tree_root), c.byref(parent), c.byref(children), c.byref(count)): + continue + parents[window] = parent.value + queue.extend(children[index] for index in range(min(count.value, 256))) + if children: lib.XFree(children) + assert len(parents) < 256, 'Unexpectedly large owned X fixture' + actual, fmt, items, after, data = c.c_ulong(), c.c_int(), c.c_ulong(), c.c_ulong(), c.c_void_p() + status = lib.XGetWindowProperty(display, window, atom, 0, 2, 0, 0, c.byref(actual), c.byref(fmt), c.byref(items), c.byref(after), c.byref(data)) + if status == 0 and fmt.value == 32 and items.value >= 2: + plugs.append(window) + if data: lib.XFree(data) + assert len(plugs) == 1, f'Expected one embedded GtkPlug, got {len(plugs)}' + plug = plugs[0] + socket = parents[plug] + host = parents[socket] + assert host != root and parents[host] == root, 'GTK browser is not a child of the existing app window' + def geometry(window): + r, x, y, width, height, border, depth = window_t(), c.c_int(), c.c_int(), c.c_uint(), c.c_uint(), c.c_uint(), c.c_uint() + assert lib.XGetGeometry(display, window, c.byref(r), c.byref(x), c.byref(y), c.byref(width), c.byref(height), c.byref(border), c.byref(depth)) + return dict(x=x.value, y=y.value, width=width.value, height=height.value) + result = {'hierarchy': 'GtkPlug -> native child socket -> Makepad window -> X root', 'plug': geometry(plug), 'socket': geometry(socket), 'host': geometry(host)} + assert result['socket']['width'] > 100 and result['socket']['height'] > 100 + assert result['plug']['width'] >= result['socket']['width'] and result['plug']['height'] >= result['socket']['height'] + return result + finally: + lib.XCloseDisplay(display) diff --git a/tools/ci_local.py b/tools/ci_local.py index 502cb3ee..e0a3371d 100644 --- a/tools/ci_local.py +++ b/tools/ci_local.py @@ -71,6 +71,8 @@ # Workflows ci-local deliberately does not run, and why. --check-drift skips # them (an entry naming no workflow, or one ci-local runs, is drift). NOT_LOCAL = { + "embedded-browser.yml": "native Windows WebView2 acceptance needs a Windows desktop and installed engine; " + "ci-local-merge requires a successful GitHub run on the exact PR head", "release-desktop.yml": "a release workflow (tag push or manual run): signed packages for three OSes, " "with a matrix, environments, secrets and artifacts; nothing a pull request merges " "on depends on it, and its packaging scripts' tests run in desktop.yml", diff --git a/tools/ci_local_merge.py b/tools/ci_local_merge.py index d97e2c86..7cee4c35 100644 --- a/tools/ci_local_merge.py +++ b/tools/ci_local_merge.py @@ -92,6 +92,25 @@ def evidence_problems(last, head, changed_files): return problems, required +# Native Windows acceptance cannot be replaced by a macOS/Linux local pass. +REMOTE_REQUIRED = ("embedded-browser.yml",) + + +def remote_evidence_problems(head, changed_files): + problems = [] + required = [] + for workflow in REMOTE_REQUIRED: + paths = ci_local.pull_request_paths(workflow) + if paths is not None and not any(ci_local.glob_match(f, p) for f in changed_files for p in paths): + continue + required.append(workflow) + runs = gh_json(["run", "list", "--workflow", workflow, "--commit", head, + "--event", "pull_request", "--limit", "1", "--json", "headSha,status,conclusion"]) + if not runs or runs[0].get("headSha") != head or runs[0].get("status") != "completed" or runs[0].get("conclusion") != "success": + problems.append(f"{workflow}: native GitHub acceptance has not passed on {head[:12]}") + return problems, required + + RED = ("failure", "timed_out", "startup_failure") @@ -165,12 +184,17 @@ def main(argv=None): problems, required = evidence_problems(last, head, changed) if problems: raise Refused("the local run is not evidence for this merge:\n - " + "\n - ".join(problems)) - red = red_main(ci_local.GROUPS["all"]) + remote_problems, remote_required = remote_evidence_problems(head, changed) + if remote_problems: + raise Refused("native acceptance cannot be replaced by local CI:\n - " + "\n - ".join(remote_problems)) + red = red_main(ci_local.GROUPS["all"] + list(REMOTE_REQUIRED)) if red and not args.fixes_main: raise Refused("main is red on GitHub; fix it first (or pass --fixes-main for the fix):\n - " + "\n - ".join(red)) body = comment_body(last, required) + if remote_required: + body += "\nNative GitHub acceptance passed on the same head: " + ", ".join(remote_required) + ".\n" owner = (info.get("headRepositoryOwner") or {}).get("login", "") subject = f"Merge pull request #{args.pr} from {owner}/{info['headRefName']}" merge_body = f"Local CI (tools/ci-local.sh --only {last['only']}) passed on {head}." diff --git a/tools/runtime-patches/makepad-desktop-webview.patch b/tools/runtime-patches/makepad-desktop-webview.patch new file mode 100644 index 00000000..9a20d065 --- /dev/null +++ b/tools/runtime-patches/makepad-desktop-webview.patch @@ -0,0 +1,2198 @@ +diff --git a/platform/Cargo.toml b/platform/Cargo.toml +index 8f4a84ffd..31b5a5398 100644 +--- a/platform/Cargo.toml ++++ b/platform/Cargo.toml +@@ -43,6 +43,7 @@ makepad-zune-png = { path = "../libs/zune/zune-png", version = "0.5.2" } + makepad-tsdf = { path = "../libs/tsdf", version = "0.1.0" } + smallvec = { version = "1.15", path = "../libs/smallvec" } + bitflags = { version = "2.10", path = "../libs/bitflags" } ++url = "2.5.8" + + [target.wasm32-unknown-unknown.dependencies] + makepad-wasm-bridge = { path = "../libs/wasm_bridge", version = "1.0.0" } +@@ -107,6 +108,9 @@ ohos-sys = { version = "0.2.1", features = ["xcomponent"] } + path = "../libs/futures_legacy" + version = "1.0.0" + ++[target.'cfg(windows)'.dependencies.webview2-com] ++version = "=0.39.1" ++ + [target.'cfg(windows)'.dependencies.windows-core] + path = "../libs/windows/windows-core" + version = "0.62.2" +@@ -165,3 +169,8 @@ features = [ + ## `windows-targets` v0.52.*, so we use that version to avoid duplicates. + [target.'cfg(windows)'.dependencies.windows-targets] + version = "0.52" ++ ++[target.'cfg(windows)'.dependencies.webview2-windows] ++package = "windows" ++version = "=0.62.2" ++features = ["Win32_Foundation", "Win32_System_Com", "Win32_System_Com_StructuredStorage", "Win32_System_LibraryLoader", "Win32_UI_WindowsAndMessaging"] +diff --git a/platform/src/cx_api.rs b/platform/src/cx_api.rs +index 96085e29b..8c8072dc9 100644 +--- a/platform/src/cx_api.rs ++++ b/platform/src/cx_api.rs +@@ -209,8 +209,8 @@ pub fn close(&mut self) { + } + + /// Inspect the app's own native WebView asynchronously. The snapshot is +- /// captured by WebKit, including content absent from the Metal drawable. +- #[cfg(target_os = "macos")] ++ /// captured by the native browser, including content absent from the GPU drawable. ++ #[cfg(any(target_os = "macos", target_os = "windows"))] + pub fn inspect(&mut self, result_path: String, snapshot_path: Option, scroll_y: Option) { + self.cx.platform_ops.push_back(CxOsOp::InspectSystemBrowser { + browser_id: self.id.0, result_path, snapshot_path, scroll_y, +@@ -538,7 +538,7 @@ pub enum CxOsOp { + script: String, + result_path: String, + }, +- #[cfg(target_os = "macos")] ++ #[cfg(any(target_os = "macos", target_os = "windows"))] + InspectSystemBrowser { + browser_id: LiveId, + result_path: String, +@@ -701,7 +701,7 @@ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Self::CloseSystemBrowser { .. } => write!(f, "CloseSystemBrowser"), + #[cfg(target_os = "macos")] + Self::EvaluateAuthBrowser { .. } => write!(f, "EvaluateAuthBrowser"), +- #[cfg(target_os = "macos")] ++ #[cfg(any(target_os = "macos", target_os = "windows"))] + Self::InspectSystemBrowser { .. } => write!(f, "InspectSystemBrowser"), + Self::SetSystemBrowserHtml { .. } => write!(f, "SetSystemBrowserHtml"), + Self::EvalSystemBrowserJs { .. } => write!(f, "EvalSystemBrowserJs"), +diff --git a/platform/src/lib.rs b/platform/src/lib.rs +index 6e041dd51..ce292b230 100644 +--- a/platform/src/lib.rs ++++ b/platform/src/lib.rs +@@ -98,6 +98,7 @@ + mod xr_tsdf; + + pub mod web_socket; ++pub mod system_browser; + + pub mod audio_stream; + +diff --git a/platform/src/os/linux/linux_webkit.rs b/platform/src/os/linux/linux_webkit.rs +new file mode 100644 +index 000000000..d079a6d60 +--- /dev/null ++++ b/platform/src/os/linux/linux_webkit.rs +@@ -0,0 +1,507 @@ ++//! Optional WebKitGTK browser, embedded by XEmbed in an existing X11 window. ++//! ++//! Only system libraries are loaded; ordinary Makepad builds require no GTK or ++//! WebKit development packages. GTK owns a child GtkPlug, never a free-standing ++//! application window. All GTK operations and callbacks run on the UI thread. ++use super::{module_loader::ModuleLoader, x11::x11_sys as x}; ++use crate::{ ++ cx::Cx, ++ event::{NativeAuthBrowserEvent, NativeSystemBrowserNavigation, NativeSystemBrowserPageError}, ++ makepad_math::Rect, ++ system_browser::{BrowserPolicy, NavigationDecision}, ++ window::WindowId, ++}; ++use std::{ ++ cell::{Cell, RefCell}, ++ ffi::{c_char, c_int, c_long, c_uint, c_ulong, c_void, CStr, CString}, ++ mem::ManuallyDrop, ++ ptr, ++ rc::Rc, ++}; ++ ++type Object = *mut c_void; ++type Callback = unsafe extern "C" fn(); ++ ++#[repr(C)] ++struct Allocation { x: c_int, y: c_int, width: c_int, height: c_int } ++ ++macro_rules! api { ++ ($($name:ident: $ty:ty),* $(,)?) => { ++ struct Api { ++ // GTypes retain class/vtable addresses until process exit, even ++ // after the last view closes. Never dlclose their libraries. ++ _libraries: ManuallyDrop>, ++ $($name: $ty,)* ++ } ++ impl Api { ++ fn load() -> Result, &'static str> { ++ let gtk = ModuleLoader::load("libgtk-3.so.0") ++ .map_err(|_| "Install the system GTK3 and WebKitGTK 4.1 runtime to use embedded browsing")?; ++ let webkit = ModuleLoader::load("libwebkit2gtk-4.1.so.0") ++ .or_else(|_| ModuleLoader::load("libwebkit2gtk-4.0.so.37")) ++ .map_err(|_| "Install the system WebKitGTK 4.1 runtime to use embedded browsing")?; ++ let api = Self { ++ $($name: webkit.get_symbol(stringify!($name)) ++ .or_else(|_| gtk.get_symbol(stringify!($name))) ++ .map_err(|_| "The installed WebKitGTK/GTK3 runtime is missing a required browser API")?,)* ++ _libraries: ManuallyDrop::new(vec![gtk, webkit]), ++ }; ++ unsafe { ++ // GTK must use the same protocol as our parent X11 window. ++ // This does not change Makepad's backend or process env. ++ (api.gdk_set_allowed_backends)(c"x11".as_ptr()); ++ if (api.gtk_init_check)(ptr::null_mut(), ptr::null_mut()) == 0 { ++ return Err("GTK could not connect to the X11 display"); ++ } ++ } ++ Ok(Rc::new(api)) ++ } ++ } ++ }; ++} ++api! { ++ gdk_set_allowed_backends: unsafe extern "C" fn(*const c_char), ++ gdk_display_get_default: unsafe extern "C" fn() -> Object, ++ gdk_display_sync: unsafe extern "C" fn(Object), ++ gdk_x11_display_error_trap_push: unsafe extern "C" fn(Object), ++ gdk_x11_display_error_trap_pop_ignored: unsafe extern "C" fn(Object), ++ gtk_init_check: unsafe extern "C" fn(*mut c_int, *mut *mut *mut c_char) -> c_int, ++ g_main_context_iteration: unsafe extern "C" fn(Object, c_int) -> c_int, ++ g_signal_connect_data: unsafe extern "C" fn(Object, *const c_char, Callback, Object, Option, c_uint) -> c_ulong, ++ g_signal_handler_disconnect: unsafe extern "C" fn(Object, c_ulong), ++ g_object_unref: unsafe extern "C" fn(Object), ++ gtk_plug_new: unsafe extern "C" fn(c_ulong) -> Object, ++ gtk_plug_get_id: unsafe extern "C" fn(Object) -> c_ulong, ++ gtk_container_add: unsafe extern "C" fn(Object, Object), ++ gtk_widget_show_all: unsafe extern "C" fn(Object), ++ gtk_widget_destroy: unsafe extern "C" fn(Object), ++ gtk_widget_set_size_request: unsafe extern "C" fn(Object, c_int, c_int), ++ gtk_widget_size_allocate: unsafe extern "C" fn(Object, *const Allocation), ++ gtk_widget_get_scale_factor: unsafe extern "C" fn(Object) -> c_int, ++ gtk_widget_grab_focus: unsafe extern "C" fn(Object), ++ webkit_web_context_new_ephemeral: unsafe extern "C" fn() -> Object, ++ webkit_web_context_set_tls_errors_policy: unsafe extern "C" fn(Object, c_int), ++ webkit_web_view_new_with_context: unsafe extern "C" fn(Object) -> Object, ++ webkit_web_view_get_settings: unsafe extern "C" fn(Object) -> Object, ++ webkit_settings_set_javascript_can_open_windows_automatically: unsafe extern "C" fn(Object, c_int), ++ webkit_settings_set_enable_developer_extras: unsafe extern "C" fn(Object, c_int), ++ webkit_web_view_set_zoom_level: unsafe extern "C" fn(Object, f64), ++ webkit_web_view_load_uri: unsafe extern "C" fn(Object, *const c_char), ++ webkit_web_view_load_html: unsafe extern "C" fn(Object, *const c_char, *const c_char), ++ webkit_web_view_stop_loading: unsafe extern "C" fn(Object), ++ webkit_web_view_get_uri: unsafe extern "C" fn(Object) -> *const c_char, ++ webkit_web_view_get_title: unsafe extern "C" fn(Object) -> *const c_char, ++ webkit_web_view_is_loading: unsafe extern "C" fn(Object) -> c_int, ++ webkit_web_view_run_javascript: unsafe extern "C" fn(Object, *const c_char, Object, Option, Object), ++ webkit_web_view_get_back_forward_list: unsafe extern "C" fn(Object) -> Object, ++ webkit_back_forward_list_get_nth_item: unsafe extern "C" fn(Object, c_int) -> Object, ++ webkit_web_view_go_to_back_forward_list_item: unsafe extern "C" fn(Object, Object), ++ webkit_navigation_policy_decision_get_request: unsafe extern "C" fn(Object) -> Object, ++ webkit_response_policy_decision_get_request: unsafe extern "C" fn(Object) -> Object, ++ webkit_response_policy_decision_is_main_frame_main_resource: unsafe extern "C" fn(Object) -> c_int, ++ webkit_response_policy_decision_is_mime_type_supported: unsafe extern "C" fn(Object) -> c_int, ++ webkit_uri_request_get_uri: unsafe extern "C" fn(Object) -> *const c_char, ++ webkit_policy_decision_use: unsafe extern "C" fn(Object), ++ webkit_policy_decision_ignore: unsafe extern "C" fn(Object), ++ webkit_permission_request_deny: unsafe extern "C" fn(Object), ++ webkit_file_chooser_request_cancel: unsafe extern "C" fn(Object), ++ webkit_download_cancel: unsafe extern "C" fn(Object), ++ webkit_authentication_request_cancel: unsafe extern "C" fn(Object), ++} ++thread_local! { ++ static API: RefCell, &'static str>>> = const { RefCell::new(None) }; ++} ++fn api() -> Result, &'static str> { ++ API.with(|slot| slot.borrow_mut().get_or_insert_with(Api::load).clone()) ++} ++ ++/// Run a bounded amount of GLib work. Never wait for events or a web process. ++pub(crate) fn pump() { ++ API.with(|slot| { ++ let api = match &*slot.borrow() { Some(Ok(api)) => api.clone(), _ => return }; ++ for _ in 0..16 { ++ if unsafe { (api.g_main_context_iteration)(ptr::null_mut(), 0) } == 0 { break; } ++ } ++ }); ++} ++ ++pub(crate) fn unavailable(browser_id: u64, auth: bool, description: &str) { ++ if auth { ++ Cx::post_action(NativeAuthBrowserEvent { browser_id, kind: "error".into(), url: String::new() }); ++ } else { ++ Cx::post_action(NativeSystemBrowserPageError { ++ browser_id, code: -1, description: description.into(), url: String::new(), ++ }); ++ } ++} ++ ++struct State { ++ api: Rc, ++ id: u64, ++ policy: RefCell, ++ navigable: bool, ++ finished: Cell, ++} ++impl State { ++ fn blocked(&self) { ++ self.error("The embedded browser blocked a disallowed navigation or operation"); ++ } ++ fn error(&self, description: &str) { ++ if !self.finished.get() { unavailable(self.id, false, description); } ++ } ++ unsafe fn navigation(&self, view: Object) { ++ if self.finished.get() { return; } ++ Cx::post_action(NativeSystemBrowserNavigation { ++ browser_id: self.id, ++ url: string((self.api.webkit_web_view_get_uri)(view)), ++ title: string((self.api.webkit_web_view_get_title)(view)), ++ loading: (self.api.webkit_web_view_is_loading)(view) != 0, ++ error: None, ++ }); ++ } ++} ++unsafe fn string(value: *const c_char) -> String { ++ if value.is_null() { String::new() } else { CStr::from_ptr(value).to_string_lossy().into_owned() } ++} ++unsafe fn state<'a>(value: Object) -> &'a State { &*(value as *const State) } ++ ++unsafe extern "C" fn decide(_view: Object, decision: Object, kind: c_int, data: Object) -> c_int { ++ let s = state(data); ++ if s.finished.get() || kind == 1 { // Never create a popup/auxiliary browsing context. ++ (s.api.webkit_policy_decision_ignore)(decision); ++ s.blocked(); ++ return 1; ++ } ++ let (request, main) = match kind { ++ 0 => ((s.api.webkit_navigation_policy_decision_get_request)(decision), false), ++ 2 => ((s.api.webkit_response_policy_decision_get_request)(decision), ++ (s.api.webkit_response_policy_decision_is_main_frame_main_resource)(decision) != 0), ++ _ => { (s.api.webkit_policy_decision_ignore)(decision); return 1; } ++ }; ++ let url = string((s.api.webkit_uri_request_get_uri)(request)); ++ let policy = s.policy.borrow(); ++ let result = policy.decide(&url, main); ++ let supported = kind != 2 || (s.api.webkit_response_policy_decision_is_mime_type_supported)(decision) != 0; ++ match result { ++ NavigationDecision::Allow if supported => (s.api.webkit_policy_decision_use)(decision), ++ _ => { (s.api.webkit_policy_decision_ignore)(decision); s.blocked(); } ++ } ++ 1 ++} ++unsafe extern "C" fn load_changed(view: Object, _event: c_int, data: Object) { state(data).navigation(view); } ++unsafe extern "C" fn load_failed(_view: Object, _event: c_int, _uri: *const c_char, error: *const GError, data: Object) -> c_int { ++ // WEBKIT_NETWORK_ERROR_CANCELLED is an intentional stop/policy rejection. ++ if !error.is_null() && (*error).code == 302 { return 1; } ++ state(data).error("The embedded browser could not load the page"); ++ 1 // Never display an engine-generated error page carrying a sensitive URL. ++} ++#[repr(C)] ++struct GError { domain: u32, code: c_int, message: *mut c_char } ++unsafe extern "C" fn tls_failed(_view: Object, _uri: *const c_char, _certificate: Object, _errors: c_uint, data: Object) -> c_int { ++ state(data).error("The embedded browser rejected an invalid TLS certificate"); ++ 1 ++} ++unsafe extern "C" fn process_terminated(_view: Object, _reason: c_int, data: Object) { ++ state(data).error("The embedded browser process exited"); ++} ++unsafe extern "C" fn deny_permission(_view: Object, request: Object, data: Object) -> c_int { ++ let s = state(data); (s.api.webkit_permission_request_deny)(request); s.blocked(); 1 ++} ++unsafe extern "C" fn deny_file(_view: Object, request: Object, data: Object) -> c_int { ++ let s = state(data); (s.api.webkit_file_chooser_request_cancel)(request); s.blocked(); 1 ++} ++unsafe extern "C" fn deny_authenticate(_view: Object, request: Object, data: Object) -> c_int { ++ let s = state(data); (s.api.webkit_authentication_request_cancel)(request); s.blocked(); 1 ++} ++unsafe extern "C" fn deny_download(_context: Object, download: Object, data: Object) { ++ let s = state(data); (s.api.webkit_download_cancel)(download); s.blocked(); ++} ++unsafe extern "C" fn deny_popup(_view: Object, _action: Object, data: Object) -> Object { ++ state(data).blocked(); ptr::null_mut() ++} ++unsafe extern "C" fn deny_dialog(_view: Object, _dialog: Object, data: Object) -> c_int { ++ state(data).blocked(); 1 ++} ++unsafe extern "C" fn deny_context_menu(_view: Object, _menu: Object, _event: Object, _hit: Object, _data: Object) -> c_int { 1 } ++unsafe extern "C" fn deny_fullscreen(_view: Object, _data: Object) -> c_int { 1 } ++unsafe extern "C" fn deny_print(_view: Object, _operation: Object, _data: Object) -> c_int { 1 } ++unsafe extern "C" fn closed(_view: Object, data: Object) { ++ let s = state(data); ++ s.finished.set(true); ++} ++ ++enum Document { Url(String, bool), Html(String, String) } ++ ++pub(crate) struct LinuxSystemBrowser { ++ state: Box, ++ view: Object, ++ plug: Object, ++ context: Object, ++ signals: Vec<(Object, c_ulong)>, ++ display: *mut x::Display, ++ socket: x::Window, ++ plug_id: x::Window, ++ xembed: x::Atom, ++ parent: x::Window, ++ attached: Option, ++ visible: bool, ++ focused: bool, ++ has_allocation: bool, ++ pending_document: Option, ++} ++impl LinuxSystemBrowser { ++ pub(crate) fn new(display: *mut x::Display, id: u64, url: &str, navigable: bool) -> Result { ++ let policy = BrowserPolicy::reader(url, navigable)?; ++ let api = api()?; ++ unsafe { ++ // Start under an UNMAPPED child of root. No independent GTK toplevel ++ // becomes visible, even if loading starts before the first draw. ++ let root = x::XRootWindow(display, x::XDefaultScreen(display)); ++ let socket = x::XCreateWindow(display, root, 0, 0, 1, 1, 0, 0, x::InputOutput, ptr::null_mut(), 0, ptr::null_mut()); ++ if socket == 0 { return Err("Could not create the browser child window"); } ++ XSelectInput(display, socket, (x::SubstructureNotifyMask | x::StructureNotifyMask | x::FocusChangeMask) as c_long); ++ XSync(display, 0); // GTK uses a separate X connection: socket must exist first. ++ let context = (api.webkit_web_context_new_ephemeral)(); ++ if context.is_null() { x::XDestroyWindow(display, socket); return Err("Could not create an isolated WebKit context"); } ++ (api.webkit_web_context_set_tls_errors_policy)(context, 1); // WEBKIT_TLS_ERRORS_POLICY_FAIL, never IGNORE. ++ let view = (api.webkit_web_view_new_with_context)(context); ++ let plug = (api.gtk_plug_new)(socket); ++ if view.is_null() || plug.is_null() { ++ if !view.is_null() { (api.gtk_widget_destroy)(view); } ++ if !plug.is_null() { (api.gtk_widget_destroy)(plug); } ++ (api.g_object_unref)(context); ++ x::XDestroyWindow(display, socket); ++ return Err("Could not create the embedded WebKit view"); ++ } ++ let settings = (api.webkit_web_view_get_settings)(view); ++ (api.webkit_settings_set_javascript_can_open_windows_automatically)(settings, 0); ++ (api.webkit_settings_set_enable_developer_extras)(settings, 0); ++ let mut browser = Self { ++ state: Box::new(State { api: api.clone(), id, policy: RefCell::new(policy), navigable, finished: Cell::new(false) }), ++ view, plug, context, signals: Vec::new(), display, socket, plug_id: 0, ++ xembed: x::XInternAtom(display, c"_XEMBED".as_ptr(), 0), parent: root, ++ attached: None, visible: false, focused: false, ++ has_allocation: false, pending_document: Some(Document::Url(url.to_owned(), false)), ++ }; ++ macro_rules! signal { ($object:expr, $name:literal, $callback:ident) => {{ ++ let handler = (api.g_signal_connect_data)($object, concat!($name, "\0").as_ptr().cast(), ++ std::mem::transmute::<*const (), Callback>($callback as *const ()), ++ (&*browser.state as *const State).cast_mut().cast(), None, 0); ++ browser.signals.push(($object, handler)); ++ }}; } ++ signal!(view, "decide-policy", decide); ++ signal!(view, "load-changed", load_changed); ++ signal!(view, "load-failed", load_failed); ++ signal!(view, "load-failed-with-tls-errors", tls_failed); ++ signal!(view, "web-process-terminated", process_terminated); ++ signal!(view, "permission-request", deny_permission); ++ signal!(view, "run-file-chooser", deny_file); ++ signal!(view, "authenticate", deny_authenticate); ++ signal!(view, "create", deny_popup); ++ signal!(view, "script-dialog", deny_dialog); ++ signal!(view, "context-menu", deny_context_menu); ++ signal!(view, "enter-fullscreen", deny_fullscreen); ++ signal!(view, "print", deny_print); ++ signal!(view, "close", closed); ++ signal!(context, "download-started", deny_download); ++ (api.gtk_container_add)(plug, view); ++ (api.gtk_widget_show_all)(plug); ++ browser.plug_id = (api.gtk_plug_get_id)(plug); ++ // Establish GTK's child on its X connection before the Makepad ++ // connection sends XEmbed messages or geometry requests to it. ++ (api.gdk_display_sync)((api.gdk_display_get_default)()); ++ browser.send_xembed(0, 0, socket as c_long, 0); // EMBEDDED_NOTIFY, protocol 0. ++ Ok(browser) ++ } ++ } ++ ++ fn send_xembed(&self, message: c_long, detail: c_long, data1: c_long, data2: c_long) { ++ if self.plug_id == 0 { return; } ++ unsafe { ++ let mut event: x::XEvent = std::mem::zeroed(); ++ event.xclient = x::XClientMessageEvent { ++ type_: x::ClientMessage as c_int, serial: 0, send_event: 1, ++ display: self.display, window: self.plug_id, message_type: self.xembed, format: 32, ++ data: x::XClientMessageEvent__bindgen_ty_1 { l: [0, message, detail, data1, data2] }, ++ }; ++ x::XSendEvent(self.display, self.plug_id, 0, 0, &mut event); ++ x::XFlush(self.display); ++ } ++ } ++ ++ pub(crate) fn update(&mut self, window: WindowId, parent: x::Window, full: Rect, clip: Rect, dpi: f64, visible: bool) { ++ if self.state.finished.get() || !visible || clip.size.x <= 0.0 || clip.size.y <= 0.0 ++ || full.size.x <= 0.0 || full.size.y <= 0.0 || !dpi.is_finite() ++ || ![full.pos.x, full.pos.y, full.size.x, full.size.y, clip.pos.x, clip.pos.y, clip.size.x, clip.size.y].iter().all(|v| v.is_finite()) { ++ self.hide(); return; ++ } ++ unsafe { ++ if self.parent != parent { ++ self.hide(); ++ XReparentWindow(self.display, self.socket, parent, 0, 0); ++ self.parent = parent; ++ } ++ self.attached = Some(window); ++ // The socket clips the plug, preserving the complete document's ++ // layout while parent scroll/clip changes its visible rectangle. ++ x::XMoveWindow(self.display, self.socket, coord(clip.pos.x), coord(clip.pos.y)); ++ x::XResizeWindow(self.display, self.socket, extent(clip.size.x), extent(clip.size.y)); ++ let scale = (self.state.api.gtk_widget_get_scale_factor)(self.plug).max(1) as f64; ++ let allocation = Allocation { x: 0, y: 0, width: extent(full.size.x / scale) as c_int, height: extent(full.size.y / scale) as c_int }; ++ (self.state.api.gtk_widget_set_size_request)(self.plug, allocation.width, allocation.height); ++ // The host may intentionally be hidden (remote instrumentation). ++ // GTK then receives no frame-clock allocation before page load. ++ // Allocate the sole child explicitly so its first document has the ++ // same viewport as the real native child, including in hidden mode. ++ (self.state.api.gtk_widget_size_allocate)(self.plug, &allocation); ++ (self.state.api.gtk_widget_size_allocate)(self.view, &allocation); ++ (self.state.api.webkit_web_view_set_zoom_level)(self.view, (dpi / scale).clamp(0.25, 5.0)); ++ x::XMoveWindow(self.display, self.plug_id, coord(full.pos.x - clip.pos.x), coord(full.pos.y - clip.pos.y)); ++ x::XResizeWindow(self.display, self.plug_id, extent(full.size.x), extent(full.size.y)); ++ if !self.visible { ++ x::XMapWindow(self.display, self.socket); ++ self.send_xembed(1, 0, 0, 0); // WINDOW_ACTIVATE, no focus stealing. ++ self.visible = true; ++ } ++ x::XFlush(self.display); ++ } ++ self.has_allocation = true; ++ if let Some(document) = self.pending_document.take() { ++ match document { ++ Document::Url(url, replace) => self.set_url(&url, replace), ++ Document::Html(html, base) => self.set_html(&html, &base), ++ } ++ } ++ } ++ pub(crate) fn window(&self) -> Option { self.attached } ++ pub(crate) fn hide(&mut self) { ++ if self.visible { ++ self.focus(false); ++ self.send_xembed(2, 0, 0, 0); ++ unsafe { XUnmapWindow(self.display, self.socket); x::XFlush(self.display); } ++ self.visible = false; ++ } ++ } ++ pub(crate) fn detach(&mut self) { ++ self.hide(); ++ unsafe { ++ let root = x::XRootWindow(self.display, x::XDefaultScreen(self.display)); ++ XReparentWindow(self.display, self.socket, root, 0, 0); ++ self.parent = root; ++ x::XFlush(self.display); ++ } ++ self.attached = None; ++ } ++ pub(crate) fn focus(&mut self, focused: bool) { ++ if focused && !self.visible { return; } ++ if self.focused == focused { return; } ++ self.focused = focused; ++ self.send_xembed(if focused { 4 } else { 5 }, 0, 0, 0); ++ unsafe { ++ if focused { ++ x::XSetInputFocus(self.display, self.plug_id, 2, 0); ++ (self.state.api.gtk_widget_grab_focus)(self.view); ++ } else if self.visible { ++ x::XSetInputFocus(self.display, self.parent, 2, 0); ++ } ++ } ++ } ++ pub(crate) fn active(&mut self, active: bool) { ++ if self.visible { self.send_xembed(if active { 1 } else { 2 }, 0, 0, 0); } ++ if !active { self.focused = false; self.send_xembed(5, 0, 0, 0); } ++ } ++ pub(crate) fn xembed_message(&mut self, window: x::Window, atom: x::Atom, message: c_long) -> bool { ++ if window != self.socket || atom != self.xembed { return false; } ++ match message { 3 => self.focus(true), 6 | 7 => self.focus(false), _ => {} } ++ true ++ } ++ pub(crate) fn set_url(&mut self, url: &str, replace: bool) { ++ if self.state.finished.get() { return; } ++ if !self.has_allocation { self.pending_document = Some(Document::Url(url.to_owned(), replace)); return; } ++ match BrowserPolicy::reader(url, self.state.navigable) { ++ Ok(policy) => *self.state.policy.borrow_mut() = policy, ++ Err(message) => { self.state.error(message); return; } ++ } ++ if !matches!(self.state.policy.borrow().decide(url, true), NavigationDecision::Allow) { ++ self.state.blocked(); return; ++ } ++ let Ok(uri) = CString::new(url) else { self.state.error("Invalid browser URL"); return; }; ++ unsafe { ++ // A native replace is implemented inside the existing document; ++ // normal navigation policies still enforce its destination. ++ if replace { ++ let quoted = js_string(url); ++ self.eval_js(&format!("window.location.replace({quoted})")); ++ } else { (self.state.api.webkit_web_view_load_uri)(self.view, uri.as_ptr()); } ++ } ++ } ++ pub(crate) fn set_html(&mut self, html: &str, base: &str) { ++ if self.state.finished.get() { return; } ++ if !self.has_allocation { self.pending_document = Some(Document::Html(html.to_owned(), base.to_owned())); return; } ++ let Ok(policy) = BrowserPolicy::reader(base, self.state.navigable) else { self.state.error("Invalid browser base URL"); return; }; ++ let (Ok(html), Ok(base)) = (CString::new(html), CString::new(base)) else { return; }; ++ *self.state.policy.borrow_mut() = policy; ++ unsafe { (self.state.api.webkit_web_view_load_html)(self.view, html.as_ptr(), base.as_ptr()); } ++ } ++ pub(crate) fn eval_js(&self, js: &str) { ++ // Only the owning native host can evaluate script; no page-to-host bridge. ++ if self.state.finished.get() { return; } ++ if let Ok(js) = CString::new(js) { ++ unsafe { (self.state.api.webkit_web_view_run_javascript)(self.view, js.as_ptr(), ptr::null_mut(), None, ptr::null_mut()); } ++ } ++ } ++ pub(crate) fn history_go(&self, delta: i32) { ++ if self.state.finished.get() { return; } ++ unsafe { ++ let list = (self.state.api.webkit_web_view_get_back_forward_list)(self.view); ++ let item = (self.state.api.webkit_back_forward_list_get_nth_item)(list, delta); ++ if !item.is_null() { (self.state.api.webkit_web_view_go_to_back_forward_list_item)(self.view, item); } ++ } ++ } ++} ++impl Drop for LinuxSystemBrowser { ++ fn drop(&mut self) { ++ self.hide(); ++ self.state.finished.set(true); ++ unsafe { ++ // Disconnect before destroying GTK objects or releasing callback ++ // state. Web process signals may arrive during destruction. ++ for (object, signal) in self.signals.drain(..) { (self.state.api.g_signal_handler_disconnect)(object, signal); } ++ (self.state.api.webkit_web_view_stop_loading)(self.view); ++ let gdk_display = (self.state.api.gdk_display_get_default)(); ++ // A window-manager close can already have destroyed the native ++ // parent. Trap only GTK teardown's expected stale-child errors. ++ (self.state.api.gdk_x11_display_error_trap_push)(gdk_display); ++ (self.state.api.gtk_widget_destroy)(self.plug); ++ // GTK and Makepad use separate X connections. Complete the plug's ++ // unmap/destroy before destroying its socket, otherwise the server ++ // can remove the child before GTK's queued UnmapWindow arrives. ++ (self.state.api.gdk_display_sync)(gdk_display); ++ (self.state.api.gdk_x11_display_error_trap_pop_ignored)(gdk_display); ++ (self.state.api.g_object_unref)(self.context); ++ x::XDestroyWindow(self.display, self.socket); ++ x::XFlush(self.display); ++ } ++ } ++} ++fn coord(value: f64) -> c_int { value.round().clamp(-32768.0, 32767.0) as c_int } ++fn extent(value: f64) -> c_uint { value.ceil().clamp(1.0, 32767.0) as c_uint } ++fn js_string(value: &str) -> String { ++ let mut out = String::from("\""); ++ for character in value.chars() { ++ match character { ++ '"' => out.push_str("\\\""), '\\' => out.push_str("\\\\"), ++ '\n' => out.push_str("\\n"), '\r' => out.push_str("\\r"), ++ c if c.is_control() || c == '\u{2028}' || c == '\u{2029}' => out.push_str(&format!("\\u{:04x}", c as u32)), ++ c => out.push(c), ++ } ++ } ++ out.push('"'); out ++} ++#[link(name = "X11")] ++extern "C" { ++ fn XSelectInput(display: *mut x::Display, window: x::Window, mask: c_long) -> c_int; ++ fn XSync(display: *mut x::Display, discard: c_int) -> c_int; ++ fn XReparentWindow(display: *mut x::Display, window: x::Window, parent: x::Window, x: c_int, y: c_int) -> c_int; ++ fn XUnmapWindow(display: *mut x::Display, window: x::Window) -> c_int; ++} +diff --git a/platform/src/os/linux/mod.rs b/platform/src/os/linux/mod.rs +index e234e0fee..1994e6a98 100644 +--- a/platform/src/os/linux/mod.rs ++++ b/platform/src/os/linux/mod.rs +@@ -2,6 +2,8 @@ + pub mod opengl_cx; + #[cfg(not(any(linux_direct, target_env = "ohos", target_os = "android")))] + pub mod file_dialog; ++#[cfg(not(any(linux_direct, target_env = "ohos", target_os = "android")))] ++pub(crate) mod linux_webkit; + // The wayland-* crates are dependencies of the linux-gnu targets only, so + // this has to carry the same gate as its siblings: Android and OHOS are + // `os::linux` too, and would otherwise fail to resolve `wayland_client`. +diff --git a/platform/src/os/linux/wayland/linux_wayland.rs b/platform/src/os/linux/wayland/linux_wayland.rs +index 9fa1ec6a8..94021ab60 100644 +--- a/platform/src/os/linux/wayland/linux_wayland.rs ++++ b/platform/src/os/linux/wayland/linux_wayland.rs +@@ -184,6 +184,7 @@ fn state_event_callback(&mut self, state: &mut WaylandState, event: XlibEvent) - + } + } + match event { ++ XlibEvent::BrowserMessage { .. } => {} // XEmbed is X11-only. + XlibEvent::WindowGotFocus(window_id) => { + // repaint all window passes. Metal sometimes doesnt flip buffers when hidden/no focus + let mut cx = self.cx.borrow_mut(); +@@ -674,6 +675,14 @@ fn handle_platform_ops(&self, state: &mut WaylandState) -> EventFlow { + } + } + match op { ++ CxOsOp::SpawnSystemBrowser { browser_id, .. } => { ++ super::super::linux_webkit::unavailable(browser_id.0, false, ++ "Embedded WebKitGTK requires X11/XWayland; relaunch with --linux-backend=x11 in a session with DISPLAY"); ++ } ++ CxOsOp::SpawnAuthBrowser { browser_id, .. } => { ++ super::super::linux_webkit::unavailable(browser_id.0, true, ++ "Embedded authentication is unavailable on Linux; use the external browser"); ++ } + CxOsOp::CreateWindow(window_id) => { + #[cfg(not(use_vulkan))] + let gl_cx = cx.os.opengl_cx.as_ref().unwrap(); +diff --git a/platform/src/os/linux/windowing_backend.rs b/platform/src/os/linux/windowing_backend.rs +index 499c6dc3c..f7e8af444 100644 +--- a/platform/src/os/linux/windowing_backend.rs ++++ b/platform/src/os/linux/windowing_backend.rs +@@ -11,6 +11,13 @@ + /// text, matching the common Windows and GTK defaults. + pub const PIXELS_PER_WHEEL_DETENT: f64 = 60.0; + ++static PREFER_X11_FOR_EMBEDDED_BROWSER: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); ++ ++fn prefers_x11_for_embedded_browser() -> bool { ++ !cfg!(use_vulkan) && env_var_is_nonempty("DISPLAY") ++ && PREFER_X11_FOR_EMBEDDED_BROWSER.load(std::sync::atomic::Ordering::Relaxed) ++} ++ + fn env_var_is_nonempty(name: &str) -> bool { + std::env::var_os(name).is_some_and(|value| !value.is_empty()) + } +@@ -42,6 +49,12 @@ fn detect_windowing_protocol() -> WindowingProtocol { + return protocol; + } + ++ // Hosts that require embedded WebKit may opt into XWayland. Explicit user ++ // choices above always win, and Vulkan still requires native Wayland. ++ if prefers_x11_for_embedded_browser() { ++ return WindowingProtocol::X11; ++ } ++ + // Check for Wayland first + if env_var_is_nonempty("WAYLAND_DISPLAY") { + return WindowingProtocol::Wayland; +@@ -63,6 +76,13 @@ pub enum WindowingProtocol { + } + + impl Cx { ++ /// Call before starting the UI event loop to prefer X11/XWayland when an X ++ /// display exists. Needed for WebKitGTK XEmbed; no environment is mutated. ++ /// An explicit --linux-backend choice and the Vulkan requirement take priority. ++ pub fn prefer_x11_for_embedded_browser() { ++ PREFER_X11_FOR_EMBEDDED_BROWSER.store(true, std::sync::atomic::Ordering::Relaxed); ++ } ++ + pub fn event_loop(cx: Rc>) { + #[cfg(use_vulkan)] + if is_stdin_loop_mode() { +@@ -123,6 +143,8 @@ pub fn event_loop(cx: Rc>) { + println!("Reason: --stdin-loop mode uses X11 stdin backend"); + } else if forced_windowing_protocol_from_args() == Some(WindowingProtocol::X11) { + println!("Reason: --linux-backend=x11 override"); ++ } else if prefers_x11_for_embedded_browser() { ++ println!("Reason: host preference for embedded WebKitGTK via X11/XWayland"); + } else if env_var_is_nonempty("DISPLAY") { + println!("Reason: DISPLAY environment variable is set"); + } else { +diff --git a/platform/src/os/linux/x11/linux_x11.rs b/platform/src/os/linux/x11/linux_x11.rs +index b5c58e27e..02943f128 100644 +--- a/platform/src/os/linux/x11/linux_x11.rs ++++ b/platform/src/os/linux/x11/linux_x11.rs +@@ -1,6 +1,7 @@ + use { + self::super::super::{ + egl_sys, ++ linux_webkit::{self, LinuxSystemBrowser}, + linux_video_playback::{poll_pending_gstreamer_teardowns, GStreamerVideoPlayer}, + linux_video_player::{ + collect_linux_video_player_events, prepare_desktop_linux_video, LinuxPrepareResult, +@@ -48,12 +49,14 @@ pub fn x11_event_loop(cx: Rc>) { + + pub struct X11Cx { + pub cx: Rc>, ++ browsers: std::collections::HashMap, + } + + impl X11Cx { + pub fn event_loop_impl(cx: Rc>) { + let mut x11_cx = X11Cx { + cx: cx.clone(), ++ browsers: Default::default(), + }; + cx.borrow_mut().self_ref = Some(cx.clone()); + cx.borrow_mut().os_type = OsType::LinuxWindow(LinuxWindowParams { +@@ -73,7 +76,11 @@ pub fn event_loop_impl(cx: Rc>) { + return EventFlow::Wait; + } + let mut opengl_windows = opengl_windows.borrow_mut(); +- x11_cx.xlib_event_callback(xlib_app, events, &mut *opengl_windows) ++ let flow = x11_cx.xlib_event_callback(xlib_app, events, &mut *opengl_windows); ++ // XlibApp closes the Display as soon as Exit is returned. ++ // Dispose child sockets while that connection is still valid. ++ if matches!(flow, EventFlow::Exit) { x11_cx.browsers.clear(); } ++ flow + } + })); + +@@ -112,7 +119,13 @@ fn xlib_event_callback( + } + + match event { ++ XlibEvent::BrowserMessage { window, atom, message } => { ++ for browser in self.browsers.values_mut() { ++ if browser.xembed_message(window, atom, message) { break; } ++ } ++ } + XlibEvent::WindowGotFocus(window_id) => { ++ for browser in self.browsers.values_mut().filter(|browser| browser.window() == Some(window_id)) { browser.active(true); } + // repaint all window passes. Metal sometimes doesnt flip buffers when hidden/no focus + let mut cx = self.cx.borrow_mut(); + for window in opengl_windows.iter_mut() { +@@ -123,6 +136,7 @@ fn xlib_event_callback( + cx.call_event_handler(&Event::WindowGotFocus(window_id)); + } + XlibEvent::WindowLostFocus(window_id) => { ++ for browser in self.browsers.values_mut().filter(|browser| browser.window() == Some(window_id)) { browser.active(false); } + let mut cx = self.cx.borrow_mut(); + cx.call_event_handler(&Event::WindowLostFocus(window_id)); + } +@@ -152,6 +166,7 @@ fn xlib_event_callback( + cx.call_event_handler(&Event::WindowGeomChange(re)); + } + XlibEvent::WindowClosed(wc) => { ++ self.browsers.retain(|_, browser| browser.window() != Some(wc.window_id)); + let window_id = wc.window_id; + self.close_popup_children(opengl_windows, xlib_app, window_id); + +@@ -224,6 +239,7 @@ fn xlib_event_callback( + } + } + XlibEvent::MouseDown(mut e) => { ++ for browser in self.browsers.values_mut() { browser.focus(false); } + let mut cx = self.cx.borrow_mut(); + cx.dpi_override_scale(&mut e.abs, e.window_id); + cx.fingers.process_tap_count(e.abs, e.time); +@@ -308,6 +324,7 @@ fn xlib_event_callback( + cx.call_event_handler(&Event::TextCut(e)) + } + XlibEvent::Timer(e) => { ++ if e.timer_id == 0 { linux_webkit::pump(); } + let mut cx = self.cx.borrow_mut(); + if e.timer_id == 0 { + if SignalToUI::check_and_clear_ui_signal() { +@@ -567,6 +584,7 @@ fn handle_platform_ops( + opengl_windows.push(opengl_window); + } + CxOsOp::CloseWindow(window_id) => { ++ self.browsers.retain(|_, browser| browser.window() != Some(window_id)); + drop(cx); + self.close_popup_children(opengl_windows, xlib_app, window_id); + cx = self.cx.borrow_mut(); +@@ -596,7 +614,7 @@ fn handle_platform_ops( + } + } + } +- CxOsOp::Quit => ret = EventFlow::Exit, ++ CxOsOp::Quit => { self.browsers.clear(); ret = EventFlow::Exit; }, + CxOsOp::MinimizeWindow(window_id) => { + if let Some(window) = + opengl_windows.iter_mut().find(|w| w.window_id == window_id) +@@ -912,6 +930,53 @@ fn handle_platform_ops( + let _ = player.select_audio_track(index); + } + } ++ CxOsOp::SpawnSystemBrowser { browser_id, url, navigable } => { ++ // Respawn discards prior page state, callbacks and context. ++ self.browsers.remove(&browser_id); ++ match LinuxSystemBrowser::new(xlib_app.display, browser_id.0, &url, navigable) { ++ Ok(browser) => { self.browsers.insert(browser_id, browser); } ++ Err(error) => linux_webkit::unavailable(browser_id.0, false, error), ++ } ++ } ++ CxOsOp::SpawnAuthBrowser { browser_id, .. } => { ++ self.browsers.remove(&browser_id); ++ // WebKitGTK cannot reliably distinguish a main-frame callback ++ // before its network request. Keep authentication external; ++ // never send a callback token to octosense.invalid. ++ linux_webkit::unavailable(browser_id.0, true, ++ "Embedded authentication is unavailable on Linux; use the external browser"); ++ } ++ CxOsOp::UpdateSystemBrowser { browser_id, area, visible } => { ++ let target = area.draw_list_id() ++ .and_then(|id| cx.draw_lists[id].draw_pass_id) ++ .and_then(|id| cx.get_pass_window_id(id)) ++ .and_then(|id| opengl_windows.iter().find(|window| window.window_id == id) ++ .and_then(|window| window.xlib_window.window.map(|native| (id, native)))); ++ if let Some(browser) = self.browsers.get_mut(&browser_id) { ++ if let Some((id, parent)) = target { ++ let window = &cx.windows[id]; ++ let full = window.layout_rect_to_physical_pixels(area.rect(&cx)); ++ let clip = window.layout_rect_to_physical_pixels(area.clipped_rect(&cx)); ++ browser.update(id, parent, full, clip, window.window_geom.dpi_factor, visible); ++ } else { browser.hide(); } ++ } ++ } ++ CxOsOp::DetachSystemBrowser { browser_id } => { ++ if let Some(browser) = self.browsers.get_mut(&browser_id) { browser.detach(); } ++ } ++ CxOsOp::SetSystemBrowserUrl { browser_id, url, replace } => { ++ if let Some(browser) = self.browsers.get_mut(&browser_id) { browser.set_url(&url, replace); } ++ } ++ CxOsOp::SetSystemBrowserHtml { browser_id, html, base_url } => { ++ if let Some(browser) = self.browsers.get_mut(&browser_id) { browser.set_html(&html, &base_url); } ++ } ++ CxOsOp::EvalSystemBrowserJs { browser_id, js } => { ++ if let Some(browser) = self.browsers.get(&browser_id) { browser.eval_js(&js); } ++ } ++ CxOsOp::SystemBrowserHistoryGo { browser_id, delta } => { ++ if let Some(browser) = self.browsers.get(&browser_id) { browser.history_go(delta); } ++ } ++ CxOsOp::CloseSystemBrowser { browser_id } => { self.browsers.remove(&browser_id); } + CxOsOp::AttachCameraNativePreview { .. } + | CxOsOp::UpdateCameraNativePreview { .. } + | CxOsOp::DetachCameraNativePreview { .. } => { +diff --git a/platform/src/os/linux/x11/xlib_app.rs b/platform/src/os/linux/x11/xlib_app.rs +index c3308698e..62a67d4bd 100644 +--- a/platform/src/os/linux/x11/xlib_app.rs ++++ b/platform/src/os/linux/x11/xlib_app.rs +@@ -1170,6 +1170,9 @@ pub unsafe fn event_loop_poll(&mut self) { + } + x11_sys::ClientMessage => { + let event = event.xclient; ++ if event.format == 32 { ++ self.do_callback(XlibEvent::BrowserMessage { window: event.window, atom: event.message_type, message: event.data.l[1] }); ++ } + if event.message_type == self.atoms.wm_protocols + && event.data.l[0] as c_ulong == self.atoms.wm_delete_window + { +@@ -1222,6 +1225,7 @@ pub unsafe fn event_loop_poll(&mut self) { + if event.mode == x11_sys::NotifyGrab + || event.mode == x11_sys::NotifyUngrab + || event.detail == x11_sys::NotifyPointer ++ || event.detail == 2 // NotifyInferior: focus stays inside an embedded child. + { + continue; + } +@@ -1244,6 +1248,7 @@ pub unsafe fn event_loop_poll(&mut self) { + if event.xfocus.mode == x11_sys::NotifyGrab + || event.xfocus.mode == x11_sys::NotifyUngrab + || event.xfocus.detail == x11_sys::NotifyPointer ++ || event.xfocus.detail == 2 // NotifyInferior: XEmbed child, same app window. + { + continue; + } +diff --git a/platform/src/os/linux/x11/xlib_event.rs b/platform/src/os/linux/x11/xlib_event.rs +index 6e69c6605..84372712a 100644 +--- a/platform/src/os/linux/x11/xlib_event.rs ++++ b/platform/src/os/linux/x11/xlib_event.rs +@@ -15,6 +15,7 @@ pub enum XlibEvent { + WindowClosed(WindowClosedEvent), + PopupDismissed(PopupDismissedEvent), + Paint, ++ BrowserMessage { window: std::os::raw::c_ulong, atom: std::os::raw::c_ulong, message: std::os::raw::c_long }, + + MouseDown(MouseDownEvent), + MouseUp(MouseUpEvent), +diff --git a/platform/src/os/windows/mod.rs b/platform/src/os/windows/mod.rs +index 19aea0eb4..9ec64eaec 100644 +--- a/platform/src/os/windows/mod.rs ++++ b/platform/src/os/windows/mod.rs +@@ -14,6 +14,7 @@ + pub mod win32_event; + pub mod win32_screen; + pub mod win32_window; ++pub mod windows_webview; + pub mod windows_media; + pub mod windows_media_engine_notify; + pub mod windows_mf_source_reader; +diff --git a/platform/src/os/windows/windows.rs b/platform/src/os/windows/windows.rs +index c0404864b..19a5b124a 100644 +--- a/platform/src/os/windows/windows.rs ++++ b/platform/src/os/windows/windows.rs +@@ -24,6 +24,7 @@ + win32_window::Win32Window, + windows_game_input::WindowsGameInput, + windows_media::CxWindowsMedia, ++ windows_webview::WindowsSystemBrowser, + windows_video_player::WindowsUnifiedVideoPlayer, + }, + }, +@@ -164,6 +165,7 @@ fn win32_event_callback( + // close path; no other code may synthesize a WindowClosed, or the app + // would see it twice. + let window_id = wc.window_id; ++ self.os.system_browsers.retain(|_, browser| browser.attached_window() != Some(window_id)); + // Cascade-close popups parented to this window. Their own WindowClosed + // arrives via the queued authentic event; removing the D3d11Window now + // makes the app's `WindowHandle::close()` response a no-op. +@@ -990,6 +992,7 @@ fn handle_platform_ops( + }); + } + CxOsOp::CloseWindow(window_id) => { ++ self.os.system_browsers.retain(|_, browser| browser.attached_window() != Some(window_id)); + // The authentic WindowClosed event this triggers delivers + // Event::WindowClosed and the exit check; firing it here would double it. + // Remove the D3d11Window now so later ops cannot touch the destroyed hwnd. +@@ -1290,6 +1293,51 @@ fn handle_platform_ops( + player.set_playback_rate(rate); + } + } ++ CxOsOp::SpawnSystemBrowser { browser_id, url, navigable } => { ++ self.os.system_browsers.insert(browser_id, WindowsSystemBrowser::new(browser_id, &url, navigable)); ++ } ++ CxOsOp::SpawnAuthBrowser { browser_id, url, callback_url, inspectable: _ } => { ++ self.os.system_browsers.insert(browser_id, WindowsSystemBrowser::new_auth(browser_id, &url, &callback_url)); ++ } ++ CxOsOp::UpdateSystemBrowser { browser_id, area, visible } => { ++ let placement = area.draw_list_id() ++ .and_then(|id| self.draw_lists[id].draw_pass_id) ++ .and_then(|pass| self.get_pass_window_id(pass)) ++ .and_then(|id| d3d11_windows.iter().find(|w| w.window_id == id).map(|w| ++ (id, w.win32_window.hwnd, w.win32_window.get_dpi_factor()))); ++ if let Some((window, hwnd, dpi)) = placement { ++ let rect = area.rect(self); ++ let clip = area.clipped_rect(self); ++ if let Some(browser) = self.os.system_browsers.get_mut(&browser_id) { ++ browser.update(window, hwnd, rect, clip, dpi, visible); ++ } ++ } else if let Some(browser) = self.os.system_browsers.get_mut(&browser_id) { ++ browser.detach(); ++ } ++ } ++ CxOsOp::DetachSystemBrowser { browser_id } => { ++ if let Some(browser) = self.os.system_browsers.get_mut(&browser_id) { browser.detach(); } ++ } ++ CxOsOp::SetSystemBrowserUrl { browser_id, url, replace } => { ++ if let Some(browser) = self.os.system_browsers.get_mut(&browser_id) { browser.set_url(&url, replace); } ++ } ++ CxOsOp::SetSystemBrowserHtml { browser_id, html, base_url } => { ++ if let Some(browser) = self.os.system_browsers.get_mut(&browser_id) { browser.set_html(&html, &base_url); } ++ } ++ CxOsOp::EvalSystemBrowserJs { browser_id, js } => { ++ if let Some(browser) = self.os.system_browsers.get(&browser_id) { browser.eval_js(&js); } ++ } ++ CxOsOp::SystemBrowserHistoryGo { browser_id, delta } => { ++ if let Some(browser) = self.os.system_browsers.get(&browser_id) { browser.history_go(delta); } ++ } ++ CxOsOp::CloseSystemBrowser { browser_id } => { self.os.system_browsers.remove(&browser_id); } ++ CxOsOp::InspectSystemBrowser { browser_id, result_path, snapshot_path, scroll_y } => { ++ if let Some(browser) = self.os.system_browsers.get(&browser_id) { ++ browser.inspect(result_path, snapshot_path, scroll_y); ++ } else { ++ let _ = std::fs::write(result_path, r#"{"error":"Browser is closed"}"#); ++ } ++ } + // Track selection is currently implemented on Linux GStreamer only. + CxOsOp::SelectVideoTrack(_, _) | CxOsOp::SelectAudioTrack(_, _) => {} + CxOsOp::AttachCameraNativePreview { .. } +@@ -1424,6 +1472,7 @@ pub struct CxOs { + pub(crate) game_input_events: GameInputEventChannel, + pub(crate) windows_game_input: Option, + pub(crate) video_players: HashMap, ++ pub(crate) system_browsers: HashMap, + pub(crate) async_hlsl_compile: crate::os::windows::d3d11::AsyncHlslCompile, + pub(crate) stdin_timers: crate::os::shared_framebuf::PollTimers, + /// Earliest time the device-loss recovery may try again, and how many tries this outage +diff --git a/platform/src/os/windows/windows_webview.rs b/platform/src/os/windows/windows_webview.rs +new file mode 100644 +index 000000000..34fa36c63 +--- /dev/null ++++ b/platform/src/os/windows/windows_webview.rs +@@ -0,0 +1,1049 @@ ++//! WebView2 lives inside a clipping child HWND of the Makepad window. COM ++//! completions run on the existing STA message loop; no nested pump or worker ++//! waits. A weak state reference prevents late creation callbacks resurrecting ++//! closed views. Pages never receive host objects or an IPC bridge. ++use crate::{ ++ cx::Cx, ++ event::{NativeAuthBrowserEvent, NativeSystemBrowserNavigation, NativeSystemBrowserPageError}, ++ makepad_live_id::LiveId, ++ makepad_math::Rect, ++ system_browser::{BrowserPolicy, NavigationDecision}, ++ window::WindowId, ++}; ++use std::{ ++ cell::RefCell, ++ rc::Rc, ++ sync::atomic::{AtomicU64, Ordering}, ++}; ++use webview2_com::{Microsoft::Web::WebView2::Win32::*, *}; ++use webview2_windows::{ ++ core::{w, Interface, Result, BOOL, PCWSTR, PWSTR}, ++ Win32::{ ++ Foundation::{E_FAIL, HWND, RECT}, ++ System::{ ++ Com::{ ++ CoTaskMemFree, IStream, StructuredStorage::CreateStreamOnHGlobal, STATFLAG_NONAME, ++ STATSTG, STREAM_SEEK_SET, ++ }, ++ LibraryLoader::GetModuleHandleW, ++ }, ++ UI::WindowsAndMessaging::*, ++ }, ++}; ++ ++static NEXT_PROFILE: AtomicU64 = AtomicU64::new(1); ++ ++type Shared = Rc>; ++ ++struct State { ++ id: LiveId, ++ policy: Option, ++ navigable: bool, ++ auth: bool, ++ auth_finished: bool, ++ closed: bool, ++ creating: bool, ++ attached_window: Option, ++ child: HWND, ++ bounds: RECT, ++ visible: bool, ++ controller: Option, ++ webview: Option, ++ pending: Document, ++ inline: bool, ++} ++ ++#[derive(Clone)] ++enum Document { ++ Url(String, bool), ++ Html(String), ++} ++ ++pub(crate) struct WindowsSystemBrowser { ++ state: Shared, ++} ++ ++impl WindowsSystemBrowser { ++ pub(crate) fn new(id: LiveId, url: &str, navigable: bool) -> Self { ++ Self::create(id, url, navigable, None) ++ } ++ ++ pub(crate) fn new_auth(id: LiveId, url: &str, callback: &str) -> Self { ++ Self::create(id, url, false, Some(callback)) ++ } ++ ++ fn create(id: LiveId, url: &str, navigable: bool, callback: Option<&str>) -> Self { ++ let policy = if let Some(callback) = callback { ++ BrowserPolicy::auth(url, callback) ++ } else { ++ BrowserPolicy::reader(url, navigable) ++ }; ++ let state = Rc::new(RefCell::new(State { ++ id, ++ policy: policy.ok(), ++ navigable, ++ auth: callback.is_some(), ++ auth_finished: false, ++ closed: false, ++ creating: false, ++ attached_window: None, ++ child: HWND::default(), ++ bounds: RECT::default(), ++ visible: false, ++ controller: None, ++ webview: None, ++ pending: Document::Url(url.into(), false), ++ inline: false, ++ })); ++ if state.borrow().policy.is_none() { ++ report_error(&state, "The browser URL is not permitted", -1); ++ } ++ Self { state } ++ } ++ ++ pub(crate) fn attached_window(&self) -> Option { ++ self.state.borrow().attached_window ++ } ++ ++ pub(crate) fn update( ++ &mut self, ++ window: WindowId, ++ parent: windows::Win32::Foundation::HWND, ++ rect: Rect, ++ clip: Rect, ++ dpi: f64, ++ visible: bool, ++ ) { ++ let parent = HWND(parent.0); ++ // Keep the WebView's full document size while the outer HWND clips it ++ // to the widget's visible area (scrolling, portals and covered edges). ++ let pixel = |v: f64| (v * dpi).round().clamp(i32::MIN as f64, i32::MAX as f64) as i32; ++ let visible = visible && clip.size.x > 0.0 && clip.size.y > 0.0; ++ let bounds = RECT { ++ left: pixel(rect.pos.x - clip.pos.x), ++ top: pixel(rect.pos.y - clip.pos.y), ++ right: pixel(rect.pos.x - clip.pos.x + rect.size.x), ++ bottom: pixel(rect.pos.y - clip.pos.y + rect.size.y), ++ }; ++ let (mut child, old_window, controller, creating, closed) = { ++ let mut state = self.state.borrow_mut(); ++ let old = state.attached_window; ++ state.attached_window = Some(window); ++ state.bounds = bounds; ++ state.visible = visible; ++ ( ++ state.child, ++ old, ++ state.controller.clone(), ++ state.creating, ++ state.closed, ++ ) ++ }; ++ if closed { ++ return; ++ } ++ unsafe { ++ if child.is_invalid() { ++ let result = CreateWindowExW( ++ WINDOW_EX_STYLE::default(), ++ w!("STATIC"), ++ w!(""), ++ WS_CHILD | WS_CLIPCHILDREN | WS_CLIPSIBLINGS, ++ 0, ++ 0, ++ 0, ++ 0, ++ Some(parent), ++ None, ++ GetModuleHandleW(None).ok().map(Into::into), ++ None, ++ ); ++ match result { ++ Ok(hwnd) => { ++ child = hwnd; ++ self.state.borrow_mut().child = hwnd; ++ } ++ Err(error) => { ++ report_error( ++ &self.state, ++ "Cannot attach the embedded browser", ++ error.code().0, ++ ); ++ return; ++ } ++ } ++ } else if old_window != Some(window) { ++ if let Err(error) = SetParent(child, Some(parent)) { ++ report_error( ++ &self.state, ++ "Cannot reattach the embedded browser", ++ error.code().0, ++ ); ++ return; ++ } ++ } ++ if let Err(error) = SetWindowPos( ++ child, ++ None, ++ pixel(clip.pos.x), ++ pixel(clip.pos.y), ++ pixel(clip.size.x).max(0), ++ pixel(clip.size.y).max(0), ++ SWP_NOACTIVATE | SWP_NOZORDER, ++ ) { ++ report_error( ++ &self.state, ++ "Cannot resize the embedded browser", ++ error.code().0, ++ ); ++ } ++ let _ = ShowWindow(child, if visible { SW_SHOWNA } else { SW_HIDE }); ++ if let Some(controller) = controller { ++ if let Err(error) = controller ++ .SetBounds(bounds) ++ .and_then(|_| controller.SetIsVisible(visible)) ++ .and_then(|_| controller.NotifyParentWindowPositionChanged()) ++ { ++ report_error( ++ &self.state, ++ "Cannot update the embedded browser", ++ error.code().0, ++ ); ++ } ++ } else if !creating { ++ self.state.borrow_mut().creating = true; ++ if let Err(error) = start_environment(&self.state, child) { ++ report_error( ++ &self.state, ++ "WebView2 could not start; install the Microsoft Edge WebView2 Runtime", ++ error.code().0, ++ ); ++ } ++ } ++ } ++ } ++ ++ pub(crate) fn detach(&mut self) { ++ let (child, controller) = { ++ let mut state = self.state.borrow_mut(); ++ state.visible = false; ++ // Keep ownership tied to the window so its destruction also closes ++ // detached views. The next update may reparent the hidden child. ++ (state.child, state.controller.clone()) ++ }; ++ unsafe { ++ if let Some(controller) = controller { ++ let _ = controller.SetIsVisible(false); ++ } ++ if !child.is_invalid() { ++ let _ = ShowWindow(child, SW_HIDE); ++ } ++ } ++ } ++ ++ pub(crate) fn set_url(&mut self, url: &str, replace: bool) { ++ let accepted = { ++ let mut state = self.state.borrow_mut(); ++ if state.auth { ++ !state.auth_finished ++ && state ++ .policy ++ .as_ref() ++ .is_some_and(|p| matches!(p.decide(url, true), NavigationDecision::Allow)) ++ } else if let Ok(policy) = BrowserPolicy::reader(url, state.navigable) { ++ state.policy = Some(policy); ++ true ++ } else { ++ false ++ } ++ }; ++ if !accepted { ++ report_blocked(&self.state); ++ return; ++ } ++ { ++ let mut state = self.state.borrow_mut(); ++ state.inline = false; ++ state.pending = Document::Url(url.into(), replace); ++ } ++ load_pending(&self.state); ++ } ++ ++ pub(crate) fn set_html(&mut self, html: &str, base_url: &str) { ++ if self.state.borrow().auth { ++ report_blocked(&self.state); ++ return; ++ } ++ // NavigateToString keeps an opaque about:blank origin. A base element ++ // resolves relative resources but never grants a host origin or IPC. ++ if html.len() > 2 * 1024 * 1024 { ++ report_error(&self.state, "Embedded HTML exceeds the 2 MiB limit", -1); ++ return; ++ } ++ let html = if base_url.is_empty() || base_url == "about:blank" { ++ html.to_owned() ++ } else if public_resource(base_url) { ++ format!("{}", html_attribute(base_url), html) ++ } else { ++ report_blocked(&self.state); ++ return; ++ }; ++ { ++ let mut state = self.state.borrow_mut(); ++ state.inline = true; ++ state.pending = Document::Html(html); ++ } ++ load_pending(&self.state); ++ } ++ ++ pub(crate) fn eval_js(&self, js: &str) { ++ let state = self.state.borrow(); ++ if state.auth || state.closed || js.len() > 1024 * 1024 { ++ return; ++ } ++ let webview = state.webview.clone(); ++ drop(state); ++ if let Some(webview) = webview { ++ if let Err(error) = unsafe { execute(&webview, js) } { ++ report_error( ++ &self.state, ++ "The embedded browser script failed", ++ error.code().0, ++ ); ++ } ++ } ++ } ++ ++ pub(crate) fn history_go(&self, delta: i32) { ++ // The host's Back/Forward control is allowed in auth, but arbitrary ++ // script evaluation remains forbidden. Every traversal is rechecked. ++ let (auth, finished, webview) = { ++ let s = self.state.borrow(); ++ (s.auth, s.closed || s.auth_finished, s.webview.clone()) ++ }; ++ if finished { ++ return; ++ } ++ if auth { ++ if let Some(webview) = webview { ++ let result = unsafe { ++ match delta { ++ -1 => webview.GoBack(), ++ 1 => webview.GoForward(), ++ _ => return, ++ } ++ }; ++ if let Err(error) = result { ++ report_error( ++ &self.state, ++ "Browser history is unavailable", ++ error.code().0, ++ ); ++ } ++ } ++ } else { ++ self.eval_js(&format!("history.go({delta})")); ++ } ++ } ++ ++ /// Host-owned native acceptance instrumentation. It is unavailable on ++ /// authentication views and writes only to the native caller's paths. ++ pub(crate) fn inspect( ++ &self, ++ result_path: String, ++ snapshot_path: Option, ++ scroll_y: Option, ++ ) { ++ let (webview, allowed) = { ++ let s = self.state.borrow(); ++ let width = s.bounds.right.saturating_sub(s.bounds.left); ++ let height = s.bounds.bottom.saturating_sub(s.bounds.top); ++ ( ++ s.webview.clone(), ++ !s.auth && !s.closed && width > 0 && height > 0 && width <= 2048 && height <= 2048, ++ ) ++ }; ++ if !allowed { ++ let _ = std::fs::write( ++ result_path, ++ r#"{"error":"Browser inspection is unavailable or exceeds 2048px"}"#, ++ ); ++ return; ++ } ++ let Some(webview) = webview else { ++ let _ = std::fs::write(result_path, r#"{"error":"Browser is not ready"}"#); ++ return; ++ }; ++ let scroll = scroll_y ++ .filter(|v| v.is_finite()) ++ .map(|v| format!("window.scrollTo(0,{v});")) ++ .unwrap_or_default(); ++ let script = wide(&format!( ++ r#"{scroll}({{ ++ readyState:document.readyState,title:document.title.slice(0,512), ++ scrollY:window.scrollY,scrollHeight:document.documentElement.scrollHeight, ++ viewportHeight:window.innerHeight,viewportWidth:window.innerWidth, ++ hostBridgePresent:typeof window.octos!=='undefined' ++ }})"# ++ )); ++ let weak = Rc::downgrade(&self.state); ++ let capture_view = webview.clone(); ++ let error_path = result_path.clone(); ++ let result = unsafe { ++ webview.ExecuteScript( ++ PCWSTR(script.as_ptr()), ++ &ExecuteScriptCompletedHandler::create(Box::new(move |result, metadata| { ++ let Some(state) = weak.upgrade() else { ++ return Ok(()); ++ }; ++ if state.borrow().closed { ++ return Ok(()); ++ } ++ if result.is_err() || metadata.len() > 16 * 1024 { ++ let _ = std::fs::write( ++ &result_path, ++ r#"{"error":"Browser inspection failed"}"#, ++ ); ++ return Ok(()); ++ } ++ if let Some(path) = snapshot_path { ++ if let Err(error) = ++ capture(&capture_view, path, result_path.clone(), metadata) ++ { ++ let _ = std::fs::write( ++ result_path, ++ format!( ++ "{{\"error\":\"Browser capture failed ({})\"}}", ++ error.code().0 ++ ), ++ ); ++ } ++ } else { ++ let _ = std::fs::write(result_path, metadata); ++ } ++ Ok(()) ++ })), ++ ) ++ }; ++ if result.is_err() { ++ let _ = std::fs::write(error_path, r#"{"error":"Browser inspection failed"}"#); ++ } ++ } ++ ++ pub(crate) fn cleanup(&mut self) { ++ let (controller, child, cancel) = { ++ let mut state = self.state.borrow_mut(); ++ if state.closed { ++ return; ++ } ++ state.closed = true; ++ state.webview.take(); ++ let cancel = state.auth && !state.auth_finished; ++ state.auth_finished = true; ++ let child = std::mem::take(&mut state.child); ++ (state.controller.take(), child, cancel) ++ }; ++ if cancel { ++ auth_event(&self.state, "cancelled", String::new()); ++ } ++ unsafe { ++ if let Some(controller) = controller { ++ close_controller(&controller); ++ } ++ if !child.is_invalid() { ++ let _ = DestroyWindow(child); ++ } ++ } ++ } ++} ++ ++impl Drop for WindowsSystemBrowser { ++ fn drop(&mut self) { ++ self.cleanup(); ++ } ++} ++ ++unsafe fn close_controller(controller: &ICoreWebView2Controller) { ++ // InPrivate never persists cookies/storage. Delete the fresh profile as ++ // well, so its on-disk bookkeeping does not accumulate across app cards. ++ if let Ok(profile) = controller ++ .CoreWebView2() ++ .and_then(|view| view.cast::()) ++ .and_then(|view| view.Profile()) ++ .and_then(|profile| profile.cast::()) ++ { ++ let _ = profile.Delete(); ++ } ++ let _ = controller.Close(); ++} ++ ++unsafe fn start_environment(state: &Shared, child: HWND) -> Result<()> { ++ let weak = Rc::downgrade(state); ++ let options: ICoreWebView2EnvironmentOptions = CoreWebView2EnvironmentOptions::default().into(); ++ // Never attach an OS account or share a browser profile with another app ++ // card. Controllers use a fresh InPrivate profile below, including auth. ++ options.SetAllowSingleSignOnUsingOSPrimaryAccount(false)?; ++ let folder = std::env::var_os("LOCALAPPDATA") ++ .ok_or_else(|| webview2_windows::core::Error::from(E_FAIL))?; ++ let folder = std::path::PathBuf::from(folder) ++ .join("Makepad") ++ .join("WebView2"); ++ let folder = wide(&folder.to_string_lossy()); ++ CreateCoreWebView2EnvironmentWithOptions( ++ PCWSTR::null(), ++ PCWSTR(folder.as_ptr()), ++ &options, ++ &CreateCoreWebView2EnvironmentCompletedHandler::create(Box::new( ++ move |result, environment| { ++ let Some(state) = weak.upgrade() else { ++ return Ok(()); ++ }; ++ if state.borrow().closed { ++ return Ok(()); ++ } ++ let result = result ++ .and_then(|_| { ++ environment.ok_or_else(|| webview2_windows::core::Error::from(E_FAIL)) ++ }) ++ .and_then(|environment| create_controller(&state, &environment, child)); ++ if let Err(error) = result { ++ report_error( ++ &state, ++ "WebView2 is unavailable or does not support isolated browser profiles", ++ error.code().0, ++ ); ++ } ++ Ok(()) ++ }, ++ )), ++ ) ++} ++ ++unsafe fn create_controller( ++ state: &Shared, ++ environment: &ICoreWebView2Environment, ++ child: HWND, ++) -> Result<()> { ++ let version: ICoreWebView2Environment10 = environment.cast()?; ++ let options = version.CreateCoreWebView2ControllerOptions()?; ++ options.SetIsInPrivateModeEnabled(true)?; ++ let profile = wide(&format!( ++ "makepad-{}-{}", ++ std::process::id(), ++ NEXT_PROFILE.fetch_add(1, Ordering::Relaxed) ++ )); ++ options.SetProfileName(PCWSTR(profile.as_ptr()))?; ++ let weak = Rc::downgrade(state); ++ let environment = environment.clone(); ++ version.CreateCoreWebView2ControllerWithOptions( ++ child, ++ &options, ++ &CreateCoreWebView2ControllerCompletedHandler::create(Box::new( ++ move |result, controller| { ++ let Some(state) = weak.upgrade() else { ++ if let Some(controller) = controller { ++ close_controller(&controller); ++ } ++ return Ok(()); ++ }; ++ if state.borrow().closed { ++ if let Some(controller) = controller { ++ close_controller(&controller); ++ } ++ return Ok(()); ++ } ++ let result = result ++ .and_then(|_| { ++ controller.ok_or_else(|| webview2_windows::core::Error::from(E_FAIL)) ++ }) ++ .and_then(|controller| { ++ let webview = controller.CoreWebView2()?; ++ if let Err(error) = configure(&state, &environment, &webview) { ++ close_controller(&controller); ++ return Err(error); ++ } ++ let (bounds, visible) = { ++ let s = state.borrow(); ++ (s.bounds, s.visible) ++ }; ++ if let Err(error) = controller ++ .SetBounds(bounds) ++ .and_then(|_| controller.SetIsVisible(visible)) ++ { ++ close_controller(&controller); ++ return Err(error); ++ } ++ { ++ let mut s = state.borrow_mut(); ++ s.controller = Some(controller); ++ s.webview = Some(webview); ++ } ++ load_pending(&state); ++ Ok(()) ++ }); ++ if let Err(error) = result { ++ report_error( ++ &state, ++ "Cannot initialize the embedded browser safely", ++ error.code().0, ++ ); ++ } ++ Ok(()) ++ }, ++ )), ++ ) ++} ++ ++unsafe fn configure( ++ state: &Shared, ++ environment: &ICoreWebView2Environment, ++ webview: &ICoreWebView2, ++) -> Result<()> { ++ let settings = webview.Settings()?; ++ settings.SetAreHostObjectsAllowed(false)?; ++ settings.SetIsWebMessageEnabled(false)?; ++ settings.SetAreDefaultContextMenusEnabled(false)?; ++ settings.SetAreDevToolsEnabled(false)?; ++ settings.SetAreDefaultScriptDialogsEnabled(false)?; ++ settings.SetIsStatusBarEnabled(false)?; ++ settings ++ .cast::()? ++ .SetAreBrowserAcceleratorKeysEnabled(false)?; ++ let autofill: ICoreWebView2Settings4 = settings.cast()?; ++ autofill.SetIsPasswordAutosaveEnabled(false)?; ++ autofill.SetIsGeneralAutofillEnabled(false)?; ++ let mut token = 0; ++ for main_frame in [true, false] { ++ let weak = Rc::downgrade(state); ++ let handler = NavigationStartingEventHandler::create(Box::new(move |_, args| { ++ let Some(args) = args else { ++ return Ok(()); ++ }; ++ let Some(state) = weak.upgrade() else { ++ return args.SetCancel(true); ++ }; ++ let url = read_string(|p| args.Uri(p))?; ++ let decision = { ++ let s = state.borrow(); ++ if s.closed || s.auth_finished { ++ NavigationDecision::Block ++ } else if s.inline && url == "about:blank" && main_frame { ++ NavigationDecision::Allow ++ } else { ++ s.policy ++ .as_ref() ++ .map(|p| p.decide(&url, main_frame)) ++ .unwrap_or(NavigationDecision::Block) ++ } ++ }; ++ match decision { ++ NavigationDecision::Allow => { ++ if main_frame { ++ navigation(&state, &url, true, None); ++ } ++ } ++ NavigationDecision::Block => { ++ args.SetCancel(true)?; ++ report_blocked(&state); ++ } ++ NavigationDecision::Callback => { ++ args.SetCancel(true)?; ++ let emit = { ++ let mut s = state.borrow_mut(); ++ let emit = !s.auth_finished; ++ s.auth_finished = true; ++ emit ++ }; ++ if emit { ++ auth_event(&state, "callback", url); ++ } ++ } ++ } ++ Ok(()) ++ })); ++ if main_frame { ++ webview.add_NavigationStarting(&handler, &mut token)?; ++ } else { ++ webview.add_FrameNavigationStarting(&handler, &mut token)?; ++ } ++ } ++ let weak = Rc::downgrade(state); ++ webview.add_NavigationCompleted( ++ &NavigationCompletedEventHandler::create(Box::new(move |sender, args| { ++ let (Some(state), Some(sender), Some(args)) = (weak.upgrade(), sender, args) else { ++ return Ok(()); ++ }; ++ if state.borrow().closed || state.borrow().auth_finished { ++ return Ok(()); ++ } ++ let mut success = BOOL::default(); ++ args.IsSuccess(&mut success)?; ++ if success.as_bool() { ++ let url = read_string(|p| sender.Source(p))?; ++ let title = read_string(|p| sender.DocumentTitle(p))?; ++ navigation(&state, &url, false, Some(title)); ++ } else { ++ let mut status = COREWEBVIEW2_WEB_ERROR_STATUS::default(); ++ args.WebErrorStatus(&mut status)?; ++ // Cancelled policy navigations are reported by NavigationStarting. ++ if status != COREWEBVIEW2_WEB_ERROR_STATUS_OPERATION_CANCELED { ++ report_error(&state, "The embedded page failed to load", status.0); ++ } ++ } ++ Ok(()) ++ })), ++ &mut token, ++ )?; ++ let weak = Rc::downgrade(state); ++ webview.add_DocumentTitleChanged( ++ &DocumentTitleChangedEventHandler::create(Box::new(move |sender, _| { ++ let (Some(state), Some(sender)) = (weak.upgrade(), sender) else { ++ return Ok(()); ++ }; ++ let url = read_string(|p| sender.Source(p))?; ++ let title = read_string(|p| sender.DocumentTitle(p))?; ++ navigation(&state, &url, false, Some(title)); ++ Ok(()) ++ })), ++ &mut token, ++ )?; ++ webview.add_NewWindowRequested( ++ &NewWindowRequestedEventHandler::create(Box::new(|_, args| { ++ if let Some(args) = args { ++ args.SetHandled(true)?; ++ } ++ Ok(()) ++ })), ++ &mut token, ++ )?; ++ webview.add_PermissionRequested( ++ &PermissionRequestedEventHandler::create(Box::new(|_, args| { ++ if let Some(args) = args { ++ args.SetState(COREWEBVIEW2_PERMISSION_STATE_DENY)?; ++ if let Ok(args) = args.cast::() { ++ args.SetSavesInProfile(false)?; ++ } ++ } ++ Ok(()) ++ })), ++ &mut token, ++ )?; ++ // Require APIs that can block native downloads and client-certificate UI; ++ // an old runtime fails closed instead of silently weakening the boundary. ++ webview.cast::()?.add_DownloadStarting( ++ &DownloadStartingEventHandler::create(Box::new(|_, args| { ++ if let Some(args) = args { ++ args.SetCancel(true)?; ++ args.SetHandled(true)?; ++ } ++ Ok(()) ++ })), ++ &mut token, ++ )?; ++ webview ++ .cast::()? ++ .add_ClientCertificateRequested( ++ &ClientCertificateRequestedEventHandler::create(Box::new(|_, args| { ++ if let Some(args) = args { ++ args.SetCancel(true)?; ++ args.SetHandled(true)?; ++ } ++ Ok(()) ++ })), ++ &mut token, ++ )?; ++ webview ++ .cast::()? ++ .add_BasicAuthenticationRequested( ++ &BasicAuthenticationRequestedEventHandler::create(Box::new(|_, args| { ++ if let Some(args) = args { ++ args.SetCancel(true)?; ++ } ++ Ok(()) ++ })), ++ &mut token, ++ )?; ++ webview ++ .cast::()? ++ .add_LaunchingExternalUriScheme( ++ &LaunchingExternalUriSchemeEventHandler::create(Box::new(|_, args| { ++ if let Some(args) = args { ++ args.SetCancel(true)?; ++ } ++ Ok(()) ++ })), ++ &mut token, ++ )?; ++ webview ++ .cast::()? ++ .add_ServerCertificateErrorDetected( ++ &ServerCertificateErrorDetectedEventHandler::create(Box::new(|_, args| { ++ if let Some(args) = args { ++ args.SetAction(COREWEBVIEW2_SERVER_CERTIFICATE_ERROR_ACTION_CANCEL)?; ++ } ++ Ok(()) ++ })), ++ &mut token, ++ )?; ++ webview.cast::()?.add_SaveAsUIShowing( ++ &SaveAsUIShowingEventHandler::create(Box::new(|_, args| { ++ if let Some(args) = args { ++ args.SetCancel(true)?; ++ args.SetSuppressDefaultDialog(true)?; ++ } ++ Ok(()) ++ })), ++ &mut token, ++ )?; ++ // Screen sharing is a separate WebView2 event, not PermissionRequested. ++ // It bubbles from subframes as well; never show the native screen picker. ++ webview ++ .cast::()? ++ .add_ScreenCaptureStarting( ++ &ScreenCaptureStartingEventHandler::create(Box::new(|_, args| { ++ if let Some(args) = args { ++ args.SetCancel(true)?; ++ args.SetHandled(true)?; ++ } ++ Ok(()) ++ })), ++ &mut token, ++ )?; ++ let weak = Rc::downgrade(state); ++ webview.add_ProcessFailed( ++ &ProcessFailedEventHandler::create(Box::new(move |_, _| { ++ if let Some(state) = weak.upgrade() { ++ report_error(&state, "The embedded browser process stopped", -1); ++ } ++ Ok(()) ++ })), ++ &mut token, ++ )?; ++ // Filter fetches as well as top-level navigation. Auth is confined to its ++ // declared origin; readers may fetch public HTTPS resources, never local ++ // files or literal private-network endpoints. DNS rebinding is not solved ++ // by URL policy alone and is not advertised as an OS network sandbox. ++ webview.AddWebResourceRequestedFilter(w!("*"), COREWEBVIEW2_WEB_RESOURCE_CONTEXT_ALL)?; ++ let weak = Rc::downgrade(state); ++ let environment = environment.clone(); ++ webview.add_WebResourceRequested( ++ &WebResourceRequestedEventHandler::create(Box::new(move |_, args| { ++ let Some(args) = args else { ++ return Ok(()); ++ }; ++ let url = read_string(|p| args.Request()?.Uri(p))?; ++ let allowed = weak.upgrade().is_some_and(|state| { ++ let s = state.borrow(); ++ !s.closed ++ && !s.auth_finished ++ && if s.auth { ++ s.policy.as_ref().is_some_and(|p| { ++ matches!(p.decide(&url, false), NavigationDecision::Allow) ++ }) ++ } else { ++ public_resource(&url) ++ || s.policy.as_ref().is_some_and(|p| { ++ matches!(p.decide(&url, false), NavigationDecision::Allow) ++ }) ++ || url == "about:blank" ++ || url.starts_with("data:") ++ || url.starts_with("blob:") ++ } ++ }); ++ if !allowed { ++ let response = environment.CreateWebResourceResponse( ++ None::<&IStream>, ++ 403, ++ w!("Blocked"), ++ w!("Content-Type: text/plain\r\n"), ++ )?; ++ args.SetResponse(&response)?; ++ } ++ Ok(()) ++ })), ++ &mut token, ++ )?; ++ Ok(()) ++} ++ ++fn load_pending(state: &Shared) { ++ let (webview, document, allowed) = { ++ let s = state.borrow(); ++ ( ++ s.webview.clone(), ++ s.pending.clone(), ++ !s.closed && !s.auth_finished && (s.policy.is_some() || s.inline), ++ ) ++ }; ++ if !allowed { ++ return; ++ } ++ let Some(webview) = webview else { ++ return; ++ }; ++ let result = unsafe { ++ match document { ++ Document::Url(url, true) => { ++ execute(&webview, &format!("location.replace({})", js_string(&url))) ++ } ++ Document::Url(url, false) => { ++ let url = wide(&url); ++ webview.Navigate(PCWSTR(url.as_ptr())) ++ } ++ Document::Html(html) => { ++ let html = wide(&html); ++ webview.NavigateToString(PCWSTR(html.as_ptr())) ++ } ++ } ++ }; ++ if let Err(error) = result { ++ report_error(state, "Cannot load the embedded page", error.code().0); ++ } ++} ++ ++unsafe fn capture( ++ webview: &ICoreWebView2, ++ path: String, ++ result_path: String, ++ metadata: String, ++) -> Result<()> { ++ let stream = CreateStreamOnHGlobal(Default::default(), true)?; ++ let output = stream.clone(); ++ webview.CapturePreview( ++ COREWEBVIEW2_CAPTURE_PREVIEW_IMAGE_FORMAT_PNG, ++ &stream, ++ &CapturePreviewCompletedHandler::create(Box::new(move |result| { ++ let result = result.and_then(|_| { ++ let mut stat = STATSTG::default(); ++ output.Stat(&mut stat, STATFLAG_NONAME)?; ++ if stat.cbSize == 0 || stat.cbSize > 32 * 1024 * 1024 { ++ return Err(webview2_windows::core::Error::from(E_FAIL)); ++ } ++ output.Seek(0, STREAM_SEEK_SET, None)?; ++ let mut bytes = vec![0u8; stat.cbSize as usize]; ++ let mut read = 0; ++ output ++ .Read( ++ bytes.as_mut_ptr().cast(), ++ bytes.len() as u32, ++ Some(&mut read), ++ ) ++ .ok()?; ++ if read as usize != bytes.len() { ++ return Err(webview2_windows::core::Error::from(E_FAIL)); ++ } ++ std::fs::write(path, bytes) ++ .map_err(|_| webview2_windows::core::Error::from(E_FAIL))?; ++ Ok(()) ++ }); ++ // The metadata file is the completion marker: the PNG is fully ++ // written first, so acceptance never races a partial snapshot. ++ let text = if result.is_ok() { ++ metadata ++ } else { ++ r#"{"error":"Browser capture failed"}"#.into() ++ }; ++ let _ = std::fs::write(result_path, text); ++ Ok(()) ++ })), ++ ) ++} ++ ++unsafe fn execute(webview: &ICoreWebView2, js: &str) -> Result<()> { ++ let js = wide(js); ++ webview.ExecuteScript( ++ PCWSTR(js.as_ptr()), ++ &ExecuteScriptCompletedHandler::create(Box::new(|result, _| result)), ++ ) ++} ++ ++unsafe fn read_string(read: impl FnOnce(*mut PWSTR) -> Result<()>) -> Result { ++ let mut raw = PWSTR::null(); ++ let result = read(&mut raw); ++ let value = if result.is_ok() { ++ raw.to_string().unwrap_or_default() ++ } else { ++ String::new() ++ }; ++ CoTaskMemFree(Some(raw.0.cast())); ++ result.map(|_| value) ++} ++ ++fn public_resource(url: &str) -> bool { ++ BrowserPolicy::reader("about:blank", true) ++ .is_ok_and(|p| matches!(p.decide(url, false), NavigationDecision::Allow)) ++} ++ ++fn wide(value: &str) -> Vec { ++ value.encode_utf16().chain(Some(0)).collect() ++} ++fn js_string(value: &str) -> String { ++ let mut result = String::from("\""); ++ for unit in value.encode_utf16() { ++ use std::fmt::Write; ++ let _ = write!(result, "\\u{unit:04x}"); ++ } ++ result.push('"'); ++ result ++} ++fn html_attribute(value: &str) -> String { ++ value ++ .replace('&', "&") ++ .replace('"', """) ++ .replace('<', "<") ++ .replace('>', ">") ++} ++fn auth_event(state: &Shared, kind: &str, url: String) { ++ Cx::post_action(NativeAuthBrowserEvent { ++ browser_id: state.borrow().id.0, ++ kind: kind.into(), ++ url, ++ }); ++} ++fn report_blocked(state: &Shared) { ++ let s = state.borrow(); ++ if s.closed || s.auth_finished { ++ return; ++ } ++ if s.auth { ++ drop(s); ++ auth_event(state, "blocked", String::new()); ++ } else { ++ drop(s); ++ report_error( ++ state, ++ "Navigation is not permitted in this embedded browser", ++ -1, ++ ); ++ } ++} ++fn report_error(state: &Shared, description: &str, code: i32) { ++ let s = state.borrow(); ++ if s.closed || s.auth_finished { ++ return; ++ } ++ if s.auth { ++ drop(s); ++ auth_event(state, "error", String::new()); ++ } else { ++ Cx::post_action(NativeSystemBrowserPageError { ++ browser_id: s.id.0, ++ code, ++ description: description.into(), ++ url: String::new(), ++ }); ++ } ++} ++fn navigation(state: &Shared, url: &str, loading: bool, title: Option) { ++ let s = state.borrow(); ++ if s.auth || s.closed { ++ return; ++ } ++ Cx::post_action(NativeSystemBrowserNavigation { ++ browser_id: s.id.0, ++ url: url.chars().take(16384).collect(), ++ title: title.unwrap_or_default().chars().take(2048).collect(), ++ loading, ++ error: None, ++ }); ++} +diff --git a/platform/src/system_browser.rs b/platform/src/system_browser.rs +new file mode 100644 +index 000000000..863e40441 +--- /dev/null ++++ b/platform/src/system_browser.rs +@@ -0,0 +1,122 @@ ++//! Navigation boundaries shared by the desktop embedded-browser adapters. ++//! The caller has already checked the app's initial URL grant. Native page ++//! navigations must not turn that grant into file, device or external-app access. ++use std::net::IpAddr; ++use url::{Host, Url}; ++ ++#[derive(Clone, Copy, Debug, PartialEq, Eq)] ++pub enum NavigationDecision { ++ Allow, ++ Block, ++ /// Intercept before loading; deliver only through the host auth event. ++ Callback, ++} ++ ++#[derive(Clone)] ++pub struct BrowserPolicy { ++ initial: Url, ++ navigable: bool, ++ callback: Option, ++} ++ ++impl BrowserPolicy { ++ pub fn reader(initial: &str, navigable: bool) -> Result { ++ let initial = address(initial).ok_or("Invalid embedded page address")?; ++ if !page_scheme(&initial) && initial.as_str() != "about:blank" { ++ return Err("Embedded pages require an HTTP or HTTPS address"); ++ } ++ Ok(Self { initial, navigable, callback: None }) ++ } ++ ++ pub fn auth(initial: &str, callback: &str) -> Result { ++ let initial = address(initial).filter(auth_scheme) ++ .ok_or("Invalid authentication origin")?; ++ let callback = address(callback).filter(auth_scheme) ++ .filter(|url| url.query().is_none() && url.fragment().is_none()) ++ .ok_or("Invalid authentication callback")?; ++ Ok(Self { initial, navigable: false, callback: Some(callback) }) ++ } ++ ++ pub fn is_auth(&self) -> bool { self.callback.is_some() } ++ ++ pub fn decide(&self, requested: &str, main_frame: bool) -> NavigationDecision { ++ let Some(requested) = address(requested) else { return NavigationDecision::Block; }; ++ if let Some(callback) = &self.callback { ++ if requested.origin() == callback.origin() && requested.path() == callback.path() { ++ return if main_frame && requested.fragment().is_none() { ++ NavigationDecision::Callback ++ } else { NavigationDecision::Block }; ++ } ++ return if auth_scheme(&requested) && requested.origin() == self.initial.origin() { ++ NavigationDecision::Allow ++ } else { NavigationDecision::Block }; ++ } ++ // Fragment changes stay inside the same document; redirects or links ++ // to another path/query do not inherit a read-only document grant. ++ if same_document(&self.initial, &requested) { ++ return NavigationDecision::Allow; ++ } ++ if self.navigable && public_https(&requested) { ++ NavigationDecision::Allow ++ } else { NavigationDecision::Block } ++ } ++} ++ ++fn address(value: &str) -> Option { ++ if value.is_empty() || value.len() > 16_384 ++ || value.chars().any(|c| c.is_control() || c.is_whitespace() || c == '\\') ++ { return None; } ++ let parsed = Url::parse(value).ok()?; ++ if !parsed.username().is_empty() || parsed.password().is_some() { return None; } ++ Some(parsed) ++} ++ ++fn page_scheme(url: &Url) -> bool { ++ matches!(url.scheme(), "http" | "https") && url.host().is_some() ++} ++ ++fn auth_scheme(url: &Url) -> bool { ++ url.host().is_some() && (url.scheme() == "https" ++ || (url.scheme() == "http" && url.host_str() == Some("127.0.0.1"))) ++} ++ ++fn same_document(initial: &Url, requested: &Url) -> bool { ++ let mut initial = initial.clone(); ++ let mut requested = requested.clone(); ++ initial.set_fragment(None); ++ requested.set_fragment(None); ++ initial == requested ++} ++ ++fn public_https(url: &Url) -> bool { ++ if url.scheme() != "https" { return false; } ++ match url.host() { ++ Some(Host::Ipv4(ip)) => public_ip(ip.into()), ++ Some(Host::Ipv6(ip)) => public_ip(ip.into()), ++ Some(Host::Domain(host)) => { ++ let host = host.trim_end_matches('.'); ++ host.contains('.') && ![".localhost", ".internal", ".local", ".lan", ".home.arpa"] ++ .iter().any(|suffix| host.ends_with(suffix)) ++ } ++ None => false, ++ } ++} ++ ++fn public_ip(ip: IpAddr) -> bool { ++ match ip { ++ IpAddr::V4(ip) => { ++ let [a, b, ..] = ip.octets(); ++ !(ip.is_loopback() || ip.is_private() || ip.is_link_local() ++ || ip.is_unspecified() || ip.is_broadcast() || ip.is_documentation() ++ || ip.is_multicast() || a == 0 || a >= 240 ++ || (a == 100 && (64..128).contains(&b))) ++ } ++ IpAddr::V6(ip) => { ++ if let Some(v4) = ip.to_ipv4_mapped() { return public_ip(v4.into()); } ++ let parts = ip.segments(); ++ !(ip.is_loopback() || ip.is_unspecified() || ip.is_multicast() ++ || (parts[0] & 0xfe00) == 0xfc00 || (parts[0] & 0xffc0) == 0xfe80 ++ || (parts[0] == 0x2001 && parts[1] == 0xdb8)) ++ } ++ } ++} +diff --git a/widgets/src/web_reader.rs b/widgets/src/web_reader.rs +index 44240521e..a4fcad174 100644 +--- a/widgets/src/web_reader.rs ++++ b/widgets/src/web_reader.rs +@@ -1,7 +1,7 @@ + //! A web page inside a script app: the platform's own web view (WKWebView, +-//! Android's WebView) glued to this widget's rect, driven from script. +-//! Linux/Windows have no SystemBrowser adapter: open returns false with an +-//! explicit error rather than claiming success for an invisible browser. ++//! Android's WebView, WebKitGTK on Linux X11/XWayland, or WebView2 on Windows) ++//! glued to this widget's rect, driven from script. Missing native runtimes ++//! and native Wayland report a page error rather than opening another browser. + //! + //! ```text + //! reader := WebReader{width: Fill height: Fill on_error: || show(ui.reader.error())} +@@ -174,7 +174,7 @@ pub fn open(&mut self, cx: &mut Cx, url: &str) -> bool { + } + + fn open_on_platform(&mut self, cx: &mut Cx, url: &str, platform: &str) -> bool { +- if !matches!(platform, "macos" | "ios" | "android") { ++ if !matches!(platform, "macos" | "ios" | "android" | "linux" | "windows") { + self.report(cx, "Embedded web pages are unavailable on this platform; this host has no native WebReader adapter".into()); + return false; + } +@@ -466,7 +466,7 @@ fn unsupported_platform_never_starts_browser_lifecycle() { + let value = vm.eval(script! { use mod.widgets.* WebReader {} }); + WebReader::script_from_value(vm, value) + }); +- for platform in ["linux", "windows", "freebsd", "unknown"] { ++ for platform in ["freebsd", "tvos", "unknown"] { + assert!(!reader.open_on_platform(&mut cx, "https://example.test/", platform)); + assert!(!reader.open); + assert!(!reader.spawned); diff --git a/tools/test_ci_local.py b/tools/test_ci_local.py index 8cbf14ea..9b2d99ee 100644 --- a/tools/test_ci_local.py +++ b/tools/test_ci_local.py @@ -224,6 +224,23 @@ def test_the_comment_names_the_commit(self): self.assertIn("| phone.yml / home | Test | PASS |", body) +class NativeRemoteEvidence(unittest.TestCase): + def test_unrelated_changes_do_not_query_native_runner(self): + with patch.object(merge, "gh_json") as query: + self.assertEqual(merge.remote_evidence_problems("b" * 40, ["docs/x.md"]), ([], [])) + query.assert_not_called() + + def test_only_success_on_the_exact_head_satisfies_native_gate(self): + head = "b" * 40 + changed = ["tools/browser-smoke.py"] + good = {"headSha": head, "status": "completed", "conclusion": "success"} + for runs in ([], [dict(good, headSha="a" * 40)], [dict(good, status="in_progress")], [dict(good, conclusion="skipped")], [dict(good, conclusion="failure")]): + with self.subTest(runs=runs), patch.object(merge, "gh_json", return_value=runs): + self.assertTrue(merge.remote_evidence_problems(head, changed)[0]) + with patch.object(merge, "gh_json", return_value=[good]): + self.assertEqual(merge.remote_evidence_problems(head, changed), ([], ["embedded-browser.yml"])) + + class LinuxHostPlan(unittest.TestCase): def where(self, workflows, linux_host): return {(w, j): where for w, j, _, where in ci.plan_jobs(workflows, linux_host)} From 3f7dcbf07d9fd4ba1402a69fc3713f74bb9f6f04 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:44:19 -0700 Subject: [PATCH 02/35] fix: complete Linux entry and embedded browser runtime requirements --- crates/browser-smoke/src/policy_tests.rs | 3 +++ desktop/packaging/release.json | 3 ++- desktop/scripts/package.py | 5 ++++- desktop/scripts/test_package.py | 2 ++ desktop/src/main.rs | 11 +++++++---- docs/desktop-embedded-browser.md | 7 ++++--- docs/desktop-embedded-browser.zh-CN.md | 6 +++--- runtime-patches.lock.json | 4 ++-- tools/runtime-patches/makepad-desktop-webview.patch | 9 +++++---- 9 files changed, 32 insertions(+), 18 deletions(-) diff --git a/crates/browser-smoke/src/policy_tests.rs b/crates/browser-smoke/src/policy_tests.rs index fe79021b..4924feba 100644 --- a/crates/browser-smoke/src/policy_tests.rs +++ b/crates/browser-smoke/src/policy_tests.rs @@ -36,6 +36,9 @@ fn navigable_readers_allow_public_https_and_reject_private_destinations() { "https://[::1]/", "https://[::ffff:127.0.0.1]/", "https://[fd00::1]/", + "https://[fec0::1]/", + "https://home.arpa/", + "https://device.home.arpa/", "https://[2001:db8::1]/", "https://printer.local/", "https://localhost./", diff --git a/desktop/packaging/release.json b/desktop/packaging/release.json index 40b57c28..717966b3 100644 --- a/desktop/packaging/release.json +++ b/desktop/packaging/release.json @@ -25,7 +25,8 @@ "entitlements": "macos/entitlements.plist" }, "deb": { - "section": "utils" + "section": "utils", + "depends": ["libgtk-3-0t64 | libgtk-3-0", "libwebkit2gtk-4.1-0 | libwebkit2gtk-4.0-37"] }, "nsis": { "installMode": "currentUser" diff --git a/desktop/scripts/package.py b/desktop/scripts/package.py index 1e3a9742..9f53b23e 100644 --- a/desktop/scripts/package.py +++ b/desktop/scripts/package.py @@ -332,7 +332,10 @@ def main(argv=None): if os_name == "linux": depends = debian_depends([binaries_dir / PACKAGE, *([sidecar.with_name(f"{KERNEL_NAME}-{triple}")] if sidecar else [])]) if depends: - config.setdefault("deb", {})["depends"] = depends + # GTK/WebKit are loaded dynamically and do not appear in shlibdeps. + # Preserve explicit runtime dependencies from release.json. + deb = config.setdefault("deb", {}) + deb["depends"] = list(dict.fromkeys([*deb.get("depends", []), *depends])) # Relative paths in the config resolve from its directory, so the # generated copy sits beside release.json (git-ignored). generated = PACKAGING / ".release.generated.json" diff --git a/desktop/scripts/test_package.py b/desktop/scripts/test_package.py index e5450d9d..e13d07e6 100644 --- a/desktop/scripts/test_package.py +++ b/desktop/scripts/test_package.py @@ -91,6 +91,8 @@ def test_the_release_config_is_filled_in_per_build(self): base = json.loads((HERE.parent / "packaging/release.json").read_text()) self.assertEqual(base["identifier"], "org.octosense.desktop") self.assertEqual(base["productName"], "OctoSense") + self.assertIn("libwebkit2gtk-4.1-0 | libwebkit2gtk-4.0-37", base["deb"]["depends"]) + self.assertIn("libgtk-3-0t64 | libgtk-3-0", base["deb"]["depends"]) for icon in base["icons"]: self.assertTrue((HERE.parent / "packaging" / icon).is_file(), icon) config = package.packager_config(base, version="0.2.0", binaries_dir=Path("/t/release"), out_dir=Path("/t/dist"), diff --git a/desktop/src/main.rs b/desktop/src/main.rs index b4e91864..aae64457 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -21,10 +21,13 @@ mod linux_entry { } #[cfg(target_os = "linux")] -pub use linux_entry::app_main; - -#[cfg(target_os = "linux")] -fn main() { +pub fn app_main() { Cx::prefer_x11_for_embedded_browser(); linux_entry::start(); } + +// This source is also the desktop library entry; keep both entry functions public. +#[cfg(target_os = "linux")] +pub fn main() { + app_main(); +} diff --git a/docs/desktop-embedded-browser.md b/docs/desktop-embedded-browser.md index 0a64ab39..bf60d750 100644 --- a/docs/desktop-embedded-browser.md +++ b/docs/desktop-embedded-browser.md @@ -17,9 +17,10 @@ installing an app bundle alone cannot upgrade an older host. | Windows | An installed Microsoft Edge WebView2 Runtime exposing `ICoreWebView2_27`. Missing engines or older runtimes fail explicitly; the host does not download an engine. The adapter uses `webview2-com` 0.39.1. | | macOS / Android | Existing native reader adapters are unchanged by this desktop work. | -A Linux package maintainer must include the appropriate distribution runtime -dependency, usually `libwebkit2gtk-4.1-0` and GTK 3. Windows deployment should -provide the supported WebView2 Runtime. This source change does not bundle either +The `.deb` release configuration declares GTK 3 and WebKitGTK 4.1-or-4.0 +runtime dependencies because dynamic loading is invisible to `dpkg-shlibdeps`. +AppImage users need these libraries installed separately. Windows deployment +needs the supported WebView2 Runtime. This source change does not bundle either engine. The Windows adapter deliberately requires the interfaces used to deny native Save As and screen capture instead of silently omitting those gates. diff --git a/docs/desktop-embedded-browser.zh-CN.md b/docs/desktop-embedded-browser.zh-CN.md index 81246000..4be0bcc3 100644 --- a/docs/desktop-embedded-browser.zh-CN.md +++ b/docs/desktop-embedded-browser.zh-CN.md @@ -16,9 +16,9 @@ | Windows | 需要已安装且提供 `ICoreWebView2_27` 的 Microsoft Edge WebView2 Runtime。引擎缺失或版本过旧会明确失败,宿主不会下载引擎。适配器使用 `webview2-com` 0.39.1。 | | macOS / Android | 本次桌面改动不改变已有原生阅读器适配器。 | -Linux 打包维护者需要声明对应发行版的运行库依赖,通常为 `libwebkit2gtk-4.1-0` -与 GTK 3。Windows 部署需要提供受支持的 WebView2 Runtime。本次源码改动不打包 -任何引擎。Windows 适配器要求用于阻止原生“另存为”和屏幕捕获的接口, +由于 `dpkg-shlibdeps` 看不到动态加载的库,`.deb` 发布配置显式声明 GTK 3 和 +WebKitGTK 4.1 或 4.0 运行库依赖。AppImage 用户需要另行安装这些库。Windows +部署需要提供受支持的 WebView2 Runtime。本次源码改动不打包任何引擎。Windows 适配器要求用于阻止原生“另存为”和屏幕捕获的接口, 不会在旧引擎上悄悄跳过这些限制。 ## 应用与宿主边界 diff --git a/runtime-patches.lock.json b/runtime-patches.lock.json index 1503e1f7..13e3236a 100644 --- a/runtime-patches.lock.json +++ b/runtime-patches.lock.json @@ -4,7 +4,7 @@ "base_revision": "68d1f4ecc111daa90c50530e77df3245f05fc2cf", "patch": "tools/runtime-patches/makepad-settings.patch", "sha256": "b17352ebf59e50ab3db6ff1cb825c4e006bcdff47d83522e372b8fe585e57570", - "tree": "408d6742481ec063b4c152bd8cd381b32faef3f8", + "tree": "78f543377794e5ed872c83b22ba4d1405c416d27", "product_fixes": [ "Settings integration: Android IME synchronization, accessibility and reduced motion, GLES program-cache validation, obscured-gesture cancellation and scroll-position observation.", "Contained-app isolation and secret-input restrictions come from the pinned upstream runtime; the Settings overlay preserves those gates.", @@ -113,7 +113,7 @@ }, { "patch": "tools/runtime-patches/makepad-desktop-webview.patch", - "sha256": "f477e4e955a343ae37843db463cf5a3c897f27a62da7a6568939658dc915038b" + "sha256": "7fdcbaf20adc488cfca0e2e985ed48b4ede2e21fe58edd201aba82a99da6b245" } ] } diff --git a/tools/runtime-patches/makepad-desktop-webview.patch b/tools/runtime-patches/makepad-desktop-webview.patch index 9a20d065..3cd4c7da 100644 --- a/tools/runtime-patches/makepad-desktop-webview.patch +++ b/tools/runtime-patches/makepad-desktop-webview.patch @@ -2037,10 +2037,10 @@ index 000000000..34fa36c63 +} diff --git a/platform/src/system_browser.rs b/platform/src/system_browser.rs new file mode 100644 -index 000000000..863e40441 +index 000000000..1fe6ef745 --- /dev/null +++ b/platform/src/system_browser.rs -@@ -0,0 +1,122 @@ +@@ -0,0 +1,123 @@ +//! Navigation boundaries shared by the desktop embedded-browser adapters. +//! The caller has already checked the app's initial URL grant. Native page +//! navigations must not turn that grant into file, device or external-app access. @@ -2138,8 +2138,8 @@ index 000000000..863e40441 + Some(Host::Ipv6(ip)) => public_ip(ip.into()), + Some(Host::Domain(host)) => { + let host = host.trim_end_matches('.'); -+ host.contains('.') && ![".localhost", ".internal", ".local", ".lan", ".home.arpa"] -+ .iter().any(|suffix| host.ends_with(suffix)) ++ host.contains('.') && !["localhost", "internal", "local", "lan", "home.arpa"] ++ .iter().any(|name| host == *name || host.strip_suffix(name).is_some_and(|prefix| prefix.ends_with('.'))) + } + None => false, + } @@ -2159,6 +2159,7 @@ index 000000000..863e40441 + let parts = ip.segments(); + !(ip.is_loopback() || ip.is_unspecified() || ip.is_multicast() + || (parts[0] & 0xfe00) == 0xfc00 || (parts[0] & 0xffc0) == 0xfe80 ++ || (parts[0] & 0xffc0) == 0xfec0 + || (parts[0] == 0x2001 && parts[1] == 0xdb8)) + } + } From 86d0097eb2db0f36c34ce9c072ac51d8d91937e5 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:44:53 -0700 Subject: [PATCH 03/35] docs: state native browser dialog policy limits --- docs/desktop-embedded-browser.md | 9 ++++++--- docs/desktop-embedded-browser.zh-CN.md | 6 ++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/desktop-embedded-browser.md b/docs/desktop-embedded-browser.md index bf60d750..ae3b87f4 100644 --- a/docs/desktop-embedded-browser.md +++ b/docs/desktop-embedded-browser.md @@ -40,9 +40,12 @@ or clipping only detaches the visible overlay and preserves the current page. The native child keeps its full document dimensions inside a clipping parent, so scrolling the host does not reflow the page to the clipped rectangle. -These views provide no OctoSense tool bridge. Browser-originated permission -requests, downloads and popups are denied. Ordinary pages cannot use a native -browser prompt to acquire the app's camera, microphone or filesystem authority. +These views provide no OctoSense tool bridge. Browser permission requests, +downloads and popups are denied. Pages cannot use these browser requests to grant +Splash camera, microphone or filesystem APIs. Linux also cancels file chooser and +print signals. Windows does not intercept HTML file-selection or print UI; their +native interaction remains unverified and must not be described as disabled. +A file selected for a website is separate from an app's host-service grants. URL validation is not a DNS or network sandbox: a permitted public hostname can resolve to a private address. Do not treat a reader as an SSRF isolation boundary. diff --git a/docs/desktop-embedded-browser.zh-CN.md b/docs/desktop-embedded-browser.zh-CN.md index 4be0bcc3..c877b58b 100644 --- a/docs/desktop-embedded-browser.zh-CN.md +++ b/docs/desktop-embedded-browser.zh-CN.md @@ -33,8 +33,10 @@ WebKitGTK 4.1 或 4.0 运行库依赖。AppImage 用户需要另行安装这些 再次打开使用新会话。隐藏或裁剪只移开可见覆盖层,保留当前页面。原生子视图 在裁剪父窗口中保持完整文档尺寸,避免宿主滚动时网页按可见小块重新排版。 -网页没有 OctoSense 工具桥。拒绝网页发起的原生权限请求、下载及弹窗,不能借 -浏览器权限框取得应用的摄像头、麦克风或文件权限。URL 校验不是 DNS 或网络 +网页没有 OctoSense 工具桥。拒绝浏览器权限请求、下载及弹窗;这些请求不能授予 +Splash 摄像头、麦克风或文件 API 权限。Linux 还取消文件选择与打印信号。 +Windows 尚未拦截 HTML 文件选择或打印 UI,这些原生交互未验证,不能宣称已禁用。 +为网站选择文件与授予应用宿主服务权限是不同操作。URL 校验不是 DNS 或网络 沙箱:获准的公开域名仍可能解析到私有地址,因此不能把阅读器当作 SSRF 隔离边界。 后端登录是独立的宿主流程。**Linux 和 Windows 仍使用已有外部浏览器认证路径**; From c184e96803557c0ad97557e81e005762a4798dc9 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:45:34 -0700 Subject: [PATCH 04/35] ci: reuse release browser artifacts for policy checks --- .github/workflows/embedded-browser.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/embedded-browser.yml b/.github/workflows/embedded-browser.yml index 04a0aac8..969f8607 100644 --- a/.github/workflows/embedded-browser.yml +++ b/.github/workflows/embedded-browser.yml @@ -55,7 +55,7 @@ jobs: - name: Build the native acceptance host run: cargo build --locked --release -p octosense-browser-smoke - name: Navigation and callback policy tests - run: cargo test --locked -p octosense-browser-smoke + run: cargo test --locked --release -p octosense-browser-smoke - name: Real embedded WebView2 acceptance # The runner must already provide WebView2. No installer, fake engine # or external-browser fallback converts a missing runtime into a pass. From 288a3d471ea17114308e7e2db982d2ae5d84f24f Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:48:50 -0700 Subject: [PATCH 05/35] test: use Windows software graphics on GPU-less browser runners --- .github/workflows/embedded-browser.yml | 3 +- docs/desktop-embedded-browser.md | 5 +++ docs/desktop-embedded-browser.zh-CN.md | 6 ++- runtime-patches.lock.json | 4 +- tools/browser-smoke.py | 7 +++- .../makepad-desktop-webview.patch | 37 +++++++++++++++++++ 6 files changed, 57 insertions(+), 5 deletions(-) diff --git a/.github/workflows/embedded-browser.yml b/.github/workflows/embedded-browser.yml index 969f8607..d282de5d 100644 --- a/.github/workflows/embedded-browser.yml +++ b/.github/workflows/embedded-browser.yml @@ -52,6 +52,7 @@ jobs: - uses: Swatinem/rust-cache@v2 with: shared-key: embedded-browser-windows + cache-on-failure: true - name: Build the native acceptance host run: cargo build --locked --release -p octosense-browser-smoke - name: Navigation and callback policy tests @@ -59,7 +60,7 @@ jobs: - name: Real embedded WebView2 acceptance # The runner must already provide WebView2. No installer, fake engine # or external-browser fallback converts a missing runtime into a pass. - run: python tools/browser-smoke.py --binary target/release/octosense-browser-smoke.exe --out "$env:RUNNER_TEMP/browser-smoke" --require-snapshot + run: python tools/browser-smoke.py --binary target/release/octosense-browser-smoke.exe --out "$env:RUNNER_TEMP/browser-smoke" --require-snapshot --software-graphics - name: Native fixture diagnostics on failure if: failure() run: | diff --git a/docs/desktop-embedded-browser.md b/docs/desktop-embedded-browser.md index ae3b87f4..0c827baf 100644 --- a/docs/desktop-embedded-browser.md +++ b/docs/desktop-embedded-browser.md @@ -82,6 +82,11 @@ JavaScript, DOM editing, navigation rejection before a forbidden HTTP request, view detach/reattach, stopped execution after close, fresh cookies after reopen, and native network errors. It never uses personal accounts or downloads engines. The Windows workflow also requires an engine-owned PNG snapshot and metadata. +On its GPU-less runner, `--software-graphics` explicitly selects the built-in +[Windows WARP rasterizer](https://learn.microsoft.com/en-us/windows/win32/direct3darticles/directx-warp) +for Makepad using `MAKEPAD_D3D11_WARP=1`; the receipt records this mode. WebView2 +remains the real native browser with its normal sandbox. This test does not prove +hardware GPU performance, and production graphics selection stays unchanged. The shared URL-policy tests run with `cargo test --locked -p octosense-browser-smoke` and cover restricted documents, public HTTPS, malformed/private URLs and exact diff --git a/docs/desktop-embedded-browser.zh-CN.md b/docs/desktop-embedded-browser.zh-CN.md index c877b58b..dfbb3545 100644 --- a/docs/desktop-embedded-browser.zh-CN.md +++ b/docs/desktop-embedded-browser.zh-CN.md @@ -65,7 +65,11 @@ Windows 适配器包含回调拦截,但完整宿主认证流程通过验收前 `tools/browser-smoke.py` 在回环地址提供合成 HTML,通过私有控制目录驱动测试宿主。 检查真实页面 JavaScript、DOM 编辑、禁止导航是否在 HTTP 请求前被拦截、视图隐藏与 恢复、关闭后停止执行、重开后全新 Cookie,以及原生网络错误。不使用个人账户, -也不下载引擎。Windows 工作流还要求浏览器引擎自行产生 PNG 快照和元数据。 +也不下载引擎。Windows 工作流还要求浏览器引擎自行产生 PNG 快照和元数据。无 GPU 的 runner +通过 `--software-graphics` 显式设置 `MAKEPAD_D3D11_WARP=1`,为 Makepad 使用 +[Windows 内置 WARP 软件光栅器](https://learn.microsoft.com/en-us/windows/win32/direct3darticles/directx-warp), +回执记录该模式。WebView2 仍为真实原生浏览器,保留正常沙箱。这不证明硬件 GPU +性能,也不改变生产默认图形后端。 共享 URL 策略测试使用 `cargo test --locked -p octosense-browser-smoke`,覆盖受限 文档、公开 HTTPS、畸形/私有地址及精确主框架回调;它们本身不证明原生导航拦截。 diff --git a/runtime-patches.lock.json b/runtime-patches.lock.json index 13e3236a..826673ba 100644 --- a/runtime-patches.lock.json +++ b/runtime-patches.lock.json @@ -4,7 +4,7 @@ "base_revision": "68d1f4ecc111daa90c50530e77df3245f05fc2cf", "patch": "tools/runtime-patches/makepad-settings.patch", "sha256": "b17352ebf59e50ab3db6ff1cb825c4e006bcdff47d83522e372b8fe585e57570", - "tree": "78f543377794e5ed872c83b22ba4d1405c416d27", + "tree": "a6defa4126465d6f84072772a7e244e09322110b", "product_fixes": [ "Settings integration: Android IME synchronization, accessibility and reduced motion, GLES program-cache validation, obscured-gesture cancellation and scroll-position observation.", "Contained-app isolation and secret-input restrictions come from the pinned upstream runtime; the Settings overlay preserves those gates.", @@ -113,7 +113,7 @@ }, { "patch": "tools/runtime-patches/makepad-desktop-webview.patch", - "sha256": "7fdcbaf20adc488cfca0e2e985ed48b4ede2e21fe58edd201aba82a99da6b245" + "sha256": "476ea1f2ee4ccd18720d66863a771a0bac04567fef6585a4461e374d87c518d3" } ] } diff --git a/tools/browser-smoke.py b/tools/browser-smoke.py index c453279e..3039cfd2 100644 --- a/tools/browser-smoke.py +++ b/tools/browser-smoke.py @@ -39,8 +39,11 @@ def main(): parser.add_argument('--out', type=Path, required=True) parser.add_argument('--host-arg', action='append', default=[]) parser.add_argument('--require-snapshot', action='store_true') + parser.add_argument('--software-graphics', action='store_true', help='Use the built-in Windows WARP rasterizer (not a browser substitute)') parser.add_argument('--require-xembed', action='store_true', help='Probe geometry on an isolated, owned Linux X display only') args = parser.parse_args() + if args.software_graphics and os.name != 'nt': + parser.error('--software-graphics requires Windows') binary = args.binary.resolve(strict=True) root = args.out.resolve() root.mkdir(mode=0o700, parents=True, exist_ok=False) @@ -81,6 +84,8 @@ def do_POST(self): page = f'http://127.0.0.1:{server.server_port}/page' env = os.environ.copy() env['MAKEPAD_HIDE_WINDOWS'] = '1' + if args.software_graphics: + env['MAKEPAD_D3D11_WARP'] = '1' process = None sequence = 0 failure = None @@ -203,7 +208,7 @@ def server_events(): server.shutdown() server.server_close() thread.join(timeout=2) - receipt = {'schema': 1, 'platform': os.name, 'binary_sha256': hashlib.sha256(binary.read_bytes()).hexdigest(), + receipt = {'schema': 1, 'platform': os.name, 'software_graphics': args.software_graphics, 'binary_sha256': hashlib.sha256(binary.read_bytes()).hexdigest(), 'checks': checks, 'passed': failure is None, 'failure': failure, 'scope': 'real native engine; DOM actions are automated, not physical typing or visual UX approval', 'owned_process_exited': process is None or process.poll() is not None} diff --git a/tools/runtime-patches/makepad-desktop-webview.patch b/tools/runtime-patches/makepad-desktop-webview.patch index 3cd4c7da..d666014f 100644 --- a/tools/runtime-patches/makepad-desktop-webview.patch +++ b/tools/runtime-patches/makepad-desktop-webview.patch @@ -880,6 +880,43 @@ index 6e69c6605..84372712a 100644 MouseDown(MouseDownEvent), MouseUp(MouseUpEvent), +diff --git a/platform/src/os/windows/d3d11.rs b/platform/src/os/windows/d3d11.rs +index f060468ab..6beec0f15 100644 +--- a/platform/src/os/windows/d3d11.rs ++++ b/platform/src/os/windows/d3d11.rs +@@ -33,7 +33,7 @@ + Graphics::{ + Direct3D::{ + Fxc::D3DCompile, D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST, +- D3D_DRIVER_TYPE_UNKNOWN, D3D_FEATURE_LEVEL_11_0, D3D_SRV_DIMENSION, ++ D3D_DRIVER_TYPE_UNKNOWN, D3D_DRIVER_TYPE, D3D_FEATURE_LEVEL_11_0, D3D_SRV_DIMENSION, + D3D_SRV_DIMENSION_TEXTURECUBE, + }, + Direct3D11::{ +@@ -2542,15 +2542,19 @@ fn create_device_tier() -> windows_core::Result<( + // before a hybrid-GPU transition or a driver reinstall keeps handing back the + // adapter that went away. Recovery always starts from a fresh factory. + let factory: IDXGIFactory2 = CreateDXGIFactory2(DXGI_CREATE_FACTORY_FLAGS(0))?; +- let adapter = factory.EnumAdapters(0)?; ++ // Explicit diagnostics/CI opt-in to Windows' built-in software ++ // rasterizer. Normal hosts still select their hardware adapter. ++ let warp = std::env::var_os("MAKEPAD_D3D11_WARP").is_some_and(|value| value == "1"); ++ let adapter = if warp { None } else { Some(factory.EnumAdapters(0)?) }; + let mut device: Option = None; + let mut context: Option = None; + let mut query: Option = None; + D3D11CreateDevice( +- &adapter, +- D3D_DRIVER_TYPE_UNKNOWN, ++ adapter.as_ref(), ++ if warp { D3D_DRIVER_TYPE(5) /* D3D_DRIVER_TYPE_WARP */ } else { D3D_DRIVER_TYPE_UNKNOWN }, + HMODULE(std::ptr::null_mut()), +- D3D11_CREATE_DEVICE_FLAG(0x800 | 0x20), // VIDEO_SUPPORT | BGRA_SUPPORT ++ // WARP does not require the hardware video decoding interface. ++ D3D11_CREATE_DEVICE_FLAG(if warp { 0x20 } else { 0x800 | 0x20 }), + Some(&[D3D_FEATURE_LEVEL_11_0]), + D3D11_SDK_VERSION, + Some(&mut device), diff --git a/platform/src/os/windows/mod.rs b/platform/src/os/windows/mod.rs index 19aea0eb4..9ec64eaec 100644 --- a/platform/src/os/windows/mod.rs From 2f6d822c64834d3590f67f3817d438bda1068f05 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:00:31 -0700 Subject: [PATCH 06/35] fix: preserve embedded readers across blocked frames and page updates --- crates/browser-smoke/src/main.rs | 70 ++++++------ runtime-patches.lock.json | 4 +- tools/browser-smoke.py | 16 ++- .../makepad-desktop-webview.patch | 100 +++++++++++++----- 4 files changed, 128 insertions(+), 62 deletions(-) diff --git a/crates/browser-smoke/src/main.rs b/crates/browser-smoke/src/main.rs index ab428696..e2c3f670 100644 --- a/crates/browser-smoke/src/main.rs +++ b/crates/browser-smoke/src/main.rs @@ -21,7 +21,7 @@ script_mod! { window.inner_size: vec2(800, 620) body +: { flow: Down padding: 16 spacing: 12 Label {text: "OctoSense embedded browser acceptance"} - browser := View {width: Fill height: Fill} + browser := WebReader {width: Fill height: Fill} } }} } @@ -37,17 +37,13 @@ struct App { timer: Option, #[rust] last_command: u64, - #[rust] - open: bool, - #[rust] - visible: bool, -} - -fn browser_id() -> SystemBrowserId { - SystemBrowserId(live_id!(octosense_browser_smoke)) } impl App { + fn browser_id(&self, cx: &mut Cx) -> SystemBrowserId { + SystemBrowserId(LiveId(self.ui.widget(cx, ids!(browser)).widget_uid().0)) + } + fn record(&self, event: Value) { if self.root.as_os_str().is_empty() { return; @@ -80,19 +76,20 @@ impl App { self.last_command = id; let operation = command["op"].as_str().unwrap_or(""); let mut accepted = true; + let widget = self.ui.widget(cx, ids!(browser)); + let browser_id = self.browser_id(cx); match operation { "open" => { if let Some(url) = command["url"].as_str().filter(|url| url.len() <= 16_384) { - if self.open { - cx.system_browser(browser_id()).close(); - } - if command["navigable"].as_bool().unwrap_or(false) { - cx.system_browser(browser_id()).spawn_navigable(url); + if let Some(mut reader) = + widget.borrow_mut::() + { + reader.close(cx); + accepted = reader.open(cx, url); } else { - cx.system_browser(browser_id()).spawn(url); + accepted = false; } - self.open = true; - self.visible = true; + widget.set_visible(cx, true); } else { accepted = false; } @@ -102,24 +99,23 @@ impl App { .as_str() .filter(|script| script.len() <= 32_768) { - cx.system_browser(browser_id()).eval_js(script); + cx.system_browser(browser_id).eval_js(script); } else { accepted = false; } } - "hide" => { - self.visible = false; - cx.system_browser(browser_id()).detach(); - } - "show" => self.visible = true, + "hide" => widget.set_visible(cx, false), + "show" => widget.set_visible(cx, true), "close" => { - cx.system_browser(browser_id()).detach(); - cx.system_browser(browser_id()).close(); - self.open = false; + if let Some(mut reader) = + widget.borrow_mut::() + { + reader.close(cx); + } } "inspect" => { #[cfg(any(target_os = "macos", target_os = "windows"))] - cx.system_browser(browser_id()).inspect( + cx.system_browser(browser_id).inspect( self.root .join(format!("inspect-{id}.json")) .to_string_lossy() @@ -138,7 +134,7 @@ impl App { } } "quit" => { - cx.system_browser(browser_id()).close(); + cx.system_browser(browser_id).close(); cx.quit(); } _ => accepted = false, @@ -172,27 +168,33 @@ impl AppMain for App { self.commands(cx); } if let Event::Actions(actions) = event { + let browser_id = self.browser_id(cx); for action in actions { if let Some(nav) = action.downcast_ref::() { - if nav.browser_id == browser_id().0 .0 { + if nav.browser_id == browser_id.0 .0 { self.record(json!({"kind":"navigation", "loading":nav.loading,"title":nav.title,"url":nav.url})); } } + if let Some(blocked) = action + .downcast_ref::( + ) { + if blocked.browser_id == browser_id.0 .0 { + self.record( + json!({"kind":"policy_blocked","description":blocked.description}), + ); + } + } if let Some(error) = action.downcast_ref::() { - if error.browser_id == browser_id().0 .0 { + if error.browser_id == browser_id.0 .0 { self.record(json!({"kind":"page_error", "code":error.code,"description":error.description})); } } } } self.ui.handle_event(cx, event, &mut Scope::empty()); - if self.open && matches!(event, Event::Draw(_)) { - let area = self.ui.widget(cx, ids!(browser)).area(); - cx.system_browser(browser_id()).update(area, self.visible); - } } } diff --git a/runtime-patches.lock.json b/runtime-patches.lock.json index 826673ba..efc4b64d 100644 --- a/runtime-patches.lock.json +++ b/runtime-patches.lock.json @@ -4,7 +4,7 @@ "base_revision": "68d1f4ecc111daa90c50530e77df3245f05fc2cf", "patch": "tools/runtime-patches/makepad-settings.patch", "sha256": "b17352ebf59e50ab3db6ff1cb825c4e006bcdff47d83522e372b8fe585e57570", - "tree": "a6defa4126465d6f84072772a7e244e09322110b", + "tree": "ed64250073dbc64dad90aa75f65d23df1bfddf8c", "product_fixes": [ "Settings integration: Android IME synchronization, accessibility and reduced motion, GLES program-cache validation, obscured-gesture cancellation and scroll-position observation.", "Contained-app isolation and secret-input restrictions come from the pinned upstream runtime; the Settings overlay preserves those gates.", @@ -113,7 +113,7 @@ }, { "patch": "tools/runtime-patches/makepad-desktop-webview.patch", - "sha256": "476ea1f2ee4ccd18720d66863a771a0bac04567fef6585a4461e374d87c518d3" + "sha256": "59a49af7ade420d436f94e5df9c320679fc1424bdef1d3c475818c3caad2d122" } ] } diff --git a/tools/browser-smoke.py b/tools/browser-smoke.py index 3039cfd2..bc2e345d 100644 --- a/tools/browser-smoke.py +++ b/tools/browser-smoke.py @@ -146,6 +146,8 @@ def server_events(): command('eval', script="document.querySelector('#message').value='Reviewed locally';document.querySelector('#submit').click()") wait(lambda: any(e.get('body', {}).get('value') == 'Reviewed locally' for e in server_events()), 'real DOM edit and form action') checks.append('real_dom_edit_and_action') + wait(lambda: any(e.get('kind') == 'navigation' and e.get('title') == 'Saved: Reviewed locally' for e in events()), 'dynamic page title notification') + checks.append('dynamic_title_notification') if args.require_snapshot: capture = command('inspect') @@ -159,12 +161,24 @@ def server_events(): assert len(snapshot) > 100 and snapshot.startswith(bytes([137, 80, 78, 71, 13, 10, 26, 10])) checks.append('native_engine_snapshot_saved') + before = len(events()) + command('eval', script="let frame=document.createElement('iframe');frame.src='/forbidden-frame';document.body.appendChild(frame)") + wait(lambda: any(e.get('kind') == 'policy_blocked' for e in events()[before:]), 'blocked iframe policy event') + assert not any(e.get('kind') == 'page_error' for e in events()[before:]), 'Blocked iframe failed the parent WebReader' + assert not any(e['path'] == '/forbidden-frame' for e in server_events()), 'Forbidden iframe reached server' + command('eval', script="document.querySelector('#message').value='Still interactive';document.querySelector('#submit').click()") + wait(lambda: any(e.get('body', {}).get('value') == 'Still interactive' for e in server_events()), 'parent reader after denied iframe') + time.sleep(.3) + assert not any(e.get('kind') == 'page_error' for e in events()[before:]), 'Denied iframe later failed its parent reader' + checks.append('blocked_iframe_preserves_parent_reader') + before = len(events()) command('eval', script="location.href='/forbidden'") - wait(lambda: any(e.get('kind') == 'page_error' for e in events()[before:]), 'restricted navigation rejection') + wait(lambda: any(e.get('kind') == 'policy_blocked' for e in events()[before:]), 'restricted navigation rejection') assert not any(e['path'] == '/forbidden' for e in server_events()), 'Forbidden navigation reached server' checks.append('restricted_navigation_blocked_before_request') command('hide') + time.sleep(1.2) # WebReader's existing visibility watchdog detaches hidden views. command('show') checks.append('detach_and_reattach_commands_accepted') command('close') diff --git a/tools/runtime-patches/makepad-desktop-webview.patch b/tools/runtime-patches/makepad-desktop-webview.patch index d666014f..db7855b3 100644 --- a/tools/runtime-patches/makepad-desktop-webview.patch +++ b/tools/runtime-patches/makepad-desktop-webview.patch @@ -62,6 +62,25 @@ index 96085e29b..8c8072dc9 100644 Self::InspectSystemBrowser { .. } => write!(f, "InspectSystemBrowser"), Self::SetSystemBrowserHtml { .. } => write!(f, "SetSystemBrowserHtml"), Self::EvalSystemBrowserJs { .. } => write!(f, "EvalSystemBrowserJs"), +diff --git a/platform/src/event/event.rs b/platform/src/event/event.rs +index db8830e66..ac73ebfba 100644 +--- a/platform/src/event/event.rs ++++ b/platform/src/event/event.rs +@@ -591,6 +591,14 @@ pub struct NativeSystemBrowserPageError { + pub url: String, + } + ++/// A navigation or browser operation was refused without failing the loaded page. ++/// In particular, a blocked subframe must not hide its permitted parent reader. ++#[derive(Clone, Debug, Default)] ++pub struct NativeSystemBrowserPolicyBlocked { ++ pub browser_id: u64, ++ pub description: String, ++} ++ + /// Result of a native file-picker dialog, carrying the `call_id` its + /// `CxOsOp::OpenFileDialog` was issued with. + #[derive(Clone, Debug, Default)] diff --git a/platform/src/lib.rs b/platform/src/lib.rs index 6e041dd51..ce292b230 100644 --- a/platform/src/lib.rs @@ -76,10 +95,10 @@ index 6e041dd51..ce292b230 100644 diff --git a/platform/src/os/linux/linux_webkit.rs b/platform/src/os/linux/linux_webkit.rs new file mode 100644 -index 000000000..d079a6d60 +index 000000000..3a2190f6a --- /dev/null +++ b/platform/src/os/linux/linux_webkit.rs -@@ -0,0 +1,507 @@ +@@ -0,0 +1,524 @@ +//! Optional WebKitGTK browser, embedded by XEmbed in an existing X11 window. +//! +//! Only system libraries are loaded; ordinary Makepad builds require no GTK or @@ -88,7 +107,7 @@ index 000000000..d079a6d60 +use super::{module_loader::ModuleLoader, x11::x11_sys as x}; +use crate::{ + cx::Cx, -+ event::{NativeAuthBrowserEvent, NativeSystemBrowserNavigation, NativeSystemBrowserPageError}, ++ event::{NativeAuthBrowserEvent, NativeSystemBrowserNavigation, NativeSystemBrowserPageError, NativeSystemBrowserPolicyBlocked}, + makepad_math::Rect, + system_browser::{BrowserPolicy, NavigationDecision}, + window::WindowId, @@ -162,6 +181,8 @@ index 000000000..d079a6d60 + gtk_widget_get_scale_factor: unsafe extern "C" fn(Object) -> c_int, + gtk_widget_grab_focus: unsafe extern "C" fn(Object), + webkit_web_context_new_ephemeral: unsafe extern "C" fn() -> Object, ++ webkit_network_error_quark: unsafe extern "C" fn() -> u32, ++ webkit_policy_error_quark: unsafe extern "C" fn() -> u32, + webkit_web_context_set_tls_errors_policy: unsafe extern "C" fn(Object, c_int), + webkit_web_view_new_with_context: unsafe extern "C" fn(Object) -> Object, + webkit_web_view_get_settings: unsafe extern "C" fn(Object) -> Object, @@ -226,7 +247,12 @@ index 000000000..d079a6d60 +} +impl State { + fn blocked(&self) { -+ self.error("The embedded browser blocked a disallowed navigation or operation"); ++ if !self.finished.get() { ++ Cx::post_action(NativeSystemBrowserPolicyBlocked { ++ browser_id: self.id, ++ description: "The embedded browser blocked a disallowed navigation or operation".into(), ++ }); ++ } + } + fn error(&self, description: &str) { + if !self.finished.get() { unavailable(self.id, false, description); } @@ -271,10 +297,13 @@ index 000000000..d079a6d60 + 1 +} +unsafe extern "C" fn load_changed(view: Object, _event: c_int, data: Object) { state(data).navigation(view); } ++unsafe extern "C" fn property_changed(view: Object, _property: Object, data: Object) { state(data).navigation(view); } +unsafe extern "C" fn load_failed(_view: Object, _event: c_int, _uri: *const c_char, error: *const GError, data: Object) -> c_int { -+ // WEBKIT_NETWORK_ERROR_CANCELLED is an intentional stop/policy rejection. -+ if !error.is_null() && (*error).code == 302 { return 1; } -+ state(data).error("The embedded browser could not load the page"); ++ let s = state(data); ++ // Intentional stops and policy cancellations do not fail a retained page. ++ if !error.is_null() && (((*error).domain == (s.api.webkit_network_error_quark)() && (*error).code == 302) ++ || ((*error).domain == (s.api.webkit_policy_error_quark)() && (*error).code == 102)) { return 1; } ++ s.error("The embedded browser could not load the page"); + 1 // Never display an engine-generated error page carrying a sensitive URL. +} +#[repr(C)] @@ -309,7 +338,11 @@ index 000000000..d079a6d60 +unsafe extern "C" fn deny_print(_view: Object, _operation: Object, _data: Object) -> c_int { 1 } +unsafe extern "C" fn closed(_view: Object, data: Object) { + let s = state(data); -+ s.finished.set(true); ++ if !s.finished.replace(true) { ++ unavailable(s.id, false, "The embedded page closed"); ++ } ++ // The X11 owner removes this view after GLib returns, never while its ++ // callback still borrows State. +} + +enum Document { Url(String, bool), Html(String, String) } @@ -373,6 +406,8 @@ index 000000000..d079a6d60 + }}; } + signal!(view, "decide-policy", decide); + signal!(view, "load-changed", load_changed); ++ signal!(view, "notify::title", property_changed); ++ signal!(view, "notify::uri", property_changed); + signal!(view, "load-failed", load_failed); + signal!(view, "load-failed-with-tls-errors", tls_failed); + signal!(view, "web-process-terminated", process_terminated); @@ -456,6 +491,7 @@ index 000000000..d079a6d60 + } + } + pub(crate) fn window(&self) -> Option { self.attached } ++ pub(crate) fn is_finished(&self) -> bool { self.state.finished.get() } + pub(crate) fn hide(&mut self) { + if self.visible { + self.focus(false); @@ -682,7 +718,7 @@ index 499c6dc3c..f7e8af444 100644 println!("Reason: DISPLAY environment variable is set"); } else { diff --git a/platform/src/os/linux/x11/linux_x11.rs b/platform/src/os/linux/x11/linux_x11.rs -index b5c58e27e..02943f128 100644 +index b5c58e27e..c445ec3b0 100644 --- a/platform/src/os/linux/x11/linux_x11.rs +++ b/platform/src/os/linux/x11/linux_x11.rs @@ -1,6 +1,7 @@ @@ -759,15 +795,18 @@ index b5c58e27e..02943f128 100644 let mut cx = self.cx.borrow_mut(); cx.dpi_override_scale(&mut e.abs, e.window_id); cx.fingers.process_tap_count(e.abs, e.time); -@@ -308,6 +324,7 @@ fn xlib_event_callback( +@@ -308,6 +324,10 @@ fn xlib_event_callback( cx.call_event_handler(&Event::TextCut(e)) } XlibEvent::Timer(e) => { -+ if e.timer_id == 0 { linux_webkit::pump(); } ++ if e.timer_id == 0 { ++ linux_webkit::pump(); ++ self.browsers.retain(|_, browser| !browser.is_finished()); ++ } let mut cx = self.cx.borrow_mut(); if e.timer_id == 0 { if SignalToUI::check_and_clear_ui_signal() { -@@ -567,6 +584,7 @@ fn handle_platform_ops( +@@ -567,6 +587,7 @@ fn handle_platform_ops( opengl_windows.push(opengl_window); } CxOsOp::CloseWindow(window_id) => { @@ -775,7 +814,7 @@ index b5c58e27e..02943f128 100644 drop(cx); self.close_popup_children(opengl_windows, xlib_app, window_id); cx = self.cx.borrow_mut(); -@@ -596,7 +614,7 @@ fn handle_platform_ops( +@@ -596,7 +617,7 @@ fn handle_platform_ops( } } } @@ -784,7 +823,7 @@ index b5c58e27e..02943f128 100644 CxOsOp::MinimizeWindow(window_id) => { if let Some(window) = opengl_windows.iter_mut().find(|w| w.window_id == window_id) -@@ -912,6 +930,53 @@ fn handle_platform_ops( +@@ -912,6 +933,53 @@ fn handle_platform_ops( let _ = player.select_audio_track(index); } } @@ -1019,17 +1058,17 @@ index c0404864b..19a5b124a 100644 /// Earliest time the device-loss recovery may try again, and how many tries this outage diff --git a/platform/src/os/windows/windows_webview.rs b/platform/src/os/windows/windows_webview.rs new file mode 100644 -index 000000000..34fa36c63 +index 000000000..4d7a880df --- /dev/null +++ b/platform/src/os/windows/windows_webview.rs -@@ -0,0 +1,1049 @@ +@@ -0,0 +1,1047 @@ +//! WebView2 lives inside a clipping child HWND of the Makepad window. COM +//! completions run on the existing STA message loop; no nested pump or worker +//! waits. A weak state reference prevents late creation callbacks resurrecting +//! closed views. Pages never receive host objects or an IPC bridge. +use crate::{ + cx::Cx, -+ event::{NativeAuthBrowserEvent, NativeSystemBrowserNavigation, NativeSystemBrowserPageError}, ++ event::{NativeAuthBrowserEvent, NativeSystemBrowserNavigation, NativeSystemBrowserPageError, NativeSystemBrowserPolicyBlocked}, + makepad_live_id::LiveId, + makepad_math::Rect, + system_browser::{BrowserPolicy, NavigationDecision}, @@ -2034,12 +2073,10 @@ index 000000000..34fa36c63 + drop(s); + auth_event(state, "blocked", String::new()); + } else { -+ drop(s); -+ report_error( -+ state, -+ "Navigation is not permitted in this embedded browser", -+ -1, -+ ); ++ Cx::post_action(NativeSystemBrowserPolicyBlocked { ++ browser_id: s.id.0, ++ description: "Navigation is not permitted in this embedded browser".into(), ++ }); + } +} +fn report_error(state: &Shared, description: &str, code: i32) { @@ -2202,7 +2239,7 @@ index 000000000..1fe6ef745 + } +} diff --git a/widgets/src/web_reader.rs b/widgets/src/web_reader.rs -index 44240521e..a4fcad174 100644 +index 44240521e..97589c92a 100644 --- a/widgets/src/web_reader.rs +++ b/widgets/src/web_reader.rs @@ -1,7 +1,7 @@ @@ -2225,7 +2262,20 @@ index 44240521e..a4fcad174 100644 self.report(cx, "Embedded web pages are unavailable on this platform; this host has no native WebReader adapter".into()); return false; } -@@ -466,7 +466,7 @@ fn unsupported_platform_never_starts_browser_lifecycle() { +@@ -421,6 +421,12 @@ fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + self.auth_changed(cx); + } + } ++ if let Some(blocked) = action.downcast_ref::() { ++ if blocked.browser_id == self.browser_id().0.get_value() && self.open && !self.auth_mode { ++ // A denied iframe, popup or permission leaves the permitted page usable. ++ self.report(cx, blocked.description.clone()); ++ } ++ } + if let Some(err) = action.downcast_ref::() { + if err.browser_id == self.browser_id().0.get_value() && self.open { + if self.auth_mode { +@@ -466,7 +472,7 @@ fn unsupported_platform_never_starts_browser_lifecycle() { let value = vm.eval(script! { use mod.widgets.* WebReader {} }); WebReader::script_from_value(vm, value) }); From 9f64448cff43e0b37919e92663ee59d9d1b30d71 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:16:12 -0700 Subject: [PATCH 07/35] test: verify native messaging denial and page-requested close --- docs/desktop-embedded-browser.md | 39 ++++++++++++------- docs/desktop-embedded-browser.zh-CN.md | 27 ++++++++----- runtime-patches.lock.json | 4 +- tools/browser-smoke.py | 30 ++++++++++++-- tools/browser_smoke_x11.py | 5 ++- .../makepad-desktop-webview.patch | 23 +++++++++-- 6 files changed, 97 insertions(+), 31 deletions(-) diff --git a/docs/desktop-embedded-browser.md b/docs/desktop-embedded-browser.md index 0c827baf..fe0b8c41 100644 --- a/docs/desktop-embedded-browser.md +++ b/docs/desktop-embedded-browser.md @@ -70,18 +70,27 @@ retain their existing provider flows. See the [connected-account guide](../crate work on the existing UI thread. `windows_webview.rs` embeds a WebView2 controller in a clipping child HWND and receives asynchronous callbacks on the UI STA. - `WebReader` retains its existing overlay lifecycle and converts native loading, - navigation and failure events into widget state. A missing engine reaches this - error path instead of leaving an invisible successful view. + navigation and failure events into widget state. Policy-blocked navigation is + nonfatal: a denied iframe cannot hide the allowed parent document. Dynamic + titles are forwarded; Linux also forwards URI changes and releases its native + child after a page-requested close callback returns. A missing engine reaches the + failure path instead of leaving an invisible successful view. ## Acceptance `crates/browser-smoke` is an unpublished native test host, separate from the -production shell. `tools/browser-smoke.py` serves synthetic HTML on loopback and -drives that host through a private control directory. It checks real page -JavaScript, DOM editing, navigation rejection before a forbidden HTTP request, -view detach/reattach, stopped execution after close, fresh cookies after reopen, -and native network errors. It never uses personal accounts or downloads engines. -The Windows workflow also requires an engine-owned PNG snapshot and metadata. +production shell, that mounts the actual `WebReader` widget. +`tools/browser-smoke.py` serves synthetic HTML on loopback and drives that host +through a private control directory. It checks real page JavaScript, DOM editing, +dynamic titles, a blocked iframe preserving the interactive parent, navigation +rejection before a forbidden HTTP request, accepted hide/show commands, stopped +execution after close, fresh cookies after reopen, and native network errors. +It never uses personal accounts or downloads engines. +The Windows workflow also requires an engine-owned PNG snapshot, native settings +readback proving messaging and host objects are disabled, and a page-side attempt +to send a message that must throw an error. WebView2 keeps its `chrome.webview` +namespace even when [messaging is disabled](https://learn.microsoft.com/en-us/dotnet/api/microsoft.web.webview2.core.corewebview2settings.iswebmessageenabled); +namespace presence alone does not grant communication. On its GPU-less runner, `--software-graphics` explicitly selects the built-in [Windows WARP rasterizer](https://learn.microsoft.com/en-us/windows/win32/direct3darticles/directx-warp) for Makepad using `MAKEPAD_D3D11_WARP=1`; the receipt records this mode. WebView2 @@ -94,11 +103,15 @@ main-frame callbacks. They do not prove native navigation interception by themse The native driver uses automated DOM input; it does not certify physical typing, accessibility, visual quality, full shell UX, or OAuth. -Linux native acceptance passed eight checks using WebKitGTK 2.52.6 and Xvfb -21.1.22 with the engine sandbox enabled. An XQueryTree probe additionally proved -that the GTK plug is a child of the socket inside the Makepad window, with valid -geometry; it did not capture the display. Windows native acceptance remains -**unverified** until its CI receipt passes. Detailed receipts belong to the PR +Linux native acceptance passed eleven checks using WebKitGTK 2.52.6 and Xvfb +21.1.22 with the engine sandbox enabled. The XQueryTree check proved that the GTK +plug is a child of the socket inside the Makepad window, with valid geometry; it +did not capture the display. The same driver also proved that page-requested +`window.close()` removes the native child and stops page heartbeats. Hide/show +command acceptance does not prove native overlay visibility; physical input, +HiDPI and closing during asynchronous Windows controller creation remain +unverified. The Windows CI receipt records its native acceptance result; a +cross-compile alone cannot satisfy that gate. Detailed receipts belong to the PR and CI artifacts. The `.github/workflows/embedded-browser.yml` Windows job fails when its installed engine is absent or incompatible; it never converts that into a skipped success. Only synthetic receipts and engine-owned captures are uploaded. diff --git a/docs/desktop-embedded-browser.zh-CN.md b/docs/desktop-embedded-browser.zh-CN.md index dfbb3545..48ab7ebe 100644 --- a/docs/desktop-embedded-browser.zh-CN.md +++ b/docs/desktop-embedded-browser.zh-CN.md @@ -57,15 +57,21 @@ Windows 适配器包含回调拦截,但完整宿主认证流程通过验收前 事件。`windows_webview.rs` 把 WebView2 控制器嵌入裁剪用子 HWND,并在 UI STA 接收异步回调。 - `WebReader` 保留已有覆盖层生命周期,把原生加载、导航和失败事件转换为控件 - 状态。引擎缺失会进入错误状态,不会留下声称成功的不可见视图。 + 状态。策略拒绝导航不会变成致命错误,因此被拒绝的子框架不会隐藏获准的父页面。 + 动态标题会转发给组件;Linux 也转发 URI 更新,并在页面关闭请求的回调返回后 + 释放原生子窗口。引擎缺失会进入错误状态,不会留下声称成功的不可见视图。 ## 验收 `crates/browser-smoke` 是不发布的原生测试宿主,与生产 Shell 分开。 -`tools/browser-smoke.py` 在回环地址提供合成 HTML,通过私有控制目录驱动测试宿主。 -检查真实页面 JavaScript、DOM 编辑、禁止导航是否在 HTTP 请求前被拦截、视图隐藏与 -恢复、关闭后停止执行、重开后全新 Cookie,以及原生网络错误。不使用个人账户, -也不下载引擎。Windows 工作流还要求浏览器引擎自行产生 PNG 快照和元数据。无 GPU 的 runner +`tools/browser-smoke.py` 在回环地址提供合成 HTML,通过私有控制目录驱动挂载真实 +`WebReader` 组件的测试宿主。检查真实页面 JavaScript、DOM 编辑、动态标题、拒绝 +子框架后父页面仍可交互、禁止导航是否在 HTTP 请求前被拦截、隐藏与恢复命令获接收、 +关闭后停止执行、重开后全新 Cookie,以及原生网络错误。不使用个人账户, +也不下载引擎。Windows 工作流还要求浏览器引擎自行产生 PNG 快照,回读原生设置 +确认消息与宿主对象已禁用,并从页面尝试发送消息,要求该调用抛出错误。WebView2 +在[消息禁用](https://learn.microsoft.com/en-us/dotnet/api/microsoft.web.webview2.core.corewebview2settings.iswebmessageenabled) +时仍保留 `chrome.webview` 名称空间;名称空间存在本身不授予通信能力。无 GPU 的 runner 通过 `--software-graphics` 显式设置 `MAKEPAD_D3D11_WARP=1`,为 Makepad 使用 [Windows 内置 WARP 软件光栅器](https://learn.microsoft.com/en-us/windows/win32/direct3darticles/directx-warp), 回执记录该模式。WebView2 仍为真实原生浏览器,保留正常沙箱。这不证明硬件 GPU @@ -76,10 +82,13 @@ Windows 适配器包含回调拦截,但完整宿主认证流程通过验收前 原生驱动使用自动 DOM 输入,不代表物理键盘输入、无障碍、视觉质量、完整 Shell UX 或 OAuth 已验收。 -Linux 已使用 WebKitGTK 2.52.6 和 Xvfb 21.1.22 通过八项原生检查,引擎沙箱 -保持开启。XQueryTree 探针证明 GTK plug 位于 Makepad 窗口中的子 socket 内, -且几何尺寸有效,没有捕获整个显示器。Windows 原生验收在 CI 回执通过前仍为 -**未验证**;详细回执记录在 PR 和 CI 工件中。`.github/workflows/embedded-browser.yml` 的 Windows 任务在 +Linux 已使用 WebKitGTK 2.52.6 和 Xvfb 21.1.22 通过十一项原生检查,引擎沙箱 +保持开启。XQueryTree 检查证明 GTK plug 位于 Makepad 窗口中的子 socket 内, +且几何尺寸有效,没有捕获整个显示器。同一驱动还证明页面调用 `window.close()` +后原生子窗口被移除,页面心跳停止。隐藏与恢复命令获接收不代表原生覆盖层可见性 +已验收;物理输入、HiDPI 和 Windows 控制器异步创建期间关闭仍未验证。 +Windows 的原生验收结果以 CI 回执为准,只有交叉编译通过不足以满足该门禁。 +详细回执记录在 PR 和 CI 工件中。`.github/workflows/embedded-browser.yml` 的 Windows 任务在 引擎缺失或不兼容时失败,不把它算作跳过后通过。只上传合成回执与引擎自身的捕获。 修改浏览器文件时,本地 CI 合并工具同样要求 Windows 原生工作流在精确的 PR diff --git a/runtime-patches.lock.json b/runtime-patches.lock.json index efc4b64d..5ca50554 100644 --- a/runtime-patches.lock.json +++ b/runtime-patches.lock.json @@ -4,7 +4,7 @@ "base_revision": "68d1f4ecc111daa90c50530e77df3245f05fc2cf", "patch": "tools/runtime-patches/makepad-settings.patch", "sha256": "b17352ebf59e50ab3db6ff1cb825c4e006bcdff47d83522e372b8fe585e57570", - "tree": "ed64250073dbc64dad90aa75f65d23df1bfddf8c", + "tree": "5bbe78e211f40d441f248587688b2e6eaa813b43", "product_fixes": [ "Settings integration: Android IME synchronization, accessibility and reduced motion, GLES program-cache validation, obscured-gesture cancellation and scroll-position observation.", "Contained-app isolation and secret-input restrictions come from the pinned upstream runtime; the Settings overlay preserves those gates.", @@ -113,7 +113,7 @@ }, { "patch": "tools/runtime-patches/makepad-desktop-webview.patch", - "sha256": "59a49af7ade420d436f94e5df9c320679fc1424bdef1d3c475818c3caad2d122" + "sha256": "8eddc542782bf8f4e9a8cb9d8d7280dd7e0344041aa62acd39217dc3d0317728" } ] } diff --git a/tools/browser-smoke.py b/tools/browser-smoke.py index bc2e345d..8f3eb877 100644 --- a/tools/browser-smoke.py +++ b/tools/browser-smoke.py @@ -24,9 +24,13 @@ It contains no account, password or personal information.

Ready

"#; + +type Shared = Rc>; + +pub struct ProbeOutcome { + pub passed: bool, + pub delivered_messages: u64, + pub web_message_enabled: Option, + pub host_objects_allowed: Option, + pub cleanup_complete: bool, + pub error: Option, +} + +struct State { + child: HWND, + controller: Option, + webview: Option, + message_token: Option, + closed: bool, + creation_pending: bool, + delivered_messages: u64, + web_message_enabled: Option, + host_objects_allowed: Option, + error: Option, +} + +/// Must be started, polled, and dropped on the fixture's existing UI STA. +/// `Rc` intentionally prevents moving this controller onto another thread. +pub struct WindowsMessageProbe { + state: Shared, + folder: PathBuf, + started: Instant, + cleanup_started: Option, + reported: bool, +} + +impl WindowsMessageProbe { + pub fn start() -> std::result::Result { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| e.to_string())? + .as_nanos(); + let folder = std::env::temp_dir().join(format!( + "octosense-web-message-probe-{}-{stamp}-{}", + std::process::id(), + NEXT_PROBE.fetch_add(1, Ordering::Relaxed) + )); + // Create a new directory, never reuse an existing browser profile. + std::fs::create_dir(&folder).map_err(|e| e.to_string())?; + let child = unsafe { + CreateWindowExW( + WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE, + w!("STATIC"), + w!("OctoSense synthetic message probe"), + WS_OVERLAPPEDWINDOW | WS_CLIPCHILDREN, + 0, + 0, + 320, + 240, + None, + None, + GetModuleHandleW(None).ok().map(Into::into), + None, + ) + }; + let child = match child { + Ok(child) => child, + Err(error) => { + let _ = std::fs::remove_dir(&folder); + return Err(error.to_string()); + } + }; + // WS_VISIBLE is deliberately absent: this is an owned hidden test HWND. + let state = Rc::new(RefCell::new(State { + child, + controller: None, + webview: None, + message_token: None, + closed: false, + creation_pending: true, + delivered_messages: 0, + web_message_enabled: None, + host_objects_allowed: None, + error: None, + })); + let probe = Self { + state, + folder, + started: Instant::now(), + cleanup_started: None, + reported: false, + }; + if let Err(error) = unsafe { probe.start_environment() } { + return Err(error.to_string()); // Drop closes the owned window. + } + Ok(probe) + } + + /// Returns exactly one terminal result. All work is asynchronous; the + /// caller keeps the normal UI message loop running between polls. + pub fn poll(&mut self) -> Option { + if self.reported { + return None; + } + let finish = { + let mut state = self.state.borrow_mut(); + if self.started.elapsed() >= LOAD_TIMEOUT && state.delivered_messages == 0 { + state + .error + .get_or_insert_with(|| "Native positive-control delivery timed out".into()); + } + state.delivered_messages > 0 || state.error.is_some() + }; + if finish && self.cleanup_started.is_none() { + self.close(); + self.cleanup_started = Some(Instant::now()); + } + let cleanup_started = self.cleanup_started?; + // Closing WebView2 releases its user-data directory asynchronously. + // Retry only while the UI timer is running; never sleep or block COM. + // A timed-out create operation may still be completing. Its weak + // callback closes late controllers; do not claim profile cleanup until + // it has settled and cannot recreate this directory. + let cleanup_complete = !self.state.borrow().creation_pending + && match std::fs::remove_dir_all(&self.folder) { + Ok(()) => true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => true, + Err(_) => false, + }; + if !cleanup_complete && cleanup_started.elapsed() < CLEANUP_TIMEOUT { + return None; + } + let mut state = self.state.borrow_mut(); + if !cleanup_complete { + state + .error + .get_or_insert_with(|| "Synthetic WebView2 profile cleanup timed out".into()); + } + self.reported = true; + Some(ProbeOutcome { + passed: state.error.is_none() + && state.delivered_messages > 0 + && state.web_message_enabled == Some(true) + && state.host_objects_allowed == Some(false) + && cleanup_complete, + delivered_messages: state.delivered_messages, + web_message_enabled: state.web_message_enabled, + host_objects_allowed: state.host_objects_allowed, + cleanup_complete, + error: state.error.clone(), + }) + } + + unsafe fn start_environment(&self) -> WinResult<()> { + let options: ICoreWebView2EnvironmentOptions = + CoreWebView2EnvironmentOptions::default().into(); + options.SetAllowSingleSignOnUsingOSPrimaryAccount(false)?; + let folder = wide(&self.folder.to_string_lossy()); + let weak = Rc::downgrade(&self.state); + CreateCoreWebView2EnvironmentWithOptions( + PCWSTR::null(), + PCWSTR(folder.as_ptr()), + &options, + &CreateCoreWebView2EnvironmentCompletedHandler::create(Box::new( + move |result, environment| { + let Some(state) = weak.upgrade() else { + return Ok(()); + }; + if state.borrow().closed { + state.borrow_mut().creation_pending = false; + return Ok(()); + } + let result = result + .and_then(|_| environment.ok_or_else(|| E_FAIL.into())) + .and_then(|environment| create_controller(&state, &environment)); + if let Err(error) = result { + let mut state = state.borrow_mut(); + state.creation_pending = false; + state.error = Some(error.to_string()); + } + Ok(()) + }, + )), + ) + } + + fn close(&mut self) { + let (controller, webview, token, child) = { + let mut state = self.state.borrow_mut(); + state.closed = true; + ( + state.controller.take(), + state.webview.take(), + state.message_token.take(), + std::mem::take(&mut state.child), + ) + }; + unsafe { + if let (Some(webview), Some(token)) = (&webview, token) { + let _ = webview.remove_WebMessageReceived(token); + } + drop(webview); + if let Some(controller) = controller { + close_controller(&controller); + } + if !child.is_invalid() { + let _ = DestroyWindow(child); + } + } + } +} + +impl Drop for WindowsMessageProbe { + fn drop(&mut self) { + self.close(); + let _ = std::fs::remove_dir_all(&self.folder); + } +} + +unsafe fn create_controller( + state: &Shared, + environment: &ICoreWebView2Environment, +) -> WinResult<()> { + let version: ICoreWebView2Environment10 = environment.cast()?; + let options = version.CreateCoreWebView2ControllerOptions()?; + options.SetIsInPrivateModeEnabled(true)?; + options.SetProfileName(w!("synthetic-message-probe"))?; + let weak = Rc::downgrade(state); + let child = state.borrow().child; + version.CreateCoreWebView2ControllerWithOptions( + child, + &options, + &CreateCoreWebView2ControllerCompletedHandler::create(Box::new( + move |result, controller| { + let Some(state) = weak.upgrade() else { + if let Some(controller) = controller { + close_controller(&controller); + } + return Ok(()); + }; + state.borrow_mut().creation_pending = false; + if state.borrow().closed { + if let Some(controller) = controller { + close_controller(&controller); + } + return Ok(()); + } + let result = result + .and_then(|_| controller.ok_or_else(|| E_FAIL.into())) + .and_then(|controller| { + let result = configure(&state, &controller); + if result.is_err() { + close_controller(&controller); + } + result + }); + if let Err(error) = result { + state.borrow_mut().error = Some(error.to_string()); + } + Ok(()) + }, + )), + ) +} + +unsafe fn configure(state: &Shared, controller: &ICoreWebView2Controller) -> WinResult<()> { + let webview = controller.CoreWebView2()?; + let settings = webview.Settings()?; + settings.SetAreHostObjectsAllowed(false)?; + // The only enablement is this synthetic, unpublished test controller. + settings.SetIsWebMessageEnabled(true)?; + settings.SetAreDefaultScriptDialogsEnabled(false)?; + settings.SetAreDefaultContextMenusEnabled(false)?; + settings.SetAreDevToolsEnabled(false)?; + let mut messaging = BOOL::default(); + let mut host_objects = BOOL::default(); + settings.IsWebMessageEnabled(&mut messaging)?; + settings.AreHostObjectsAllowed(&mut host_objects)?; + { + let mut state = state.borrow_mut(); + state.web_message_enabled = Some(messaging.as_bool()); + state.host_objects_allowed = Some(host_objects.as_bool()); + } + if !messaging.as_bool() || host_objects.as_bool() { + return Err(E_FAIL.into()); + } + let weak = Rc::downgrade(state); + let mut message_token = 0; + webview.add_WebMessageReceived( + &WebMessageReceivedEventHandler::create(Box::new(move |_, _| { + // Counting alone proves the native COM event works. Never read, + // decode, dispatch, or echo a page-supplied message. + if let Some(state) = weak.upgrade() { + let mut state = state.borrow_mut(); + if !state.closed { + state.delivered_messages = state.delivered_messages.saturating_add(1); + } + } + Ok(()) + })), + &mut message_token, + )?; + // Even this test controller refuses page popups and device grants. + let mut token = 0; + webview.add_NewWindowRequested( + &NewWindowRequestedEventHandler::create(Box::new(|_, args| { + if let Some(args) = args { + args.SetHandled(true)?; + } + Ok(()) + })), + &mut token, + )?; + webview.add_PermissionRequested( + &PermissionRequestedEventHandler::create(Box::new(|_, args| { + if let Some(args) = args { + args.SetState(COREWEBVIEW2_PERMISSION_STATE_DENY)?; + } + Ok(()) + })), + &mut token, + )?; + controller.SetBounds(RECT { + left: 0, + top: 0, + right: 320, + bottom: 240, + })?; + controller.SetIsVisible(false)?; + { + let mut state = state.borrow_mut(); + state.controller = Some(controller.clone()); + state.webview = Some(webview.clone()); + state.message_token = Some(message_token); + } + let html = wide(DOCUMENT); + webview.NavigateToString(PCWSTR(html.as_ptr())) +} + +unsafe fn close_controller(controller: &ICoreWebView2Controller) { + if let Ok(profile) = controller + .CoreWebView2() + .and_then(|view| view.cast::()) + .and_then(|view| view.Profile()) + .and_then(|profile| profile.cast::()) + { + let _ = profile.Delete(); + } + let _ = controller.Close(); +} + +fn wide(value: &str) -> Vec { + value.encode_utf16().chain(std::iter::once(0)).collect() +} From 1c4181544c65a364d233fa33fc778a65148cb326 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:38:47 -0700 Subject: [PATCH 11/35] test: verify native message non-delivery with a calibrated observer --- crates/browser-smoke/src/main.rs | 29 +++++++++++ docs/desktop-embedded-browser.md | 19 ++++++-- docs/desktop-embedded-browser.zh-CN.md | 14 ++++-- runtime-patches.lock.json | 4 +- tools/browser-smoke.py | 48 ++++++++++++++----- .../makepad-desktop-webview.patch | 33 +++++++++++-- 6 files changed, 122 insertions(+), 25 deletions(-) diff --git a/crates/browser-smoke/src/main.rs b/crates/browser-smoke/src/main.rs index e2c3f670..47c5c288 100644 --- a/crates/browser-smoke/src/main.rs +++ b/crates/browser-smoke/src/main.rs @@ -11,6 +11,9 @@ use std::{ #[cfg(test)] mod policy_tests; +#[cfg(target_os = "windows")] +mod windows_message_probe; + app_main!(App); script_mod! { @@ -37,6 +40,9 @@ struct App { timer: Option, #[rust] last_command: u64, + #[cfg(target_os = "windows")] + #[rust] + message_probe: Option, } impl App { @@ -79,6 +85,20 @@ impl App { let widget = self.ui.widget(cx, ids!(browser)); let browser_id = self.browser_id(cx); match operation { + #[cfg(target_os = "windows")] + "calibrate_messages" => { + if self.message_probe.is_some() { + accepted = false; + } else { + match windows_message_probe::WindowsMessageProbe::start() { + Ok(probe) => self.message_probe = Some(probe), + Err(error) => { + self.record(json!({"kind":"message_control","passed":false,"error":error})); + accepted = false; + } + } + } + } "open" => { if let Some(url) = command["url"].as_str().filter(|url| url.len() <= 16_384) { if let Some(mut reader) = @@ -166,6 +186,15 @@ impl AppMain for App { .is_some_and(|timer| timer.is_event(event).is_some()) { self.commands(cx); + #[cfg(target_os = "windows")] + if let Some(outcome) = self.message_probe.as_mut().and_then(|probe| probe.poll()) { + self.record(json!({"kind":"message_control","passed":outcome.passed, + "delivered_messages":outcome.delivered_messages, + "web_message_enabled":outcome.web_message_enabled, + "host_objects_allowed":outcome.host_objects_allowed, + "cleanup_complete":outcome.cleanup_complete,"error":outcome.error})); + self.message_probe = None; + } } if let Event::Actions(actions) = event { let browser_id = self.browser_id(cx); diff --git a/docs/desktop-embedded-browser.md b/docs/desktop-embedded-browser.md index fe0b8c41..8dadc6a3 100644 --- a/docs/desktop-embedded-browser.md +++ b/docs/desktop-embedded-browser.md @@ -86,11 +86,20 @@ dynamic titles, a blocked iframe preserving the interactive parent, navigation rejection before a forbidden HTTP request, accepted hide/show commands, stopped execution after close, fresh cookies after reopen, and native network errors. It never uses personal accounts or downloads engines. -The Windows workflow also requires an engine-owned PNG snapshot, native settings -readback proving messaging and host objects are disabled, and a page-side attempt -to send a message that must throw an error. WebView2 keeps its `chrome.webview` -namespace even when [messaging is disabled](https://learn.microsoft.com/en-us/dotnet/api/microsoft.web.webview2.core.corewebview2settings.iswebmessageenabled); -namespace presence alone does not grant communication. +The Windows workflow requires an engine-owned PNG snapshot and native settings +readback proving messaging and host objects are disabled. A separate disposable +controller in the unpublished test host enables messaging for one fixed synthetic +page, proves the observer receives a message, then closes and removes its profile. +The production controller never enables messaging: its observer only counts +native deliveries, never reads a payload or dispatches an operation. The driver +requires zero deliveries after a bounded event-loop wait and again near the same +view's close. This proves non-delivery during the observed test window. + +The receipt retains the JavaScript return or exception as a diagnostic. The +[documented API](https://learn.microsoft.com/en-us/dotnet/api/microsoft.web.webview2.core.corewebview2settings.iswebmessageenabled) +says disabled messaging throws, but the tested runtime returned normally while +both native policy flags were false. Neither namespace presence nor a normal +JavaScript return establishes host delivery; native observation is required. On its GPU-less runner, `--software-graphics` explicitly selects the built-in [Windows WARP rasterizer](https://learn.microsoft.com/en-us/windows/win32/direct3darticles/directx-warp) for Makepad using `MAKEPAD_D3D11_WARP=1`; the receipt records this mode. WebView2 diff --git a/docs/desktop-embedded-browser.zh-CN.md b/docs/desktop-embedded-browser.zh-CN.md index 48ab7ebe..ed4b2cbc 100644 --- a/docs/desktop-embedded-browser.zh-CN.md +++ b/docs/desktop-embedded-browser.zh-CN.md @@ -68,10 +68,16 @@ Windows 适配器包含回调拦截,但完整宿主认证流程通过验收前 `WebReader` 组件的测试宿主。检查真实页面 JavaScript、DOM 编辑、动态标题、拒绝 子框架后父页面仍可交互、禁止导航是否在 HTTP 请求前被拦截、隐藏与恢复命令获接收、 关闭后停止执行、重开后全新 Cookie,以及原生网络错误。不使用个人账户, -也不下载引擎。Windows 工作流还要求浏览器引擎自行产生 PNG 快照,回读原生设置 -确认消息与宿主对象已禁用,并从页面尝试发送消息,要求该调用抛出错误。WebView2 -在[消息禁用](https://learn.microsoft.com/en-us/dotnet/api/microsoft.web.webview2.core.corewebview2settings.iswebmessageenabled) -时仍保留 `chrome.webview` 名称空间;名称空间存在本身不授予通信能力。无 GPU 的 runner +也不下载引擎。Windows 工作流要求浏览器自行产生 PNG 快照,并回读原生设置, +确认消息与宿主对象已禁用。不发布的测试宿主另建一次性控制器,为固定合成页面启用 +消息,证明观察器能收到消息,再关闭控制器并删除其配置目录。生产控制器始终禁用 +消息;观察器只统计原生交付次数,不读取消息内容,也不派发任何操作。驱动在有界 +事件循环等待后,以及同一视图关闭前,再次要求交付次数为零,证明观测窗口内无交付。 + +回执保留 JavaScript 返回或异常作为诊断。[API 文档](https://learn.microsoft.com/en-us/dotnet/api/microsoft.web.webview2.core.corewebview2settings.iswebmessageenabled) +描述禁用消息时抛出异常,但实测运行时在两个原生策略标志均为 false 时正常返回。 +名称空间存在或调用正常返回都不代表消息到达宿主,必须检查原生交付。 +无 GPU 的 runner 通过 `--software-graphics` 显式设置 `MAKEPAD_D3D11_WARP=1`,为 Makepad 使用 [Windows 内置 WARP 软件光栅器](https://learn.microsoft.com/en-us/windows/win32/direct3darticles/directx-warp), 回执记录该模式。WebView2 仍为真实原生浏览器,保留正常沙箱。这不证明硬件 GPU diff --git a/runtime-patches.lock.json b/runtime-patches.lock.json index 5ca50554..95ba49d8 100644 --- a/runtime-patches.lock.json +++ b/runtime-patches.lock.json @@ -4,7 +4,7 @@ "base_revision": "68d1f4ecc111daa90c50530e77df3245f05fc2cf", "patch": "tools/runtime-patches/makepad-settings.patch", "sha256": "b17352ebf59e50ab3db6ff1cb825c4e006bcdff47d83522e372b8fe585e57570", - "tree": "5bbe78e211f40d441f248587688b2e6eaa813b43", + "tree": "7b521e6030b146debe8b8bbb85d3948ea6d62f0d", "product_fixes": [ "Settings integration: Android IME synchronization, accessibility and reduced motion, GLES program-cache validation, obscured-gesture cancellation and scroll-position observation.", "Contained-app isolation and secret-input restrictions come from the pinned upstream runtime; the Settings overlay preserves those gates.", @@ -113,7 +113,7 @@ }, { "patch": "tools/runtime-patches/makepad-desktop-webview.patch", - "sha256": "8eddc542782bf8f4e9a8cb9d8d7280dd7e0344041aa62acd39217dc3d0317728" + "sha256": "dda81412903a247525cf3750a00aab820aa1dea28722cc633cbdf65fd5770221" } ] } diff --git a/tools/browser-smoke.py b/tools/browser-smoke.py index f730098f..5fd9dcda 100644 --- a/tools/browser-smoke.py +++ b/tools/browser-smoke.py @@ -94,6 +94,8 @@ def do_POST(self): sequence = 0 failure = None security_failures = [] + message_probe = None + message_control = None def events(): path = root / 'events.jsonl' @@ -136,22 +138,30 @@ def server_events(): process = subprocess.Popen([str(binary), f'--control-root={root}', '--remote', *args.host_arg], env=env, stdout=log, stderr=subprocess.STDOUT) wait(lambda: any(e.get('kind') == 'started' for e in events()), 'native host startup') + if os.name == 'nt': + command('calibrate_messages') + wait(lambda: any(e.get('kind') == 'message_control' for e in events()), 'isolated messaging positive control', timeout=45) + message_control = next(e for e in events() if e.get('kind') == 'message_control') + assert message_control.get('passed') is True, message_control + assert type(message_control.get('delivered_messages')) is int and message_control['delivered_messages'] > 0 + assert message_control.get('web_message_enabled') is True + assert message_control.get('host_objects_allowed') is False + assert message_control.get('cleanup_complete') is True + checks.append('isolated_native_message_observer_positive_control') command('open', url=page) wait(lambda: any(e.get('body', {}).get('kind') == 'loaded' for e in server_events()), 'real document JavaScript') loaded = next(e for e in server_events() if e.get('body', {}).get('kind') == 'loaded') assert loaded['body']['width'] > 100 and loaded['body']['height'] > 100, loaded['body'] if loaded['body']['bridge']: security_failures.append('Ordinary page received an OctoSense bridge') - if loaded['body']['webMessageProbe'] not in ('absent', 'denied'): - policy = {} - if os.name == 'nt' and args.require_snapshot: - diagnostic = command('inspect') - wait(lambda: (root / f'inspect-{diagnostic}.json').is_file(), 'native policy diagnostics') - metadata = json.loads((root / f'inspect-{diagnostic}.json').read_text(encoding='utf-8')) - policy = {key: metadata.get(key) for key in ('error', 'webMessageEnabled', 'hostObjectsAllowed')} - security_failures.append(f"Page-to-host probe={loaded['body']['webMessageProbe']}, exception={loaded['body']['webMessageErrorKind']}, native policy={policy}") - if os.name == 'nt' and loaded['body']['webMessageProbe'] == 'absent': - security_failures.append('WebView2 messaging denial was not exercised') + message_probe = {'return': loaded['body']['webMessageProbe'], 'exception_kind': loaded['body']['webMessageErrorKind']} + if os.name == 'nt': + assert message_probe['return'] in ('accepted', 'denied'), 'WebView2 message call was not validly exercised' + # Delivery is asynchronous and not ordered with DOM events. + # The return/exception alone does not prove native delivery. + time.sleep(.5) + elif message_probe['return'] not in ('absent', 'denied'): + security_failures.append(f'Unexpected page messaging API: {message_probe}') assert loaded['cookie_present'], 'Browser did not retain its own HttpOnly fixture cookie' if not security_failures: checks.append('native_document_loaded_without_bridge') @@ -180,6 +190,10 @@ def server_events(): security_failures.append('Native messaging remained enabled') if meta.get('hostObjectsAllowed') is not False: security_failures.append('Native host objects remained allowed') + if meta.get('webMessageObserverActive') is not True: + security_failures.append('Native message observer was not registered') + if type(meta.get('observedWebMessages')) is not int or meta['observedWebMessages'] != 0: + security_failures.append('Native observer received page messages or lacked a count') snapshot = (root / f'snapshot-{capture}.png').read_bytes() assert len(snapshot) > 100 and snapshot.startswith(bytes([137, 80, 78, 71, 13, 10, 26, 10])) checks.append('native_engine_snapshot_saved') @@ -204,6 +218,18 @@ def server_events(): time.sleep(1.2) # WebReader's existing visibility watchdog detaches hidden views. command('show') checks.append('detach_and_reattach_commands_accepted') + if os.name == 'nt': + time.sleep(.5) + last_capture = command('inspect') + wait(lambda: (root / f'inspect-{last_capture}.json').is_file(), 'final same-view delivery observation') + last_meta = json.loads((root / f'inspect-{last_capture}.json').read_text(encoding='utf-8')) + if (last_meta.get('error') or last_meta.get('webMessageObserverActive') is not True or + type(last_meta.get('observedWebMessages')) is not int or last_meta['observedWebMessages'] != 0 or + last_meta.get('webMessageEnabled') is not False or last_meta.get('hostObjectsAllowed') is not False or + last_meta.get('hostBridgePresent') is not False): + security_failures.append('Final native delivery/policy observation failed') + if not security_failures: + checks.append('zero_native_message_deliveries_observed_during_test_window') command('close') time.sleep(.7) stopped = len(server_events()) @@ -262,7 +288,7 @@ def server_events(): server.server_close() thread.join(timeout=2) receipt = {'schema': 1, 'platform': os.name, 'software_graphics': args.software_graphics, 'binary_sha256': hashlib.sha256(binary.read_bytes()).hexdigest(), - 'checks': checks, 'passed': failure is None, 'failure': failure, 'security_failures': security_failures, + 'checks': checks, 'passed': failure is None, 'failure': failure, 'security_failures': security_failures, 'web_message_probe': message_probe, 'message_control': message_control, 'scope': 'real native engine; DOM actions are automated, not physical typing or visual UX approval', 'owned_process_exited': process is None or process.poll() is not None} (root / 'requests.json').write_text(json.dumps(server_events(), indent=2) + '\n', encoding='utf-8') diff --git a/tools/runtime-patches/makepad-desktop-webview.patch b/tools/runtime-patches/makepad-desktop-webview.patch index 0c9e50c0..f1bdbcfc 100644 --- a/tools/runtime-patches/makepad-desktop-webview.patch +++ b/tools/runtime-patches/makepad-desktop-webview.patch @@ -1058,10 +1058,10 @@ index c0404864b..19a5b124a 100644 /// Earliest time the device-loss recovery may try again, and how many tries this outage diff --git a/platform/src/os/windows/windows_webview.rs b/platform/src/os/windows/windows_webview.rs new file mode 100644 -index 000000000..ad832d114 +index 000000000..bf1ca2e78 --- /dev/null +++ b/platform/src/os/windows/windows_webview.rs -@@ -0,0 +1,1064 @@ +@@ -0,0 +1,1091 @@ +//! WebView2 lives inside a clipping child HWND of the Makepad window. COM +//! completions run on the existing STA message loop; no nested pump or worker +//! waits. A weak state reference prevents late creation callbacks resurrecting @@ -1107,6 +1107,8 @@ index 000000000..ad832d114 + auth_finished: bool, + closed: bool, + creating: bool, ++ observed_web_messages: u64, ++ web_message_observer_active: bool, + attached_window: Option, + child: HWND, + bounds: RECT, @@ -1150,6 +1152,8 @@ index 000000000..ad832d114 + auth_finished: false, + closed: false, + creating: false, ++ observed_web_messages: 0, ++ web_message_observer_active: false, + attached_window: None, + child: HWND::default(), + bounds: RECT::default(), @@ -1454,6 +1458,10 @@ index 000000000..ad832d114 + let _ = std::fs::write(result_path, r#"{"error":"Browser policy inspection failed"}"#); + return; + }; ++ let (web_message_observer_active, observed_web_messages) = { ++ let s = self.state.borrow(); ++ (s.web_message_observer_active, s.observed_web_messages) ++ }; + let scroll = scroll_y + .filter(|v| v.is_finite()) + .map(|v| format!("window.scrollTo(0,{v});")) @@ -1464,7 +1472,8 @@ index 000000000..ad832d114 + scrollY:window.scrollY,scrollHeight:document.documentElement.scrollHeight, + viewportHeight:window.innerHeight,viewportWidth:window.innerWidth, + hostBridgePresent:typeof window.octos!=='undefined'||typeof window.octos_native!=='undefined', -+ webMessageEnabled:{web_message_enabled},hostObjectsAllowed:{host_objects_allowed} ++ webMessageEnabled:{web_message_enabled},hostObjectsAllowed:{host_objects_allowed}, ++ webMessageObserverActive:{web_message_observer_active},observedWebMessages:{observed_web_messages} + }})"# + )); + let weak = Rc::downgrade(&self.state); @@ -1683,6 +1692,24 @@ index 000000000..ad832d114 + let settings = webview.Settings()?; + settings.SetAreHostObjectsAllowed(false)?; + settings.SetIsWebMessageEnabled(false)?; ++ // Count delivery only for native acceptance diagnostics. Never read the ++ // payload, dispatch it to an app, or grant access to any host operation. ++ // Register before navigation so an initial document cannot evade observation. ++ let weak = Rc::downgrade(state); ++ let mut observer_token = 0; ++ webview.add_WebMessageReceived( ++ &WebMessageReceivedEventHandler::create(Box::new(move |_, _| { ++ if let Some(state) = weak.upgrade() { ++ let mut s = state.borrow_mut(); ++ if !s.closed { ++ s.observed_web_messages = s.observed_web_messages.saturating_add(1); ++ } ++ } ++ Ok(()) ++ })), ++ &mut observer_token, ++ )?; ++ state.borrow_mut().web_message_observer_active = true; + settings.SetAreDefaultContextMenusEnabled(false)?; + settings.SetAreDevToolsEnabled(false)?; + settings.SetAreDefaultScriptDialogsEnabled(false)?; From 0ac32e27c5de62c3d5a6db4f76d23d0ac58303b6 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:24:24 -0700 Subject: [PATCH 12/35] ci: seal bounded privacy-failure diagnostics for source tracing --- .github/workflows/release-desktop.yml | 28 ++++++ tools/release-scan-diagnostic-public.pem | 14 +++ tools/release-scan-sealed.py | 118 +++++++++++++++++++++++ tools/test_release_scan_sealed.py | 70 ++++++++++++++ 4 files changed, 230 insertions(+) create mode 100644 tools/release-scan-diagnostic-public.pem create mode 100644 tools/release-scan-sealed.py create mode 100644 tools/test_release_scan_sealed.py diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index f642f8dd..fba5128d 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -201,6 +201,19 @@ jobs: cd "$SRC" python tools/setup.py --check --cargo bash tools/check-shell-graph.sh -p octosense + - name: Check diagnostic encryption before the long build + run: | + cd "$SRC" + python - <<'PY' + import base64, importlib.util + from pathlib import Path + spec = importlib.util.spec_from_file_location("sealed", "tools/release-scan-sealed.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + encrypted = module.seal(b"synthetic preflight", Path("tools/release-scan-diagnostic-public.pem")) + assert len(base64.b64decode(encrypted, validate=True)) == 512 + print("Diagnostic RSA-OAEP encryption passed") + PY - name: Build and package, unsigned (paths remapped, resources and kernel bundled) run: | cd "$SRC" @@ -228,6 +241,7 @@ jobs: cp receipt.json "upload/packages/OctoSense_${version}_${{ matrix.platform }}.receipt.json" ls -la upload/packages - name: Refuse private paths and names (the packages and their payloads) + id: privacy run: | cd "$SRC/target/octosense-package" # The packages (inside .dmg, .deb, .AppImage, .zip, the NSIS @@ -235,6 +249,20 @@ jobs: # the staged resources. exe=octosense; [ "$RUNNER_OS" = Windows ] && exe=octosense.exe python "$SRC/tools/release-scan.py" upload/packages/* resources kernel "$SRC/target/release/$exe" + - name: Seal bounded failure diagnostics for the local reviewer + if: failure() && steps.privacy.outcome == 'failure' + run: | + exe=octosense; [ "$RUNNER_OS" = Windows ] && exe=octosense.exe + python "$SRC/tools/release-scan-sealed.py" --binary "$SRC/target/release/$exe" \ + --public-key "$SRC/tools/release-scan-diagnostic-public.pem" \ + --output "$RUNNER_TEMP/release-scan-sealed.json" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: failure() && steps.privacy.outcome == 'failure' + with: + name: sealed-release-diagnostic-${{ matrix.platform }} + path: ${{ runner.temp }}/release-scan-sealed.json + if-no-files-found: error + retention-days: 2 - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: octosense-desktop-unsigned-${{ matrix.platform }} diff --git a/tools/release-scan-diagnostic-public.pem b/tools/release-scan-diagnostic-public.pem new file mode 100644 index 00000000..a7300cef --- /dev/null +++ b/tools/release-scan-diagnostic-public.pem @@ -0,0 +1,14 @@ +-----BEGIN PUBLIC KEY----- +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAv5BPgrMvZr10DBOn/TwX +9MDNwz2GtnSahWyo6H6NvqrKv0sDe/oZj1Vyv6EdsjpMhLuM00UvtlEy5oThu/mV +qcq63SfFqenaPv+zUY4tRwRoAPKCYXakzrqMK/FwdUYUL5LqqPcdjxQx1KRbg3Yx +QpnToD+kbp2tvAhxH3W9vvCMXPUgqxfkr0gjzGcPqT8zUOMkDOp53mIprbrRg/2x +xLBIa7KFPpEJOmk3FIMPSutXCXAkl2VcR1APzNpNC5yRRV+MljoNCeI4ooUAWpn2 +2Q6TzIQ4BChA21+o+J+jWKJS0jZ1lSfRGCP9UREhN4Gn/HJ11Hl7aoF/Lp6X3hoM +8J0U5QldBbJBr8kJDPIqUMNHaFP/aSMubQzSTQ4ZuwmDGfYmO5gkWRp6E6Ve4lKq +VT5DMVFijer401zeCsYtecJugWdzLfit9o8KFIItLmU98OgVzYzsrOz+kFg0qYw/ +kp3BJdx8oG6uA9nVEhg0XuukKFOwlTMTzl9EwOgj08uXMPukXaxWX6hJAI5RVNtu +poP9yRXQkfHt51R4GiAeDOSt4IZpGwYziv7iSanUQjuNaYNnqyNRYhU/5iHOIhYE +5NMrK0l33VRyQGjPf+GKYJbJIGNVi2/fvX4eRvKpOiBNs10I3VVDGs7Bop3Q870X +ABJw60GjtGsbYs58artoX78CAwEAAQ== +-----END PUBLIC KEY----- diff --git a/tools/release-scan-sealed.py b/tools/release-scan-sealed.py new file mode 100644 index 00000000..fdaafa5a --- /dev/null +++ b/tools/release-scan-sealed.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""One-shot, encrypted diagnostics for PR361's release privacy finding. + +This never changes release-scan's result. Only RSA-OAEP ciphertext leaves the +runner; the diagnostic private key is held outside the checkout. Delete this +helper and the public key once the offending public source has been traced. +""" +import argparse +import base64 +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import sys + + +def seal(payload, public_key): + # A 4096-bit RSA key with OAEP-SHA256 admits at most 446 bytes. Refuse + # oversize input rather than truncate the finding or emit plaintext. + if len(payload) > 446: + raise RuntimeError("diagnostic record exceeds its encryption budget") + if sys.platform == "win32": + script = """ +$ErrorActionPreference = 'Stop' +Add-Type -TypeDefinition @' +using System; +using System.Text; +using System.Security.Cryptography; +public static class OctoSenseDiagnosticSeal { + public static string Seal(string pem, string payload) { + using (var rsa = RSA.Create()) { + rsa.ImportFromPem(pem); + return Convert.ToBase64String(rsa.Encrypt(Encoding.UTF8.GetBytes(payload), RSAEncryptionPadding.OaepSHA256)); + } + } +} +'@ +[Console]::Out.Write([OctoSenseDiagnosticSeal]::Seal($env:OCTOSENSE_SCAN_DIAGNOSTIC_PUBLIC_KEY, [Console]::In.ReadToEnd())) +""" + result = subprocess.run( + ["pwsh", "-NoProfile", "-NonInteractive", "-Command", script], + input=payload, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + env={**os.environ, "OCTOSENSE_SCAN_DIAGNOSTIC_PUBLIC_KEY": public_key.read_text()}, + timeout=30, + ) + if result.returncode: + raise RuntimeError("native RSA diagnostic encryption failed") + encrypted = base64.b64decode(result.stdout.strip(), validate=True) + else: + result = subprocess.run( + ["openssl", "pkeyutl", "-encrypt", "-pubin", "-inkey", str(public_key), + "-pkeyopt", "rsa_padding_mode:oaep", "-pkeyopt", "rsa_oaep_md:sha256"], + input=payload, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=30, + ) + if result.returncode: + raise RuntimeError("RSA diagnostic encryption failed") + encrypted = result.stdout + if len(encrypted) != 512: + raise RuntimeError("diagnostic key is not RSA-4096") + return base64.b64encode(encrypted).decode("ascii") + + +def collect(binary, public_key): + spec = importlib.util.spec_from_file_location("release_scan", Path(__file__).with_name("release-scan.py")) + scan = importlib.util.module_from_spec(spec) + spec.loader.exec_module(scan) + data = binary.read_bytes() + digest = hashlib.sha256(data).hexdigest() + records = [] + for label, pattern in scan.compile_patterns(identity=False): + seen = set() + for match in pattern.finditer(data): + value = match.group() + if value in seen: + continue + if len(records) >= 16 or len(value) > 128: + raise RuntimeError("finding exceeds bounded diagnostic scope") + seen.add(value) + record = { + "label": label, "offset": match.start(), "binary_sha256": digest, + "value": base64.b64encode(value).decode("ascii"), + "before": base64.b64encode(data[max(0, match.start()-24):match.start()]).decode("ascii"), + "after": base64.b64encode(data[match.end():match.end()+24]).decode("ascii"), + } + payload = json.dumps(record, separators=(",", ":")).encode("utf-8") + records.append(seal(payload, public_key)) + if len(seen) >= 5: + break + return { + "schema": 1, "algorithm": "RSA-OAEP-SHA256", + "public_key_sha256": hashlib.sha256(public_key.read_bytes()).hexdigest(), + "records": records, + } + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--binary", type=Path, required=True) + parser.add_argument("--public-key", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + try: + sealed = collect(args.binary, args.public_key) + # No plaintext temporary file, stdout, diagnostic or artifact. + with args.output.open("x") as out: + json.dump(sealed, out, indent=2) + out.write("\n") + except Exception: + print("release-scan diagnostic failed; no plaintext was emitted", file=sys.stderr) + return 1 + print(f"Sealed {len(sealed['records'])} diagnostic records; privacy gate remains failed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/test_release_scan_sealed.py b/tools/test_release_scan_sealed.py new file mode 100644 index 00000000..ad2978a3 --- /dev/null +++ b/tools/test_release_scan_sealed.py @@ -0,0 +1,70 @@ +"""One-shot diagnostics must encrypt every finding and fail without plaintext.""" +import base64 +import importlib.util +import json +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest +from unittest.mock import patch + + +spec = importlib.util.spec_from_file_location("sealed_scan", Path(__file__).with_name("release-scan-sealed.py")) +sealed = importlib.util.module_from_spec(spec) +spec.loader.exec_module(sealed) + + +@unittest.skipUnless(shutil.which("openssl"), "standard OpenSSL required for independent decryption") +class SealedDiagnosticTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.temp = tempfile.TemporaryDirectory() + cls.root = Path(cls.temp.name) + cls.private = cls.root / "private.pem" + subprocess.run(["openssl", "genpkey", "-algorithm", "RSA", "-pkeyopt", "rsa_keygen_bits:4096", + "-out", str(cls.private)], check=True, capture_output=True) + cls.private.chmod(0o600) + cls.public = cls.root / "public.pem" + subprocess.run(["openssl", "pkey", "-in", str(cls.private), "-pubout", "-out", str(cls.public)], + check=True, capture_output=True) + + @classmethod + def tearDownClass(cls): + cls.temp.cleanup() + + def test_findings_are_ciphertext_and_independently_decryptable(self): + binary = self.root / "synthetic.bin" + value = b"/home/diagnostic-example/" + binary.write_bytes(b"prefix " + value + b" file") + result = sealed.collect(binary, self.public) + encoded = json.dumps(result).encode() + self.assertNotIn(value, encoded) + self.assertNotIn(base64.b64encode(value), encoded) + self.assertEqual(len(result["records"]), 1) + ciphertext = base64.b64decode(result["records"][0], validate=True) + plain = subprocess.run( + ["openssl", "pkeyutl", "-decrypt", "-inkey", str(self.private), + "-pkeyopt", "rsa_padding_mode:oaep", "-pkeyopt", "rsa_oaep_md:sha256"], + input=ciphertext, check=True, capture_output=True, + ).stdout + record = json.loads(plain) + self.assertEqual(base64.b64decode(record["value"]), value) + self.assertEqual(record["offset"], 7) + self.assertNotEqual(sealed.collect(binary, self.public)["records"][0], result["records"][0], + "OAEP must use fresh randomness") + + def test_encryption_failure_does_not_echo_native_error(self): + failure = subprocess.CompletedProcess([], 1, b"sensitive", b"sensitive") + with patch.object(sealed.subprocess, "run", return_value=failure): + with self.assertRaisesRegex(RuntimeError, "encryption failed") as raised: + sealed.seal(b"synthetic", self.public) + self.assertNotIn("sensitive", str(raised.exception)) + + def test_oversize_record_is_refused(self): + with self.assertRaisesRegex(RuntimeError, "encryption budget"): + sealed.seal(b"x" * 447, self.public) + + +if __name__ == "__main__": + unittest.main() From eff3d33123f90d0c228a77c48c2071e7d81c8a29 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:33:22 -0700 Subject: [PATCH 13/35] test: tolerate brief Windows browser command file collisions --- tools/browser-smoke.py | 26 ++++++- tools/test_browser_smoke_commands.py | 112 +++++++++++++++++++++++++++ 2 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 tools/test_browser_smoke_commands.py diff --git a/tools/browser-smoke.py b/tools/browser-smoke.py index 5fd9dcda..e7361255 100644 --- a/tools/browser-smoke.py +++ b/tools/browser-smoke.py @@ -37,6 +37,30 @@ ''' +def replace_command(staged, destination, process): + """Keep atomic publication, allowing brief Windows file-sharing collisions.""" + deadline = time.monotonic() + 2.0 + last_error = None + while True: + if process is not None: + status = process.poll() + if status is not None: + raise AssertionError( + f'Native host exited while publishing a command (status {status})' + ) from last_error + try: + staged.replace(destination) + return + except OSError as error: + if os.name != 'nt' or getattr(error, 'winerror', None) not in (5, 32, 33): + raise + remaining = deadline - time.monotonic() + if remaining <= 0: + raise # Preserve the terminal filesystem error on expiry. + last_error = error + time.sleep(min(.025, remaining)) + + def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--binary', type=Path, required=True) @@ -124,7 +148,7 @@ def command(operation, **fields): sequence += 1 staged = root / 'command.next' staged.write_text(json.dumps({'id': sequence, 'op': operation, **fields}), encoding='utf-8') - staged.replace(root / 'command.json') + replace_command(staged, root / 'command.json', process) wait(lambda: any(e.get('kind') == 'command' and e.get('id') == sequence for e in events()), operation) assert any(e.get('id') == sequence and e.get('accepted') for e in events()), operation return sequence diff --git a/tools/test_browser_smoke_commands.py b/tools/test_browser_smoke_commands.py new file mode 100644 index 00000000..e604ea75 --- /dev/null +++ b/tools/test_browser_smoke_commands.py @@ -0,0 +1,112 @@ +"""The native fixture's atomic command publication tolerates only bounded sharing errors.""" +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import Mock, patch + + +spec = importlib.util.spec_from_file_location("browser_smoke", Path(__file__).with_name("browser-smoke.py")) +smoke = importlib.util.module_from_spec(spec) +spec.loader.exec_module(smoke) + + +class Clock: + def __init__(self): + self.now = 0.0 + self.sleeps = [] + + def monotonic(self): + return self.now + + def sleep(self, delay): + self.sleeps.append(delay) + self.now += delay + + +def windows_error(code): + error = PermissionError(13, "synthetic command-file collision") + error.winerror = code + return error + + +class CommandPublicationTests(unittest.TestCase): + def test_transient_windows_errors_publish_the_same_complete_command(self): + for code in (5, 32, 33): + with self.subTest(winerror=code), tempfile.TemporaryDirectory() as temp: + root = Path(temp) + staged, destination = root / "command.next", root / "command.json" + payload = json.dumps({"id": 11, "op": "close"}).encode() + previous = json.dumps({"id": 10, "op": "inspect"}).encode() + staged.write_bytes(payload) + destination.write_bytes(previous) + attempts = [] + clock = Clock() + real_replace = Path.replace + + def replace(path, target): + attempts.append((path, target, path.read_bytes())) + self.assertEqual(destination.read_bytes(), previous) + if len(attempts) <= 2: + raise windows_error(code) + return real_replace(path, target) + + with patch.object(smoke.os, "name", "nt"), \ + patch.object(smoke.time, "monotonic", clock.monotonic), \ + patch.object(smoke.time, "sleep", clock.sleep), \ + patch.object(Path, "replace", replace): + smoke.replace_command(staged, destination, Mock(poll=Mock(return_value=None))) + self.assertEqual(attempts, [(staged, destination, payload)] * 3) + self.assertEqual(destination.read_bytes(), payload) + self.assertFalse(staged.exists()) + self.assertEqual(clock.sleeps, [.025, .025]) + + def test_persistent_collision_is_bounded_and_preserves_terminal_error(self): + clock = Clock() + error = windows_error(5) + staged = Mock() + staged.replace.side_effect = error + with patch.object(smoke.os, "name", "nt"), \ + patch.object(smoke.time, "monotonic", clock.monotonic), \ + patch.object(smoke.time, "sleep", clock.sleep): + with self.assertRaises(PermissionError) as raised: + smoke.replace_command(staged, Mock(), Mock(poll=Mock(return_value=None))) + self.assertIs(raised.exception, error) + self.assertAlmostEqual(clock.now, 2.0) + self.assertLessEqual(staged.replace.call_count, 82) + + def test_other_errors_are_not_retried(self): + for platform, error in (("nt", windows_error(123)), ("nt", OSError(2, "synthetic missing file")), + ("posix", windows_error(5))): + with self.subTest(platform=platform, winerror=getattr(error, "winerror", None)): + staged = Mock() + staged.replace.side_effect = error + with patch.object(smoke.os, "name", platform), patch.object(smoke.time, "sleep") as sleep: + with self.assertRaises(OSError) as raised: + smoke.replace_command(staged, Mock(), None) + self.assertIs(raised.exception, error) + staged.replace.assert_called_once() + sleep.assert_not_called() + + def test_child_exit_stops_retry_before_another_publication(self): + error = windows_error(32) + staged = Mock() + staged.replace.side_effect = error + process = Mock(poll=Mock(side_effect=[None, 7])) + with patch.object(smoke.os, "name", "nt"), patch.object(smoke.time, "sleep") as sleep: + with self.assertRaisesRegex(AssertionError, "status 7") as raised: + smoke.replace_command(staged, Mock(), process) + self.assertIs(raised.exception.__cause__, error) + staged.replace.assert_called_once() + sleep.assert_called_once() + + def test_exited_child_receives_no_command(self): + staged = Mock() + with self.assertRaisesRegex(AssertionError, "status 0"): + smoke.replace_command(staged, Mock(), Mock(poll=Mock(return_value=0))) + staged.replace.assert_not_called() + + +if __name__ == "__main__": + unittest.main() From de25c13c7622d572fab1edf58e876a67a5095a30 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:50:37 -0700 Subject: [PATCH 14/35] fix: distinguish adjacent public Rinx source paths from home directories --- tools/release-scan.py | 22 +++++++++++++++++++++- tools/test_release_scan.py | 17 +++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/tools/release-scan.py b/tools/release-scan.py index 0d2f0774..92d00b78 100644 --- a/tools/release-scan.py +++ b/tools/release-scan.py @@ -38,7 +38,8 @@ clean. Findings are about the build, not the code: fix them with neutral build paths (see desktop/scripts/package.py). The only exceptions are known `.local` constants, exact names: the product's own (PRODUCT_LOCAL_NAMES) and -its dependencies' (DEPENDENCY_LOCAL_NAMES). +its dependencies' (DEPENDENCY_LOCAL_NAMES), plus the independently verified +public-source seam in RINX_SOURCE_SEAM below. """ import argparse import getpass @@ -74,6 +75,17 @@ "send-queue.local", ) +# Rinx 4b89097's two public source filenames are adjacent Rust literals in +# the Linux executable. The slash starting the second remapped path makes +# the first filename look like a Linux account: /home/main_desktop_ui.rs/. +# Admit only this proven pair, in the same neutral Cargo checkout/revision; +# a standalone /home/main_desktop_ui.rs/private path must still fail. +RINX_SOURCE_SEAM = re.compile( + rb"(?P/cargo/git/checkouts/rinx-[0-9a-f]{16}/[0-9a-f]{7,40}/src)" + rb"/home/main_desktop_ui\.rs(?P(?P=root))" + rb"/home/tombstone_footer\.rs" +) + BASE_PATTERNS = [ ("macOS user directory", rb"/Users/[^/\s\x00\"']+"), ("Windows user directory", rb"[A-Za-z]:[\\/]{1,2}Users[\\/]{1,2}(?!runneradmin[\\/])[^\\/\s\x00\"']+"), @@ -124,8 +136,16 @@ def mask(match): def scan_bytes(data, where, patterns, findings): for label, regex in patterns: + source_seams = set() + if label == "Linux home directory": + source_seams = { + (seam.start("next") - len(b"/home/main_desktop_ui.rs"), seam.start("next") + 1) + for seam in RINX_SOURCE_SEAM.finditer(data) + } seen = set() for m in regex.finditer(data): + if (m.start(), m.end()) in source_seams: + continue if m.group(0) in seen: continue seen.add(m.group(0)) diff --git a/tools/test_release_scan.py b/tools/test_release_scan.py index ee23b3c1..2585298c 100644 --- a/tools/test_release_scan.py +++ b/tools/test_release_scan.py @@ -46,6 +46,23 @@ def test_a_dependency_constant_is_not_a_host(self): for leak in (b"my-send-queue.local", b"send-queue2.local", b"xsend-queue.local"): self.assertTrue(findings(b"at " + leak + b" end"), leak) + def test_only_the_proven_public_rinx_source_seam_is_ignored(self): + root = b"/cargo/git/checkouts/rinx-cf0dcd4e7b4d3fa8/4b89097/src" + first = root + b"/home/main_desktop_ui.rs" + second = root + b"/home/tombstone_footer.rs" + self.assertEqual(findings(first + second), []) + for leak in ( + b"/home/main_desktop_ui.rs/private", + b"/home/someone/private", + first + b"/private", + first + second.replace(b"rinx-", b"other-"), + first + second.replace(b"4b89097", b"1234567"), + first + second.replace(b"tombstone_footer", b"unproven_file"), + (first + second).replace(b"/cargo/", b"/private/"), + first + second + b"\x00/home/main_desktop_ui.rs/private", + ): + self.assertTrue(findings(leak), leak) + def test_findings_are_masked_and_extra_patterns_apply(self): out = findings(b"/Users/someone/x") self.assertEqual(len(out), 1) From 9f8ce11c7cc890aa77131821c42a0ae61b46fc54 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:32:10 -0700 Subject: [PATCH 15/35] fix: recognize proven Mail literal seam in release privacy scan --- .github/workflows/release-desktop.yml | 28 ------ tools/release-scan-diagnostic-public.pem | 14 --- tools/release-scan-sealed.py | 118 ----------------------- tools/release-scan.py | 14 ++- tools/test_release_scan.py | 15 +++ tools/test_release_scan_sealed.py | 70 -------------- 6 files changed, 28 insertions(+), 231 deletions(-) delete mode 100644 tools/release-scan-diagnostic-public.pem delete mode 100644 tools/release-scan-sealed.py delete mode 100644 tools/test_release_scan_sealed.py diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index fba5128d..f642f8dd 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -201,19 +201,6 @@ jobs: cd "$SRC" python tools/setup.py --check --cargo bash tools/check-shell-graph.sh -p octosense - - name: Check diagnostic encryption before the long build - run: | - cd "$SRC" - python - <<'PY' - import base64, importlib.util - from pathlib import Path - spec = importlib.util.spec_from_file_location("sealed", "tools/release-scan-sealed.py") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - encrypted = module.seal(b"synthetic preflight", Path("tools/release-scan-diagnostic-public.pem")) - assert len(base64.b64decode(encrypted, validate=True)) == 512 - print("Diagnostic RSA-OAEP encryption passed") - PY - name: Build and package, unsigned (paths remapped, resources and kernel bundled) run: | cd "$SRC" @@ -241,7 +228,6 @@ jobs: cp receipt.json "upload/packages/OctoSense_${version}_${{ matrix.platform }}.receipt.json" ls -la upload/packages - name: Refuse private paths and names (the packages and their payloads) - id: privacy run: | cd "$SRC/target/octosense-package" # The packages (inside .dmg, .deb, .AppImage, .zip, the NSIS @@ -249,20 +235,6 @@ jobs: # the staged resources. exe=octosense; [ "$RUNNER_OS" = Windows ] && exe=octosense.exe python "$SRC/tools/release-scan.py" upload/packages/* resources kernel "$SRC/target/release/$exe" - - name: Seal bounded failure diagnostics for the local reviewer - if: failure() && steps.privacy.outcome == 'failure' - run: | - exe=octosense; [ "$RUNNER_OS" = Windows ] && exe=octosense.exe - python "$SRC/tools/release-scan-sealed.py" --binary "$SRC/target/release/$exe" \ - --public-key "$SRC/tools/release-scan-diagnostic-public.pem" \ - --output "$RUNNER_TEMP/release-scan-sealed.json" - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - if: failure() && steps.privacy.outcome == 'failure' - with: - name: sealed-release-diagnostic-${{ matrix.platform }} - path: ${{ runner.temp }}/release-scan-sealed.json - if-no-files-found: error - retention-days: 2 - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: octosense-desktop-unsigned-${{ matrix.platform }} diff --git a/tools/release-scan-diagnostic-public.pem b/tools/release-scan-diagnostic-public.pem deleted file mode 100644 index a7300cef..00000000 --- a/tools/release-scan-diagnostic-public.pem +++ /dev/null @@ -1,14 +0,0 @@ ------BEGIN PUBLIC KEY----- -MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAv5BPgrMvZr10DBOn/TwX -9MDNwz2GtnSahWyo6H6NvqrKv0sDe/oZj1Vyv6EdsjpMhLuM00UvtlEy5oThu/mV -qcq63SfFqenaPv+zUY4tRwRoAPKCYXakzrqMK/FwdUYUL5LqqPcdjxQx1KRbg3Yx -QpnToD+kbp2tvAhxH3W9vvCMXPUgqxfkr0gjzGcPqT8zUOMkDOp53mIprbrRg/2x -xLBIa7KFPpEJOmk3FIMPSutXCXAkl2VcR1APzNpNC5yRRV+MljoNCeI4ooUAWpn2 -2Q6TzIQ4BChA21+o+J+jWKJS0jZ1lSfRGCP9UREhN4Gn/HJ11Hl7aoF/Lp6X3hoM -8J0U5QldBbJBr8kJDPIqUMNHaFP/aSMubQzSTQ4ZuwmDGfYmO5gkWRp6E6Ve4lKq -VT5DMVFijer401zeCsYtecJugWdzLfit9o8KFIItLmU98OgVzYzsrOz+kFg0qYw/ -kp3BJdx8oG6uA9nVEhg0XuukKFOwlTMTzl9EwOgj08uXMPukXaxWX6hJAI5RVNtu -poP9yRXQkfHt51R4GiAeDOSt4IZpGwYziv7iSanUQjuNaYNnqyNRYhU/5iHOIhYE -5NMrK0l33VRyQGjPf+GKYJbJIGNVi2/fvX4eRvKpOiBNs10I3VVDGs7Bop3Q870X -ABJw60GjtGsbYs58artoX78CAwEAAQ== ------END PUBLIC KEY----- diff --git a/tools/release-scan-sealed.py b/tools/release-scan-sealed.py deleted file mode 100644 index fdaafa5a..00000000 --- a/tools/release-scan-sealed.py +++ /dev/null @@ -1,118 +0,0 @@ -#!/usr/bin/env python3 -"""One-shot, encrypted diagnostics for PR361's release privacy finding. - -This never changes release-scan's result. Only RSA-OAEP ciphertext leaves the -runner; the diagnostic private key is held outside the checkout. Delete this -helper and the public key once the offending public source has been traced. -""" -import argparse -import base64 -import hashlib -import importlib.util -import json -import os -from pathlib import Path -import subprocess -import sys - - -def seal(payload, public_key): - # A 4096-bit RSA key with OAEP-SHA256 admits at most 446 bytes. Refuse - # oversize input rather than truncate the finding or emit plaintext. - if len(payload) > 446: - raise RuntimeError("diagnostic record exceeds its encryption budget") - if sys.platform == "win32": - script = """ -$ErrorActionPreference = 'Stop' -Add-Type -TypeDefinition @' -using System; -using System.Text; -using System.Security.Cryptography; -public static class OctoSenseDiagnosticSeal { - public static string Seal(string pem, string payload) { - using (var rsa = RSA.Create()) { - rsa.ImportFromPem(pem); - return Convert.ToBase64String(rsa.Encrypt(Encoding.UTF8.GetBytes(payload), RSAEncryptionPadding.OaepSHA256)); - } - } -} -'@ -[Console]::Out.Write([OctoSenseDiagnosticSeal]::Seal($env:OCTOSENSE_SCAN_DIAGNOSTIC_PUBLIC_KEY, [Console]::In.ReadToEnd())) -""" - result = subprocess.run( - ["pwsh", "-NoProfile", "-NonInteractive", "-Command", script], - input=payload, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - env={**os.environ, "OCTOSENSE_SCAN_DIAGNOSTIC_PUBLIC_KEY": public_key.read_text()}, - timeout=30, - ) - if result.returncode: - raise RuntimeError("native RSA diagnostic encryption failed") - encrypted = base64.b64decode(result.stdout.strip(), validate=True) - else: - result = subprocess.run( - ["openssl", "pkeyutl", "-encrypt", "-pubin", "-inkey", str(public_key), - "-pkeyopt", "rsa_padding_mode:oaep", "-pkeyopt", "rsa_oaep_md:sha256"], - input=payload, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=30, - ) - if result.returncode: - raise RuntimeError("RSA diagnostic encryption failed") - encrypted = result.stdout - if len(encrypted) != 512: - raise RuntimeError("diagnostic key is not RSA-4096") - return base64.b64encode(encrypted).decode("ascii") - - -def collect(binary, public_key): - spec = importlib.util.spec_from_file_location("release_scan", Path(__file__).with_name("release-scan.py")) - scan = importlib.util.module_from_spec(spec) - spec.loader.exec_module(scan) - data = binary.read_bytes() - digest = hashlib.sha256(data).hexdigest() - records = [] - for label, pattern in scan.compile_patterns(identity=False): - seen = set() - for match in pattern.finditer(data): - value = match.group() - if value in seen: - continue - if len(records) >= 16 or len(value) > 128: - raise RuntimeError("finding exceeds bounded diagnostic scope") - seen.add(value) - record = { - "label": label, "offset": match.start(), "binary_sha256": digest, - "value": base64.b64encode(value).decode("ascii"), - "before": base64.b64encode(data[max(0, match.start()-24):match.start()]).decode("ascii"), - "after": base64.b64encode(data[match.end():match.end()+24]).decode("ascii"), - } - payload = json.dumps(record, separators=(",", ":")).encode("utf-8") - records.append(seal(payload, public_key)) - if len(seen) >= 5: - break - return { - "schema": 1, "algorithm": "RSA-OAEP-SHA256", - "public_key_sha256": hashlib.sha256(public_key.read_bytes()).hexdigest(), - "records": records, - } - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--binary", type=Path, required=True) - parser.add_argument("--public-key", type=Path, required=True) - parser.add_argument("--output", type=Path, required=True) - args = parser.parse_args() - try: - sealed = collect(args.binary, args.public_key) - # No plaintext temporary file, stdout, diagnostic or artifact. - with args.output.open("x") as out: - json.dump(sealed, out, indent=2) - out.write("\n") - except Exception: - print("release-scan diagnostic failed; no plaintext was emitted", file=sys.stderr) - return 1 - print(f"Sealed {len(sealed['records'])} diagnostic records; privacy gate remains failed") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/release-scan.py b/tools/release-scan.py index 92d00b78..d04d33e5 100644 --- a/tools/release-scan.py +++ b/tools/release-scan.py @@ -39,7 +39,7 @@ build paths (see desktop/scripts/package.py). The only exceptions are known `.local` constants, exact names: the product's own (PRODUCT_LOCAL_NAMES) and its dependencies' (DEPENDENCY_LOCAL_NAMES), plus the independently verified -public-source seam in RINX_SOURCE_SEAM below. +public-source seams in RINX_SOURCE_SEAM and MAIL_LITERAL_SEAM below. """ import argparse import getpass @@ -86,6 +86,16 @@ rb"/home/tombstone_footer\.rs" ) +# The Windows linker pools these four public Mail literals without NULs: +# `Mail service is not registered`, `attempts`, `send`, `octosense.local` +# (apps/mail/host-service/src/drafts.rs: configured() and add_attempt()). +# The scanner otherwise reads the last word + three literals as one host. +# Require the entire known sentence and exact sequence; the same apparent +# hostname standing alone, or another hostname after the sentence, fails. +MAIL_LITERAL_SEAM = re.compile( + rb"Mail service is not (?Pregisteredattemptssendoctosense\.local)" +) + BASE_PATTERNS = [ ("macOS user directory", rb"/Users/[^/\s\x00\"']+"), ("Windows user directory", rb"[A-Za-z]:[\\/]{1,2}Users[\\/]{1,2}(?!runneradmin[\\/])[^\\/\s\x00\"']+"), @@ -142,6 +152,8 @@ def scan_bytes(data, where, patterns, findings): (seam.start("next") - len(b"/home/main_desktop_ui.rs"), seam.start("next") + 1) for seam in RINX_SOURCE_SEAM.finditer(data) } + elif label == "mDNS .local host name": + source_seams = {seam.span("host") for seam in MAIL_LITERAL_SEAM.finditer(data)} seen = set() for m in regex.finditer(data): if (m.start(), m.end()) in source_seams: diff --git a/tools/test_release_scan.py b/tools/test_release_scan.py index 2585298c..e8182f94 100644 --- a/tools/test_release_scan.py +++ b/tools/test_release_scan.py @@ -63,6 +63,21 @@ def test_only_the_proven_public_rinx_source_seam_is_ignored(self): ): self.assertTrue(findings(leak), leak) + def test_only_the_proven_mail_literal_seam_is_ignored(self): + prefix = b"Mail service is not " + apparent_host = b"registeredattemptssendoctosense.local" + self.assertEqual(findings(prefix + apparent_host + b"\x00"), []) + for leak in ( + apparent_host, + b"https://" + apparent_host + b"/", + b"Other service is not " + apparent_host, + prefix + b"registeredattemptssendprivate.local", + prefix + b"registeredattemptsotheroctosense.local", + prefix + b"registeredattemptssendoctosense2.local", + prefix + apparent_host + b"\x00" + apparent_host, + ): + self.assertTrue(findings(leak), leak) + def test_findings_are_masked_and_extra_patterns_apply(self): out = findings(b"/Users/someone/x") self.assertEqual(len(out), 1) diff --git a/tools/test_release_scan_sealed.py b/tools/test_release_scan_sealed.py deleted file mode 100644 index ad2978a3..00000000 --- a/tools/test_release_scan_sealed.py +++ /dev/null @@ -1,70 +0,0 @@ -"""One-shot diagnostics must encrypt every finding and fail without plaintext.""" -import base64 -import importlib.util -import json -from pathlib import Path -import shutil -import subprocess -import tempfile -import unittest -from unittest.mock import patch - - -spec = importlib.util.spec_from_file_location("sealed_scan", Path(__file__).with_name("release-scan-sealed.py")) -sealed = importlib.util.module_from_spec(spec) -spec.loader.exec_module(sealed) - - -@unittest.skipUnless(shutil.which("openssl"), "standard OpenSSL required for independent decryption") -class SealedDiagnosticTests(unittest.TestCase): - @classmethod - def setUpClass(cls): - cls.temp = tempfile.TemporaryDirectory() - cls.root = Path(cls.temp.name) - cls.private = cls.root / "private.pem" - subprocess.run(["openssl", "genpkey", "-algorithm", "RSA", "-pkeyopt", "rsa_keygen_bits:4096", - "-out", str(cls.private)], check=True, capture_output=True) - cls.private.chmod(0o600) - cls.public = cls.root / "public.pem" - subprocess.run(["openssl", "pkey", "-in", str(cls.private), "-pubout", "-out", str(cls.public)], - check=True, capture_output=True) - - @classmethod - def tearDownClass(cls): - cls.temp.cleanup() - - def test_findings_are_ciphertext_and_independently_decryptable(self): - binary = self.root / "synthetic.bin" - value = b"/home/diagnostic-example/" - binary.write_bytes(b"prefix " + value + b" file") - result = sealed.collect(binary, self.public) - encoded = json.dumps(result).encode() - self.assertNotIn(value, encoded) - self.assertNotIn(base64.b64encode(value), encoded) - self.assertEqual(len(result["records"]), 1) - ciphertext = base64.b64decode(result["records"][0], validate=True) - plain = subprocess.run( - ["openssl", "pkeyutl", "-decrypt", "-inkey", str(self.private), - "-pkeyopt", "rsa_padding_mode:oaep", "-pkeyopt", "rsa_oaep_md:sha256"], - input=ciphertext, check=True, capture_output=True, - ).stdout - record = json.loads(plain) - self.assertEqual(base64.b64decode(record["value"]), value) - self.assertEqual(record["offset"], 7) - self.assertNotEqual(sealed.collect(binary, self.public)["records"][0], result["records"][0], - "OAEP must use fresh randomness") - - def test_encryption_failure_does_not_echo_native_error(self): - failure = subprocess.CompletedProcess([], 1, b"sensitive", b"sensitive") - with patch.object(sealed.subprocess, "run", return_value=failure): - with self.assertRaisesRegex(RuntimeError, "encryption failed") as raised: - sealed.seal(b"synthetic", self.public) - self.assertNotIn("sensitive", str(raised.exception)) - - def test_oversize_record_is_refused(self): - with self.assertRaisesRegex(RuntimeError, "encryption budget"): - sealed.seal(b"x" * 447, self.public) - - -if __name__ == "__main__": - unittest.main() From 58b550c9c763cee92d8b02582e08dbc31d8b557c Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:43:39 -0700 Subject: [PATCH 16/35] test: run signed host API acceptance on Android --- Cargo.lock | 13 ++ Cargo.toml | 1 + crates/host-api-smoke/Cargo.toml | 31 +++++ crates/shell/examples/host-api-lab.rs | 18 ++- tools/fixtures/host-api-lab/ANDROID.md | 37 +++++ tools/fixtures/host-api-lab/ANDROID.zh-CN.md | 37 +++++ tools/fixtures/host-api-lab/README.md | 2 + tools/fixtures/host-api-lab/README.zh-CN.md | 2 + .../evidence/android/native-result.json | 109 +++++++++++++++ .../evidence/android/receipt.json | 36 +++++ tools/test-host-api-android.py | 130 ++++++++++++++++++ 11 files changed, 413 insertions(+), 3 deletions(-) create mode 100644 crates/host-api-smoke/Cargo.toml create mode 100644 tools/fixtures/host-api-lab/ANDROID.md create mode 100644 tools/fixtures/host-api-lab/ANDROID.zh-CN.md create mode 100644 tools/fixtures/host-api-lab/evidence/android/native-result.json create mode 100644 tools/fixtures/host-api-lab/evidence/android/receipt.json create mode 100644 tools/test-host-api-android.py diff --git a/Cargo.lock b/Cargo.lock index f4c8f3a7..731da9d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8016,6 +8016,19 @@ dependencies = [ "serde_json", ] +[[package]] +name = "octosense-host-api-smoke" +version = "0.1.0" +dependencies = [ + "makepad-app-module", + "makepad-widgets", + "octosense-app-hub", + "octosense-app-policy", + "octosense-appstore", + "octosense-shell", + "serde_json", +] + [[package]] name = "octosense-kernel" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 68f2262e..32194898 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ members = [ "crates/oauth-service", "crates/markdown-editor", "crates/browser-smoke", + "crates/host-api-smoke", "crates/wasm-host", "apps/reference", "apps/news/host-service", diff --git a/crates/host-api-smoke/Cargo.toml b/crates/host-api-smoke/Cargo.toml new file mode 100644 index 00000000..6132e019 --- /dev/null +++ b/crates/host-api-smoke/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "octosense-host-api-smoke" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +# Android needs a library entry point. Reuse the exact desktop acceptance +# host; this package supplies no alternate API implementation or grant path. +[lib] +path = "../shell/examples/host-api-lab.rs" +test = false +bench = false +doctest = false + +[[bin]] +name = "octosense-host-api-smoke" +path = "../shell/examples/host-api-lab.rs" + +[features] +default = ["acceptance-fixtures"] +acceptance-fixtures = ["octosense-shell/acceptance-fixtures"] + +[dependencies] +makepad-widgets.workspace = true +makepad-app-module.workspace = true +octosense-appstore.workspace = true +octosense-app-hub.workspace = true +octosense-app-policy.workspace = true +octosense-shell.workspace = true +serde_json.workspace = true diff --git a/crates/shell/examples/host-api-lab.rs b/crates/shell/examples/host-api-lab.rs index 577b4d8a..5d5dd4dd 100644 --- a/crates/shell/examples/host-api-lab.rs +++ b/crates/shell/examples/host-api-lab.rs @@ -61,11 +61,19 @@ struct App { impl MatchEvent for App { fn handle_startup(&mut self, cx: &mut Cx) { + #[cfg(not(target_os = "android"))] let arg = |name: &str| std::env::args().find_map(|a| a.strip_prefix(name).map(str::to_owned)); + #[cfg(not(target_os = "android"))] let root = PathBuf::from(arg("--app-data=").expect("Pass an isolated --app-data directory")); + #[cfg(not(target_os = "android"))] let bundle = PathBuf::from(arg("--bundle=").expect("Pass --bundle=")); + #[cfg(target_os = "android")] + let data = PathBuf::from(cx.get_data_dir().expect("Android private data directory")) + .join("host-api-lab"); + #[cfg(target_os = "android")] + let (root, bundle) = (data.join("apps"), data.join("bundle")); let preview = std::env::args().any(|a| a == "--preview"); octosense_appstore::set_data_root(root.clone()); let policy = if preview { @@ -117,9 +125,13 @@ impl MatchEvent for App { if preview { return; } - self.receipt = Some(PathBuf::from( + #[cfg(not(target_os = "android"))] + let receipt = PathBuf::from( arg("--receipt=").expect("Pass a new --receipt file"), - )); + ); + #[cfg(target_os = "android")] + let receipt = data.join("receipt.json"); + self.receipt = Some(receipt); assert!( !self.receipt.as_ref().unwrap().exists(), "Receipt must be a new file" @@ -206,7 +218,7 @@ impl AppMain for App { "signed_install":true,"bundle_digest":self.launch.as_ref().unwrap().manifest.integrity.bundle_blake3, "tool_result":reply,"refusals":self.preliminary,"closed_app":closed, "host_sheet_visible":self.sheet.borrow().is_some_and(|s| s.view.visible), - "not_verified":["model or peer relay consent", "physical OS permission approval", "camera capture", "Android runtime"]}); + "not_verified":["model or peer relay consent", "physical OS permission approval", "camera capture"]}); use std::io::Write; std::fs::OpenOptions::new() .write(true) diff --git a/tools/fixtures/host-api-lab/ANDROID.md b/tools/fixtures/host-api-lab/ANDROID.md new file mode 100644 index 00000000..f0af6110 --- /dev/null +++ b/tools/fixtures/host-api-lab/ANDROID.md @@ -0,0 +1,37 @@ +# Host API Lab on Android + +English | [简体中文](ANDROID.zh-CN.md) + +The [receipt](evidence/android/receipt.json) records **14/14 checks on a OnePlus 6 running Android 15**. The [native result](evidence/android/native-result.json) comes from the actual phone process. A signed ordinary bundle calls its own Splash tool, which discovers a host API and reads Android's camera permission status through the production service. The fixture rejects cross-account calls, undeclared capabilities/tools, invalid arguments, uncompiled Rust functions, background permission prompts and calls after the tool endpoint closes. + +This proves the native app/tool/service path. It does not start a model or peer agent, capture a camera image, approve a permission, log in to a provider, or establish physical approval. The test package was stopped after completion. Normal Home and personal profiles were not changed. + +Use an assigned phone and the existing Makepad Android SDK/JDK. From the repository root, prepare the pinned runtime and build its packager: + +```sh +python3 tools/setup.py +cargo build --release --offline --manifest-path .sources/makepad/tools/cargo_makepad/Cargo.toml +``` + +Set `JAVA_HOME` and `ANDROID_HOME` to existing tool installations. Set `PACKAGER` to that built `cargo-makepad` executable and `MAKEPAD_ANDROID_SDK` to the existing Makepad Android SDK. Build a separate debuggable package; `octosense-host-api-smoke` uses the same Rust source as the desktop `host-api-lab` example: + +```sh +MAKEPAD_FORCE_DEBUGGABLE=1 "$PACKAGER" makepad android \ + --sdk-path="$MAKEPAD_ANDROID_SDK" --abi=aarch64 \ + --package-name=dev.makepad.octosense.hostapilab \ + --app-label='Host API Lab' \ + build -p octosense-host-api-smoke --release --locked --offline +``` + +Set `APK` to the aligned APK reported by the packager, `HUB` to the built compatible App Hub CLI, and choose a new `EVIDENCE` directory. Run the tested driver: + +```sh +python3 tools/test-host-api-android.py \ + --adb "$ANDROID_HOME/platform-tools/adb" \ + --aapt2 "$ANDROID_HOME/build-tools/35.0.0/aapt2" \ + --apk "$APK" --hub "$HUB" --out "$EVIDENCE" +``` + +The driver checks the APK's package and debuggable flag, refuses an already installed package, copies only the public synthetic bundle, stamps its Android listing, and collects the native result. The phone host creates ephemeral signing keys and admits the bundle through the normal signed Store path. No grants or credentials are injected. If multiple devices are attached, supply `--serial` for the assigned phone; serials are omitted from evidence. For another run, choose a fresh `.hostapilab.` package consistently in the build and driver's `--package` argument. + +The driver force-stops only its own package after success or failure. It leaves that isolated test package/data available for inspection. The published evidence contains no device serial, personal account, credential or local checkout path. The commands above were exercised using existing local tool/cache paths; they install no build tools. diff --git a/tools/fixtures/host-api-lab/ANDROID.zh-CN.md b/tools/fixtures/host-api-lab/ANDROID.zh-CN.md new file mode 100644 index 00000000..e8a0b928 --- /dev/null +++ b/tools/fixtures/host-api-lab/ANDROID.zh-CN.md @@ -0,0 +1,37 @@ +# Android 上的 Host API Lab + +[English](ANDROID.md) | 简体中文 + +[验收记录](evidence/android/receipt.json)确认:**OnePlus 6、Android 15,14 项检查全部通过**。[原生结果](evidence/android/native-result.json)来自实际手机进程。普通签名应用调用自己的 Splash 工具,通过生产环境宿主服务发现 API 并读取 Android 摄像头权限状态。测试同时验证跨账户调用、未声明的能力和工具、无效参数、未编译进宿主的 Rust 函数、后台权限弹窗以及工具关闭后的调用都会被拒绝。 + +这验证了原生应用、工具和宿主服务之间的执行链路。测试没有启动模型或 peer agent,没有拍照、批准权限、登录提供商,也不能证明物理输入审批。测试完成后只停止了自己的测试包,没有修改正常 Home 或个人资料。 + +使用获准测试的手机和已有的 Makepad Android SDK/JDK。在仓库根目录准备固定版本运行时及打包工具: + +```sh +python3 tools/setup.py +cargo build --release --offline --manifest-path .sources/makepad/tools/cargo_makepad/Cargo.toml +``` + +将 `JAVA_HOME`、`ANDROID_HOME` 指向已有工具,将 `PACKAGER` 指向生成的 `cargo-makepad`,将 `MAKEPAD_ANDROID_SDK` 指向已有 Makepad Android SDK。构建独立、可调试的测试包。`octosense-host-api-smoke` 与桌面 `host-api-lab` 示例共用 Rust 源码: + +```sh +MAKEPAD_FORCE_DEBUGGABLE=1 "$PACKAGER" makepad android \ + --sdk-path="$MAKEPAD_ANDROID_SDK" --abi=aarch64 \ + --package-name=dev.makepad.octosense.hostapilab \ + --app-label='Host API Lab' \ + build -p octosense-host-api-smoke --release --locked --offline +``` + +将 `APK` 指向打包器输出的已对齐 APK,`HUB` 指向已构建的兼容 App Hub CLI,并选择全新的 `EVIDENCE` 目录,然后运行已在手机上验证的驱动: + +```sh +python3 tools/test-host-api-android.py \ + --adb "$ANDROID_HOME/platform-tools/adb" \ + --aapt2 "$ANDROID_HOME/build-tools/35.0.0/aapt2" \ + --apk "$APK" --hub "$HUB" --out "$EVIDENCE" +``` + +驱动检查 APK 包名与可调试标记,拒绝覆盖已安装的包,只复制公开合成测试应用,为 Android listing 重新计算摘要并收集原生结果。手机宿主在内存中创建临时签名密钥,通过正常签名 Store 流程接纳应用,没有注入权限或凭据。多台设备连接时,用 `--serial` 指定获准设备;验收记录不包含设备序列号。再次测试时,构建和驱动的 `--package` 参数应统一使用新的 `.hostapilab.` 包名。 + +无论成功或失败,驱动最后只停止自己的测试包,保留独立测试包及其数据供检查。公开记录不含设备序列号、个人账户、凭据或本地源码路径。上述命令已使用已有工具和缓存实际运行,不会安装构建工具。 diff --git a/tools/fixtures/host-api-lab/README.md b/tools/fixtures/host-api-lab/README.md index 2e4bb682..9d65e7a3 100644 --- a/tools/fixtures/host-api-lab/README.md +++ b/tools/fixtures/host-api-lab/README.md @@ -2,6 +2,8 @@ English | [简体中文](README.zh-CN.md) +[Android reproduction and OnePlus 6 results](ANDROID.md): the same native host completed all 14 phone checks, without a model, account login or permission approval. + This development fixture demonstrates an app-owned Splash tool calling Rust already compiled into OctoSense. It reads the real macOS camera permission status, updates the same app UI, and returns a structured answer to its native diff --git a/tools/fixtures/host-api-lab/README.zh-CN.md b/tools/fixtures/host-api-lab/README.zh-CN.md index 1587cd91..9ee768b7 100644 --- a/tools/fixtures/host-api-lab/README.zh-CN.md +++ b/tools/fixtures/host-api-lab/README.zh-CN.md @@ -2,6 +2,8 @@ [English](README.md) | 简体中文 +[Android 复现步骤与 OnePlus 6 结果](ANDROID.zh-CN.md):同一原生宿主完成了全部 14 项手机检查,没有启动模型、登录账户或批准权限。 + 此开发测试示例展示:应用自己的 Splash 工具调用已编译进 OctoSense 的 Rust 服务,读取真实 macOS 相机权限状态,更新同一应用的界面,并把结构化 结果返回给原生调用方。它不会采集媒体或批准设备访问,不是 App Hub 投稿, diff --git a/tools/fixtures/host-api-lab/evidence/android/native-result.json b/tools/fixtures/host-api-lab/evidence/android/native-result.json new file mode 100644 index 00000000..ef247b02 --- /dev/null +++ b/tools/fixtures/host-api-lab/evidence/android/native-result.json @@ -0,0 +1,109 @@ +{ + "bundle_digest": "ce4a5b1ca73b733413a553e69cf0d710f04b3888c833504dec2878c9f67e8d6d", + "closed_app": "app_not_running: open this app before calling its script tools", + "host_sheet_visible": false, + "not_verified": [ + "model or peer relay consent", + "physical OS permission approval", + "camera capture" + ], + "platform": "android", + "proof": "signed app -> app_tool -> host.request -> native OS permission status", + "refusals": { + "invalid_arguments": "invalid_arguments: $: injected is not a declared field", + "undeclared_tool": "tool_not_declared: the running bundle does not implement this tool", + "wrong_account": "account_scope: app account changed" + }, + "schema": 1, + "signed_install": true, + "tool_result": { + "Ok": { + "account": "device", + "background_error": "camera.permission.request is unavailable to agents/background surfaces", + "background_prompt_allowed": false, + "calls": 1, + "camera": { + "app_consent": false, + "app_policy_granted": true, + "background_permission": false, + "capability": "camera", + "location_read_supported": false, + "os_permission": "not_determined", + "scope": "host_api_v1_device_access", + "supported": true + }, + "discovery": { + "authorization": "checked-on-call", + "configured": null, + "descriptor": { + "agent_access": "allowed", + "capability": "camera", + "input_schema": { + "additionalProperties": false, + "type": "object" + }, + "name": "camera.permission.status", + "output_schema": { + "properties": { + "app_consent": { + "type": "boolean" + }, + "app_policy_granted": { + "type": "boolean" + }, + "background_permission": { + "const": false + }, + "capability": { + "type": "string" + }, + "location_read_supported": { + "type": "boolean" + }, + "os_permission": { + "enum": [ + "granted", + "not_determined", + "denied", + "settings_required", + "unsupported" + ] + }, + "scope": { + "const": "host_api_v1_device_access" + }, + "supported": { + "type": "boolean" + } + }, + "required": [ + "capability", + "supported", + "app_policy_granted", + "app_consent", + "os_permission" + ], + "type": "object" + }, + "platforms": [ + "android", + "macos" + ], + "summary": "Read this app's device consent and the OS permission without prompting", + "version": 1 + }, + "implemented": true, + "supported": true + }, + "microphone_allowed": false, + "microphone_error": "this app was not granted \"microphone\", which \"microphone.permission.status\" needs", + "missing": { + "authorization": "unavailable", + "configured": null, + "implemented": false, + "method": "apilab.custom_rust", + "supported": false + } + } + } +} diff --git a/tools/fixtures/host-api-lab/evidence/android/receipt.json b/tools/fixtures/host-api-lab/evidence/android/receipt.json new file mode 100644 index 00000000..40950694 --- /dev/null +++ b/tools/fixtures/host-api-lab/evidence/android/receipt.json @@ -0,0 +1,36 @@ +{ + "schema": 1, + "platform": "android", + "model": "ONEPLUS A6003", + "android": "15", + "package": "dev.makepad.octosense.hostapilab", + "apk_sha256": "14b3dd6ea65338927da3f321e359f284dd4cc422fcc72340f82884530283ea65", + "personal_accounts_used": false, + "model_started": false, + "checks": { + "signed_native_android_install": true, + "real_script_tool_completed": true, + "bound_device_account": true, + "native_camera_status": true, + "declared_capability_is_not_consent": true, + "runtime_api_discovery": true, + "uncompiled_rust_function_refused": true, + "undeclared_microphone_refused": true, + "background_permission_prompt_refused": true, + "no_native_approval_sheet": true, + "cross_account_refused": true, + "undeclared_tool_refused": true, + "invalid_arguments_refused": true, + "closed_tool_endpoint_refused": true + }, + "passed": true, + "not_verified": [ + "model or peer relay consent", + "physical OS permission approval", + "camera capture" + ], + "owned_package_stopped": true, + "runtime_tree": "7133ac8e40a37402cb30f623e613683ca2c54f91", + "tested_source_base": "9f8ce11c7cc890aa77131821c42a0ae61b46fc54", + "fixture_source": "Android adaptation in this change" +} diff --git a/tools/test-host-api-android.py b/tools/test-host-api-android.py new file mode 100644 index 00000000..5673577f --- /dev/null +++ b/tools/test-host-api-android.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Run the signed Host API Lab on an assigned Android phone in a fresh package. + +No account, model, permission grant, camera capture or production Home change. +The native host uses the same app-tool/host-service code as the desktop lab. +""" +import argparse +import hashlib +import io +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import tarfile +import time + +ROOT = Path(__file__).resolve().parents[1] + + +def main(): + os.umask(0o077) + parser = argparse.ArgumentParser(description=__doc__) + for key in ('adb', 'apk', 'aapt2', 'hub', 'out'): + parser.add_argument('--' + key, type=Path, required=True) + parser.add_argument('--serial', help='Assigned phone only; omitted from evidence') + parser.add_argument('--package', default='dev.makepad.octosense.hostapilab') + args = parser.parse_args() + if not re.fullmatch(r'dev\.makepad\.octosense\.hostapilab(?:\.[a-z0-9_]+)*', args.package): + parser.error('Use a separate hostapilab package, never Home') + args.out.mkdir(mode=0o700, parents=True, exist_ok=False) + base = [str(args.adb)] + (['-s', args.serial] if args.serial else []) + + def adb(*values, data=None, checked=True): + return subprocess.run(base + list(values), input=data, check=checked, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=45) + + devices = adb('devices').stdout.decode().splitlines()[1:] + if not args.serial and sum(line.endswith('\tdevice') for line in devices) != 1: + parser.error('Select the assigned phone with --serial') + model = adb('shell', 'getprop', 'ro.product.model').stdout.decode().strip() + android = adb('shell', 'getprop', 'ro.build.version.release').stdout.decode().strip() + present = adb('shell', 'pm', 'path', args.package, checked=False) + if present.returncode not in (0, 1) or present.stderr.strip() or present.stdout.strip(): + parser.error('Expected a fresh, absent package; select a new suffix') + badging = subprocess.run([str(args.aapt2), 'dump', 'badging', str(args.apk)], + check=True, capture_output=True).stdout.decode() + if f"name='{args.package}'" not in badging or 'application-debuggable' not in badging: + parser.error('APK must match the separate debuggable package') + bundle = args.out / 'bundle' + shutil.copytree(ROOT / 'tools/fixtures/host-api-lab/bundle', bundle) + listing = json.loads((bundle / 'listing.json').read_text()) + listing['platforms'] = ['android'] + (bundle / 'listing.json').write_text(json.dumps(listing, indent=2) + '\n') + subprocess.run([str(args.hub), 'stamp', str(bundle)], check=True, capture_output=True) + archive = io.BytesIO() + with tarfile.open(fileobj=archive, mode='w') as tar: + for path in sorted(bundle.rglob('*')): + if path.is_symlink() or not (path.is_file() or path.is_dir()): + raise ValueError('Fixture accepts regular files and directories only') + tar.add(path, arcname='host-api-lab/bundle/' + str(path.relative_to(bundle)), recursive=False) + installed = False + receipt = {'schema': 1, 'platform': 'android', 'model': model, 'android': android, + 'package': args.package, 'apk_sha256': hashlib.sha256(args.apk.read_bytes()).hexdigest(), + 'personal_accounts_used': False, 'model_started': False, 'checks': {}} + try: + adb('install', str(args.apk)) + installed = True + adb('shell', 'run-as', args.package, 'mkdir', '-p', 'files') + adb('shell', '-T', 'run-as', args.package, 'tar', '-xf', '-', '-C', 'files', data=archive.getvalue()) + adb('shell', 'am', 'start', '-n', args.package + '/.MakepadApp') + deadline = time.monotonic() + 90 + while time.monotonic() < deadline: + result = adb('exec-out', 'run-as', args.package, 'cat', + 'files/host-api-lab/receipt.json', checked=False) + if result.returncode == 0: + try: + native = json.loads(result.stdout) + break + except ValueError: + pass + time.sleep(.25) + else: + raise AssertionError('Native phone host did not produce a receipt') + (args.out / 'native-result.json').write_text(json.dumps(native, indent=2) + '\n') + checks = receipt['checks'] + + def require(name, condition): + checks[name] = bool(condition) + if not condition: + raise AssertionError(name) + + require('signed_native_android_install', native['signed_install'] is True and native['platform'] == 'android') + require('real_script_tool_completed', 'Ok' in native['tool_result']) + data = native['tool_result']['Ok'] + require('bound_device_account', data['account'] == 'device') + require('native_camera_status', data['camera']['supported'] is True and data['camera']['os_permission'] in ('granted', 'not_determined', 'denied', 'settings_required')) + require('declared_capability_is_not_consent', data['camera']['app_policy_granted'] is True and data['camera']['app_consent'] is False) + require('runtime_api_discovery', data['discovery']['supported'] is True and data['discovery']['descriptor']['name'] == 'camera.permission.status') + require('uncompiled_rust_function_refused', data['missing']['implemented'] is False and data['missing']['supported'] is False) + require('undeclared_microphone_refused', data['microphone_allowed'] is False and data['microphone_error']) + require('background_permission_prompt_refused', data['background_prompt_allowed'] is False and 'background' in data['background_error']) + require('no_native_approval_sheet', native['host_sheet_visible'] is False) + require('cross_account_refused', 'account_scope' in native['refusals']['wrong_account']) + require('undeclared_tool_refused', bool(native['refusals']['undeclared_tool'])) + require('invalid_arguments_refused', bool(native['refusals']['invalid_arguments'])) + require('closed_tool_endpoint_refused', bool(native['closed_app'])) + receipt['passed'] = True + receipt['not_verified'] = native['not_verified'] + except Exception as error: + receipt['passed'] = False + # Subprocess exceptions include their command (possibly --serial and + # private build paths). Only our fixed assertion messages are public. + receipt['error'] = str(error) if isinstance(error, AssertionError) else type(error).__name__ + raise + finally: + if installed: + try: + receipt['owned_package_stopped'] = adb( + 'shell', 'am', 'force-stop', args.package, checked=False).returncode == 0 + except subprocess.SubprocessError: + receipt['owned_package_stopped'] = False + (args.out / 'receipt.json').write_text(json.dumps(receipt, indent=2) + '\n') + print(json.dumps({'passed': receipt['passed'], 'checks': len(receipt['checks']), + 'owned_package_stopped': receipt['owned_package_stopped']})) + + +if __name__ == '__main__': + main() From 3ac62612ece41ba036453dbb6ff676652dcea0c3 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:46:17 -0700 Subject: [PATCH 17/35] test: exercise native Windows OAuth credential storage --- .github/workflows/platform-vault.yml | 43 ++++++++++++++++++++++++++++ tools/ci_local.py | 2 ++ 2 files changed, 45 insertions(+) create mode 100644 .github/workflows/platform-vault.yml diff --git a/.github/workflows/platform-vault.yml b/.github/workflows/platform-vault.yml new file mode 100644 index 00000000..fbc20942 --- /dev/null +++ b/.github/workflows/platform-vault.yml @@ -0,0 +1,43 @@ +name: Platform credential vault + +on: + pull_request: + paths: + - 'crates/oauth-service/**' + - 'Cargo.toml' + - 'Cargo.lock' + - 'native-runtime.lock.json' + - 'runtime-patches.lock.json' + - 'tools/runtime-patches/**' + - '.github/workflows/platform-vault.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: platform-vault-${{ github.ref }} + cancel-in-progress: true + +jobs: + windows: + runs-on: windows-2022 + timeout-minutes: 40 + steps: + - name: Preserve runtime patch bytes + run: git config --global core.autocrlf false + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - uses: dtolnay/rust-toolchain@stable + - run: python tools/setup.py + - uses: Swatinem/rust-cache@v2 + with: + shared-key: embedded-browser-windows + cache-on-failure: true + - name: Actual Windows Credential Manager lifecycle + # A fresh synthetic profile on a disposable runner. Missing/unusable + # native credentials are a failure, never replaced by a file vault. + # This checks storage/revocation, not provider login or user approval. + run: cargo test --locked --release -p octosense-oauth-service --features host host_vault_acceptance::platform_vault_persists_across_reopen_without_plaintext_credentials -- --ignored --exact --nocapture diff --git a/tools/ci_local.py b/tools/ci_local.py index e0a3371d..2095950f 100644 --- a/tools/ci_local.py +++ b/tools/ci_local.py @@ -71,6 +71,8 @@ # Workflows ci-local deliberately does not run, and why. --check-drift skips # them (an entry naming no workflow, or one ci-local runs, is drift). NOT_LOCAL = { + "platform-vault.yml": "actual Windows Credential Manager acceptance needs an unlocked Windows session; " + "compilation or another OS is not native-vault evidence", "embedded-browser.yml": "native Windows WebView2 acceptance needs a Windows desktop and installed engine; " "ci-local-merge requires a successful GitHub run on the exact PR head", "release-desktop.yml": "a release workflow (tag push or manual run): signed packages for three OSes, " From f6a3104cb244cf08638b5e9cfcb5fdc8349dd2fb Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:04:58 -0700 Subject: [PATCH 18/35] Pin App Hub developer admission and media policy fixes --- Cargo.lock | 8 ++++---- Cargo.toml | 8 ++++---- native-apps.json | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index eeea0f35..5e10c416 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7884,7 +7884,7 @@ dependencies = [ [[package]] name = "octosense-app-hub" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=2e5768964f15916cdb66cc6824f7671dafc6445a#2e5768964f15916cdb66cc6824f7671dafc6445a" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=8885c3755bb56f083c4df3e6b539b58287433895#8885c3755bb56f083c4df3e6b539b58287433895" dependencies = [ "base64 0.22.1", "blake3", @@ -7903,7 +7903,7 @@ dependencies = [ [[package]] name = "octosense-app-hub-app" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=2e5768964f15916cdb66cc6824f7671dafc6445a#2e5768964f15916cdb66cc6824f7671dafc6445a" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=8885c3755bb56f083c4df3e6b539b58287433895#8885c3755bb56f083c4df3e6b539b58287433895" dependencies = [ "makepad-app-module", "makepad-widgets", @@ -7931,7 +7931,7 @@ dependencies = [ [[package]] name = "octosense-app-policy" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=2e5768964f15916cdb66cc6824f7671dafc6445a#2e5768964f15916cdb66cc6824f7671dafc6445a" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=8885c3755bb56f083c4df3e6b539b58287433895#8885c3755bb56f083c4df3e6b539b58287433895" dependencies = [ "makepad-widgets", "octosense-app-contract", @@ -7953,7 +7953,7 @@ dependencies = [ [[package]] name = "octosense-appstore" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=2e5768964f15916cdb66cc6824f7671dafc6445a#2e5768964f15916cdb66cc6824f7671dafc6445a" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=8885c3755bb56f083c4df3e6b539b58287433895#8885c3755bb56f083c4df3e6b539b58287433895" dependencies = [ "makepad-app-module", "makepad-widgets", diff --git a/Cargo.toml b/Cargo.toml index 51d51847..6a7dd37c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,7 +73,7 @@ octosense-reference = { path = "apps/reference" } makepad-sheets = { git = "https://github.com/OctoSense-org/makepad.git", rev = "32d6415fb7476345ad36ee4f98d6f844d1f07fd2", default-features = false } makepad-terminal = { git = "https://github.com/OctoSense-org/makepad.git", rev = "32d6415fb7476345ad36ee4f98d6f844d1f07fd2" } octosense-appcard = { path = "apps/appcard/module" } -octosense-app-hub-app = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "2e5768964f15916cdb66cc6824f7671dafc6445a" } +octosense-app-hub-app = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "8885c3755bb56f083c4df3e6b539b58287433895" } makepad-calculator = { git = "https://github.com/OctoSense-org/makepad.git", rev = "32d6415fb7476345ad36ee4f98d6f844d1f07fd2", default-features = false } makepad-clock = { git = "https://github.com/OctoSense-org/makepad.git", rev = "32d6415fb7476345ad36ee4f98d6f844d1f07fd2", default-features = false } makepad-notes = { git = "https://github.com/OctoSense-org/makepad.git", rev = "32d6415fb7476345ad36ee4f98d6f844d1f07fd2", default-features = false } @@ -108,12 +108,12 @@ octoscript-makepad = { git = "https://github.com/OctoSense-org/Octoscript-Makepa octoscript-node = { git = "https://github.com/OctoSense-org/Octoscript-Makepad.git", rev = "33dea2f1f3ad3f1346a219aa8cf6e91b31361e23" } # --- App Hub's host service registry the Mail and llm services register # into; the same revision as App Hub's app crate (a native app, above). -octosense-appstore = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "2e5768964f15916cdb66cc6824f7671dafc6445a" } +octosense-appstore = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "8885c3755bb56f083c4df3e6b539b58287433895" } # The app policy (resolution, isolate settings, the Splash adapter) and the # Hub client the Card runner resolves an app's policy with; the shell's # glance tiles run cards under it (crates/shell/src/glance_card.rs). -octosense-app-policy = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "2e5768964f15916cdb66cc6824f7671dafc6445a" } -octosense-app-hub = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "2e5768964f15916cdb66cc6824f7671dafc6445a" } +octosense-app-policy = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "8885c3755bb56f083c4df3e6b539b58287433895" } +octosense-app-hub = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "8885c3755bb56f083c4df3e6b539b58287433895" } # The app contract (ADR 0005), from crates.io by version: App Hub, the shell # and Rinx all depend on `octosense-app-contract = "1"`, so Cargo links ONE # 1.x for the whole build (Cargo.lock pins the exact version OctoSense ships) diff --git a/native-apps.json b/native-apps.json index bebf9363..22d16cce 100644 --- a/native-apps.json +++ b/native-apps.json @@ -100,7 +100,7 @@ "id": "apphub", "feature": "app-hub", "crate": "octosense-app-hub-app", - "source": { "git": "https://github.com/OctoSense-org/OctoSense-App-Hub", "rev": "2e5768964f15916cdb66cc6824f7671dafc6445a", "local": null }, + "source": { "git": "https://github.com/OctoSense-org/OctoSense-App-Hub", "rev": "8885c3755bb56f083c4df3e6b539b58287433895", "local": null }, "module": ["octosense_app_hub_app::APP_HUB_MODULE", "octosense_app_hub_app::CARD_MODULE"], "bin": null, "default_features": true, From ea55dedefca590d03f77f6e99ebf4e7b0763fdab Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:11:57 -0700 Subject: [PATCH 19/35] test: exercise native backend login with real desktop browsers --- .github/workflows/platform-vault.yml | 37 +++++- tools/ci_local.py | 4 +- tools/connected-e2e/README.md | 37 ++++++ tools/connected-e2e/README.zh-CN.md | 30 +++++ tools/connected-e2e/backend_login.py | 30 +++-- .../evidence/linux-backend-native.json | 86 +++++++++++++ tools/connected-e2e/webdriver_browser.py | 119 ++++++++++++++++++ 7 files changed, 331 insertions(+), 12 deletions(-) create mode 100644 tools/connected-e2e/evidence/linux-backend-native.json create mode 100644 tools/connected-e2e/webdriver_browser.py diff --git a/.github/workflows/platform-vault.yml b/.github/workflows/platform-vault.yml index fbc20942..9b8bd724 100644 --- a/.github/workflows/platform-vault.yml +++ b/.github/workflows/platform-vault.yml @@ -1,9 +1,17 @@ -name: Platform credential vault +name: Platform accounts on: pull_request: paths: - 'crates/oauth-service/**' + - 'crates/shell/examples/connected-app-host.rs' + - 'crates/shell/examples/connected-install.rs' + - 'crates/shell/examples/connected_support/**' + - 'tools/connected-e2e/backend_login.py' + - 'tools/connected-e2e/webdriver_browser.py' + - 'tools/connected-e2e/native.py' + - 'tools/connected-e2e/backend-login/**' + - 'tools/backend-login-fixture.py' - 'Cargo.toml' - 'Cargo.lock' - 'native-runtime.lock.json' @@ -22,7 +30,10 @@ concurrency: jobs: windows: runs-on: windows-2022 - timeout-minutes: 40 + timeout-minutes: 55 + env: + PYTHONUTF8: '1' + MAKEPAD_D3D11_WARP: '1' steps: - name: Preserve runtime patch bytes run: git config --global core.autocrlf false @@ -41,3 +52,25 @@ jobs: # native credentials are a failure, never replaced by a file vault. # This checks storage/revocation, not provider login or user approval. run: cargo test --locked --release -p octosense-oauth-service --features host host_vault_acceptance::platform_vault_persists_across_reopen_without_plaintext_credentials -- --ignored --exact --nocapture + - name: Build real connected-app host and signed installer + run: | + cargo build --locked --release -p octosense-shell --features acceptance-fixtures --example connected-app-host --example connected-install + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + cargo build --locked --release -p octosense-app-hub --bin hub + - name: Browser automation client only + # Use the runner's existing Edge browser. No downloaded browser engine, + # developer profile, provider account, or credential injection. + run: python -m pip install 'playwright==1.55.0' + - name: Actual synthetic backend browser and callback + run: | + $edge = Join-Path ${env:ProgramFiles(x86)} 'Microsoft/Edge/Application/msedge.exe' + if (-not (Test-Path $edge)) { throw 'The runner must already provide Edge' } + python tools/connected-e2e/backend_login.py --binary target/release/examples/connected-app-host.exe --installer target/release/examples/connected-install.exe --hub target/release/hub.exe --chrome "$edge" --out "$env:RUNNER_TEMP/backend-login" + - uses: actions/upload-artifact@v7 + if: always() + with: + name: windows-backend-account-receipt + if-no-files-found: ignore + # Never upload the whole private directory: it contains OAuth URLs, + # fictional tokens and raw failure diagnostics. + path: ${{ runner.temp }}/backend-login/receipt.json diff --git a/tools/ci_local.py b/tools/ci_local.py index 2095950f..23279605 100644 --- a/tools/ci_local.py +++ b/tools/ci_local.py @@ -71,8 +71,8 @@ # Workflows ci-local deliberately does not run, and why. --check-drift skips # them (an entry naming no workflow, or one ci-local runs, is drift). NOT_LOCAL = { - "platform-vault.yml": "actual Windows Credential Manager acceptance needs an unlocked Windows session; " - "compilation or another OS is not native-vault evidence", + "platform-vault.yml": "actual Windows Credential Manager and backend browser acceptance need a Windows session; " + "compilation or another OS is not native account evidence", "embedded-browser.yml": "native Windows WebView2 acceptance needs a Windows desktop and installed engine; " "ci-local-merge requires a successful GitHub run on the exact PR head", "release-desktop.yml": "a release workflow (tag push or manual run): signed packages for three OSes, " diff --git a/tools/connected-e2e/README.md b/tools/connected-e2e/README.md index 2f88be0a..873a31af 100644 --- a/tools/connected-e2e/README.md +++ b/tools/connected-e2e/README.md @@ -144,3 +144,40 @@ copy. No provider write occurs. Normal Notes admission/provider and soak drivers remain the commands above. Android developer-backend login has its own isolated package and real-form procedure: [Android backend acceptance](ANDROID-BACKEND.md). It does not authorize Google embedded login. + +## Native backend browser acceptance (2026-10-08) + +`backend_login.py` uses a real host, browser and operating-system credential +vault. Only the backend HTTP server and its fictional users are synthetic; no +account or token is injected into the host. Linux and Windows use the supported +external-browser route with an ephemeral loopback callback. Embedded WebReader +acceptance is a separate test and does not establish backend sign-in. + +The [Linux receipt](evidence/linux-backend-native.json) records seven successful +behaviors: browser registration/sign-in and PKCE callback, protected identity, +rotated refresh recovery, native restart, logout, repeat login and isolation +between two signed apps. MiniBrowser/WebKitGTK 2.52.6 and native Secret Service +ran in an isolated display/session; all owned processes and fictional +connections were cleaned up. The receipt binds the actual binary and source +hashes. Its archived Cargo lock predates the later Wasm integration; the 35 +other fixture/OAuth source files match this acceptance change. This is not an +exact-head whole-shell build claim. + +Build the host/installer as above and the pinned Hub CLI with +`cargo build --locked --release -p octosense-app-hub --bin hub`. With an existing +isolated WebKitWebDriver, invoke `backend_login.py` with `--binary`, `--installer`, +`--hub`, `--webdriver http://127.0.0.1:PORT` and a new private `--out` directory. +The Linux run used that interface with the real engine and normal host vault. +`--chrome PATH` instead uses an existing Chromium browser through Playwright. +The `Platform accounts` workflow exercises the Windows native vault and this +same browser/callback journey using the runner's existing Edge installation. +The vault job passed; the added Windows browser journey is pending execution. + +The acceptance host copies its own consent-sheet link into a private test +file, and the harness opens that exact URL in a fresh browser. **The OS default +browser link click is not tested.** Synthetic Makepad clicks cannot approve a +business write; these tests do not prove a physical press or OS-authenticated +approval. The disposable fictional account is not Google/GitHub provider +acceptance. Keep the entire run directory private: raw callback URLs and +fictional tokens are not suitable for public artifacts. Publish only reviewed +receipts. diff --git a/tools/connected-e2e/README.zh-CN.md b/tools/connected-e2e/README.zh-CN.md index 1debe4f2..e2a5ba24 100644 --- a/tools/connected-e2e/README.zh-CN.md +++ b/tools/connected-e2e/README.zh-CN.md @@ -116,3 +116,33 @@ python3 tools/connected-e2e/notes_recovery.py \ 测试不执行提供商写入;正常准入、提供商流程和浸泡测试继续使用上面的命令。 Android 开发者后端登录使用独立测试包和真实表单,参见 [Android 后端验收](ANDROID-BACKEND.zh-CN.md)。这不代表支持 Google 嵌入式登录。 + +## 原生后端浏览器验收(2026-10-08) + +`backend_login.py` 使用真实宿主、浏览器和操作系统凭据库。只有后端 HTTP +服务及虚构用户是模拟数据;不会向宿主注入账户或 token。Linux 和 Windows +使用受支持的外部浏览器路径与临时回环回调。嵌入式 WebReader 的验收是另一个 +测试,不能证明后端登录可用。 + +[Linux 回执](evidence/linux-backend-native.json) 记录七项成功行为:浏览器注册/ +登录与 PKCE 回调、受保护身份读取、轮换刷新恢复、原生进程重启、退出登录、 +再次登录,以及两个签名应用之间的账户隔离。MiniBrowser/WebKitGTK 2.52.6 +和原生 Secret Service 在隔离显示/会话中运行;所有测试进程与虚构连接均已清理。 +回执绑定实际二进制及源码哈希。归档 Cargo 锁文件早于之后的 Wasm 集成,其余 +35 个测试/OAuth 源文件与本次验收变更一致。这不表示整个 shell 的当前提交构建 +已通过。 + +按前文构建宿主/安装器,并用 +`cargo build --locked --release -p octosense-app-hub --bin hub` 构建固定版本 Hub +命令行。启动已有的隔离 WebKitWebDriver 后,以 `--binary`、`--installer`、 +`--hub`、`--webdriver http://127.0.0.1:PORT` 和新的私有 `--out` 目录调用 +`backend_login.py`。Linux 验收使用此接口、真实引擎及普通宿主凭据库。 +`--chrome PATH` 则通过 Playwright 使用已有 Chromium 浏览器。 +`Platform accounts` 工作流使用执行器已有的 Edge,验证 Windows 原生凭据库 +及同一浏览器/回调流程。凭据库测试已通过,新增浏览器流程仍待执行。 + +验收宿主把其原生授权页中的链接复制到私有测试文件,测试器在新浏览器中打开 +完全相同的 URL。**未测试操作系统默认浏览器链接的点击。** Makepad 合成点击 +不能批准业务写入;这些测试不证明物理点击或操作系统身份认证批准。一次性 +虚构账户也不等同于 Google/GitHub 提供商验收。整个运行目录须保密:原始回调 +URL 及虚构 token 不适合作为公开工件,只发布审查过的回执。 diff --git a/tools/connected-e2e/backend_login.py b/tools/connected-e2e/backend_login.py index a4186be2..e762d2e0 100644 --- a/tools/connected-e2e/backend_login.py +++ b/tools/connected-e2e/backend_login.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 -"""Real native host + isolated Chrome + synthetic HTTP backend acceptance. +"""Real native host + isolated browser + synthetic HTTP backend acceptance. -Run with a Python environment containing Playwright, and an existing Chrome. +Use Playwright with an existing Chromium browser, or a loopback W3C WebDriver. No real provider sign-in is automated. All account creation occurs in the browser, and all token exchanges, vault persistence and revocation in the host. The private run directory is never an artifact to publish wholesale. @@ -14,6 +14,7 @@ from pathlib import Path import shutil import subprocess +import sys import time import urllib.error import urllib.parse @@ -71,7 +72,7 @@ def bundle(path, app, hub): listing = {'schema': 1, 'subtitle': 'Internal browser login acceptance', 'description': 'Private test fixture; not a published app. Placeholder listing artwork is not UX evidence.', 'category': 'productivity', 'screenshots': ['fixture.svg'], 'icon': 'fixture.svg', - 'platforms': ['macos'], 'publisher': {'name': 'Acceptance fixture', + 'platforms': [{'darwin': 'macos', 'win32': 'windows'}.get(sys.platform, 'linux')], 'publisher': {'name': 'Acceptance fixture', 'support': 'https://example.test/support', 'privacy_policy_url': 'https://example.test/privacy'}, 'age_rating': 'all', 'license': 'Apache-2.0'} (path / 'listing.json').write_text(json.dumps(listing, indent=2)) @@ -83,25 +84,36 @@ def main(): parser.add_argument('--binary', type=Path, required=True) parser.add_argument('--installer', type=Path, required=True) parser.add_argument('--hub', type=Path, required=True) - parser.add_argument('--chrome', type=Path, required=True) + browser = parser.add_mutually_exclusive_group(required=True) + browser.add_argument('--chrome', type=Path) + browser.add_argument('--webdriver', help='Existing isolated W3C WebDriver endpoint for MiniBrowser or Edge') + parser.add_argument('--webdriver-browser', default='MiniBrowser', choices=['MiniBrowser', 'MicrosoftEdge']) parser.add_argument('--out', type=Path, required=True, help='NEW private run directory') args = parser.parse_args() - from playwright.sync_api import sync_playwright + if args.webdriver: + from webdriver_browser import automation + sync_playwright = lambda: automation(args.webdriver, args.webdriver_browser) + else: + from playwright.sync_api import sync_playwright os.umask(0o077) run = args.out.resolve() run.mkdir(mode=0o700, parents=True, exist_ok=False) os.environ['RINX_DATA_DIR'] = str(run / 'rinx') pixels = run / 'evidence' pixels.mkdir() + revision = subprocess.run(['git', 'rev-parse', 'HEAD'], cwd=ROOT, + text=True, capture_output=True) receipt = {'schema': 1, 'result': 'running', 'started_utc': datetime.now(timezone.utc).isoformat(), 'case': 'Real native backend login with synthetic server and actual browser callbacks', 'provider': 'synthetic HTTP loopback; not live GitHub or Google', 'vault': 'normal platform credential vault; no preseeded accounts or tokens', - 'browser_handoff': 'host-only LinkLabel URL copied privately by test example; exact URL opened in fresh Chrome context; OS-default-browser click omitted', + 'browser_handoff': 'host-only LinkLabel URL copied privately by test example; exact URL opened in a fresh browser context; OS-default-browser click omitted', + 'browser_engine': args.webdriver_browser if args.webdriver else args.chrome.name, 'installation': 'ephemeral fixture signing, actual Store install and prepared launch', 'input': 'Makepad native instrument, default input acknowledgement, no input replay', 'binary_sha256': sha(args.binary), 'installer_sha256': sha(args.installer), - 'runtime_base_commit': subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT, text=True).strip(), + 'runtime_base_commit': revision.stdout.strip() if revision.returncode == 0 else None, + 'source_identity': 'Git revision plus file hashes' if revision.returncode == 0 else 'Archived source: file hashes below are authoritative', 'visual_review': 'pending', 'checks': [], 'instrument_events': []} source_files = [Path(__file__).resolve(), ROOT / 'tools/backend-login-fixture.py', ROOT / 'tools/connected-e2e/native.py', @@ -109,6 +121,8 @@ def main(): ROOT / 'crates/shell/examples/connected-app-host.rs', ROOT / 'crates/shell/examples/connected_support/mod.rs', ROOT / 'Cargo.lock'] source_files += sorted((ROOT / 'crates/oauth-service/src').rglob('*.rs')) + if args.webdriver: + source_files.append(ROOT / 'tools/connected-e2e/webdriver_browser.py') receipt['source_sha256'] = {str(p.relative_to(ROOT)): sha(p) for p in source_files} server_log = (run / 'server.log').open('w') server = subprocess.Popen(['python3', str(ROOT / 'tools/backend-login-fixture.py'), @@ -287,7 +301,7 @@ def login(capture, register=False, label='login'): native.capture('FAILURE') except Exception: pass - raise + raise SystemExit('Backend acceptance failed; inspect the private error file') from None finally: if native: receipt['instrument_events'] += native.actions diff --git a/tools/connected-e2e/evidence/linux-backend-native.json b/tools/connected-e2e/evidence/linux-backend-native.json new file mode 100644 index 00000000..732f79aa --- /dev/null +++ b/tools/connected-e2e/evidence/linux-backend-native.json @@ -0,0 +1,86 @@ +{ + "schema": 1, + "result": "PASS", + "case": "Real native backend login with synthetic server and actual browser callbacks", + "provider": "synthetic HTTP loopback; not live GitHub or Google", + "vault": "normal platform credential vault; no preseeded accounts or tokens", + "browser_handoff": "host-only LinkLabel URL copied privately by test example; exact URL opened in a fresh browser context; OS-default-browser click omitted", + "browser_engine": "MiniBrowser", + "installation": "ephemeral fixture signing, actual Store install and prepared launch", + "input": "Makepad native instrument, default input acknowledgement, no input replay", + "binary_sha256": "d9695098fb0d79f2300d9d504e54ea3e32fc9514c5c70a64ff2ff4eef8e65a05", + "installer_sha256": "59b284d24c2c12c5fd63a220c78dd338cbee01beeccbfca5297a2c39dfc21d35", + "source_identity": "Archived source: file hashes below are authoritative", + "checks": [ + "Short-lived token forces refresh; transient protected-data failure followed by another refresh recovers using durably stored rotated credential", + "Fresh browser registration, password sign-in, real PKCE callback and protected identity", + "Native process restart restores same app-bound connection from platform vault and reloads protected data", + "Logout durably removes connection and protected identity is denied", + "Existing fictional user signs in again through browser without registration; fresh connection issued", + "Second signed app cannot access first handle before or after its own genuine browser login", + "Both fictional handles durably revoked through real host service; platform-vault deletion requested (deletion not independently read back)" + ], + "source_sha256": { + "tools/connected-e2e/backend_login.py": "bacda9960c4f8b25735ffab76d61fb518cc99efeb5af63b8aa019df281ffc79a", + "tools/backend-login-fixture.py": "5c2695ad6da5d82b0c41782cefd77392130cc8c362ecf1170b87ea4512000424", + "tools/connected-e2e/native.py": "3182c430d9b3797a823a67403543d056891598b404cf7fda577836f09e7ae7b6", + "tools/connected-e2e/backend-login/main.splash": "f2953891acde9ab08625c14e5a7d5dd73e809c1702777a9229276cbc5cee24ac", + "crates/shell/examples/connected-app-host.rs": "5e960ac06a96c5302f5f89e72ee6fd45bcfbf4c0a033f94ee67e1cb7a8bb6d92", + "crates/shell/examples/connected_support/mod.rs": "5e6a567808dfe34b21d9348afe9345a52ec9165f747504c623bdc9af6898ab2d", + "Cargo.lock": "e23635cea386bb4dab2f922fe3e0210fdfa09103447f2a503c162d3f48696daa", + "crates/oauth-service/src/acceptance_calendar.rs": "e3f03818b554cbfdd1f7d9bcf22272e8ec46c25bffc69fd4498c690c137446ac", + "crates/oauth-service/src/acceptance_fixtures.rs": "af8b2c8187a35015ea42b59aa7b2ca8e5c4e4834efa0144259ca691f00bcfdf0", + "crates/oauth-service/src/acceptance_github.rs": "520fd3681f4831c35cf671f0d4883af8c7d9e63e748ec41ede96d54fcaa77c7d", + "crates/oauth-service/src/acceptance_inbox.rs": "efc33661cbb2801037e6db9a1416a8b7b480a549e05dbe790ba2338dfb037162", + "crates/oauth-service/src/api.rs": "0d2f6edb38874709b0ef8affc8602896f8065e59af3d6eff97b33daebb0cd13e", + "crates/oauth-service/src/api_tests.rs": "6f4809c941b048717d001a80190ad7a50db49cf24f38f5454d8cb2249c7d72c3", + "crates/oauth-service/src/authorize.rs": "cfcea9157e678e53d9787a03d4a46a5f1b27804a5596ff11aa1000e9ea30af4b", + "crates/oauth-service/src/backend.rs": "93a5ea1227936e3394239398a8f39294729fd3e0912340e1db6b6a701d294cbb", + "crates/oauth-service/src/backend_registry.rs": "dff1937066c90ab54cd65c22f3cf1499e7c20d55f7d7dc5d8c774fc151682aec", + "crates/oauth-service/src/backend_tests.rs": "9e45303b92bfb5992e25d844f1affdc9a556dc9c2d51effb16d1e15fbdcb603c", + "crates/oauth-service/src/calendar_cache.rs": "91efe0916a4fa73c3f36756cbbd6359c5ecc1122f7e090343b0dde3773d601e2", + "crates/oauth-service/src/host.rs": "401a3810d2f76ec8cbaf62b344f060ed9b073c751500fc8143eb7398bf8fd069", + "crates/oauth-service/src/host_api.rs": "a38e2fd7b0b24eda2d8c0f974b4032551e1286a905273eb6772d0475836da418", + "crates/oauth-service/src/host_backend.rs": "1839063da9a9f7534e79839fc807abe3b64143efab998678599a699604a8d066", + "crates/oauth-service/src/host_catalog.rs": "fb7cc2a019b56dc230009d4aa36168734b3bf71c3cd378584f2f47b4a016774e", + "crates/oauth-service/src/host_inbox.rs": "aa69422fd3fbe504035eb60fe4203222ebb9f1877884f0dbe77143f864c48ad8", + "crates/oauth-service/src/host_lifetime_tests.rs": "50904d91121ae3f16caf4c8c9008c66f9cd6b85e2af54558451be683079c662b", + "crates/oauth-service/src/host_operation_tests.rs": "ba0dc9cbbbcc592b1268be23d2e45e0cd8faa3e1ff7f202090cc7d1df3f45526", + "crates/oauth-service/src/host_vault_acceptance.rs": "ff672463d37614b7f64f20a92c5e0e0a3de213dacb7b478d81d1bc894b6afe2a", + "crates/oauth-service/src/inbox.rs": "ec34c7fe90e6c0d43c8d785a9e2443aec234fafb6356a4759b2b6992ff88437d", + "crates/oauth-service/src/inbox_events.rs": "a795b00448ff0b679b019bdaf5df1e8bdffe754bcd46c0a2cf55b30e4a906af9", + "crates/oauth-service/src/lib.rs": "c7f60fcd66072440635aeb475e3ea4fc95899470fe308e9ae30e6840b41ab79e", + "crates/oauth-service/src/oauth.rs": "b3a7edd30fcb0235db93befbea8653847c9899ed26d0681e070e48e6077d9318", + "crates/oauth-service/src/protocol.rs": "5495da22f0d50b8f89f8840bcfad1b8e2ec3227e9564f96e1552a4b7705917f0", + "crates/oauth-service/src/providers.rs": "a22b9bc5bd54536c0fabee41cd3f84f00a29ef474c64e851fe42ceb02853765d", + "crates/oauth-service/src/registration.rs": "33392b88f2444c9f88794add5b47cb30f9c731340dfba60dd3bec76385da2e62", + "crates/oauth-service/src/store.rs": "4f23735e6a14bc40145d9b1a9720a6efa20f89610bd7942ceab7eaea23fa8bc8", + "crates/oauth-service/src/transport.rs": "1db7518fa5ed853412e2bdddb836d45266c21f9a793d82e3351e7eabaa5d5960", + "tools/connected-e2e/webdriver_browser.py": "b33a93c43ecd3f636f0d70cea00c868128ca9d54c80e895624127f37d8817b9f" + }, + "local_connections_revoked": true, + "vault_cleanup": "Deletion requested through host disconnect; no independent vault readback", + "finished_utc": "2026-10-08T04:11:21.895397+00:00", + "native_processes_stopped": true, + "synthetic_server_stopped": true, + "visual_review": "pending", + "isolated_runtime": { + "webkitgtk": "2.52.6-0ubuntu0.26.04.1", + "webdriver_package_bytes": 529074, + "system_packages_changed": false, + "personal_accounts_used": false, + "owned_runtime_descendants_remaining": 0 + }, + "not_verified": [ + "OS default browser link click", + "live Google/GitHub provider sign-in", + "physical or OS-authenticated business write approval" + ], + "source_comparison": { + "matching_current_files": 35, + "different_files": [ + "Cargo.lock" + ], + "reason": "Tested archived lock predates later Wasm integration; actual tested hash retained, not whole-shell exact-head evidence" + } +} diff --git a/tools/connected-e2e/webdriver_browser.py b/tools/connected-e2e/webdriver_browser.py new file mode 100644 index 00000000..f7170f93 --- /dev/null +++ b/tools/connected-e2e/webdriver_browser.py @@ -0,0 +1,119 @@ +"""Small W3C WebDriver adapter for the synthetic backend acceptance fixture. + +Uses an already-running isolated WebDriver. It neither installs a browser nor +attaches to an existing user profile. Session URLs and provider responses are +never printed; the owning driver keeps failures in its private run directory. +""" +import base64 +import fnmatch +import json +from pathlib import Path +import time +import urllib.error +import urllib.parse +import urllib.request + + +class automation: + def __init__(self, endpoint, browser): + parsed = urllib.parse.urlsplit(endpoint) + if parsed.scheme != 'http' or parsed.hostname not in ('127.0.0.1', 'localhost'): + raise ValueError('WebDriver must be on loopback') + self.endpoint = endpoint.rstrip('/') + self.browser = browser + self.session = None + self.chromium = self + + def __enter__(self): + return self + + def __exit__(self, *_): + self.close() + + def request(self, method, path, data=None): + payload = None if data is None else json.dumps(data).encode() + request = urllib.request.Request(self.endpoint + path, data=payload, + headers={'Content-Type': 'application/json'}, method=method) + try: + with urllib.request.urlopen(request, timeout=30) as response: + body = json.load(response) + except urllib.error.HTTPError as error: + body = json.load(error) + value = body.get('value') + if isinstance(value, dict) and 'error' in value: + raise RuntimeError('WebDriver command failed: ' + value['error']) + return value + + def call(self, method, path, data=None): + return self.request(method, '/session/' + self.session + path, data) + + def launch(self, **_): + caps = {'browserName': self.browser} + if self.browser == 'MiniBrowser': + caps['webkitgtk:browserOptions'] = { + 'binary': '/usr/lib/x86_64-linux-gnu/webkit2gtk-4.1/MiniBrowser', + 'args': ['--automation', '--private']} + else: + caps['ms:edgeOptions'] = {'args': ['--inprivate', '--headless=new']} + result = self.request('POST', '/session', {'capabilities': {'alwaysMatch': caps}}) + self.session = result['sessionId'] + self.call('POST', '/timeouts', {'pageLoad': 30000, 'script': 10000, 'implicit': 0}) + return self + + def new_context(self, viewport): + self.call('POST', '/window/rect', viewport) + return self + + def new_page(self): + return self + + def on(self, *_): + # W3C WebDriver has no portable browser-console subscription. HTTP + # events and native logs remain checked by the acceptance driver. + pass + + def goto(self, url): + self.call('POST', '/url', {'url': url}) + + def locator(self, selector): + return _Element(self, selector) + + def screenshot(self, path): + Path(path).write_bytes(base64.b64decode(self.call('GET', '/screenshot'), validate=True)) + + def wait_for_url(self, pattern): + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + if fnmatch.fnmatchcase(self.call('GET', '/url'), pattern): + return + time.sleep(.1) + raise AssertionError('Browser did not reach the native callback') + + def close(self): + if self.session: + try: + self.call('DELETE', '') + finally: + self.session = None + + +class _Element: + def __init__(self, driver, selector): + self.driver = driver + self.selector = selector + + def path(self): + value = self.driver.call('POST', '/element', {'using': 'css selector', 'value': self.selector}) + identifier = value['element-6066-11e4-a52e-4f735466cecf'] + return '/element/' + urllib.parse.quote(identifier, safe='') + + def fill(self, text): + path = self.path() + self.driver.call('POST', path + '/clear', {}) + self.driver.call('POST', path + '/value', {'text': text}) + + def click(self): + self.driver.call('POST', self.path() + '/click', {}) + + def inner_text(self): + return self.driver.call('GET', self.path() + '/text') From e9765575fcb102ccf2ca7354daab5eb005ce3526 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:40:43 -0700 Subject: [PATCH 20/35] fix: expose supported desktop account methods in runtime discovery --- crates/oauth-service/src/host_catalog.rs | 43 ++++++++++++++++++- tools/connected-e2e/README.md | 12 ++++++ tools/connected-e2e/README.zh-CN.md | 9 ++++ tools/connected-e2e/backend_login.py | 19 ++++++++ .../evidence/linux-backend-native.json | 7 ++- 5 files changed, 88 insertions(+), 2 deletions(-) diff --git a/crates/oauth-service/src/host_catalog.rs b/crates/oauth-service/src/host_catalog.rs index eba3b74c..0495659d 100644 --- a/crates/oauth-service/src/host_catalog.rs +++ b/crates/oauth-service/src/host_catalog.rs @@ -93,7 +93,20 @@ fn method( args, result, ) - .with_platforms(&["macos", "android"]) + // Native account/vault and external-browser routes exist on both desktop + // targets too. Do not hide read/local-draft methods from their agents. + // Write-review methods retain the physical-approval platform boundary; + // a separate OS-authenticated approval adapter must establish the others. + .with_platforms( + if matches!( + name, + "github.review_save" | "gcalendar.review_save" | "gmail.draft.review" + ) { + &["macos", "android"] + } else { + &["macos", "android", "linux", "windows"] + }, + ) .with_agent_access(agent) } fn result() -> Value { @@ -326,6 +339,34 @@ pub(crate) fn gmail(native_review: bool) -> Vec { mod tests { use super::*; #[test] + fn desktop_accounts_reads_and_local_drafts_are_discoverable_without_widening_write_approval() { + let methods = auth() + .into_iter() + .chain(connector("github", true)) + .chain(connector("gcalendar", true)) + .chain(gmail(true)); + let mut count = 0; + for method in methods { + let review = matches!( + method.name.as_str(), + "github.review_save" | "gcalendar.review_save" | "gmail.draft.review" + ); + for platform in ["linux", "windows"] { + assert_eq!( + method.platforms.iter().any(|p| p == platform), + !review, + "{} on {platform}", + method.name + ); + } + if method.name == "auth.backend.request" { + assert!(method.platforms.iter().any(|p| p == "linux")); + } + count += 1; + } + assert!(count > 20); + } + #[test] fn descriptors_are_unique_valid_and_exclude_private_sheet_controls() { let methods = auth() .into_iter() diff --git a/tools/connected-e2e/README.md b/tools/connected-e2e/README.md index 873a31af..f43492e2 100644 --- a/tools/connected-e2e/README.md +++ b/tools/connected-e2e/README.md @@ -181,3 +181,15 @@ approval. The disposable fictional account is not Google/GitHub provider acceptance. Keep the entire run directory private: raw callback URLs and fictional tokens are not suitable for public artifacts. Publish only reviewed receipts. + +The Linux test did not install packages systemwide. Its disposable bwrap mount +namespace overlays the owned extracted browser/vault package trees onto `/usr`, +read-only. Therefore WebDriver's MiniBrowser path resolves to the extracted +engine, not another system browser. The receipt includes both engine hashes. +Linux/Windows account, read and local-draft methods are now advertised in runtime +discovery; native write-review methods retain their physical-approval platform +limits. `auth.backend.request` supports declared GETs there, while mutations +still require the separately available native approval. Google authorization on +Android remains unsupported by the current provider adapter; the OnePlus 6 test +device has no Play Services. A method's platform support does not configure an +OAuth client or approve a remote write. diff --git a/tools/connected-e2e/README.zh-CN.md b/tools/connected-e2e/README.zh-CN.md index e2a5ba24..c09f4fa5 100644 --- a/tools/connected-e2e/README.zh-CN.md +++ b/tools/connected-e2e/README.zh-CN.md @@ -146,3 +146,12 @@ Android 开发者后端登录使用独立测试包和真实表单,参见 [Andr 不能批准业务写入;这些测试不证明物理点击或操作系统身份认证批准。一次性 虚构账户也不等同于 Google/GitHub 提供商验收。整个运行目录须保密:原始回调 URL 及虚构 token 不适合作为公开工件,只发布审查过的回执。 + +Linux 测试未安装系统级软件包。一次性 bwrap 挂载命名空间把测试拥有的浏览器/ +凭据库软件包目录只读叠加到 `/usr`;因此 WebDriver 的 MiniBrowser 路径解析到 +提取出的引擎,而不是另一个系统浏览器。回执包含浏览器与 WebKit 库的哈希。 +运行时发现现在会列出 Linux/Windows 的账户、读取和本地草稿方法;原生写入 +审查方法仍保留物理批准的平台限制。`auth.backend.request` 在这些平台支持声明 +的 GET,变更仍须可用的原生批准。当前提供商适配器仍不支持 Android Google +授权,OnePlus 6 测试设备没有 Play Services。方法支持某个平台不表示已经配置 +OAuth 客户端,更不等于批准远端写入。 diff --git a/tools/connected-e2e/backend_login.py b/tools/connected-e2e/backend_login.py index e762d2e0..463ab133 100644 --- a/tools/connected-e2e/backend_login.py +++ b/tools/connected-e2e/backend_login.py @@ -12,6 +12,7 @@ import json import os from pathlib import Path +import re import shutil import subprocess import sys @@ -147,6 +148,7 @@ def main(): def start(app): nonlocal native, native_count + receipt['stage'] = 'native startup for ' + app if native: receipt['instrument_events'] += native.actions native.check_logs() @@ -160,6 +162,7 @@ def start(app): return capture def state(expected): + receipt['stage'] = 'waiting for ' + expected native.wait(lambda: (native.find(identifier='status', kind='Label') or {}).get('t') == expected) def me(): @@ -176,6 +179,7 @@ def handle(app): return next(value['handle'] for value in entries.values() if value['app_id'] == app) with sync_playwright() as playwright: + receipt['stage'] = 'launching isolated browser' browser = playwright.chromium.launch(executable_path=str(args.chrome), headless=True) context = browser.new_context(viewport={'width': 1000, 'height': 800}) page = context.new_page() @@ -294,6 +298,21 @@ def login(capture, register=False, label='login'): except Exception as error: receipt['result'] = 'FAIL' receipt['error_type'] = type(error).__name__ + if native: + try: + statuses = [] + for row in native.rows(): + if row.get('i') not in ('status', 'oauth_status'): + continue + value = str(row.get('t', '')) + if re.search(r'access_token|refresh_token|password|bearer|client_secret|code=', value, re.I): + value = '[redacted status]' + value = re.sub(r'https?://\S+', '[URL]', value) + value = value.replace(str(run), '[fixture]').replace(str(ROOT), '[source]') + statuses.append({'id': row['i'], 'text': value[:256]}) + receipt['failure_native_status'] = statuses + except Exception: + receipt['failure_native_status'] = 'native snapshot unavailable' # Full exceptions may contain callback URLs. Keep raw failure only privately. (run / 'private-error.txt').write_text(str(error)) if native: diff --git a/tools/connected-e2e/evidence/linux-backend-native.json b/tools/connected-e2e/evidence/linux-backend-native.json index 732f79aa..c69bfc5d 100644 --- a/tools/connected-e2e/evidence/linux-backend-native.json +++ b/tools/connected-e2e/evidence/linux-backend-native.json @@ -69,7 +69,12 @@ "webdriver_package_bytes": 529074, "system_packages_changed": false, "personal_accounts_used": false, - "owned_runtime_descendants_remaining": 0 + "owned_runtime_descendants_remaining": 0, + "namespace_mapping": "Read-only bwrap /usr overlay combines the owned extracted browser and vault package trees; MiniBrowser path resolves inside that disposable namespace only", + "engine_sha256": { + "MiniBrowser": "3b394371daa9f980d0973de9d7301608d7feb3b126ebfa9bfee298f431e7b94a", + "libwebkit2gtk-4.1.so.0.21.10": "9f2cbdef0697bb3da401a0453e921fd47be00fb932fefa723c94c495082debe6" + } }, "not_verified": [ "OS default browser link click", From 3626c37358aacfc4a7289620da3ba1b512ed3cc0 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:28:12 -0700 Subject: [PATCH 21/35] fix(wasm): isolate invocations and revoke stale work --- .github/workflows/phone.yml | 4 + Cargo.lock | 1 + crates/shell/Cargo.toml | 1 + crates/shell/src/wasm_service.rs | 905 ++++++++++++++++-- crates/wasm-host/src/lib.rs | 96 +- crates/wasm-host/tests/runtime.rs | 142 ++- .../0011-apps-own-functions-in-webassembly.md | 43 +- ...apps-own-functions-in-webassembly.zh-CN.md | 34 +- 8 files changed, 1098 insertions(+), 128 deletions(-) diff --git a/.github/workflows/phone.yml b/.github/workflows/phone.yml index 07e74ba7..8811d33f 100644 --- a/.github/workflows/phone.yml +++ b/.github/workflows/phone.yml @@ -120,6 +120,10 @@ jobs: # The linked Terminal (the desktop's default): its AI tools reach # the live emulator behind the module's TermTabs root. cargo test --locked --features mobile-apps,app-terminal -p octosense-shell terminal + # The opt-in Wasm service must keep fresh per-call guest state and + # enforce resource, cancellation and current-admission boundaries. + cargo test --locked -p octosense-wasm-host + cargo test --locked --features mobile-apps,wasm-lab -p octosense-shell wasm_service::tests cargo test --locked -p makepad-widgets splash_policy cargo test --locked -p makepad-script-std gate::tests # The two-lane scenario (crates/shell/src/host_tools/scenario_tests.rs): diff --git a/Cargo.lock b/Cargo.lock index 82c43bea..f6be4100 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8203,6 +8203,7 @@ dependencies = [ "tokio", "tokio-tungstenite 0.24.0", "uuid", + "wat", ] [[package]] diff --git a/crates/shell/Cargo.toml b/crates/shell/Cargo.toml index 8284f602..2e48296e 100644 --- a/crates/shell/Cargo.toml +++ b/crates/shell/Cargo.toml @@ -170,6 +170,7 @@ wasm-lab = ["app-hub", "dep:octosense-wasm-host"] toolbox-peers = ["octosense-ai-host/toolbox-peers"] [dev-dependencies] +wat = "1" # The two-lane scenario's external client (src/host_tools/scenario_tests.rs): # a Talk to Octos connection over the kernel's WebSocket, as a browser makes. tokio = { workspace = true, features = ["rt-multi-thread"] } diff --git a/crates/shell/src/wasm_service.rs b/crates/shell/src/wasm_service.rs index 3b2952b9..84c851b6 100644 --- a/crates/shell/src/wasm_service.rs +++ b/crates/shell/src/wasm_service.rs @@ -18,78 +18,242 @@ //! gate and the tool executor both require the app's `wasm` capability, and //! the service checks the admitted manifest again before it loads anything. //! -//! **Where it runs.** Requests arrive on the UI thread. Each app gets one -//! worker thread holding its instances, so a slow function holds up only -//! its own app. Compiled code is cached in the host directory -//! (`wasm-cache`), keyed by the module's digest. An instance that trapped or -//! ran past its deadline is replaced before the next call. -//! -//! Not done yet: an app updated while the shell runs keeps its old -//! functions until the shell restarts, and a worker lives as long as the -//! shell. +//! **Where it runs.** A bounded worker per active app caches compiled Programs, +//! never guest instances. Every invocation starts with fresh memory/globals/tables. +//! Admission, cancellation and deadline are checked before execution and delivery; +//! an update or withdrawal invalidates loaded Programs. Workers exit when idle. +//! The host-only disk cache holds compiled code keyed by module digest, not data. use std::collections::{BTreeMap, HashMap}; +use std::io::Write; use std::path::{Path, PathBuf}; -use std::sync::mpsc::{channel, Receiver, SendError, Sender}; -use std::sync::{Mutex, OnceLock}; -use std::time::Instant; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::mpsc::{sync_channel, Receiver, SyncSender, TrySendError}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, Instant}; use octosense_appstore::services::{HostService, Replier, ServiceCall, ServiceHost}; -use octosense_wasm_host::{Instance, Limits, Program, Runtime}; +use octosense_wasm_host::{Limits, Program, Runtime}; use serde_json::{json, Value}; use crate::host_tools::script_apps; -/// The most modules one app's bundle may carry. const MAX_MODULES: usize = 8; +const MAX_WORKERS: usize = 4; +const MAX_QUEUED_PER_APP: usize = 4; +const MAX_INPUT_BYTES: usize = 1 << 20; +const MAX_BUFFERED_BYTES: usize = 16 << 20; +const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); +const WORKER_IDLE: Duration = Duration::from_secs(5); -/// One engine, one epoch ticker and one code cache for the process. static RUNTIME: OnceLock> = OnceLock::new(); +static WORKERS: Mutex>> = Mutex::new(None); +static BUFFERED_BYTES: AtomicUsize = AtomicUsize::new(0); + +struct Worker { + jobs: SyncSender, + retired: Arc, +} + +/// Includes executing input: cancelling a queued request cannot free its +/// reservation until the worker actually drops those bytes. +struct Reservation(usize); +impl Reservation { + fn acquire(bytes: usize) -> Result { + BUFFERED_BYTES + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |used| { + used.checked_add(bytes) + .filter(|total| *total <= MAX_BUFFERED_BYTES) + }) + .map(|_| Self(bytes)) + .map_err(|_| "wasm input queue is full; try again later".into()) + } +} +impl Drop for Reservation { + fn drop(&mut self) { + BUFFERED_BYTES.fetch_sub(self.0, Ordering::AcqRel); + } +} -/// Each app's worker, by app id. -static WORKERS: Mutex>>> = Mutex::new(None); +/// Authority is the signed, digest-checked bundle, never request arguments. +/// Comparing the whole admitted manifest includes the version and grants. +#[derive(Clone, Debug, PartialEq)] +struct Admission { + root: PathBuf, + bundle: PathBuf, + manifest: Value, +} +impl Admission { + fn current(app: &str) -> Result { + let (root, bundle) = script_apps::admitted_bundle(app)?; + let loaded = script_apps::from_bundle(&bundle)?; + if !loaded.families.contains("wasm") { + return Err(format!("{app} was not granted the wasm service")); + } + Ok(Self { + root, + bundle, + manifest: loaded.manifest, + }) + } + fn verify(&self, app: &str) -> Result<(), String> { + if Self::current(app)? != *self { + return Err("wasm app admission changed; retry from the current app".into()); + } + Ok(()) + } +} struct Job { method: String, - args: Value, + input: Vec, host_dir: PathBuf, + admission: Admission, + deadline: Instant, reply: Replier, + _reservation: Reservation, +} +impl Job { + fn live(&self) -> bool { + self.reply.is_pending() && Instant::now() < self.deadline + } + fn check(&self, app: &str) -> Result<(), String> { + if !self.live() { + return Err("wasm invocation cancelled or past its deadline".into()); + } + self.admission.verify(app)?; + if !self.live() { + return Err("wasm invocation cancelled or past its deadline".into()); + } + Ok(()) + } } -/// The `wasm` family for the Card runner and the tool executor. -pub struct WasmService; +/// Serialize into bounded storage, rather than allocating an unlimited JSON +/// buffer and only then discovering the request is too large. +fn input_bytes(args: &Value) -> Result, String> { + struct Bounded(Vec); + impl Write for Bounded { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + if bytes.len() > MAX_INPUT_BYTES.saturating_sub(self.0.len()) { + return Err(std::io::Error::other("wasm input exceeds 1 MiB")); + } + self.0.extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + if let Value::String(text) = args { + if text.len() > MAX_INPUT_BYTES { + return Err("wasm input exceeds 1 MiB".into()); + } + return Ok(text.as_bytes().to_vec()); + } + let mut out = Bounded(Vec::new()); + serde_json::to_writer(&mut out, args).map_err(|error| error.to_string())?; + Ok(out.0) +} +pub struct WasmService; impl HostService for WasmService { fn family(&self) -> &'static str { "wasm" } - + fn timeout(&self, _call: &ServiceCall) -> Duration { + REQUEST_TIMEOUT + } fn call(&mut self, call: ServiceCall, reply: Replier, _host: &mut dyn ServiceHost) { if call.from_sheet { reply.send(Err("the wasm service has no sheet".into())); return; } + if !reply.is_pending() { + return; + } + let deadline = Instant::now() + REQUEST_TIMEOUT; + let prepared = (|| { + let input = input_bytes(&call.args)?; + let reservation = Reservation::acquire(input.len())?; + let admission = Admission::current(&call.app_id)?; + // The cache is host-owned, not a caller-selected or app directory. + if call.host_dir != admission.root.join(".host") { + return Err("wasm host directory does not belong to this app store".into()); + } + Ok((input, reservation, admission)) + })(); + let (input, reservation, admission) = match prepared { + Ok(prepared) => prepared, + Err(error) => { + reply.send(Err(error)); + return; + } + }; let app = call.app_id.clone(); - let job = Job { method: call.method().to_string(), host_dir: call.host_dir, args: call.args, reply }; + let mut job = Job { + method: call.method().to_string(), + host_dir: call.host_dir, + input, + admission, + deadline, + reply, + _reservation: reservation, + }; + if !job.live() { + return; + } let mut workers = WORKERS.lock().unwrap_or_else(|e| e.into_inner()); let workers = workers.get_or_insert_with(HashMap::new); - // A worker that ended (only a panic ends one) is replaced. - let job = match workers.get(&app) { - Some(jobs) => match jobs.send(job) { + workers.retain(|_, worker| !worker.retired.load(Ordering::Acquire)); + if let Some(worker) = workers.get(&app) { + job = match worker.jobs.try_send(job) { Ok(()) => return, - Err(SendError(job)) => job, - }, - None => job, - }; - let (jobs, queue) = channel(); + Err(TrySendError::Full(job)) => { + job.reply + .send(Err("wasm app queue is full; try again later".into())); + return; + } + Err(TrySendError::Disconnected(job)) => job, + }; + workers.remove(&app); + } + if workers.len() >= MAX_WORKERS { + job.reply + .send(Err("wasm workers are busy; try again later".into())); + return; + } + let (jobs, queue) = sync_channel(MAX_QUEUED_PER_APP); + let retired = Arc::new(AtomicBool::new(false)); + let done = retired.clone(); let name = app.clone(); - let spawned = std::thread::Builder::new().name(format!("wasm {app}")).spawn(move || work(name, queue)); + let spawned = std::thread::Builder::new() + .name(format!("wasm {app}")) + .spawn(move || { + struct Retire(Arc); + impl Drop for Retire { + fn drop(&mut self) { + self.0.store(true, Ordering::Release); + } + } + let _retire = Retire(done); + work(name, queue, &_retire.0); + }); if let Err(error) = spawned { - job.reply.send(Err(format!("cannot start {app}'s functions: {error}"))); + job.reply + .send(Err(format!("cannot start {app}'s functions: {error}"))); return; } - let _ = jobs.send(job); - workers.insert(app, jobs); + if let Err(error) = jobs.try_send(job) { + let job = match error { + TrySendError::Full(job) | TrySendError::Disconnected(job) => job, + }; + job.reply.send(Err( + "wasm worker stopped before accepting the invocation".into() + )); + return; + } + workers.insert(app, Worker { jobs, retired }); } } @@ -97,27 +261,84 @@ pub fn register() { octosense_appstore::services::register_host_service(Box::new(WasmService)); } -/// One app's worker: loads its modules on the first request (again on the -/// next one, if that failed), then answers each request in turn. -fn work(app: String, jobs: Receiver) { +fn work(app: String, jobs: Receiver, retired: &Arc) { let mut lab: Option = None; - for job in jobs { - if lab.is_none() { - match Lab::load(&app, &job.host_dir) { - Ok(loaded) => lab = Some(loaded), - Err(error) => { - makepad_widgets::log!("wasm {app}: {error}"); - job.reply.send(Err(error)); - continue; + loop { + let job = match jobs.recv_timeout(WORKER_IDLE) { + Ok(job) => job, + Err(_) => { + // Serialize idle retirement with enqueue. A sender that won + // the timeout race still gets its accepted request processed. + let mut workers = WORKERS.lock().unwrap_or_else(|e| e.into_inner()); + if let Ok(job) = jobs.try_recv() { + job + } else { + retired.store(true, Ordering::Release); + if let Some(workers) = workers.as_mut() { + if workers + .get(&app) + .is_some_and(|worker| Arc::ptr_eq(&worker.retired, retired)) + { + workers.remove(&app); + } + } + return; } } - } - if let Some(lab) = lab.as_mut() { - job.reply.send(lab.answer(&job.method, &job.args)); + }; + let result = process(&app, &mut lab, &job); + // Cancellation can win while admission is being checked. Replier's + // pending-request check remains the final isolate boundary. + if job.live() { + job.reply.send(result); } } } +fn process(app: &str, lab: &mut Option, job: &Job) -> Result { + process_checked(app, lab, job, || {}) +} + +// The completion boundary is explicit so regressions can replace the real +// admitted bundle precisely after guest execution, without timing sleeps. +fn process_checked( + app: &str, + lab: &mut Option, + job: &Job, + before_delivery: impl FnOnce(), +) -> Result { + if let Err(error) = job.check(app) { + *lab = None; + return Err(error); + } + if lab + .as_ref() + .is_some_and(|loaded| loaded.admission != job.admission) + { + *lab = None; + } + if lab.is_none() { + *lab = Some(Lab::load(app, job)?); + } + // Compilation and start-up work must not revive an expired request. + if let Err(error) = job.check(app) { + *lab = None; + return Err(error); + } + let result = + lab.as_mut() + .unwrap() + .answer(&job.method, &job.input, job.deadline, job.reply.clone()); + before_delivery(); + // A withdrawal, changed grant, update or tamper while code runs vetoes + // its result and releases the old compiled-module references. + if let Err(error) = job.check(app) { + *lab = None; + return Err(error); + } + result +} + fn runtime(host_dir: &Path) -> Result<&'static Runtime, String> { RUNTIME .get_or_init(|| Runtime::new(Limits::default(), Some(host_dir.join("wasm-cache")))) @@ -133,6 +354,7 @@ struct Lab { /// Which module exports each function. owner: BTreeMap, stats: BTreeMap, + admission: Admission, } struct Module { @@ -141,9 +363,9 @@ struct Module { load_ms: f64, from_cache: bool, program: Program, - instance: Instance, - /// Fresh instances made after a trap or a deadline. - renewed: u32, + /// Diagnostic high-water mark only: guest memory is never retained. + memory_bytes: usize, + invocations: u64, } /// How one function has run: in the worker, from input to output. @@ -156,12 +378,10 @@ struct Stats { } impl Lab { - fn load(app: &str, host_dir: &Path) -> Result { - let (_, bundle) = script_apps::admitted_bundle(app)?; - if !script_apps::from_bundle(&bundle)?.families.contains("wasm") { - return Err(format!("{app} was not granted the wasm service")); - } - let runtime = runtime(host_dir)?; + fn load(app: &str, job: &Job) -> Result { + let admission = &job.admission; + let bundle = &admission.bundle; + let runtime = runtime(&job.host_dir)?; let mut files: Vec = std::fs::read_dir(bundle.join("fns")) .map_err(|_| format!("{app}'s bundle has no fns directory"))? .flatten() @@ -173,34 +393,76 @@ impl Lab { .collect(); files.sort(); if files.is_empty() || files.len() > MAX_MODULES { - return Err(format!("{app}'s bundle must carry 1 to {MAX_MODULES} fns/*.wasm modules")); + return Err(format!( + "{app}'s bundle must carry 1 to {MAX_MODULES} fns/*.wasm modules" + )); } - let mut lab = Lab { app: app.to_string(), runtime, modules: Vec::new(), owner: BTreeMap::new(), stats: BTreeMap::new() }; + let mut lab = Lab { + app: app.to_string(), + runtime, + modules: Vec::new(), + owner: BTreeMap::new(), + stats: BTreeMap::new(), + admission: admission.clone(), + }; for path in files { - let file = path.file_name().unwrap_or_default().to_string_lossy().into_owned(); + job.check(app)?; + let file = path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + if std::fs::metadata(&path).map_err(|e| e.to_string())?.len() + > runtime.limits().module_bytes as u64 + { + return Err(format!("{file}: module exceeds the size limit")); + } let bytes = std::fs::read(&path).map_err(|e| format!("{file}: {e}"))?; let started = Instant::now(); let program = runtime.load(&bytes).map_err(|e| format!("{file}: {e}"))?; - let instance = runtime.instantiate(&program).map_err(|e| format!("{file}: {e}"))?; let load_ms = started.elapsed().as_secs_f64() * 1e3; for function in program.functions() { - if lab.owner.insert(function.clone(), lab.modules.len()).is_some() { - return Err(format!("{file}: {function} is exported by another module too")); + if lab + .owner + .insert(function.clone(), lab.modules.len()) + .is_some() + { + return Err(format!( + "{file}: {function} is exported by another module too" + )); } } let from_cache = program.from_cache(); makepad_widgets::log!( "wasm {app}: {file} ({} KiB) {} in {load_ms:.1} ms: {}", bytes.len() / 1024, - if from_cache { "loaded from the cache" } else { "compiled" }, + if from_cache { + "loaded from the cache" + } else { + "compiled" + }, program.functions().join(", ") ); - lab.modules.push(Module { file, bytes: bytes.len(), load_ms, from_cache, program, instance, renewed: 0 }); + lab.modules.push(Module { + file, + bytes: bytes.len(), + load_ms, + from_cache, + program, + memory_bytes: 0, + invocations: 0, + }); } Ok(lab) } - fn answer(&mut self, method: &str, args: &Value) -> Result { + fn answer( + &mut self, + method: &str, + input: &[u8], + deadline: Instant, + reply: Replier, + ) -> Result { if method == "functions" { // Also in the log: where there is no remote instrument (an // Android build), the numbers are read from logcat. @@ -212,20 +474,21 @@ impl Lab { return Err(format!("{} has no function {method:?}", self.app)); }; let module = &mut self.modules[index]; - if module.instance.spent() { - module.instance = self.runtime.instantiate(&module.program).map_err(|e| e.to_string())?; - module.renewed += 1; - } - let input = match args { - Value::String(text) => text.clone().into_bytes(), - other => serde_json::to_vec(other).map_err(|e| e.to_string())?, - }; let started = Instant::now(); - let result = module.instance.call(method, &input); + let mut instance = self + .runtime + .instantiate_guarded(&module.program, deadline, move || reply.is_pending()) + .map_err(|error| error.to_string())?; + module.invocations += 1; + let result = instance.call(method, input); let us = started.elapsed().as_secs_f64() * 1e6; - for line in module.instance.take_logs() { + module.memory_bytes = module.memory_bytes.max(instance.memory_bytes()); + for line in instance.take_logs() { makepad_widgets::log!("wasm {}: {line}", self.app); } + // Drop guest memory, mutable globals, tables and logs after every + // invocation, including successful calls and guest-returned errors. + drop(instance); let stats = self.stats.entry(method.to_string()).or_default(); stats.calls += 1; stats.total_us += us; @@ -234,7 +497,8 @@ impl Lab { stats.errors += 1; error.to_string() })?; - Ok(serde_json::from_slice(&output).unwrap_or_else(|_| json!({"text": String::from_utf8_lossy(&output)}))) + Ok(serde_json::from_slice(&output) + .unwrap_or_else(|_| json!({"text": String::from_utf8_lossy(&output)}))) } fn describe(&self) -> Value { @@ -243,7 +507,7 @@ impl Lab { .iter() .map(|m| { json!({"file": m.file, "bytes": m.bytes, "load_ms": round(m.load_ms), - "from_cache": m.from_cache, "memory_bytes": m.instance.memory_bytes(), "renewed": m.renewed}) + "from_cache": m.from_cache, "memory_bytes": m.memory_bytes, "invocations": m.invocations, "renewed": m.invocations.saturating_sub(1), "instance_policy": "fresh-per-call"}) }) .collect(); let stats: serde_json::Map = self @@ -277,12 +541,22 @@ mod tests { let dir = script_apps::tests::stamped_bundle("wasmlab", "tools", |_, _| {}); let loaded = script_apps::from_bundle(&dir).unwrap(); let _ = std::fs::remove_dir_all(&dir); - let names: Vec<&str> = loaded.tools.iter().filter_map(|t| t["name"].as_str()).collect(); - assert_eq!(names, ["wasmlab.find_slots", "wasmlab.rank", "wasmlab.diff"]); + let names: Vec<&str> = loaded + .tools + .iter() + .filter_map(|t| t["name"].as_str()) + .collect(); + assert_eq!( + names, + ["wasmlab.find_slots", "wasmlab.rank", "wasmlab.diff"] + ); assert_eq!(loaded.host_methods["wasmlab.find_slots"], "wasm.find_slots"); assert_eq!(loaded.host_methods["wasmlab.rank"], "wasm.fuzzy_rank"); assert_eq!(loaded.host_methods["wasmlab.diff"], "wasm.text_diff"); - assert!(loaded.tools.iter().all(|t| t["risk"] == "read" && t["output_schema"]["type"] == "object")); + assert!(loaded + .tools + .iter() + .all(|t| t["risk"] == "read" && t["output_schema"]["type"] == "object")); assert_eq!(loaded.families.iter().collect::>(), ["wasm"]); assert_eq!(loaded.generic, ["ask_user_question"]); } @@ -298,10 +572,19 @@ mod tests { /// What the app's script would get from `host.request(service, args)`. fn request(app: &str, service: &str, args: Value, host_dir: &Path) -> Result { let heap = NEXT_HEAP.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let call = ServiceCall { app_id: app.into(), service: service.into(), args, from_sheet: false, may_prompt: true, host_dir: host_dir.into() }; + let call = ServiceCall { + app_id: app.into(), + service: service.into(), + args, + from_sheet: false, + may_prompt: true, + host_dir: host_dir.into(), + }; octosense_appstore::services::dispatch(call, heap, 1, &mut NoSheet); for _ in 0..1000 { - if let Some((_, _, answer)) = octosense_appstore::services::take_replies_for(&[heap]).pop() { + if let Some((_, _, answer)) = + octosense_appstore::services::take_replies_for(&[heap]).pop() + { return answer.map(|text| serde_json::from_str(&text).unwrap()); } std::thread::sleep(Duration::from_millis(10)); @@ -326,6 +609,349 @@ mod tests { }); } + // A real guest deliberately retains the previous input in both linear + // memory and a mutable global. Reusing the old instance leaks it on recall. + const STATE_GUEST: &str = r#"(module + (memory (export "memory") 1) + (global $size (mut i32) (i32.const 0)) + (data (i32.const 8) "\01failed") + (func (export "octo_alloc") (param i32) (result i32) (i32.const 4096)) + (func (export "octo_free") (param i32 i32)) + (func $remember (param $p i32) (param $n i32) + (global.set $size (local.get $n)) + (memory.copy (i32.const 65) (local.get $p) (local.get $n))) + (func (export "remember") (param i32 i32) (result i64) + (call $remember (local.get 0) (local.get 1)) (i64.const 1)) + (func (export "remember_fail") (param i32 i32) (result i64) + (call $remember (local.get 0) (local.get 1)) (i64.const 34359738375)) + (func (export "recall") (param i32 i32) (result i64) + (i64.or (i64.const 274877906944) (i64.extend_i32_u (i32.add (global.get $size) (i32.const 1))))))"#; + + fn ship_state(id: &'static str, version: &str, granted: bool) { + ship(id, "state", |dir, manifest| { + manifest["version"] = json!(version); + manifest["capabilities"] = if granted { json!(["wasm"]) } else { json!([]) }; + manifest.as_object_mut().unwrap().remove("agent"); + std::fs::remove_file(dir.join("tools.json")).unwrap(); + std::fs::remove_file(dir.join("AGENT.md")).unwrap(); + std::fs::remove_file(dir.join("fns/wasmlab.wasm")).unwrap(); + std::fs::write( + dir.join("fns/state.wasm"), + wat::parse_str(if version == "2.0.0" { + STATE_GUEST.replace( + "(global $size (mut i32) (i32.const 0))", + "(global $size (mut i32) (i32.const 3)) (data (i32.const 65) \"new\")", + ) + } else { + STATE_GUEST.to_string() + }) + .unwrap(), + ) + .unwrap(); + }); + } + + fn captured_job(app: &str, method: &str, host_dir: &Path) -> (usize, Job) { + struct Capture(Arc>>); + impl HostService for Capture { + fn family(&self) -> &'static str { + "wasm_capture" + } + fn call(&mut self, _: ServiceCall, reply: Replier, _: &mut dyn ServiceHost) { + *self.0.lock().unwrap() = Some(reply); + } + } + let captured = Arc::new(Mutex::new(None)); + octosense_appstore::services::register_host_service(Box::new(Capture(captured.clone()))); + let heap = NEXT_HEAP.fetch_add(1, Ordering::Relaxed); + octosense_appstore::services::dispatch( + ServiceCall { + app_id: app.into(), + service: "wasm_capture.capture".into(), + args: json!({}), + from_sheet: false, + may_prompt: false, + host_dir: host_dir.into(), + }, + heap, + 1, + &mut NoSheet, + ); + let reply = captured.lock().unwrap().take().unwrap(); + ( + heap, + Job { + method: method.into(), + input: Vec::new(), + host_dir: host_dir.into(), + admission: Admission::current(app).unwrap(), + deadline: Instant::now() + REQUEST_TIMEOUT, + reply, + _reservation: Reservation::acquire(0).unwrap(), + }, + ) + } + + #[test] + fn bounded_inputs_reject_strings_and_json_before_queueing() { + assert!(input_bytes(&json!("x".repeat(MAX_INPUT_BYTES + 1))).is_err()); + // String escaping expands an otherwise smaller tree beyond the limit. + assert!(input_bytes(&json!({"x": "\n".repeat(MAX_INPUT_BYTES / 2)})).is_err()); + assert_eq!(input_bytes(&json!("plain")).unwrap(), b"plain"); + assert_eq!(input_bytes(&json!({"n": 1})).unwrap(), br#"{"n":1}"#); + } + + #[test] + fn invocation_isolation_revocation_cancellation_and_queue_bounds() { + const CHILD: &str = "OCTOSENSE_TEST_WASM_ISOLATION"; + if std::env::var_os(CHILD).is_none() { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "wasm_service::tests::invocation_isolation_revocation_cancellation_and_queue_bounds", "--nocapture"]) + .env(CHILD, "1").output().unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + return; + } + let root = std::env::temp_dir().join(format!("wasm-isolation-{}", uuid::Uuid::new_v4())); + let host_dir = root.join(".host"); + octosense_appstore::set_data_root(root.clone()); + ship_state("os.wasmstate", "1.0.0", true); + register(); + // Positive control: this guest really retains input when an Instance + // is reused, reproducing the old host behavior rather than testing an + // inert fixture that could never leak. + let engine = runtime(&host_dir).unwrap(); + let program = engine.load(&wat::parse_str(STATE_GUEST).unwrap()).unwrap(); + let mut reused = engine.instantiate(&program).unwrap(); + reused.call("remember", b"private fixture input").unwrap(); + assert_eq!( + reused.call("recall", b"").unwrap(), + b"private fixture input" + ); + drop(reused); + for method in ["remember", "remember_fail"] { + let result = request( + "os.wasmstate", + &format!("wasm.{method}"), + json!("private fixture input"), + &host_dir, + ); + assert_eq!(result.is_ok(), method == "remember"); + let recalled = request("os.wasmstate", "wasm.recall", json!(null), &host_dir).unwrap(); + assert_eq!( + recalled, + json!({"text":""}), + "no successful or failed call retains private input" + ); + } + + let mut lab = None; + let (heap, job) = captured_job("os.wasmstate", "recall", &host_dir); + octosense_appstore::services::cancel_heap(heap); + assert!(process("os.wasmstate", &mut lab, &job) + .unwrap_err() + .contains("cancelled")); + assert!( + lab.is_none(), + "cancelled queued call never loads or executes guest code" + ); + let (heap, mut job) = captured_job("os.wasmstate", "recall", &host_dir); + job.deadline = Instant::now(); + assert!(process("os.wasmstate", &mut lab, &job) + .unwrap_err() + .contains("deadline")); + assert!(lab.is_none()); + octosense_appstore::services::cancel_heap(heap); + + let (heap, stale) = captured_job("os.wasmstate", "recall", &host_dir); + ship_state("os.wasmstate", "2.0.0", true); + assert!(process("os.wasmstate", &mut lab, &stale) + .unwrap_err() + .contains("admission changed")); + assert!(lab.is_none(), "old queued revision cannot run after update"); + octosense_appstore::services::cancel_heap(heap); + let (heap, current) = captured_job("os.wasmstate", "recall", &host_dir); + assert_eq!( + process("os.wasmstate", &mut lab, ¤t).unwrap(), + json!({"text":"new"}), + "new revision executes its new bytes" + ); + assert!(lab.is_some()); + octosense_appstore::services::cancel_heap(heap); + + // Real registration/grants change exactly between calculation and + // delivery, so this fails if process omits its completion admission check. + let (heap, in_flight) = captured_job("os.wasmstate", "recall", &host_dir); + let error = process_checked("os.wasmstate", &mut lab, &in_flight, || { + ship_state("os.wasmstate", "3.0.0", false) + }) + .unwrap_err(); + assert!(error.contains("not granted"), "{error}"); + assert!( + lab.is_none(), + "revocation drops cached Programs before delivery" + ); + octosense_appstore::services::cancel_heap(heap); + ship_state("os.wasmstate", "4.0.0", true); + let (heap, in_flight) = captured_job("os.wasmstate", "recall", &host_dir); + assert!(process_checked("os.wasmstate", &mut lab, &in_flight, || { + octosense_appstore::services::cancel_heap(heap) + }) + .is_err()); + assert!(octosense_appstore::services::take_replies_for(&[heap]).is_empty()); + + // Exercise the real service enqueue path against a deliberately held + // worker receiver: no timing-dependent slow guest is needed to fill it. + let (tx, held) = sync_channel(MAX_QUEUED_PER_APP); + WORKERS.lock().unwrap().as_mut().unwrap().insert( + "os.wasmstate".into(), + Worker { + jobs: tx, + retired: Arc::new(AtomicBool::new(false)), + }, + ); + let mut heaps = Vec::new(); + for i in 0..=MAX_QUEUED_PER_APP { + let heap = NEXT_HEAP.fetch_add(1, Ordering::Relaxed); + heaps.push(heap); + octosense_appstore::services::dispatch( + ServiceCall { + app_id: "os.wasmstate".into(), + service: "wasm.recall".into(), + args: json!({}), + from_sheet: false, + may_prompt: false, + host_dir: host_dir.clone(), + }, + heap, + 1, + &mut NoSheet, + ); + let replies = octosense_appstore::services::take_replies_for(&[heap]); + if i < MAX_QUEUED_PER_APP { + assert!(replies.is_empty()); + } else { + assert!(replies[0].2.as_ref().unwrap_err().contains("queue is full")); + } + } + for heap in heaps { + octosense_appstore::services::cancel_heap(heap); + } + let queued = held.try_iter().collect::>(); + assert_eq!(queued.len(), MAX_QUEUED_PER_APP); + assert!(queued.iter().all(|job| !job.live())); + drop(queued); + let used = BUFFERED_BYTES.load(Ordering::Acquire); + assert!(Reservation::acquire(MAX_BUFFERED_BYTES - used).is_ok()); + let all = Reservation::acquire(MAX_BUFFERED_BYTES - used).unwrap(); + assert!(Reservation::acquire(1).is_err()); + drop(all); + assert_eq!(BUFFERED_BYTES.load(Ordering::Acquire), used); + WORKERS.lock().unwrap().as_mut().unwrap().clear(); + // The global limit is enforced even when one more admitted app has + // no existing queue. These receiver handles stand in for busy workers. + let mut held_workers = Vec::new(); + for n in 0..MAX_WORKERS { + let (jobs, queue) = sync_channel(MAX_QUEUED_PER_APP); + held_workers.push(queue); + WORKERS.lock().unwrap().as_mut().unwrap().insert( + format!("busy-{n}"), + Worker { + jobs, + retired: Arc::new(AtomicBool::new(false)), + }, + ); + } + assert!( + request("os.wasmstate", "wasm.recall", json!(null), &host_dir) + .unwrap_err() + .contains("workers are busy") + ); + WORKERS.lock().unwrap().as_mut().unwrap().clear(); + drop(held_workers); + request("os.wasmstate", "wasm.recall", json!(null), &host_dir).unwrap(); + let idle_deadline = Instant::now() + WORKER_IDLE + Duration::from_secs(2); + while !WORKERS.lock().unwrap().as_ref().unwrap().is_empty() + && Instant::now() < idle_deadline + { + std::thread::sleep(Duration::from_millis(20)); + } + assert!( + WORKERS.lock().unwrap().as_ref().unwrap().is_empty(), + "idle workers release their cached modules and slot" + ); + + // A real signed store catalog withdrawal also vetoes an already + // calculated result. This exercises installed_bundle/may_run, not a + // fake admission checker or the system-app shortcut. + use octosense_app_hub::{Catalog, Entry, HubKey, Source, Status}; + let id = "org.example.wasm-isolation"; + let bundle = octosense_app_hub::installed_bundle_dir(&root, id); + std::fs::create_dir_all(bundle.join("fns")).unwrap(); + std::fs::write(bundle.join("main.splash"), "Label {text: \"Fixture\"}").unwrap(); + std::fs::write( + bundle.join("fns/state.wasm"), + wat::parse_str(STATE_GUEST).unwrap(), + ) + .unwrap(); + let mut manifest = octosense_app_contract::AppManifest::parse(&json!({ + "schema":1,"id":id,"version":"1.0.0","name":"Wasm isolation fixture", + "capabilities":["wasm"],"integrity":{"bundle_blake3":octosense_app_contract::digest_dir(&bundle).unwrap()} + }).to_string()).unwrap(); + let publisher = HubKey::generate(); + octosense_app_hub::sign_manifest(&publisher, &mut manifest, "fixture").unwrap(); + std::fs::write( + bundle.join("manifest.json"), + serde_json::to_vec(&manifest).unwrap(), + ) + .unwrap(); + let anchor = HubKey::generate(); + let working = HubKey::generate(); + let certificate = anchor.certify(&working.public_hex()).unwrap(); + std::env::set_var("OCTOSENSE_HUB_ANCHOR", anchor.public_hex()); + let entry = Entry { + manifest, + listing: None, + tools: vec![], + artifact: "fixture".into(), + publisher: "fixture".into(), + publisher_key: publisher.public_hex(), + source: Source { + repository: String::new(), + commit: String::new(), + }, + status: Status::Offered, + admitted: "2026-10-08".into(), + }; + let mut catalog = Catalog::new(1, "2026-10-08", vec![entry]); + let write_catalog = |catalog: &mut Catalog| { + working.sign_catalog(catalog, &certificate).unwrap(); + std::fs::write( + root.join("catalog.json"), + serde_json::to_vec(catalog).unwrap(), + ) + .unwrap(); + }; + write_catalog(&mut catalog); + let (heap, in_flight) = captured_job(id, "recall", &host_dir); + let mut installed_lab = None; + let error = process_checked(id, &mut installed_lab, &in_flight, || { + catalog.sequence += 1; + catalog.entries[0].status = Status::Withdrawn("fixture withdrawal".into()); + write_catalog(&mut catalog); + }) + .unwrap_err(); + assert!(error.contains("withdrawn"), "{error}"); + assert!(installed_lab.is_none()); + assert!(Admission::current(id).is_err()); + octosense_appstore::services::cancel_heap(heap); + let _ = std::fs::remove_dir_all(root); + } + /// The whole path, in a process of its own (the apps root and the /// registries are process-wide): Wasm Lab's tools reach its functions /// through the executor and the service, its script's requests do too, @@ -336,14 +962,24 @@ mod tests { const CHILD: &str = "OCTOSENSE_TEST_WASM_SERVICE"; if std::env::var_os(CHILD).is_none() { let output = std::process::Command::new(std::env::current_exe().unwrap()) - .args(["--exact", "wasm_service::tests::the_apps_own_functions_answer_its_tools_and_its_script", "--nocapture"]) + .args([ + "--exact", + "wasm_service::tests::the_apps_own_functions_answer_its_tools_and_its_script", + "--nocapture", + ]) .env(CHILD, "1") .output() .unwrap(); - assert!(output.status.success(), "{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); return; } - let root = std::env::temp_dir().join(format!("octosense-wasm-service-{}", std::process::id())); + let root = + std::env::temp_dir().join(format!("octosense-wasm-service-{}", std::process::id())); let host_dir = root.join(".host"); octosense_appstore::set_data_root(root.clone()); ship("os.wasmlab", "service", |_, _| {}); @@ -372,7 +1008,10 @@ mod tests { "name": "wasmlab.find_slots", "args": {"day_start": "09:00", "day_end": "12:00", "duration": 30, "busy": [["09:30", "11:00"]]}})) .unwrap(); - executor.execute(call, ToolReply::new("c1", move |v| into.lock().unwrap().push(v))); + executor.execute( + call, + ToolReply::new("c1", move |v| into.lock().unwrap().push(v)), + ); for _ in 0..1000 { script_apps::poll(); if !sent.lock().unwrap().is_empty() { @@ -383,42 +1022,108 @@ mod tests { let answer = sent.lock().unwrap().pop().expect("the tool answered"); assert_eq!(answer["ok"], true, "{answer}"); // Every 15 minutes, the default step. - assert_eq!(answer["data"]["slots"], json!([["09:00", "09:30"], ["11:00", "11:30"], ["11:15", "11:45"], ["11:30", "12:00"]]), "{answer}"); + assert_eq!( + answer["data"]["slots"], + json!([ + ["09:00", "09:30"], + ["11:00", "11:30"], + ["11:15", "11:45"], + ["11:30", "12:00"] + ]), + "{answer}" + ); // The script's own requests: a string goes in as its text, and an // output that is not JSON comes back as text. let html = request("os.wasmlab", "wasm.md_to_html", json!("# Hi"), &host_dir).unwrap(); assert_eq!(html["text"], "

Hi

\n"); - let ranked = request("os.wasmlab", "wasm.fuzzy_rank", json!({"query": "cal", "items": ["Mail", "Calendar"]}), &host_dir).unwrap(); + let ranked = request( + "os.wasmlab", + "wasm.fuzzy_rank", + json!({"query": "cal", "items": ["Mail", "Calendar"]}), + &host_dir, + ) + .unwrap(); assert_eq!(ranked["ranked"][0]["item"], "Calendar"); - let error = request("os.wasmlab", "wasm.find_slots", json!({"day_start": "12:00", "day_end": "09:00", "duration": 30, "busy": []}), &host_dir).unwrap_err(); + let error = request( + "os.wasmlab", + "wasm.find_slots", + json!({"day_start": "12:00", "day_end": "09:00", "duration": 30, "busy": []}), + &host_dir, + ) + .unwrap_err(); assert!(error.contains("ends before it starts"), "{error}"); let error = request("os.wasmlab", "wasm.nothing", json!({}), &host_dir).unwrap_err(); assert!(error.contains("no function"), "{error}"); // Rogue code: an error each time, and the next call still answers. for mode in ["loop", "alloc", "panic", "recurse"] { - let error = request("os.wasmlab", "wasm.rogue", json!({"mode": mode}), &host_dir).unwrap_err(); - assert!(error.contains("deadline") || error.contains("trapped"), "{mode}: {error}"); - let ranked = request("os.wasmlab", "wasm.fuzzy_rank", json!({"query": "m", "items": ["Mail"]}), &host_dir).unwrap(); + let error = + request("os.wasmlab", "wasm.rogue", json!({"mode": mode}), &host_dir).unwrap_err(); + assert!( + error.contains("deadline") || error.contains("trapped"), + "{mode}: {error}" + ); + let ranked = request( + "os.wasmlab", + "wasm.fuzzy_rank", + json!({"query": "m", "items": ["Mail"]}), + &host_dir, + ) + .unwrap(); assert_eq!(ranked["ranked"][0]["item"], "Mail", "{mode}"); } let described = request("os.wasmlab", "wasm.functions", json!({}), &host_dir).unwrap(); - assert_eq!(described["functions"], json!(["find_slots", "fuzzy_rank", "md_to_html", "rogue", "text_diff"])); + assert_eq!( + described["functions"], + json!([ + "find_slots", + "fuzzy_rank", + "md_to_html", + "rogue", + "text_diff" + ]) + ); assert_eq!(described["modules"][0]["file"], "wasmlab.wasm"); - assert_eq!(described["modules"][0]["renewed"], 4); + assert_eq!(described["modules"][0]["instance_policy"], "fresh-per-call"); assert_eq!(described["stats"]["rogue"]["errors"], 4); - assert!(host_dir.join("wasm-cache").read_dir().unwrap().next().is_some(), "compiled code is cached"); + assert!( + host_dir + .join("wasm-cache") + .read_dir() + .unwrap() + .next() + .is_some(), + "compiled code is cached" + ); // No grant, no functions: the service checks the admitted manifest // itself, whoever dispatched the request. - let error = request("os.wasmplain", "wasm.fuzzy_rank", json!({"query": "m", "items": []}), &host_dir).unwrap_err(); - assert!(error.contains("was not granted the wasm service"), "{error}"); + let error = request( + "os.wasmplain", + "wasm.fuzzy_rank", + json!({"query": "m", "items": []}), + &host_dir, + ) + .unwrap_err(); + assert!( + error.contains("was not granted the wasm service"), + "{error}" + ); // And a sheet never reaches it. let heap = NEXT_HEAP.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let call = ServiceCall { app_id: "os.wasmlab".into(), service: "wasm.functions".into(), args: json!({}), from_sheet: true, may_prompt: true, host_dir: host_dir.clone() }; + let call = ServiceCall { + app_id: "os.wasmlab".into(), + service: "wasm.functions".into(), + args: json!({}), + from_sheet: true, + may_prompt: true, + host_dir: host_dir.clone(), + }; octosense_appstore::services::dispatch(call, heap, 1, &mut NoSheet); - let (_, _, answer) = octosense_appstore::services::take_replies_for(&[heap]).pop().unwrap(); + let (_, _, answer) = octosense_appstore::services::take_replies_for(&[heap]) + .pop() + .unwrap(); assert!(answer.unwrap_err().contains("no sheet")); let _ = std::fs::remove_dir_all(root); } diff --git a/crates/wasm-host/src/lib.rs b/crates/wasm-host/src/lib.rs index 364fa47b..4cbebf28 100644 --- a/crates/wasm-host/src/lib.rs +++ b/crates/wasm-host/src/lib.rs @@ -28,8 +28,9 @@ use std::collections::HashMap; use std::fmt; use std::hash::{Hash, Hasher}; +use std::io::Write; use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -55,6 +56,8 @@ pub struct Limits { pub deadline: Duration, /// The most linear memory an instance may have. pub memory_bytes: usize, + /// The most elements in an instance's single table (including growth). + pub table_elements: usize, /// The largest module the runtime loads. pub module_bytes: usize, /// The largest input, and the largest output, of a call. @@ -68,6 +71,7 @@ impl Default for Limits { Limits { deadline: Duration::from_secs(2), memory_bytes: 256 << 20, + table_elements: 16_384, module_bytes: 8 << 20, io_bytes: 16 << 20, stack_bytes: 512 << 10, @@ -172,6 +176,16 @@ struct State { logs: Vec, } +struct InvocationGuard { + deadline: Instant, + pending: Box bool + Send + Sync>, +} +impl InvocationGuard { + fn live(&self) -> bool { + Instant::now() < self.deadline && (self.pending)() + } +} + /// One app's instance of a [`Program`]: its own memory and its own state. /// It may move to another thread. pub struct Instance { @@ -186,6 +200,7 @@ pub struct Instance { /// be half-updated then (a Rust guest's stack pointer and allocator live /// in its memory and globals), so the instance takes no more calls. spent: bool, + guard: Option>, _ticker: Arc, } @@ -245,8 +260,25 @@ impl Runtime { // Check before caching: a refused module is never stored. check(&module)?; if let Ok(code) = module.serialize() { - let tmp = path.with_extension("tmp"); - if std::fs::write(&tmp, &code).is_ok() && std::fs::rename(&tmp, path).is_ok() { + // Different apps may compile identical bytes concurrently. + // Never truncate another worker's staging file: deserialization + // is only safe for complete, unmodified serialized modules. + static NEXT_CACHE_WRITE: AtomicU64 = AtomicU64::new(0); + let tmp = path.with_extension(format!( + "{}-{}.tmp", + std::process::id(), + NEXT_CACHE_WRITE.fetch_add(1, Ordering::Relaxed) + )); + let published = (|| -> std::io::Result<()> { + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&tmp)?; + file.write_all(&code)?; + drop(file); + std::fs::rename(&tmp, path) + })(); + if published.is_ok() { // Read it back off this thread: an on-access scanner // (Microsoft Defender's, on a managed Mac) holds the // first open of a new file for about a second, so let @@ -256,6 +288,7 @@ impl Runtime { .name("wasm-cache".into()) .spawn(move || drop(std::fs::read(path))); } + let _ = std::fs::remove_file(tmp); } } Ok(module) @@ -275,8 +308,38 @@ impl Runtime { /// A fresh instance of `program`, with its own memory. pub fn instantiate(&self, program: &Program) -> Result { + self.instantiate_inner(program, None) + } + + /// A fresh invocation whose start function and exported call share a + /// deadline. Cancellation is checked before starting and every epoch; + /// compiled code is reusable, but none of the instance's state is shared. + pub fn instantiate_guarded( + &self, + program: &Program, + deadline: Instant, + pending: impl Fn() -> bool + Send + Sync + 'static, + ) -> Result { + let deadline = deadline.min(Instant::now() + self.limits.deadline); + self.instantiate_inner(program, Some((deadline, Box::new(pending)))) + } + + fn instantiate_inner( + &self, + program: &Program, + guard: Option<(Instant, Box bool + Send + Sync>)>, + ) -> Result { + if guard + .as_ref() + .is_some_and(|(deadline, pending)| Instant::now() >= *deadline || !pending()) + { + return Err(LoadError::Invalid( + "invocation cancelled or past its deadline".into(), + )); + } let limits = StoreLimitsBuilder::new() .memory_size(self.limits.memory_bytes) + .table_elements(self.limits.table_elements) .instances(1) .memories(1) .tables(1) @@ -290,7 +353,19 @@ impl Runtime { }, ); store.limiter(|state| &mut state.limits); - store.epoch_deadline_trap(); + let guard = + guard.map(|(deadline, pending)| Arc::new(InvocationGuard { deadline, pending })); + if let Some(guard) = guard.clone() { + store.epoch_deadline_callback(move |_| { + Ok(if !guard.live() { + wasmtime::UpdateDeadline::Interrupt + } else { + wasmtime::UpdateDeadline::Continue(1) + }) + }); + } else { + store.epoch_deadline_trap(); + } // Instantiation runs the start function and data initialisers: a // deadline covers it too. let deadline_ticks = self @@ -299,7 +374,7 @@ impl Runtime { .as_millis() .div_ceil(TICK.as_millis()) .max(1) as u64; - store.set_epoch_deadline(deadline_ticks); + store.set_epoch_deadline(if guard.is_some() { 1 } else { deadline_ticks }); let mut linker = Linker::new(&self.engine); linker .func_wrap( @@ -349,6 +424,7 @@ impl Runtime { deadline_ticks, io_bytes: self.limits.io_bytes, spent: false, + guard, _ticker: self.ticker.clone(), }) } @@ -449,6 +525,10 @@ impl Instance { if self.spent { return Err(CallError::Spent); } + if self.guard.as_ref().is_some_and(|guard| !guard.live()) { + self.spent = true; + return Err(CallError::Deadline); + } let f = self .functions .get(name) @@ -470,7 +550,11 @@ impl Instance { } fn run(&mut self, f: TypedFunc<(i32, i32), i64>, input: &[u8]) -> Result, CallError> { - self.store.set_epoch_deadline(self.deadline_ticks); + self.store.set_epoch_deadline(if self.guard.is_some() { + 1 + } else { + self.deadline_ticks + }); let len = input.len() as i32; let ptr = self.alloc.call(&mut self.store, len).map_err(trap)?; self.memory diff --git a/crates/wasm-host/tests/runtime.rs b/crates/wasm-host/tests/runtime.rs index 0bf449b7..60e46114 100644 --- a/crates/wasm-host/tests/runtime.rs +++ b/crates/wasm-host/tests/runtime.rs @@ -116,7 +116,10 @@ fn a_function_gets_its_input_and_returns_its_output() { fn a_guest_error_comes_back_as_its_text_and_the_instance_goes_on() { let runtime = Runtime::new(limits(), None).unwrap(); let mut guest = instance(&runtime); - assert_eq!(guest.call("fail", b""), Err(CallError::Guest("nope".into()))); + assert_eq!( + guest.call("fail", b""), + Err(CallError::Guest("nope".into())) + ); assert!(!guest.spent()); assert_eq!(guest.call("upper", b"still here").unwrap(), b"STILL HERE"); } @@ -253,3 +256,140 @@ fn compiled_code_is_cached_by_digest() { ); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn table_initial_size_and_growth_have_an_element_cap() { + let runtime = Runtime::new( + Limits { + table_elements: 8, + ..limits() + }, + None, + ) + .unwrap(); + let guest = |initial: usize| { + wat::parse_str(format!( + r#"(module + (memory (export "memory") 1) (table {initial} funcref) + (func (export "octo_alloc") (param i32) (result i32) (i32.const 0)) + (func (export "octo_free") (param i32 i32)) + (func (export "grow") (param i32 i32) (result i64) + (drop (table.grow (ref.null func) (i32.const 9))) (i64.const 1)))"# + )) + .unwrap() + }; + let program = runtime.load(&guest(9)).unwrap(); + assert!( + runtime.instantiate(&program).is_err(), + "initial table must respect the cap" + ); + let program = runtime.load(&guest(1)).unwrap(); + let error = runtime + .instantiate(&program) + .unwrap() + .call("grow", b"") + .unwrap_err(); + assert!(matches!(error, CallError::Trap(_)), "{error}"); +} + +#[test] +fn guarded_invocations_observe_cancellation_and_the_absolute_deadline() { + use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }; + let runtime = Runtime::new( + Limits { + deadline: Duration::from_secs(2), + ..limits() + }, + None, + ) + .unwrap(); + let program = runtime.load(&wat::parse_str(GUEST).unwrap()).unwrap(); + assert!(runtime + .instantiate_guarded(&program, Instant::now(), || true) + .is_err()); + assert!(runtime + .instantiate_guarded(&program, Instant::now() + Duration::from_secs(2), || false) + .is_err()); + let pending = Arc::new(AtomicBool::new(true)); + let check = pending.clone(); + let mut instance = runtime + .instantiate_guarded( + &program, + Instant::now() + Duration::from_secs(2), + move || check.load(Ordering::Acquire), + ) + .unwrap(); + let cancel = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(40)); + pending.store(false, Ordering::Release); + }); + let started = Instant::now(); + assert_eq!(instance.call("spin", b""), Err(CallError::Deadline)); + assert!( + started.elapsed() < Duration::from_secs(1), + "cancellation interrupts active guest code" + ); + cancel.join().unwrap(); + let mut instance = runtime + .instantiate_guarded(&program, Instant::now() + Duration::from_millis(30), || { + true + }) + .unwrap(); + std::thread::sleep(Duration::from_millis(40)); + assert_eq!( + instance.call("spin", b""), + Err(CallError::Deadline), + "call does not restart an expired budget" + ); +} + +#[test] +fn concurrent_compilation_publishes_only_complete_cache_entries() { + use std::sync::{Arc, Barrier}; + let dir = + std::env::temp_dir().join(format!("octosense-wasm-cache-race-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let runtime = Arc::new(Runtime::new(limits(), Some(dir.clone())).unwrap()); + let bytes = Arc::new(wat::parse_str(GUEST).unwrap()); + let start = Arc::new(Barrier::new(4)); + let workers: Vec<_> = (0..4) + .map(|_| { + let (runtime, bytes, start) = (runtime.clone(), bytes.clone(), start.clone()); + std::thread::spawn(move || { + start.wait(); + let program = runtime.load(&bytes).unwrap(); + assert_eq!( + runtime + .instantiate(&program) + .unwrap() + .call("upper", b"parallel") + .unwrap(), + b"PARALLEL" + ); + }) + }) + .collect(); + for worker in workers { + worker.join().unwrap(); + } + let program = runtime.load(&bytes).unwrap(); + assert!(program.from_cache()); + assert_eq!( + runtime + .instantiate(&program) + .unwrap() + .call("upper", b"cached") + .unwrap(), + b"CACHED" + ); + assert!(std::fs::read_dir(&dir).unwrap().all(|entry| entry + .unwrap() + .path() + .extension() + .unwrap() + == "cwasm")); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/docs/adr/0011-apps-own-functions-in-webassembly.md b/docs/adr/0011-apps-own-functions-in-webassembly.md index 241d69f6..eefa2c57 100644 --- a/docs/adr/0011-apps-own-functions-in-webassembly.md +++ b/docs/adr/0011-apps-own-functions-in-webassembly.md @@ -37,9 +37,9 @@ outside their own input and output. Cranelift when they load. Compiled code is cached in the host directory, keyed by the module's digest and the engine's compatibility hash. Every call has a deadline (2 s, epoch interruption against wall-clock time); - memory (256 MiB), the wasm stack (512 KiB), the module (8 MiB) and input - and output (16 MiB) have caps. A trap or a deadline ends the call with an - error, never the process, and spends the instance: the next call gets a + memory (256 MiB), table elements (16,384), the wasm stack (512 KiB), + the module (8 MiB) and runtime input/output (16 MiB) have caps. A trap or + a deadline ends the call with an error, never the process, and spends the instance: the next call gets a fresh one, because a Rust guest's stack pointer and allocator may be half-updated. 3. **The `wasm` host service** (shell feature `wasm-lab`). `wasm.` @@ -48,8 +48,24 @@ outside their own input and output. anything else as JSON; JSON output comes back as data, anything else as `{"text": …}`. Modules come only from the calling app's own admitted, digest-checked bundle, never from an argument, and the service checks the - admitted manifest's grant itself. Each app has one worker thread, so no - call runs on the UI thread and a slow function holds up only its own app. + admitted manifest's grant itself. Each invocation gets a fresh instance, + including after successful calls and guest-returned errors: memory, globals, + tables and logs cannot carry one caller's input into another call. Only + compiled Programs are reused. The low-level runtime's reusable `Instance` + API remains available for benchmarks; the app service never reuses it. + + The service admits at most four active workers, four queued requests per + app, 1 MiB serialized input per request and 16 MiB total buffered input + (including executing calls). Full queues return an error without blocking + the UI. Workers release Programs and exit after five idle seconds. A + request has a ten-second budget including queueing/loading; guest start + and function execution share at most two seconds, with cancellation + checked at each epoch. Compilation itself is not interruptible, but an + expired or cancelled request cannot proceed from compilation to execution. + Admission is checked before queueing, before execution and before delivery; + changed bundles/grants or signed withdrawal discard the result and cached + Program references. On a valid new revision the worker loads the new code. + The disk cache contains compiled code only, never guest state. 4. **The `wasm` capability** joins App Hub's closed capability list. A store says: "Runs its own functions in a sandbox on this device; they reach no files, network or other apps." @@ -78,6 +94,9 @@ CoreMark on the M5 Max (higher is faster; native Rust is about 45,000): ## Measurements (Wasm Lab, `apps/wasmlab`) +These are the original runtime measurements, before the service switched to +fresh instances for every call. They are not a new end-to-end service benchmark. + Light algorithms only: Markdown to HTML (pulldown-cmark), free slots around busy times, fuzzy ranking (strsim), a line diff (similar). @@ -146,12 +165,14 @@ Two platform findings: (about 17× slower than Cranelift). A shipped system app's functions could be translated to native code when the app is built; a store app's could not. OpenHarmony's JIT policy is unknown (**unverified**). -- The limits are per call and per instance. Nothing yet stops an app from - keeping one core busy with back-to-back calls, or from using 256 MiB in - each of its eight modules; a per-app CPU and memory budget is follow-up - work. A request that App Hub has timed out still runs in the worker. -- An app updated while the shell runs keeps its old functions until the - shell restarts; workers live as long as the shell. +- Guest memory is no longer retained across calls or across the eight modules: + each worker holds at most one live instance, and only four workers may run. + Continuous admitted requests can still keep those workers busy; CPU fairness + across applications and a bounded disk-cache eviction policy remain follow-up + work. Compiled Programs also consume host memory beyond the linear-memory cap. +- Runtime and shell service regression tests cover actual Wasm state retention, + table growth, cancellation, input/queue bounds, revision/grant changes and + signed withdrawal. This hardening has not been revalidated on a phone yet. ## Open questions diff --git a/docs/adr/0011-apps-own-functions-in-webassembly.zh-CN.md b/docs/adr/0011-apps-own-functions-in-webassembly.zh-CN.md index 0c1bec3d..e6f5e7f7 100644 --- a/docs/adr/0011-apps-own-functions-in-webassembly.zh-CN.md +++ b/docs/adr/0011-apps-own-functions-in-webassembly.zh-CN.md @@ -26,15 +26,25 @@ Rust 写:解析器、排序或排程算法、差异比较、格式转换,或 serde 类型生成这一切。这一版不用组件模型,也不用 WASI。 2. **运行时。** `crates/wasm-host` 在 Wasmtime 49 中运行模块,模块加载时由 Cranelift 编译。编译结果缓存在宿主目录中,以模块摘要和引擎兼容性哈希为键。每次调用都有截止时间 - (2 秒,按实际经过的时间做 epoch 中断);内存(256 MiB)、wasm 栈(512 KiB)、模块 + (2 秒,按实际经过的时间做 epoch 中断);内存(256 MiB)、表元素(16,384 个)、wasm 栈(512 KiB)、模块 (8 MiB)以及输入输出(16 MiB)都有上限。陷阱或超时只让这次调用出错,绝不结束进程, 并作废这个实例:下次调用会得到新实例,因为 Rust 客体的栈指针和分配器可能只更新了一半。 3. **`wasm` 宿主服务**(外壳特性 `wasm-lab`)。`wasm.` 调用发起请求的应用自己的 - 函数;`wasm.functions` 列出这些函数、它们的加载情况和运行统计。字符串参数按文本传入, - 其他参数按 JSON 传入;JSON 输出作为数据返回,其他输出返回为 `{"text": …}`。模块只来自 - 发起请求的应用自己那个已准入、经过摘要校验的应用包,绝不来自参数;服务还会自己检查已准入 - 清单中的授权。每个应用有一个工作线程,所以没有调用在 UI 线程上运行,一个慢函数只会拖慢 - 它自己的应用。 + 函数;`wasm.functions` 列出这些函数、加载情况和运行统计。字符串参数按文本传入, + 其他参数按 JSON 传入;JSON 输出作为数据返回,其他输出返回 `{"text": …}`。 + 模块只来自调用应用自己通过准入、经过摘要检查的应用包,而不是请求参数指定的文件。 + 每次调用都使用全新实例,包括上次成功或返回客体错误的情况;内存、全局变量、表和 + 日志不能把某次调用的输入带到下一次。只复用编译后的 Program。底层运行时仍为基准 + 测试提供可复用的 `Instance` API,但应用服务不复用实例。 + + 服务最多允许四个活动工作线程,每个应用最多排队四个请求;每个请求序列化后的输入 + 不超过 1 MiB,所有排队及正在执行的输入合计不超过 16 MiB。队列已满时立即返回错误, + 不阻塞界面线程。工作线程空闲五秒后释放 Program 并退出。每个请求从排队到加载有 + 十秒总预算;客体启动函数与导出函数共享最多两秒的执行预算,每次 epoch 检查取消。 + 编译本身不能被中断,但过期或已取消的请求不会在编译后继续执行。 + 服务在入队前、执行前和交付结果前重新检查准入;应用包、权限发生变化或签名目录 + 撤回应用时,丢弃结果和缓存的 Program 引用。有效新版本的下一次调用会加载新代码。 + 磁盘缓存只包含编译代码,不包含客体状态。 4. **`wasm` 能力**加入 App Hub 封闭的能力列表。商店的说明是:“在此设备上的沙盒中运行它 自带的函数;这些函数接触不到文件、网络或其他应用。” 5. **Agent 工具。** 工具可以写 `host_method: "wasm."`:应用的 Agent(以及在 @@ -60,6 +70,9 @@ M5 Max 上的 CoreMark(越高越快;原生 Rust 约 45,000): ## 测量(Wasm Lab,`apps/wasmlab`) +以下是服务改为每次调用使用全新实例之前的原始运行时测量,不代表本次加固后的 +端到端服务性能。 + 只有轻量算法:Markdown 转 HTML(pulldown-cmark)、在忙碌时段之间找空闲时段、模糊排序 (strsim)、按行差异比较(similar)。 @@ -115,10 +128,11 @@ Cranelift 与 LLVM 的代码生成,而不是向量化。Wasmtime 和 Cranelift - iOS 不允许应用使用 JIT,也不允许下载原生代码:那里只能用 Pulley(比 Cranelift 慢约 17 倍)。随系统发布的应用,其函数可以在构建应用时转成原生代码;商店应用不行。 OpenHarmony 的 JIT 策略未知(**未验证**)。 -- 限制是按调用、按实例计算的。目前还没有办法阻止一个应用用连续调用占满一个核心,或在 - 它的八个模块里各用 256 MiB;按应用的 CPU 和内存预算是后续工作。App Hub 已判定超时的 - 请求,仍会在工作线程里运行。 -- 外壳运行期间更新的应用,在外壳重启前仍使用旧函数;工作线程与外壳同寿命。 +- 客体内存不再跨调用或在八个模块间保留:每个工作线程最多有一个活动实例,最多 + 四个工作线程。持续准入的请求仍可占满这些线程;应用间 CPU 公平调度和磁盘编译缓存 + 的有界淘汰策略仍待实现。编译后的 Program 也会占用线性内存上限以外的宿主内存。 +- 运行时和外壳服务回归测试覆盖真实 Wasm 状态残留、表扩容、取消、输入/队列上限、 + 版本/权限变化及签名目录撤回。本次加固尚未在手机上重新验证。 ## 未决问题 From 43bad6643f0aa759ff4003e987926806621636e3 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:42:09 -0700 Subject: [PATCH 22/35] Move Wasm admission off the UI thread and retain worker slots through cleanup --- crates/shell/src/wasm_service.rs | 296 ++++++++++++++---- .../0011-apps-own-functions-in-webassembly.md | 13 +- ...apps-own-functions-in-webassembly.zh-CN.md | 8 +- 3 files changed, 252 insertions(+), 65 deletions(-) diff --git a/crates/shell/src/wasm_service.rs b/crates/shell/src/wasm_service.rs index 84c851b6..bb277d11 100644 --- a/crates/shell/src/wasm_service.rs +++ b/crates/shell/src/wasm_service.rs @@ -53,6 +53,35 @@ static BUFFERED_BYTES: AtomicUsize = AtomicUsize::new(0); struct Worker { jobs: SyncSender, retired: Arc, + admission: Arc, +} + +/// Enqueue only clones this host-owned identity. Bundle IO, signature checks +/// and digesting run on the worker, including the first cold admission. +#[derive(Default)] +struct AdmissionEpoch { + admitted: OnceLock>, + obsolete: AtomicBool, +} + +/// Removed only after the worker has dropped its queue and compiled modules. +/// A closing worker keeps its slot until then, even during revision churn. +struct RetireWorker { + app: String, + retired: Arc, +} +impl Drop for RetireWorker { + fn drop(&mut self) { + let mut workers = WORKERS.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(workers) = workers.as_mut() { + if workers + .get(&self.app) + .is_some_and(|worker| Arc::ptr_eq(&worker.retired, &self.retired)) + { + workers.remove(&self.app); + } + } + } } /// Includes executing input: cancelling a queued request cannot free its @@ -108,7 +137,7 @@ struct Job { method: String, input: Vec, host_dir: PathBuf, - admission: Admission, + admission: Arc, deadline: Instant, reply: Replier, _reservation: Reservation, @@ -117,15 +146,39 @@ impl Job { fn live(&self) -> bool { self.reply.is_pending() && Instant::now() < self.deadline } - fn check(&self, app: &str) -> Result<(), String> { + fn check(&self, app: &str) -> Result<&Admission, String> { if !self.live() { return Err("wasm invocation cancelled or past its deadline".into()); } - self.admission.verify(app)?; + if self.admission.obsolete.load(Ordering::Acquire) { + return Err("wasm app admission changed; retry from the current app".into()); + } + let admitted = self + .admission + .admitted + .get_or_init(|| Admission::current(app)); + let verified = admitted + .as_ref() + .map_err(Clone::clone) + .and_then(|admission| { + admission.verify(app)?; + Ok(admission) + }); + let admission = match verified { + Ok(admission) => admission, + Err(error) => { + self.admission.obsolete.store(true, Ordering::Release); + return Err(error); + } + }; + // The cache is host-owned, not a caller-selected or app directory. + if self.host_dir != admission.root.join(".host") { + return Err("wasm host directory does not belong to this app store".into()); + } if !self.live() { return Err("wasm invocation cancelled or past its deadline".into()); } - Ok(()) + Ok(admission) } } @@ -176,14 +229,9 @@ impl HostService for WasmService { let prepared = (|| { let input = input_bytes(&call.args)?; let reservation = Reservation::acquire(input.len())?; - let admission = Admission::current(&call.app_id)?; - // The cache is host-owned, not a caller-selected or app directory. - if call.host_dir != admission.root.join(".host") { - return Err("wasm host directory does not belong to this app store".into()); - } - Ok((input, reservation, admission)) + Ok((input, reservation)) })(); - let (input, reservation, admission) = match prepared { + let (input, reservation) = match prepared { Ok(prepared) => prepared, Err(error) => { reply.send(Err(error)); @@ -195,7 +243,7 @@ impl HostService for WasmService { method: call.method().to_string(), host_dir: call.host_dir, input, - admission, + admission: Arc::default(), deadline, reply, _reservation: reservation, @@ -203,20 +251,33 @@ impl HostService for WasmService { if !job.live() { return; } - let mut workers = WORKERS.lock().unwrap_or_else(|e| e.into_inner()); - let workers = workers.get_or_insert_with(HashMap::new); - workers.retain(|_, worker| !worker.retired.load(Ordering::Acquire)); - if let Some(worker) = workers.get(&app) { - job = match worker.jobs.try_send(job) { + let mut registry = WORKERS.lock().unwrap_or_else(|e| e.into_inner()); + let workers = registry.get_or_insert_with(HashMap::new); + if let Some(worker) = workers.get_mut(&app) { + if worker.retired.load(Ordering::Acquire) { + job.reply + .send(Err("wasm worker is closing; try again later".into())); + return; + } + if worker.admission.obsolete.load(Ordering::Acquire) { + worker.admission = Arc::default(); + } + job.admission = worker.admission.clone(); + match worker.jobs.try_send(job) { Ok(()) => return, Err(TrySendError::Full(job)) => { job.reply .send(Err("wasm app queue is full; try again later".into())); return; } - Err(TrySendError::Disconnected(job)) => job, + Err(TrySendError::Disconnected(job)) => { + // The worker's guard removes its slot after all resources + // have dropped. Do not create an overlapping replacement. + job.reply + .send(Err("wasm worker is closing; try again later".into())); + return; + } }; - workers.remove(&app); } if workers.len() >= MAX_WORKERS { job.reply @@ -225,35 +286,64 @@ impl HostService for WasmService { } let (jobs, queue) = sync_channel(MAX_QUEUED_PER_APP); let retired = Arc::new(AtomicBool::new(false)); - let done = retired.clone(); + let admission = job.admission.clone(); + // Reserve the worker slot and queue before spawning, then release the + // registry mutex: neither thread creation nor admission holds it. + if jobs.try_send(job).is_err() { + unreachable!("new wasm queue has room"); + } + workers.insert( + app.clone(), + Worker { + jobs, + retired: retired.clone(), + admission, + }, + ); + // Keep ownership outside the spawn closure too, so a spawn failure + // can explicitly answer every request accepted during this interval. + let queue = Arc::new(Mutex::new(Some(queue))); + drop(registry); + self.start_worker(app, retired, queue); + } +} + +impl WasmService { + fn start_worker( + &self, + app: String, + retired: Arc, + queue: Arc>>>, + ) { let name = app.clone(); + let worker_queue = queue.clone(); + let worker_retired = retired.clone(); let spawned = std::thread::Builder::new() .name(format!("wasm {app}")) .spawn(move || { - struct Retire(Arc); - impl Drop for Retire { - fn drop(&mut self) { - self.0.store(true, Ordering::Release); - } - } - let _retire = Retire(done); - work(name, queue, &_retire.0); + let _retire = RetireWorker { + app: name.clone(), + retired: worker_retired.clone(), + }; + let queue = worker_queue + .lock() + .unwrap_or_else(|e| e.into_inner()) + .take() + .unwrap(); + work(name, queue, &worker_retired); }); if let Err(error) = spawned { - job.reply - .send(Err(format!("cannot start {app}'s functions: {error}"))); - return; - } - if let Err(error) = jobs.try_send(job) { - let job = match error { - TrySendError::Full(job) | TrySendError::Disconnected(job) => job, - }; - job.reply.send(Err( - "wasm worker stopped before accepting the invocation".into() - )); - return; + let registry = WORKERS.lock().unwrap_or_else(|e| e.into_inner()); + retired.store(true, Ordering::Release); + drop(registry); + if let Some(queue) = queue.lock().unwrap_or_else(|e| e.into_inner()).take() { + for job in queue.try_iter() { + job.reply + .send(Err(format!("cannot start {app}'s functions: {error}"))); + } + } + drop(RetireWorker { app, retired }); } - workers.insert(app, Worker { jobs, retired }); } } @@ -269,19 +359,13 @@ fn work(app: String, jobs: Receiver, retired: &Arc) { Err(_) => { // Serialize idle retirement with enqueue. A sender that won // the timeout race still gets its accepted request processed. - let mut workers = WORKERS.lock().unwrap_or_else(|e| e.into_inner()); + let _workers = WORKERS.lock().unwrap_or_else(|e| e.into_inner()); if let Ok(job) = jobs.try_recv() { job } else { retired.store(true, Ordering::Release); - if let Some(workers) = workers.as_mut() { - if workers - .get(&app) - .is_some_and(|worker| Arc::ptr_eq(&worker.retired, retired)) - { - workers.remove(&app); - } - } + // The outer retirement guard removes the slot only once + // this function has dropped the Lab and queued inputs. return; } } @@ -307,13 +391,16 @@ fn process_checked( job: &Job, before_delivery: impl FnOnce(), ) -> Result { - if let Err(error) = job.check(app) { - *lab = None; - return Err(error); - } + let admission = match job.check(app) { + Ok(admission) => admission, + Err(error) => { + *lab = None; + return Err(error); + } + }; if lab .as_ref() - .is_some_and(|loaded| loaded.admission != job.admission) + .is_some_and(|loaded| loaded.admission != *admission) { *lab = None; } @@ -379,7 +466,7 @@ struct Stats { impl Lab { fn load(app: &str, job: &Job) -> Result { - let admission = &job.admission; + let admission = job.check(app)?; let bundle = &admission.bundle; let runtime = runtime(&job.host_dir)?; let mut files: Vec = std::fs::read_dir(bundle.join("fns")) @@ -684,7 +771,10 @@ mod tests { method: method.into(), input: Vec::new(), host_dir: host_dir.into(), - admission: Admission::current(app).unwrap(), + admission: Arc::new(AdmissionEpoch { + admitted: OnceLock::from(Ok(Admission::current(app).unwrap())), + obsolete: AtomicBool::new(false), + }), deadline: Instant::now() + REQUEST_TIMEOUT, reply, _reservation: Reservation::acquire(0).unwrap(), @@ -721,6 +811,53 @@ mod tests { octosense_appstore::set_data_root(root.clone()); ship_state("os.wasmstate", "1.0.0", true); register(); + // Real dispatch into a held cold queue must neither unpack nor verify + // a bundle on this (UI) thread. The old synchronous admission path + // creates .system here and populates the identity before returning. + let (cold_sender, cold_queue) = sync_channel(MAX_QUEUED_PER_APP); + let cold_epoch = Arc::new(AdmissionEpoch::default()); + WORKERS + .lock() + .unwrap() + .get_or_insert_with(HashMap::new) + .insert( + "os.wasmstate".into(), + Worker { + jobs: cold_sender, + retired: Arc::new(AtomicBool::new(false)), + admission: cold_epoch.clone(), + }, + ); + let cold_heap = NEXT_HEAP.fetch_add(1, Ordering::Relaxed); + octosense_appstore::services::dispatch( + ServiceCall { + app_id: "os.wasmstate".into(), + service: "wasm.recall".into(), + args: json!(null), + from_sheet: false, + may_prompt: false, + host_dir: host_dir.clone(), + }, + cold_heap, + 1, + &mut NoSheet, + ); + assert!(cold_epoch.admitted.get().is_none()); + assert!( + !root.join(".system").exists(), + "enqueue must not touch bundle storage" + ); + let cold_job = cold_queue.try_recv().unwrap(); + assert!(process("os.wasmstate", &mut None, &cold_job).is_ok()); + assert!(cold_epoch.admitted.get().unwrap().is_ok()); + assert!( + root.join(".system").exists(), + "the worker performed actual admission" + ); + octosense_appstore::services::cancel_heap(cold_heap); + drop(cold_job); + WORKERS.lock().unwrap().as_mut().unwrap().clear(); + drop(cold_queue); // Positive control: this guest really retains input when an Instance // is reused, reproducing the old host behavior rather than testing an // inert fixture that could never leak. @@ -812,6 +949,10 @@ mod tests { Worker { jobs: tx, retired: Arc::new(AtomicBool::new(false)), + admission: Arc::new(AdmissionEpoch { + admitted: OnceLock::from(Ok(Admission::current("os.wasmstate").unwrap())), + obsolete: AtomicBool::new(false), + }), }, ); let mut heaps = Vec::new(); @@ -838,11 +979,43 @@ mod tests { assert!(replies[0].2.as_ref().unwrap_err().contains("queue is full")); } } + let queued = held.try_iter().collect::>(); + assert_eq!(queued.len(), MAX_QUEUED_PER_APP); + ship_state("os.wasmstate", "5.0.0", true); + for job in &queued { + assert!( + process("os.wasmstate", &mut lab, job) + .unwrap_err() + .contains("admission changed"), + "the actual dispatch queue retains its original verified revision" + ); + } + let retry_heap = NEXT_HEAP.fetch_add(1, Ordering::Relaxed); + octosense_appstore::services::dispatch( + ServiceCall { + app_id: "os.wasmstate".into(), + service: "wasm.recall".into(), + args: json!(null), + from_sheet: false, + may_prompt: false, + host_dir: host_dir.clone(), + }, + retry_heap, + 1, + &mut NoSheet, + ); + let retry = held.try_recv().unwrap(); + assert!(!Arc::ptr_eq(&retry.admission, &queued[0].admission)); + assert_eq!( + process("os.wasmstate", &mut lab, &retry).unwrap(), + json!({"text":""}), + "a fresh dispatched request re-admits the new revision on the same worker" + ); + octosense_appstore::services::cancel_heap(retry_heap); + drop(retry); for heap in heaps { octosense_appstore::services::cancel_heap(heap); } - let queued = held.try_iter().collect::>(); - assert_eq!(queued.len(), MAX_QUEUED_PER_APP); assert!(queued.iter().all(|job| !job.live())); drop(queued); let used = BUFFERED_BYTES.load(Ordering::Acquire); @@ -862,7 +1035,10 @@ mod tests { format!("busy-{n}"), Worker { jobs, - retired: Arc::new(AtomicBool::new(false)), + // Closing workers still hold resources. They must not + // release a slot merely because retirement has started. + retired: Arc::new(AtomicBool::new(n == 0)), + admission: Arc::default(), }, ); } diff --git a/docs/adr/0011-apps-own-functions-in-webassembly.md b/docs/adr/0011-apps-own-functions-in-webassembly.md index eefa2c57..fdedbe55 100644 --- a/docs/adr/0011-apps-own-functions-in-webassembly.md +++ b/docs/adr/0011-apps-own-functions-in-webassembly.md @@ -62,9 +62,16 @@ outside their own input and output. and function execution share at most two seconds, with cancellation checked at each epoch. Compilation itself is not interruptible, but an expired or cancelled request cannot proceed from compilation to execution. - Admission is checked before queueing, before execution and before delivery; - changed bundles/grants or signed withdrawal discard the result and cached - Program references. On a valid new revision the worker loads the new code. + Queueing clones a host-owned admission identity; it does no bundle IO, + signature verification or digest work on the UI thread. The first cold + request binds that identity when the worker first verifies admission: + `ServiceCall` does not carry the originating UI's loaded-revision token. + Later queued requests retain that verified revision. The worker rechecks + admission before execution and before delivery; changed bundles/grants or + signed withdrawal discard the result and cached Program references, and + invalidate the old identity. A fresh request can then load the new code; + already queued old requests remain refused. A closing worker keeps its + global slot until its queue and compiled Programs have been dropped. The disk cache contains compiled code only, never guest state. 4. **The `wasm` capability** joins App Hub's closed capability list. A store says: "Runs its own functions in a sandbox on this device; they reach no diff --git a/docs/adr/0011-apps-own-functions-in-webassembly.zh-CN.md b/docs/adr/0011-apps-own-functions-in-webassembly.zh-CN.md index e6f5e7f7..76eba01c 100644 --- a/docs/adr/0011-apps-own-functions-in-webassembly.zh-CN.md +++ b/docs/adr/0011-apps-own-functions-in-webassembly.zh-CN.md @@ -42,8 +42,12 @@ Rust 写:解析器、排序或排程算法、差异比较、格式转换,或 不阻塞界面线程。工作线程空闲五秒后释放 Program 并退出。每个请求从排队到加载有 十秒总预算;客体启动函数与导出函数共享最多两秒的执行预算,每次 epoch 检查取消。 编译本身不能被中断,但过期或已取消的请求不会在编译后继续执行。 - 服务在入队前、执行前和交付结果前重新检查准入;应用包、权限发生变化或签名目录 - 撤回应用时,丢弃结果和缓存的 Program 引用。有效新版本的下一次调用会加载新代码。 + 入队只复制宿主持有的准入身份引用,不在界面线程读取应用包、验签或计算摘要。 + 第一次冷启动请求在工作线程首次验证准入时绑定身份;`ServiceCall` 并不携带发起 + 请求的界面已加载版本的令牌。后续排队请求保留该已验证版本。工作线程在执行前和 + 交付结果前重新检查准入;应用包、权限发生变化或签名目录撤回应用时,丢弃结果和 + 缓存的 Program 引用,并使旧身份失效。此后的新请求可以加载新代码;已经排队的 + 旧请求仍会被拒绝。正在关闭的工作线程在释放队列和编译模块之前继续占用全局名额。 磁盘缓存只包含编译代码,不包含客体状态。 4. **`wasm` 能力**加入 App Hub 封闭的能力列表。商店的说明是:“在此设备上的沙盒中运行它 自带的函数;这些函数接触不到文件、网络或其他应用。” From 723b202e76c62473aa0d95f16a58f38553a2882d Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:04:30 -0700 Subject: [PATCH 23/35] test: keep backend login status visible on short desktop viewports --- tools/connected-e2e/README.md | 9 ++-- tools/connected-e2e/README.zh-CN.md | 7 +-- tools/connected-e2e/backend-login/main.splash | 4 +- tools/connected-e2e/backend_login.py | 25 +++++++++ .../evidence/linux-backend-native.json | 26 +++++----- tools/test_backend_failure_diagnostics.py | 51 +++++++++++++++++++ 6 files changed, 102 insertions(+), 20 deletions(-) create mode 100644 tools/test_backend_failure_diagnostics.py diff --git a/tools/connected-e2e/README.md b/tools/connected-e2e/README.md index f43492e2..7f21fb08 100644 --- a/tools/connected-e2e/README.md +++ b/tools/connected-e2e/README.md @@ -159,9 +159,12 @@ rotated refresh recovery, native restart, logout, repeat login and isolation between two signed apps. MiniBrowser/WebKitGTK 2.52.6 and native Secret Service ran in an isolated display/session; all owned processes and fictional connections were cleaned up. The receipt binds the actual binary and source -hashes. Its archived Cargo lock predates the later Wasm integration; the 35 -other fixture/OAuth source files match this acceptance change. This is not an -exact-head whole-shell build claim. +hashes. The native examples were rebuilt from `e9765575` with its current locked +dependencies; all 36 recorded host/OAuth/fixture/lockfile hashes match the +reviewed source. The final run includes the responsive fixture and bounded +failure diagnostics; those two test files changed after the native build, with +no Rust source changes. This validates the installed-app fixture, not the entire +shell or OS-authenticated business writes. Build the host/installer as above and the pinned Hub CLI with `cargo build --locked --release -p octosense-app-hub --bin hub`. With an existing diff --git a/tools/connected-e2e/README.zh-CN.md b/tools/connected-e2e/README.zh-CN.md index c09f4fa5..5e1c4dd4 100644 --- a/tools/connected-e2e/README.zh-CN.md +++ b/tools/connected-e2e/README.zh-CN.md @@ -128,9 +128,10 @@ Android 开发者后端登录使用独立测试包和真实表单,参见 [Andr 登录与 PKCE 回调、受保护身份读取、轮换刷新恢复、原生进程重启、退出登录、 再次登录,以及两个签名应用之间的账户隔离。MiniBrowser/WebKitGTK 2.52.6 和原生 Secret Service 在隔离显示/会话中运行;所有测试进程与虚构连接均已清理。 -回执绑定实际二进制及源码哈希。归档 Cargo 锁文件早于之后的 Wasm 集成,其余 -35 个测试/OAuth 源文件与本次验收变更一致。这不表示整个 shell 的当前提交构建 -已通过。 +回执绑定实际二进制及源码哈希。原生示例使用 `e9765575` 的当前锁定依赖重新构建, +记录的全部 36 个宿主/OAuth/测试/锁文件哈希均与审阅源码一致。最终运行包括响应式 +测试界面及有界故障诊断;这两个测试文件在原生构建后修改,Rust 源码没有变化。 +这验证已安装应用测试流程,不代表整个 shell 或 OS 认证业务写入通过。 按前文构建宿主/安装器,并用 `cargo build --locked --release -p octosense-app-hub --bin hub` 构建固定版本 Hub diff --git a/tools/connected-e2e/backend-login/main.splash b/tools/connected-e2e/backend-login/main.splash index 90393491..df986c75 100644 --- a/tools/connected-e2e/backend-login/main.splash +++ b/tools/connected-e2e/backend-login/main.splash @@ -53,6 +53,8 @@ SolidView {width: Fill height: Fill flow: Down padding: 24 spacing: 10 draw_bg.c Label {width: Fill text: "Synthetic test account. Credentials stay in the browser and host vault." draw_text.color: #43536c} account := Label {width: Fill text: "No account" draw_text.color: #172033} identity := Label {width: Fill text: "No protected data" draw_text.color: #172033} + status := Label {width: Fill text: "Ready" draw_text.color: #233854} + ScrollYView {width: Fill height: Fill flow: Down spacing: 10 Button {width: Fill height: 44 text: "Connect backend" on_click: || connect("backend", ["app.session"])} Button {width: Fill height: 44 text: "Connect in WebView" on_click: || connect_webview()} Button {width: Fill height: 44 text: "Connect GitHub" on_click: || connect("github", ["read:user"])} @@ -61,5 +63,5 @@ SolidView {width: Fill height: Fill flow: Down padding: 24 spacing: 10 draw_bg.c Button {width: Fill height: 44 text: "Protected identity" on_click: || identity()} Button {width: Fill height: 44 text: "Disconnect" on_click: || disconnect()} Button {width: Fill height: 44 text: "Probe other app" on_click: || probe()} - status := Label {width: Fill text: "Ready" draw_text.color: #233854} + } } diff --git a/tools/connected-e2e/backend_login.py b/tools/connected-e2e/backend_login.py index 463ab133..36828c14 100644 --- a/tools/connected-e2e/backend_login.py +++ b/tools/connected-e2e/backend_login.py @@ -61,6 +61,30 @@ def call(self, route, **query): time.sleep(.2) +def failure_geometry(native): + """Bounded structural diagnostics; no widget text, URLs or log contents.""" + snapshot = native.call('snap').get('s', []) + windows = native.call('s').get('w', []) + allowed_ids = {'status', 'oauth_status', 'account', 'identity', 'app', 'sheet'} + def dimensions(value, length): + return value if isinstance(value, list) and len(value) == length and all( + type(n) in (int, float) and -1e9 <= n <= 1e9 for n in value) else None + rows = [] + for row in snapshot[:80]: + kind = row.get('ty', '') + rows.append({ + 'kind': kind if re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]{0,63}', kind) else 'other', + 'id': row.get('i') if row.get('i') in allowed_ids else None, + 'rect': dimensions(row.get('r'), 4), + }) + log = native.log_path.read_text(errors='replace') + return {'child_running': native.child.poll() is None, + 'window_sizes': [dimensions(window.get('sz'), 2) for window in windows[:4]], + 'snapshot_row_count': len(snapshot), 'rows': rows, + 'log_markers': {marker: log.count(marker) for marker in + ('ScriptError', 'callback error', 'on_render closure failed', 'panicked')}} + + def bundle(path, app, hub): path.mkdir() shutil.copyfile(ROOT / 'tools/connected-e2e/backend-login/main.splash', path / 'main.splash') @@ -300,6 +324,7 @@ def login(capture, register=False, label='login'): receipt['error_type'] = type(error).__name__ if native: try: + receipt['failure_geometry'] = failure_geometry(native) statuses = [] for row in native.rows(): if row.get('i') not in ('status', 'oauth_status'): diff --git a/tools/connected-e2e/evidence/linux-backend-native.json b/tools/connected-e2e/evidence/linux-backend-native.json index c69bfc5d..8f1fee90 100644 --- a/tools/connected-e2e/evidence/linux-backend-native.json +++ b/tools/connected-e2e/evidence/linux-backend-native.json @@ -8,8 +8,8 @@ "browser_engine": "MiniBrowser", "installation": "ephemeral fixture signing, actual Store install and prepared launch", "input": "Makepad native instrument, default input acknowledgement, no input replay", - "binary_sha256": "d9695098fb0d79f2300d9d504e54ea3e32fc9514c5c70a64ff2ff4eef8e65a05", - "installer_sha256": "59b284d24c2c12c5fd63a220c78dd338cbee01beeccbfca5297a2c39dfc21d35", + "binary_sha256": "389e797556f346dadb04e878f7a7efbc3be8b590d7d69079140a150231906ab4", + "installer_sha256": "fc094084e7f39f2e86c6bb68f2f0a1bdc01414957033e5b7528f723373bd0dfe", "source_identity": "Archived source: file hashes below are authoritative", "checks": [ "Short-lived token forces refresh; transient protected-data failure followed by another refresh recovers using durably stored rotated credential", @@ -21,13 +21,13 @@ "Both fictional handles durably revoked through real host service; platform-vault deletion requested (deletion not independently read back)" ], "source_sha256": { - "tools/connected-e2e/backend_login.py": "bacda9960c4f8b25735ffab76d61fb518cc99efeb5af63b8aa019df281ffc79a", + "tools/connected-e2e/backend_login.py": "032d20c3c259ea9b62f5fa510d04eac410912e33baff26b090ba94f318e4d355", "tools/backend-login-fixture.py": "5c2695ad6da5d82b0c41782cefd77392130cc8c362ecf1170b87ea4512000424", "tools/connected-e2e/native.py": "3182c430d9b3797a823a67403543d056891598b404cf7fda577836f09e7ae7b6", - "tools/connected-e2e/backend-login/main.splash": "f2953891acde9ab08625c14e5a7d5dd73e809c1702777a9229276cbc5cee24ac", + "tools/connected-e2e/backend-login/main.splash": "817ace3dd8b87bfcecdbd9fffe4b8f13fb7721ed1240e0c849f267c0853c42e8", "crates/shell/examples/connected-app-host.rs": "5e960ac06a96c5302f5f89e72ee6fd45bcfbf4c0a033f94ee67e1cb7a8bb6d92", "crates/shell/examples/connected_support/mod.rs": "5e6a567808dfe34b21d9348afe9345a52ec9165f747504c623bdc9af6898ab2d", - "Cargo.lock": "e23635cea386bb4dab2f922fe3e0210fdfa09103447f2a503c162d3f48696daa", + "Cargo.lock": "05993722b356ca50434220d705affac333c73878e50a1e03f5e1419daa5c656b", "crates/oauth-service/src/acceptance_calendar.rs": "e3f03818b554cbfdd1f7d9bcf22272e8ec46c25bffc69fd4498c690c137446ac", "crates/oauth-service/src/acceptance_fixtures.rs": "af8b2c8187a35015ea42b59aa7b2ca8e5c4e4834efa0144259ca691f00bcfdf0", "crates/oauth-service/src/acceptance_github.rs": "520fd3681f4831c35cf671f0d4883af8c7d9e63e748ec41ede96d54fcaa77c7d", @@ -42,7 +42,7 @@ "crates/oauth-service/src/host.rs": "401a3810d2f76ec8cbaf62b344f060ed9b073c751500fc8143eb7398bf8fd069", "crates/oauth-service/src/host_api.rs": "a38e2fd7b0b24eda2d8c0f974b4032551e1286a905273eb6772d0475836da418", "crates/oauth-service/src/host_backend.rs": "1839063da9a9f7534e79839fc807abe3b64143efab998678599a699604a8d066", - "crates/oauth-service/src/host_catalog.rs": "fb7cc2a019b56dc230009d4aa36168734b3bf71c3cd378584f2f47b4a016774e", + "crates/oauth-service/src/host_catalog.rs": "e8cb3e4b02cb95703e3650e1fe6db9ccda49e2ad4c84e2ebc19dcf5d37740760", "crates/oauth-service/src/host_inbox.rs": "aa69422fd3fbe504035eb60fe4203222ebb9f1877884f0dbe77143f864c48ad8", "crates/oauth-service/src/host_lifetime_tests.rs": "50904d91121ae3f16caf4c8c9008c66f9cd6b85e2af54558451be683079c662b", "crates/oauth-service/src/host_operation_tests.rs": "ba0dc9cbbbcc592b1268be23d2e45e0cd8faa3e1ff7f202090cc7d1df3f45526", @@ -60,7 +60,7 @@ }, "local_connections_revoked": true, "vault_cleanup": "Deletion requested through host disconnect; no independent vault readback", - "finished_utc": "2026-10-08T04:11:21.895397+00:00", + "finished_utc": "2026-10-08T05:03:32.191324+00:00", "native_processes_stopped": true, "synthetic_server_stopped": true, "visual_review": "pending", @@ -82,10 +82,10 @@ "physical or OS-authenticated business write approval" ], "source_comparison": { - "matching_current_files": 35, - "different_files": [ - "Cargo.lock" - ], - "reason": "Tested archived lock predates later Wasm integration; actual tested hash retained, not whole-shell exact-head evidence" - } + "matching_current_files": 36, + "different_files": [], + "reason": "All recorded host, OAuth, fixture and lockfile hashes match the reviewed source; native binary built from e9765575, then only the driver and responsive fixture changed." + }, + "native_build_source_revision": "e9765575fcb102ccf2ca7354daab5eb005ce3526", + "runtime_patches_lock_sha256": "b0679f53d756dee9dc8ee6b0aa3e560fd407b75006b1d1d35a70436467ada43c" } diff --git a/tools/test_backend_failure_diagnostics.py b/tools/test_backend_failure_diagnostics.py new file mode 100644 index 00000000..a1e560e1 --- /dev/null +++ b/tools/test_backend_failure_diagnostics.py @@ -0,0 +1,51 @@ +"""Failure receipts preserve geometry, never fixture text or raw log content.""" +import importlib.util +import json +from pathlib import Path +import sys +import tempfile +import unittest +from types import SimpleNamespace + +TOOLS = Path(__file__).resolve().parent +sys.path.insert(0, str(TOOLS / 'connected-e2e')) +spec = importlib.util.spec_from_file_location('backend_login_diagnostics', + TOOLS / 'connected-e2e/backend_login.py') +backend = importlib.util.module_from_spec(spec) +spec.loader.exec_module(backend) + + +class FailureDiagnosticsTests(unittest.TestCase): + def report(self, rows, windows): + with tempfile.TemporaryDirectory() as directory: + log = Path(directory) / 'native.log' + log.write_text('ScriptError: synthetic-sensitive-content https://example.test/?code=fictional') + native = SimpleNamespace(log_path=log, child=SimpleNamespace(poll=lambda: None), + call=lambda route: {'s': rows} if route == 'snap' else {'w': windows}) + return backend.failure_geometry(native) + + def test_reports_clipping_without_text_or_logs(self): + report = self.report([ + {'ty': 'Label', 'i': 'status', 'r': [24, 801, 382, 0], + 't': 'synthetic-sensitive-content'}, + {'ty': 'TextInput', 'i': 'synthetic-sensitive-content', 'r': [24, 40, 120, 40], + 't': 'synthetic-sensitive-content'}, + ], [{'sz': [430, 720], 'title': 'synthetic-sensitive-content'}]) + self.assertEqual(report['window_sizes'], [[430, 720]]) + self.assertEqual(report['rows'][0]['rect'], [24, 801, 382, 0]) + self.assertEqual(report['log_markers']['ScriptError'], 1) + output = json.dumps(report) + for secret in ('synthetic-sensitive-content', 'https:', 'code='): + self.assertNotIn(secret, output) + + def test_non_numeric_or_unbounded_geometry_cannot_be_published(self): + report = self.report([{'ty': 'https://example.test', 'i': 'private', + 'r': ['private', 1, 2, 3]}] * 100, + [{'sz': [float('nan'), 500]}] * 10) + self.assertEqual(len(report['rows']), 80) + self.assertEqual(report['rows'][0], {'kind': 'other', 'id': None, 'rect': None}) + self.assertEqual(report['window_sizes'], [None] * 4) + + +if __name__ == '__main__': + unittest.main() From 33ea2378bf2d2dd7f15d27df78c45f01974efaec Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:12:03 -0700 Subject: [PATCH 24/35] Route shell catalog consumers through the selected trust channel --- crates/shell/src/app_storage/check.rs | 2 +- crates/shell/src/app_storage/mod.rs | 8 +++--- crates/shell/src/app_storage/tests.rs | 17 ++++++++++++ crates/shell/src/connected_backends.rs | 33 +++++++++++++++++++++++- crates/shell/src/glance_card.rs | 7 ++--- crates/shell/src/host_tools/admission.rs | 17 +++++++++--- docs/architecture-walkthrough.md | 10 +++++++ docs/architecture-walkthrough.zh-CN.md | 8 ++++++ 8 files changed, 91 insertions(+), 11 deletions(-) diff --git a/crates/shell/src/app_storage/check.rs b/crates/shell/src/app_storage/check.rs index 95f5bd52..0b1eda30 100644 --- a/crates/shell/src/app_storage/check.rs +++ b/crates/shell/src/app_storage/check.rs @@ -153,7 +153,7 @@ pub fn check_workspaces(layout: &Layout) -> Report { for app in apps { let app_id = app.file_name().to_string_lossy().into_owned(); if validate_app_id(&app_id).is_err() { - continue; // App Hub's own `.host`, `.system`, `catalog.json` + continue; // Host directories and catalog cache/lock files are not apps. } let jail = app.path(); let accounts = jail.join("accounts"); diff --git a/crates/shell/src/app_storage/mod.rs b/crates/shell/src/app_storage/mod.rs index 1ee28f9a..3e6d9711 100644 --- a/crates/shell/src/app_storage/mod.rs +++ b/crates/shell/src/app_storage/mod.rs @@ -104,13 +104,15 @@ pub fn account_hash(account: &str) -> String { } /// An app id usable as one path component: `[A-Za-z0-9._-]{1,128}`, -/// starting with a letter or digit (App Hub keeps `.host`, `.system` and -/// `catalog.json` beside the jails), no `..`. +/// starting with a letter or digit, no `..`. Host catalog cache/lock names +/// are reserved too, including on case-insensitive filesystems. pub fn validate_app_id(id: &str) -> Result<(), String> { let ok_len = !id.is_empty() && id.len() <= 128; let ok_chars = id.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')); let ok_first = id.bytes().next().is_some_and(|b| b.is_ascii_alphanumeric()); - if ok_len && ok_chars && ok_first && !id.contains("..") && id != "catalog.json" { + let host_file = ["catalog.json", "catalog.lock", "catalog-v2.json", "catalog-v2.lock"] + .iter().any(|name| id.eq_ignore_ascii_case(name)); + if ok_len && ok_chars && ok_first && !id.contains("..") && !host_file { Ok(()) } else { Err(format!("invalid app id {id:?}")) diff --git a/crates/shell/src/app_storage/tests.rs b/crates/shell/src/app_storage/tests.rs index 15593c68..e4128c32 100644 --- a/crates/shell/src/app_storage/tests.rs +++ b/crates/shell/src/app_storage/tests.rs @@ -83,6 +83,20 @@ fn app_ids_are_single_plain_path_components() { } } +#[test] +fn catalog_caches_and_locks_cannot_become_an_app_jail() { + let home = Scratch::new("catalog-host-files"); + let host = storage(&home.0); + for name in ["catalog.json", "catalog.lock", "catalog-v2.json", "catalog-v2.lock"] { + for id in [name.to_string(), name.to_ascii_uppercase()] { + assert!(validate_app_id(&id).is_err(), "{id}"); + assert!(host.open(&id).is_err(), "{id} must not create storage over a host file"); + assert!(!home.0.join("apps").join(&id).exists()); + } + } + assert!(host.open("org.example.catalog").is_ok()); +} + // ---- the account hash ----------------------------------------------------- #[test] @@ -484,6 +498,9 @@ fn populated(tag: &str) -> (Scratch, Arc, PathBuf, PathBuf) { // App Hub's own entries beside the jails are not apps. std::fs::create_dir_all(home.0.join("apps/.host/model")).unwrap(); std::fs::write(home.0.join("apps/catalog.json"), "{}").unwrap(); + for name in ["catalog.lock", "catalog-v2.json", "catalog-v2.lock"] { + std::fs::write(home.0.join("apps").join(name), "host metadata").unwrap(); + } let secret = home.0.join("secrets/rinx/token"); (home, host, ws, secret) } diff --git a/crates/shell/src/connected_backends.rs b/crates/shell/src/connected_backends.rs index 674ba848..acf74811 100644 --- a/crates/shell/src/connected_backends.rs +++ b/crates/shell/src/connected_backends.rs @@ -21,7 +21,7 @@ pub fn register() { if let Some(root) = octosense_appstore::data_root_if_set() { let stamp = ( root.clone(), - file_stamp(&root.join("catalog.json")), + catalog_stamp(&root, octosense_appstore::source::CatalogChannel::from_environment(&root)), file_stamp(&root.join(".host/oauth/connections.json")), octosense_app_hub_app::icons::generation(), ); @@ -49,6 +49,17 @@ fn file_stamp(path: &Path) -> Option<(u64, std::time::SystemTime)> { Some((metadata.len(), metadata.modified().ok()?)) } +// Include the selected channel even if its cache is missing. A transition to +// v2 (or a refused channel selection) must revalidate existing registrations; +// otherwise a quiet legacy cache could leave them active until the next call. +fn catalog_stamp( + root: &Path, + channel: Result, +) -> Result<(&'static str, Option<(u64, std::time::SystemTime)>), String> { + let channel = channel?; + Ok((channel.filename(), file_stamp(&root.join(channel.filename())))) +} + fn valid_backend_app_id(app: &str) -> bool { !app.is_empty() && app.len() <= 64 @@ -165,6 +176,26 @@ pub fn installed_changed(app: &str) { #[cfg(test)] mod tests { use super::*; + #[test] + fn watcher_tracks_selected_catalog_updates_and_channel_refusal() { + use octosense_appstore::source::CatalogChannel::{GitHub, Legacy}; + let root = std::env::temp_dir().join(format!("backend-catalog-watch-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("catalog.json"), "unchanged legacy").unwrap(); + let legacy = catalog_stamp(&root, Ok(Legacy)); + let missing_v2 = catalog_stamp(&root, Ok(GitHub)); + assert_ne!(legacy, missing_v2, "changing channel must invalidate even before its cache arrives"); + std::fs::write(root.join("catalog-v2.json"), "first v2").unwrap(); + let first_v2 = catalog_stamp(&root, Ok(GitHub)); + assert_ne!(missing_v2, first_v2); + std::fs::write(root.join("catalog-v2.json"), "updated v2 withdrawal").unwrap(); + assert_ne!(first_v2, catalog_stamp(&root, Ok(GitHub))); + assert_eq!(legacy, catalog_stamp(&root, Ok(Legacy)), "v2 refresh is independent of legacy bytes"); + assert_ne!(legacy, catalog_stamp(&root, Err("downgrade refused".into()))); + std::fs::remove_file(root.join("catalog-v2.json")).unwrap(); + assert_eq!(missing_v2, catalog_stamp(&root, Ok(GitHub)), "cache removal must invalidate too"); + std::fs::remove_dir_all(root).unwrap(); + } fn manifest() -> Value { serde_json::json!({"schema":1,"id":"org.example.backend","version":"1.0.0","name":"Backend fixture", "integrity":{"bundle_blake3":"00".repeat(32)},"capabilities":["auth"],"storage":{"accounts":true}, diff --git a/crates/shell/src/glance_card.rs b/crates/shell/src/glance_card.rs index 2d1fcbe8..13c4620e 100644 --- a/crates/shell/src/glance_card.rs +++ b/crates/shell/src/glance_card.rs @@ -1351,9 +1351,10 @@ fn admitted_app_isolate(cx: &Cx, app: &str) -> Result octosense_appstore::system::prepare(&root, &system)?.1, None => { let anchor = std::env::var("OCTOSENSE_HUB_ANCHOR").unwrap_or_else(|_| octosense_appstore::DEFAULT_ANCHOR.to_string()); - let mut store = octosense_app_hub::Store::new(&anchor, &root, octosense_app_contract::HostLimits::default()) - .with_host_api_versions(octosense_appstore::host_api::available_versions()); - let catalog = std::fs::read_to_string(root.join("catalog.json")).unwrap_or_default(); + let channel = octosense_appstore::source::CatalogChannel::from_environment(&root)?; + let mut store = channel.configure(octosense_app_hub::Store::new(&anchor, &root, octosense_app_contract::HostLimits::default()) + .with_host_api_versions(octosense_appstore::host_api::available_versions())); + let catalog = channel.read_cache(&root).map_err(|e| format!("no verified catalog on this device ({e})"))?; store.accept_catalog(&catalog).map_err(|e| format!("no verified catalog on this device ({e})"))?; store.may_run(app)? } diff --git a/crates/shell/src/host_tools/admission.rs b/crates/shell/src/host_tools/admission.rs index 2ed48e82..02976fa2 100644 --- a/crates/shell/src/host_tools/admission.rs +++ b/crates/shell/src/host_tools/admission.rs @@ -24,13 +24,14 @@ pub(crate) fn installed_bundle(root: &Path, app: &str) -> Result Result { - let mut store = octosense_app_hub::Store::new( + let channel = octosense_appstore::source::CatalogChannel::from_environment(root)?; + let mut store = channel.configure(octosense_app_hub::Store::new( anchor, root, octosense_app_contract::HostLimits::default(), ) - .with_host_api_versions(octosense_appstore::host_api::available_versions()); - let catalog = std::fs::read_to_string(root.join("catalog.json")) + .with_host_api_versions(octosense_appstore::host_api::available_versions())); + let catalog = channel.read_cache(root) .map_err(|_| "No verified App Hub catalog is available on this device".to_string())?; store.accept_catalog(&catalog).map_err(|e| format!("App Hub catalog refused: {e}"))?; store.may_run(app).map_err(|e| format!("App agent is unavailable: {e}"))?; @@ -72,6 +73,16 @@ mod tests { write(&mut catalog); let check = || installed_bundle_with_anchor(&root,id,&anchor.public_hex()); assert_eq!(check().unwrap(), bundle); + // A v2 cache selects v2 verification for agents as well as the UI. + // Neither an invalid proof nor a legacy document renamed to v2 may + // fall back to the still-valid, offered legacy release beside it. + let v2 = root.join("catalog-v2.json"); + for bytes in ["{}".to_string(), std::fs::read_to_string(root.join("catalog.json")).unwrap()] { + std::fs::write(&v2, bytes).unwrap(); + assert!(check().unwrap_err().contains("catalog refused")); + } + std::fs::remove_file(v2).unwrap(); + assert_eq!(check().unwrap(), bundle); catalog.sequence += 1; catalog.entries[0].status = Status::Withdrawn("unsafe release".into()); write(&mut catalog); diff --git a/docs/architecture-walkthrough.md b/docs/architecture-walkthrough.md index 6be4305a..a7120b15 100644 --- a/docs/architecture-walkthrough.md +++ b/docs/architecture-walkthrough.md @@ -154,6 +154,16 @@ For agent calls resolving to `glance.publish`, including aliases such as `inbox. An installed app's agent also depends on its exact release remaining admitted. Guidance, tool offers and system-agent input read the current signed local catalog. The broker also checks `ToolHost::admit_turn` immediately before every actual `turn/start`, including cached conversations, queued input and retries; the relay checks both tool owner and calling app again before execution, including after a pending approval. A withdrawal takes effect after the next catalog fetch, even for cached peers. The Gmail dispatcher then releases the unavailable peer and retains its unfinished event for a later authorized retry. Saved user consent is unchanged; an unavailable app is not treated as a user denial. +The App Hub UI, agent admission, Glance isolates and backend registration watcher +use the host-selected `CatalogChannel`. Legacy catalogs use `catalog.json`; +the GitHub-attested channel uses `catalog-v2.json` and verifies its proof before +admitting an app. An existing v2 cache keeps that library on v2: malformed proof +or a missing selected cache is an error, never a reason to use the legacy file. +The default remains legacy until official v2 acceptance; the operator can select +`OCTOSENSE_HUB_CATALOG=github-v2`. Both cache names and their `.lock` counterparts +are host-owned names, not app storage IDs. A new channel or changed cache causes +the backend watcher to recheck connected apps; per-call admission still applies. + The relay routes every call by the tool's owner: | Owner | Executor | diff --git a/docs/architecture-walkthrough.zh-CN.md b/docs/architecture-walkthrough.zh-CN.md index 9a785218..8456ce66 100644 --- a/docs/architecture-walkthrough.zh-CN.md +++ b/docs/architecture-walkthrough.zh-CN.md @@ -154,6 +154,14 @@ Agent 调用最终映射到 `glance.publish` 时(包括 `inbox.notify` 等别 已安装应用的 Agent 还要求该精确版本持续满足准入条件。加载指导文本、提供工具及接受系统 Agent 输入时都会读取当前本地签名目录。代理还会在每次实际发送 `turn/start` 前检查 `ToolHost::admit_turn`,包括缓存对话、排队输入与重试;中转在执行前再次检查工具所有者与调用应用,包括等待审批后恢复执行的情况。撤回在下一次获取目录后生效,缓存的 peer 也不能绕过。Gmail 分发器随后释放不可用的 peer,保留未完成事件以便之后经授权重试。用户之前保存的同意不会改变;应用不可用不等于用户拒绝。 +App Hub 界面、Agent 准入、Glance 隔离环境和后端注册监听器统一使用宿主选择的 +`CatalogChannel`。旧签名目录使用 `catalog.json`,GitHub 证明通道使用 +`catalog-v2.json`,应用准入前必须验证其证明。只要应用库已有 v2 缓存,就继续使用 +v2;证明无效或所选缓存缺失都会报错,不会回退读取旧目录。在官方 v2 验收完成前, +默认仍使用旧通道;运维者可设置 `OCTOSENSE_HUB_CATALOG=github-v2` 选择新通道。 +两个缓存文件名及各自的 `.lock` 文件名均属于宿主,不能作为应用存储 ID。 +通道切换或缓存更新会让后端监听器重新检查已连接应用,每次调用的准入检查仍会执行。 + 中转按工具的所有者分派每一次调用: | 所有者 | 执行器 | From bb4c2b68ce113666afac57e640786e78d118d3db Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:16:48 -0700 Subject: [PATCH 25/35] Apply verified catalog sequence floor to agents and Glance --- crates/shell/src/glance_card.rs | 3 +++ crates/shell/src/host_tools/admission.rs | 3 +++ docs/architecture-walkthrough.md | 2 ++ docs/architecture-walkthrough.zh-CN.md | 2 ++ 4 files changed, 10 insertions(+) diff --git a/crates/shell/src/glance_card.rs b/crates/shell/src/glance_card.rs index 13c4620e..23c9019a 100644 --- a/crates/shell/src/glance_card.rs +++ b/crates/shell/src/glance_card.rs @@ -1356,6 +1356,9 @@ fn admitted_app_isolate(cx: &Cx, app: &str) -> Result Result< let catalog = channel.read_cache(root) .map_err(|_| "No verified App Hub catalog is available on this device".to_string())?; store.accept_catalog(&catalog).map_err(|e| format!("App Hub catalog refused: {e}"))?; + octosense_app_hub_app::catalog::check_verified_catalog_floor( + root, anchor, store.catalog().map(|catalog| catalog.sequence), + )?; store.may_run(app).map_err(|e| format!("App agent is unavailable: {e}"))?; Ok(store.install_dir(app)) } diff --git a/docs/architecture-walkthrough.md b/docs/architecture-walkthrough.md index a7120b15..ca74a89e 100644 --- a/docs/architecture-walkthrough.md +++ b/docs/architecture-walkthrough.md @@ -163,6 +163,8 @@ The default remains legacy until official v2 acceptance; the operator can select `OCTOSENSE_HUB_CATALOG=github-v2`. Both cache names and their `.lock` counterparts are host-owned names, not app storage IDs. A new channel or changed cache causes the backend watcher to recheck connected apps; per-call admission still applies. +If a newer verified catalog cannot be saved, agents and Glance also honor the +process's highest verified sequence and refuse the older cache until it is saved. The relay routes every call by the tool's owner: diff --git a/docs/architecture-walkthrough.zh-CN.md b/docs/architecture-walkthrough.zh-CN.md index 8456ce66..34739d69 100644 --- a/docs/architecture-walkthrough.zh-CN.md +++ b/docs/architecture-walkthrough.zh-CN.md @@ -161,6 +161,8 @@ v2;证明无效或所选缓存缺失都会报错,不会回退读取旧目录 默认仍使用旧通道;运维者可设置 `OCTOSENSE_HUB_CATALOG=github-v2` 选择新通道。 两个缓存文件名及各自的 `.lock` 文件名均属于宿主,不能作为应用存储 ID。 通道切换或缓存更新会让后端监听器重新检查已连接应用,每次调用的准入检查仍会执行。 +如果已验证的新目录无法保存,Agent 和 Glance 也会遵守当前进程记录的最高已验证序号, +拒绝使用较旧的缓存,直到新目录成功保存。 中转按工具的所有者分派每一次调用: From 9b4bc7db34f21b64417c86e75ec34edf63e3055b Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:29:11 -0700 Subject: [PATCH 26/35] Record native Windows backend and Linux package acceptance --- tools/connected-e2e/LINUX-PACKAGE.md | 41 +++++++++ tools/connected-e2e/LINUX-PACKAGE.zh-CN.md | 31 +++++++ tools/connected-e2e/README.md | 14 +++- tools/connected-e2e/WINDOWS-BACKEND.zh-CN.md | 24 ++++++ .../evidence/linux-package-37723207058.json | 42 ++++++++++ .../evidence/windows-backend-37730564370.json | 83 +++++++++++++++++++ 6 files changed, 234 insertions(+), 1 deletion(-) create mode 100644 tools/connected-e2e/LINUX-PACKAGE.md create mode 100644 tools/connected-e2e/LINUX-PACKAGE.zh-CN.md create mode 100644 tools/connected-e2e/WINDOWS-BACKEND.zh-CN.md create mode 100644 tools/connected-e2e/evidence/linux-package-37723207058.json create mode 100644 tools/connected-e2e/evidence/windows-backend-37730564370.json diff --git a/tools/connected-e2e/LINUX-PACKAGE.md b/tools/connected-e2e/LINUX-PACKAGE.md new file mode 100644 index 00000000..6b6aa89b --- /dev/null +++ b/tools/connected-e2e/LINUX-PACKAGE.md @@ -0,0 +1,41 @@ +# Linux desktop package acceptance + +The [immutable receipt](evidence/linux-package-37723207058.json) records eight +successful checks of the real `.deb` produced by [desktop package workflow +37723207058](https://github.com/OctoSense-org/OctoSense/actions/runs/37723207058), +artifact `11528131778`. This was an Ubuntu 22.04 package build. Its source was +the workflow's planned pull-request merge commit +`c15a0ab93d6d4991e5774e1b56028247bf9c7ac2`, rather than only the pull-request +head `9f8ce11c7cc890aa77131821c42a0ae61b46fc54`. + +The package was extracted with `dpkg-deb -x` into a new private test directory; +it was not installed into the host OS. The extracted executable ran against +its packaged resources in a disposable Xvfb display and D-Bus session. A +read-only bwrap overlay supplied already extracted test dependencies. The run +used a fresh OctoSense profile and no personal provider account. Neither +system packages nor global settings changed. + +The eight automated checks cover the packaged executable, isolated display, +continued native execution, native widget snapshot, rendered frame, App Hub +presence, resource loading and normal shutdown. Inspection of that actual +frame also confirmed the Google Calendar, GitHub Notes and Inbox Assistant +listing rows. The agent-consent dialog remained unanswered. All owned test +processes stopped, and the namespace exited successfully. + +This evidence does **not** establish App Hub installation, account sign-in, +OS-authenticated write approval, package-manager installation or a final +integrated release. The receipt binds both the package and executable hashes +and the reviewed frame hash. Raw profiles, logs and snapshots remain private. +Future package runs must create new receipts; do not update this record to +claim evidence for a different package or source revision. + +For reproduction, verify the downloaded artifact's `.deb` SHA-256 against the +receipt, extract it to a new directory, and launch `usr/bin/octosense` with +fresh `OCTOSENSE_HOME`, `MAKEPAD_HOME` and XDG directories. Use an isolated +X11 display, `MAKEPAD=linux-x11`, `MAKEPAD_REMOTE=on` and +`MAKEPAD_WM_TEST_APP=apphub`. The native instrument's `snap`, `g` and `quit` +endpoints provide the same structural snapshot, frame and normal shutdown +checks. Keep the instrument on loopback, do not approve the agent-consent +dialog, and retain a new private run directory for the raw evidence. The +original environment-specific launch helpers are not shipped as a portable +driver. diff --git a/tools/connected-e2e/LINUX-PACKAGE.zh-CN.md b/tools/connected-e2e/LINUX-PACKAGE.zh-CN.md new file mode 100644 index 00000000..392f6368 --- /dev/null +++ b/tools/connected-e2e/LINUX-PACKAGE.zh-CN.md @@ -0,0 +1,31 @@ +# Linux 桌面安装包验收 + +[不可变验收记录](evidence/linux-package-37723207058.json) 记录了真实 `.deb` +安装包的八项通过结果。安装包来自 [桌面打包任务 +37723207058](https://github.com/OctoSense-org/OctoSense/actions/runs/37723207058), +产物编号为 `11528131778`,构建环境为 Ubuntu 22.04。实际源码是任务指定的 +PR 合并提交 `c15a0ab93d6d4991e5774e1b56028247bf9c7ac2`,不能仅用 PR +分支提交 `9f8ce11c7cc890aa77131821c42a0ae61b46fc54` 代替。 + +测试通过 `dpkg-deb -x` 将安装包解包到新的私有目录,没有向宿主系统安装。 +真实可执行文件及其随包资源在独立 Xvfb 显示和 D-Bus 会话中运行,已有的 +解包测试依赖通过只读 bwrap 挂载提供。测试使用全新 OctoSense 配置,没有 +使用个人提供商账号,也没有修改系统软件包或全局设置。 + +八项自动检查覆盖可执行文件存在、独立显示、原生进程持续运行、控件快照、 +实际渲染画面、App Hub 显示、资源加载和正常退出。对实际画面的审阅还确认了 +Google Calendar、GitHub Notes 和 Inbox Assistant 三个列表项。界面上的 +代理授权对话框没有被确认。所有自有测试进程均已停止,命名空间正常退出。 + +这项证据**不证明**从 App Hub 安装应用、登录账号、经操作系统认证后执行写入、 +通过包管理器安装或最终集成发行版可用。记录绑定了安装包、可执行文件及已审阅 +画面的哈希。原始配置、日志和快照保持私有。后续安装包必须生成新的验收记录, +不能修改这份记录来代表其他版本。 + +复现时,先核对 `.deb` 的 SHA-256,再解包到新目录。为 `usr/bin/octosense` +设置全新的 `OCTOSENSE_HOME`、`MAKEPAD_HOME` 和 XDG 目录,并使用独立 X11 +显示、`MAKEPAD=linux-x11`、`MAKEPAD_REMOTE=on` 与 +`MAKEPAD_WM_TEST_APP=apphub`。原生 instrument 的 `snap`、`g`、`quit` 端点 +分别用于相同的结构快照、画面和正常退出检查。instrument 仅监听回环地址, +不要确认代理授权对话框;新的原始证据保存在新的私有运行目录。原测试环境专用的 +启动脚本没有作为可移植驱动发布。 diff --git a/tools/connected-e2e/README.md b/tools/connected-e2e/README.md index 7f21fb08..02bc82f1 100644 --- a/tools/connected-e2e/README.md +++ b/tools/connected-e2e/README.md @@ -147,6 +147,10 @@ Android developer-backend login has its own isolated package and real-form proce ## Native backend browser acceptance (2026-10-08) +The separately built Linux desktop `.deb` passed eight package launch, render, +resource and shutdown checks. See the immutable [package receipt and scope](LINUX-PACKAGE.md) +([中文](LINUX-PACKAGE.zh-CN.md)); that run did not install an app or connect an account. + `backend_login.py` uses a real host, browser and operating-system credential vault. Only the backend HTTP server and its fictional users are synthetic; no account or token is injected into the host. Linux and Windows use the supported @@ -174,7 +178,15 @@ The Linux run used that interface with the real engine and normal host vault. `--chrome PATH` instead uses an existing Chromium browser through Playwright. The `Platform accounts` workflow exercises the Windows native vault and this same browser/callback journey using the runner's existing Edge installation. -The vault job passed; the added Windows browser journey is pending execution. +Both passed in [run 37730564370](https://github.com/OctoSense-org/OctoSense/actions/runs/37730564370). +The [Windows receipt](evidence/windows-backend-37730564370.json) ([中文](WINDOWS-BACKEND.zh-CN.md)) records all +seven backend behaviors, normal Credential Manager use and clean native/server +shutdown. Its 35 recorded source/lockfile hashes match the reviewed files. +The actual workflow checkout was merge commit +`95d1da069c01cebdb585eca3e75354e58833abd8`, with PR head `723b202e`. +That receipt preserves the original artifact hash and clarifies its incorrectly +named checkout-revision field. It does not claim Windows visual UX acceptance +or independent readback after credential deletion. The acceptance host copies its own consent-sheet link into a private test file, and the harness opens that exact URL in a fresh browser. **The OS default diff --git a/tools/connected-e2e/WINDOWS-BACKEND.zh-CN.md b/tools/connected-e2e/WINDOWS-BACKEND.zh-CN.md new file mode 100644 index 00000000..4fc669ba --- /dev/null +++ b/tools/connected-e2e/WINDOWS-BACKEND.zh-CN.md @@ -0,0 +1,24 @@ +# Windows 开发者后端登录验收 + +[任务 37730564370](https://github.com/OctoSense-org/OctoSense/actions/runs/37730564370) +通过了真实 Windows Credential Manager 生命周期检查,以及原生 OctoSense +测试宿主、已有 Edge 浏览器和合成 HTTP 后端之间的完整登录流程。 +[不可变验收记录](evidence/windows-backend-37730564370.json) 保存了二进制哈希、 +原始记录哈希、35 个与已审阅源码一致的文件哈希和七项通过结果。 + +七项行为包括:浏览器注册与密码登录、真实 PKCE 回调及受保护数据访问、 +轮换刷新凭据后的故障恢复、原生进程重启后从系统凭据库恢复连接、退出与再次 +登录、两个已签名应用之间的账号句柄隔离,以及撤销两个虚构连接。所有自有 +原生进程和合成服务器均已停止。凭据删除通过真实宿主断开连接接口请求; +本流程没有额外读取系统凭据库来独立确认删除。 + +实际工作流检出的是合并提交 `95d1da069c01cebdb585eca3e75354e58833abd8`, +PR 分支提交为 `723b202e76c62473aa0d95f16a58f38553a2882d`。原始记录误将 +OctoSense 检出版本字段命名为 `runtime_base_commit`;公开记录明确了这个 +含义,不能把它当作 Makepad 版本。 + +测试使用临时签名、真实 Store 安装和全新的浏览器上下文,没有向宿主注入 +账号或令牌。浏览器打开的是测试宿主从自己的原生授权链接复制出的精确 URL; +**没有测试操作系统默认浏览器的链接点击**。此结果也不证明 Windows 视觉 UX、 +Google/GitHub 真实账号登录,或经人和操作系统认证后执行写入。原始回调 URL、 +配置及诊断文件保持私有。复现命令见 [英文驱动说明](README.md#native-backend-browser-acceptance-2026-10-08)。 diff --git a/tools/connected-e2e/evidence/linux-package-37723207058.json b/tools/connected-e2e/evidence/linux-package-37723207058.json new file mode 100644 index 00000000..dd592f1b --- /dev/null +++ b/tools/connected-e2e/evidence/linux-package-37723207058.json @@ -0,0 +1,42 @@ +{ + "schema": 1, + "platform": "linux-x86_64", + "package_run": 37723207058, + "source_revision": "c15a0ab93d6d4991e5774e1b56028247bf9c7ac2", + "pull_request_head": "9f8ce11c7cc890aa77131821c42a0ae61b46fc54", + "source_identity_evidence": "Release desktop run37723207058 plan job113135689588 exact planned PR merge commit", + "deb_sha256": "08dc2761669e3477fa2ec2466dfa976c2a1eda4871b7f3b447a8a51d422a057f", + "binary_sha256": "ca92f9f8d9460c6a18648bb32c733c4c3976171f1a2a0e4cf27277c85f425553", + "personal_accounts_used": false, + "system_packages_changed": false, + "checks": { + "packaged_binary_present": true, + "isolated_x_display": true, + "desktop_stays_running": true, + "native_widget_snapshot_available": true, + "actual_packaged_frame_captured": true, + "app_hub_loaded": true, + "packaged_resources_found": true, + "normal_owned_shutdown": true + }, + "passed": true, + "owned_processes_stopped": true, + "namespace_exit": 0, + "test_scope": "Unprivileged package extraction; real Xvfb/Makepad launch, App Hub catalog rendering, clean shutdown. No agent consent or app installation.", + "artifact_id": 11528131778, + "package_kind": "deb", + "package_installation": "extracted without installing into the host OS", + "agent_consent_approved": false, + "screenshot_sha256": "92461d087d85ddbc808b22da686ea861e2c89823845e2e4040175beb144c0e5a", + "visual_observations": [ + "App Hub rendered Google Calendar, GitHub Notes and Inbox Assistant listing rows.", + "The native agent-consent dialog was visible and remained unanswered." + ], + "not_verified": [ + "App installation or launch from App Hub", + "Any provider sign-in or account access", + "OS-authenticated business writes", + "Package-manager installation or system-policy installation", + "Final integrated release" + ] +} diff --git a/tools/connected-e2e/evidence/windows-backend-37730564370.json b/tools/connected-e2e/evidence/windows-backend-37730564370.json new file mode 100644 index 00000000..8cf00d98 --- /dev/null +++ b/tools/connected-e2e/evidence/windows-backend-37730564370.json @@ -0,0 +1,83 @@ +{ + "schema": 1, + "result": "PASS", + "started_utc": "2026-10-08T05:25:07.457732+00:00", + "finished_utc": "2026-10-08T05:25:22.859975+00:00", + "case": "Real native backend login with synthetic server and actual browser callbacks", + "provider": "synthetic HTTP loopback; not live GitHub or Google", + "vault": "normal platform credential vault; no preseeded accounts or tokens", + "browser_handoff": "host-only LinkLabel URL copied privately by test example; exact URL opened in a fresh browser context; OS-default-browser click omitted", + "browser_engine": "msedge.exe", + "installation": "ephemeral fixture signing, actual Store install and prepared launch", + "input": "Makepad native instrument, default input acknowledgement, no input replay", + "binary_sha256": "68eb499c7dcfbb9b128e4df2ad0be83273d3aa347602749b5fc4f9a43f07be39", + "installer_sha256": "824b116b3c1a8b8c86aaf49951e483eebc6526dc459990d48f176484ff7216a4", + "visual_review": "pending", + "checks": [ + "Short-lived token forces refresh; transient protected-data failure followed by another refresh recovers using durably stored rotated credential", + "Fresh browser registration, password sign-in, real PKCE callback and protected identity", + "Native process restart restores same app-bound connection from platform vault and reloads protected data", + "Logout durably removes connection and protected identity is denied", + "Existing fictional user signs in again through browser without registration; fresh connection issued", + "Second signed app cannot access first handle before or after its own genuine browser login", + "Both fictional handles durably revoked through real host service; platform-vault deletion requested (deletion not independently read back)" + ], + "local_connections_revoked": true, + "vault_cleanup": "Deletion requested through host disconnect; no independent vault readback", + "native_processes_stopped": true, + "synthetic_server_stopped": true, + "workflow_run": 37730564370, + "artifact_id": 11530745579, + "pull_request_head": "723b202e76c62473aa0d95f16a58f38553a2882d", + "tested_checkout_revision": "95d1da069c01cebdb585eca3e75354e58833abd8", + "source_revision_note": "The original artifact names the OctoSense checkout revision runtime_base_commit; it is not a Makepad commit.", + "original_receipt_sha256": "35fb7e5df6267415a3081bce7450d1cb7044c6242adaabb29568b2f5d6292d91", + "source_sha256": { + "tools/connected-e2e/backend_login.py": "032d20c3c259ea9b62f5fa510d04eac410912e33baff26b090ba94f318e4d355", + "tools/backend-login-fixture.py": "5c2695ad6da5d82b0c41782cefd77392130cc8c362ecf1170b87ea4512000424", + "tools/connected-e2e/native.py": "3182c430d9b3797a823a67403543d056891598b404cf7fda577836f09e7ae7b6", + "tools/connected-e2e/backend-login/main.splash": "817ace3dd8b87bfcecdbd9fffe4b8f13fb7721ed1240e0c849f267c0853c42e8", + "crates/shell/examples/connected-app-host.rs": "5e960ac06a96c5302f5f89e72ee6fd45bcfbf4c0a033f94ee67e1cb7a8bb6d92", + "crates/shell/examples/connected_support/mod.rs": "5e6a567808dfe34b21d9348afe9345a52ec9165f747504c623bdc9af6898ab2d", + "Cargo.lock": "05993722b356ca50434220d705affac333c73878e50a1e03f5e1419daa5c656b", + "crates/oauth-service/src/acceptance_calendar.rs": "e3f03818b554cbfdd1f7d9bcf22272e8ec46c25bffc69fd4498c690c137446ac", + "crates/oauth-service/src/acceptance_fixtures.rs": "af8b2c8187a35015ea42b59aa7b2ca8e5c4e4834efa0144259ca691f00bcfdf0", + "crates/oauth-service/src/acceptance_github.rs": "520fd3681f4831c35cf671f0d4883af8c7d9e63e748ec41ede96d54fcaa77c7d", + "crates/oauth-service/src/acceptance_inbox.rs": "efc33661cbb2801037e6db9a1416a8b7b480a549e05dbe790ba2338dfb037162", + "crates/oauth-service/src/api.rs": "0d2f6edb38874709b0ef8affc8602896f8065e59af3d6eff97b33daebb0cd13e", + "crates/oauth-service/src/api_tests.rs": "6f4809c941b048717d001a80190ad7a50db49cf24f38f5454d8cb2249c7d72c3", + "crates/oauth-service/src/authorize.rs": "cfcea9157e678e53d9787a03d4a46a5f1b27804a5596ff11aa1000e9ea30af4b", + "crates/oauth-service/src/backend.rs": "93a5ea1227936e3394239398a8f39294729fd3e0912340e1db6b6a701d294cbb", + "crates/oauth-service/src/backend_registry.rs": "dff1937066c90ab54cd65c22f3cf1499e7c20d55f7d7dc5d8c774fc151682aec", + "crates/oauth-service/src/backend_tests.rs": "9e45303b92bfb5992e25d844f1affdc9a556dc9c2d51effb16d1e15fbdcb603c", + "crates/oauth-service/src/calendar_cache.rs": "91efe0916a4fa73c3f36756cbbd6359c5ecc1122f7e090343b0dde3773d601e2", + "crates/oauth-service/src/host.rs": "401a3810d2f76ec8cbaf62b344f060ed9b073c751500fc8143eb7398bf8fd069", + "crates/oauth-service/src/host_api.rs": "a38e2fd7b0b24eda2d8c0f974b4032551e1286a905273eb6772d0475836da418", + "crates/oauth-service/src/host_backend.rs": "1839063da9a9f7534e79839fc807abe3b64143efab998678599a699604a8d066", + "crates/oauth-service/src/host_catalog.rs": "e8cb3e4b02cb95703e3650e1fe6db9ccda49e2ad4c84e2ebc19dcf5d37740760", + "crates/oauth-service/src/host_inbox.rs": "aa69422fd3fbe504035eb60fe4203222ebb9f1877884f0dbe77143f864c48ad8", + "crates/oauth-service/src/host_lifetime_tests.rs": "50904d91121ae3f16caf4c8c9008c66f9cd6b85e2af54558451be683079c662b", + "crates/oauth-service/src/host_operation_tests.rs": "ba0dc9cbbbcc592b1268be23d2e45e0cd8faa3e1ff7f202090cc7d1df3f45526", + "crates/oauth-service/src/host_vault_acceptance.rs": "ff672463d37614b7f64f20a92c5e0e0a3de213dacb7b478d81d1bc894b6afe2a", + "crates/oauth-service/src/inbox.rs": "ec34c7fe90e6c0d43c8d785a9e2443aec234fafb6356a4759b2b6992ff88437d", + "crates/oauth-service/src/inbox_events.rs": "a795b00448ff0b679b019bdaf5df1e8bdffe754bcd46c0a2cf55b30e4a906af9", + "crates/oauth-service/src/lib.rs": "c7f60fcd66072440635aeb475e3ea4fc95899470fe308e9ae30e6840b41ab79e", + "crates/oauth-service/src/oauth.rs": "b3a7edd30fcb0235db93befbea8653847c9899ed26d0681e070e48e6077d9318", + "crates/oauth-service/src/protocol.rs": "5495da22f0d50b8f89f8840bcfad1b8e2ec3227e9564f96e1552a4b7705917f0", + "crates/oauth-service/src/providers.rs": "a22b9bc5bd54536c0fabee41cd3f84f00a29ef474c64e851fe42ceb02853765d", + "crates/oauth-service/src/registration.rs": "33392b88f2444c9f88794add5b47cb30f9c731340dfba60dd3bec76385da2e62", + "crates/oauth-service/src/store.rs": "4f23735e6a14bc40145d9b1a9720a6efa20f89610bd7942ceab7eaea23fa8bc8", + "crates/oauth-service/src/transport.rs": "1db7518fa5ed853412e2bdddb836d45266c21f9a793d82e3351e7eabaa5d5960" + }, + "source_hash_verification": { + "recorded": 35, + "matched_reviewed_source": 35 + }, + "not_verified": [ + "OS-default-browser link click", + "Visual UX acceptance", + "Successful person-mediated write approval", + "Live Google or GitHub login", + "Independent platform-vault deletion readback" + ] +} From 484826895808bf98e08c9b0fb98825ba5e5897ba Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:15:41 -0700 Subject: [PATCH 27/35] Preserve raw native response JSON keys and add reproducible Wasm phone acceptance --- .github/workflows/desktop.yml | 2 + Cargo.lock | 13 + Cargo.toml | 1 + .../shell/examples/connected_support/mod.rs | 2 + .../examples/encode_phone_fixture.rs | 4 + crates/wasm-phone-smoke/Cargo.toml | 34 +++ crates/wasm-phone-smoke/build.rs | 67 +++++ crates/wasm-phone-smoke/src/main.rs | 235 ++++++++++++++++++ runtime-patches.lock.json | 9 +- tools/fixtures/wasm-phone-lab/README.md | 25 ++ tools/fixtures/wasm-phone-lab/README.zh-CN.md | 25 ++ tools/fixtures/wasm-phone-lab/bundle/AGENT.md | 1 + .../wasm-phone-lab/bundle/assets/icon.svg | 1 + .../wasm-phone-lab/bundle/fns/state.wasm | Bin 0 -> 293 bytes .../wasm-phone-lab/bundle/listing.json | 25 ++ .../wasm-phone-lab/bundle/main.splash | 36 +++ .../wasm-phone-lab/bundle/manifest.json | 33 +++ .../bundle/screenshots/01-main.png | Bin 0 -> 143410 bytes .../fixtures/wasm-phone-lab/bundle/tools.json | 39 +++ tools/fixtures/wasm-phone-lab/state.wat | 17 ++ .../makepad-host-response-json.patch | 133 ++++++++++ tools/test-wasm-phone.py | 145 +++++++++++ 22 files changed, 845 insertions(+), 2 deletions(-) create mode 100644 crates/wasm-host/examples/encode_phone_fixture.rs create mode 100644 crates/wasm-phone-smoke/Cargo.toml create mode 100644 crates/wasm-phone-smoke/build.rs create mode 100644 crates/wasm-phone-smoke/src/main.rs create mode 100644 tools/fixtures/wasm-phone-lab/README.md create mode 100644 tools/fixtures/wasm-phone-lab/README.zh-CN.md create mode 100644 tools/fixtures/wasm-phone-lab/bundle/AGENT.md create mode 100644 tools/fixtures/wasm-phone-lab/bundle/assets/icon.svg create mode 100644 tools/fixtures/wasm-phone-lab/bundle/fns/state.wasm create mode 100644 tools/fixtures/wasm-phone-lab/bundle/listing.json create mode 100644 tools/fixtures/wasm-phone-lab/bundle/main.splash create mode 100644 tools/fixtures/wasm-phone-lab/bundle/manifest.json create mode 100644 tools/fixtures/wasm-phone-lab/bundle/screenshots/01-main.png create mode 100644 tools/fixtures/wasm-phone-lab/bundle/tools.json create mode 100644 tools/fixtures/wasm-phone-lab/state.wat create mode 100644 tools/runtime-patches/makepad-host-response-json.patch create mode 100644 tools/test-wasm-phone.py diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index 3222f81d..e52e8f03 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -107,6 +107,8 @@ jobs: shared-key: native-host-api - name: Build the native acceptance host and pinned Hub CLI run: | + # Fresh child processes catch native response keys missing from the string lookup table. + cargo test --manifest-path .sources/makepad/Cargo.toml -p makepad-widgets splash_host::tests cargo build --locked --release -p octosense-shell --example host-api-lab --features acceptance-fixtures cargo build --locked -p octosense-app-hub --bin hub - name: Signed app tool calls a real OS permission API diff --git a/Cargo.lock b/Cargo.lock index 731da9d2..84f6bd79 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8260,6 +8260,19 @@ dependencies = [ "wat", ] +[[package]] +name = "octosense-wasm-phone-smoke" +version = "0.1.0" +dependencies = [ + "makepad-app-module", + "makepad-widgets", + "octosense-app-hub", + "octosense-app-policy", + "octosense-appstore", + "octosense-shell", + "serde_json", +] + [[package]] name = "ohos-sys" version = "0.2.2" diff --git a/Cargo.toml b/Cargo.toml index 32194898..60c86f39 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ [workspace] resolver = "2" members = [ + "crates/wasm-phone-smoke", "desktop", "phone", "crates/kernel", diff --git a/crates/shell/examples/connected_support/mod.rs b/crates/shell/examples/connected_support/mod.rs index 061ad1ca..9659099a 100644 --- a/crates/shell/examples/connected_support/mod.rs +++ b/crates/shell/examples/connected_support/mod.rs @@ -17,6 +17,8 @@ const IDS: &[&str] = &[ #[cfg(feature = "acceptance-fixtures")] "org.octosense.samples.apilab", #[cfg(feature = "acceptance-fixtures")] + "org.octosense.samples.wasmprobe", + #[cfg(feature = "acceptance-fixtures")] "org.octosense.samples.backend", #[cfg(feature = "acceptance-fixtures")] "org.octosense.samples.backendother", diff --git a/crates/wasm-host/examples/encode_phone_fixture.rs b/crates/wasm-host/examples/encode_phone_fixture.rs new file mode 100644 index 00000000..beba148d --- /dev/null +++ b/crates/wasm-host/examples/encode_phone_fixture.rs @@ -0,0 +1,4 @@ +fn main() { + let a: Vec<_> = std::env::args().collect(); + std::fs::write(&a[2], wat::parse_file(&a[1]).unwrap()).unwrap(); +} diff --git a/crates/wasm-phone-smoke/Cargo.toml b/crates/wasm-phone-smoke/Cargo.toml new file mode 100644 index 00000000..9ca493a1 --- /dev/null +++ b/crates/wasm-phone-smoke/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "octosense-wasm-phone-smoke" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +# Android needs a library entry point. The fixture uses the normal signed +# Store, script-tool dispatcher and Wasm service without alternate grants. +[lib] +path = "src/main.rs" +test = false +bench = false +doctest = false + +[[bin]] +name = "octosense-wasm-phone-smoke" +path = "src/main.rs" + +[features] +default = ["acceptance-fixtures"] +acceptance-fixtures = ["octosense-shell/acceptance-fixtures", "octosense-shell/wasm-lab"] + +[dependencies] +makepad-widgets.workspace = true +makepad-app-module.workspace = true +octosense-appstore.workspace = true +octosense-app-hub.workspace = true +octosense-app-policy.workspace = true +octosense-shell.workspace = true +serde_json.workspace = true + +[build-dependencies] +serde_json.workspace = true diff --git a/crates/wasm-phone-smoke/build.rs b/crates/wasm-phone-smoke/build.rs new file mode 100644 index 00000000..9e1c0412 --- /dev/null +++ b/crates/wasm-phone-smoke/build.rs @@ -0,0 +1,67 @@ +use std::{env, fs, path::Path, process::Command}; + +fn git(root: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .args(args) + .current_dir(root) + .output() + .unwrap(); + assert!( + output.status.success(), + "Cannot read fixture source identity" + ); + String::from_utf8(output.stdout).unwrap().trim().to_owned() +} + +fn main() { + let manifest = env::var_os("CARGO_MANIFEST_DIR").unwrap(); + let root = Path::new(&manifest).ancestors().nth(2).unwrap(); + let revision = git(root, &["rev-parse", "HEAD"]); + let runtime = root.join(".sources/makepad"); + let lock: serde_json::Value = + serde_json::from_slice(&fs::read(root.join("runtime-patches.lock.json")).unwrap()).unwrap(); + let tree = git(&runtime, &["write-tree"]); + assert_eq!( + Some(tree.as_str()), + lock["makepad"]["tree"].as_str(), + "Prepare the pinned runtime before building the fixture" + ); + let runtime_clean = Command::new("git") + .args(["diff", "--quiet", "--"]) + .current_dir(&runtime) + .status() + .unwrap() + .success(); + let dirty = !runtime_clean + || !git(root, &["status", "--porcelain", "--untracked-files=normal"]).is_empty(); + println!("cargo:rustc-env=WASM_FIXTURE_REVISION={revision}"); + println!("cargo:rustc-env=WASM_FIXTURE_RUNTIME_TREE={tree}"); + println!("cargo:rustc-env=WASM_FIXTURE_DIRTY={dirty}"); + for path in [ + "src", + "build.rs", + "../shell/examples/connected_support/mod.rs", + "../../runtime-patches.lock.json", + "../../.sources/makepad/widgets/src/splash_host.rs", + ] { + println!("cargo:rerun-if-changed={path}"); + } + for name in ["HEAD", "index"] { + println!( + "cargo:rerun-if-changed={}", + git(root, &["rev-parse", "--git-path", name]) + ); + } + let symbolic = Command::new("git") + .args(["symbolic-ref", "-q", "HEAD"]) + .current_dir(root) + .output() + .unwrap(); + if symbolic.status.success() { + let name = String::from_utf8(symbolic.stdout).unwrap(); + println!( + "cargo:rerun-if-changed={}", + git(root, &["rev-parse", "--git-path", name.trim()]) + ); + } +} diff --git a/crates/wasm-phone-smoke/src/main.rs b/crates/wasm-phone-smoke/src/main.rs new file mode 100644 index 00000000..c64b899e --- /dev/null +++ b/crates/wasm-phone-smoke/src/main.rs @@ -0,0 +1,235 @@ +//! Synthetic signed-app Wasm containment and raw-response acceptance host. +//! Uses normal Store admission, the script-tool dispatcher and Wasm service. +//! It does not start a model, use an account or confer device consent. +use makepad_app_module::AppModule; +use makepad_widgets::*; +use octosense_appstore::{host_api, script_tools, services}; +use serde_json::{json, Value}; +use std::{ + path::PathBuf, + sync::{Arc, Mutex}, + time::Duration, +}; +#[path = "../../shell/examples/connected_support/mod.rs"] +mod connected_support; +app_main!(App, font_set: International); + +script_mod! { + use mod.prelude.widgets.* + use mod.widgets.* + startup() do #(App::script_component(vm)) { + ui: Root { main_window := Window { + window.inner_size: vec2(640, 650) + body +: {flow: Overlay + app := Splash {width: Fill height: Fill} + sheet := Splash {width: Fill height: Fill visible: false} + } + }} + } +} + +#[derive(Script, ScriptHook)] +struct App { + #[live] + ui: WidgetRef, + #[rust] + app: String, + #[rust] + root: PathBuf, + #[rust] + receipt: Option, + #[rust] + launch: Option, + #[rust] + assets: Option, + #[rust] + registration: Option, + #[rust] + card: SplashRef, + #[rust] + sheet: SplashRef, + #[rust] + timer: Option, + #[rust] + replies: Arc>>>, + #[rust] + preliminary: Value, + #[rust] + admitted_prompts: bool, + #[rust] + suspended: bool, +} + +impl MatchEvent for App { + fn handle_startup(&mut self, cx: &mut Cx) { + #[cfg(not(target_os = "android"))] + let arg = + |name: &str| std::env::args().find_map(|a| a.strip_prefix(name).map(str::to_owned)); + #[cfg(not(target_os = "android"))] + let root = + PathBuf::from(arg("--app-data=").expect("Pass an isolated --app-data directory")); + #[cfg(not(target_os = "android"))] + let bundle = PathBuf::from(arg("--bundle=").expect("Pass --bundle=")); + #[cfg(target_os = "android")] + let data = PathBuf::from(cx.get_data_dir().expect("Android private data directory")) + .join("wasm-phone-lab"); + #[cfg(target_os = "android")] + let (root, bundle) = (data.join("apps"), data.join("bundle")); + let preview = std::env::args().any(|a| a == "--preview"); + octosense_appstore::set_data_root(root.clone()); + let policy = if preview { + let manifest = std::fs::read_to_string(bundle.join("manifest.json")).unwrap(); + let digest = octosense_app_policy::digest_dir(&bundle).unwrap(); + octosense_app_policy::admit_and_resolve_dir( + &manifest, + &digest, + &octosense_app_policy::HostLimits::default().with_require_signature(false), + &octosense_app_policy::RefuseAllSignatures, + ) + .expect("Preview admission") + } else { + let installed = + connected_support::install(&[bundle.clone()], &root).expect("Signed installation"); + std::env::set_var( + "OCTOSENSE_HUB_ANCHOR", + installed["anchor"].as_str().unwrap(), + ); + let launch = connected_support::open(&root, "org.octosense.samples.wasmprobe").unwrap(); + let policy = launch.policy.clone(); + self.launch = Some(launch); + policy + }; + assert_eq!(policy.app_id, "org.octosense.samples.wasmprobe"); + self.app = policy.app_id.clone(); + self.root = root.join(".host"); + let bundle = self + .launch + .as_ref() + .map(|l| l.bundle().to_path_buf()) + .unwrap_or(bundle); + let mut settings = policy.isolate_settings(&root); + self.admitted_prompts = settings.host_prompts; + std::fs::create_dir_all(&settings.jail_root).unwrap(); + let assets = octosense_app_policy::AssetServer::start_with_static(&bundle, &[]).unwrap(); + settings.hosts.push(assets.allowlist_entry()); + let source = octosense_app_policy::script_source(&bundle, assets.origin()) + .unwrap() + .unwrap(); + self.card = self.ui.splash(cx, ids!(app)); + self.sheet = self.ui.splash(cx, ids!(sheet)); + octosense_app_policy::splash_adapter::apply(&self.card, cx, &settings); + octosense_appstore::apply_device_consent(cx, &bundle, &self.card).unwrap(); + self.card.set_text(cx, &source); + self.assets = Some(assets); + self.registration = script_tools::bind(cx, &self.app, &bundle, &self.card).unwrap(); + self.timer = Some(cx.start_interval(0.05)); + if preview { + return; + } + #[cfg(not(target_os = "android"))] + let receipt = PathBuf::from(arg("--receipt=").expect("Pass a new --receipt file")); + #[cfg(target_os = "android")] + let receipt = data.join("receipt.json"); + self.receipt = Some(receipt); + assert!( + !self.receipt.as_ref().unwrap().exists(), + "Receipt must be a new file" + ); + let submit = |name, args, account| { + script_tools::submit( + &self.app, + name, + args, + account, + "native-acceptance", + Duration::from_secs(15), + Box::new(|_| {}), + ) + }; + let wrong_account = submit("wasmprobe.inspect", json!({}), "other-account").unwrap_err(); + let undeclared = submit("wasmprobe.not_declared", json!({}), "device").unwrap_err(); + let invalid_args = + submit("wasmprobe.inspect", json!({"injected":true}), "device").unwrap_err(); + self.preliminary = json!({"wrong_account":wrong_account,"undeclared_tool":undeclared,"invalid_arguments":invalid_args}); + let out = self.replies.clone(); + script_tools::submit( + &self.app, + "wasmprobe.inspect", + json!({}), + "device", + "native-acceptance", + Duration::from_secs(15), + Box::new(move |r| out.lock().unwrap().push(r)), + ) + .unwrap(); + } +} + +impl AppMain for App { + fn script_mod(vm: &mut ScriptVm) -> ScriptValue { + makepad_widgets::theme_mod(vm); + script_eval!(vm, {mod.theme = mod.themes.light}); + makepad_widgets::widgets_mod(vm); + widget_async::set_splash_theme(widget_async::SplashTheme::Light); + octosense_appstore::cardapp::CARD_MODULE.register(vm); + octosense_shell::platform_services::register(); + octosense_shell::wasm_service::register(); + self::script_mod(vm) + } + fn handle_event(&mut self, cx: &mut Cx, event: &Event) { + self.match_event(cx, event); + if matches!(event, Event::Pause | Event::Background) { + self.suspended = true; + } + if matches!(event, Event::Resume | Event::Foreground) { + self.suspended = false; + } + // Match the production CardAppView baseline; the test must not hide + // provenance bugs by leaving this false forever after its first tool. + let may_prompt = self.admitted_prompts + && !self.suspended + && !script_tools::pending_for(cx, &self.app, &self.card); + self.card.set_host_prompts(cx, may_prompt); + let sheet_up = self.sheet.borrow().is_some_and(|s| s.view.visible); + if sheet_up && services::is_sheet_input_event(event) { + self.sheet.handle_event(cx, event, &mut Scope::empty()); + } else { + self.ui.handle_event(cx, event, &mut Scope::empty()); + } + script_tools::pump(cx, &self.app, &self.card); + services::pump(cx, &self.app, &self.root, &self.card, &self.sheet); + octosense_shell::platform_services::handle_event(cx, event); + let reply = self.replies.lock().unwrap().pop(); + if let Some(reply) = reply { + let path = self.receipt.take().expect("One acceptance reply"); + self.registration = None; + let closed = script_tools::submit( + &self.app, + "wasmprobe.inspect", + json!({}), + "device", + "native-acceptance", + Duration::from_secs(1), + Box::new(|_| {}), + ) + .unwrap_err(); + let result = json!({"schema":1,"platform":host_api::platform(), + "proof":"signed app -> app_tool -> host.request -> isolated Wasm invocation", + "signed_install":true,"source_revision":env!("WASM_FIXTURE_REVISION"), + "source_dirty":env!("WASM_FIXTURE_DIRTY") != "false", + "runtime_tree":env!("WASM_FIXTURE_RUNTIME_TREE"),"bundle_digest":self.launch.as_ref().unwrap().manifest.integrity.bundle_blake3, + "tool_result":reply,"refusals":self.preliminary,"closed_app":closed, + "host_sheet_visible":self.sheet.borrow().is_some_and(|s| s.view.visible), + "not_verified":["live model or peer relay", "performance benchmark", "release APK"]}); + use std::io::Write; + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .unwrap() + .write_all(&serde_json::to_vec_pretty(&result).unwrap()) + .unwrap(); + println!("HOST_API_LAB_COMPLETE"); + } + } +} diff --git a/runtime-patches.lock.json b/runtime-patches.lock.json index 0fa02771..8acee876 100644 --- a/runtime-patches.lock.json +++ b/runtime-patches.lock.json @@ -4,7 +4,7 @@ "base_revision": "32d6415fb7476345ad36ee4f98d6f844d1f07fd2", "patch": "tools/runtime-patches/makepad-settings.patch", "sha256": "b17352ebf59e50ab3db6ff1cb825c4e006bcdff47d83522e372b8fe585e57570", - "tree": "7133ac8e40a37402cb30f623e613683ca2c54f91", + "tree": "6a6baef94610d7b6f8c7edcb0e7db3fc786415ea", "product_fixes": [ "Settings integration: Android IME synchronization, accessibility and reduced motion, GLES program-cache validation, obscured-gesture cancellation and scroll-position observation.", "Contained-app isolation and secret-input restrictions come from the pinned upstream runtime; the Settings overlay preserves those gates.", @@ -26,7 +26,8 @@ "Opted-in camera and microphone check OS grants before prompting; background/agent requests retain nonprompt authority through callbacks and cannot use desktop timeout approval. Passive location reads remain available; legacy GPS requests require foreground prompt authority.", "Host request JSON and pre-drain queues are bounded before native allocation; delayed host callbacks retain their nonprompting origin after an agent tool completes or is cancelled.", "Agent/background prompt denial follows detached timers, tasks, paused continuations, widget callbacks and HTTP/WebSocket callbacks, including native device helpers. A fresh foreground entry retains its own authority; scoped denial restores on exit or unwind.", - "Ordinary WebReader embeds sandboxed WebKitGTK in X11/XWayland and private WebView2 controllers on Windows, with bounded UI integration, URL policy, no app bridge and explicit engine errors. Native Wayland and Linux/Windows production embedded authentication remain unsupported." + "Ordinary WebReader embeds sandboxed WebKitGTK in X11/XWayland and private WebView2 controllers on Windows, with bounded UI integration, URL policy, no app bridge and explicit engine errors. Native Wayland and Linux/Windows production embedded authentication remain unsupported.", + "Native host-response envelope keys are interned when created, so contained apps can forward raw success, error and capability-refusal replies as bounded JSON without first naming each field in script source." ], "stacked": [ { @@ -104,6 +105,10 @@ { "patch": "tools/runtime-patches/makepad-desktop-webview.patch", "sha256": "dda81412903a247525cf3750a00aab820aa1dea28722cc633cbdf65fd5770221" + }, + { + "patch": "tools/runtime-patches/makepad-host-response-json.patch", + "sha256": "a95cd969b2d034ca30d38bfdb6ca6a890aaef4f24c7034076ddf72eb4c3d829a" } ] } diff --git a/tools/fixtures/wasm-phone-lab/README.md b/tools/fixtures/wasm-phone-lab/README.md new file mode 100644 index 00000000..cd5a9eee --- /dev/null +++ b/tools/fixtures/wasm-phone-lab/README.md @@ -0,0 +1,25 @@ +# Wasm phone acceptance + +English | [简体中文](README.zh-CN.md) + +This synthetic app exercises the signed Store → app tool → `host.request` → Wasm service path. It uses no personal account or model. It checks fresh state after a successful call, guest error, trap and deadline; raw native responses must survive bounded JSON conversion. It also checks account scope, undeclared tools, invalid arguments, closed endpoints and absence of an approval sheet. + +Use a clean committed checkout and an assigned Android device. The fixture embeds its source commit and runtime tree when compiled; the driver refuses stale or dirty builds. It installs only a fresh `dev.makepad.octosense.hostapilab.*` package and stops that package afterward. It does not use or replace Home. The APK is a debuggable test build, not a release or performance benchmark. + +Use your existing Android SDK, JDK, ADB and pinned Makepad packager. Set `CARGO_MAKEPAD`, `MAKEPAD_ANDROID_SDK`, `ADB`, `AAPT2`, `HUB`, `WASM_TARGET` and `WASM_EVIDENCE` to your local paths; the evidence directory must not exist. From the repository root: + +```sh +python3 tools/setup.py --check --cargo +MAKEPAD_FORCE_DEBUGGABLE=1 CARGO_TARGET_DIR="$WASM_TARGET" "$CARGO_MAKEPAD" makepad android --sdk-path="$MAKEPAD_ANDROID_SDK" --abi=aarch64 --version-code=2026100703 --package-name=dev.makepad.octosense.hostapilab.wasm1 --app-label=OctoSenseWasmIsolationTest build -p octosense-wasm-phone-smoke --release --locked --offline +python3 tools/test-wasm-phone.py --adb "$ADB" --aapt2 "$AAPT2" --hub "$HUB" --apk "$WASM_TARGET/makepad-android-apk/octosense_wasm_phone_smoke/apk/octo_sense_wasm_isolation_test.apk" --out "$WASM_EVIDENCE" +``` + +The driver stamps a temporary copy. The native host creates ephemeral signing keys in memory and uses normal signature, digest, capability and tool admission. Keys and provider credentials are not published. Receipts record APK/source/runtime identities and individual results, without device serials or SDK paths. Keep an earlier receipt separate; choose a new package suffix for another install or remove only your previous test package. + +`state.wat` is the source of `bundle/fns/state.wasm`. To reproduce the checked-in module: + +```sh +cargo run --locked --offline -p octosense-wasm-host --example encode_phone_fixture -- tools/fixtures/wasm-phone-lab/state.wat tools/fixtures/wasm-phone-lab/bundle/fns/state.wasm +``` + +The guest deliberately remembers input if its instance is reused. Passing requires the host to create a new instance, including after success. Earlier 19-check evidence used explicit result fields and did not prove raw response forwarding; the repaired raw path adds that check, and the reproducible harness adds two compiled-identity checks. Actual device results are recorded separately from unrun instructions. diff --git a/tools/fixtures/wasm-phone-lab/README.zh-CN.md b/tools/fixtures/wasm-phone-lab/README.zh-CN.md new file mode 100644 index 00000000..9b935ab2 --- /dev/null +++ b/tools/fixtures/wasm-phone-lab/README.zh-CN.md @@ -0,0 +1,25 @@ +# Wasm 手机验收 + +[English](README.md) | 简体中文 + +这个合成应用验证签名商店准入 → 应用工具 → `host.request` → Wasm 服务的真实路径,不使用个人账户或模型。它检查成功调用、客体错误、陷阱和超时之后的新实例状态,以及未经重组的原生响应能否通过有大小限制的 JSON 转换。它也检查账户范围、未声明工具、无效参数、已关闭端点和审批面板未出现。 + +使用已提交且干净的检出目录和获准测试的 Android 设备。编译时把源码提交及运行时树标识嵌入测试应用,驱动拒绝过期或未提交的构建。只安装全新的 `dev.makepad.octosense.hostapilab.*` 包,并在结束后停止该包,不读取或替换 Home。这是可调试的测试 APK,不是发布包或性能基准。 + +使用已有 Android SDK、JDK、ADB 和固定版本的 Makepad 打包器。把 `CARGO_MAKEPAD`、`MAKEPAD_ANDROID_SDK`、`ADB`、`AAPT2`、`HUB`、`WASM_TARGET`、`WASM_EVIDENCE` 设置为本机路径;证据目录必须尚不存在。在仓库根目录运行: + +```sh +python3 tools/setup.py --check --cargo +MAKEPAD_FORCE_DEBUGGABLE=1 CARGO_TARGET_DIR="$WASM_TARGET" "$CARGO_MAKEPAD" makepad android --sdk-path="$MAKEPAD_ANDROID_SDK" --abi=aarch64 --version-code=2026100703 --package-name=dev.makepad.octosense.hostapilab.wasm1 --app-label=OctoSenseWasmIsolationTest build -p octosense-wasm-phone-smoke --release --locked --offline +python3 tools/test-wasm-phone.py --adb "$ADB" --aapt2 "$AAPT2" --hub "$HUB" --apk "$WASM_TARGET/makepad-android-apk/octosense_wasm_phone_smoke/apk/octo_sense_wasm_isolation_test.apk" --out "$WASM_EVIDENCE" +``` + +驱动只给临时副本重新计算摘要。原生宿主在内存中生成临时签名密钥,使用正常的签名、摘要、能力和工具准入检查,不发布密钥或服务商凭据。回执记录 APK、源码及运行时标识和各项结果,不包含设备序列号或 SDK 路径。保留旧回执;重新安装时选择新的包后缀,或只删除自己上一次的测试包。 + +`state.wat` 是 `bundle/fns/state.wasm` 的源码。重建已提交的模块: + +```sh +cargo run --locked --offline -p octosense-wasm-host --example encode_phone_fixture -- tools/fixtures/wasm-phone-lab/state.wat tools/fixtures/wasm-phone-lab/bundle/fns/state.wasm +``` + +客体在实例复用时会主动保留输入;通过测试要求宿主在每次调用(包括成功调用)之后创建新实例。先前的 19 项结果使用了显式组装的字段,不能证明原始响应转发。修复后的原始路径增加一项转发检查,可复现的公开测试还增加两项编译标识检查。实际设备结果与未执行的说明分开记录。 diff --git a/tools/fixtures/wasm-phone-lab/bundle/AGENT.md b/tools/fixtures/wasm-phone-lab/bundle/AGENT.md new file mode 100644 index 00000000..4544dbd9 --- /dev/null +++ b/tools/fixtures/wasm-phone-lab/bundle/AGENT.md @@ -0,0 +1 @@ +This is a local synthetic Wasm containment test. Never request personal data or external services. No live model is started. diff --git a/tools/fixtures/wasm-phone-lab/bundle/assets/icon.svg b/tools/fixtures/wasm-phone-lab/bundle/assets/icon.svg new file mode 100644 index 00000000..605a5588 --- /dev/null +++ b/tools/fixtures/wasm-phone-lab/bundle/assets/icon.svg @@ -0,0 +1 @@ + diff --git a/tools/fixtures/wasm-phone-lab/bundle/fns/state.wasm b/tools/fixtures/wasm-phone-lab/bundle/fns/state.wasm new file mode 100644 index 0000000000000000000000000000000000000000..a62efdcc959c8ace4e6fabc792bfd3c9c68c19b0 GIT binary patch literal 293 zcmX|+OHRW;5JanH{29B6NN!+>oFFH_xdUvI!Ay{me;Q{43+x3Fr{NIXiv{CIp%$&z z(ra}$?AQ_jH~Pql(@{+mmckF68IWp8tO+Jb5K2h;V3O9gy?;~4`|8{uY}51=kX@RS zcMde(1?S0m=+zYuCtEj&67Q-ofjByQJ0OY1q3)38k0wSr7os2=6x^cxK@s3L2PD7X zTJ0&fSNxhoC~NE&n9UtZzJ7m-4t@rGe*+1+tccP`%4NHI#xCk?>nK$JH>xbW2@YhZ RbEZUWUu@l_fugZ~cYo`_y#)ilcG`yMd;T;S&NW8DJ7wjJ%()Avrde$l_a4JA!OfYtTW6Q6J_7m z!5CAv!OR%z490N2`knPU=jFMsb6)&EFEZCK^I4wf^W67yKhL+Ahemgg@(A;QK%k@g z_ijH1fw+K|T&E5l1b&3);DSM*b0GcOI!|7HqY}BkJuww&UEywQTJoykJ;fy;z^Q(s zSr3iDVDc_rJaxPIA6|V9&cugcUQX+ZJ6E_SS=u)`(=q&zK$m3VALfRi6NGF;)F7+& zsF!_ybILY@(`CUc zD8rzIf~T_L@TczDd%Fo7z=ALD90DBxJ!~r@mA7-zY=hZ+P)M^dYn`%zTr;}kR}-?l zWtE0=tkF~@o3osUgE@iEJbbyo+3&|$?le89S0e&B9e>ATaLzEOvVM2cjWttJvu~U4 zFV2Gwfo}fGXlep`uH|^(SRwy`*zBRG?=%Vyp6WLnj@odJPHLyzw-;)7NBI?5v@ z7-a){m#9v@r(X7p!#Pd%Zl`L2=raL?MmuBDFS!seXIA~zlfHyy+M?=LJEW0V$|M#; z5QMQ9h6_O=${sHaDxNh@MDA1st<9Bs!fD{#Lu5q|h`@g!90WS`xcQx)%R+j{PN?mU z9Bj|Vr}5fYCCD7HgPO9^LrH!FkY8v%nS`{3en-pOb)ml%rS2wUu)x zR?rLTX9NP3!~Q#x8rQ+O!r66xKHo5KRcH=u_h;%^`LNP4wGk3rT_`py;3Cr{(ytJ7|^gMI4ehis+S1jn_pXtvMh6;djK>dHse$R8f`ih%RqpJ57J@cvT zM{#A(iPLN`LYuy1`phnSwNsu(gplIu;ww3|w`M*UZbURWT4y)_SFY->3w+=)0!=|L z9_QIzY1KJ~Y04vYel8+v{?8|F7L!n

Pm! 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

fUKOikaG*zZ$mZpq;nF5SH$+gK@~pWYbDx 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%0Ak9%XJeN7Z;d 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 literal 0 HcmV?d00001 diff --git a/tools/fixtures/wasm-phone-lab/bundle/tools.json b/tools/fixtures/wasm-phone-lab/bundle/tools.json new file mode 100644 index 00000000..099dd6aa --- /dev/null +++ b/tools/fixtures/wasm-phone-lab/bundle/tools.json @@ -0,0 +1,39 @@ +{ + "schema": 1, + "tools": [ + { + "name": "wasmprobe.inspect", + "description": "Run synthetic Wasm isolation and containment checks.", + "implemented_by": "app", + "risk": "read", + "background": false, + "shareable": false, + "private_data": true, + "input_schema": { + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "output_schema": { + "type": "object", + "required": [ + "account", + "results", + "functions" + ], + "properties": { + "account": { + "type": "string" + }, + "results": { + "type": "object" + }, + "functions": { + "type": "object" + } + }, + "additionalProperties": false + } + } + ] +} diff --git a/tools/fixtures/wasm-phone-lab/state.wat b/tools/fixtures/wasm-phone-lab/state.wat new file mode 100644 index 00000000..ff722671 --- /dev/null +++ b/tools/fixtures/wasm-phone-lab/state.wat @@ -0,0 +1,17 @@ +(module + (memory (export "memory") 1) + (global $size (mut i32) (i32.const 0)) + (data (i32.const 8) "\01failed") + (func (export "octo_alloc") (param i32) (result i32) (i32.const 4096)) + (func (export "octo_free") (param i32 i32)) + (func $remember (param $p i32) (param $n i32) + (global.set $size (local.get $n)) + (memory.copy (i32.const 65) (local.get $p) (local.get $n))) + (func (export "remember") (param i32 i32) (result i64) + (call $remember (local.get 0) (local.get 1)) (i64.const 1)) + (func (export "remember_fail") (param i32 i32) (result i64) + (call $remember (local.get 0) (local.get 1)) (i64.const 34359738375)) + (func (export "recall") (param i32 i32) (result i64) + (i64.or (i64.const 274877906944) (i64.extend_i32_u (i32.add (global.get $size) (i32.const 1))))) + (func (export "trap") (param i32 i32) (result i64) unreachable) + (func (export "spin") (param i32 i32) (result i64) (loop $again (br $again)) (i64.const 1))) \ No newline at end of file diff --git a/tools/runtime-patches/makepad-host-response-json.patch b/tools/runtime-patches/makepad-host-response-json.patch new file mode 100644 index 00000000..5cb7555d --- /dev/null +++ b/tools/runtime-patches/makepad-host-response-json.patch @@ -0,0 +1,133 @@ +diff --git a/widgets/src/splash_host.rs b/widgets/src/splash_host.rs +index 257498cfc..15d9c8a76 100644 +--- a/widgets/src/splash_host.rs ++++ b/widgets/src/splash_host.rs +@@ -6,7 +6,7 @@ + //! This module is the ONE doorway for all of that: an isolate's script asks, + //! the embedding host (the launcher) decides and answers. No policy lives + //! here; an undrained or unrecognized request simply never resolves, and a +-//! host that says no answers `{ok: false}`. ++//! host that says no answers `{is_ok: false}`. + //! + //! Registered in isolates as `mod.host` (bound as `host` by the Splash + //! prefix, like `fs`): +@@ -218,10 +218,12 @@ pub fn splash_host_respond( + }; + let obj = vm.bx.heap.new_object(); + let trap = vm.bx.threads.cur().trap.pass(); ++ // Intern the native keys so the complete envelope can round-trip as JSON ++ // even when the app never names a field in its source. + // `is_ok`, never `ok`: `ok` is a script keyword and unusable as a field. +- vm.bx.heap.set_value(obj, id!(is_ok).into(), ok.into(), trap); +- vm.bx.heap.set_value(obj, id!(data).into(), data, trap); +- vm.bx.heap.set_value(obj, id!(error).into(), error, trap); ++ vm.bx.heap.set_value(obj, id_lut!(is_ok).into(), ok.into(), trap); ++ vm.bx.heap.set_value(obj, id_lut!(data).into(), data, trap); ++ vm.bx.heap.set_value(obj, id_lut!(error).into(), error, trap); + if !crate::splash_policy::may_run(heap_key) { + delivered = false; + return; +@@ -336,9 +338,9 @@ pub fn script_mod(vm: &mut ScriptVm) { + let obj = vm.bx.heap.new_object(); + let error = vm.bx.heap.new_string_from_str(&reason); + let trap = vm.bx.threads.cur().trap.pass(); +- vm.bx.heap.set_value(obj, id!(is_ok).into(), false.into(), trap); +- vm.bx.heap.set_value(obj, id!(data).into(), NIL, trap); +- vm.bx.heap.set_value(obj, id!(error).into(), error, trap); ++ vm.bx.heap.set_value(obj, id_lut!(is_ok).into(), false.into(), trap); ++ vm.bx.heap.set_value(obj, id_lut!(data).into(), NIL, trap); ++ vm.bx.heap.set_value(obj, id_lut!(error).into(), error, trap); + vm.call(callback.as_object().into(), &[obj.into()]); + (0.0f64).into() + } +@@ -413,6 +415,90 @@ pub fn script_mod(vm: &mut ScriptVm) { + mod tests { + use super::*; + ++ #[test] ++ fn raw_service_envelopes_round_trip_without_script_field_lookups() { ++ const CHILD: &str = "MAKEPAD_TEST_RAW_HOST_ENVELOPE"; ++ if std::env::var_os(CHILD).is_none() { ++ for mode in ["success", "error", "refusal"] { ++ let output = std::process::Command::new(std::env::current_exe().unwrap()) ++ .args(["--exact", "splash_host::tests::raw_service_envelopes_round_trip_without_script_field_lookups", "--nocapture"]) ++ .env(CHILD, mode).output().unwrap(); ++ assert!( ++ output.status.success(), ++ "{mode}: {}\n{}", ++ String::from_utf8_lossy(&output.stdout), ++ String::from_utf8_lossy(&output.stderr) ++ ); ++ } ++ return; ++ } ++ use crate::*; ++ let mode = std::env::var(CHILD).unwrap(); ++ let mut cx = Cx::new(Box::new(|_, _| {})); ++ let root = cx.with_vm(|vm| { ++ crate::script_mod(vm); ++ let value = script_eval!(vm, { use mod.widgets.* View { card := Splash{} } }); ++ WidgetRef::script_from_value(vm, value) ++ }); ++ assert!( ++ [id!(is_ok), id!(data), id!(error)] ++ .iter() ++ .any(|id| id.as_string(|text| text.is_none())), ++ "fresh-process control: the script must not have interned every native envelope key" ++ ); ++ let card = root.splash(&mut cx, ids!(card)); ++ card.set_host_caps(&mut cx, vec!["fixture".into()]); ++ card.set_policy(&mut cx, Some(vec![]), Some(100_000)); ++ let service = if mode == "refusal" { ++ "ungranted.read" ++ } else { ++ "fixture.read" ++ }; ++ // In particular, do not reference any envelope field in this source: ++ // field access would intern its spelling and mask the native bug. ++ card.set_text( ++ &mut cx, ++ &format!( ++ r#" ++ host.request("{service}", {{}}, fn(reply) {{ ++ host.request("fixture.forward", reply, nil) ++ }}) ++ Label{{text:"raw response fixture"}} ++ "# ++ ), ++ ); ++ let heap = card.isolate_heap_key(&mut cx).unwrap(); ++ if mode != "refusal" { ++ let initial = take_splash_host_requests_for(&[heap]).pop().unwrap(); ++ let result = if mode == "success" { ++ Ok(r#"{"unknown_payload_key": [-2, "value"]}"#) ++ } else { ++ Err("synthetic failure") ++ }; ++ splash_host_respond(&mut cx, heap, initial.req_id, result); ++ } ++ let forwarded = take_splash_host_requests_for(&[heap]); ++ assert_eq!( ++ forwarded.len(), ++ 1, ++ "a raw envelope remains bounded, serializable JSON" ++ ); ++ assert_eq!(forwarded[0].service, "fixture.forward"); ++ let value: serde_json::Value = serde_json::from_str(&forwarded[0].args_json).unwrap(); ++ assert_eq!(value["is_ok"], mode == "success"); ++ if mode == "success" { ++ assert_eq!( ++ value["data"]["unknown_payload_key"], ++ serde_json::json!([-2, "value"]) ++ ); ++ assert!(value["error"].is_null()); ++ } else { ++ assert!(value["data"].is_null()); ++ assert!(value["error"].as_str().is_some_and(|text| !text.is_empty())); ++ } ++ gc_bridge(&[heap]); ++ } ++ + #[test] + fn delayed_background_callbacks_keep_nested_requests_nonprompting() { + use crate::*; diff --git a/tools/test-wasm-phone.py b/tools/test-wasm-phone.py new file mode 100644 index 00000000..41ec4dfb --- /dev/null +++ b/tools/test-wasm-phone.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""Run the signed Host API Lab on an assigned Android phone in a fresh package. + +Synthetic Wasm input only. No account, model, permission grant or production Home change. +The native host uses the same app-tool/host-service code as the desktop lab. +""" +import argparse +import hashlib +import io +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import tarfile +import time + +ROOT = Path(__file__).resolve().parents[1] + + +def main(): + os.umask(0o077) + parser = argparse.ArgumentParser(description=__doc__) + for key in ('adb', 'apk', 'aapt2', 'hub', 'out'): + parser.add_argument('--' + key, type=Path, required=True) + parser.add_argument('--serial', help='Assigned phone only; omitted from evidence') + parser.add_argument('--package', default='dev.makepad.octosense.hostapilab.wasm1') + args = parser.parse_args() + if not re.fullmatch(r'dev\.makepad\.octosense\.hostapilab(?:\.[a-z0-9_]+)*', args.package): + parser.error('Use a separate hostapilab package, never Home') + source_revision = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT, text=True).strip() + if subprocess.check_output(['git', 'status', '--porcelain', '--untracked-files=normal'], cwd=ROOT): + parser.error('Build and run the fixture from a clean committed source tree') + runtime_tree = json.loads((ROOT / 'runtime-patches.lock.json').read_text())['makepad']['tree'] + args.out.mkdir(mode=0o700, parents=True, exist_ok=False) + base = [str(args.adb)] + (['-s', args.serial] if args.serial else []) + + def adb(*values, data=None, checked=True): + return subprocess.run(base + list(values), input=data, check=checked, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=45) + + devices = adb('devices').stdout.decode().splitlines()[1:] + if not args.serial and sum(line.endswith('\tdevice') for line in devices) != 1: + parser.error('Select the assigned phone with --serial') + model = adb('shell', 'getprop', 'ro.product.model').stdout.decode().strip() + android = adb('shell', 'getprop', 'ro.build.version.release').stdout.decode().strip() + present = adb('shell', 'pm', 'path', args.package, checked=False) + if present.returncode not in (0, 1) or present.stderr.strip() or present.stdout.strip(): + parser.error('Expected a fresh, absent package; select a new suffix') + badging = subprocess.run([str(args.aapt2), 'dump', 'badging', str(args.apk)], + check=True, capture_output=True).stdout.decode() + if f"name='{args.package}'" not in badging or 'application-debuggable' not in badging: + parser.error('APK must match the separate debuggable package') + bundle = args.out / 'bundle' + shutil.copytree(ROOT / 'tools/fixtures/wasm-phone-lab/bundle', bundle) + listing = json.loads((bundle / 'listing.json').read_text()) + listing['platforms'] = ['android'] + (bundle / 'listing.json').write_text(json.dumps(listing, indent=2) + '\n') + subprocess.run([str(args.hub), 'stamp', str(bundle)], check=True, capture_output=True) + archive = io.BytesIO() + with tarfile.open(fileobj=archive, mode='w') as tar: + for path in sorted(bundle.rglob('*')): + if path.is_symlink() or not (path.is_file() or path.is_dir()): + raise ValueError('Fixture accepts regular files and directories only') + tar.add(path, arcname='wasm-phone-lab/bundle/' + str(path.relative_to(bundle)), recursive=False) + installed = False + receipt = {'schema': 1, 'platform': 'android', 'model': model, 'android': android, + 'package': args.package, 'apk_sha256': hashlib.sha256(args.apk.read_bytes()).hexdigest(), + 'personal_accounts_used': False, 'model_started': False, 'source_revision': source_revision, 'runtime_tree': runtime_tree, 'wasm_service_sha256': hashlib.sha256((ROOT/'crates/shell/src/wasm_service.rs').read_bytes()).hexdigest(), 'checks': {}} + try: + adb('install', str(args.apk)) + installed = True + adb('shell', 'run-as', args.package, 'mkdir', '-p', 'files') + adb('shell', '-T', 'run-as', args.package, 'tar', '-xf', '-', '-C', 'files', data=archive.getvalue()) + adb('shell', 'am', 'start', '-n', args.package + '/.MakepadApp') + deadline = time.monotonic() + 90 + while time.monotonic() < deadline: + result = adb('exec-out', 'run-as', args.package, 'cat', + 'files/wasm-phone-lab/receipt.json', checked=False) + if result.returncode == 0: + try: + native = json.loads(result.stdout) + break + except ValueError: + pass + time.sleep(.25) + else: + raise AssertionError('Native phone host did not produce a receipt') + (args.out / 'native-result.json').write_text(json.dumps(native, indent=2) + '\n') + checks = receipt['checks'] + + def require(name, condition): + checks[name] = bool(condition) + if not condition: + raise AssertionError(name) + + require('compiled_source_identity_matches', native['source_revision'] == source_revision and native['source_dirty'] is False) + require('compiled_runtime_identity_matches', native['runtime_tree'] == runtime_tree) + require('signed_native_android_install', native['signed_install'] is True and native['platform'] == 'android') + require('real_script_tool_completed', 'Ok' in native['tool_result']) + data = native['tool_result']['Ok'] + require('bound_device_account', data['account'] == 'device') + raw_results = data['results'] + require('raw_host_envelopes_forwarded', all(set(r) == {'is_ok','data','error'} for r in raw_results.values()) and set(data['functions']) == {'is_ok','data','error'}) + results = {name: {'is_ok': r['is_ok'], 'text': r['data']['text'] if r['is_ok'] else '', 'error': r['error']} for name, r in raw_results.items()} + require('successful_guest_call', results['remember']['is_ok'] is True) + require('success_does_not_retain_input', results['after_success']['is_ok'] is True and results['after_success']['text'] == '') + require('guest_error_received', results['guest_error']['is_ok'] is False and 'failed' in results['guest_error']['error']) + require('guest_error_does_not_retain_input', results['after_guest_error']['is_ok'] is True and results['after_guest_error']['text'] == '') + require('guest_trap_received', results['trap']['is_ok'] is False and 'unreachable' in results['trap']['error'].lower()) + require('fresh_instance_after_trap', results['after_trap']['is_ok'] is True and results['after_trap']['text'] == '') + require('guest_deadline_received', results['deadline']['is_ok'] is False and 'deadline' in results['deadline']['error'].lower()) + require('fresh_instance_after_deadline', results['after_deadline']['is_ok'] is True and results['after_deadline']['text'] == '') + require('functions_answered', data['functions']['is_ok'] is True) + modules = data['functions']['data']['modules'] + require('fresh_instance_policy_reported', len(modules) == 1 and modules[0]['instance_policy'] == 'fresh-per-call') + require('eight_distinct_guest_invocations', modules[0]['invocations'] == 8) + require('no_native_approval_sheet', native['host_sheet_visible'] is False) + require('cross_account_refused', 'account_scope' in native['refusals']['wrong_account']) + require('undeclared_tool_refused', bool(native['refusals']['undeclared_tool'])) + require('invalid_arguments_refused', bool(native['refusals']['invalid_arguments'])) + require('closed_tool_endpoint_refused', bool(native['closed_app'])) + receipt['passed'] = True + receipt['not_verified'] = native['not_verified'] + except Exception as error: + receipt['passed'] = False + # Subprocess exceptions include their command (possibly --serial and + # private build paths). Only our fixed assertion messages are public. + receipt['error'] = str(error) if isinstance(error, AssertionError) else type(error).__name__ + raise + finally: + if installed: + try: + receipt['owned_package_stopped'] = adb( + 'shell', 'am', 'force-stop', args.package, checked=False).returncode == 0 + except subprocess.SubprocessError: + receipt['owned_package_stopped'] = False + (args.out / 'receipt.json').write_text(json.dumps(receipt, indent=2) + '\n') + print(json.dumps({'passed': receipt['passed'], 'checks': len(receipt['checks']), + 'owned_package_stopped': receipt['owned_package_stopped']})) + + +if __name__ == '__main__': + main() From 065fd5f216f410026eb652612782e8887fa7ad8a Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:28:12 -0700 Subject: [PATCH 28/35] test(android): record reproducible raw-response Wasm acceptance --- tools/fixtures/wasm-phone-lab/README.md | 2 +- tools/fixtures/wasm-phone-lab/README.zh-CN.md | 2 +- .../wasm-phone-lab/acceptance-oneplus6.json | 44 +++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 tools/fixtures/wasm-phone-lab/acceptance-oneplus6.json diff --git a/tools/fixtures/wasm-phone-lab/README.md b/tools/fixtures/wasm-phone-lab/README.md index cd5a9eee..9878ed86 100644 --- a/tools/fixtures/wasm-phone-lab/README.md +++ b/tools/fixtures/wasm-phone-lab/README.md @@ -22,4 +22,4 @@ The driver stamps a temporary copy. The native host creates ephemeral signing ke cargo run --locked --offline -p octosense-wasm-host --example encode_phone_fixture -- tools/fixtures/wasm-phone-lab/state.wat tools/fixtures/wasm-phone-lab/bundle/fns/state.wasm ``` -The guest deliberately remembers input if its instance is reused. Passing requires the host to create a new instance, including after success. Earlier 19-check evidence used explicit result fields and did not prove raw response forwarding; the repaired raw path adds that check, and the reproducible harness adds two compiled-identity checks. Actual device results are recorded separately from unrun instructions. +The guest deliberately remembers input if its instance is reused. Passing requires the host to create a new instance, including after success. Earlier 19-check evidence used explicit result fields and did not prove raw response forwarding; the repaired raw path adds that check, and the reproducible harness adds two compiled-identity checks. The [OnePlus 6 receipt](acceptance-oneplus6.json) records **22/22 checks passed** on Android 15, built from source `e67ce63ebca6054961899691e1644a54c2b4f081` with runtime tree `0fc8e29e2411fd7eb94d845ba16a261d2ebc9dfc`. The build and driver commands above were executed with local tool paths. Live model/peer relay, performance and release APK behavior remain unverified. diff --git a/tools/fixtures/wasm-phone-lab/README.zh-CN.md b/tools/fixtures/wasm-phone-lab/README.zh-CN.md index 9b935ab2..f984e339 100644 --- a/tools/fixtures/wasm-phone-lab/README.zh-CN.md +++ b/tools/fixtures/wasm-phone-lab/README.zh-CN.md @@ -22,4 +22,4 @@ python3 tools/test-wasm-phone.py --adb "$ADB" --aapt2 "$AAPT2" --hub "$HUB" -- cargo run --locked --offline -p octosense-wasm-host --example encode_phone_fixture -- tools/fixtures/wasm-phone-lab/state.wat tools/fixtures/wasm-phone-lab/bundle/fns/state.wasm ``` -客体在实例复用时会主动保留输入;通过测试要求宿主在每次调用(包括成功调用)之后创建新实例。先前的 19 项结果使用了显式组装的字段,不能证明原始响应转发。修复后的原始路径增加一项转发检查,可复现的公开测试还增加两项编译标识检查。实际设备结果与未执行的说明分开记录。 +客体在实例复用时会主动保留输入;通过测试要求宿主在每次调用(包括成功调用)之后创建新实例。先前的 19 项结果使用了显式组装的字段,不能证明原始响应转发。修复后的原始路径增加一项转发检查,可复现的公开测试还增加两项编译标识检查。[OnePlus 6 回执](acceptance-oneplus6.json)记录 Android 15 上 **22/22 项通过**,构建源码为 `e67ce63ebca6054961899691e1644a54c2b4f081`,运行时树为 `0fc8e29e2411fd7eb94d845ba16a261d2ebc9dfc`。上面的构建和驱动命令已使用本机工具路径执行。真实模型/对等代理转发、性能和发布 APK 行为仍未验证。 diff --git a/tools/fixtures/wasm-phone-lab/acceptance-oneplus6.json b/tools/fixtures/wasm-phone-lab/acceptance-oneplus6.json new file mode 100644 index 00000000..59b88f84 --- /dev/null +++ b/tools/fixtures/wasm-phone-lab/acceptance-oneplus6.json @@ -0,0 +1,44 @@ +{ + "schema": 1, + "platform": "android", + "model": "ONEPLUS A6003", + "android": "15", + "package": "dev.makepad.octosense.hostapilab.wasm1", + "apk_sha256": "ffdadbfbc2b268c6007856dcc7f2a8b5e971f63076738a6d6026a1abecaebc5a", + "personal_accounts_used": false, + "model_started": false, + "source_revision": "e67ce63ebca6054961899691e1644a54c2b4f081", + "runtime_tree": "0fc8e29e2411fd7eb94d845ba16a261d2ebc9dfc", + "wasm_service_sha256": "8197e7ef731d69779fc1e002aa3d4318ee227869be5aa0ee9fa2ee5da58e565f", + "checks": { + "compiled_source_identity_matches": true, + "compiled_runtime_identity_matches": true, + "signed_native_android_install": true, + "real_script_tool_completed": true, + "bound_device_account": true, + "raw_host_envelopes_forwarded": true, + "successful_guest_call": true, + "success_does_not_retain_input": true, + "guest_error_received": true, + "guest_error_does_not_retain_input": true, + "guest_trap_received": true, + "fresh_instance_after_trap": true, + "guest_deadline_received": true, + "fresh_instance_after_deadline": true, + "functions_answered": true, + "fresh_instance_policy_reported": true, + "eight_distinct_guest_invocations": true, + "no_native_approval_sheet": true, + "cross_account_refused": true, + "undeclared_tool_refused": true, + "invalid_arguments_refused": true, + "closed_tool_endpoint_refused": true + }, + "passed": true, + "not_verified": [ + "live model or peer relay", + "performance benchmark", + "release APK" + ], + "owned_package_stopped": true +} From bb336a819f8abcd2a6bfed3e7c42d3a8a37aed7f Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:57:49 -0700 Subject: [PATCH 29/35] test(wasm): use actual native fixture capture in listing --- tools/fixtures/wasm-phone-lab/README.md | 2 ++ tools/fixtures/wasm-phone-lab/README.zh-CN.md | 2 ++ .../wasm-phone-lab/bundle/manifest.json | 2 +- .../bundle/screenshots/01-main.png | Bin 143410 -> 39354 bytes 4 files changed, 5 insertions(+), 1 deletion(-) diff --git a/tools/fixtures/wasm-phone-lab/README.md b/tools/fixtures/wasm-phone-lab/README.md index 9878ed86..ad730913 100644 --- a/tools/fixtures/wasm-phone-lab/README.md +++ b/tools/fixtures/wasm-phone-lab/README.md @@ -23,3 +23,5 @@ cargo run --locked --offline -p octosense-wasm-host --example encode_phone_fixtu ``` The guest deliberately remembers input if its instance is reused. Passing requires the host to create a new instance, including after success. Earlier 19-check evidence used explicit result fields and did not prove raw response forwarding; the repaired raw path adds that check, and the reproducible harness adds two compiled-identity checks. The [OnePlus 6 receipt](acceptance-oneplus6.json) records **22/22 checks passed** on Android 15, built from source `e67ce63ebca6054961899691e1644a54c2b4f081` with runtime tree `0fc8e29e2411fd7eb94d845ba16a261d2ebc9dfc`. The build and driver commands above were executed with local tool paths. Live model/peer relay, performance and release APK behavior remain unverified. + +The listing image is a separate native macOS capture of this fixture after its signed app tool completed, made in a hidden Makepad window from source `d3535a95`. It is not a phone screenshot and does not extend the 22-check OnePlus receipt to the later screenshot-only change. The image and refreshed fixture digest replace the earlier unrelated Host API Lab image. diff --git a/tools/fixtures/wasm-phone-lab/README.zh-CN.md b/tools/fixtures/wasm-phone-lab/README.zh-CN.md index f984e339..eab1b806 100644 --- a/tools/fixtures/wasm-phone-lab/README.zh-CN.md +++ b/tools/fixtures/wasm-phone-lab/README.zh-CN.md @@ -23,3 +23,5 @@ cargo run --locked --offline -p octosense-wasm-host --example encode_phone_fixtu ``` 客体在实例复用时会主动保留输入;通过测试要求宿主在每次调用(包括成功调用)之后创建新实例。先前的 19 项结果使用了显式组装的字段,不能证明原始响应转发。修复后的原始路径增加一项转发检查,可复现的公开测试还增加两项编译标识检查。[OnePlus 6 回执](acceptance-oneplus6.json)记录 Android 15 上 **22/22 项通过**,构建源码为 `e67ce63ebca6054961899691e1644a54c2b4f081`,运行时树为 `0fc8e29e2411fd7eb94d845ba16a261d2ebc9dfc`。上面的构建和驱动命令已使用本机工具路径执行。真实模型/对等代理转发、性能和发布 APK 行为仍未验证。 + +应用列表图片是单独在 macOS 隐藏 Makepad 窗口中捕获的真实界面,源码为 `d3535a95`,拍摄时此测试应用的签名应用工具已经完成。它不是手机截图,也不把 OnePlus 的 22 项回执扩展到后续仅替换图片的提交。该图片及更新后的测试包摘要替换了先前无关的 Host API Lab 图片。 diff --git a/tools/fixtures/wasm-phone-lab/bundle/manifest.json b/tools/fixtures/wasm-phone-lab/bundle/manifest.json index f9c2856f..821ac636 100644 --- a/tools/fixtures/wasm-phone-lab/bundle/manifest.json +++ b/tools/fixtures/wasm-phone-lab/bundle/manifest.json @@ -21,7 +21,7 @@ }, "id": "org.octosense.samples.wasmprobe", "integrity": { - "bundle_blake3": "2dc9b724c9d3a9c909a9cb0e57a5409ea78f4835d4be12c708e03db791230aa2" + "bundle_blake3": "162037c17ea778ddfd413c212062d07ab85d209eca75344bea361ff5f3d76fa7" }, "name": "Wasm isolation probe", "requires": [ diff --git a/tools/fixtures/wasm-phone-lab/bundle/screenshots/01-main.png b/tools/fixtures/wasm-phone-lab/bundle/screenshots/01-main.png index 83164aecbb8eabdb3c0f09032e590c82f4c1fa30..012dd2354066e2a16da6a9aae801f0443e70e40a 100644 GIT binary patch literal 39354 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 ts}%+?fv}WVfShW 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 Vo$H%g-0^#!J<(DwSF--_-vD&_{wn|g literal 143410 zcmeFZX*kq>_y#)ilcG`yMd;T;S&NW8DJ7wjJ%()Avrde$l_a4JA!OfYtTW6Q6J_7m z!5CAv!OR%z490N2`knPU=jFMsb6)&EFEZCK^I4wf^W67yKhL+Ahemgg@(A;QK%k@g z_ijH1fw+K|T&E5l1b&3);DSM*b0GcOI!|7HqY}BkJuww&UEywQTJoykJ;fy;z^Q(s zSr3iDVDc_rJaxPIA6|V9&cugcUQX+ZJ6E_SS=u)`(=q&zK$m3VALfRi6NGF;)F7+& zsF!_ybILY@(`CUc zD8rzIf~T_L@TczDd%Fo7z=ALD90DBxJ!~r@mA7-zY=hZ+P)M^dYn`%zTr;}kR}-?l zWtE0=tkF~@o3osUgE@iEJbbyo+3&|$?le89S0e&B9e>ATaLzEOvVM2cjWttJvu~U4 zFV2Gwfo}fGXlep`uH|^(SRwy`*zBRG?=%Vyp6WLnj@odJPHLyzw-;)7NBI?5v@ z7-a){m#9v@r(X7p!#Pd%Zl`L2=raL?MmuBDFS!seXIA~zlfHyy+M?=LJEW0V$|M#; z5QMQ9h6_O=${sHaDxNh@MDA1st<9Bs!fD{#Lu5q|h`@g!90WS`xcQx)%R+j{PN?mU z9Bj|Vr}5fYCCD7HgPO9^LrH!FkY8v%nS`{3en-pOb)ml%rS2wUu)x zR?rLTX9NP3!~Q#x8rQ+O!r66xKHo5KRcH=u_h;%^`LNP4wGk3rT_`py;3Cr{(ytJ7|^gMI4ehis+S1jn_pXtvMh6;djK>dHse$R8f`ih%RqpJ57J@cvT zM{#A(iPLN`LYuy1`phnSwNsu(gplIu;ww3|w`M*UZbURWT4y)_SFY->3w+=)0!=|L z9_QIzY1KJ~Y04vYel8+v{?8|F7L!n

Pm! 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

fUKOikaG*zZ$mZpq;nF5SH$+gK@~pWYbDx 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%0Ak9%XJeN7Z;d zsj%t_3z-hnlWEZVz)v#b!-&d0Tv}zeOTs_plLeHnw8QxvG3{K`pM01p@k{0jP$uSa zK2Y(3^(nh``Hdph48K%cZ2A1D5vRmxtmmT!$Y%Yqa{=vS!72Jgy`!KO+ts^6^K&Ze zba*tZ<%ic6{dAD8!#Oo~#&4>{`6rfkM`4%d%WTQ_VVEkFFpO7e_Rf8LSy;aG8PN!u z|<06DGmMiecbm^T- zKVS5fto`_0RiM>tl%*2joUmlhgxp1hRl9>+;|Uwl=~7ilbc?`WxOOW9%U6dNgoG`@ z(48R(53MhoJxjSZCzTH_aJv1t*;QfbIXC=+kyt?mqX}Gfw$-4XF4yQ&ueI)@2GrsC zb0M_SO`Z>(tEXS<5qXoH)LF=Vn}UKb$+?i7FV(a1)PiipXyVWl<3L|@RL-|web-q z-R$zYCdZ5a9dSwb|3?)q)S9OE|Iz{gSNDI)djB8PGcWremunbwYVNUf4WSSfn-npN zZJLZ%t6n)wWXYvM5*WT{ZkuydTL;pChQzvBN6NL)7Y!?PJa?q(Ui1+e4bGdQ$ZVGbR{;aly-Im?DeEBR%V{`e7{b#M*rIc=lQu|eH$*K7i6Dm0z zXa}B@xpHsYH-4V7g$kJpIC_-3V#leFyO)YqTba1)4T{CDAWWx%$%8~Y1!VuG5+j&n zfba6qE|HTV@0Vm<*!S&>NpGia6Gv-aM%A>k zPGT9#NP*elz+9H&YrPWpy%r?bc(f)2hmH!B>#0( zKenm6<7gsEq1uODy^Shi1kAShL^A+mA^*YV$vxs}yAo?^u99SrW&0T2RzKp z8t_FMJ!;NT(5}WZq`GD>n79C8)I`nOMKfrxq45z=NLVaQdL!Tvf0<(09<>IE8jEZv zEc&WA5EmHy48%X1z9D$q#W@9UY%J#5s5C1z9>w82liH3DgkxiJFRBK|V5Xp{5PAni zYix&=YCINv7V&9*=d^~fXKO5RVZtP7KwDX1t>rKC$Nzb-Ksw{XpFvsDX@J(Kc=6)J z(XOn+SE1bfyKX!GRq@}SW`3#r8l?|yo|<4$WV*z1w9zqMVSyoW9Uq?;dX?$9PN`S6 zcqx#kcZr2CJsDfo`)5)&Y`om;)QRJjE-wxElT)kLt$5_BFcM6c%-ERMn-E@Uc z{4g}o)%d1ca8{G)j`$^}XCd~?FOLs;vU?$D=I14*u`5>34q2XLt?IF7BLmOfG$lt?E$+X|}E5>A=&T_GYt--Y;4kE{F%6^H$$oe3N;6MkZaL z``YjxihBr&i*8Prbh!Ul*8h)=jMv0HnEUW`)7eN8S|Rv8rH!_!Lh9tyNQ)PWp7Lfh zR^5KRzgCjF@#4Reyv%=de3kdFh84wJBesx?{_avRzL@|`Z0;~Dh%*&ch6-_Hwr zX@=ho(xq>2rig<)yFT%`l?Xu&hcC&tmPA0J%>DC(#CHOY{(CasHpjO|;RqKO_+4fH zZ&H8(m#Ddc8CNtOitLY~V%^T$5im#v@JzjwTXs;&^rb4)QTS+`i~eY1fZIhkpd-I6 z`#qkRq7&<53OuyNz>l4?2{(8?99Jnrt@?rkp`mKbFlbSDT9aKMk? zJW4ML*Jj`qYd92SJ!t0tV(-18n%bkUQPdMr5YZ!u2#V#XH0eb;97HTsAs{7yLO@DF z4>eQ)1p$>_Ly_KFLJJ5eNEfL|Xo@7X&;kh%Lhj~#_j|ba@q4=Cj{kmRkdeLf+rP5b zTyxE7Vy=ocxgNOLQ9t~{ll`*jW2}{yY`VN_HZ^^Dv`Aa5Mb$cisgfz)w%2I_K3_Tc z;il%Tn2IIj*y%9Jx$DXgWNhfb5ELcf>;nW*T+05U&s9B1xQn*YHNN1uGFE~h6hq!c zXUe!tNx+T(pWg3(qNtFC?pV+IxqGNUDYCcEw0jgJ5#N^ERF9L zF>G!@jgluA=Jl^%S_N)t)j5p|*Db^;r6Z}hp13DVY=2J!vqph9QY#0;k6q?745{&2 z{#kPCLJW^MS|c5J1rcK5VZm(pKq}6`vA-d3-$UAys6&|z*ho8kQD+~!511X2YX#p@ z7E`S8yKY=xGUQ~~kj z?aM0APq9SchH;`x+rVPN3q1~14tCRqbk4Lrmv|bg!5y;|u--&kt8M(3r)9ml3>QQv zlaZiH+|?WeeZ&46zyxmRc;77C0Vfoz6x+mx`M=~v0Qb$Bk?V^yl1IRlV4B`YABq}v z%9fZNurBGB8-khmH5bfmSS|GCYOuavcG9;4x;Z)r-yZeNVDl(tyhArov4RhZa#mNX z+Oy^2o%wLB?Qz^HL{|%qmy}*$dw3ml(ox~-+-1uTdua~Yl71Rgf#dbZ#R;tFn$F6+ zWicwW3QK(&poe=XVp>^GBcNf<;ivjMn#pLwKocq3Ss)mA34^Qxfgj{hQbk;DM)MCt zYkqY_5pE=e@h$eJV5;_)^5g#vf7n;(DGV;PJ9VE;ZdnE1y4C&O437J;EnJ2n4 zaTpXT%6t_buEF*}=*vX2w{r$}@nV^Ed(es>NP{$xW(&-1+wfwxPT>r<3>dm*SmaA$ zirA=FNSRF`8hbJkZ))Bi&Z+vq>pDnaakGn4K3_W?9eNX*DO4sp6pqv;4E**@}0ETo54Kgrkj2WyMY0 z-#g;#Ylu*xPIXn*IkP*F;D?Wce<<^Lo%rv3`W*`F+|{|@kR%XyCfAga3E`PI*3LSA zKvg%cnRW#H0%}{>CAPr*@nXZ<++1hu2TKT_Px7?H}S;C%| z=|@Z}^xs{vFUmsH28+Bvbn`lV9z+QQY+x}QL;P-EPEcK878Oy=#)_V#A>E7~&?8_r z%JZG^?*FG{qpgMG%R97lbwF|cxh`Nk9zTt&M{<2JGO@SYD&um40Hpx}(yJaS>q=lE7l*}(ZK8PoS1y&abQ_2y zT)usTa=CFhPx`{)sAJ4x$O}w*2 zIw|h4^^GTLNFXUGxuQ7a-O1}>--*`e-8)i?p?KiPF*>2-Z5rzz48nQjuRRR5`M$+E z#+Awb6X6bNgv`syAGuV{x4Bv7$cR(k+_&a}S~P!22y=d!_>p+h-Ecu&SiO7_v`>g> zhL9qpsGWbHj4&<EILNt+)07VL}#`;{)$jj)Zs~_XVTLr}EiZ zlu>;(oP|oI`s1lT!*BXyis5f6S;E5pfe$pq_Cgg-_oRCgtWTsD!*Xxrya8!`^OhW{ zs$TCjtm;MgH->$x?5~W@7fdi(;1!mZh^~DIDa>hxI+y*-x)qEq1mCLvqoIEMtp6S8 zp2CqiVpT`Ia||%#9|lD1n$IsLF~b$VwbXexmhm}jQbX7oqkoXi0fgN7katf zrlvZhb@oc0d5)poBKV9`^B-*aXin@!vH6y?6y5bUU2-{z-in6LW^T+tJ@`!x9m&d=M@EE%`2s4&o?SM&l;ju zjmuw1PWI~a8nLCmZk3DWEn0g3x;B&O2#Kk6L2J5MaCxr`mEPjIYCVDim5Qo^N{tV1 zO;zl_y&YE_kMjNf4q^FFo7)3CrDc2Tbc><3VbV2th z3Q#md)j%e5NE#$Vp|rfmB`!8rgSWEuYx$Nu1E$emlBXif_Q~~aGfs%Ch>sweVf z?0Vy-D@9l9+cT$$@-!!QHsV1UXH>&~d6W8H10oa-Y(y#98#=< znm7dQ`az~+u_ib>zKqp${)T~!S7~x`-VNXo6L2IDB{&K6!vfVndfWyk5mS8d#tzlO z=po#u0~YG!Txsywc(buuXuf0xyFy$m6%XF*VD^zLepj@gGQz^xU#5gq!V(xbZePQLQi*Gu1|l%B5gC-oA$ zXV{HMe_A3@JNc2r-@| z@iHt;$p>z}YcmtP_bZBw3~nF+=hEcmqaO%^`6+!3JDz{Qv<(+xh6K$XN43p98*q^}VU~Z`jQ7GFJa$I32^LV8 zP->nZ>v{gyWyQiDifa}gf^LT;>4Q1Ofyfkn%|~}e%)6{+sMZVL=gOw;Sz(ECYKarp{5-U!xrPIL=39ej5BPj?wn_lL*IM);Y}oLIrLFCX zA65^QKTqsn6>veBTF_Yk3g4)5zbZTT_1&dWE6>BN9zvc{txd`aA_q8*`iR237jIM6dzP*Q_muK)euV|0U#v zW5;>uIpN#OKpI%5A%|(*FM$52Ef%Teo6}o00>TW7bGLO0o+jx4VXcwvZDIDcN$cPS zT9Q!7ZvpukFQd5q>7efwc$gf(^}{BW6p$JQ!)4%Ed@V!z9TmVzPbXvMW{)aL2jQX* zxt9zs9N+d?hBjd%j9sjHqiA?+RTd@qqV&t_7g~g3=m&g*Gl5JBGwrK2-ZfKLtL_?@ z?+71UZ`sJURmIFmfzlGwcp4y`nZZZmusXGU`H;pcNzm~)0))8I|vy==CBA9<^QA7xA^ zq~7PT^ugX0$2qh8EP?!$qQxB#k$Zgw^o{$5H{EHf`X#VoeW`$P1zM-16|v-p)uzjr zJ0lnSlE`u%8&&795*<)eE$fuUl;ynpLo|NBW4ZCl`8Ydd(&v~zYYSMo z%IBxv9^a@{+M<3p>vW+gy;FS7YJQDl@65jnMgjU_#s#1}s0ophV7CwCU_i?ts zVo(oo9Zq_kz;>Ninp*b)c5V5E6DI|0lSR#5-2*YbX8-%CTu1pGIU9WeVgNu#Lx{i%Apcb1K4Xc2 zXPpSq)kq2&Uo<8C17eXSqK1svPX2d`|ByQXN`ejm8?}>oZOVNMdm#ChrvOFc)LuKK`w>TrEuXE*{Ywzq#oqDTc7?)L4ZHK}A1n(ZlVFA374=%IS6 zCamh3w;|!5jKzU5RgVBMjL;~3(II#d&F{D0s?*QkaLy2w=Ez$+V~<X|Q z=O^DDZC;(ue-ghMbp~|g74*MDVRj%Ce%#SMgCGp90hx-Nh#Qd4*UoIn$mL`tCM_^6 z%)*<&vM-qx0a;AB2UP)<6afFOqO{Lq#hij@naTH7>cTg!Sx!_7xmaPc4dY4Fw4?U$ z{#EOq`2%fOOf`9t8Uvp}GPn0qy3`h_Z5=euAwD!Y---tvgj?5UDn=mnUMR5z!N;f^ zgy0V9?_g0EXIaeZ5T>wI@UC@ja<~iH zVzZ`ojqak_)eg&_CA90M&B|kj2VwMxL4m`8Y8bCvI}^*;;6}rbdKef(x~ll+)&n{P^= z=jUb-RkkvPDz7%I*$-js3zw&C-{SZ!t+1gbD`aUKTMtrn%X)^h!Gmn%maOLyw|rWE zWr=IFQdYhUgF$;xl?>F>{Z*a!Qt0mmWE@x843gd$HNc-mjH=xSwn^y6h9|w7iDmA zF3@$>U!KG~Z{Z5*J3Wq`dc|(^0SL1kG5hBM@;}?puO&Vi;+K)OsyoTNz~~*YFHR9z~YYSJ7h3R8si;7tuNjz7xlI6S)3TBdM*JyNH?$KS08n6fP} zBmDM$f7^WUpn|MA?x$P@@Mav8s_xFry+DZ8g=;9gv_m>h|B@?E>0h`15KL3sNmWU) zw8j(wf#uYdGc5MGD^Q+4sbX!LE=HwxPw0pF6qGEIHD3B#L@j%^|2nskKLMtbiQQUr z@uWANAbz{IE~g%JOpv)Qvi?GQi}tIBovb=2KG<@TLhAXh0U6wRr#WC-S9FETl{R=~ zZZrG(|KpD~&5SWV>uH{485dGZKN=7HP{Rh<#;= zu9ViRczs@N=SKMNjkQsIDf${@;%i6AJ+Q=Lyy7%)gJV#zF*IGiS;(6?E#t;0I_ zzU2NEAcUP3ugaJ{YE6kxg5k`)c`&X^7T z^!>al4mQ)E_@u!ZfGql|M~cpfZ`U#028vhdz2&8<)2W5&Xd@L7+u?M z(O@9xv#|vVETKmXALPVbRXgJ&)iPG`{s4{#;wrKkvA>)$w?;|tY$VP4lTJ~nfc3@b zP!;^Va4_7dxh9A&fyV*jm8G!QS~i%mAzB?g-0WJ3)G^8!I(3UQ3JNQ|3@njd;DE_BH#do zw!ID_jmROHUHZ4xoCn_U5`jj)rx~Oj_R8Ip=(^h^_nseFv;j#qz-PXqav5b!*lduW z>z72KdmNK0CB@se3mF!$iEAP+ZG#ZYD%1SeUT1I*HFoYS$qRen!bq&w!aLAN8Y4bc zXSQ0Trgjb4Gq+WUDF|{SUbb1;qdwj4)cj;8p5s-(!B-DolR#jCH*c!& zYXe05jLuotmR^ync3VB=7IVAHpHFgl^LbbEIMr6T>h$th1$9;St1%A~xtGF0U&a=u=hsfBbnM0si9lSrC-!FXc}f-Ma~ zUI=Dl$8_3PHTbH}EU2l~uPXj9Vcj(%tdDsXH#t!Ab15VTy2zQ*7&FM9m7W+R4L8c~ z|2>r21mv1i?2$4JQjIjSBm@pAsX|QVulFk=2cwje{IM(FYLVilHXlT*6eHX{_ z>Iz7eJLE;POB1fcncY040*(J^zo(0(gz}%bOl|>}nHDj~h10K5tkx-xM|chbE~i=v z)8c*LyfugRD8=hoEn5}}6$5RvYk=px)K7MYQs;eb4x8nSaj;pKH4-uuhG7YFiKSO4OcG5O3K2ORmqE?vt8SN@Y8h z&GV?Ks7Ph5*+?y5i1EAMbxpx^Cb!?e2M=)e*8&gRZ-{#hl#!QPbP#i}6ysmTv{p)8 zn`gf8_ZCWY+&#t>3eXn3MthrI+IUcNQ`7+5?feWMUv{d;9-b@;Azm9z8mJR*wP#NF zP~k1>E*s7YTslT0dDBaDOVZptEji54sm_mkTnrrejIb21b~z*9y6@OIY8(_g<(lMj zug}i(N3ABNLxBk1)a${}v#09T)~PK|5*vyI)VRNJ;y*BML0WSPg7`-ibk3{QXEIzE zuk*Yg46&y_DsemDCa$wym+ViuSNjI@>}YNaXbVh(BekbC%(dq<3`q$U=s$A+v&F!7 zPv7>`zPJb1fY7W|r-mN8r}csQu5S zwh~g8)Q7}gg;#6F0nF%U!fBjVC1pM#@T%+3FF|NQ38%*{ z?_9Ty^ul!90I_WU%k!o5UqJ{#zF~E9Wt5f{v7zeth+`$)A42(HQ_h~+#a^GR^gG&>yoeTaIpq~~$7dUNiE*x@O_Y_af zU~9~gKF{4S%iE_BHFRsTV@FwhD*W2txGZt(K{9XlH2Ni%Oye|xs6UtmpVWuBcYaqb zAz2`noKN}2DTz%>LHzb3N_xavmZry69|fF%c>5#;6vayk_|r9e-ojmrgS1xaHnLR2 zJ#bt%jL;)0qmS_9BB~6m^C0GTP`Ibn648fl?XQarD4UNzy>6ZjeVgtpdhcnSHkl zk^QH;VJNxT_|rjb=1pHnKZ3521I<&0=K}DT2R<}dRd?vq^?J&rp)SlZ)xlV5la)N; zMf{j8=g?IFMdp`Zm#{1@w>;~A4Nl_7Io{8eHs+*1HdM&vpCg2Q4p}}YKCiq={e@5p z{MC?;lkO?Z7LXTGG1@B?=D`#0W&8Ci1xGq~sxg1AQmK*7ldNuPn_)|023`Xr+P*!% z?E+@UY63Uv4eci(h}vNx^*z_*h2O%Bc{d8is*n{;1$zD=#=NbZL%Mb*@|D)3*KaF2 zw5?oomcGl;5Wm_J$_Ix-YjY<{$?t}~9mM_g$;4+3Y3W75!=>8d+cLZ|SNSV9E?(1i zijR{*vq#tZL-oZk6hZ?@1yO^1!-HtT{MYE7DbjUHkn#wpyx~q)a!{-P{7?;oZza&3 zHg>Uud6sKxP$oF&5TM@-W{=X!hS*U7%Lb%Z*#cJYsn+Z@OBoW7sw8A{z!`WlR0r_fl$tMFewWlu5tTsm~LQ^h#}ts(ZrX zm7;t#2JJCpwYZCUYlS0LUwR>b_N9OR$)c$}WjLw9I&XNcrxkrxnagvBiGM+~Vo^di z3#21Q)$Psj!8<(JS=7v<(1{4bGkrVMiK23~$%Y14%z;9-Yk;##K-szSV)ttp5eVKY zT_2*m zwSj5>*4I&SJe`z_p#1=itfl!?{R>(ANg2F`;gCAzkCydzvVu-_7-C3$ zNkjEC?#6RhC1ebDx88Q0mrKB^_Z`v6S^j$c4{U#42rx97Z291k_-oe@z^O!C9!8+r2W8Qw(+URq6Vo=mGG|II_FBdLB& zmZVHSbDbi1u&z0W*9D~nz-VM1($tp>6g8uLyGg1V=t~QuIGMKvB0sz!eVIS(Lua_r zA=I{sB1p_>gwk6nNL2-*gjc@2h+z?WIZK@V)5*(4nZB7YBadlGW*zyq&LU;_=}hne zE)yI}3Lx=M#!d%c-$+?Qm)Jfbi8g4E-YoQIq@Y^mcYRQywz}}0YrmUKot8kLu<+CI znRc#Kl^!1*N0pI}IgqHv`M9CUz6<@aZNpq#&DB47sg~uU-rA(RC%rcg`oLwPA7q&7+-V@=KYsw>>qn9t+0 z{xDLDZ=^{3;jx7|B@p4RS?Fm2i=)RItO+9}q;RHoW9|(_3@j6m&J$H<%*)#TzA#$i zo&SBfe>jL&LeyOk`&~(Fr^y1|q}o}GKoZ`5jgRcRvg^YcOv1*m{c`|s>!e$G1M1)< z(cMDoWr*fgv3KguK}S45&@Fi8FBZ?-^3vPFR;jj%)cSs%80xC@+rzY*AoAM}eerC6 zXQm%&Z$+VkBqItsCAA|}D}#xbP+7CF-=MoCirZ;LSyvP{Vr47qF)uZ0B`V84b@Q+< zgeBm%%o16BcOC;>YoF6b!C5w;5--c zfvZ|mCD_k>V)DR3BvDslIr8Q{INzWUmh>0VcX6h6~_}w(1g3H zlz#{jO7CkwH~Uzs*)LquZ?|(D$I4z=al+P~fT8fO)xai%2vU z-{k6U$lnOoQPUsHXm87CB}8c)4b<)HS8+y{>Gq*6lHO$b2;0g<^kkd2zi)7k^m&3^ z3acB4gZ@p*nxC@a`?up`HNv-5d};v`?^Ed>no|uY0su`T7K~^-NsJ3p|M+MZeg--B zyx=KbJxh6OAm;8Rj{CoAYlc)-xuhHQqxh@*X4$DLtn!Wq@`uxYj*GiyP5P1VuU&bh zkxO{=hEvOZK8XH#-9gySa~Ah2jnY@>-BHv8dZ86`_|_^nUc{}9+4fH01W*FwQ(%_d zix9|P`LrUHVI8=5-{=F^IVI&kJtLQd`Lo!JgO3*F9fzCNSH=3}mv)Rq(sj0*t!I4$ zG*(*LUVc^B{a1W%rOlSHaH#r$zga`mLr_U=K*i^MeOl?kMWDsS#^@OGNQC9OTum{n zmu!mpZexmxrQ?=o^xBW^8X8tlpP=J%45z#dnp$BZJWFG#E|U{&a-ze_q@)QXRmnN;sDx56N zTa<78m7bY};bG`$Kl%*kbILtBf1wSy8Hl>F|(2fofX{U+N@44j~oukp5;Nc2KmR&LESJxDuS z4)?i=F@Olj!Y7+WTQV;v%g;R(&6o0o+_k%s!rde(T)m!{dkvBTSO*L6CSsS$%^C52 zio81Q!n_0QeLGz-?4{0!bvk0@>EBaT*FJclpCMAUtSi*Sk^pUEc*=e2Y{ko}v1hI> zYRQq>ruhK@ge=oem!{DEa#4Aq@3w04P{iXv>*O>1Os3NB3jt-4#TrENhH!g^o{zj?W*I0XE#e&v)@Nww zfv%86F`JAO_VKw-IQf+co`%80Z`Y?^eDwQ;c6?z< z`LM5_>cd05swClLfhJ3RaS>g&tO36pNGlV2uu0fhm97!n9AXu-eUcOdK2%Rq0{^1{ z{ulh()@X0AV${T}a#R2Z)!@18&Eu2(ERN=|aj!0JyiFJtY`@fcM`{h@!e&{#f}w9Y z{o=7slf2~crfn!#r!3mTn83L0R|#a8X}uGk;%)G&v-t7LAD_GP`%IUHe2w(zAzBBE zOMK{=S5X7f3yje4JJ+T$q7YDMwiR5YD+d__1B5$EDcAo@CXv_P~_w%{9NTH|4%SW+!D434OK!=GQ-sn5C*# zbHmHvcMsq1x<*R_r;6RfY68AYUDoow$g z3G~^yJqVo3k66Ip~ro>GJ&^0!Uhe1kSjRvmKaJS|wm?T6&4ZZh1uVA@~`@m)tz z8ndiH56M*^TUDDGjT$Q4G zx$xtN)em_LVzsLK`r;GuC7Q9666RUUU36X}TYBRH!lnvgugOSJuCkLBeJHo187pX0 zHlHe-OCOeHU2l)aUC@&JFka}V(vn?jqRh-y8i3sn005;~ylM*UVogpp8s{DeX;0^g;dqMay4pGQJF5P6s0~Ln7Uc1%rb`gdkd+D=r;we0HfhkLh zDJXa4V4X@^dNPMg#`M(j=HF<}XFJ{G!tdwn;Rgh(+p^8YN9**50Sb3hMDKy8jU%hs z?lg8#84IW3=Y-pCDUuw7uW!L>2er7naH&I2O!mgnCI$oCvxe%HNrU;KLq8h4M@-)> zcw^4l8pmDvaE6+Mdr3F`B@nytV0Vc1rxj_Kh5oxgj8|XZEI7|)yII}}#@01iLg_my zydmof5S(hBvv<-30-d6z7!p3e{2WqS3eU^)-EwK)>)EhK?2$t|c$Dk=WCJ`7!M-yr z!~`{VH+(rRcOk08qeRg6qKPh*aE_<#TN0nKRR=3G^?6WVkWg@60k25@>G9&xTiZ=p zpSabY?KN-NW7-ZTS`$PcT?F5h<}*C; zeU1rew?rPg1e-pv1u$!^(?v4bRtE|OK7%e)^P&MWM!2*U6nm5p=P^?nU%h4n!`Zn!5+!pG5dzlM-^^Bxaxn3)s@Q^+(;fnYk8 zbI`O`;J)#0(+#b0?mjLzgcNB1x~|D00#Yk~2Yiz=h*MF?!7x>%imnw(m+F0`WqWT> z#?WcPIZIJBv#j(VVP@rvCgCLT)z9nXB(WAr@Y}P;;nGc(#uES$^(E-iG^ldS881W@ z@M7HdheYO>{4x&)Z2`q6vax~JmfH^Vd>w67ZskMiKg+Gmj(df_)4&7Uiy}I)c#QpS zwl*s8o!>$Q4Yu{3*1FFyzP1Tey3jOfXxScPdv$|qa>`$Xee)Q*qp9q@aPqeqS|IMH z%vSfm_+S|@vOSBh^fA8xw9YB+@;j|(sSKaHuF%`MeQ=;Llw?MfiXkc=B=SB8d}aP^ zXc7zabZ=tC&ZZK>pK9X>@TWW~k$>nh^62wXSZ6B(ZNQf|da?sHtrVJqjve-t=X^AZwx z>{gU~Gm%;Owwqxh6YM#0iTb=zB}dYtkPwth8a~ZAS}ekx-o7!lq__!AboJMXEICZ~!0;Ms_w${B!83|E$4u zM4fRJv;Cgk`Ey*bc2Brc(2$CV3QI$TxPRVSRcki$up>p*dE!s1b!ZPD6mK6Dm;Nf4 zf7@u%oM&E@xO97<t|`i~8fI)0536wHG}7bIOb4 z4|CS7ZPcDb;8+u)b~h56t2Ums6FDyREQfodB*Gu*mt;m#Nj5 z+bz9=hT3=A@{|44rU(80l+=@qL$M~|y9@13fxX4HG#rnWyzSkA$7Oe!Kg(RxoZ&9X z2Q3FlJQ;@ua-R!xY(~n)%b1e7tpfFjDja#qro4#_SL>hjH;+4H`Z(&+RrB0whsJix zIol+><3TyA?#t1}(#c7fV>_!ioWk?c8KCG#^));)NA2xyX)Lks~UfWqZB?px(7zNxE{F}0eT5;dzGFzFaeeTKU-CE_kwC?+@Fx}StI-ffN~ z4)n>oc&i)I2A_T6)oW7tT^&9cL|=n$h?D{9D7l45j7CWbEG4v1FP|#+tfr19QACiG z=~vsdd>}$*G{vjf#%K!`m$2J|y;|u<+H0Qo)5m&0M&xdt&pL~lgA#s}Bvs03XB*&7 zfAStlwpbjwfz)%tiM4eYSB(6y^34j;@OEuM%hPoD8jk#2d3$uNP`tT)IpdX&?NkL{ zm%s`bxyW*-#`RYo-Cu8(_$9h4NGhnN#Bnz$aoE`KsC?sxWtkn?{fy(c|)U z>xovoRAb^19Mrt|EFct7!Sklvs;o(&_7yav!pl1;W{o0%+-_>Bc7hoh0L4%(Tjgl6 z_f@TJA7|DT<(NLdwF(XUzxqbvO_0rK8NTr5%?+&St;hI0q(F-+_~Y*8f_OdMoI2y6 zFYRu8&#0?{O+w!Bl{_6-%K(|GBxb0^$g7{vWgGuf&uQM8_w=p#du6sw{V=8sI<6GK>^JcZl)`q@h;N2qIqi+N8b z8T4kl^`(>w$+~;o?~rwCb~%5rW--2+SEBjCcp`1s3W|zfK^%)?^{YUb4v<;yT4Tcyk*CkRX20@yKk2sg@&nx{==X=4X*Rt1qWo8kyxU-7Qr_~YycUQhI4%U;KeO%z( z=gBXrbMhD8fDJ|=5+a`4>q?K(i5$+0srnt*_}M^ZSa};D&)Ovx9~Rww<+;sm@d#19 zO))oFmUi~Q`;}0YZWiQ2zu(s#lv%^sU<*vTUuvf^QN{zbEdYVp^_~DN?`D*}C&pg?O-HIt9f znk*yhY+TdN_gTOcs5wb&XE;5V#WPRc)tIjM;aO!oH1A52N3CH$501)^nhg)NT(Zhe5={H6dUn*phn`&xrVO`VE~@>pi=EyiuKpXt zyXstzg}sIjzt2*;$Zfxt+8bv?quXc}o7a{QIctmmGd_)3$?U|VJ zJ+Gq9{1ksRk{kUejA zpaKdwV{GL|nllqey(RGgyW1H~rRI3N)94h~_P*G{9Fjy}1>S$3HN5+(5Gi~$hTPO+ zPkg}xv0VCBt`9vM@kXjKj3QJl$kuFsFfGB86`bt1(1df`D5B=X{B5W;&O@4m%}rWY zj2cdAz{=*OR3tphInWV?h5A$e$0b)|qV^T2$^AfD3DAlWQ|@(HgCF#Qn=9Z?+Y`Am z1B|Pxno>ns&=sgp@zY`@lQ5nTp!Pagqr>*17lcH4dT7rZZW+cqF~4z+oQ3i=)#u4UaDO(u={WJ_QbwK)@V zZj^=H>e&4uqo~Pk3;C`lNs1dg8gQ0azYDCF`MeBMg4%mvrEJXC3e9Jl3LoZp;-YT$ zm)g8l)rKiNg_9pA){v}Hbh#~KFYbNS^!f9mJ)kgFhugQeUes{heGa+*>-!?MX+m~( ze?ONWRU)8gDIsp5sLCyKuFZ5q?44ZEm&EKzkJh8LQy(|-JEied&-J#!(^>)v9XA_ zfAx*na9#Xtnm=Td`B&^q-f^)yh4q=X^UkgZ=YRU>yCEB3btML?-wh7!*_oQi`}$mg z@K2N_UD1ZQKkQ)R(l3oHa|c1dmca3}(p;KT%*T0N>l7X6*RWAJ88YR9+#mPhMVa2b zfb&6N|Lgf;QFqq@Y)Y*9Vo^pPp(Xsct%vCp_3Q|ApKwyz$g|%?V-dE(1u@0t(p9qQl1^BGU4B~11 zG$tJMa{E22P67g{+x{y7*bme-ZnIo@wYgN{mwmvA%A{Tg@TDQW{jG6%wNwC%?wzLp z2sD~%-W9ODp2lq5-=S0i_Af!s_ej5bvK#tq-A3e3gGlUEkid__=o2pI+YX5V^iyBg zw6o<{swI{8*UE@JsNnx@{A+P?PWCuA_oHLu0ky{e?))1NX#L}7UWcdE2z)@5%9<@}3SpS*G)RZ_ed?>ocoaM&EYH?}{B#tMFNgiE zq=(cXAb=`1$0bwNYt>aJKFBk)b2Id)MI5@fW@I{ACMIEi8BMU7H}maDns;9E`LP-J8=kD5B^(f<= z20r`P0@je^N15T&1%#|P3_gri%D~Pa+dNDGUa|1|t)4cQjCOxg&V{u4tDWl(H1 zR;Eism+|OaUfG;{oN@QL6+1-?+@>&Y9SS@R+tm|aE)2Rh-R$wAF2B1mKT+^BQgse( zcFoA(IGXnyyU3c+Nu3LBYA0@La>}q?dwltPNGz!us8kGvMx{13Qrfh2!Y`}nAYGkC zVZ|p5WUl$_{IW;ciNDod+_R070S4f>taW^!*d;(07;Q_30isA2a5>gvfY};J4?hlL zIoRet-RU17+IcF(Mc=LtF_V(0#++}10Vg`50F;k1n|@w0YF1w zSW~>3gVUI^vpM4W>oU3(q$R0h=kj8*33hGgfFSV4SMQr5o^}OZ*<1p}k2P%+D|1-R zH0laSBeo$W?LB&J1l7y3`?1+FBD=oENqXED+^gI*h9ANF3FlHl0{82Ko!6vP3LW?M zcpWM7+r&{WaLB_2)UqF#jpI3Y`=g4046AV0y5M%o(Y@K#9^7PnJ0qLFsvWk1EhOH* z_7hz+E8h+&9+N#*c_bEgXX$2CYU(7Qk>sO5z@oxzu!>Q<%f7w} z>_cU+8aqd?FH0}0V&z`rq+!`B-h|Z9+eN7Wy&cdYroyUlFVyN=At{XS!(-b@x8WHd&Es0%;;9gNtlS)P@7a>FYbzzxaW*&jyRz+C$hM&+ z-=wpZ-vXAOP-28KBvt{u+iGuc0E2{*rEUa}1~?&;XMLuf=jPfWVLO>t(!bG4oj3elr=#v4Pdwf#Fxc4X@fHIM)sN3>+&?g+jaY&-J zKF(C@{xcPi#~j_f9cF1^R_m2!*7mA^R}qo!qjRo$6~!UXGy=R7m_at{3576PZZ1r> zW4!wRV(+d0nu`1PaRosIDFtazP(YBDZUHgqt`X9mqXz=gEz;fHF*-!LbHFGi1`HTE zVvK#}eShyCzW>Af{IJJ<*g4yIojuQay{_wdU6-37C4x%pPpaZki(PDMmp`iU`h)Vv zM)U&UA=E7I%lfoP3b%LVL>9(x7qx~tPAr4Sskwx0c)xeB7|Ci-Vkux58><@wrh5o@ zO7L)-i}+^v7(syp%B>ueFc~nzP1ntenEyFwA9Fx#BsP8+rbrJ4d(g!T8Y?*g_|faL zQ(|jP-4A2hR`06nl7PfST)wgh0ns~w!12;Q1}&6$*O`zP3ti$)3dew^)@ zzwF@O#cJgPV?N}@h>NPkWD9egU)Tod<7a{Mcds7#8(XI_j;X#Y(?i{P7$BAXG5Y~H znA_!Q1n9U7t1>zHaIZGL^LWGnnQu0p-h}mFdYXa4M&|IxNnHG(rgoj?Y?~rzU!3W8 zwBlyVje57_0LdEvmt2JiXglVZtsdmzM+{Rc3Ho0jl8E79V$*Na=eyV5>NT%Ou5qiP zb$^}+9v+1qp0R2Qj~sL(jF2bThXVg0quXU_@gbOl-s_e`OnwQ=F&>Y?2aO!)GCAhh%Th=)~NGcwc5)@02`l@;M26>xCc~ArCXkX>-1sbHS2&OFneQT=VXnul1`zp^hl~M^n6+6uH(TF)CRu_lIXuS#xh&lNtVHto!%cdPkwzeF z0gE@EaK+}-%b>9xVAJ?0^7u<99XvcI#|6&wPN0i;AG8D;PGQ@8I>Fq2&{zQSJ6vws zQ9|fWg}9Q_U!LW7FIDQcju2<4{z+y22ic}Ab2l92SdCyGhFP~q{>Lt~yUrAg0+S5w zhhO8Q9E8v&g_lqEMVvdlkEcrRZwm*iz<8S70y8ej#^Xj%AjU zecWy4!&M(YM3$47*I-;1|1AtL+38Wv$VbU_BkF#q_RMPeEG;a>v``Yvg zd<(HJyo7_m%|zp?i4QY;ofvKL-JNW>-)@KKtjCVA6Kn>lwe@tF*)#H+>I(5&Xma!W zn6RF8_niq^=Hf5vI7l4?3D*{4Ul~gcV-H6&xNbTN|HX*|_pAM@q z7LR4D1^&T0EZbXspEuwnAt>J><7}}3iKB+Kz@Wq0q%#Un%SX4@9?iC;#IXREbIt4~YN5)lX^uc;>H1*ulTJq=ThHA*r z9IlT!zI#g8p7E&3ItTaL`0AaB$GD2?6XTsxQF23>n4bO>klMqO8b3RX-J1Q!~W z5dKwcoOCL830bwWPp&fB}3%)`KQhMctHp zNYaCCgOARh238;7d^5hDU|hD6^#${KDlwRYD`JbXJls2b7p-Yc^kps((|FIv5hr;yRb~Ohso;dj{PJk>WY|Tu*SG&e$Qm zWYXlM%VeW64!t!G-yf>MwA6UUxV;#h17NU~<5KWSijE}oE+_A7BMW>X6ti!X;nRhf z`XsXBeY=-3j!jIrKo|qB@sREAE#T^9siWJDE5vJLSRUI=&PS9T26XwZB+K1UpZ3`I z<{Hw$DC2p0huO0}Y6XR-Np|J5md@;z!IGNFU$`Tbl{&Yn9Ki-U>)v|39roKWU-iJ@ z%MQe&vhA#OEAMr}bcI2!l!b`xDc&|NlJ%;Qxx3R0ga$&S56Y?^O1+87psg@=WQ6dN zCjr<9Cg4=_-k%4*AssXj3INtC3>~)oDY50RNa;c_)N=1v-oryDAD}*?*C;Yyb`#WE zw}lON{L!e`jBONOy-O6EA|%)i5~4N5J%ICKw%VvdMG9B$kbk8a0&ZT5ttIWI>@g-% zt4J&hDQCcTnMvw27R|8F-Js?wu1xp9QfZkpe4$hyAuJW4As7cAC=B=>Cw`FIh+VYk za#Cn&eNYu#^G9rIhOUka)V$N;5)n$$E3zlS17gD~-n!m>nni%YBgv|y8ymQnUnEMl zGdA$}3|T%L=ePp_Pw|L>YOPqolg8xUS3Sz}{3vS+h{&B{KfE091DXAAt=HbN>%=WS zdel`hTyeA3+=b1r)PR~yN_YrJh*fu!g0^EkXN_t$!U@AUdVuQvr)5tH0i7FGi5YgO zi5v`O7~-R6O^le3pF5*p+FhY4?g<{wtrt)udG7%)84rsDfdBMT*&xu6W2#_Ntry@K z{RNNvo~z#Jm8zvq@yU%lZ$N)PtQeW*c;V0WsC1*cKI0bNwn+@Xtl@i22&Z}qh~|hF z&ov;VrJ8lwQfwD@ak;D(I^cf_}@cUzZD{4IVLNW*?Jj8$;SX*6Onv8;ii5v)L`KjEkY=5p=91_Rj+<=^`*6?NLM#kafY3NyvP zT60UK`dAao_@9mDF{U(560ZcHMQ;(u!SiZQm7B@!iW9}|5bODJqaDi5dZ3K6wS_@z`|1eW~EP=U=y zFt@*xq?fvrylKbsGrR-7w!;TI;Wf$_ zNs*n>K6R7^_4QFe=nIe&JRs~32nwA%1>cuC?DJTQlf(L>KDuRhtM#v{&^UabX1@%% zVeo{?1@76Vxx)#x7<>Q8ob2JP{zqKdj>-e%XY~ZfzL%E5v8U8EKVVg)mWOhg%i&7 zXQN7-)au2Y9Gj2PIyKdtrtW#QynBCpfV_u7em@TL%j#?jT~_ThD2X#7xxNQeDo->F z8=q0Udt_8A#+C5`>{3JrynGIDH+JpS%d2^V>E4Z|5)%CTn)1U#7ZtG+VPBN;3yx`^ zfmZ0J)XvLg=6T7v?ePRiC?uhY8K zF^7h%8JP%D05sb-#fM@Nsh^}?ze)TesptWH%Z62l3l5sydAJ>@g@sMPnlGJR0-`6y z&di;<`rgCbU=g*OTQ&U=c|^@or7L@}d04i`lha)$o`buJTr`Kz(sM~L*(rm`UM$j#m}(^#On9%em;9D zMf&z?x1kZ{e=RUApc8n(b0g`oG>o_BfKVV3`WcAvLZ0Qg-NJz2xRejkt8MTJv9w=> zTnDD)TiD=>ue)SK85`E&KPWh!rln{xFI+Sx#(i0hqrt{QXt0~#X1kBjw>a%?D!dNP zBG5gec3l(Q3HUSU$#Q+2?S598BBQ%dNz2Y;aJSig>Rl4nRb0=FdwLPhHSv(1v};va z_pYb;i>y%W#>JdTMAAlLJMupwAx|{9Uyrqe8D9P<#|B{@pR8~<#n8-1dXS~Fb z1&Umn<3k6d(Qy&EYuJoT@aEZfB`!BMgQ!H*FQEKrjpC9=7^6>aCy~c}t=Xq3|LXuG$$FtkQm70;gxaKJRy3U{_Wa5*|AKEzRA?_4BBFd1Kbd#g^z;9@I^c zq#E_B>pYjdEmGs1_0Snzi$l)XkP(H;O3MpXY+x!O$ViHOyBU3p_wDxnQ;HOI zkKDGCohB2Nc)qLr1F*_p6zi=2$`rvMO(I_;VLqd`ZmZ$sQni?eYViff6D-P9(5VGo zS`$NraGveYS7=b9kLcJ~>5bRcY&+VI(1Xj2j;B~tK2z6TPt2(wf)=j1)NKkcuU-;k z2@W%61uMnVRx@?y2OXEez@xW*skeSA42NPL41m<;xL!9C`t2T7!z3wcrT4rwEuDs+ zzEP6 z94f|SK$qW?52>srv!>zw%p-JB%k_9Rf28@dKv?ayl9m>vRcju)dNhqv)_e+X^Mo`l;Uec z!u5*AyE_1t_)YAP!^Q41Zu3k}*~xM5?FluSL0;Q5fmXpyZltR#_VMZn8_Cvc`l_R+ z1GA>cT+;kC@kQ#IK(TB?bFQt8krONVw~36x4mmrmp6tW$w&Mz(NcP~0{Fd11v`dNf z6#1cj)HKFV_vOFPj(;QF)?i(kMmQlc@3H58PEyvk-gp@##odLBU0PhYsuz5!CEvhk zV5MJ0imzOuPZGH@HGu9{pnh%%; zvJ=qq#A%M};x<&g2ygq!(6YYfaVsrE@iV{6qTa@Hi9sPFvi_XBN9*4x>fjf*jaoEt zJC%Wy*f(1|61kf1MX;xpv&Gj3$N|C&+ET3=4w1`lmyO<7t-%0lj^`m3@aRJ2g}zk{ z(bc*iTkv;o;danLaFlM$UxW8$46y9v>~=mP2_%R`%8G%IUQ3PBoO%q3+iAVb`>?pH z?BArYKe(;Avp=?Eml3QtxQm6u`Gi(xdd=b8;{Jr>-A)?!u;@7Uv0|y|P;Gj+Z+ZSL zc4rM5wir0+)LCS?e~rcbtOQvoI#WF^`)uMa82Eb0ffhgH$=zkD0|(qP>i-PfyK%zB z)l1_$avdG0z@}HVR)0Ymz;{wrlkXI1r=gxq7*Ds6=7R8n+;yxUnN4D2}din$sSXP zwwc)*8%XHS8?EYsMp!iB*|-I_RvPN8?b~jfn=pc>aZjME?FHq8&Vr+rAZqt~N^x}; zS{6kjzVeT=Xl?UzOG;TWwqRoK8mAV93S)G9LLU&(u(ldXAH$4*N&^R>mQ z=}a3*iAJ}h84`(fuHJw*r(z!ObCY8U&<*SW56l7JJ?P8k)sePW+{8LJZKMuQB=Dn~ zz$2^`2V17K`IGCNIn}UI z$r`hRDH&jcL*rv0jh1qk=l+yol$jr9HZ<>wyK`oqjL)CHDZ z>hgpzgFR~`RPzm>-o0-k4t_UoKsu)tpxtxuaGlEgx`>xJskf@m({r7ZS8}(VP*Q+4 zLyQD9*e+qx-Kl;VHKH35@w_J%)m6zROAKF2?zZcb0|Z=J?qB z!Eh36X%Ar`4(e;WfC$5ZP>v@wiMw&#Pv^Ol@^YD8r?3VPL4@_!w@&`@kNGnCKfk#G zuRARIwg)VB-|V0o10iVlI^7!Y%SNt=w9M>E=Teg>{Kq6`7N<<2W7EU?A!pgq41*(D zNJ4FPAm#1lcp9S!LV`rn5leru4Q?{Cjz!artpE*{sWt<$0+g{yaFNtmKJOKi(=u7<9LZ8251 z2@!5<*a`ph>vWj0_h@X>^F}*9v2m?3k~8m6UiVd`pOuW)_DQL9MfuZqmxqi#?|c}+ z2wri44;97`e=|kLQB7T#rH(t($3FVo6WCYZ_4Oiv{Fl!^Ki)E)nuG!m7wzQq<)MF> zu-6bBrXr}eFqycDajZ^n3-V)s^lV3D9YZe<8p4)Etqry=ShBs46#PDa-$MK4a63>O zy5*yTs|}0|muRzD@?xR2Yn}_0Qa(WD@>267T+3V4^6eUS~fL$*R=0H z^{p6-V!$0N;hHkXLgXGjC%%m7PXFjg?_RJWy-(~ha?`+r#|@}+J}bBYx4?jq??RMN zzhm5rWoDX4=3u<81g~sE@^_}x4`9}Bb{3x6PE}OlboQx=%rbQ5w-FJfh@>q6dzIe?C!7Mh(N;n&*mPNSY z84(#`E-O{5EshL8cKQ2(PUg=MPw=`26+ipIcH7@c>5ZvrhUoWOy(6OH?f>V?h)rDL zb@|g;;!XK7B7j_=$M>SmL6L*|MPbix0ib>n7Y>@jn{X7XNFMJ=hzP(*x_JJUefp)y zKxUBD5uE4C`>V?2;g;iKzw` zA9g}gYJysFZ{F+sM>A|F-si|<*MF)h5KYAxdFNcbz1g}fg*X0L5Yv<8c$<5 zwF{PSUK9q|h(4!infK7}RE>dc*NZnwAX@B{cddjfHCCT6O7$5T?wz?;;5?1(RuXAP zG=JIe)N=ksui#IU2)}K3rk6w@+IS#7H0Q7|-|VCnt<~cbMszSyX5qY<{KmT6O&x9E zn`*h{qS@(a$1f?;l@vr?&19(tyr7_5G{A!ctM_&f`>-7CA|np@RQF47KN|zv07=vg zZJ9UVh1`7>i-R}LupIK^DI{d8=G^CfNWlT`8-lU*xxxkqDWwjJ zO=Z|ude(B?%I50LRf!{ahyRBJV$wx}DIP0&z4GUfKwv;)2Slv9n z?-3W(_R(SQmajVn5s>Vsn@0$G^ zRvpUZn}=ZY;U9utd>}umL`s6cM0=|P2PtC@RC#35hz)!(yLO;=mS)8c{%Mr2u!){& zpe#0x?aBU#N>V^B@zMNprpw4l-dXv_z~yhKgLQw*tTr2Y6A6I)irZY?tYuZoq2;iA zr#Y}*$Y+_urVK?Q)W~0Zw zn@v(c(^eQmdr`9K=-W7*)`MRSN;R=c_B^U{ovRjQySxXoNrEgyXyEV0w+JdedzPi)JFf2|V(3jrrTN~T8uC#?ZxZ!1T ziz6E6*7pa>ksz!rx!M@cezx9*VVaa}JycMDi_F=bDX_Gwari3A+yr0%R@|paRpl>n zPMP-UQpa7B*!7rFke6|wwVa68ju|c9NlVw$B~fGl__($vJL_O!eM)+MkBsX!T=sC! z?V==mPn9aS<*iO5#LJOeHJ){q!NN$EgE|x!*6N(R)uqc_a@s4X7w6Rojk;RkDdvlL z0J_t>Y~0*!u-*Ts+H`owl$Etq>si?HiSe*o+<@q;@ zuI+e}tziN|wyjj_i3G;;N~SqAMeLy%P4W9UJ7`orUecRV>GtI%Z>wzc&LybT1RANwW)EVihI{oYbDnH8Y%wH-x@M?eZE}k26BX2+n$>);$os*1F=JlIo~gijPY4B?uATSS&NF8NAp`q zmi<`4U6U%f`=rIOD(t)@x*r~_F}gSK?Noz@*Ioku!rFo{!^HrbS*op_m;u4VzkMg% zwc}aiVs?71B?>p^AB7U%U1;7@beAc*Zd{Wwd+6Cb2}JsHPR4K*iZ)f|nE9?rc&iTB zcPeU{$jN-hpvF({rXe?U>W&yg=s?0S;jaz8vR{=PKgr}BUR*6zgX91mQS~B0XV&V+ zy;?#O$p%tYc2s!P=N%k0&opP+RT>8x9Wa;gT2N%oY> zAnQAT_-o^e{S`-%IN`=jYB9Dt@iyz&`~%_f659p>K^uDV-9>CJ786V@UUzhRL7-e| z)pb@tWT8a9@3*%CWof!f@XW*3OHGq^LNvKX4zI675WD6{MA?C0dl&EyI}ldjkv6yG}8IrrwE9%)aLInG4<$Epg|U zMn>O^)0|qfAQgsw)kMqqDeYjy!<-iq)eelA3Wt@SI^j%Ga4h1E#7WFjAAQFW2a~~| zcIomM885Obnt2_aQ>HMKx9^@iFJj&-A8ivqa#9eTXsjd%waT}J`v9WM1+7(sGpZ+A zxXzq?=3w*EiN!eRCvMvVTuij?JXhB(a`KO3 z>sC;My8P4HJ$pd;&v7}8T7IedCLskHe>v(})S)aA+XpS}g6#>$I3c)t3d@&;zcD_R zx0fS}CJ`fjtVX8#roTt-Gu93)126gL2Z_`nLa!G_oI)VeHnT(NE@(H{6Q(R;BTmM zBV($JSHDih1?t*$y#&8iaYb70-}ZBc>fS3RQ4(`iV9oH`-Cu!vnY_KS4L(V8c12&x z)%yweJ_UTH;I3p1?}4X^7d8y`VBGJ9e=}tOcR>9=xCB%T&nUvIuv8&87Srk`&T4ykEJ+RFduTiv^O?tT2vJM0U2-@l*m|80?` z|2ctwN5meJ=Rd~`|IhZ73isRpc9G+azAyd1UGk|0?!EZ$4rF9%Pt5Mc|96M*aJEq1 z|NEN%8?^uXr~UuxCO>MZZWB}1lJ`uj{YX|5>KLqb7K_%ZgbTU$0H7mWb@MPtgXRUwXJ3Zygy8?>iJt)kLP&33KssoYuO7ru&~rM&8%EW?R2S_Ro^+G%L$yD>L*P921xCMjbECCXDA3 z3NWR*V4qbI+sQTD{R$_@n>JzdiOf&vdbkkqw)+|Ta|g_}4*Fx21;A&oC~_t{M#@nm zK;IimXm5UyiDCaB;;>2-6Jk)}J z-W4C_&kH<~`1ao;`yuOl=;^8)Pr{a^x9Q_nu7u*ygxc&jhblVye-13Sy~GN4X|{egfx; zdU74EWW^Sj5Kw$P_o|=j+iy0ghhJVY3YXF}?-k7u&=HC=)nRJ74`Cq;<91S9?u(4O z9y=B@;o0RH6>YYfeys9kh^apXWF>k`r-C+KB=bunQ2Yzq0xtJH6i^kgJg1YTzo0eU zJlPhTQzOnM0J?HJE;O+VI&w9A z&#PUjv4-%kbhzJM_GEPeY5iv=jX40n{6VXs~ykw|Leu6TmJzJE_hcs0cKYC8qm?@oFxwYthVfN&R7inNH6U5F>Vuina9jt zCG7IIaJd!bc`=v`isW*{Fo;$A*UkLO@x82QJlLH+(})!zGvy|Ga#`ng$+&+hH9H}K zgfjX9Q+8&pCQ2K({8H{4f%3kWYoA!4{U*C`_eY(&G~_7j3n*PW<95IH-ZOb5X|w~s z;|T1wilmqc;JtU5U{3y)cZhGG_>D%Dd{4obm5)s*u+%dU!{)Sr^{N4h^Z~w7^NA#b zSNcXUN#;361b1L1d2UujhC7{!3Z$PP}_#wn<` zozv&V!6LbzS?c`8c%^nnM5xqu0W&-=Z#g@{CN~-!;o*ZnwvFKgDg0HY+Czl|&z}`@ zPXTPk5c74(*LOcAaU{dm2kJklq*q|1?2Cx>y4-T!+0H7IY*)J!h^L4-?)zyi)vXjC zZU#Nyfg5y3Sd4?|p({8W@(yAL; ztH&to*4k@npY*QXb^fyY-^l~IjVC`+7K;!{X$oYxKh`zs7j?MsN{dLD|5Af64cMR= zHv-=obO12S;QU`Nz>KGP{`$e3ZwtIh zW6Y>lSnZ~2y}abcWREg?y%z}1b6EuzhY)v$;?Yt1P55=|7hug5C@aJRjB}i{jc}Yg zF`s&_V-*bJPdpQZ{=ntf)x zIL9A(%Wx{X<-Ow>&&uTF{!2hRg<2m^r2#9NTF9Y2_`&6juAUa=nUwdx*@LY;%OASX zvwWbCMrFo1;-d(zhFrz8F>I5+%;`ghzE9>b3t{S>f>n(o&Ay6&8<&^ZoqUNt1-rEe z)~zja{mdCdkWfkLy~ew4Xi)s_>R1bkw8Lwb9Z@QU{_(NdQBb!`q>W|tI}NeQ4*8Vr zeCWoxe%tXS1WsRJ7e7n4eWHG2wW!>1T>J4l__TcQY)wn< zCw6xJN&3Up=JWM_O`HHrAqqv}r;i_WEbHGVE332^i-L+4E_B&gkFP9&@^TDjtSxW#UpJRxeYy%$a+<7TCWVyAHob{w8c8b>HV-X^Z#&+t&4H?N6ZCcIg3=$&Z(^ zJ}40R4OaP6uAD7iWGk>&ZdId{Bl!mnSfIBt+=#nNtjD#t;z@qffoy7dXZ8UtX?gAT zKIHC8g>zUtL z)yppGMUvAxV)35H4P>iN=~^gorkmfR+NowWxjxX3DNyOIPYGxZ=+1MMuvie&p5lMG z5q#Efmm`dI;8QNy9tFhW=DG&+O2(m5I0gx;xJkc#sGT1-Uho+{brzp~I-tmE{W>Jl z1xw1%im<9u%bOCx@SE?GlFQ{iml=%70GCN>LvReO$lSM9Qz=%U>_3*G9&h5R8FUObj8dTv_7258w?o! zbye{w7XOP%Zz>FzV3W=7uh*g5gEO)hGI%DlDqVgwtK@Dg?!o?d!A+PG060yX%wD1B z$(IW;sX*T`9I*GIw~?^qMAX0m6b zjbL`Ei5FBXPkYSujRt99w5Kidj9ic;i%f)NR|LqY%*Ypl_?yb?6lMPRiRMBAIPW#< zX$odF9gt%zT%5W(#}OWc4smxa*kJ7zAB{HN{o$ik=x6BT6 zBcRX$BhnxZCf=ko_#^D<-tyG=dvq2wKOIyDWb|0c)2jINUqKE|W_MWAOCJoH{f!RE zmf0t%`Yh^5oJ;pmxZ64V{zMWxk?4bH3@!1>ZI1vcoK%&oVM6ag)yhV(F#8{25@l2k za|*zt4DH)O&2u*XTgGVXLT)Fmmz48*$1PBX>bj67KYPQ!~SCVW?Pf59U4a)kaE_Ftf|6SGR!BMNPXMA;I& zt&$dR_qq11sK^K`&@ZyeUmkh4=Gg}8)vLSdDUU(2 zDg13lX}CU?aue=i;9;=@+VAbY(|o`qCiLZQl%||*uUFgKut@l18vh+~ zcLU`(8al7p=m3s|L=|PLZ-%+g4!VvE8I_w)4<25w#!?Uzw_M3?ic-w4M4Z?b1EX1q z5M_~4*Qx&Wo^M~@Q)~M5#RvVGEns|JX9-oHRu##vX+8X^N{p9-Z-LUr?pN&*(uw&I zxjnvtrr@chR}PIpZeqGs(Kxl)Cf3=0nU3EgJ5nxFD;~bmmOVkcqe5MH19Wig4%5~# zV;P@?^=y$z0M~dYme)zv0#f`LDTmi0Jd#1sNif)Ng>~g(c%Lx5V7ZfuDclUasY3 zd)=B|X#T-L`aa_OxJ`J?GV_3iU3%@iSNZixMaCUf+z{ng*P#Y)I&0f-jx%>~3*=%?$eL7DN%M z5q)*Ccd-h_aQ&piQ1}Jvk>jTuQme`A*}%urc=zFPy~ih%knHxO7Nzd6&yJ-st`FR% z${#x`4Tx@J$}ji0iLgj)4&wQd2z5J)=Q%b(=HJ3;!f)}sYYY`g<1@wK4wDwlJW=0% zhu_h=(_~5(JM}b?a{h3(_qnbKBrGAC#>GV0+}YLAJ~%Bl*rW0$T_A+M8)#!8=e!N~ z$8=+qzt8c#(o9`dI}JOnk*qjN>FtE>pAkY{N%y=*HVxY+sQqbzo!80T@q5d0YZa|T zWeX|h-y?RwrGM8%JWO9c(c9kya37jB{_ZRys@Bq!VwV3DzdeK#BT#$*@&zVqqmXxF( zwL(QOXl<5}P-Bvz=9n(n7MB1v{ITnM7*mH|H_j%;)cH5{i#J2n_?R>scE#)b4STg2 zPieM7hMwK`es%geffXVlniEpQv0^o4)U@EKBxCYsUH0)N9aCv=E_tJ!wnoV~c0_j@ zj|bq~yAT)2ha3hDOU)XvvddLaJ8*o&$v6iwxspg~)VLDLQ zq7~riv<+}PF15+-cKN&YiKDht6(+Qp(vqEaE$Mj7c*W5{dm>jCd%x$E-%TqbKLyt7 zi(?e9%s3_8dm^3csWW~gFm#S+LkprXzLkW;?5S+dpF^_^ts^dSx(UZo(O}iUw}rodB!(%$>r_@sU1Bh?3N|$zytvH1sh&6ZfdYv?J@=VAlogNIbD36ps^PTd~sfR_PMhSpgj_p(-o zKisbm)cTl$Q{UiAa8&!bx6$aL{)Vf*akmwjUQzkY2#-VBt;9&Dm7mJ?<_SLD>&6=# z&C~e1ZRNMg!9bZmB$jRNU!FZOWU}_w5H#+g;e5ufZ=nT^)5bBEaV$Yf?abnhp{ zW6g3eQxprWzVRmAj~6f43oHpACDOIi30<>$Sw|R`*ixfDQ0Qy?zO*h19<*t|+eOlT5@nh04X1ru zMqN;bA&QT1gbT-lrqCD-g0J`t3M+ID{r5KaX*+WI%D<~W~g>5X1-sBBL= z1Ic_TR9bnxENf#)7)x6)|0uEj&Pc%dOB zw;u1AE$yk=zDI zPE4&pabI~@=U(r-ElvrscMFi$vojRNP$5n-_qNGysr!R;+umElk9_{%VU!Dlw4)hPSew-9*(oGpHS&Xf?F}t zlqNsJ)SlZKb${-;pyuZd{#lYGX>>%}_PC1pH5(0kWA^W+2SQ1iqe&e&L^7oIDYuNO zA9$?Hb54pqTgilQ899#Qn=l~Q^AT#c^-uQ>N1YayWGU~BjL(cvKAR%vJPkjxcrb)1 z*YVSXFy2GWRg~P@|J<@*BH<#L1`RzM=ul=}6`o{$!0mbM`{N~jUNl$zs#oW}?JDWO z$*;xKydHr9wL=2YnuL3$J#x|4w=oX>^+CP8xgFncKa7x<(&ig-FZeO)IZ5Q}_TMuh zrP`DkVDo9S&ii(GO3m++XTm_dm;GHj%pmVRr9#AQZj zV_6+^Msw!7FVePw^u8Waw2w9uMKyzei$EC!RIrvvIY*E1)M=Vc#Gn9+sVo zk((fV)`pXDt=QOc>*oSr>0Y|%graLQ0uP<@epoy=QJQJn7O1HIi+}&f@N%^*N37kG zDLmLDE8}44Df<&D$Xw%ex1#3$)a%*tz7ITe-$z;xILdBQC2!`0VyS}YEQ?-~8^PXn z9FJ+-aO>M0U+)7>CynyW9WUw zgYSC1cl*%qb+X1j3+Q%N<-!Lwpe>-o9QvfC9}(&dejC|h z;@!*^pC0qE-i;q%ML%@7!kXZgbHA2p0?nauE|~MnnL~047aRJ6SH0d(TukuOw{HFj zR3p(c$i9%z%}aJaXuxiJ4K;pyB#`h737_H@CY*t0JZJB~3qQYgsI`%O$$qTPfg3E99>1fc-b9 zYby>&n?HV_X^r1Ia!Zj(_+pyOo+^33U*6T_IU+~*zCSOv#S5VZfc;v_);rjX3zhsR zGTd)6xd!YTOgkN<1@aHt`hT5r>y$YUQOP+{-2VI)t#%Y|W?vS9;F{-D$)8S_9v~k} zSZzo3UEIAedKDDo`ABGWm&3e`=w7xVTRhl7zpq>|S$p~wMFciCu42&k`d1r2V!nzy z*>H~$vlDIh92@m{S@bfx;77U#W(WS)Va?l;>M?6hSBX5BHG!}-swXdK ztcQtg`tH1{R-@A47a5Bj-+A%OHstew!~10g()q)djgDkLbNHm#A%a18x4rD!CQDW0 zERNWun49)%h1&Ya!W@&tvBK{hHHxC>Tsrb`arR;0$bmm+3hhpwkp`GrLa0z)sG4)v zC;HRjFKTY&`&TkAe%_z%4;XJ#`h;IMtwmS=aL@H%Eb*0!ECVaRXZ0hE&}hM<9rKGE z$}e<6$z3-CItuS;_;DNg{FH0$tk?j>!Q;o#&u3$}yvj4>*er*TA5j`R2b zUv+Q&71jHO|I(czB~mINASG`?q`RbZNNJ?I8%603>6GpqVhHKZp@xtcx_gMT^>fZz z=Rf$aFTb%CYuIZ)&+Pr&*L}UN`%hx5ieY$pG!yh&LFKe0ITZ#{Naws!AiF<>a?fq^ zZ=GxY(Iqgu{LS?u?T(nu|?M(1# z^dB&99qlYjhb<)RC~W5&g&l>51ZXB_Yj}c-`$Dw8O533XbJ2sU?L)F$5Q_mz7NO&S zk+B^48^52{*CP2J`+70hILu{HmM>}l`|>j@lZ?$G&fOAYcQ5{>A?(Y9Rr4>yWo8uLOtMsVwmA#<-)o@Q{PKF>B{QqEFiE_6$Hapj_@ zG?V{Cq1kH9XYlKHbB&0UWBwkHurQmzIcqz`lam4G33@}!K!_Shao`;1t?ipc=)~NqZIvJ6rZ|T4Sa0cyNHy+6eHn94tiN60^N!&Pz31_vbApe|n4X&gu{B%#3r=>uw}k4e^duVDT^nv53eW#Ij5;N> zuQ0b96^kmQ%m98d(f7KfeL!!O)cQZE+NV6`R(NfC8z$zh0e8>?+2qH zxJrUnwu3fi=9wKzH|6bVbpFV#9&|J(4~{fhUC^|A5o;&h%1XX5Q*5$V6M&P+4x&G#?QMSXy1O!C!%5$6|^&97y3VjxCi z-T_=}j7D@kdFrE^=yg#WYBUrsT&mzYir;!543nZttFb>nlMS{A84KNe5ibI?Q(eTS zEIWU*Qcl_9#?AyEWu#6LatD~WTaM^3!<1C5;}#t9KL-Xhs%nIDay_$H++S)bp)r0D z^D8Q?Q2m*O2>^7+3H>fT=3T6?=k$Dt=gI_500z|y^JOM;9NIk z6FIsm%Mu_%Uh8;e^tKZG{=&Xts(bsOWvv3f=|{=_x`K1MyXCFE0>`-rvIO~>N;K}6 zN6;X4#{P4Kv2UJivNrM}=VtN!HKq{)EQN9t>@#OMOZ-{$WQs%=+nZtnMzaqGgHZpt!9CrbLx^C(`I*=0vHoU?%M7f(u!PAo1JD%tK#D@)^JPVaA9md5T4|f8 zo2eNJdlYx$RgRT$aRbIM3h`SElH7rJXWMUtfSx1Pt_RV4%3Syj+l(^TZFpkJkY{9< zqU2x?QVu5eeNJ}-Wl9^(vv=JvPFHk-uaR=quLs~y^K~MVHdC6Xsr@r&>z({Wsa*J| z*SzJLVvui@e(Uk#U0HrBrohBkru$W)>=CU%b#W_nil~jCwRbr0v9N~l!v4C!)Dm28 z#jn9L#|kxua-GspEDYbZreuTAV9T-E=k-t|4}mV85DD+meT-M5N1Z-I$q zJ+}zPHE+vFbUv9_uNHo~{IY7sWn~TLSp%o2oJ|*`@)dD9;rGp;L>8V~Jz{Fs4>mh0 zL5<*4m~s}e(HOhHq{6W4z?HF}!NOhyaGkffXToW|Yq|Q@rS-pGX7$+A<~bTmyNQyo z34M-Mb&Cqrmjo=;r?csC%)zC0r5Z_C`T5fQ&x#iQ^tBQpJ^c=+% zt@%_=+2x(5N7@pVD~??VakG7ovVd{|5(Aha9l73WFk^wdK6zmx|IouU8|LIAZmc;p z*fqwc`0Xv7ZQ3-s_uH8mkk zo$FnMp4hecSNkXXV0_0IuC%rRG#t4ouIJcQHPL1V1BtIHoI;|+>UMgzzAVq&B3ETz zk#};OrlK*On&My^F{RlC;4+R%`-D?tvL-+c{&7-Xq_9Da27>bQRF z&)$WME&M#+zS0zMQu0BMn6N7w&pBn$c>kIO!|(cTRH;3z?6HS&=}F`e^34?QKkKyJ z`jKXLxF6>wYM>R@mRoyggWhJHy)0wO0^4CGVX~J$fvKj6EnC8ek0;Mk(u(K{S^Aje zh8PWDb1IE-Ts(y%31kDd>F4BZr}ub$mYJv9J}f+fn$3Za5pqlfkACx)+{d@)5mc2B zc4J<(O%KywU=I(Tunbun$8(5j)#s}xHFvzrR^XfJ>NjpvOF)_OBB z(?L^iFY2hPp1Sz#^9p*chW#MN(kH*f`u6%1*D075aEGo8`4q@0y_`Fiv7raQBJM!P zPi`l%S-&Tv(gH}?Q;iCrZ+;yX1Z{l#I4G;ORhanq4Ny*j4^pKUjuP`dv2U zzoM%1ryrPVq%uT&ROOUpacnM*&JyG>#!yGH2WSbhE3zJ}Uv#K6`P_dvWS?DfWoJ*j8)XTDoX5Hhke+G{66AgxpT?x~g|MXg2@ zL{xF>uii=FWJMatPGz_e$ZHSP#zf`hujA&Ae0WF`kv+1V{7kk>G*1A(k!>m@HL6hh zZxG|G22VQWz?hAYcqY&`O?*VGih)*2p`HVR zJ<>zGxy$2N{vZP8Oxu6_vit1nT-pwSwyZDeA5MAwK6HY#eV(LajkoVP>?Z#>LvGAd z_HF0}$eiY5eROYulv)5R08fENkk}mQj5+-|@Ui zPT?kUnMtTGIbynv?6vGGZVeM#X}H^qeuupSj|&&qYo!vgjfAP%X(S=$#Li39p!D$v z2`&$gT~Mr-rn z_w$j3Dc_Zkv@QN{Kww)MPuKJMws4MRjb~n*UB32`MvYynBd85O7WyINdVnO359LfA z`(66P*`@o_Cz=wOoaJra4el2o2JqTbVK?bKR46O`ZWUSym0Bn8$h{YEKW;${HNKy9 zClW%He8DPlBac+!Ka&h618QEr6tP@DO)$ZuqNs1*{QR-Foh`UtYIrgPm0YWuld+%(Vxt~Al zeC4WeY1jaK-5RGAPz(`pi2fs;tt->r5pBUik{3aV8pz&p9+MKP^*r}KdJg>w5ABx~ zy^Xd@X`Ar4_1V(1pZ?L^<_}u!8kyU$OVo-CBHN(t%Q7@y!Xyc7f!vcI%J>uN5t@&# z2Y8y@M!PPhTI&q0a!;GO4rR4Gpxr>3va!=r=k_c26uhRI68oWK&8ObTVPE>3)4BntPE1 zkHGX*W)$q?ioak+k5$3jS();WM)FF!qrPw+p0zc9BABwf)y~$e$&v2WK7jZ1jn0sG z7&AJ)G+i#~k43TvAuNeyK37F(-bf0_EJ`pCB3{0~>ks8JaFVicGo5?)xHVcEqx#m+ zf6Swt*tgWI5VPKDqJIFSy=nUXiVj8Ptz!BF52a&M+gD9FoH_4F+;6g-ffs>u1gV)d zSxHs&6yX>2-1D#I87shk`vxQa!e%3`W$_crZpR`Z2fkoI*&em7JxUQRZBKMiuLiUB z;v;Pxt#;$on`q)Qbq#js74JHE9B}M92FBIs>C@vh`t7~fD+lD8YBx4XQJkf~OHrED z`O<2K^Cmn=8#zn=uet1;mCQNftECEUE76Qvg(doca|vY~ab&Q!1&{bzPctt9^k-(q zRf#(l-qh0O&y%vjrG!rLz!R6ve-q&f*;hvIW&HXY76 zVv7vvSZV||w@f+|q}WLkP4!^V=NN(m4=gcBgKHjP`m*T~4fLH_EjLzm*uT!)z#y%6 zFl1HTXKiETWJW!+GWt;PbxfV}jVQ>uo%BBTwC+#EewiLks348`bI~+(>z4?W-Iuo+ zBN^|^GgUsn#|^3v-+HC|J=y1Y%o>*AEiEEL*B+f@?|AQM|0)WC$mOVxwo@nx{H_kM zXS*;$ljTg)jq020u$@&m(kpza**(;XD?-LE*6qUJ*TZ1kHU-x57I#EPbbRTFcE^<& zJpbfDX+TYdsFKNlneg@Ajg!9l$_9-uga}W@c;0u7v5itg>V#w2?;TWcSzkif!lBMB z%4c}>Y}sykE{pobK4c(4&x5*n*jl#gZHRmi@(lSKEX;wWfI zHY=stIDxW^gJ=j8#*dX!;Y zM(;tbOblgi_+KVFwfQ7)l@f(w4Y}f8ory0Ophk+^n9*NreaBMCi$k{8`HX_MdHwdh z15T)9n(Wr%{u40L+BH@0n~AZAECc?lBYW_q_Hj?@*x3Cus28O) ziV&S+`>@zw6zH0O6`YieltkX2L@lAkxN_$JWY z&s11H6s!xOf(J`{&q{%b-!-Tw%Ya0m;KiRkic6o1Y?eonY0?eTz-EJ}3Nma>za@m`r*pV4)&}l(L+T z0&H}_hoX-1l^c&G(vM~Tcvk5B)S*47N=+0jV~k(XL$oPMr|aKy==tVyshDWZ5Bnlb z1=%0)Q20c?N29ww6e(`KU5^&P80`D79@>pHM-DSLyB&LoV#mPB819@Hb2H?0tA?3B zt9V|mFFH{me@m_j<-JUzD%V!Ce#)875!pVMo>|Qi(bxix8%SS$?iuISFLpaOE{1R!Gyo&Vq5W)s#eY9c zLy;b0h3zVQ*Un<+R?E>CzI=L_7bny6x?Vb&amYKa;5f;+v~ERy-fP zs6!LC1oTESV(;%}tjAZe8y}ig7Zz{`6ck?6uO-&l5TA(oA8S5_n|ZBiHX>()q>gF_ zqgjcC)3601`A>tBG$c~!ob2|C_=A_xBmjpRx)j_Ll3hSooM_7p6urCr162qTW)pAb#!=dr4+aRS@f$2P@Fe_JeK&^1aInKj5R1}-4@N0 z#FVP!*mz}@nje_k!aCHOLS=~RZISM@*f-CXEXdK~g!HtibDSQpif@$e#E+%?r^`v#))f#qgZm z|3FP-;Zj4Kdy3D20%%6$S!_Kp|zI(4m$#t{BPffvG zh6&g0jH#mFZba(V5+trxa2 zDvUF3dB!$?YV7QO?Dx9Rg|O91PWV1z05^o15Dg;n>AH9)CpawU!_@~AW+$z$V>bE> z1;m?nSo$Aw(dou)3Z8UnhW%%8csE`;9m#Cccl8}eb(*MD@diQJ_XVW_sgN^d&xC9- zX_E9&$j^KX;d<4+xYbJY=qb-tJ4|;Loj%rVDcM>mxXN;cCl^23L7>^CzquOJ^&~ zbj-(shnMqB6%CHCc2f>(H%=c0rJwRE19^xNcWXWGM;gbh3+$TikUS=J`pOWAa9 zy>xsHPW^{mJ~ssj6Dr8F9{$iJ=O1IVXo!h!rfXD>eNh}@V)Q2G28TvOzqoC4oYq<& zt2aJ9B!$dPQj$69FV&exQD>j;5PQO;=E(c^*K_NhqKYfDXAIfi`T*eR_zw{6MZz0m z3vs9Y=&ldZs(s7)mm}5V^rESBOMQ;V>68-rNnTQNQB}N7(c5j&MMIJqmBpZq{{n{S09ZA5f13I8vqa$K zi)!Jc6v4SKFh@!vL`QSRB4wG^(+fD7GdaZS;;ijG+=xmCEyhOx__3?xHm$b8(V6e(}_1 z;V)fi{i6yK&nS=zz85HX{KzYaO6Amh@_P#vx_UjA$?v2VH{Rj`f3Etah3I@sok^MX zAF(U%g@>dj`E$0coM!YXhvjk8G<=!`S7>ywS!e#m;JkpyiEWUeieUtx32*_Z#D8kq|aME`rSo> zb=>SVwHvI}Bn-+yJf+(&?qAExk*js+A0?@kN}Mlhb-r)<-v=c21KZ~7R~RS(zT8sB zqQzxbkWt^}@bT(}MUztbN^@N}MiIl`Kh4`QLE=Pz2WSEhfdMhp+7f*u6G?9bF=DbR zuMR#EUGO#9ua+>W?<{+Seu4UZ-xG4o0AvVMx1AGJQoE-I4{-o9dOgeSU*1sORcf;= z+kWs##E$T;J?(rI8Y3%dir~F(*2D++j)r3{$*S{$G>4M>r~9&a)YWXnTv#a_X|X7DY z8S?9*orn3FYfe%JA*0qunXSf8K=PR?KoFHb$$+mGi&Uoiv{x&q`>Qe=^vgNMBG|OR zSZ+Rqw6`+9atR*KzwFFY?sXakl+}0IA!@Q${wL#w1VY8-$VKcBQp)}t3j6spBYYAc zk2?%n#gvDpJNi|G#L%J-2?3nJD#0#Q@Z1j3P^DcJ!#`SecZ8WqBcNkV+3((Y!n}uw z5-CYM+u~a-dF;8jo*YVuP4-{8wTWN-+ui2*e1{@r9WlmH?rVA#+`E_RW-m!{MIW7i z^Dy52IooG)faCG@I+Y{f!id%Tf;PWsRJY(!*jG|W{lMvAAYtE-6h~LN>HN*gq82vN zV~aFP#r*4tpkaa9ROxXmrne4UK5Q9PUcKz!|2>!>kopw|mql8W%5{cR5smLe zbS+;sU05sd&=R^TO@}Lnk7mK^u#nsMRI67}#{wyCp8c|@`IKzE8(wwj)>Yd+UBZ-r(ZgZG^Dae!#p5+rDO%dk`8|g z)v%}MP5&l9rJUVUx5b=RXQ%}AANzMWN8K%epxe}lve|uql_md`LS6@0Zt^?55|&xJ zUylNw1D8$2on_N)tps^~trj6GF?x-M^^Gnez|7}h0teP(ej0YGJ=z}8@s0Dh|6BDb==LCUo8O@Yp9LanSdML50d}{nUwmKTk&|l7f}UK zzXV;)EYY$oJn@eyQS`8vEck{+r5CQs&`rNtit(dw1nqw{9@H{@FnjVkWEJJa>uQ~y zGcp3LR<1N&iQR(iW=hnLNZ#Ii{0aRs0Zt*vpeJgw>u0By{9SWsfnN6KTeHLXj-1Qo zm9Yr;gU?mDO-GZ%lacpF#}%v$xc%;HW}s`W+tn;5AY^Q`KL09qVHhkI7?!YbH^PP# z=}LcGbt~+Maq=(+xB2fV&tancZY-OuLJ)h^ddaT(FF+0VX7}{?@nyL&M%aT7B&L~Z zq_YG$X;;=DWgYhz4l@&Ki>8XET#?Cvh5CIrWvVu`NAT)&WGvsK>{f$%lsfJw11dT{ zQJ^*atvBrlof1m5+cUmQ=7i&*M{~?wjte139QOAv?%FT zf}4;Qr5GgaTrlV9=%`#E@kX-ajqrXiTy`RFy# zoaKopDci5{j41g#(VzFGAo=SC9B{nxYj*c>HDgc&aT~ zHM>RD2LRodY2l0D^O8&tAW4Ln*x(%nlA>5~4msfFuT&uejFR5qosCkzIuJM>Yey@b zOp&of@fO9jK?5lFF6efv+EnzTc2>I{moXKDo3f1nOnNWK=XhpZ#IR$^&0qqIPxRlg zG5V{ufTnGgYu%(4fPvP0Xl$(mlAhqj?hLz|hwE=>0ZC_^Je#`jKQD+`a zj;2cNN<0ts#E-G+87ag6lH3%W#53dK9q?Ss00dQ67JRJs4}dxZFy4(>vCcCmBu3KA z>joH2S^Y|OyF3k~Z92f3FB&peSb8!w`r7*Gw1)&lI`(4K2-A@`RV=nN!7WRp>?Gcw zFvyY{$x^^RlaC>SY$n0>d0j`C+YvBHmj!Xgedp)8Aw68Aviu0qyD)01?6*+RsP}bo zkB}~FdUzzW^jPsPlg9}joYw|m@=5OZc13vSZSXWQ>y_Ur?e^(|zUQy33uLC*WbyMiBX7 zt9e%%Fyh0@;|Rr{G|i?Io!#>D2F2beIq zx4cda-5{FCZrykCxY>?ORZv~)ACJd1qq~b;iM`UdMDr8F0wge9h1Uzh(}gJ( z7rs@2kdLl!6O6v|tRk+IuWWVQ0;fjp9?1ev0MK6p6hHYo6PCzU2KNBQs`D@#yJot_ zYY9!7MAvy(Djkyn<^h-1$5!CXUwN#6tM$vKszu&ln~>$(;^q6}kPd8Iq6x14P05rs zKn~p{Gqj?|9h-AczenygUW(T@^!kn_7c#izz_5eC23rez>$(t0w%f>IFaZOi8qzOU zVeAz%6QwtMWtB7ITs6I+yB$aSnnOuiA0o9r*AkIfA9cCuxIHkBPy9iP`j z1FQ8;c-X$bfi!Q%E8LGSTW$-%1B48~3eW$&Ukg9sA!RY8yirx{zVW4Cb(s16!JEj{ z%d2@#z2x>j&*=>fBJs)$;+j6eC&I&uGM-lHjavQvU%a}$evfrhq$Cy63u3U z0wX&=YHF5g5-b=-?8Dhz$%9=}-WX7hC+?~xfvye!^-RFU;&|gtKWF>}`4De9(Pj(0 zGUM(x`4Bq@mQ?1;_GS|O9>54E_FdQ3=o^3ph%3v2S>rAgFO4s_3=_|6X)Q;cD<1cG zD0^>1*Kyj$Borl)fdl)WI~Xu)Xa^ETfLMVbqL*e^ITEqJZOkq!r}|kL`8==;BQe2U#Kt3hqsFrT?qyv+4m zD*5cx(QnYSx_t{n@ge~uD|kq!!TxjE9zLWL?G5%wDeiq7L7dQu?=I)__e@?)%k^ zhg@7u#n0KQa`_Z*d6LF+XvqmW6r*8G#0Z7@7r`}VbT;6t1maIspt(Ee(BEm zvb%!2kM2}B=g;^$zg*eU+T-m~RzKvI^~r7bXNvSfd50ql+LE z{HGhnn!%s)$P$s~DO{$~Ffeu<^3xja5+RA>_b}(%v237xh1U60={Ctd1qh?e_o`@ z-I-P*eBW0&+Qo8Q7aES~8lkN?5WKLI&7vX7QwacULLHE-(e(d4`q(j$g!I?53pNO2 zPFJRWDK8j!cohxn8;SU^dy3Y64@fpjyYx7`?hx&*V|eEAl*FE97&7bgsgVdj&Yt!q z?z8$k?@{ju?O^ZR*fe8e&l@jzRaSO7P*lq^>ovA0zk9Z4Mz$V5_)M$vZsnYNXR*oS z$%q96U?4F=k~sFfk2^jbJRvrOLTl+KXe2S^hVcEn+us|6OUJKwUUS1M4IsJ?N7S+D~EiZbc6@$gRtiwp3yE&KT17PXYw-hWS@U?GUSJbUSP;MRn)BR%~ z10xat`%w9aIfunOfotkqrp4XGqK+>bWx*?uPy3o`z2Sn-TsG+cofmsw0k>~?mv-bO zEIlJZ*jTXm2RB)1;>9nn&%rmhDMmt;(xj|$1~M+%aM!#cuD?Z!RB6! zu1b!LQ%*+yhbsaJmM&t{!aKP?4AD177xW8Kw#KH!@xAhXD!>9{2-@>}s$2XIqJ5CpY5v1I*E zK7Ar{Ono!USLcnwR@D`Gs^gjEWdXK$B8 zm?n_L7|FzvFyd;XXY^0{+MB%ZFs5eYTYQ49BCp1EK9%1dw~nzywqY53 z2OJ6&4g}wsXvQ6sHMnh;%OzKPyq0w1Fl zqH-t#MKv|fPocLux6@;$F$_ujOoH(xNA7%Rt zT(|s#c?u>|r64 z3TvwOGP8GB1E_#6S_IsO(o4PiAHM(*Okyc^MKQn=`l2xn9>B>VcwXQ)vQcsI{dCKb z1Z7`%8K-{Mdd>inGAJ)%k@F1MwncZc*u6LKVW|?}_I?DoFBzViq9#LuV%$fZwxAs- zPWL_&Cs*DqE?OoWTy|^(G|ls2p@BNOXnn4BU(R2Sbkecv+#s{1XR|bOy50JUO{O zl{qW^s9x-2a_t^<0M7;m)#RF5?$JS6Sa(!fhim`^V(JPZ;^j(}MECP8(5ByqztBrE zFL5+2TANIOmwcW(TQR&Z0Y9yLoZ06PvWp4%W^UkRZb2IUh19S%+u^}WTXeY5%9a)s zpL-U4O)RJnzPAzLr7~WwnmoeZ$sVt)xfImj6%1sFQg8@K9$tUcv#<{T1S0 zJfmR4fi}vL>}4pW4rE%)!y4C3St%L;f#%}O$iFfb{0O$uknh^WOua$lbV32i<&wtR z9=m8EHUA`4eE%6GG+v4pI?knDDcJqD=g11XMFA$@369}-=QaGS@BUjJY6*C%7BWUk zr1j22V?^TlCM_M=_02O-Z(rkKh-lY-he;V+W$Sr=iUz_4<_(Iq{hCypbbv8;;|J_V zSdBczx+|Bhe*;&u^UHWGFlAKmObB5ZREKhiy z6ma$q%Q8vGt!xLT=J}l_U?oJCjxbw(Cz^#}MgQGy@DG0gCNT07p3hH)#h=GObyj;d zYExodofRK8*a3SSu43U?lm6;u@WJm+u@lvxr*Mhe0$YhalAI+$%WI2!o7sg+X+-?* zIpjI;1`a9KZ>-OXdlr(OZxR8q`#GahL8T4dpvmPSp;1aQ3=jhvwUVRhn+C)`1!|if z@q!JD4i;)l(bX~cxsoRgo^?rUv;jhZDLO&cMBVLJ8owxJ=W;SW`q}mrge}I{v?Mn^ z99z_kit9)>opBZzfOvUC%-u_{EiKQ`An_gQTrCOg;50 zbI;a}dx|(g{@YGgW+l^)BCpkWn7iba^AgKbE9N&ek@wikn} zx9pvVH|7DvkUAZ$ZMEG`;%Pt101`u|r-o<_xF!>zPVy|xS?O2S39>jynMD0q1~?rr z?`eWL&KdD%*sz_l)>WGp_q}VJIlij!_@WCPi<#(cQd=M|cge31iq4_(JsKSh zDZkIFbrbT`$Znm74sclLAciW?S-)APS`_92SsW9q{bxh%YC}UFcV)JG`c*6)S8Xdz z&(PeQw?B{F^8q4h#ytw3I8i7Y!kO7m$Wb^}BzN|maq^n6X+$^|YU_@9!)a58``&!5 z(u6NYKh;J!8PEcPXa743KL;ATrIi&=$vSr1I zp+g`4u4)c8W9jM7*l({wTxY5+yxL>Z^^oaYccpWfnNyLlkUh};oN|Uq%I;oZ4R|=Y zputg+W6qZs3_kpv_^E#OdKt;H!c~q{Sssilfa2Y3J=Lrv<#%La6!utGWs)$cHQSSgE+x4F z<#Ox?<39brRay7HOkb}TQ%#vPmOKte2#vXS(7m)6LK=Z`0$QQcR*Y3Y_|pt`FAbC= zvh6~j>wn3@e~TU9A3uL?0qQ?E0j5Q%yD;_b>w0nFo&lwk2BdOSvwd&t0&nWy)g|)& ep9lzkBrFzm$FSD(Cj%nXk>sS5BrC;@ga03x^jUKN From 5607e90b5c0ac217d6cd2d946c06dc6d2f046c00 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:51:50 -0700 Subject: [PATCH 30/35] Integrate GitHub publisher verification and default authenticated catalog --- Cargo.lock | 327 +++++++++++++++++-- Cargo.toml | 10 +- README.md | 10 + README.zh-CN.md | 7 + crates/shell/examples/connected-app-host.rs | 2 + crates/shell/examples/connected-inbox-e2e.rs | 2 + crates/shell/examples/host-api-lab.rs | 2 + crates/shell/src/app_chat/tests.rs | 1 + crates/shell/src/host_tools/admission.rs | 17 +- crates/shell/src/wasm_service.rs | 4 +- native-apps.json | 2 +- 11 files changed, 347 insertions(+), 37 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 697d9b7f..b31bceb4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -261,7 +261,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -272,7 +272,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -636,6 +636,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ "aws-lc-sys", + "untrusted 0.7.1", "zeroize", ] @@ -1345,6 +1346,18 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "cms" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730" +dependencies = [ + "const-oid 0.9.6", + "der", + "spki", + "x509-cert", +] + [[package]] name = "cobs" version = "0.3.0" @@ -1400,7 +1413,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" dependencies = [ "lazy_static", - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] @@ -1456,6 +1469,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -2172,6 +2191,30 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "der_derive", + "flagset", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "deranged" version = "0.5.8" @@ -2275,7 +2318,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", - "const-oid", + "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", ] @@ -2310,7 +2353,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.3", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2417,7 +2460,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" dependencies = [ "serdect", - "signature", + "signature 3.0.0", ] [[package]] @@ -2431,7 +2474,7 @@ dependencies = [ "rand_core 0.10.1", "serde", "sha2 0.11.0", - "signature", + "signature 3.0.0", "subtle", "zeroize", ] @@ -2607,7 +2650,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4472,7 +4515,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.56.0", + "windows-core 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -4773,7 +4816,7 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82cb6a9f675da968c63b6208c641b9dca58fc0133ae53375736b1767b0cab8bd" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6993,7 +7036,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7946,9 +7989,9 @@ dependencies = [ [[package]] name = "octosense-app-contract" -version = "1.7.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c3e8bb885e67a1290abdf6d3a71bf59b2707fa7e67c9b833eae6843355e86e5" +checksum = "0222acdc13544bb9bbf661321b99f2ecd8aefa631b746a8ff44162d6fd0337da" dependencies = [ "blake3", "serde", @@ -7959,26 +8002,32 @@ dependencies = [ [[package]] name = "octosense-app-hub" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=8885c3755bb56f083c4df3e6b539b58287433895#8885c3755bb56f083c4df3e6b539b58287433895" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=655114c4943cd2490daaefa2173e7b5aaa20669f#655114c4943cd2490daaefa2173e7b5aaa20669f" dependencies = [ "base64 0.22.1", "blake3", "ed25519-dalek", + "fs2", "hex", "image", "octoscript-ui-l0", "octosense-app-policy", "rand_core 0.9.5", "roxmltree 0.21.1", + "semver", "serde", "serde_json", + "sha2 0.10.9", + "sigstore-trust-root", + "sigstore-types", + "sigstore-verify", "ureq 3.4.2", ] [[package]] name = "octosense-app-hub-app" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=8885c3755bb56f083c4df3e6b539b58287433895#8885c3755bb56f083c4df3e6b539b58287433895" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=655114c4943cd2490daaefa2173e7b5aaa20669f#655114c4943cd2490daaefa2173e7b5aaa20669f" dependencies = [ "makepad-app-module", "makepad-widgets", @@ -8006,7 +8055,7 @@ dependencies = [ [[package]] name = "octosense-app-policy" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=8885c3755bb56f083c4df3e6b539b58287433895#8885c3755bb56f083c4df3e6b539b58287433895" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=655114c4943cd2490daaefa2173e7b5aaa20669f#655114c4943cd2490daaefa2173e7b5aaa20669f" dependencies = [ "makepad-widgets", "octosense-app-contract", @@ -8028,7 +8077,7 @@ dependencies = [ [[package]] name = "octosense-appstore" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=8885c3755bb56f083c4df3e6b539b58287433895#8885c3755bb56f083c4df3e6b539b58287433895" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=655114c4943cd2490daaefa2173e7b5aaa20669f#655114c4943cd2490daaefa2173e7b5aaa20669f" dependencies = [ "makepad-app-module", "makepad-widgets", @@ -8562,6 +8611,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -9106,7 +9164,7 @@ dependencies = [ "once_cell 1.21.4", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -9562,7 +9620,7 @@ dependencies = [ "cfg-if 1.0.4 (registry+https://github.com/rust-lang/crates.io-index)", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -10146,7 +10204,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -10205,7 +10263,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -10223,7 +10281,7 @@ dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -10345,6 +10403,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "ryu-js" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714" + [[package]] name = "safe_arch" version = "0.7.4" @@ -10740,6 +10804,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_json_canonicalizer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe52319a927259afbfa5180c5157cd8167edfd3e8c254f9558c7fef44c5649f2" +dependencies = [ + "ryu-js", + "serde", + "serde_json", +] + [[package]] name = "serde_path_to_error" version = "0.1.20" @@ -10939,6 +11014,15 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "signature" version = "3.0.0" @@ -10948,6 +11032,144 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "sigstore-bundle" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a74b758235084c8baee7869e6073702b04d42d1efbd55b7042a43dca3a27f15e" +dependencies = [ + "jiff", + "serde_json", + "sigstore-types", + "thiserror 2.0.20", +] + +[[package]] +name = "sigstore-crypto" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62ee2920d66400eef83fe543bff02c3ac5ea9a59ff864d365dd746ada4c8fc4c" +dependencies = [ + "aws-lc-rs", + "const-oid 0.9.6", + "der", + "futures-io", + "jiff", + "sigstore-types", + "spki", + "thiserror 2.0.20", + "x509-cert", +] + +[[package]] +name = "sigstore-merkle" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f64415f7d91620a3fce3f89f5955997e7104d863c12f36bf0dcbad1b258cf584" +dependencies = [ + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.20", +] + +[[package]] +name = "sigstore-rekor" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b17fdd6fd9070f6779b117594582b9d2b4d9a9339d1b2f57711ad10f6a0cc0c8" +dependencies = [ + "base64 0.22.1", + "hex", + "jiff", + "serde", + "serde_json", + "sigstore-merkle", + "sigstore-types", + "thiserror 2.0.20", +] + +[[package]] +name = "sigstore-trust-root" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16644cdbe963a469df667174ce6447649281aef02300149b195bd6f66d910ae4" +dependencies = [ + "hex", + "jiff", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.20", +] + +[[package]] +name = "sigstore-tsa" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "403b8a741c152f65752314805dd20bf9022a2e23191f7aa39b4fe8bf30699265" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "cms", + "const-oid 0.9.6", + "der", + "hex", + "jiff", + "rand 0.9.5", + "rustls-pki-types", + "rustls-webpki", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.20", + "tracing", + "x509-cert", +] + +[[package]] +name = "sigstore-types" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d644275d2d22b52bc872214dc1618bcbb830f71064124f8e8b3cfc07c1020205" +dependencies = [ + "base64 0.22.1", + "hex", + "jiff", + "pem", + "serde", + "serde_json", + "thiserror 2.0.20", +] + +[[package]] +name = "sigstore-verify" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc73ef1cd580920f1233358cebe550a43369daf8110b57cf820576888fbc0a8d" +dependencies = [ + "base64 0.22.1", + "const-oid 0.9.6", + "futures-io", + "hex", + "jiff", + "rustls-pki-types", + "rustls-webpki", + "serde", + "serde_json", + "serde_json_canonicalizer", + "sigstore-bundle", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-rekor", + "sigstore-trust-root", + "sigstore-tsa", + "sigstore-types", + "thiserror 2.0.20", + "tls_codec", + "tracing", + "x509-cert", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -11048,7 +11270,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -11064,6 +11286,16 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "sqlite-wasm-rs" version = "0.5.5" @@ -11359,10 +11591,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell 1.21.4", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -11555,6 +11787,27 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tokio" version = "1.53.1" @@ -12096,7 +12349,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -12255,6 +12508,12 @@ dependencies = [ "ctutils", ] +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -13068,7 +13327,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -13780,6 +14039,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid 0.9.6", + "der", + "sha1", + "signature 2.2.0", + "spki", + "tls_codec", +] + [[package]] name = "xattr" version = "1.6.1" diff --git a/Cargo.toml b/Cargo.toml index c0ce51e6..01cd7d41 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -76,7 +76,7 @@ octosense-reference = { path = "apps/reference" } makepad-sheets = { git = "https://github.com/OctoSense-org/makepad.git", rev = "32d6415fb7476345ad36ee4f98d6f844d1f07fd2", default-features = false } makepad-terminal = { git = "https://github.com/OctoSense-org/makepad.git", rev = "32d6415fb7476345ad36ee4f98d6f844d1f07fd2" } octosense-appcard = { path = "apps/appcard/module" } -octosense-app-hub-app = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "8885c3755bb56f083c4df3e6b539b58287433895" } +octosense-app-hub-app = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "655114c4943cd2490daaefa2173e7b5aaa20669f" } makepad-calculator = { git = "https://github.com/OctoSense-org/makepad.git", rev = "32d6415fb7476345ad36ee4f98d6f844d1f07fd2", default-features = false } makepad-clock = { git = "https://github.com/OctoSense-org/makepad.git", rev = "32d6415fb7476345ad36ee4f98d6f844d1f07fd2", default-features = false } makepad-notes = { git = "https://github.com/OctoSense-org/makepad.git", rev = "32d6415fb7476345ad36ee4f98d6f844d1f07fd2", default-features = false } @@ -112,17 +112,17 @@ octoscript-makepad = { git = "https://github.com/OctoSense-org/Octoscript-Makepa octoscript-node = { git = "https://github.com/OctoSense-org/Octoscript-Makepad.git", rev = "33dea2f1f3ad3f1346a219aa8cf6e91b31361e23" } # --- App Hub's host service registry the Mail and llm services register # into; the same revision as App Hub's app crate (a native app, above). -octosense-appstore = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "8885c3755bb56f083c4df3e6b539b58287433895" } +octosense-appstore = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "655114c4943cd2490daaefa2173e7b5aaa20669f" } # The app policy (resolution, isolate settings, the Splash adapter) and the # Hub client the Card runner resolves an app's policy with; the shell's # glance tiles run cards under it (crates/shell/src/glance_card.rs). -octosense-app-policy = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "8885c3755bb56f083c4df3e6b539b58287433895" } -octosense-app-hub = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "8885c3755bb56f083c4df3e6b539b58287433895" } +octosense-app-policy = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "655114c4943cd2490daaefa2173e7b5aaa20669f" } +octosense-app-hub = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "655114c4943cd2490daaefa2173e7b5aaa20669f" } # The app contract (ADR 0005), from crates.io by version: App Hub, the shell # and Rinx all depend on `octosense-app-contract = "1"`, so Cargo links ONE # 1.x for the whole build (Cargo.lock pins the exact version OctoSense ships) # and App Hub and Rinx no longer need the same App Hub commit. -octosense-app-contract = "1.7" +octosense-app-contract = "1.8" # --- octos: ONE source for every octos crate (reviewed octos#2715 at # b0759a57: bounded ledger session-directory names, serialized peer purge # against session writes, and provider transport diagnostics that omit diff --git a/README.md b/README.md index fee27f26..73934d17 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,16 @@ To read the code in order, start with [From an app window to an agent turn](docs Connected App Hub samples share a host-owned GitHub/Google OAuth service, without an OctoSense account. Start with the [service and sample guide](crates/oauth-service/README.md) and [ADR 0010](docs/adr/0010-shared-oauth-and-connected-apps.md). GitHub Notes reuses Rinx’s Markdown editor; Inbox Assistant and Google Calendar are ordinary bundles. **Provider login requires distributor-supplied OAuth registrations.** Existing beta.2 downloads contain none; an operator must supply the private host `oauth/clients.json` override or build with the [registration settings](crates/oauth-service/README.md#configure-a-release-maintainers). Ordinary app users should receive a configured build. Live GitHub and Google sign-in has passed on macOS, and a [macOS test-account Calendar login/save](tools/connected-e2e/evidence/calendar-login-20261007.json) passed on its recorded build; this is not public Google verification. GitHub writes and Gmail sends are still unverified, and Google sign-in on Android still needs its native adapter. +App Hub in this source build defaults to the GitHub-attested catalog and supports +`publisher-github-v1` releases through app-contract 1.8.0. Developers request +publication by opening an [App Hub submission issue](https://github.com/OctoSense-org/OctoSense-App-Hub/issues/new?template=submit-app.yml); +GitHub release proofs replace separate developer signing keys, while a Hub admin +still approves admission. The first catalog fetch needs a network connection; +an old offline catalog is not converted into a GitHub proof. Custom local test +catalogs can explicitly select `OCTOSENSE_HUB_CATALOG=legacy` with a fresh app-data +directory; a library with a v2 cache refuses that downgrade. Beta.2 does not +support this publishing mode; a compatible packaged release is pending. + ## How it fits together One shell process per device, one octos kernel per shell, and every agent is a session in that kernel. The shell is the kernel's only full client. It starts octos and holds its host token, starts every app agent's turns, relays every call to an app's tools, and owns every approval. Apps never talk to the kernel. diff --git a/README.zh-CN.md b/README.zh-CN.md index b1bb613d..6822e07f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -46,6 +46,13 @@ Android 桌面支持从左右边缘滑动翻页;应用和已展开卡片仍使 连接账户的 App Hub 示例共用宿主持有的 GitHub/Google OAuth 服务,无需创建 OctoSense 账户。请从[服务与示例指南](crates/oauth-service/README.zh-CN.md)和 [ADR 0010](docs/adr/0010-shared-oauth-and-connected-apps.zh-CN.md)开始。GitHub Notes 复用 Rinx 的 Markdown 编辑器;Inbox Assistant 和 Google Calendar 都是普通应用包。**服务商登录需要发行方提供 OAuth 客户端注册信息。** 现有 beta.2 下载包未包含这些信息;运维者须提供私有宿主 `oauth/clients.json` 配置,或按[注册设置](crates/oauth-service/README.zh-CN.md#配置发行版本维护者)构建。普通应用用户应拿到已配置的发行包。真实 GitHub 和 Google 账户的登录已在 macOS 上通过,[macOS 测试账户的 Calendar 登录与保存](tools/connected-e2e/evidence/calendar-login-20261007.json)也已在收据记录的构建上通过,但这不代表 Google 已完成公开验证。GitHub 写入和 Gmail 发信仍未验证,Android 上的 Google 登录还需要原生授权适配器。 +此源码构建中的 App Hub 默认使用带 GitHub 证明的目录,并通过应用契约 1.8.0 支持 +`publisher-github-v1` 发布包。开发者通过[提交 App Hub issue](https://github.com/OctoSense-org/OctoSense-App-Hub/issues/new?template=submit-app.yml) +申请发布;GitHub 发布证明取代单独的开发者签名密钥,仍须由 Hub 管理员批准准入。 +首次获取目录需要网络连接,旧的离线目录不会被转换成 GitHub 证明。自定义本地测试 +目录可以在全新的应用数据目录下显式设置 `OCTOSENSE_HUB_CATALOG=legacy`;已有 v2 +缓存的应用库会拒绝这种降级。beta.2 不支持此发布模式,兼容的发行包尚待发布。 + ## 整体如何运作 每台设备一个 Shell 进程,每个 Shell 一个 octos 内核,每个 Agent 都是这个内核中的一个会话。Shell 是内核唯一的完整客户端:它启动 octos 并持有宿主 token,启动每个应用 Agent 的回合,转交每一次对应用工具的调用,并掌管所有审批。应用从不直接与内核通信。 diff --git a/crates/shell/examples/connected-app-host.rs b/crates/shell/examples/connected-app-host.rs index 9c62d447..cdaf2f34 100644 --- a/crates/shell/examples/connected-app-host.rs +++ b/crates/shell/examples/connected-app-host.rs @@ -72,6 +72,8 @@ impl MatchEvent for App { }) .or(installed); let (bundle, policy) = if let Some(id) = installed { + // connected_support only serves isolated legacy test catalogs. + std::env::set_var("OCTOSENSE_HUB_CATALOG", "legacy"); let launch = connected_support::open(&root, &id).expect("Verify installed launch"); let result = (launch.bundle().to_path_buf(), launch.policy.clone()); println!( diff --git a/crates/shell/examples/connected-inbox-e2e.rs b/crates/shell/examples/connected-inbox-e2e.rs index 82458890..240f52e1 100644 --- a/crates/shell/examples/connected-inbox-e2e.rs +++ b/crates/shell/examples/connected-inbox-e2e.rs @@ -43,6 +43,8 @@ fn setup() { ) .expect("Read fixture metadata"); assert_eq!(meta["fixture"], "connected-e2e"); + // The marked fixture uses its own ephemeral legacy catalog authority. + std::env::set_var("OCTOSENSE_HUB_CATALOG", "legacy"); assert!(meta["apps"] .as_array() .unwrap() diff --git a/crates/shell/examples/host-api-lab.rs b/crates/shell/examples/host-api-lab.rs index 5d5dd4dd..3b8c247e 100644 --- a/crates/shell/examples/host-api-lab.rs +++ b/crates/shell/examples/host-api-lab.rs @@ -89,6 +89,8 @@ impl MatchEvent for App { } else { let installed = connected_support::install(&[bundle.clone()], &root).expect("Signed installation"); + // This example creates an ephemeral legacy catalog, not the public Hub. + std::env::set_var("OCTOSENSE_HUB_CATALOG", "legacy"); std::env::set_var( "OCTOSENSE_HUB_ANCHOR", installed["anchor"].as_str().unwrap(), diff --git a/crates/shell/src/app_chat/tests.rs b/crates/shell/src/app_chat/tests.rs index 7fe098e6..f59a2ba0 100644 --- a/crates/shell/src/app_chat/tests.rs +++ b/crates/shell/src/app_chat/tests.rs @@ -501,6 +501,7 @@ impl AgentFixture { .env("OCTOSENSE_CHAT_FIXTURE_CHILD", test) .env("OCTOSENSE_CHAT_FIXTURE_ROOT", &root) .env("OCTOSENSE_HUB_ANCHOR", anchor.public_hex()) + .env("OCTOSENSE_HUB_CATALOG", "legacy") .output(); let _ = std::fs::remove_dir_all(&root); let output = output.expect("start isolated app-chat test"); diff --git a/crates/shell/src/host_tools/admission.rs b/crates/shell/src/host_tools/admission.rs index dfe11eed..95529440 100644 --- a/crates/shell/src/host_tools/admission.rs +++ b/crates/shell/src/host_tools/admission.rs @@ -49,6 +49,17 @@ mod tests { #[test] fn cached_agent_admission_tracks_signed_withdrawal_and_bundle_integrity() { + const CHILD: &str = "OCTOSENSE_TEST_AGENT_ADMISSION"; + if std::env::var_os(CHILD).is_none() { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "host_tools::admission::tests::cached_agent_admission_tracks_signed_withdrawal_and_bundle_integrity", "--nocapture"]) + .env(CHILD, "1") + .env("OCTOSENSE_HUB_CATALOG", "legacy") + .output().unwrap(); + assert!(output.status.success(), "{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); + assert!(String::from_utf8_lossy(&output.stdout).contains("1 passed")); + return; + } let root = std::env::temp_dir().join(format!("agent-withdrawal-{}", uuid::Uuid::new_v4())); let id = "org.example.fixture"; let bundle = octosense_app_hub::installed_bundle_dir(&root, id); @@ -76,13 +87,13 @@ mod tests { write(&mut catalog); let check = || installed_bundle_with_anchor(&root,id,&anchor.public_hex()); assert_eq!(check().unwrap(), bundle); - // A v2 cache selects v2 verification for agents as well as the UI. + // This isolated legacy fixture must stop when a v2 cache appears. // Neither an invalid proof nor a legacy document renamed to v2 may - // fall back to the still-valid, offered legacy release beside it. + // let an agent continue using the offered legacy release beside it. let v2 = root.join("catalog-v2.json"); for bytes in ["{}".to_string(), std::fs::read_to_string(root.join("catalog.json")).unwrap()] { std::fs::write(&v2, bytes).unwrap(); - assert!(check().unwrap_err().contains("catalog refused")); + assert!(check().unwrap_err().contains("downgrade refused")); } std::fs::remove_file(v2).unwrap(); assert_eq!(check().unwrap(), bundle); diff --git a/crates/shell/src/wasm_service.rs b/crates/shell/src/wasm_service.rs index bb277d11..e1c41292 100644 --- a/crates/shell/src/wasm_service.rs +++ b/crates/shell/src/wasm_service.rs @@ -797,7 +797,9 @@ mod tests { if std::env::var_os(CHILD).is_none() { let output = std::process::Command::new(std::env::current_exe().unwrap()) .args(["--exact", "wasm_service::tests::invocation_isolation_revocation_cancellation_and_queue_bounds", "--nocapture"]) - .env(CHILD, "1").output().unwrap(); + .env(CHILD, "1") + .env("OCTOSENSE_HUB_CATALOG", "legacy") + .output().unwrap(); assert!( output.status.success(), "{}\n{}", diff --git a/native-apps.json b/native-apps.json index 100a4b4d..e1658b09 100644 --- a/native-apps.json +++ b/native-apps.json @@ -100,7 +100,7 @@ "id": "apphub", "feature": "app-hub", "crate": "octosense-app-hub-app", - "source": { "git": "https://github.com/OctoSense-org/OctoSense-App-Hub", "rev": "8885c3755bb56f083c4df3e6b539b58287433895", "local": null }, + "source": { "git": "https://github.com/OctoSense-org/OctoSense-App-Hub", "rev": "655114c4943cd2490daaefa2173e7b5aaa20669f", "local": null }, "module": ["octosense_app_hub_app::APP_HUB_MODULE", "octosense_app_hub_app::CARD_MODULE"], "bin": null, "default_features": true, From 8afcf35f03649f31b939b822302c132f18feb86e Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:30:54 -0700 Subject: [PATCH 31/35] Fix Android acceptance catalog and host crypto build setup --- .cargo/config.toml | 4 ++++ crates/wasm-phone-smoke/src/main.rs | 3 +++ tools/fixtures/host-api-lab/ANDROID.md | 2 ++ tools/fixtures/host-api-lab/ANDROID.zh-CN.md | 2 ++ tools/fixtures/wasm-phone-lab/README.md | 2 ++ tools/fixtures/wasm-phone-lab/README.zh-CN.md | 2 ++ 6 files changed, 15 insertions(+) diff --git a/.cargo/config.toml b/.cargo/config.toml index cd4ab8ff..af0212cf 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -27,6 +27,10 @@ OCTOSENSE_SYSTEM_APPS = { value = "desktop/system-apps.json", relative = true } # The locked aws-lc-sys supports its CC builder on Android arm64. Use the # installed NDK Clang directly; no additional CMake installation is needed. AWS_LC_SYS_CMAKE_BUILDER_aarch64_linux_android = "0" +# The Android packager also forces its global CMake setting into host-side +# bundle validation dependencies. On Apple arm64, use the supported CC builder +# there too. This changes only the build driver, not crypto or assembly features. +AWS_LC_SYS_CMAKE_BUILDER_aarch64_apple_darwin = "0" # iOS gives the main thread a 1 MB stack, and the shell's widget tree is # built on it through nested script_apply/script_new calls that overflowed diff --git a/crates/wasm-phone-smoke/src/main.rs b/crates/wasm-phone-smoke/src/main.rs index c64b899e..5ec174fe 100644 --- a/crates/wasm-phone-smoke/src/main.rs +++ b/crates/wasm-phone-smoke/src/main.rs @@ -90,6 +90,9 @@ impl MatchEvent for App { } else { let installed = connected_support::install(&[bundle.clone()], &root).expect("Signed installation"); + // Only this isolated fixture uses the temporary legacy catalog. + // The Wasm worker rechecks it through normal host admission. + std::env::set_var("OCTOSENSE_HUB_CATALOG", "legacy"); std::env::set_var( "OCTOSENSE_HUB_ANCHOR", installed["anchor"].as_str().unwrap(), diff --git a/tools/fixtures/host-api-lab/ANDROID.md b/tools/fixtures/host-api-lab/ANDROID.md index f0af6110..172efe18 100644 --- a/tools/fixtures/host-api-lab/ANDROID.md +++ b/tools/fixtures/host-api-lab/ANDROID.md @@ -15,6 +15,8 @@ cargo build --release --offline --manifest-path .sources/makepad/tools/cargo_mak Set `JAVA_HOME` and `ANDROID_HOME` to existing tool installations. Set `PACKAGER` to that built `cargo-makepad` executable and `MAKEPAD_ANDROID_SDK` to the existing Makepad Android SDK. Build a separate debuggable package; `octosense-host-api-smoke` uses the same Rust source as the desktop `host-api-lab` example: +The workspace Cargo configuration selects the supported AWS-LC CC builder for both Android arm64 and an Apple arm64 build host. The Android packager otherwise forces CMake for host-side bundle validation too. This avoids an extra CMake installation without disabling cryptography or assembly. The equivalent target-scoped host environment override was exercised in the Android build before being added to the configuration. + ```sh MAKEPAD_FORCE_DEBUGGABLE=1 "$PACKAGER" makepad android \ --sdk-path="$MAKEPAD_ANDROID_SDK" --abi=aarch64 \ diff --git a/tools/fixtures/host-api-lab/ANDROID.zh-CN.md b/tools/fixtures/host-api-lab/ANDROID.zh-CN.md index e8a0b928..0d96bdcd 100644 --- a/tools/fixtures/host-api-lab/ANDROID.zh-CN.md +++ b/tools/fixtures/host-api-lab/ANDROID.zh-CN.md @@ -15,6 +15,8 @@ cargo build --release --offline --manifest-path .sources/makepad/tools/cargo_mak 将 `JAVA_HOME`、`ANDROID_HOME` 指向已有工具,将 `PACKAGER` 指向生成的 `cargo-makepad`,将 `MAKEPAD_ANDROID_SDK` 指向已有 Makepad Android SDK。构建独立、可调试的测试包。`octosense-host-api-smoke` 与桌面 `host-api-lab` 示例共用 Rust 源码: +工作区 Cargo 配置为 Android arm64 目标和 Apple arm64 构建主机选择 AWS-LC 支持的 CC 构建器。否则,Android 打包器也会强制宿主侧的测试包验证依赖使用 CMake。此设置避免额外安装 CMake,不禁用密码学或汇编功能。在写入配置前,等效的宿主目标环境变量覆盖已用于实际 Android 构建。 + ```sh MAKEPAD_FORCE_DEBUGGABLE=1 "$PACKAGER" makepad android \ --sdk-path="$MAKEPAD_ANDROID_SDK" --abi=aarch64 \ diff --git a/tools/fixtures/wasm-phone-lab/README.md b/tools/fixtures/wasm-phone-lab/README.md index ad730913..6b7c5ac4 100644 --- a/tools/fixtures/wasm-phone-lab/README.md +++ b/tools/fixtures/wasm-phone-lab/README.md @@ -16,6 +16,8 @@ python3 tools/test-wasm-phone.py --adb "$ADB" --aapt2 "$AAPT2" --hub "$HUB" -- The driver stamps a temporary copy. The native host creates ephemeral signing keys in memory and uses normal signature, digest, capability and tool admission. Keys and provider credentials are not published. Receipts record APK/source/runtime identities and individual results, without device serials or SDK paths. Keep an earlier receipt separate; choose a new package suffix for another install or remove only your previous test package. +This isolated fixture explicitly selects its temporary legacy catalog before the Wasm worker rechecks admission. Normal OctoSense keeps the GitHub catalog default; this test does not establish production catalog publication or keyless App Hub UI acceptance. Apple arm64 Android build hosts use the workspace's target-scoped AWS-LC CC configuration described in the [Android build note](../host-api-lab/ANDROID.md). + `state.wat` is the source of `bundle/fns/state.wasm`. To reproduce the checked-in module: ```sh diff --git a/tools/fixtures/wasm-phone-lab/README.zh-CN.md b/tools/fixtures/wasm-phone-lab/README.zh-CN.md index eab1b806..9bccb8b5 100644 --- a/tools/fixtures/wasm-phone-lab/README.zh-CN.md +++ b/tools/fixtures/wasm-phone-lab/README.zh-CN.md @@ -16,6 +16,8 @@ python3 tools/test-wasm-phone.py --adb "$ADB" --aapt2 "$AAPT2" --hub "$HUB" -- 驱动只给临时副本重新计算摘要。原生宿主在内存中生成临时签名密钥,使用正常的签名、摘要、能力和工具准入检查,不发布密钥或服务商凭据。回执记录 APK、源码及运行时标识和各项结果,不包含设备序列号或 SDK 路径。保留旧回执;重新安装时选择新的包后缀,或只删除自己上一次的测试包。 +这个独立测试会在 Wasm 工作线程重新检查准入前,明确选择其临时旧格式目录。正常 OctoSense 仍默认使用 GitHub 目录;本测试不能证明生产目录发布或免开发者密钥的 App Hub 界面验收。Apple arm64 主机的 Android 构建使用工作区按目标设置的 AWS-LC CC 配置,见 [Android 构建说明](../host-api-lab/ANDROID.zh-CN.md)。 + `state.wat` 是 `bundle/fns/state.wasm` 的源码。重建已提交的模块: ```sh From 5e1a8414cba4c9c3ab041b5555b6d90bcbad03bb Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:48:10 -0700 Subject: [PATCH 32/35] fix: bind release scan fixture exceptions to public source digests --- .github/workflows/release-desktop.yml | 3 + tools/fixtures/release-scan/LICENSE | 201 +++++ tools/fixtures/release-scan/README.md | 9 + tools/fixtures/release-scan/browser.json | 108 +++ tools/fixtures/release-scan/pairing.json | 88 ++ tools/fixtures/release-scan/workspace.card | 61 ++ tools/release-scan-public-assets.json | 886 +++++++++++++++++++++ tools/release-scan.py | 68 +- tools/test_release_scan.py | 62 ++ 9 files changed, 1485 insertions(+), 1 deletion(-) create mode 100644 tools/fixtures/release-scan/LICENSE create mode 100644 tools/fixtures/release-scan/README.md create mode 100644 tools/fixtures/release-scan/browser.json create mode 100644 tools/fixtures/release-scan/pairing.json create mode 100644 tools/fixtures/release-scan/workspace.card create mode 100644 tools/release-scan-public-assets.json diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index f642f8dd..73fcbfc5 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -41,6 +41,9 @@ on: - 'desktop/packaging/**' - 'desktop/scripts/package.py' - 'tools/release-scan.py' + - 'tools/release-scan-public-assets.json' + - 'tools/test_release_scan.py' + - 'tools/fixtures/release-scan/**' - 'tools/kernel-artifact.py' - '.github/workflows/release-desktop.yml' workflow_dispatch: diff --git a/tools/fixtures/release-scan/LICENSE b/tools/fixtures/release-scan/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/tools/fixtures/release-scan/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/tools/fixtures/release-scan/README.md b/tools/fixtures/release-scan/README.md new file mode 100644 index 00000000..eb2fba47 --- /dev/null +++ b/tools/fixtures/release-scan/README.md @@ -0,0 +1,9 @@ +# Public OctosCode release-scan fixtures + +These three unmodified files come from the Apache-2.0 licensed [OctosCode source](https://github.com/octos-org/octoscode-app/tree/5d0c2a0962218aa94497201ce1a39b7429f09e36). They contain synthetic design examples, not release-builder data. + +- `workspace.card`: `design/components/ws-group/page.card` +- `browser.json`: `design/stage-b/phase4/cards/p4-08/reference.ocr.json` +- `pairing.json`: `design/stage-b/phase4/cards/p4-03/reference.ocr.json` + +The complete-asset hashes and individual permitted match offsets are recorded in `tools/release-scan-public-assets.json`. Changing any byte invalidates the exception. diff --git a/tools/fixtures/release-scan/browser.json b/tools/fixtures/release-scan/browser.json new file mode 100644 index 00000000..d41244da --- /dev/null +++ b/tools/fixtures/release-scan/browser.json @@ -0,0 +1,108 @@ +{ + "width": 812.0, + "height": 1552.0, + "observations": [ + { + "bounds": [ + -0.0, + 74.0, + 351.8667, + 41.0465 + ], + "confidence": 1.0, + "text": "8. Choose a folder" + }, + { + "bounds": [ + 112.7778, + 216.5581, + 575.1667, + 51.8837 + ], + "confidence": 1.0, + "text": "Choose workspace folder" + }, + { + "bounds": [ + 31.5778, + 309.0, + 471.4111, + 29.3721 + ], + "confidence": 1.0, + "text": "/ > home \u203a dev \u203a code" + }, + { + "bounds": [ + 164.5911, + 426.1404, + 101.629, + 31.9983 + ], + "confidence": 1.0, + "text": "octos" + }, + { + "bounds": [ + 172.7028, + 530.444, + 250.3675, + 50.9473 + ], + "confidence": 1.0, + "text": "octoscode-app" + }, + { + "bounds": [ + 173.5896, + 649.426, + 94.9097, + 34.3341 + ], + "confidence": 1.0, + "text": "notes" + }, + { + "bounds": [ + 171.3349, + 762.1573, + 126.4857, + 36.7087 + ], + "confidence": 1.0, + "text": "scratch" + }, + { + "bounds": [ + 51.8778, + 866.0, + 356.3778, + 39.0 + ], + "confidence": 1.0, + "text": "3 hidden by the server" + }, + { + "bounds": [ + 71.834, + 1015.8643, + 578.2367, + 49.4239 + ], + "confidence": 1.0, + "text": "/home/user/code/octoscode-app" + }, + { + "bounds": [ + 252.6222, + 1148.0, + 263.9, + 43.0698 + ], + "confidence": 1.0, + "text": "Use this folder" + } + ], + "image_sha256": "2e0b9de79e8cd1e4164b67852c273fcf18e8e11ef75e87a2c0c71e7a245d7597", + "engine": "Apple Vision VNRecognizeTextRequest accurate, en-US (pyobjc)" +} diff --git a/tools/fixtures/release-scan/pairing.json b/tools/fixtures/release-scan/pairing.json new file mode 100644 index 00000000..ca00e618 --- /dev/null +++ b/tools/fixtures/release-scan/pairing.json @@ -0,0 +1,88 @@ +{ + "width": 812.0, + "height": 1552.0, + "observations": [ + { + "bounds": [ + 0.0, + -0.0, + 281.9444, + 41.0 + ], + "confidence": 1.0, + "text": "3. Link problem" + }, + { + "bounds": [ + 216.5333, + 187.0, + 374.4222, + 45.3488 + ], + "confidence": 1.0, + "text": "Pair with Octos" + }, + { + "bounds": [ + 110.5222, + 351.907, + 552.6111, + 38.3488 + ], + "confidence": 1.0, + "text": "This pairing link was already used." + }, + { + "bounds": [ + 110.5222, + 424.0, + 426.3, + 36.186 + ], + "confidence": 1.0, + "text": "Ask Octos for a new code." + }, + { + "bounds": [ + 51.7801, + 579.4133, + 106.2064, + 32.2433 + ], + "confidence": 1.0, + "text": "Server" + }, + { + "bounds": [ + 87.9667, + 676.7442, + 426.3, + 38.3488 + ], + "confidence": 1.0, + "text": "http://192.168.1.20:50190" + }, + { + "bounds": [ + 51.8778, + 809.8372, + 209.7667, + 31.5814 + ], + "confidence": 1.0, + "text": "Access token" + }, + { + "bounds": [ + 320.0208, + 1235.0225, + 142.6362, + 36.1643 + ], + "confidence": 1.0, + "text": "Connect" + } + ], + "image_sha256": "2b5d5f5886d5b1d5342973132341134f9369ea38ff76b13a7843357b2c4a17f7", + "engine": "Apple Vision VNRecognizeTextRequest accurate, en-US (pyobjc)" +} diff --git a/tools/fixtures/release-scan/workspace.card b/tools/fixtures/release-scan/workspace.card new file mode 100644 index 00000000..3410c526 --- /dev/null +++ b/tools/fixtures/release-scan/workspace.card @@ -0,0 +1,61 @@ +# ledger ws-group@1.0.0 +# level: L0 +# profile: ui/l0 +# Static design fixture, runtime native Kit composition. +theme light +copy t_ws0_name_text { class: user-copy, en: "octos" } +copy t_ws0_path_text { class: user-copy, en: "~/home/octos" } +copy t_ws1_name_text { class: user-copy, en: "octoscode-app" } +copy t_ws1_path_text { class: user-copy, en: "~/home/octos/octoscode-app" } +copy t_ws2_name_text { class: user-copy, en: "robrix2" } +copy t_ws2_path_text { class: user-copy, en: "~/home/octos/robrix2" } +copy t_ws3_name_text { class: user-copy, en: "octos-web" } +copy t_ws3_path_text { class: user-copy, en: "~/home/octos/octos-web" } +component Surface7a7bef822439(instance: text) { + view Kit(component: "Surface7a7bef822439", instance: instance) { slot } +} +component Vectore0a378fde04f(instance: text) { + view Kit(component: "Vectore0a378fde04f", instance: instance) +} +component Text404646513d4f(instance: text, text: text) { + view Kit(component: "Text404646513d4f", instance: instance, text: text) +} +component Textfd6a3ee4835f(instance: text, text: text) { + view Kit(component: "Textfd6a3ee4835f", instance: instance, text: text) +} +component Surfacec4c38149242a(instance: text) { + view Kit(component: "Surfacec4c38149242a", instance: instance) { slot } +} +component Texta90b9b77c268(instance: text, text: text) { + view Kit(component: "Texta90b9b77c268", instance: instance, text: text) +} +component Textfd1c766318fb(instance: text, text: text) { + view Kit(component: "Textfd1c766318fb", instance: instance, text: text) +} +component Text2cf7aad1d558(instance: text, text: text) { + view Kit(component: "Text2cf7aad1d558", instance: instance, text: text) +} + +view root Surface7a7bef822439(instance: "recent_group") { + Vectore0a378fde04f(instance: "ws_icon0") + Text404646513d4f(instance: "t_ws0_name", text: copy.t_ws0_name_text) + Textfd6a3ee4835f(instance: "t_ws0_path", text: copy.t_ws0_path_text) + Surfacec4c38149242a(instance: "hair_ws1") { + + } + Vectore0a378fde04f(instance: "ws_icon1") + Texta90b9b77c268(instance: "t_ws1_name", text: copy.t_ws1_name_text) + Textfd1c766318fb(instance: "t_ws1_path", text: copy.t_ws1_path_text) + Surfacec4c38149242a(instance: "hair_ws2") { + + } + Vectore0a378fde04f(instance: "ws_icon2") + Text404646513d4f(instance: "t_ws2_name", text: copy.t_ws2_name_text) + Text2cf7aad1d558(instance: "t_ws2_path", text: copy.t_ws2_path_text) + Surfacec4c38149242a(instance: "hair_ws3") { + + } + Vectore0a378fde04f(instance: "ws_icon3") + Text404646513d4f(instance: "t_ws3_name", text: copy.t_ws3_name_text) + Text2cf7aad1d558(instance: "t_ws3_path", text: copy.t_ws3_path_text) +} diff --git a/tools/release-scan-public-assets.json b/tools/release-scan-public-assets.json new file mode 100644 index 00000000..45e84ca3 --- /dev/null +++ b/tools/release-scan-public-assets.json @@ -0,0 +1,886 @@ +{ + "repository": "https://github.com/octos-org/octoscode-app", + "revision": "5d0c2a0962218aa94497201ce1a39b7429f09e36", + "reason": "Synthetic UI example data embedded unchanged by crates/octoscode-module/build.rs; only the listed base-pattern spans in complete SHA-256-matching assets are admitted.", + "assets": [ + { + "path": "design/components/ws-group/page.card", + "size": 2616, + "sha256": "39607a6c88904ed7d22cb6a1364242608473a0c53af4e21b03a6da9854ce7ead", + "matches": [ + [ + 349, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 482, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 611, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ] + ] + }, + { + "path": "design/components/ws-group/page.design.splash", + "size": 2781, + "sha256": "43ea6e4dc89f927394f2f95a7c7ead497b5d6f1b5f967c02d7646e6a8cf1db24", + "matches": [ + [ + 1147, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 1829, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 2520, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ] + ] + }, + { + "path": "design/stage-b/phase4-new3/cards/phase4n3-02/contract.json", + "size": 17603, + "sha256": "3199436345d91dca6f653ae7de0d823de49ab5e35e0efe0c0f6d3fd419fc9d4c", + "matches": [ + [ + 3832, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 5006, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 7542, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 10734, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4-new3/cards/phase4n3-02/mapped.json", + "size": 12959, + "sha256": "10fb4d8df9f2676d646d1c4b6470006ec43f5a7e84b811fc3ce81ed68b5de263", + "matches": [ + [ + 2289, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 3208, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 5155, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 7606, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4-new3/cards/phase4n3-02/mapping.json", + "size": 17851, + "sha256": "93b6e4cd783dd0ff3ffaa51ed3f0a5afc5705a6d9705124d2cf9d4aadc2944fe", + "matches": [ + [ + 3696, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 4900, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 7231, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 10335, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4-new3/cards/phase4n3-02/observations.json", + "size": 15777, + "sha256": "0d001dfe3ecc4eab3d8c016d1ca4a113a2a615dbbb7347f4fcdd1f0b832e6213", + "matches": [ + [ + 1811, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 1963, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 2714, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 4442, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 4594, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 6376, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 15145, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4-new3/cards/phase4n3-02/page.card", + "size": 7719, + "sha256": "4660042e49774355c84ae46c56c98e952914d52c1f6a49c6909fd3d40fabc409", + "matches": [ + [ + 356, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 516, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 766, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 1065, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4-new3/cards/phase4n3-02/page.design.splash", + "size": 6433, + "sha256": "e85a357463209b73e534a53a93ae49782eec0cd19c269ada716a09c8ea5619c1", + "matches": [ + [ + 1099, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 1610, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 2638, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 3921, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4-new3/cards/phase4n3-02/reference.ocr.json", + "size": 3826, + "sha256": "bf3cc2671ca98616c7686e81266fb48bed3ce752767692c375300ebad55587c8", + "matches": [ + [ + 1013, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 1801, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 2787, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4-new3/cards/phase4n3-03/contract.json", + "size": 15061, + "sha256": "8f962eb158cf6528c384bed4d9704c52e8d92df1835bce3671df4131fe55c1e1", + "matches": [ + [ + 7588, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4-new3/cards/phase4n3-03/mapped.json", + "size": 11026, + "sha256": "db57f31239f5fba355bf53e714fd98c54208a63dee48ea35ba1c4334c921662e", + "matches": [ + [ + 5041, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4-new3/cards/phase4n3-03/mapping.json", + "size": 15538, + "sha256": "fe9946c8af443206e1a56e69fb9748d8aa785bf2fce3095526fca089b2783974", + "matches": [ + [ + 7891, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4-new3/cards/phase4n3-03/observations.json", + "size": 13693, + "sha256": "ade896ad2737365cf64916be0b4d312e0c7502ac36170d1772f7012533f59760", + "matches": [ + [ + 3496, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 3648, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4-new3/cards/phase4n3-03/page.card", + "size": 6837, + "sha256": "130e2c215a8a18f21b0641eaf5b6ca483e8c6a99cd384f674dad51977c88ff3f", + "matches": [ + [ + 576, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4-new3/cards/phase4n3-03/page.design.splash", + "size": 5440, + "sha256": "d6b032c5769139ae13fbcf1a79b5eb26d560f16efd84001ec0ec14f9ddffe4c9", + "matches": [ + [ + 2374, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4-new3/cards/phase4n3-03/reference.ocr.json", + "size": 3713, + "sha256": "5b56ebd8cd7fcf4d507eed14ec4c0cbf356b083dec755350d2b41946249268b1", + "matches": [ + [ + 616, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4-new3/cards/phase4n3-05/contract.json", + "size": 20573, + "sha256": "05586acd34df821cd2247d5aee57a0033b2fac6bb410b5064ba94e234764cff1", + "matches": [ + [ + 2557, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4-new3/cards/phase4n3-05/mapping.json", + "size": 18784, + "sha256": "16d936ab4e42a9be133c667770eeb1165800d4747c213bc9bd446535f4fef453", + "matches": [ + [ + 2165, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4-new3/cards/phase4n3-05/observations.json", + "size": 20072, + "sha256": "beb95bc0dd3ad4435c3b8fe381fdffe6094ebd741f60d6168764a3620b6bd185", + "matches": [ + [ + 1542, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 1706, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4-new3/cards/phase4n3-05/page.card", + "size": 8083, + "sha256": "739b2396d2f8d73a43b5882e9cd75a2a7682edca2797d0ba308765a072c8eafd", + "matches": [ + [ + 275, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4-new3/cards/phase4n3-05/page.design.splash", + "size": 7430, + "sha256": "213a49da2cb8664b868827d537154ad13e95b04922e568202c16fedd548503e2", + "matches": [ + [ + 660, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4-new3/cards/phase4n3-05/reference.ocr.json", + "size": 4808, + "sha256": "1a247ed39ac0ebd2f919a7512462d7ab2b3f902d284259e766c6f5ecb4f4a3f4", + "matches": [ + [ + 608, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4/cards/p4-03/contract.json", + "size": 6285, + "sha256": "44a306a3d05621885db632fccd9593fa8fa52ebe95bf59e85a05e42e697c6222", + "matches": [ + [ + 3076, + "private IPv4 address", + "552354592821b7e6b76f87d10c0cb4ff512ffed049dde0e40769a0bcf9f50c8d" + ] + ] + }, + { + "path": "design/stage-b/phase4/cards/p4-03/mapped.json", + "size": 9164, + "sha256": "49afb14ee34af8ae83517831f0709dd2bd0039816ac316d242c09a845c10d413", + "matches": [ + [ + 3287, + "private IPv4 address", + "552354592821b7e6b76f87d10c0cb4ff512ffed049dde0e40769a0bcf9f50c8d" + ] + ] + }, + { + "path": "design/stage-b/phase4/cards/p4-03/mapping.json", + "size": 7366, + "sha256": "d3722dc041edd293af1a0b2d5f00beb22ee137aa6bc4a491cb78b020498c6731", + "matches": [ + [ + 3583, + "private IPv4 address", + "552354592821b7e6b76f87d10c0cb4ff512ffed049dde0e40769a0bcf9f50c8d" + ] + ] + }, + { + "path": "design/stage-b/phase4/cards/p4-03/observations.json", + "size": 6869, + "sha256": "a8075ee54ae49a2ec6bfdd6bff32314f2aa803bcd2236c8e8904997f8d8142af", + "matches": [ + [ + 6669, + "private IPv4 address", + "552354592821b7e6b76f87d10c0cb4ff512ffed049dde0e40769a0bcf9f50c8d" + ] + ] + }, + { + "path": "design/stage-b/phase4/cards/p4-03/page.card", + "size": 4456, + "sha256": "da5aca2cd16d05388a62d8c13d62e1a69aca4897351e4ddf88089c5dcc370310", + "matches": [ + [ + 447, + "private IPv4 address", + "552354592821b7e6b76f87d10c0cb4ff512ffed049dde0e40769a0bcf9f50c8d" + ] + ] + }, + { + "path": "design/stage-b/phase4/cards/p4-03/page.design.splash", + "size": 3428, + "sha256": "c975010bb55747e2c0db1169621ca30b3c7ed72403389b0afb98ee227e45f5e0", + "matches": [ + [ + 1736, + "private IPv4 address", + "552354592821b7e6b76f87d10c0cb4ff512ffed049dde0e40769a0bcf9f50c8d" + ] + ] + }, + { + "path": "design/stage-b/phase4/cards/p4-03/reference.ocr.json", + "size": 1555, + "sha256": "ad239e092161c59b2f38dbe0d2131e5809d5df4bc3cabac4cfcd337b4d58f720", + "matches": [ + [ + 1038, + "private IPv4 address", + "552354592821b7e6b76f87d10c0cb4ff512ffed049dde0e40769a0bcf9f50c8d" + ] + ] + }, + { + "path": "design/stage-b/phase4/cards/p4-05/contract.json", + "size": 5361, + "sha256": "c77b9ed20ba97f7a8c5488363b359c8f9d9a0b6337ff301505d12495df0cd9cc", + "matches": [ + [ + 2571, + "private IPv4 address", + "552354592821b7e6b76f87d10c0cb4ff512ffed049dde0e40769a0bcf9f50c8d" + ] + ] + }, + { + "path": "design/stage-b/phase4/cards/p4-05/mapped.json", + "size": 8345, + "sha256": "8a482af17c38d43717db3396b047d17a9e05b0b82b6226acfc78b66fe37bf51a", + "matches": [ + [ + 2475, + "private IPv4 address", + "552354592821b7e6b76f87d10c0cb4ff512ffed049dde0e40769a0bcf9f50c8d" + ] + ] + }, + { + "path": "design/stage-b/phase4/cards/p4-05/mapping.json", + "size": 6852, + "sha256": "23d07c3c93c5546f8bd6a5c09939423334ad21d184d23fd62229d568594985df", + "matches": [ + [ + 3373, + "private IPv4 address", + "552354592821b7e6b76f87d10c0cb4ff512ffed049dde0e40769a0bcf9f50c8d" + ] + ] + }, + { + "path": "design/stage-b/phase4/cards/p4-05/observations.json", + "size": 6839, + "sha256": "5529a8ae98208a0d64c7085331d78a924f87dcaa6d3e11e0a59ea740a0de1b22", + "matches": [ + [ + 1397, + "private IPv4 address", + "552354592821b7e6b76f87d10c0cb4ff512ffed049dde0e40769a0bcf9f50c8d" + ], + [ + 1563, + "private IPv4 address", + "552354592821b7e6b76f87d10c0cb4ff512ffed049dde0e40769a0bcf9f50c8d" + ] + ] + }, + { + "path": "design/stage-b/phase4/cards/p4-05/page.card", + "size": 3565, + "sha256": "d219f933522ec67da181063c5d2ec83f8d2c6e4ad9a8c6c8e3fd02d1a34adac7", + "matches": [ + [ + 272, + "private IPv4 address", + "552354592821b7e6b76f87d10c0cb4ff512ffed049dde0e40769a0bcf9f50c8d" + ] + ] + }, + { + "path": "design/stage-b/phase4/cards/p4-05/page.design.splash", + "size": 3236, + "sha256": "2e46f580bf7af726f07550ab2c5e1d1d86461856878d8534bc068c62d101a50d", + "matches": [ + [ + 1269, + "private IPv4 address", + "552354592821b7e6b76f87d10c0cb4ff512ffed049dde0e40769a0bcf9f50c8d" + ] + ] + }, + { + "path": "design/stage-b/phase4/cards/p4-05/reference.ocr.json", + "size": 1683, + "sha256": "53e83db51c4ba80d88d9f473cbb315b7cd4f767e02f396708d7afb7a577fc9e7", + "matches": [ + [ + 831, + "private IPv4 address", + "552354592821b7e6b76f87d10c0cb4ff512ffed049dde0e40769a0bcf9f50c8d" + ] + ] + }, + { + "path": "design/stage-b/phase4/cards/p4-08/contract.json", + "size": 7972, + "sha256": "11999891ee368e1bb7e6c08f6224b15587f2c7dd49a341be86204add7ddb79cf", + "matches": [ + [ + 6205, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4/cards/p4-08/mapped.json", + "size": 12150, + "sha256": "87a42d5730a0fa09e7cdc46df6d8fb3e08531966b8a50e19dcba087e16f44635", + "matches": [ + [ + 6848, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4/cards/p4-08/mapping.json", + "size": 11016, + "sha256": "79256ffb82772e2cf4631dde93c64206c2ba9d80b8b0ff6c28e966ac2a58ac0a", + "matches": [ + [ + 8831, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4/cards/p4-08/observations.json", + "size": 9862, + "sha256": "95eb3557b86aa24c1a3f5f3feb0ea0e11d2b00ed6c504681c5aeaad97bf45f76", + "matches": [ + [ + 9651, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4/cards/p4-08/page.card", + "size": 5594, + "sha256": "675bf7a590f115108df2ef010a199e25d2e89d9472c954d0495d05a4585f47d4", + "matches": [ + [ + 598, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4/cards/p4-08/page.design.splash", + "size": 4648, + "sha256": "483a6c27555fc509b4fbdc5b1ecbf84698f60596885497d3914ebdcc7d563957", + "matches": [ + [ + 3719, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4/cards/p4-08/reference.ocr.json", + "size": 1886, + "sha256": "9dc7d7ef00faa120c3741b3f7916a3e23bebc03f460c7d4ad50632765d44a136", + "matches": [ + [ + 1519, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/setup/cards/setup-04/contract.json", + "size": 12227, + "sha256": "b4196d341a4b2bb73406280d25d36b91cfe16eb9315e02494ec2a7de18d233e2", + "matches": [ + [ + 6277, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 7879, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 9479, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ] + ] + }, + { + "path": "design/stage-b/setup/cards/setup-04/mapped.json", + "size": 16006, + "sha256": "3a2e751c38a509645f0e4b224237fe38d33031e3dc17836ae10417c7011a1e8c", + "matches": [ + [ + 5852, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 7454, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 9055, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ] + ] + }, + { + "path": "design/stage-b/setup/cards/setup-04/mapping.json", + "size": 12224, + "sha256": "ae48dd4bd3363f95ae49d027c85fe2c055f691f389684f11382dc91b86303d76", + "matches": [ + [ + 6515, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 8097, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 9680, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ] + ] + }, + { + "path": "design/stage-b/setup/cards/setup-04/observations.json", + "size": 12690, + "sha256": "6cca71508bd0b1974c657627fb820546dbafd0b89d2872ba701b58cc1cc1278c", + "matches": [ + [ + 3916, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 4087, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 5161, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 5327, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 6395, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 6563, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ] + ] + }, + { + "path": "design/stage-b/setup/cards/setup-04/page.card", + "size": 6300, + "sha256": "08ce2175447c7605a3874a03dc8e69d40841e661e51b60b90b2309a8bd050609", + "matches": [ + [ + 651, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 784, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 913, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ] + ] + }, + { + "path": "design/stage-b/setup/cards/setup-04/page.design.splash", + "size": 4899, + "sha256": "3d33cba900cc78da0cc69f8e04c2f975c3a1ad202d102f45c9be333fe8cf5704", + "matches": [ + [ + 2441, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 3096, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 3750, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ] + ] + }, + { + "path": "design/stage-b/setup/cards/setup-04/reference.ocr.json", + "size": 3049, + "sha256": "148d645e5e1ec2f473b40bb49ccb08c12d987252d90b9c95169ef6396c04b6b7", + "matches": [ + [ + 1623, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 2031, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 2439, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ] + ] + }, + { + "path": "design/stage-b/setup/pipeline-output/service-cards/setup/ws-group/page.card", + "size": 2616, + "sha256": "39607a6c88904ed7d22cb6a1364242608473a0c53af4e21b03a6da9854ce7ead", + "matches": [ + [ + 349, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 482, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 611, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ] + ] + }, + { + "path": "design/stage-b/setup/pipeline-output/service-cards/setup/ws-group/page.design.splash", + "size": 2781, + "sha256": "43ea6e4dc89f927394f2f95a7c7ead497b5d6f1b5f967c02d7646e6a8cf1db24", + "matches": [ + [ + 1147, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 1829, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 2520, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ] + ] + } + ] +} diff --git a/tools/release-scan.py b/tools/release-scan.py index d04d33e5..3dee5bff 100644 --- a/tools/release-scan.py +++ b/tools/release-scan.py @@ -39,11 +39,14 @@ build paths (see desktop/scripts/package.py). The only exceptions are known `.local` constants, exact names: the product's own (PRODUCT_LOCAL_NAMES) and its dependencies' (DEPENDENCY_LOCAL_NAMES), plus the independently verified -public-source seams in RINX_SOURCE_SEAM and MAIL_LITERAL_SEAM below. +public-source seams below and individual synthetic design-example spans +inside complete assets whose SHA-256 matches release-scan-public-assets.json. """ import argparse import getpass +import hashlib import io +import json import os from pathlib import Path import re @@ -54,6 +57,7 @@ import tarfile import tempfile import zipfile +from functools import lru_cache GENERIC_ACCOUNTS = {"runner", "runneradmin", "root", "admin", "administrator", "user", "builder", "build", "vagrant", "ubuntu", "ec2-user", "github", "ci", "localhost"} @@ -96,6 +100,18 @@ rb"Mail service is not (?Pregisteredattemptssendoctosense\.local)" ) +# Public OctosCode 5d0c2a0 UI examples pooled by the macOS linker. chrome.rs +# seed_board2() supplies the two demo workspace paths (2936/2939), next to +# its theme names (2568-2577). screens/browser.rs supplies the path field +# placeholder (733/780), button text and ID (736-738). Only the /home/user/ +# match spans inside these complete proven sequences qualify, never those +# paths standing alone or a different field's private value. +OCTOSCODE_PLACEHOLDER_SEAMS = ( + re.compile(rb"(?P/home/user/)src/octoscode-app" + rb"(?P/home/user/)src/octosSystemSolarizedSlateClaudeCodexLight"), + re.compile(rb"(?P/home/user/)codeUse this folderb1_br_use"), +) + BASE_PATTERNS = [ ("macOS user directory", rb"/Users/[^/\s\x00\"']+"), ("Windows user directory", rb"[A-Za-z]:[\\/]{1,2}Users[\\/]{1,2}(?!runneradmin[\\/])[^\\/\s\x00\"']+"), @@ -144,7 +160,49 @@ def mask(match): return text if len(text) <= 4 else text[:3] + "*" * min(len(text) - 3, 12) + f" ({len(text)} chars)" +@lru_cache(maxsize=1) +def public_asset_matches(): + """Reviewed public examples, indexed by rule and matched bytes' digest. + + This is deliberately not a path/address allowlist. The whole containing + asset must match, and only the recorded offsets for a base rule qualify. + Identity and --extra rules never consult this table. Missing or malformed + metadata is an error, not permission to skip scanning. + """ + manifest = json.loads(Path(__file__).with_name("release-scan-public-assets.json").read_text()) + index = {} + for asset in manifest["assets"]: + size, digest = asset["size"], asset["sha256"] + if not isinstance(size, int) or not 0 < size <= 1_000_000 or not re.fullmatch(r"[0-9a-f]{64}", digest): + raise ValueError("invalid reviewed public asset") + for offset, label, match_digest in asset["matches"]: + if (not isinstance(offset, int) or not 0 <= offset < size + or label not in ("Linux home directory", "private IPv4 address") + or not re.fullmatch(r"[0-9a-f]{64}", match_digest)): + raise ValueError("invalid reviewed public asset match") + index.setdefault((label, match_digest), []).append((offset, size, digest)) + return index + + +def is_public_asset_match(data, label, match, verified): + if label not in ("Linux home directory", "private IPv4 address"): + return False + key = (label, hashlib.sha256(match.group(0)).hexdigest()) + for offset, size, digest in public_asset_matches().get(key, ()): + start = match.start() - offset + end = start + size + if start < 0 or end > len(data) or match.end() > end: + continue + candidate = (start, end, digest) + if candidate not in verified: + verified[candidate] = hashlib.sha256(memoryview(data)[start:end]).hexdigest() == digest + if verified[candidate]: + return True + return False + + def scan_bytes(data, where, patterns, findings): + verified_assets = {} for label, regex in patterns: source_seams = set() if label == "Linux home directory": @@ -152,12 +210,20 @@ def scan_bytes(data, where, patterns, findings): (seam.start("next") - len(b"/home/main_desktop_ui.rs"), seam.start("next") + 1) for seam in RINX_SOURCE_SEAM.finditer(data) } + source_seams.update( + seam.span(group) + for pattern in OCTOSCODE_PLACEHOLDER_SEAMS + for seam in pattern.finditer(data) + for group in seam.groupdict() + ) elif label == "mDNS .local host name": source_seams = {seam.span("host") for seam in MAIL_LITERAL_SEAM.finditer(data)} seen = set() for m in regex.finditer(data): if (m.start(), m.end()) in source_seams: continue + if is_public_asset_match(data, label, m, verified_assets): + continue if m.group(0) in seen: continue seen.add(m.group(0)) diff --git a/tools/test_release_scan.py b/tools/test_release_scan.py index e8182f94..bc8bb290 100644 --- a/tools/test_release_scan.py +++ b/tools/test_release_scan.py @@ -6,6 +6,7 @@ import tarfile import tempfile import unittest +from unittest import mock import zipfile ROOT = Path(__file__).resolve().parents[1] @@ -21,6 +22,67 @@ def findings(data, extra=()): class PatternTests(unittest.TestCase): + def test_only_proven_octoscode_placeholder_seams_are_ignored(self): + themes = b"/home/user/src/octoscode-app/home/user/src/octosSystemSolarizedSlateClaudeCodexLight" + folder = b"/home/user/codeUse this folderb1_br_use" + for known in (themes, folder): + self.assertEqual(findings(known), []) + for leak in (known + b"\x00/home/user/private", b"/home/user/private\x00" + known, + known.replace(b"/home/user/", b"/home/someone/", 1), + known[:-1] + b"!"): + self.assertTrue(findings(leak), leak) + self.assertTrue(findings(known, extra=[r"/home/user/"])) + for standalone in (b"/home/user/src/octoscode-app", b"/home/user/src/octos", b"/home/user/code"): + self.assertTrue(findings(standalone), standalone) + + def test_exact_public_design_assets_are_not_private_build_data(self): + fixture = ROOT / "tools/fixtures/release-scan" + for name in ("workspace.card", "browser.json", "pairing.json"): + data = (fixture / name).read_bytes() + self.assertEqual(findings(data), [], name) + self.assertEqual(findings(b"binary prefix\x00" + data + b"\x00binary suffix"), [], name) + + def test_public_asset_exceptions_are_whole_asset_and_match_scoped(self): + fixture = ROOT / "tools/fixtures/release-scan" + for name in ("workspace.card", "browser.json", "pairing.json"): + data = (fixture / name).read_bytes() + matches = [m.group(0) for label, regex in scan.compile_patterns(identity=False) + for m in regex.finditer(data) + if label in ("Linux home directory", "private IPv4 address")] + self.assertTrue(matches, name) + for literal in matches: + # An identical private-looking value outside the verified asset + # remains a finding, regardless of which occurrence comes first. + for combined in (literal + b"private\x00" + data, + data + b"\x00" + literal + b"private"): + self.assertTrue(findings(combined), name) + self.assertTrue(findings(literal + b"private"), name) + self.assertTrue(findings(literal), "a standalone identical literal must fail: " + name) + self.assertTrue(findings(b"!" + data[1:]), "one altered byte must invalidate " + name) + self.assertTrue(findings(data[:-1]), "truncation must invalidate " + name) + self.assertTrue(findings(data, extra=[scan.re.escape(matches[0].decode())]), name) + identity = [("the scanning account's name", scan.re.compile(scan.re.escape(matches[0])))] + out = [] + scan.scan_bytes(data, "public", identity, out) + self.assertTrue(out, "public assets must never suppress identity checks") + + def test_missing_or_malformed_public_asset_metadata_fails_closed(self): + with tempfile.TemporaryDirectory() as directory: + script = Path(directory) / "release-scan.py" + metadata = script.with_name("release-scan-public-assets.json") + with mock.patch.object(scan, "__file__", str(script)): + try: + scan.public_asset_matches.cache_clear() + with self.assertRaises(FileNotFoundError): + findings(b"/home/user/private") + for invalid in ("{", '{"assets":[{"size":1,"sha256":"invalid","matches":[]}]}'): + metadata.write_text(invalid) + scan.public_asset_matches.cache_clear() + with self.assertRaises(ValueError): + findings(b"/home/user/private") + finally: + scan.public_asset_matches.cache_clear() + def test_private_paths_and_hosts_are_found(self): for leak in (b"/Users/someone/src/app.rs", b"C:\\Users\\someone\\.cargo", b"c:/Users/someone/x", "C:\\Users\\".encode("utf-16-le"), b"/home/someone/.cargo/registry", b"built on studio.local", b"my-mac.local", b"http://studio.local/api", From db2507512b2534888c11164f994643db55fed318 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 8 Oct 2026 01:38:11 -0700 Subject: [PATCH 33/35] Clarify separate macOS and Android host API acceptance scopes --- tools/fixtures/host-api-lab/README.md | 2 +- tools/fixtures/host-api-lab/README.zh-CN.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/fixtures/host-api-lab/README.md b/tools/fixtures/host-api-lab/README.md index 98c91781..fb407e50 100644 --- a/tools/fixtures/host-api-lab/README.md +++ b/tools/fixtures/host-api-lab/README.md @@ -52,7 +52,7 @@ From a host callback, which keeps the tool's background provenance, the tool del **Verified:** the `native-host-api` job of `.github/workflows/desktop.yml` ran these commands on a GitHub `macos-14` runner for the change that added this fixture, adding `--output` for its evidence directory, and they passed. -**Not covered:** real model reasoning, approving a permission with a physical press, camera capture, Android, the unsupported-platform answers on Linux and Windows, and publishing a compatible host binary. Separate runtime regression tests on the real Splash VM cover detached timers, paused tasks, HTTP and WebSocket callbacks, and the gates in the native device helpers; this fixture covers chained host callbacks. +**Not covered by this macOS run:** real model reasoning, approving a permission with a physical press, camera capture, Android, the unsupported-platform answers on Linux and Windows, and publishing a compatible host binary. Android has its [separate OnePlus 6 acceptance record](ANDROID.md). Separate runtime regression tests on the real Splash VM cover detached timers, paused tasks, HTTP and WebSocket callbacks, and the gates in the native device helpers; this fixture covers chained host callbacks. ## Reuse the pattern diff --git a/tools/fixtures/host-api-lab/README.zh-CN.md b/tools/fixtures/host-api-lab/README.zh-CN.md index 85dcb604..036f502d 100644 --- a/tools/fixtures/host-api-lab/README.zh-CN.md +++ b/tools/fixtures/host-api-lab/README.zh-CN.md @@ -52,7 +52,7 @@ python3 tools/test-host-api-native.py --hub target/debug/hub **已验证**:`.github/workflows/desktop.yml` 的 `native-host-api` 任务在 GitHub `macos-14` 运行器上,为添加本测试示例的改动运行了上述命令(另加 `--output` 指定证据目录),全部通过。 -**未覆盖**:真实的模型推理、亲手点按批准权限、摄像头拍摄、Android、Linux 和 Windows 上“不支持该平台”的应答,以及发布兼容的宿主二进制文件。另有在真实的 Splash VM 上运行的运行时回归测试,覆盖分离的定时器、暂停的任务、HTTP 和 WebSocket 回调,以及原生设备辅助函数中的检查;本测试示例覆盖的是链式宿主回调。 +**本次 macOS 运行未覆盖**:真实的模型推理、亲手点按批准权限、摄像头拍摄、Android、Linux 和 Windows 上“不支持该平台”的应答,以及发布兼容的宿主二进制文件。Android 的结果见[单独的 OnePlus 6 验收记录](ANDROID.zh-CN.md)。另有在真实的 Splash VM 上运行的运行时回归测试,覆盖分离的定时器、暂停的任务、HTTP 和 WebSocket 回调,以及原生设备辅助函数中的检查;本测试示例覆盖的是链式宿主回调。 ## 复用这一模式 From 72d4d4cf440f2860b21a599c5166519abff7c7ae Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 8 Oct 2026 01:45:59 -0700 Subject: [PATCH 34/35] Scan AppImages without executing release artifacts --- .github/workflows/release-desktop.yml | 10 +- desktop/README.md | 1 + desktop/README.zh-CN.md | 1 + desktop/scripts/package.py | 2 +- desktop/scripts/test_package.py | 4 +- tools/release-scan.py | 95 +++++++++++++----- tools/test_ci_local.py | 14 +++ tools/test_release_scan.py | 136 +++++++++++++++++++++----- 8 files changed, 206 insertions(+), 57 deletions(-) diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 73fcbfc5..c66cdf6f 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -175,7 +175,7 @@ jobs: sudo apt-get install -y --no-install-recommends pkg-config libssl-dev libx11-dev libxcursor-dev \ libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev libgl1-mesa-dev libegl1-mesa-dev libgles2-mesa-dev \ libasound2-dev libpulse-dev libdbus-1-dev libudev-dev libdrm-dev libgbm-dev libva-dev dpkg-dev file \ - desktop-file-utils libfuse2 + desktop-file-utils libfuse2 squashfs-tools - name: Install cargo-packager run: cargo install cargo-packager --locked --version 0.11.8 - name: Neutral build paths @@ -408,7 +408,6 @@ jobs: contents: write env: TAG: ${{ needs.plan.outputs.tag }} - GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 @@ -418,6 +417,7 @@ jobs: persist-credentials: false - name: HEAD is the tag's commit env: + GH_TOKEN: ${{ github.token }} WANT: ${{ needs.plan.outputs.commit }} run: | head=$(git rev-parse HEAD) @@ -444,6 +444,10 @@ jobs: cd packages sha256sum -- * | sort -k2 > SHA256SUMS cat SHA256SUMS + - name: Install the non-executing AppImage extractor + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends squashfs-tools - name: Scan again what is attached env: RELEASE_SCAN_EXTRA: ${{ secrets.RELEASE_SCAN_EXTRA }} @@ -452,6 +456,8 @@ jobs: # before signing, and the same app is in the .app.zip scanned now. python3 tools/release-scan.py --dmg-bytes-only packages/* - name: Attach to the tag's draft release + env: + GH_TOKEN: ${{ github.token }} run: | version="${TAG#desktop-v}" if gh release view "$TAG" > /dev/null 2>&1; then diff --git a/desktop/README.md b/desktop/README.md index 267d925d..2b98c274 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -187,6 +187,7 @@ What a package contains and how it is found at run time: - **System apps** (News, Photos, Maps, Camera, Mail, AI providers) are already in the binary: App Hub packs the bundles `system-apps.json` selects at build time. Nothing else is read from `apps/` or `desktop/config/`. - **The octos kernel.** The script builds octos at the revision `Cargo.lock` pins with `tools/kernel-artifact.py --host`'s steps and stages it with its `stage` (which checks the binary's `--version`). It ships as `octos-kernel` beside the executable (`Contents/MacOS/` in the app), with its receipt `octos-kernel.json` among the resources; the kernel service runs it only when the receipt names the pinned revision and the binary's SHA-256 ([Build and run](#build-and-run)). `--no-kernel` ships none, and the app then runs without an assistant. `target/octosense-package/receipt.json` records the version, resource crates and the kernel's receipt. - **Private-path-free.** Paths in the binaries are remapped (`--remap-path-prefix` for the home directory, `CARGO_HOME` and the checkout) and debug info stripped (symbol names stay, for readable backtraces). Crates also embed their source directories as plain strings, which remapping does not reach, so build from a directory outside any user's home, with `CARGO_HOME` outside it too (the release workflow does). `tools/release-scan.py` fails on `/Users/…`, `C:\Users\…` and homes other than a CI runner's (`/home/runner`, `C:\Users\runneradmin`), `*.local` hosts, private IPv4 addresses, the scanning account's and host's names and any `RELEASE_SCAN_EXTRA` pattern, inside the `.app`, `.dmg`, `.deb`, `.AppImage`, `.zip` and NSIS installers. +- **Package inspection does not run the AppImage.** The scanner reads its type-2 ELF boundary and extracts its filesystem with the host's `unsquashfs` (`squashfs-tools`, installed by the Linux build and release jobs). Missing tools, malformed headers and extraction failures stop publication. The GitHub publication token is scoped to the tag-verification and upload steps; it is absent during artifact inspection. Packaging also removes the inline `APPLE_API_KEY_P8` signing credential from build subprocesses. - **Identity.** Product name **OctoSense**, identifier `org.octosense.desktop` (`desktop/packaging/release.json`), icon from `desktop/packaging/icons/` (`make_icons.py` renders it). Android keeps `dev.makepad.octosense`. ### Cutting a desktop release diff --git a/desktop/README.zh-CN.md b/desktop/README.zh-CN.md index 8df0dbc5..7427de66 100644 --- a/desktop/README.zh-CN.md +++ b/desktop/README.zh-CN.md @@ -184,6 +184,7 @@ python3 tools/release-scan.py target/octosense-package/dist/* # refuse private - **系统应用**(新闻、相册、地图、相机、邮件、AI 提供商)已在二进制中:App Hub 在构建时打包 `system-apps.json` 选中的应用包。运行时不再读取 `apps/` 或 `desktop/config/`。 - **octos 内核。** 脚本按 `tools/kernel-artifact.py --host` 的步骤构建 `Cargo.lock` 固定版本的 octos,并用其 `stage` 放置(会检查二进制的 `--version`)。内核以 `octos-kernel` 放在可执行文件旁(应用中为 `Contents/MacOS/`),收据 `octos-kernel.json` 随资源一起;只有收据中的版本与固定版本一致且二进制 SHA-256 相符时,内核服务才运行它(见[构建与运行](#构建与运行))。`--no-kernel` 不附带内核,应用随后在没有助手的情况下运行。`target/octosense-package/receipt.json` 记录版本、资源 crate 和内核收据。 - **不含私有路径。** 二进制中的路径被重映射(对主目录、`CARGO_HOME` 和检出目录使用 `--remap-path-prefix`),并去掉调试信息(保留符号名,便于阅读回溯)。crate 还会以普通字符串嵌入源码目录,重映射无法处理,所以要在任何用户主目录之外构建,`CARGO_HOME` 也放在外面(发布工作流就是这样做的)。`tools/release-scan.py` 会在 `.app`、`.dmg`、`.deb`、`.AppImage`、`.zip` 和 NSIS 安装包内部查找:`/Users/…`、`C:\Users\…` 以及 CI 运行器之外的主目录(`/home/runner`、`C:\Users\runneradmin` 除外)、`*.local` 主机名、私有 IPv4 地址、执行扫描的账户名和主机名,以及任何 `RELEASE_SCAN_EXTRA` 模式,发现即失败。 +- **包检查不执行 AppImage。** 扫描器读取 type-2 ELF 边界,再用主机的 `unsquashfs` 解压文件系统(由 Linux 构建与发布任务安装 `squashfs-tools`)。缺少工具、头部格式错误或解压失败都会阻止发布。GitHub 发布令牌仅提供给标签校验和上传步骤,不进入包检查步骤。打包脚本也会从构建子进程的环境中移除内联签名凭据 `APPLE_API_KEY_P8`。 - **标识。** 产品名 **OctoSense**,标识符 `org.octosense.desktop`(`desktop/packaging/release.json`),图标来自 `desktop/packaging/icons/`(由 `make_icons.py` 生成)。Android 仍为 `dev.makepad.octosense`。 ### 发布桌面版本 diff --git a/desktop/scripts/package.py b/desktop/scripts/package.py index 9f53b23e..c0cf29a3 100644 --- a/desktop/scripts/package.py +++ b/desktop/scripts/package.py @@ -68,7 +68,7 @@ RECEIPT_NAME = "octos-kernel.json" # cargo-packager signs when these are set; the build never does (see above). SIGNING_ENV = ("APPLE_SIGNING_IDENTITY", "APPLE_CERTIFICATE", "APPLE_CERTIFICATE_PASSWORD", "APPLE_API_KEY", - "APPLE_API_ISSUER", "APPLE_API_KEY_PATH", "APPLE_ID", "APPLE_PASSWORD", "APPLE_TEAM_ID", + "APPLE_API_ISSUER", "APPLE_API_KEY_PATH", "APPLE_API_KEY_P8", "APPLE_ID", "APPLE_PASSWORD", "APPLE_TEAM_ID", "WINDOWS_CERTIFICATE", "WINDOWS_CERTIFICATE_PASSWORD", "WINDOWS_CERTIFICATE_THUMBPRINT") DEFAULT_FORMATS = {"macos": ["app", "dmg"], "windows": ["nsis"], "linux": ["deb", "appimage"]} # Where Makepad reads packaged resources from, per OS (see the table above). diff --git a/desktop/scripts/test_package.py b/desktop/scripts/test_package.py index e13d07e6..116837d2 100644 --- a/desktop/scripts/test_package.py +++ b/desktop/scripts/test_package.py @@ -109,11 +109,13 @@ def test_the_release_config_is_filled_in_per_build(self): def test_the_build_never_sees_a_signing_variable(self): base = {"PATH": "/bin", "RUSTFLAGS": "-C x", "APPLE_SIGNING_IDENTITY": "Developer ID Application: X (T)", - "APPLE_CERTIFICATE": "secret", "APPLE_API_KEY_PATH": "/k.p8", "WINDOWS_CERTIFICATE_THUMBPRINT": "AB12"} + "APPLE_CERTIFICATE": "secret", "APPLE_API_KEY_PATH": "/k.p8", "APPLE_API_KEY_P8": "synthetic-p8", + "WINDOWS_CERTIFICATE_THUMBPRINT": "AB12"} env = package.build_env({"MAKEPAD_PACKAGE_DIR": "."}, base) self.assertEqual(env["PATH"], "/bin") self.assertEqual(env["MAKEPAD_PACKAGE_DIR"], ".") self.assertNotIn("RUSTFLAGS", env, "folded into CARGO_ENCODED_RUSTFLAGS") + self.assertNotIn("APPLE_API_KEY_P8", env, "the inline signing credential must not reach a build") for name in package.SIGNING_ENV: self.assertNotIn(name, env) self.assertIn("APPLE_SIGNING_IDENTITY", base, "the caller's environment is not modified") diff --git a/tools/release-scan.py b/tools/release-scan.py index 3dee5bff..661fd812 100644 --- a/tools/release-scan.py +++ b/tools/release-scan.py @@ -6,15 +6,16 @@ Every file is read as bytes, including the files inside the packages a release ships: `.app` bundles and directories, `.dmg` images (mounted with -hdiutil on macOS), `.deb` (ar + tar), `.AppImage` (`--appimage-extract` on -Linux), `.zip`, `.tar.*`, and NSIS installers (7-Zip, when installed; the +hdiutil on macOS), `.deb` (ar + tar), `.AppImage` (trusted `unsquashfs`, +from squashfs-tools), `.zip`, `.tar.*`, and NSIS installers (7-Zip, when installed; the installed files themselves are scanned before packaging on Windows, since the release workflow scans the staged payload too). In a directory, a symbolic link's target is read too. An unreadable container fails the scan rather than being skipped. -An AppImage's own bytes are read except for its squashfs filesystem, which -is read through its extracted files and links instead. That filesystem is +An AppImage is never executed, including to find its payload: its type-2 +ELF section table determines the offset. Its own bytes are read except for +its squashfs filesystem, which is read through its extracted files and links instead. That filesystem is zstd-compressed, and zstd keeps a block's literal bytes back to back, so the raw bytes hold text that no file has (in octosense 0.1.0's AppImage, a `/home/` path from a literal run followed by the start of the next run). @@ -267,31 +268,73 @@ def ar_members(data): pos += 60 + size + (size % 2) +def appimage_filesystem(data): + """Return the bounded type-2 SquashFS span without running the artifact. + + libappimage's ElfFile::getSize uses the later of the section-table end + and the last section's end. Read both ELF classes and byte orders, and + refuse unsupported/invalid layouts instead of guessing an offset. + https://github.com/AppImageCommunity/libappimage/blob/master/src/libappimage/utils/ElfFile.cpp + """ + if len(data) < 16 or data[:4] != b"\x7fELF" or data[8:11] != b"AI\x02": + raise ValueError("not a type-2 ELF AppImage") + if data[4] not in (1, 2) or data[5] not in (1, 2) or data[6] != 1: + raise ValueError("unsupported AppImage ELF class, byte order or version") + order = "little" if data[5] == 1 else "big" + header, shoff_pos, word, ehsize_pos, entsize_pos, count_pos, section_size, section_pos = ( + (52, 32, 4, 40, 46, 48, 40, 16) if data[4] == 1 else + (64, 40, 8, 52, 58, 60, 64, 24)) + + def integer(pos, size): + if pos < 0 or pos + size > len(data): + raise ValueError("truncated AppImage ELF header") + return int.from_bytes(data[pos:pos + size], order) + + if len(data) < header or integer(ehsize_pos, 2) != header: + raise ValueError("invalid AppImage ELF header size") + shoff, entsize, count = integer(shoff_pos, word), integer(entsize_pos, 2), integer(count_pos, 2) + if shoff < header or entsize != section_size or not count: + raise ValueError("unsupported AppImage ELF section table") + table_end = shoff + entsize * count + if table_end > len(data): + raise ValueError("truncated AppImage ELF section table") + last = table_end - entsize + section_end = integer(last + section_pos, word) + integer(last + section_pos + word, word) + offset = max(table_end, section_end) + superblock = data[offset:offset + 96] + if len(superblock) != 96 or superblock[:4] != b"hsqs": + raise ValueError("no squashfs filesystem at the AppImage ELF boundary") + if superblock[28:32] != b"\x04\x00\x00\x00": + raise ValueError("unsupported AppImage squashfs version") + used = int.from_bytes(superblock[40:48], "little") + end = offset + used + if used < 96 or end > len(data): + raise ValueError("truncated or invalid AppImage squashfs size") + return offset, end + + def scan_appimage(path, data, patterns, findings): - """An AppImage: the runtime before its squashfs, and anything after it, - as bytes; the squashfs through `--appimage-extract` (see the module - docstring). Only a machine that can run it can extract it (Linux).""" + """Scan the runtime, trusted-tool-extracted filesystem and trailing bytes. + Neither the input runtime nor any extracted program is executed.""" name = path.name + offset, end = appimage_filesystem(data) + extractor = shutil.which("unsquashfs") + if not extractor: + raise RuntimeError(f"{name}: install squashfs-tools to scan AppImages (unsquashfs missing)") + scan_bytes(data[:offset], f"{name} (runtime)", patterns, findings) + scan_bytes(data[end:], f"{name} (after its filesystem)", patterns, findings) with tempfile.TemporaryDirectory() as temp: - copy = Path(temp) / name - shutil.copy2(path, copy) - copy.chmod(0o755) - - def run(option): - try: - return subprocess.run([str(copy), option], cwd=temp, check=True, capture_output=True).stdout - except OSError as e: # not an executable format this machine runs - raise RuntimeError(f"{name}: an AppImage can only be extracted on Linux ({e})") from e - - offset = int(run("--appimage-offset")) - superblock = data[offset:offset + 48] - if len(superblock) < 48 or superblock[:4] != b"hsqs": - raise RuntimeError(f"{name}: no squashfs filesystem at offset {offset}") - end = offset + int.from_bytes(superblock[40:48], "little") # the superblock's bytes_used - scan_bytes(data[:offset], f"{name} (runtime)", patterns, findings) - scan_bytes(data[end:], f"{name} (after its filesystem)", patterns, findings) - run("--appimage-extract") - return 1 + scan_tree(Path(temp) / "squashfs-root", name, patterns, findings) + # Extract precisely the bytes checked above, even if the caller's + # original path is replaced meanwhile. The copy is never executable. + copy = Path(temp) / "input.AppImage" + copy.write_bytes(data) + destination = Path(temp) / "squashfs-root" + subprocess.run([extractor, "-strict-errors", "-no-progress", "-no-xattrs", + "-processors", "2", "-offset", str(offset), "-dest", str(destination), str(copy)], + check=True, capture_output=True, env={"PATH": os.defpath, "LC_ALL": "C"}) + if not destination.is_dir() or destination.is_symlink(): + raise RuntimeError(f"{name}: unsquashfs did not produce a filesystem directory") + return 1 + scan_tree(destination, name, patterns, findings) def scan_artifact(path, patterns, findings, dmg_bytes_only=False): diff --git a/tools/test_ci_local.py b/tools/test_ci_local.py index e2ed76ff..724a8e7e 100644 --- a/tools/test_ci_local.py +++ b/tools/test_ci_local.py @@ -24,6 +24,20 @@ def load(name, path): class WorkflowReader(unittest.TestCase): + def test_release_token_is_scoped_to_github_cli_steps(self): + workflow = ci.load_workflow("release-desktop.yml") + self.assertNotIn("GH_TOKEN", workflow.get("env", {})) + release = workflow["jobs"]["release"] + self.assertNotIn("GH_TOKEN", release.get("env", {})) + authenticated = [] + for step in release["steps"]: + if "GH_TOKEN" in step.get("env", {}): + authenticated.append(step["name"]) + self.assertNotIn("release-scan.py", step.get("run", "")) + if "release-scan.py" in step.get("run", ""): + self.assertNotIn("GH_TOKEN", step.get("env", {})) + self.assertEqual(authenticated, ["HEAD is the tag's commit", "Attach to the tag's draft release"]) + def test_reads_what_workflows_use(self): text = ( "name: X\n" diff --git a/tools/test_release_scan.py b/tools/test_release_scan.py index bc8bb290..626f7eed 100644 --- a/tools/test_release_scan.py +++ b/tools/test_release_scan.py @@ -21,6 +21,31 @@ def findings(data, extra=()): return out +def appimage_bytes(runtime=b"", filesystem=b"", after=b"", elf_class=2, order="little", section_last=False): + """Synthetic type-2 ELF; extraction is mocked, never an executable fixture.""" + data = bytearray(4096) + data[:11] = b"\x7fELF" + bytes((elf_class, 1 if order == "little" else 2, 1, 0)) + b"AI\x02" + header, shoff_pos, word, ehsize_pos, entsize_pos, count_pos, size, section_pos = ( + (52, 32, 4, 40, 46, 48, 40, 16) if elf_class == 1 else (64, 40, 8, 52, 58, 60, 64, 24)) + + def put(pos, width, value): + data[pos:pos + width] = value.to_bytes(width, order) + + table = 2048 if section_last else 4096 - size + for pos, width, value in ((shoff_pos, word, table), (ehsize_pos, 2, header), + (entsize_pos, 2, size), (count_pos, 2, 1)): + put(pos, width, value) + if section_last: + put(table + section_pos, word, 3072) + put(table + section_pos + word, word, 1024) + data[header:header + len(runtime)] = runtime + superblock = bytearray(96) + superblock[:4] = b"hsqs" + superblock[28:32] = b"\x04\x00\x00\x00" + superblock[40:48] = (96 + len(filesystem)).to_bytes(8, "little") + return bytes(data + superblock + filesystem + after) + + class PatternTests(unittest.TestCase): def test_only_proven_octoscode_placeholder_seams_are_ignored(self): themes = b"/home/user/src/octoscode-app/home/user/src/octosSystemSolarizedSlateClaudeCodexLight" @@ -203,36 +228,93 @@ def test_a_link_target_is_read(self): self.assertIn("OctoSense.app/leak (link target): Linux home directory", out) def test_an_appimage_is_read_except_its_compressed_filesystem(self): - def appimage(path, runtime=b"", filesystem=b"", after=b"", file=b"app", link=b"usr/bin/app"): - # A shell script standing in for the runtime: it answers the two - # options the scan uses. The filesystem starts at 4096 with a - # squashfs superblock whose bytes_used (offset 40) covers it. - script = (b"#!/bin/sh\n# " + runtime + b"\ncase \"$1\" in\n" - b" --appimage-offset) echo 4096 ;;\n" - b" --appimage-extract) mkdir -p squashfs-root/usr/bin && printf '%s' '" + file - + b"' > squashfs-root/usr/bin/app && ln -s '" + link + b"' squashfs-root/AppRun ;;\n" - b"esac\nexit 0\n") - squashfs = b"hsqs" + bytes(36) + (48 + len(filesystem)).to_bytes(8, "little") + filesystem - path.write_bytes(script.ljust(4096, b"\n") + squashfs + after) - with tempfile.TemporaryDirectory() as temp: path = Path(temp) / "octosense_0.1.0_x86_64.AppImage" - # zstd's literals back to back: "/home/" then the next run's "io/". - appimage(path, filesystem=b"\x28\xb5\x2f\xfd/home/io/\x91\x07") - code, out = self.run_scan(path) - self.assertEqual(code, 0, out) - for kind, where in ((dict(runtime=b"/Users/someone/runtime"), "AppImage (runtime)"), - (dict(after=b"/home/someone/x"), "AppImage (after its filesystem)"), - (dict(file=b"/Users/someone/src"), "AppImage/usr/bin/app"), - (dict(link=b"/home/someone/app"), "AppImage/AppRun (link target)")): - appimage(path, **kind) + def extract(command, **kwargs): + self.assertEqual(command[0], "/trusted/unsquashfs") + self.assertEqual(command[command.index("-offset") + 1], "4096") + self.assertIn("-strict-errors", command) + self.assertTrue(kwargs["check"]) + self.assertNotIn("GH_TOKEN", kwargs["env"]) + self.assertNotIn("RELEASE_SCAN_EXTRA", kwargs["env"]) + self.assertEqual(Path(command[-1]).read_bytes(), path.read_bytes()) + self.assertEqual(Path(command[-1]).stat().st_mode & 0o111, 0) + root = Path(command[command.index("-dest") + 1]) + (root / "usr/bin").mkdir(parents=True) + (root / "usr/bin/app").write_bytes(file) + (root / "AppRun").symlink_to(link.decode()) + + with mock.patch.object(scan.shutil, "which", return_value="/trusted/unsquashfs"), \ + mock.patch.object(scan.subprocess, "run", side_effect=extract): + file, link = b"app", b"usr/bin/app" + # Compressed literal runs are not logical file content. + path.write_bytes(appimage_bytes(filesystem=b"\x28\xb5\x2f\xfd/home/io/\x91\x07")) code, out = self.run_scan(path) - self.assertEqual(code, 1, kind) - self.assertIn(where, out) - path.write_bytes(b"#!/bin/sh\necho 0\n") - code, out = self.run_scan(path) - self.assertEqual(code, 1) - self.assertIn("no squashfs filesystem", out) + self.assertEqual(code, 0, out) + for kind, where in ((dict(runtime=b"/Users/someone/runtime"), "AppImage (runtime)"), + (dict(after=b"/home/someone/x"), "AppImage (after its filesystem)"), + (dict(file=b"/Users/someone/src"), "AppImage/usr/bin/app"), + (dict(link=b"/home/someone/app"), "AppImage/AppRun (link target)")): + file, link = kind.get("file", b"app"), kind.get("link", b"usr/bin/app") + path.write_bytes(appimage_bytes(**{k: v for k, v in kind.items() if k not in ("file", "link")})) + code, out = self.run_scan(path) + self.assertEqual(code, 1, kind) + self.assertIn(where, out) + + def test_appimage_never_executes_an_artifact_to_find_its_offset(self): + with tempfile.TemporaryDirectory() as temp: + path, marker = Path(temp) / "malicious.AppImage", Path(temp) / "exfiltrated" + # The old scanner chmod'ed and ran this with inherited credentials. + path.write_text('#!/bin/sh\nprintf "%s" "$GH_TOKEN" > "$ATTACK_MARKER"\necho 4096\n') + path.chmod(0o755) + with mock.patch.dict(scan.os.environ, {"GH_TOKEN": "synthetic-token", "ATTACK_MARKER": str(marker)}), \ + mock.patch.object(scan.subprocess, "run", wraps=scan.subprocess.run) as run: + code, out = self.run_scan(path) + self.assertEqual(code, 1, out) + self.assertIn("not a type-2 ELF AppImage", out) + run.assert_not_called() + self.assertFalse(marker.exists(), "no executable artifact may inherit the publication token") + + def test_appimage_elf_classes_byte_orders_and_section_layouts(self): + for elf_class in (1, 2): + for order in ("little", "big"): + for section_last in (False, True): + with self.subTest(elf_class=elf_class, order=order, section_last=section_last): + self.assertEqual(scan.appimage_filesystem(appimage_bytes( + elf_class=elf_class, order=order, section_last=section_last)), (4096, 4192)) + + def test_appimage_malformed_headers_fail_before_any_extractor(self): + good = appimage_bytes() + cases = [good[:15], good[:60], good[:4090], good[:4191]] + for pos, data in ((8, b"AI\x01"), (4, b"\x03"), (5, b"\x00"), (6, b"\x00"), + (52, bytes(2)), (58, bytes(2)), (60, bytes(2)), (40, (2**64-1).to_bytes(8, "little")), + (4096, b"nope"), (4096 + 28, bytes(4)), + (4096 + 40, (95).to_bytes(8, "little")), + (4096 + 40, (2**64-1).to_bytes(8, "little"))): + cases.append(good[:pos] + data + good[pos + len(data):]) + with tempfile.TemporaryDirectory() as temp, mock.patch.object(scan.subprocess, "run") as run: + path = Path(temp) / "bad.AppImage" + for data in cases: + path.write_bytes(data) + code, out = self.run_scan(path) + self.assertEqual(code, 1, out) + run.assert_not_called() + + def test_appimage_missing_failed_or_empty_extractor_fails_closed(self): + with tempfile.TemporaryDirectory() as temp: + path = Path(temp) / "test.AppImage" + path.write_bytes(appimage_bytes()) + with mock.patch.object(scan.shutil, "which", return_value=None): + code, out = self.run_scan(path) + self.assertEqual(code, 1, out) + self.assertIn("unsquashfs missing", out) + with mock.patch.object(scan.shutil, "which", return_value="/trusted/unsquashfs"): + with mock.patch.object(scan.subprocess, "run", side_effect=scan.subprocess.CalledProcessError(1, "unsquashfs")): + self.assertEqual(self.run_scan(path)[0], 1) + with mock.patch.object(scan.subprocess, "run"): + code, out = self.run_scan(path) + self.assertEqual(code, 1, out) + self.assertIn("did not produce a filesystem directory", out) def test_a_clean_artifact_passes(self): with tempfile.TemporaryDirectory() as temp: From 0edd71b0888f0efb2f622e8260967a82071e65a5 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 8 Oct 2026 01:54:03 -0700 Subject: [PATCH 35/35] fix: pin verified mounted-card host for the release candidate --- Cargo.lock | 14 +++++++------- Cargo.toml | 8 ++++---- native-apps.json | 2 +- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b31bceb4..4dd3a22d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3170,8 +3170,8 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", - "windows-result 0.4.1 (registry+https://github.com/rust-lang/crates.io-index)", + "windows-link 0.1.3", + "windows-result 0.3.4", ] [[package]] @@ -4515,7 +4515,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)", + "windows-core 0.56.0", ] [[package]] @@ -8002,7 +8002,7 @@ dependencies = [ [[package]] name = "octosense-app-hub" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=655114c4943cd2490daaefa2173e7b5aaa20669f#655114c4943cd2490daaefa2173e7b5aaa20669f" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=ba36df0505e198f998201af312ff5184df98cae9#ba36df0505e198f998201af312ff5184df98cae9" dependencies = [ "base64 0.22.1", "blake3", @@ -8027,7 +8027,7 @@ dependencies = [ [[package]] name = "octosense-app-hub-app" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=655114c4943cd2490daaefa2173e7b5aaa20669f#655114c4943cd2490daaefa2173e7b5aaa20669f" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=ba36df0505e198f998201af312ff5184df98cae9#ba36df0505e198f998201af312ff5184df98cae9" dependencies = [ "makepad-app-module", "makepad-widgets", @@ -8055,7 +8055,7 @@ dependencies = [ [[package]] name = "octosense-app-policy" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=655114c4943cd2490daaefa2173e7b5aaa20669f#655114c4943cd2490daaefa2173e7b5aaa20669f" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=ba36df0505e198f998201af312ff5184df98cae9#ba36df0505e198f998201af312ff5184df98cae9" dependencies = [ "makepad-widgets", "octosense-app-contract", @@ -8077,7 +8077,7 @@ dependencies = [ [[package]] name = "octosense-appstore" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=655114c4943cd2490daaefa2173e7b5aaa20669f#655114c4943cd2490daaefa2173e7b5aaa20669f" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=ba36df0505e198f998201af312ff5184df98cae9#ba36df0505e198f998201af312ff5184df98cae9" dependencies = [ "makepad-app-module", "makepad-widgets", diff --git a/Cargo.toml b/Cargo.toml index 01cd7d41..cd890b9e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -76,7 +76,7 @@ octosense-reference = { path = "apps/reference" } makepad-sheets = { git = "https://github.com/OctoSense-org/makepad.git", rev = "32d6415fb7476345ad36ee4f98d6f844d1f07fd2", default-features = false } makepad-terminal = { git = "https://github.com/OctoSense-org/makepad.git", rev = "32d6415fb7476345ad36ee4f98d6f844d1f07fd2" } octosense-appcard = { path = "apps/appcard/module" } -octosense-app-hub-app = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "655114c4943cd2490daaefa2173e7b5aaa20669f" } +octosense-app-hub-app = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "ba36df0505e198f998201af312ff5184df98cae9" } makepad-calculator = { git = "https://github.com/OctoSense-org/makepad.git", rev = "32d6415fb7476345ad36ee4f98d6f844d1f07fd2", default-features = false } makepad-clock = { git = "https://github.com/OctoSense-org/makepad.git", rev = "32d6415fb7476345ad36ee4f98d6f844d1f07fd2", default-features = false } makepad-notes = { git = "https://github.com/OctoSense-org/makepad.git", rev = "32d6415fb7476345ad36ee4f98d6f844d1f07fd2", default-features = false } @@ -112,12 +112,12 @@ octoscript-makepad = { git = "https://github.com/OctoSense-org/Octoscript-Makepa octoscript-node = { git = "https://github.com/OctoSense-org/Octoscript-Makepad.git", rev = "33dea2f1f3ad3f1346a219aa8cf6e91b31361e23" } # --- App Hub's host service registry the Mail and llm services register # into; the same revision as App Hub's app crate (a native app, above). -octosense-appstore = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "655114c4943cd2490daaefa2173e7b5aaa20669f" } +octosense-appstore = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "ba36df0505e198f998201af312ff5184df98cae9" } # The app policy (resolution, isolate settings, the Splash adapter) and the # Hub client the Card runner resolves an app's policy with; the shell's # glance tiles run cards under it (crates/shell/src/glance_card.rs). -octosense-app-policy = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "655114c4943cd2490daaefa2173e7b5aaa20669f" } -octosense-app-hub = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "655114c4943cd2490daaefa2173e7b5aaa20669f" } +octosense-app-policy = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "ba36df0505e198f998201af312ff5184df98cae9" } +octosense-app-hub = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "ba36df0505e198f998201af312ff5184df98cae9" } # The app contract (ADR 0005), from crates.io by version: App Hub, the shell # and Rinx all depend on `octosense-app-contract = "1"`, so Cargo links ONE # 1.x for the whole build (Cargo.lock pins the exact version OctoSense ships) diff --git a/native-apps.json b/native-apps.json index e1658b09..149a88a7 100644 --- a/native-apps.json +++ b/native-apps.json @@ -100,7 +100,7 @@ "id": "apphub", "feature": "app-hub", "crate": "octosense-app-hub-app", - "source": { "git": "https://github.com/OctoSense-org/OctoSense-App-Hub", "rev": "655114c4943cd2490daaefa2173e7b5aaa20669f", "local": null }, + "source": { "git": "https://github.com/OctoSense-org/OctoSense-App-Hub", "rev": "ba36df0505e198f998201af312ff5184df98cae9", "local": null }, "module": ["octosense_app_hub_app::APP_HUB_MODULE", "octosense_app_hub_app::CARD_MODULE"], "bin": null, "default_features": true,