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