diff --git a/.cargo/config.toml b/.cargo/config.toml index cd4ab8ff..af0212cf 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -27,6 +27,10 @@ OCTOSENSE_SYSTEM_APPS = { value = "desktop/system-apps.json", relative = true } # The locked aws-lc-sys supports its CC builder on Android arm64. Use the # installed NDK Clang directly; no additional CMake installation is needed. AWS_LC_SYS_CMAKE_BUILDER_aarch64_linux_android = "0" +# The Android packager also forces its global CMake setting into host-side +# bundle validation dependencies. On Apple arm64, use the supported CC builder +# there too. This changes only the build driver, not crypto or assembly features. +AWS_LC_SYS_CMAKE_BUILDER_aarch64_apple_darwin = "0" # iOS gives the main thread a 1 MB stack, and the shell's widget tree is # built on it through nested script_apply/script_new calls that overflowed diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index 3222f81d..e52e8f03 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -107,6 +107,8 @@ jobs: shared-key: native-host-api - name: Build the native acceptance host and pinned Hub CLI run: | + # Fresh child processes catch native response keys missing from the string lookup table. + cargo test --manifest-path .sources/makepad/Cargo.toml -p makepad-widgets splash_host::tests cargo build --locked --release -p octosense-shell --example host-api-lab --features acceptance-fixtures cargo build --locked -p octosense-app-hub --bin hub - name: Signed app tool calls a real OS permission API diff --git a/.github/workflows/embedded-browser.yml b/.github/workflows/embedded-browser.yml new file mode 100644 index 00000000..d282de5d --- /dev/null +++ b/.github/workflows/embedded-browser.yml @@ -0,0 +1,77 @@ +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 + cache-on-failure: true + - name: Build the native acceptance host + run: cargo build --locked --release -p octosense-browser-smoke + - name: Navigation and callback policy tests + run: cargo test --locked --release -p octosense-browser-smoke + - name: Real embedded WebView2 acceptance + # The runner must already provide WebView2. No installer, fake engine + # or external-browser fallback converts a missing runtime into a pass. + run: python tools/browser-smoke.py --binary target/release/octosense-browser-smoke.exe --out "$env:RUNNER_TEMP/browser-smoke" --require-snapshot --software-graphics + - name: Native fixture diagnostics on failure + if: failure() + run: | + $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/.github/workflows/phone.yml b/.github/workflows/phone.yml index b9ee3a90..57261f84 100644 --- a/.github/workflows/phone.yml +++ b/.github/workflows/phone.yml @@ -125,6 +125,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/.github/workflows/platform-vault.yml b/.github/workflows/platform-vault.yml new file mode 100644 index 00000000..9b8bd724 --- /dev/null +++ b/.github/workflows/platform-vault.yml @@ -0,0 +1,76 @@ +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' + - '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: 55 + env: + PYTHONUTF8: '1' + MAKEPAD_D3D11_WARP: '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 + - 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 + - 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/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index f642f8dd..c66cdf6f 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -41,6 +41,9 @@ on: - 'desktop/packaging/**' - 'desktop/scripts/package.py' - 'tools/release-scan.py' + - 'tools/release-scan-public-assets.json' + - 'tools/test_release_scan.py' + - 'tools/fixtures/release-scan/**' - 'tools/kernel-artifact.py' - '.github/workflows/release-desktop.yml' workflow_dispatch: @@ -172,7 +175,7 @@ jobs: sudo apt-get install -y --no-install-recommends pkg-config libssl-dev libx11-dev libxcursor-dev \ libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev libgl1-mesa-dev libegl1-mesa-dev libgles2-mesa-dev \ libasound2-dev libpulse-dev libdbus-1-dev libudev-dev libdrm-dev libgbm-dev libva-dev dpkg-dev file \ - desktop-file-utils libfuse2 + desktop-file-utils libfuse2 squashfs-tools - name: Install cargo-packager run: cargo install cargo-packager --locked --version 0.11.8 - name: Neutral build paths @@ -405,7 +408,6 @@ jobs: contents: write env: TAG: ${{ needs.plan.outputs.tag }} - GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 @@ -415,6 +417,7 @@ jobs: persist-credentials: false - name: HEAD is the tag's commit env: + GH_TOKEN: ${{ github.token }} WANT: ${{ needs.plan.outputs.commit }} run: | head=$(git rev-parse HEAD) @@ -441,6 +444,10 @@ jobs: cd packages sha256sum -- * | sort -k2 > SHA256SUMS cat SHA256SUMS + - name: Install the non-executing AppImage extractor + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends squashfs-tools - name: Scan again what is attached env: RELEASE_SCAN_EXTRA: ${{ secrets.RELEASE_SCAN_EXTRA }} @@ -449,6 +456,8 @@ jobs: # before signing, and the same app is in the .app.zip scanned now. python3 tools/release-scan.py --dmg-bytes-only packages/* - name: Attach to the tag's draft release + env: + GH_TOKEN: ${{ github.token }} run: | version="${TAG#desktop-v}" if gh release view "$TAG" > /dev/null 2>&1; then diff --git a/Cargo.lock b/Cargo.lock index b9317a87..4dd3a22d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -261,7 +261,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -272,7 +272,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -636,6 +636,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ "aws-lc-sys", + "untrusted 0.7.1", "zeroize", ] @@ -1345,6 +1346,18 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "cms" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730" +dependencies = [ + "const-oid 0.9.6", + "der", + "spki", + "x509-cert", +] + [[package]] name = "cobs" version = "0.3.0" @@ -1400,7 +1413,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" dependencies = [ "lazy_static", - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] @@ -1456,6 +1469,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -2172,6 +2191,30 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "der_derive", + "flagset", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "deranged" version = "0.5.8" @@ -2275,7 +2318,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", - "const-oid", + "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", ] @@ -2310,7 +2353,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.3", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2417,7 +2460,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" dependencies = [ "serdect", - "signature", + "signature 3.0.0", ] [[package]] @@ -2431,7 +2474,7 @@ dependencies = [ "rand_core 0.10.1", "serde", "sha2 0.11.0", - "signature", + "signature 3.0.0", "subtle", "zeroize", ] @@ -2607,7 +2650,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3127,8 +3170,8 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", - "windows-result 0.4.1 (registry+https://github.com/rust-lang/crates.io-index)", + "windows-link 0.1.3", + "windows-result 0.3.4", ] [[package]] @@ -4773,7 +4816,7 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82cb6a9f675da968c63b6208c641b9dca58fc0133ae53375736b1767b0cab8bd" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5883,10 +5926,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", ] @@ -6990,7 +7036,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7943,9 +7989,9 @@ dependencies = [ [[package]] name = "octosense-app-contract" -version = "1.7.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c3e8bb885e67a1290abdf6d3a71bf59b2707fa7e67c9b833eae6843355e86e5" +checksum = "0222acdc13544bb9bbf661321b99f2ecd8aefa631b746a8ff44162d6fd0337da" dependencies = [ "blake3", "serde", @@ -7956,26 +8002,32 @@ 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=ba36df0505e198f998201af312ff5184df98cae9#ba36df0505e198f998201af312ff5184df98cae9" dependencies = [ "base64 0.22.1", "blake3", "ed25519-dalek", + "fs2", "hex", "image", "octoscript-ui-l0", "octosense-app-policy", "rand_core 0.9.5", "roxmltree 0.21.1", + "semver", "serde", "serde_json", + "sha2 0.10.9", + "sigstore-trust-root", + "sigstore-types", + "sigstore-verify", "ureq 3.4.2", ] [[package]] name = "octosense-app-hub-app" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=2e5768964f15916cdb66cc6824f7671dafc6445a#2e5768964f15916cdb66cc6824f7671dafc6445a" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=ba36df0505e198f998201af312ff5184df98cae9#ba36df0505e198f998201af312ff5184df98cae9" dependencies = [ "makepad-app-module", "makepad-widgets", @@ -8003,7 +8055,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=ba36df0505e198f998201af312ff5184df98cae9#ba36df0505e198f998201af312ff5184df98cae9" dependencies = [ "makepad-widgets", "octosense-app-contract", @@ -8025,7 +8077,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=ba36df0505e198f998201af312ff5184df98cae9#ba36df0505e198f998201af312ff5184df98cae9" dependencies = [ "makepad-app-module", "makepad-widgets", @@ -8036,6 +8088,16 @@ dependencies = [ "serde_json", ] +[[package]] +name = "octosense-browser-smoke" +version = "0.1.0" +dependencies = [ + "makepad-widgets", + "serde_json", + "webview2-com", + "windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "octosense-calendar-service" version = "0.1.0" @@ -8075,6 +8137,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" @@ -8276,6 +8351,7 @@ dependencies = [ "tokio", "tokio-tungstenite 0.24.0", "uuid", + "wat", ] [[package]] @@ -8311,6 +8387,19 @@ dependencies = [ "wat", ] +[[package]] +name = "octosense-wasm-phone-smoke" +version = "0.1.0" +dependencies = [ + "makepad-app-module", + "makepad-widgets", + "octosense-app-hub", + "octosense-app-policy", + "octosense-appstore", + "octosense-shell", + "serde_json", +] + [[package]] name = "ohos-sys" version = "0.2.2" @@ -8522,6 +8611,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -9066,7 +9164,7 @@ dependencies = [ "once_cell 1.21.4", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -9522,7 +9620,7 @@ dependencies = [ "cfg-if 1.0.4 (registry+https://github.com/rust-lang/crates.io-index)", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -10106,7 +10204,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -10165,7 +10263,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -10183,7 +10281,7 @@ dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -10305,6 +10403,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "ryu-js" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714" + [[package]] name = "safe_arch" version = "0.7.4" @@ -10700,6 +10804,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_json_canonicalizer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe52319a927259afbfa5180c5157cd8167edfd3e8c254f9558c7fef44c5649f2" +dependencies = [ + "ryu-js", + "serde", + "serde_json", +] + [[package]] name = "serde_path_to_error" version = "0.1.20" @@ -10899,6 +11014,15 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "signature" version = "3.0.0" @@ -10908,6 +11032,144 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "sigstore-bundle" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a74b758235084c8baee7869e6073702b04d42d1efbd55b7042a43dca3a27f15e" +dependencies = [ + "jiff", + "serde_json", + "sigstore-types", + "thiserror 2.0.20", +] + +[[package]] +name = "sigstore-crypto" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62ee2920d66400eef83fe543bff02c3ac5ea9a59ff864d365dd746ada4c8fc4c" +dependencies = [ + "aws-lc-rs", + "const-oid 0.9.6", + "der", + "futures-io", + "jiff", + "sigstore-types", + "spki", + "thiserror 2.0.20", + "x509-cert", +] + +[[package]] +name = "sigstore-merkle" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f64415f7d91620a3fce3f89f5955997e7104d863c12f36bf0dcbad1b258cf584" +dependencies = [ + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.20", +] + +[[package]] +name = "sigstore-rekor" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b17fdd6fd9070f6779b117594582b9d2b4d9a9339d1b2f57711ad10f6a0cc0c8" +dependencies = [ + "base64 0.22.1", + "hex", + "jiff", + "serde", + "serde_json", + "sigstore-merkle", + "sigstore-types", + "thiserror 2.0.20", +] + +[[package]] +name = "sigstore-trust-root" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16644cdbe963a469df667174ce6447649281aef02300149b195bd6f66d910ae4" +dependencies = [ + "hex", + "jiff", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.20", +] + +[[package]] +name = "sigstore-tsa" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "403b8a741c152f65752314805dd20bf9022a2e23191f7aa39b4fe8bf30699265" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "cms", + "const-oid 0.9.6", + "der", + "hex", + "jiff", + "rand 0.9.5", + "rustls-pki-types", + "rustls-webpki", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.20", + "tracing", + "x509-cert", +] + +[[package]] +name = "sigstore-types" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d644275d2d22b52bc872214dc1618bcbb830f71064124f8e8b3cfc07c1020205" +dependencies = [ + "base64 0.22.1", + "hex", + "jiff", + "pem", + "serde", + "serde_json", + "thiserror 2.0.20", +] + +[[package]] +name = "sigstore-verify" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc73ef1cd580920f1233358cebe550a43369daf8110b57cf820576888fbc0a8d" +dependencies = [ + "base64 0.22.1", + "const-oid 0.9.6", + "futures-io", + "hex", + "jiff", + "rustls-pki-types", + "rustls-webpki", + "serde", + "serde_json", + "serde_json_canonicalizer", + "sigstore-bundle", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-rekor", + "sigstore-trust-root", + "sigstore-tsa", + "sigstore-types", + "thiserror 2.0.20", + "tls_codec", + "tracing", + "x509-cert", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -11008,7 +11270,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -11024,6 +11286,16 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "sqlite-wasm-rs" version = "0.5.5" @@ -11319,10 +11591,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell 1.21.4", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -11515,6 +11787,27 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tokio" version = "1.53.1" @@ -12056,7 +12349,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -12215,6 +12508,12 @@ dependencies = [ "ctutils", ] +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -12919,6 +13218,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" @@ -12994,7 +13327,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -13706,6 +14039,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid 0.9.6", + "der", + "sha1", + "signature 2.2.0", + "spki", + "tls_codec", +] + [[package]] name = "xattr" version = "1.6.1" diff --git a/Cargo.toml b/Cargo.toml index 3eeb6eda..cd890b9e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ [workspace] resolver = "2" members = [ + "crates/wasm-phone-smoke", "desktop", "phone", "crates/kernel", @@ -30,6 +31,8 @@ members = [ "crates/l0-chat", "crates/oauth-service", "crates/markdown-editor", + "crates/browser-smoke", + "crates/host-api-smoke", "crates/wasm-host", "apps/reference", "apps/news/host-service", @@ -73,7 +76,7 @@ octosense-reference = { path = "apps/reference" } makepad-sheets = { git = "https://github.com/OctoSense-org/makepad.git", rev = "32d6415fb7476345ad36ee4f98d6f844d1f07fd2", default-features = false } makepad-terminal = { git = "https://github.com/OctoSense-org/makepad.git", rev = "32d6415fb7476345ad36ee4f98d6f844d1f07fd2" } octosense-appcard = { path = "apps/appcard/module" } -octosense-app-hub-app = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "2e5768964f15916cdb66cc6824f7671dafc6445a" } +octosense-app-hub-app = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "ba36df0505e198f998201af312ff5184df98cae9" } makepad-calculator = { git = "https://github.com/OctoSense-org/makepad.git", rev = "32d6415fb7476345ad36ee4f98d6f844d1f07fd2", default-features = false } makepad-clock = { git = "https://github.com/OctoSense-org/makepad.git", rev = "32d6415fb7476345ad36ee4f98d6f844d1f07fd2", default-features = false } makepad-notes = { git = "https://github.com/OctoSense-org/makepad.git", rev = "32d6415fb7476345ad36ee4f98d6f844d1f07fd2", default-features = false } @@ -109,17 +112,17 @@ octoscript-makepad = { git = "https://github.com/OctoSense-org/Octoscript-Makepa octoscript-node = { git = "https://github.com/OctoSense-org/Octoscript-Makepad.git", rev = "33dea2f1f3ad3f1346a219aa8cf6e91b31361e23" } # --- App Hub's host service registry the Mail and llm services register # into; the same revision as App Hub's app crate (a native app, above). -octosense-appstore = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "2e5768964f15916cdb66cc6824f7671dafc6445a" } +octosense-appstore = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "ba36df0505e198f998201af312ff5184df98cae9" } # The app policy (resolution, isolate settings, the Splash adapter) and the # Hub client the Card runner resolves an app's policy with; the shell's # glance tiles run cards under it (crates/shell/src/glance_card.rs). -octosense-app-policy = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "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 = "ba36df0505e198f998201af312ff5184df98cae9" } +octosense-app-hub = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "ba36df0505e198f998201af312ff5184df98cae9" } # The app contract (ADR 0005), from crates.io by version: App Hub, the shell # and Rinx all depend on `octosense-app-contract = "1"`, so Cargo links ONE # 1.x for the whole build (Cargo.lock pins the exact version OctoSense ships) # and App Hub and Rinx no longer need the same App Hub commit. -octosense-app-contract = "1.7" +octosense-app-contract = "1.8" # --- octos: ONE source for every octos crate (reviewed octos#2715 at # b0759a57: bounded ledger session-directory names, serialized peer purge # against session writes, and provider transport diagnostics that omit @@ -187,6 +190,9 @@ tokio-tungstenite = { version = "0.24", features = ["rustls-tls-native-roots"] } futures-util = "0.3" serde = { version = "1", features = ["derive"] } serde_json = "1" +# Native browser acceptance fixture only; registry bindings match WebView2. +webview2-com = "=0.39.1" +webview2-windows = { package = "windows", version = "=0.62.2", features = ["Win32_Foundation", "Win32_System_LibraryLoader", "Win32_UI_WindowsAndMessaging"] } uuid = { version = "1", features = ["v4", "serde"] } chrono = { version = "0.4", features = ["serde"] } chrono-tz = "0.10.4" diff --git a/README.md b/README.md index 8387251b..6740bd18 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,16 @@ To read the code in order, start with [From an app window to an agent turn](docs Connected App Hub samples share a host-owned GitHub/Google OAuth service, without an OctoSense account. Start with the [service and sample guide](crates/oauth-service/README.md) and [ADR 0010](docs/adr/0010-shared-oauth-and-connected-apps.md). GitHub Notes reuses Rinx’s Markdown editor; Inbox Assistant and Google Calendar are ordinary bundles. **Provider login requires distributor-supplied OAuth registrations.** Existing beta.2 downloads contain none; an operator must supply the private host `oauth/clients.json` override or build with the [registration settings](crates/oauth-service/README.md#configure-a-release-maintainers). Ordinary app users should receive a configured build. Live GitHub and Google sign-in has passed on macOS, and a [macOS test-account Calendar login/save](tools/connected-e2e/evidence/calendar-login-20261007.json) passed on its recorded build; this is not public Google verification. GitHub writes and Gmail sends are still unverified, and Google sign-in on Android still needs its native adapter. +App Hub in this source build defaults to the GitHub-attested catalog and supports +`publisher-github-v1` releases through app-contract 1.8.0. Developers request +publication by opening an [App Hub submission issue](https://github.com/OctoSense-org/OctoSense-App-Hub/issues/new?template=submit-app.yml); +GitHub release proofs replace separate developer signing keys, while a Hub admin +still approves admission. The first catalog fetch needs a network connection; +an old offline catalog is not converted into a GitHub proof. Custom local test +catalogs can explicitly select `OCTOSENSE_HUB_CATALOG=legacy` with a fresh app-data +directory; a library with a v2 cache refuses that downgrade. Beta.2 does not +support this publishing mode; a compatible packaged release is pending. + ## How it fits together One shell process per device, one octos kernel per shell, and every agent is a session in that kernel. The shell is the kernel's only full client. It starts octos and holds its host token, starts every app agent's turns, relays every call to an app's tools, and owns every approval. Apps never talk to the kernel. @@ -365,6 +375,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 870b9e01..e71ce6ed 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -44,6 +44,13 @@ Android 桌面支持从左右边缘滑动翻页;应用和已展开卡片仍使 连接账户的 App Hub 示例共用宿主持有的 GitHub/Google OAuth 服务,无需创建 OctoSense 账户。请从[服务与示例指南](crates/oauth-service/README.zh-CN.md)和 [ADR 0010](docs/adr/0010-shared-oauth-and-connected-apps.zh-CN.md)开始。GitHub Notes 复用 Rinx 的 Markdown 编辑器;Inbox Assistant 和 Google Calendar 都是普通应用包。**服务商登录需要发行方提供 OAuth 客户端注册信息。** 现有 beta.2 下载包未包含这些信息;运维者须提供私有宿主 `oauth/clients.json` 配置,或按[注册设置](crates/oauth-service/README.zh-CN.md#配置发行版本维护者)构建。普通应用用户应拿到已配置的发行包。真实 GitHub 和 Google 账户的登录已在 macOS 上通过,[macOS 测试账户的 Calendar 登录与保存](tools/connected-e2e/evidence/calendar-login-20261007.json)也已在收据记录的构建上通过,但这不代表 Google 已完成公开验证。GitHub 写入和 Gmail 发信仍未验证,Android 上的 Google 登录还需要原生授权适配器。 +此源码构建中的 App Hub 默认使用带 GitHub 证明的目录,并通过应用契约 1.8.0 支持 +`publisher-github-v1` 发布包。开发者通过[提交 App Hub issue](https://github.com/OctoSense-org/OctoSense-App-Hub/issues/new?template=submit-app.yml) +申请发布;GitHub 发布证明取代单独的开发者签名密钥,仍须由 Hub 管理员批准准入。 +首次获取目录需要网络连接,旧的离线目录不会被转换成 GitHub 证明。自定义本地测试 +目录可以在全新的应用数据目录下显式设置 `OCTOSENSE_HUB_CATALOG=legacy`;已有 v2 +缓存的应用库会拒绝这种降级。beta.2 不支持此发布模式,兼容的发行包尚待发布。 + ## 整体如何运作 每台设备一个 Shell 进程,每个 Shell 一个 octos 内核,每个 Agent 都是这个内核中的一个会话。Shell 是内核唯一的完整客户端:它启动 octos 并持有宿主 token,启动每个应用 Agent 的回合,转交每一次对应用工具的调用,并掌管所有审批。应用从不直接与内核通信。 @@ -363,6 +370,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..61c6cff8 --- /dev/null +++ b/crates/browser-smoke/Cargo.toml @@ -0,0 +1,14 @@ +[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 + +[target.'cfg(target_os = "windows")'.dependencies] +webview2-com.workspace = true +webview2-windows.workspace = true diff --git a/crates/browser-smoke/src/main.rs b/crates/browser-smoke/src/main.rs new file mode 100644 index 00000000..47c5c288 --- /dev/null +++ b/crates/browser-smoke/src/main.rs @@ -0,0 +1,229 @@ +//! 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; + +#[cfg(target_os = "windows")] +mod windows_message_probe; + +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 := WebReader {width: Fill height: Fill} + } + }} + } +} + +#[derive(Script, ScriptHook)] +struct App { + #[live] + ui: WidgetRef, + #[rust] + root: PathBuf, + #[rust] + timer: Option, + #[rust] + last_command: u64, + #[cfg(target_os = "windows")] + #[rust] + message_probe: Option, +} + +impl App { + fn browser_id(&self, cx: &mut Cx) -> SystemBrowserId { + SystemBrowserId(LiveId(self.ui.widget(cx, ids!(browser)).widget_uid().0)) + } + + fn record(&self, event: Value) { + if self.root.as_os_str().is_empty() { + return; + } + 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; + 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) = + widget.borrow_mut::() + { + reader.close(cx); + accepted = reader.open(cx, url); + } else { + accepted = false; + } + widget.set_visible(cx, 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" => widget.set_visible(cx, false), + "show" => widget.set_visible(cx, true), + "close" => { + if let Some(mut reader) = + widget.borrow_mut::() + { + reader.close(cx); + } + } + "inspect" => { + #[cfg(any(target_os = "macos", target_os = "windows"))] + cx.system_browser(browser_id).inspect( + 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); + #[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); + 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(blocked) = action + .downcast_ref::( + ) { + if blocked.browser_id == browser_id.0 .0 { + self.record( + json!({"kind":"policy_blocked","description":blocked.description}), + ); + } + } + if let Some(error) = + action.downcast_ref::() + { + if error.browser_id == browser_id.0 .0 { + self.record(json!({"kind":"page_error", "code":error.code,"description":error.description})); + } + } + } + } + self.ui.handle_event(cx, event, &mut Scope::empty()); + } +} diff --git a/crates/browser-smoke/src/policy_tests.rs b/crates/browser-smoke/src/policy_tests.rs new file mode 100644 index 00000000..4924feba --- /dev/null +++ b/crates/browser-smoke/src/policy_tests.rs @@ -0,0 +1,111 @@ +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://[fec0::1]/", + "https://home.arpa/", + "https://device.home.arpa/", + "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/browser-smoke/src/windows_message_probe.rs b/crates/browser-smoke/src/windows_message_probe.rs new file mode 100644 index 00000000..c8a5d0d8 --- /dev/null +++ b/crates/browser-smoke/src/windows_message_probe.rs @@ -0,0 +1,377 @@ +//! Positive control for the native message observer, in this unpublished test +//! binary only. It never shares a controller, HWND, profile, or page with the +//! production browser. The fixed document has no network access or host objects. +use std::{ + cell::RefCell, + path::PathBuf, + rc::Rc, + sync::atomic::{AtomicU64, Ordering}, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; +use webview2_com::{Microsoft::Web::WebView2::Win32::*, *}; +use webview2_windows::{ + core::{w, Interface, Result as WinResult, BOOL, PCWSTR}, + Win32::{ + Foundation::{E_FAIL, HWND, RECT}, + System::LibraryLoader::GetModuleHandleW, + UI::WindowsAndMessaging::*, + }, +}; + +static NEXT_PROBE: AtomicU64 = AtomicU64::new(1); +const LOAD_TIMEOUT: Duration = Duration::from_secs(20); +const CLEANUP_TIMEOUT: Duration = Duration::from_secs(10); +const DOCUMENT: &str = r#"Native message observer positive control"#; + +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() +} 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/oauth-service/README.md b/crates/oauth-service/README.md index 4875d0e2..b36fbc41 100644 --- a/crates/oauth-service/README.md +++ b/crates/oauth-service/README.md @@ -117,16 +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 and Windows, `WebReader.open` also refuses: the pinned runtime has no -embedded browser adapter for these platforms, so `open` returns `false` and -reports `Embedded web pages are unavailable on this platform; this host has no native WebReader adapter`. -The optional CEF Browser widget is a separate component and cannot stand in for -the login view: it keeps one persistent profile for the whole program and runs -Chromium without its sandbox, so it lacks the per-app sessions and navigation -limits that login needs. Embedded backend login on these platforms still needs -an isolated adapter that passes native acceptance on each OS; an external -browser does not count as embedded support. +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 4e7df6d1..739984a3 100644 --- a/crates/oauth-service/README.zh-CN.md +++ b/crates/oauth-service/README.zh-CN.md @@ -40,9 +40,12 @@ Rust 授权协议、连接器、原生审阅、账户生命周期和示例界面 后端登录复用原生 WebView 引擎的专用认证模式。macOS 每次登录使用独立、非持久化 WKWebView 存储;Android 9+ 使用不可导出的独立进程 Activity 及唯一 WebView 数据目录,进程退出后删除该目录,不影响阅读器现有 Cookie。导航限制在已注册登录来源,加载前拦截精确回调,页面没有调用应用工具的 JavaScript 桥。返回、取消、加载与重试控件由宿主管理;受限应用不能直接打开认证模式或检查其页面。 -GitHub 和 Google 保留现有提供商授权流程。需要访问其他提供商来源的后端登录应选择桌面浏览器模式;嵌入模式不会悄悄打开外站。Windows/Linux 保留桌面浏览器登录,iOS 后端登录仍不可用。 - -在 Linux 和 Windows 上,`WebReader.open` 同样会拒绝:固定版本的运行时没有适用于这两个平台的嵌入式浏览器适配器,因此 `open` 返回 `false`,并报告 `Embedded web pages are unavailable on this platform; this host has no native WebReader adapter`。可选的 CEF Browser 控件是另一个组件,不能代替登录视图:它为整个程序保留同一个持久化配置目录,并在关闭 Chromium 沙箱的情况下运行,因此缺少登录所需的按应用隔离的会话和导航限制。要在这两个平台上支持嵌入式后端登录,仍需要一个隔离的适配器,并在各个操作系统上通过原生验收;外部浏览器不算嵌入式支持。 +GitHub 和 Google 保留现有提供方授权流程。需要访问其他提供方来源的后端登录应 +选择桌面浏览器模式;嵌入模式不会悄悄打开外站。Windows/Linux 保留桌面浏览器登录, +iOS 后端登录仍不可用。兼容源码宿主为普通 `WebReader` 新增 Linux X11/XWayland +(WebKitGTK)和 Windows(WebView2)适配器,运行条件及原生验收范围见 +[桌面浏览器指南](../../docs/desktop-embedded-browser.zh-CN.md)。这不会启用两平台的 +嵌入式后端认证;该宿主入口仍关闭。原生 Wayland 仍明确报不支持。 ## 开发者后端接口约定 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/crates/shell/Cargo.toml b/crates/shell/Cargo.toml index 4e17faaf..85a39d8c 100644 --- a/crates/shell/Cargo.toml +++ b/crates/shell/Cargo.toml @@ -172,6 +172,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/examples/connected-app-host.rs b/crates/shell/examples/connected-app-host.rs index 9c62d447..cdaf2f34 100644 --- a/crates/shell/examples/connected-app-host.rs +++ b/crates/shell/examples/connected-app-host.rs @@ -72,6 +72,8 @@ impl MatchEvent for App { }) .or(installed); let (bundle, policy) = if let Some(id) = installed { + // connected_support only serves isolated legacy test catalogs. + std::env::set_var("OCTOSENSE_HUB_CATALOG", "legacy"); let launch = connected_support::open(&root, &id).expect("Verify installed launch"); let result = (launch.bundle().to_path_buf(), launch.policy.clone()); println!( diff --git a/crates/shell/examples/connected-inbox-e2e.rs b/crates/shell/examples/connected-inbox-e2e.rs index 82458890..240f52e1 100644 --- a/crates/shell/examples/connected-inbox-e2e.rs +++ b/crates/shell/examples/connected-inbox-e2e.rs @@ -43,6 +43,8 @@ fn setup() { ) .expect("Read fixture metadata"); assert_eq!(meta["fixture"], "connected-e2e"); + // The marked fixture uses its own ephemeral legacy catalog authority. + std::env::set_var("OCTOSENSE_HUB_CATALOG", "legacy"); assert!(meta["apps"] .as_array() .unwrap() diff --git a/crates/shell/examples/connected_support/mod.rs b/crates/shell/examples/connected_support/mod.rs index 061ad1ca..9659099a 100644 --- a/crates/shell/examples/connected_support/mod.rs +++ b/crates/shell/examples/connected_support/mod.rs @@ -17,6 +17,8 @@ const IDS: &[&str] = &[ #[cfg(feature = "acceptance-fixtures")] "org.octosense.samples.apilab", #[cfg(feature = "acceptance-fixtures")] + "org.octosense.samples.wasmprobe", + #[cfg(feature = "acceptance-fixtures")] "org.octosense.samples.backend", #[cfg(feature = "acceptance-fixtures")] "org.octosense.samples.backendother", diff --git a/crates/shell/examples/host-api-lab.rs b/crates/shell/examples/host-api-lab.rs index 577b4d8a..3b8c247e 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 { @@ -81,6 +89,8 @@ impl MatchEvent for App { } else { let installed = connected_support::install(&[bundle.clone()], &root).expect("Signed installation"); + // This example creates an ephemeral legacy catalog, not the public Hub. + std::env::set_var("OCTOSENSE_HUB_CATALOG", "legacy"); std::env::set_var( "OCTOSENSE_HUB_ANCHOR", installed["anchor"].as_str().unwrap(), @@ -117,9 +127,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 +220,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/crates/shell/src/app_chat/tests.rs b/crates/shell/src/app_chat/tests.rs index 7fe098e6..f59a2ba0 100644 --- a/crates/shell/src/app_chat/tests.rs +++ b/crates/shell/src/app_chat/tests.rs @@ -501,6 +501,7 @@ impl AgentFixture { .env("OCTOSENSE_CHAT_FIXTURE_CHILD", test) .env("OCTOSENSE_CHAT_FIXTURE_ROOT", &root) .env("OCTOSENSE_HUB_ANCHOR", anchor.public_hex()) + .env("OCTOSENSE_HUB_CATALOG", "legacy") .output(); let _ = std::fs::remove_dir_all(&root); let output = output.expect("start isolated app-chat test"); diff --git a/crates/shell/src/app_storage/check.rs b/crates/shell/src/app_storage/check.rs index 95f5bd52..0b1eda30 100644 --- a/crates/shell/src/app_storage/check.rs +++ b/crates/shell/src/app_storage/check.rs @@ -153,7 +153,7 @@ pub fn check_workspaces(layout: &Layout) -> Report { for app in apps { let app_id = app.file_name().to_string_lossy().into_owned(); if validate_app_id(&app_id).is_err() { - continue; // App Hub's own `.host`, `.system`, `catalog.json` + continue; // Host directories and catalog cache/lock files are not apps. } let jail = app.path(); let accounts = jail.join("accounts"); diff --git a/crates/shell/src/app_storage/mod.rs b/crates/shell/src/app_storage/mod.rs index 1ee28f9a..3e6d9711 100644 --- a/crates/shell/src/app_storage/mod.rs +++ b/crates/shell/src/app_storage/mod.rs @@ -104,13 +104,15 @@ pub fn account_hash(account: &str) -> String { } /// An app id usable as one path component: `[A-Za-z0-9._-]{1,128}`, -/// starting with a letter or digit (App Hub keeps `.host`, `.system` and -/// `catalog.json` beside the jails), no `..`. +/// starting with a letter or digit, no `..`. Host catalog cache/lock names +/// are reserved too, including on case-insensitive filesystems. pub fn validate_app_id(id: &str) -> Result<(), String> { let ok_len = !id.is_empty() && id.len() <= 128; let ok_chars = id.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')); let ok_first = id.bytes().next().is_some_and(|b| b.is_ascii_alphanumeric()); - if ok_len && ok_chars && ok_first && !id.contains("..") && id != "catalog.json" { + let host_file = ["catalog.json", "catalog.lock", "catalog-v2.json", "catalog-v2.lock"] + .iter().any(|name| id.eq_ignore_ascii_case(name)); + if ok_len && ok_chars && ok_first && !id.contains("..") && !host_file { Ok(()) } else { Err(format!("invalid app id {id:?}")) diff --git a/crates/shell/src/app_storage/tests.rs b/crates/shell/src/app_storage/tests.rs index 15593c68..e4128c32 100644 --- a/crates/shell/src/app_storage/tests.rs +++ b/crates/shell/src/app_storage/tests.rs @@ -83,6 +83,20 @@ fn app_ids_are_single_plain_path_components() { } } +#[test] +fn catalog_caches_and_locks_cannot_become_an_app_jail() { + let home = Scratch::new("catalog-host-files"); + let host = storage(&home.0); + for name in ["catalog.json", "catalog.lock", "catalog-v2.json", "catalog-v2.lock"] { + for id in [name.to_string(), name.to_ascii_uppercase()] { + assert!(validate_app_id(&id).is_err(), "{id}"); + assert!(host.open(&id).is_err(), "{id} must not create storage over a host file"); + assert!(!home.0.join("apps").join(&id).exists()); + } + } + assert!(host.open("org.example.catalog").is_ok()); +} + // ---- the account hash ----------------------------------------------------- #[test] @@ -484,6 +498,9 @@ fn populated(tag: &str) -> (Scratch, Arc, PathBuf, PathBuf) { // App Hub's own entries beside the jails are not apps. std::fs::create_dir_all(home.0.join("apps/.host/model")).unwrap(); std::fs::write(home.0.join("apps/catalog.json"), "{}").unwrap(); + for name in ["catalog.lock", "catalog-v2.json", "catalog-v2.lock"] { + std::fs::write(home.0.join("apps").join(name), "host metadata").unwrap(); + } let secret = home.0.join("secrets/rinx/token"); (home, host, ws, secret) } diff --git a/crates/shell/src/connected_backends.rs b/crates/shell/src/connected_backends.rs index 674ba848..acf74811 100644 --- a/crates/shell/src/connected_backends.rs +++ b/crates/shell/src/connected_backends.rs @@ -21,7 +21,7 @@ pub fn register() { if let Some(root) = octosense_appstore::data_root_if_set() { let stamp = ( root.clone(), - file_stamp(&root.join("catalog.json")), + catalog_stamp(&root, octosense_appstore::source::CatalogChannel::from_environment(&root)), file_stamp(&root.join(".host/oauth/connections.json")), octosense_app_hub_app::icons::generation(), ); @@ -49,6 +49,17 @@ fn file_stamp(path: &Path) -> Option<(u64, std::time::SystemTime)> { Some((metadata.len(), metadata.modified().ok()?)) } +// Include the selected channel even if its cache is missing. A transition to +// v2 (or a refused channel selection) must revalidate existing registrations; +// otherwise a quiet legacy cache could leave them active until the next call. +fn catalog_stamp( + root: &Path, + channel: Result, +) -> Result<(&'static str, Option<(u64, std::time::SystemTime)>), String> { + let channel = channel?; + Ok((channel.filename(), file_stamp(&root.join(channel.filename())))) +} + fn valid_backend_app_id(app: &str) -> bool { !app.is_empty() && app.len() <= 64 @@ -165,6 +176,26 @@ pub fn installed_changed(app: &str) { #[cfg(test)] mod tests { use super::*; + #[test] + fn watcher_tracks_selected_catalog_updates_and_channel_refusal() { + use octosense_appstore::source::CatalogChannel::{GitHub, Legacy}; + let root = std::env::temp_dir().join(format!("backend-catalog-watch-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("catalog.json"), "unchanged legacy").unwrap(); + let legacy = catalog_stamp(&root, Ok(Legacy)); + let missing_v2 = catalog_stamp(&root, Ok(GitHub)); + assert_ne!(legacy, missing_v2, "changing channel must invalidate even before its cache arrives"); + std::fs::write(root.join("catalog-v2.json"), "first v2").unwrap(); + let first_v2 = catalog_stamp(&root, Ok(GitHub)); + assert_ne!(missing_v2, first_v2); + std::fs::write(root.join("catalog-v2.json"), "updated v2 withdrawal").unwrap(); + assert_ne!(first_v2, catalog_stamp(&root, Ok(GitHub))); + assert_eq!(legacy, catalog_stamp(&root, Ok(Legacy)), "v2 refresh is independent of legacy bytes"); + assert_ne!(legacy, catalog_stamp(&root, Err("downgrade refused".into()))); + std::fs::remove_file(root.join("catalog-v2.json")).unwrap(); + assert_eq!(missing_v2, catalog_stamp(&root, Ok(GitHub)), "cache removal must invalidate too"); + std::fs::remove_dir_all(root).unwrap(); + } fn manifest() -> Value { serde_json::json!({"schema":1,"id":"org.example.backend","version":"1.0.0","name":"Backend fixture", "integrity":{"bundle_blake3":"00".repeat(32)},"capabilities":["auth"],"storage":{"accounts":true}, diff --git a/crates/shell/src/glance_card.rs b/crates/shell/src/glance_card.rs index 2d1fcbe8..23c9019a 100644 --- a/crates/shell/src/glance_card.rs +++ b/crates/shell/src/glance_card.rs @@ -1351,10 +1351,14 @@ fn admitted_app_isolate(cx: &Cx, app: &str) -> Result octosense_appstore::system::prepare(&root, &system)?.1, None => { let anchor = std::env::var("OCTOSENSE_HUB_ANCHOR").unwrap_or_else(|_| octosense_appstore::DEFAULT_ANCHOR.to_string()); - let mut store = octosense_app_hub::Store::new(&anchor, &root, octosense_app_contract::HostLimits::default()) - .with_host_api_versions(octosense_appstore::host_api::available_versions()); - let catalog = std::fs::read_to_string(root.join("catalog.json")).unwrap_or_default(); + let channel = octosense_appstore::source::CatalogChannel::from_environment(&root)?; + let mut store = channel.configure(octosense_app_hub::Store::new(&anchor, &root, octosense_app_contract::HostLimits::default()) + .with_host_api_versions(octosense_appstore::host_api::available_versions())); + let catalog = channel.read_cache(&root).map_err(|e| format!("no verified catalog on this device ({e})"))?; store.accept_catalog(&catalog).map_err(|e| format!("no verified catalog on this device ({e})"))?; + octosense_app_hub_app::catalog::check_verified_catalog_floor( + &root, &anchor, store.catalog().map(|catalog| catalog.sequence), + )?; store.may_run(app)? } }; diff --git a/crates/shell/src/host_tools/admission.rs b/crates/shell/src/host_tools/admission.rs index 2ed48e82..95529440 100644 --- a/crates/shell/src/host_tools/admission.rs +++ b/crates/shell/src/host_tools/admission.rs @@ -24,15 +24,19 @@ pub(crate) fn installed_bundle(root: &Path, app: &str) -> Result Result { - let mut store = octosense_app_hub::Store::new( + let channel = octosense_appstore::source::CatalogChannel::from_environment(root)?; + let mut store = channel.configure(octosense_app_hub::Store::new( anchor, root, octosense_app_contract::HostLimits::default(), ) - .with_host_api_versions(octosense_appstore::host_api::available_versions()); - let catalog = std::fs::read_to_string(root.join("catalog.json")) + .with_host_api_versions(octosense_appstore::host_api::available_versions())); + let catalog = channel.read_cache(root) .map_err(|_| "No verified App Hub catalog is available on this device".to_string())?; store.accept_catalog(&catalog).map_err(|e| format!("App Hub catalog refused: {e}"))?; + octosense_app_hub_app::catalog::check_verified_catalog_floor( + root, anchor, store.catalog().map(|catalog| catalog.sequence), + )?; store.may_run(app).map_err(|e| format!("App agent is unavailable: {e}"))?; Ok(store.install_dir(app)) } @@ -45,6 +49,17 @@ mod tests { #[test] fn cached_agent_admission_tracks_signed_withdrawal_and_bundle_integrity() { + const CHILD: &str = "OCTOSENSE_TEST_AGENT_ADMISSION"; + if std::env::var_os(CHILD).is_none() { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "host_tools::admission::tests::cached_agent_admission_tracks_signed_withdrawal_and_bundle_integrity", "--nocapture"]) + .env(CHILD, "1") + .env("OCTOSENSE_HUB_CATALOG", "legacy") + .output().unwrap(); + assert!(output.status.success(), "{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); + assert!(String::from_utf8_lossy(&output.stdout).contains("1 passed")); + return; + } let root = std::env::temp_dir().join(format!("agent-withdrawal-{}", uuid::Uuid::new_v4())); let id = "org.example.fixture"; let bundle = octosense_app_hub::installed_bundle_dir(&root, id); @@ -72,6 +87,16 @@ mod tests { write(&mut catalog); let check = || installed_bundle_with_anchor(&root,id,&anchor.public_hex()); assert_eq!(check().unwrap(), bundle); + // This isolated legacy fixture must stop when a v2 cache appears. + // Neither an invalid proof nor a legacy document renamed to v2 may + // let an agent continue using the offered legacy release beside it. + let v2 = root.join("catalog-v2.json"); + for bytes in ["{}".to_string(), std::fs::read_to_string(root.join("catalog.json")).unwrap()] { + std::fs::write(&v2, bytes).unwrap(); + assert!(check().unwrap_err().contains("downgrade refused")); + } + std::fs::remove_file(v2).unwrap(); + assert_eq!(check().unwrap(), bundle); catalog.sequence += 1; catalog.entries[0].status = Status::Withdrawn("unsafe release".into()); write(&mut catalog); diff --git a/crates/shell/src/wasm_service.rs b/crates/shell/src/wasm_service.rs index 3b2952b9..e1c41292 100644 --- a/crates/shell/src/wasm_service.rs +++ b/crates/shell/src/wasm_service.rs @@ -18,78 +18,332 @@ //! gate and the tool executor both require the app's `wasm` capability, and //! the service checks the admitted manifest again before it loads anything. //! -//! **Where it runs.** Requests arrive on the UI thread. Each app gets one -//! worker thread holding its instances, so a slow function holds up only -//! its own app. Compiled code is cached in the host directory -//! (`wasm-cache`), keyed by the module's digest. An instance that trapped or -//! ran past its deadline is replaced before the next call. -//! -//! Not done yet: an app updated while the shell runs keeps its old -//! functions until the shell restarts, and a worker lives as long as the -//! shell. +//! **Where it runs.** A bounded worker per active app caches compiled Programs, +//! never guest instances. Every invocation starts with fresh memory/globals/tables. +//! Admission, cancellation and deadline are checked before execution and delivery; +//! an update or withdrawal invalidates loaded Programs. Workers exit when idle. +//! The host-only disk cache holds compiled code keyed by module digest, not data. use std::collections::{BTreeMap, HashMap}; +use std::io::Write; use std::path::{Path, PathBuf}; -use std::sync::mpsc::{channel, Receiver, SendError, Sender}; -use std::sync::{Mutex, OnceLock}; -use std::time::Instant; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::mpsc::{sync_channel, Receiver, SyncSender, TrySendError}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, Instant}; use octosense_appstore::services::{HostService, Replier, ServiceCall, ServiceHost}; -use octosense_wasm_host::{Instance, Limits, Program, Runtime}; +use octosense_wasm_host::{Limits, Program, Runtime}; use serde_json::{json, Value}; use crate::host_tools::script_apps; -/// The most modules one app's bundle may carry. const MAX_MODULES: usize = 8; +const MAX_WORKERS: usize = 4; +const MAX_QUEUED_PER_APP: usize = 4; +const MAX_INPUT_BYTES: usize = 1 << 20; +const MAX_BUFFERED_BYTES: usize = 16 << 20; +const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); +const WORKER_IDLE: Duration = Duration::from_secs(5); -/// One engine, one epoch ticker and one code cache for the process. static RUNTIME: OnceLock> = OnceLock::new(); +static WORKERS: Mutex>> = Mutex::new(None); +static BUFFERED_BYTES: AtomicUsize = AtomicUsize::new(0); + +struct Worker { + jobs: SyncSender, + retired: Arc, + admission: Arc, +} + +/// Enqueue only clones this host-owned identity. Bundle IO, signature checks +/// and digesting run on the worker, including the first cold admission. +#[derive(Default)] +struct AdmissionEpoch { + admitted: OnceLock>, + obsolete: AtomicBool, +} + +/// Removed only after the worker has dropped its queue and compiled modules. +/// A closing worker keeps its slot until then, even during revision churn. +struct RetireWorker { + app: String, + retired: Arc, +} +impl Drop for RetireWorker { + fn drop(&mut self) { + let mut workers = WORKERS.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(workers) = workers.as_mut() { + if workers + .get(&self.app) + .is_some_and(|worker| Arc::ptr_eq(&worker.retired, &self.retired)) + { + workers.remove(&self.app); + } + } + } +} + +/// Includes executing input: cancelling a queued request cannot free its +/// reservation until the worker actually drops those bytes. +struct Reservation(usize); +impl Reservation { + fn acquire(bytes: usize) -> Result { + BUFFERED_BYTES + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |used| { + used.checked_add(bytes) + .filter(|total| *total <= MAX_BUFFERED_BYTES) + }) + .map(|_| Self(bytes)) + .map_err(|_| "wasm input queue is full; try again later".into()) + } +} +impl Drop for Reservation { + fn drop(&mut self) { + BUFFERED_BYTES.fetch_sub(self.0, Ordering::AcqRel); + } +} -/// Each app's worker, by app id. -static WORKERS: Mutex>>> = Mutex::new(None); +/// Authority is the signed, digest-checked bundle, never request arguments. +/// Comparing the whole admitted manifest includes the version and grants. +#[derive(Clone, Debug, PartialEq)] +struct Admission { + root: PathBuf, + bundle: PathBuf, + manifest: Value, +} +impl Admission { + fn current(app: &str) -> Result { + let (root, bundle) = script_apps::admitted_bundle(app)?; + let loaded = script_apps::from_bundle(&bundle)?; + if !loaded.families.contains("wasm") { + return Err(format!("{app} was not granted the wasm service")); + } + Ok(Self { + root, + bundle, + manifest: loaded.manifest, + }) + } + fn verify(&self, app: &str) -> Result<(), String> { + if Self::current(app)? != *self { + return Err("wasm app admission changed; retry from the current app".into()); + } + Ok(()) + } +} struct Job { method: String, - args: Value, + input: Vec, host_dir: PathBuf, + admission: Arc, + deadline: Instant, reply: Replier, + _reservation: Reservation, +} +impl Job { + fn live(&self) -> bool { + self.reply.is_pending() && Instant::now() < self.deadline + } + fn check(&self, app: &str) -> Result<&Admission, String> { + if !self.live() { + return Err("wasm invocation cancelled or past its deadline".into()); + } + if self.admission.obsolete.load(Ordering::Acquire) { + return Err("wasm app admission changed; retry from the current app".into()); + } + let admitted = self + .admission + .admitted + .get_or_init(|| Admission::current(app)); + let verified = admitted + .as_ref() + .map_err(Clone::clone) + .and_then(|admission| { + admission.verify(app)?; + Ok(admission) + }); + let admission = match verified { + Ok(admission) => admission, + Err(error) => { + self.admission.obsolete.store(true, Ordering::Release); + return Err(error); + } + }; + // The cache is host-owned, not a caller-selected or app directory. + if self.host_dir != admission.root.join(".host") { + return Err("wasm host directory does not belong to this app store".into()); + } + if !self.live() { + return Err("wasm invocation cancelled or past its deadline".into()); + } + Ok(admission) + } } -/// The `wasm` family for the Card runner and the tool executor. -pub struct WasmService; +/// Serialize into bounded storage, rather than allocating an unlimited JSON +/// buffer and only then discovering the request is too large. +fn input_bytes(args: &Value) -> Result, String> { + struct Bounded(Vec); + impl Write for Bounded { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + if bytes.len() > MAX_INPUT_BYTES.saturating_sub(self.0.len()) { + return Err(std::io::Error::other("wasm input exceeds 1 MiB")); + } + self.0.extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + if let Value::String(text) = args { + if text.len() > MAX_INPUT_BYTES { + return Err("wasm input exceeds 1 MiB".into()); + } + return Ok(text.as_bytes().to_vec()); + } + let mut out = Bounded(Vec::new()); + serde_json::to_writer(&mut out, args).map_err(|error| error.to_string())?; + Ok(out.0) +} +pub struct WasmService; impl HostService for WasmService { fn family(&self) -> &'static str { "wasm" } - + fn timeout(&self, _call: &ServiceCall) -> Duration { + REQUEST_TIMEOUT + } fn call(&mut self, call: ServiceCall, reply: Replier, _host: &mut dyn ServiceHost) { if call.from_sheet { reply.send(Err("the wasm service has no sheet".into())); return; } + if !reply.is_pending() { + return; + } + let deadline = Instant::now() + REQUEST_TIMEOUT; + let prepared = (|| { + let input = input_bytes(&call.args)?; + let reservation = Reservation::acquire(input.len())?; + Ok((input, reservation)) + })(); + let (input, reservation) = match prepared { + Ok(prepared) => prepared, + Err(error) => { + reply.send(Err(error)); + return; + } + }; let app = call.app_id.clone(); - let job = Job { method: call.method().to_string(), host_dir: call.host_dir, args: call.args, reply }; - let mut workers = WORKERS.lock().unwrap_or_else(|e| e.into_inner()); - let workers = workers.get_or_insert_with(HashMap::new); - // A worker that ended (only a panic ends one) is replaced. - let job = match workers.get(&app) { - Some(jobs) => match jobs.send(job) { + let mut job = Job { + method: call.method().to_string(), + host_dir: call.host_dir, + input, + admission: Arc::default(), + deadline, + reply, + _reservation: reservation, + }; + if !job.live() { + return; + } + let mut registry = WORKERS.lock().unwrap_or_else(|e| e.into_inner()); + let workers = registry.get_or_insert_with(HashMap::new); + if let Some(worker) = workers.get_mut(&app) { + if worker.retired.load(Ordering::Acquire) { + job.reply + .send(Err("wasm worker is closing; try again later".into())); + return; + } + if worker.admission.obsolete.load(Ordering::Acquire) { + worker.admission = Arc::default(); + } + job.admission = worker.admission.clone(); + match worker.jobs.try_send(job) { Ok(()) => return, - Err(SendError(job)) => job, + Err(TrySendError::Full(job)) => { + job.reply + .send(Err("wasm app queue is full; try again later".into())); + return; + } + Err(TrySendError::Disconnected(job)) => { + // The worker's guard removes its slot after all resources + // have dropped. Do not create an overlapping replacement. + job.reply + .send(Err("wasm worker is closing; try again later".into())); + return; + } + }; + } + if workers.len() >= MAX_WORKERS { + job.reply + .send(Err("wasm workers are busy; try again later".into())); + return; + } + let (jobs, queue) = sync_channel(MAX_QUEUED_PER_APP); + let retired = Arc::new(AtomicBool::new(false)); + let admission = job.admission.clone(); + // Reserve the worker slot and queue before spawning, then release the + // registry mutex: neither thread creation nor admission holds it. + if jobs.try_send(job).is_err() { + unreachable!("new wasm queue has room"); + } + workers.insert( + app.clone(), + Worker { + jobs, + retired: retired.clone(), + admission, }, - None => job, - }; - let (jobs, queue) = channel(); + ); + // Keep ownership outside the spawn closure too, so a spawn failure + // can explicitly answer every request accepted during this interval. + let queue = Arc::new(Mutex::new(Some(queue))); + drop(registry); + self.start_worker(app, retired, queue); + } +} + +impl WasmService { + fn start_worker( + &self, + app: String, + retired: Arc, + queue: Arc>>>, + ) { let name = app.clone(); - let spawned = std::thread::Builder::new().name(format!("wasm {app}")).spawn(move || work(name, queue)); + let worker_queue = queue.clone(); + let worker_retired = retired.clone(); + let spawned = std::thread::Builder::new() + .name(format!("wasm {app}")) + .spawn(move || { + let _retire = RetireWorker { + app: name.clone(), + retired: worker_retired.clone(), + }; + let queue = worker_queue + .lock() + .unwrap_or_else(|e| e.into_inner()) + .take() + .unwrap(); + work(name, queue, &worker_retired); + }); if let Err(error) = spawned { - job.reply.send(Err(format!("cannot start {app}'s functions: {error}"))); - return; + let registry = WORKERS.lock().unwrap_or_else(|e| e.into_inner()); + retired.store(true, Ordering::Release); + drop(registry); + if let Some(queue) = queue.lock().unwrap_or_else(|e| e.into_inner()).take() { + for job in queue.try_iter() { + job.reply + .send(Err(format!("cannot start {app}'s functions: {error}"))); + } + } + drop(RetireWorker { app, retired }); } - let _ = jobs.send(job); - workers.insert(app, jobs); } } @@ -97,25 +351,79 @@ pub fn register() { octosense_appstore::services::register_host_service(Box::new(WasmService)); } -/// One app's worker: loads its modules on the first request (again on the -/// next one, if that failed), then answers each request in turn. -fn work(app: String, jobs: Receiver) { +fn work(app: String, jobs: Receiver, retired: &Arc) { let mut lab: Option = None; - for job in jobs { - if lab.is_none() { - match Lab::load(&app, &job.host_dir) { - Ok(loaded) => lab = Some(loaded), - Err(error) => { - makepad_widgets::log!("wasm {app}: {error}"); - job.reply.send(Err(error)); - continue; + loop { + let job = match jobs.recv_timeout(WORKER_IDLE) { + Ok(job) => job, + Err(_) => { + // Serialize idle retirement with enqueue. A sender that won + // the timeout race still gets its accepted request processed. + let _workers = WORKERS.lock().unwrap_or_else(|e| e.into_inner()); + if let Ok(job) = jobs.try_recv() { + job + } else { + retired.store(true, Ordering::Release); + // The outer retirement guard removes the slot only once + // this function has dropped the Lab and queued inputs. + return; } } + }; + let result = process(&app, &mut lab, &job); + // Cancellation can win while admission is being checked. Replier's + // pending-request check remains the final isolate boundary. + if job.live() { + job.reply.send(result); } - if let Some(lab) = lab.as_mut() { - job.reply.send(lab.answer(&job.method, &job.args)); + } +} + +fn process(app: &str, lab: &mut Option, job: &Job) -> Result { + process_checked(app, lab, job, || {}) +} + +// The completion boundary is explicit so regressions can replace the real +// admitted bundle precisely after guest execution, without timing sleeps. +fn process_checked( + app: &str, + lab: &mut Option, + job: &Job, + before_delivery: impl FnOnce(), +) -> Result { + let admission = match job.check(app) { + Ok(admission) => admission, + Err(error) => { + *lab = None; + return Err(error); } + }; + if lab + .as_ref() + .is_some_and(|loaded| loaded.admission != *admission) + { + *lab = None; + } + if lab.is_none() { + *lab = Some(Lab::load(app, job)?); + } + // Compilation and start-up work must not revive an expired request. + if let Err(error) = job.check(app) { + *lab = None; + return Err(error); } + let result = + lab.as_mut() + .unwrap() + .answer(&job.method, &job.input, job.deadline, job.reply.clone()); + before_delivery(); + // A withdrawal, changed grant, update or tamper while code runs vetoes + // its result and releases the old compiled-module references. + if let Err(error) = job.check(app) { + *lab = None; + return Err(error); + } + result } fn runtime(host_dir: &Path) -> Result<&'static Runtime, String> { @@ -133,6 +441,7 @@ struct Lab { /// Which module exports each function. owner: BTreeMap, stats: BTreeMap, + admission: Admission, } struct Module { @@ -141,9 +450,9 @@ struct Module { load_ms: f64, from_cache: bool, program: Program, - instance: Instance, - /// Fresh instances made after a trap or a deadline. - renewed: u32, + /// Diagnostic high-water mark only: guest memory is never retained. + memory_bytes: usize, + invocations: u64, } /// How one function has run: in the worker, from input to output. @@ -156,12 +465,10 @@ struct Stats { } impl Lab { - fn load(app: &str, host_dir: &Path) -> Result { - let (_, bundle) = script_apps::admitted_bundle(app)?; - if !script_apps::from_bundle(&bundle)?.families.contains("wasm") { - return Err(format!("{app} was not granted the wasm service")); - } - let runtime = runtime(host_dir)?; + fn load(app: &str, job: &Job) -> Result { + let admission = job.check(app)?; + let bundle = &admission.bundle; + let runtime = runtime(&job.host_dir)?; let mut files: Vec = std::fs::read_dir(bundle.join("fns")) .map_err(|_| format!("{app}'s bundle has no fns directory"))? .flatten() @@ -173,34 +480,76 @@ impl Lab { .collect(); files.sort(); if files.is_empty() || files.len() > MAX_MODULES { - return Err(format!("{app}'s bundle must carry 1 to {MAX_MODULES} fns/*.wasm modules")); + return Err(format!( + "{app}'s bundle must carry 1 to {MAX_MODULES} fns/*.wasm modules" + )); } - let mut lab = Lab { app: app.to_string(), runtime, modules: Vec::new(), owner: BTreeMap::new(), stats: BTreeMap::new() }; + let mut lab = Lab { + app: app.to_string(), + runtime, + modules: Vec::new(), + owner: BTreeMap::new(), + stats: BTreeMap::new(), + admission: admission.clone(), + }; for path in files { - let file = path.file_name().unwrap_or_default().to_string_lossy().into_owned(); + job.check(app)?; + let file = path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + if std::fs::metadata(&path).map_err(|e| e.to_string())?.len() + > runtime.limits().module_bytes as u64 + { + return Err(format!("{file}: module exceeds the size limit")); + } let bytes = std::fs::read(&path).map_err(|e| format!("{file}: {e}"))?; let started = Instant::now(); let program = runtime.load(&bytes).map_err(|e| format!("{file}: {e}"))?; - let instance = runtime.instantiate(&program).map_err(|e| format!("{file}: {e}"))?; let load_ms = started.elapsed().as_secs_f64() * 1e3; for function in program.functions() { - if lab.owner.insert(function.clone(), lab.modules.len()).is_some() { - return Err(format!("{file}: {function} is exported by another module too")); + if lab + .owner + .insert(function.clone(), lab.modules.len()) + .is_some() + { + return Err(format!( + "{file}: {function} is exported by another module too" + )); } } let from_cache = program.from_cache(); makepad_widgets::log!( "wasm {app}: {file} ({} KiB) {} in {load_ms:.1} ms: {}", bytes.len() / 1024, - if from_cache { "loaded from the cache" } else { "compiled" }, + if from_cache { + "loaded from the cache" + } else { + "compiled" + }, program.functions().join(", ") ); - lab.modules.push(Module { file, bytes: bytes.len(), load_ms, from_cache, program, instance, renewed: 0 }); + lab.modules.push(Module { + file, + bytes: bytes.len(), + load_ms, + from_cache, + program, + memory_bytes: 0, + invocations: 0, + }); } Ok(lab) } - fn answer(&mut self, method: &str, args: &Value) -> Result { + fn answer( + &mut self, + method: &str, + input: &[u8], + deadline: Instant, + reply: Replier, + ) -> Result { if method == "functions" { // Also in the log: where there is no remote instrument (an // Android build), the numbers are read from logcat. @@ -212,20 +561,21 @@ impl Lab { return Err(format!("{} has no function {method:?}", self.app)); }; let module = &mut self.modules[index]; - if module.instance.spent() { - module.instance = self.runtime.instantiate(&module.program).map_err(|e| e.to_string())?; - module.renewed += 1; - } - let input = match args { - Value::String(text) => text.clone().into_bytes(), - other => serde_json::to_vec(other).map_err(|e| e.to_string())?, - }; let started = Instant::now(); - let result = module.instance.call(method, &input); + let mut instance = self + .runtime + .instantiate_guarded(&module.program, deadline, move || reply.is_pending()) + .map_err(|error| error.to_string())?; + module.invocations += 1; + let result = instance.call(method, input); let us = started.elapsed().as_secs_f64() * 1e6; - for line in module.instance.take_logs() { + module.memory_bytes = module.memory_bytes.max(instance.memory_bytes()); + for line in instance.take_logs() { makepad_widgets::log!("wasm {}: {line}", self.app); } + // Drop guest memory, mutable globals, tables and logs after every + // invocation, including successful calls and guest-returned errors. + drop(instance); let stats = self.stats.entry(method.to_string()).or_default(); stats.calls += 1; stats.total_us += us; @@ -234,7 +584,8 @@ impl Lab { stats.errors += 1; error.to_string() })?; - Ok(serde_json::from_slice(&output).unwrap_or_else(|_| json!({"text": String::from_utf8_lossy(&output)}))) + Ok(serde_json::from_slice(&output) + .unwrap_or_else(|_| json!({"text": String::from_utf8_lossy(&output)}))) } fn describe(&self) -> Value { @@ -243,7 +594,7 @@ impl Lab { .iter() .map(|m| { json!({"file": m.file, "bytes": m.bytes, "load_ms": round(m.load_ms), - "from_cache": m.from_cache, "memory_bytes": m.instance.memory_bytes(), "renewed": m.renewed}) + "from_cache": m.from_cache, "memory_bytes": m.memory_bytes, "invocations": m.invocations, "renewed": m.invocations.saturating_sub(1), "instance_policy": "fresh-per-call"}) }) .collect(); let stats: serde_json::Map = self @@ -277,12 +628,22 @@ mod tests { let dir = script_apps::tests::stamped_bundle("wasmlab", "tools", |_, _| {}); let loaded = script_apps::from_bundle(&dir).unwrap(); let _ = std::fs::remove_dir_all(&dir); - let names: Vec<&str> = loaded.tools.iter().filter_map(|t| t["name"].as_str()).collect(); - assert_eq!(names, ["wasmlab.find_slots", "wasmlab.rank", "wasmlab.diff"]); + let names: Vec<&str> = loaded + .tools + .iter() + .filter_map(|t| t["name"].as_str()) + .collect(); + assert_eq!( + names, + ["wasmlab.find_slots", "wasmlab.rank", "wasmlab.diff"] + ); assert_eq!(loaded.host_methods["wasmlab.find_slots"], "wasm.find_slots"); assert_eq!(loaded.host_methods["wasmlab.rank"], "wasm.fuzzy_rank"); assert_eq!(loaded.host_methods["wasmlab.diff"], "wasm.text_diff"); - assert!(loaded.tools.iter().all(|t| t["risk"] == "read" && t["output_schema"]["type"] == "object")); + assert!(loaded + .tools + .iter() + .all(|t| t["risk"] == "read" && t["output_schema"]["type"] == "object")); assert_eq!(loaded.families.iter().collect::>(), ["wasm"]); assert_eq!(loaded.generic, ["ask_user_question"]); } @@ -298,10 +659,19 @@ mod tests { /// What the app's script would get from `host.request(service, args)`. fn request(app: &str, service: &str, args: Value, host_dir: &Path) -> Result { let heap = NEXT_HEAP.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let call = ServiceCall { app_id: app.into(), service: service.into(), args, from_sheet: false, may_prompt: true, host_dir: host_dir.into() }; + let call = ServiceCall { + app_id: app.into(), + service: service.into(), + args, + from_sheet: false, + may_prompt: true, + host_dir: host_dir.into(), + }; octosense_appstore::services::dispatch(call, heap, 1, &mut NoSheet); for _ in 0..1000 { - if let Some((_, _, answer)) = octosense_appstore::services::take_replies_for(&[heap]).pop() { + if let Some((_, _, answer)) = + octosense_appstore::services::take_replies_for(&[heap]).pop() + { return answer.map(|text| serde_json::from_str(&text).unwrap()); } std::thread::sleep(Duration::from_millis(10)); @@ -326,6 +696,440 @@ mod tests { }); } + // A real guest deliberately retains the previous input in both linear + // memory and a mutable global. Reusing the old instance leaks it on recall. + const STATE_GUEST: &str = r#"(module + (memory (export "memory") 1) + (global $size (mut i32) (i32.const 0)) + (data (i32.const 8) "\01failed") + (func (export "octo_alloc") (param i32) (result i32) (i32.const 4096)) + (func (export "octo_free") (param i32 i32)) + (func $remember (param $p i32) (param $n i32) + (global.set $size (local.get $n)) + (memory.copy (i32.const 65) (local.get $p) (local.get $n))) + (func (export "remember") (param i32 i32) (result i64) + (call $remember (local.get 0) (local.get 1)) (i64.const 1)) + (func (export "remember_fail") (param i32 i32) (result i64) + (call $remember (local.get 0) (local.get 1)) (i64.const 34359738375)) + (func (export "recall") (param i32 i32) (result i64) + (i64.or (i64.const 274877906944) (i64.extend_i32_u (i32.add (global.get $size) (i32.const 1))))))"#; + + fn ship_state(id: &'static str, version: &str, granted: bool) { + ship(id, "state", |dir, manifest| { + manifest["version"] = json!(version); + manifest["capabilities"] = if granted { json!(["wasm"]) } else { json!([]) }; + manifest.as_object_mut().unwrap().remove("agent"); + std::fs::remove_file(dir.join("tools.json")).unwrap(); + std::fs::remove_file(dir.join("AGENT.md")).unwrap(); + std::fs::remove_file(dir.join("fns/wasmlab.wasm")).unwrap(); + std::fs::write( + dir.join("fns/state.wasm"), + wat::parse_str(if version == "2.0.0" { + STATE_GUEST.replace( + "(global $size (mut i32) (i32.const 0))", + "(global $size (mut i32) (i32.const 3)) (data (i32.const 65) \"new\")", + ) + } else { + STATE_GUEST.to_string() + }) + .unwrap(), + ) + .unwrap(); + }); + } + + fn captured_job(app: &str, method: &str, host_dir: &Path) -> (usize, Job) { + struct Capture(Arc>>); + impl HostService for Capture { + fn family(&self) -> &'static str { + "wasm_capture" + } + fn call(&mut self, _: ServiceCall, reply: Replier, _: &mut dyn ServiceHost) { + *self.0.lock().unwrap() = Some(reply); + } + } + let captured = Arc::new(Mutex::new(None)); + octosense_appstore::services::register_host_service(Box::new(Capture(captured.clone()))); + let heap = NEXT_HEAP.fetch_add(1, Ordering::Relaxed); + octosense_appstore::services::dispatch( + ServiceCall { + app_id: app.into(), + service: "wasm_capture.capture".into(), + args: json!({}), + from_sheet: false, + may_prompt: false, + host_dir: host_dir.into(), + }, + heap, + 1, + &mut NoSheet, + ); + let reply = captured.lock().unwrap().take().unwrap(); + ( + heap, + Job { + method: method.into(), + input: Vec::new(), + host_dir: host_dir.into(), + admission: Arc::new(AdmissionEpoch { + admitted: OnceLock::from(Ok(Admission::current(app).unwrap())), + obsolete: AtomicBool::new(false), + }), + deadline: Instant::now() + REQUEST_TIMEOUT, + reply, + _reservation: Reservation::acquire(0).unwrap(), + }, + ) + } + + #[test] + fn bounded_inputs_reject_strings_and_json_before_queueing() { + assert!(input_bytes(&json!("x".repeat(MAX_INPUT_BYTES + 1))).is_err()); + // String escaping expands an otherwise smaller tree beyond the limit. + assert!(input_bytes(&json!({"x": "\n".repeat(MAX_INPUT_BYTES / 2)})).is_err()); + assert_eq!(input_bytes(&json!("plain")).unwrap(), b"plain"); + assert_eq!(input_bytes(&json!({"n": 1})).unwrap(), br#"{"n":1}"#); + } + + #[test] + fn invocation_isolation_revocation_cancellation_and_queue_bounds() { + const CHILD: &str = "OCTOSENSE_TEST_WASM_ISOLATION"; + if std::env::var_os(CHILD).is_none() { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "wasm_service::tests::invocation_isolation_revocation_cancellation_and_queue_bounds", "--nocapture"]) + .env(CHILD, "1") + .env("OCTOSENSE_HUB_CATALOG", "legacy") + .output().unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + return; + } + let root = std::env::temp_dir().join(format!("wasm-isolation-{}", uuid::Uuid::new_v4())); + let host_dir = root.join(".host"); + octosense_appstore::set_data_root(root.clone()); + ship_state("os.wasmstate", "1.0.0", true); + register(); + // Real dispatch into a held cold queue must neither unpack nor verify + // a bundle on this (UI) thread. The old synchronous admission path + // creates .system here and populates the identity before returning. + let (cold_sender, cold_queue) = sync_channel(MAX_QUEUED_PER_APP); + let cold_epoch = Arc::new(AdmissionEpoch::default()); + WORKERS + .lock() + .unwrap() + .get_or_insert_with(HashMap::new) + .insert( + "os.wasmstate".into(), + Worker { + jobs: cold_sender, + retired: Arc::new(AtomicBool::new(false)), + admission: cold_epoch.clone(), + }, + ); + let cold_heap = NEXT_HEAP.fetch_add(1, Ordering::Relaxed); + octosense_appstore::services::dispatch( + ServiceCall { + app_id: "os.wasmstate".into(), + service: "wasm.recall".into(), + args: json!(null), + from_sheet: false, + may_prompt: false, + host_dir: host_dir.clone(), + }, + cold_heap, + 1, + &mut NoSheet, + ); + assert!(cold_epoch.admitted.get().is_none()); + assert!( + !root.join(".system").exists(), + "enqueue must not touch bundle storage" + ); + let cold_job = cold_queue.try_recv().unwrap(); + assert!(process("os.wasmstate", &mut None, &cold_job).is_ok()); + assert!(cold_epoch.admitted.get().unwrap().is_ok()); + assert!( + root.join(".system").exists(), + "the worker performed actual admission" + ); + octosense_appstore::services::cancel_heap(cold_heap); + drop(cold_job); + WORKERS.lock().unwrap().as_mut().unwrap().clear(); + drop(cold_queue); + // Positive control: this guest really retains input when an Instance + // is reused, reproducing the old host behavior rather than testing an + // inert fixture that could never leak. + let engine = runtime(&host_dir).unwrap(); + let program = engine.load(&wat::parse_str(STATE_GUEST).unwrap()).unwrap(); + let mut reused = engine.instantiate(&program).unwrap(); + reused.call("remember", b"private fixture input").unwrap(); + assert_eq!( + reused.call("recall", b"").unwrap(), + b"private fixture input" + ); + drop(reused); + for method in ["remember", "remember_fail"] { + let result = request( + "os.wasmstate", + &format!("wasm.{method}"), + json!("private fixture input"), + &host_dir, + ); + assert_eq!(result.is_ok(), method == "remember"); + let recalled = request("os.wasmstate", "wasm.recall", json!(null), &host_dir).unwrap(); + assert_eq!( + recalled, + json!({"text":""}), + "no successful or failed call retains private input" + ); + } + + let mut lab = None; + let (heap, job) = captured_job("os.wasmstate", "recall", &host_dir); + octosense_appstore::services::cancel_heap(heap); + assert!(process("os.wasmstate", &mut lab, &job) + .unwrap_err() + .contains("cancelled")); + assert!( + lab.is_none(), + "cancelled queued call never loads or executes guest code" + ); + let (heap, mut job) = captured_job("os.wasmstate", "recall", &host_dir); + job.deadline = Instant::now(); + assert!(process("os.wasmstate", &mut lab, &job) + .unwrap_err() + .contains("deadline")); + assert!(lab.is_none()); + octosense_appstore::services::cancel_heap(heap); + + let (heap, stale) = captured_job("os.wasmstate", "recall", &host_dir); + ship_state("os.wasmstate", "2.0.0", true); + assert!(process("os.wasmstate", &mut lab, &stale) + .unwrap_err() + .contains("admission changed")); + assert!(lab.is_none(), "old queued revision cannot run after update"); + octosense_appstore::services::cancel_heap(heap); + let (heap, current) = captured_job("os.wasmstate", "recall", &host_dir); + assert_eq!( + process("os.wasmstate", &mut lab, ¤t).unwrap(), + json!({"text":"new"}), + "new revision executes its new bytes" + ); + assert!(lab.is_some()); + octosense_appstore::services::cancel_heap(heap); + + // Real registration/grants change exactly between calculation and + // delivery, so this fails if process omits its completion admission check. + let (heap, in_flight) = captured_job("os.wasmstate", "recall", &host_dir); + let error = process_checked("os.wasmstate", &mut lab, &in_flight, || { + ship_state("os.wasmstate", "3.0.0", false) + }) + .unwrap_err(); + assert!(error.contains("not granted"), "{error}"); + assert!( + lab.is_none(), + "revocation drops cached Programs before delivery" + ); + octosense_appstore::services::cancel_heap(heap); + ship_state("os.wasmstate", "4.0.0", true); + let (heap, in_flight) = captured_job("os.wasmstate", "recall", &host_dir); + assert!(process_checked("os.wasmstate", &mut lab, &in_flight, || { + octosense_appstore::services::cancel_heap(heap) + }) + .is_err()); + assert!(octosense_appstore::services::take_replies_for(&[heap]).is_empty()); + + // Exercise the real service enqueue path against a deliberately held + // worker receiver: no timing-dependent slow guest is needed to fill it. + let (tx, held) = sync_channel(MAX_QUEUED_PER_APP); + WORKERS.lock().unwrap().as_mut().unwrap().insert( + "os.wasmstate".into(), + Worker { + jobs: tx, + retired: Arc::new(AtomicBool::new(false)), + admission: Arc::new(AdmissionEpoch { + admitted: OnceLock::from(Ok(Admission::current("os.wasmstate").unwrap())), + obsolete: AtomicBool::new(false), + }), + }, + ); + let mut heaps = Vec::new(); + for i in 0..=MAX_QUEUED_PER_APP { + let heap = NEXT_HEAP.fetch_add(1, Ordering::Relaxed); + heaps.push(heap); + octosense_appstore::services::dispatch( + ServiceCall { + app_id: "os.wasmstate".into(), + service: "wasm.recall".into(), + args: json!({}), + from_sheet: false, + may_prompt: false, + host_dir: host_dir.clone(), + }, + heap, + 1, + &mut NoSheet, + ); + let replies = octosense_appstore::services::take_replies_for(&[heap]); + if i < MAX_QUEUED_PER_APP { + assert!(replies.is_empty()); + } else { + assert!(replies[0].2.as_ref().unwrap_err().contains("queue is full")); + } + } + let queued = held.try_iter().collect::>(); + assert_eq!(queued.len(), MAX_QUEUED_PER_APP); + ship_state("os.wasmstate", "5.0.0", true); + for job in &queued { + assert!( + process("os.wasmstate", &mut lab, job) + .unwrap_err() + .contains("admission changed"), + "the actual dispatch queue retains its original verified revision" + ); + } + let retry_heap = NEXT_HEAP.fetch_add(1, Ordering::Relaxed); + octosense_appstore::services::dispatch( + ServiceCall { + app_id: "os.wasmstate".into(), + service: "wasm.recall".into(), + args: json!(null), + from_sheet: false, + may_prompt: false, + host_dir: host_dir.clone(), + }, + retry_heap, + 1, + &mut NoSheet, + ); + let retry = held.try_recv().unwrap(); + assert!(!Arc::ptr_eq(&retry.admission, &queued[0].admission)); + assert_eq!( + process("os.wasmstate", &mut lab, &retry).unwrap(), + json!({"text":""}), + "a fresh dispatched request re-admits the new revision on the same worker" + ); + octosense_appstore::services::cancel_heap(retry_heap); + drop(retry); + for heap in heaps { + octosense_appstore::services::cancel_heap(heap); + } + assert!(queued.iter().all(|job| !job.live())); + drop(queued); + let used = BUFFERED_BYTES.load(Ordering::Acquire); + assert!(Reservation::acquire(MAX_BUFFERED_BYTES - used).is_ok()); + let all = Reservation::acquire(MAX_BUFFERED_BYTES - used).unwrap(); + assert!(Reservation::acquire(1).is_err()); + drop(all); + assert_eq!(BUFFERED_BYTES.load(Ordering::Acquire), used); + WORKERS.lock().unwrap().as_mut().unwrap().clear(); + // The global limit is enforced even when one more admitted app has + // no existing queue. These receiver handles stand in for busy workers. + let mut held_workers = Vec::new(); + for n in 0..MAX_WORKERS { + let (jobs, queue) = sync_channel(MAX_QUEUED_PER_APP); + held_workers.push(queue); + WORKERS.lock().unwrap().as_mut().unwrap().insert( + format!("busy-{n}"), + Worker { + jobs, + // Closing workers still hold resources. They must not + // release a slot merely because retirement has started. + retired: Arc::new(AtomicBool::new(n == 0)), + admission: Arc::default(), + }, + ); + } + assert!( + request("os.wasmstate", "wasm.recall", json!(null), &host_dir) + .unwrap_err() + .contains("workers are busy") + ); + WORKERS.lock().unwrap().as_mut().unwrap().clear(); + drop(held_workers); + request("os.wasmstate", "wasm.recall", json!(null), &host_dir).unwrap(); + let idle_deadline = Instant::now() + WORKER_IDLE + Duration::from_secs(2); + while !WORKERS.lock().unwrap().as_ref().unwrap().is_empty() + && Instant::now() < idle_deadline + { + std::thread::sleep(Duration::from_millis(20)); + } + assert!( + WORKERS.lock().unwrap().as_ref().unwrap().is_empty(), + "idle workers release their cached modules and slot" + ); + + // A real signed store catalog withdrawal also vetoes an already + // calculated result. This exercises installed_bundle/may_run, not a + // fake admission checker or the system-app shortcut. + use octosense_app_hub::{Catalog, Entry, HubKey, Source, Status}; + let id = "org.example.wasm-isolation"; + let bundle = octosense_app_hub::installed_bundle_dir(&root, id); + std::fs::create_dir_all(bundle.join("fns")).unwrap(); + std::fs::write(bundle.join("main.splash"), "Label {text: \"Fixture\"}").unwrap(); + std::fs::write( + bundle.join("fns/state.wasm"), + wat::parse_str(STATE_GUEST).unwrap(), + ) + .unwrap(); + let mut manifest = octosense_app_contract::AppManifest::parse(&json!({ + "schema":1,"id":id,"version":"1.0.0","name":"Wasm isolation fixture", + "capabilities":["wasm"],"integrity":{"bundle_blake3":octosense_app_contract::digest_dir(&bundle).unwrap()} + }).to_string()).unwrap(); + let publisher = HubKey::generate(); + octosense_app_hub::sign_manifest(&publisher, &mut manifest, "fixture").unwrap(); + std::fs::write( + bundle.join("manifest.json"), + serde_json::to_vec(&manifest).unwrap(), + ) + .unwrap(); + let anchor = HubKey::generate(); + let working = HubKey::generate(); + let certificate = anchor.certify(&working.public_hex()).unwrap(); + std::env::set_var("OCTOSENSE_HUB_ANCHOR", anchor.public_hex()); + let entry = Entry { + manifest, + listing: None, + tools: vec![], + artifact: "fixture".into(), + publisher: "fixture".into(), + publisher_key: publisher.public_hex(), + source: Source { + repository: String::new(), + commit: String::new(), + }, + status: Status::Offered, + admitted: "2026-10-08".into(), + }; + let mut catalog = Catalog::new(1, "2026-10-08", vec![entry]); + let write_catalog = |catalog: &mut Catalog| { + working.sign_catalog(catalog, &certificate).unwrap(); + std::fs::write( + root.join("catalog.json"), + serde_json::to_vec(catalog).unwrap(), + ) + .unwrap(); + }; + write_catalog(&mut catalog); + let (heap, in_flight) = captured_job(id, "recall", &host_dir); + let mut installed_lab = None; + let error = process_checked(id, &mut installed_lab, &in_flight, || { + catalog.sequence += 1; + catalog.entries[0].status = Status::Withdrawn("fixture withdrawal".into()); + write_catalog(&mut catalog); + }) + .unwrap_err(); + assert!(error.contains("withdrawn"), "{error}"); + assert!(installed_lab.is_none()); + assert!(Admission::current(id).is_err()); + octosense_appstore::services::cancel_heap(heap); + let _ = std::fs::remove_dir_all(root); + } + /// The whole path, in a process of its own (the apps root and the /// registries are process-wide): Wasm Lab's tools reach its functions /// through the executor and the service, its script's requests do too, @@ -336,14 +1140,24 @@ mod tests { const CHILD: &str = "OCTOSENSE_TEST_WASM_SERVICE"; if std::env::var_os(CHILD).is_none() { let output = std::process::Command::new(std::env::current_exe().unwrap()) - .args(["--exact", "wasm_service::tests::the_apps_own_functions_answer_its_tools_and_its_script", "--nocapture"]) + .args([ + "--exact", + "wasm_service::tests::the_apps_own_functions_answer_its_tools_and_its_script", + "--nocapture", + ]) .env(CHILD, "1") .output() .unwrap(); - assert!(output.status.success(), "{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); return; } - let root = std::env::temp_dir().join(format!("octosense-wasm-service-{}", std::process::id())); + let root = + std::env::temp_dir().join(format!("octosense-wasm-service-{}", std::process::id())); let host_dir = root.join(".host"); octosense_appstore::set_data_root(root.clone()); ship("os.wasmlab", "service", |_, _| {}); @@ -372,7 +1186,10 @@ mod tests { "name": "wasmlab.find_slots", "args": {"day_start": "09:00", "day_end": "12:00", "duration": 30, "busy": [["09:30", "11:00"]]}})) .unwrap(); - executor.execute(call, ToolReply::new("c1", move |v| into.lock().unwrap().push(v))); + executor.execute( + call, + ToolReply::new("c1", move |v| into.lock().unwrap().push(v)), + ); for _ in 0..1000 { script_apps::poll(); if !sent.lock().unwrap().is_empty() { @@ -383,42 +1200,108 @@ mod tests { let answer = sent.lock().unwrap().pop().expect("the tool answered"); assert_eq!(answer["ok"], true, "{answer}"); // Every 15 minutes, the default step. - assert_eq!(answer["data"]["slots"], json!([["09:00", "09:30"], ["11:00", "11:30"], ["11:15", "11:45"], ["11:30", "12:00"]]), "{answer}"); + assert_eq!( + answer["data"]["slots"], + json!([ + ["09:00", "09:30"], + ["11:00", "11:30"], + ["11:15", "11:45"], + ["11:30", "12:00"] + ]), + "{answer}" + ); // The script's own requests: a string goes in as its text, and an // output that is not JSON comes back as text. let html = request("os.wasmlab", "wasm.md_to_html", json!("# Hi"), &host_dir).unwrap(); assert_eq!(html["text"], "

Hi

\n"); - let ranked = request("os.wasmlab", "wasm.fuzzy_rank", json!({"query": "cal", "items": ["Mail", "Calendar"]}), &host_dir).unwrap(); + let ranked = request( + "os.wasmlab", + "wasm.fuzzy_rank", + json!({"query": "cal", "items": ["Mail", "Calendar"]}), + &host_dir, + ) + .unwrap(); assert_eq!(ranked["ranked"][0]["item"], "Calendar"); - let error = request("os.wasmlab", "wasm.find_slots", json!({"day_start": "12:00", "day_end": "09:00", "duration": 30, "busy": []}), &host_dir).unwrap_err(); + let error = request( + "os.wasmlab", + "wasm.find_slots", + json!({"day_start": "12:00", "day_end": "09:00", "duration": 30, "busy": []}), + &host_dir, + ) + .unwrap_err(); assert!(error.contains("ends before it starts"), "{error}"); let error = request("os.wasmlab", "wasm.nothing", json!({}), &host_dir).unwrap_err(); assert!(error.contains("no function"), "{error}"); // Rogue code: an error each time, and the next call still answers. for mode in ["loop", "alloc", "panic", "recurse"] { - let error = request("os.wasmlab", "wasm.rogue", json!({"mode": mode}), &host_dir).unwrap_err(); - assert!(error.contains("deadline") || error.contains("trapped"), "{mode}: {error}"); - let ranked = request("os.wasmlab", "wasm.fuzzy_rank", json!({"query": "m", "items": ["Mail"]}), &host_dir).unwrap(); + let error = + request("os.wasmlab", "wasm.rogue", json!({"mode": mode}), &host_dir).unwrap_err(); + assert!( + error.contains("deadline") || error.contains("trapped"), + "{mode}: {error}" + ); + let ranked = request( + "os.wasmlab", + "wasm.fuzzy_rank", + json!({"query": "m", "items": ["Mail"]}), + &host_dir, + ) + .unwrap(); assert_eq!(ranked["ranked"][0]["item"], "Mail", "{mode}"); } let described = request("os.wasmlab", "wasm.functions", json!({}), &host_dir).unwrap(); - assert_eq!(described["functions"], json!(["find_slots", "fuzzy_rank", "md_to_html", "rogue", "text_diff"])); + assert_eq!( + described["functions"], + json!([ + "find_slots", + "fuzzy_rank", + "md_to_html", + "rogue", + "text_diff" + ]) + ); assert_eq!(described["modules"][0]["file"], "wasmlab.wasm"); - assert_eq!(described["modules"][0]["renewed"], 4); + assert_eq!(described["modules"][0]["instance_policy"], "fresh-per-call"); assert_eq!(described["stats"]["rogue"]["errors"], 4); - assert!(host_dir.join("wasm-cache").read_dir().unwrap().next().is_some(), "compiled code is cached"); + assert!( + host_dir + .join("wasm-cache") + .read_dir() + .unwrap() + .next() + .is_some(), + "compiled code is cached" + ); // No grant, no functions: the service checks the admitted manifest // itself, whoever dispatched the request. - let error = request("os.wasmplain", "wasm.fuzzy_rank", json!({"query": "m", "items": []}), &host_dir).unwrap_err(); - assert!(error.contains("was not granted the wasm service"), "{error}"); + let error = request( + "os.wasmplain", + "wasm.fuzzy_rank", + json!({"query": "m", "items": []}), + &host_dir, + ) + .unwrap_err(); + assert!( + error.contains("was not granted the wasm service"), + "{error}" + ); // And a sheet never reaches it. let heap = NEXT_HEAP.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let call = ServiceCall { app_id: "os.wasmlab".into(), service: "wasm.functions".into(), args: json!({}), from_sheet: true, may_prompt: true, host_dir: host_dir.clone() }; + let call = ServiceCall { + app_id: "os.wasmlab".into(), + service: "wasm.functions".into(), + args: json!({}), + from_sheet: true, + may_prompt: true, + host_dir: host_dir.clone(), + }; octosense_appstore::services::dispatch(call, heap, 1, &mut NoSheet); - let (_, _, answer) = octosense_appstore::services::take_replies_for(&[heap]).pop().unwrap(); + let (_, _, answer) = octosense_appstore::services::take_replies_for(&[heap]) + .pop() + .unwrap(); assert!(answer.unwrap_err().contains("no sheet")); let _ = std::fs::remove_dir_all(root); } diff --git a/crates/wasm-host/examples/encode_phone_fixture.rs b/crates/wasm-host/examples/encode_phone_fixture.rs new file mode 100644 index 00000000..beba148d --- /dev/null +++ b/crates/wasm-host/examples/encode_phone_fixture.rs @@ -0,0 +1,4 @@ +fn main() { + let a: Vec<_> = std::env::args().collect(); + std::fs::write(&a[2], wat::parse_file(&a[1]).unwrap()).unwrap(); +} diff --git a/crates/wasm-host/src/lib.rs b/crates/wasm-host/src/lib.rs index 364fa47b..4cbebf28 100644 --- a/crates/wasm-host/src/lib.rs +++ b/crates/wasm-host/src/lib.rs @@ -28,8 +28,9 @@ use std::collections::HashMap; use std::fmt; use std::hash::{Hash, Hasher}; +use std::io::Write; use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -55,6 +56,8 @@ pub struct Limits { pub deadline: Duration, /// The most linear memory an instance may have. pub memory_bytes: usize, + /// The most elements in an instance's single table (including growth). + pub table_elements: usize, /// The largest module the runtime loads. pub module_bytes: usize, /// The largest input, and the largest output, of a call. @@ -68,6 +71,7 @@ impl Default for Limits { Limits { deadline: Duration::from_secs(2), memory_bytes: 256 << 20, + table_elements: 16_384, module_bytes: 8 << 20, io_bytes: 16 << 20, stack_bytes: 512 << 10, @@ -172,6 +176,16 @@ struct State { logs: Vec, } +struct InvocationGuard { + deadline: Instant, + pending: Box bool + Send + Sync>, +} +impl InvocationGuard { + fn live(&self) -> bool { + Instant::now() < self.deadline && (self.pending)() + } +} + /// One app's instance of a [`Program`]: its own memory and its own state. /// It may move to another thread. pub struct Instance { @@ -186,6 +200,7 @@ pub struct Instance { /// be half-updated then (a Rust guest's stack pointer and allocator live /// in its memory and globals), so the instance takes no more calls. spent: bool, + guard: Option>, _ticker: Arc, } @@ -245,8 +260,25 @@ impl Runtime { // Check before caching: a refused module is never stored. check(&module)?; if let Ok(code) = module.serialize() { - let tmp = path.with_extension("tmp"); - if std::fs::write(&tmp, &code).is_ok() && std::fs::rename(&tmp, path).is_ok() { + // Different apps may compile identical bytes concurrently. + // Never truncate another worker's staging file: deserialization + // is only safe for complete, unmodified serialized modules. + static NEXT_CACHE_WRITE: AtomicU64 = AtomicU64::new(0); + let tmp = path.with_extension(format!( + "{}-{}.tmp", + std::process::id(), + NEXT_CACHE_WRITE.fetch_add(1, Ordering::Relaxed) + )); + let published = (|| -> std::io::Result<()> { + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&tmp)?; + file.write_all(&code)?; + drop(file); + std::fs::rename(&tmp, path) + })(); + if published.is_ok() { // Read it back off this thread: an on-access scanner // (Microsoft Defender's, on a managed Mac) holds the // first open of a new file for about a second, so let @@ -256,6 +288,7 @@ impl Runtime { .name("wasm-cache".into()) .spawn(move || drop(std::fs::read(path))); } + let _ = std::fs::remove_file(tmp); } } Ok(module) @@ -275,8 +308,38 @@ impl Runtime { /// A fresh instance of `program`, with its own memory. pub fn instantiate(&self, program: &Program) -> Result { + self.instantiate_inner(program, None) + } + + /// A fresh invocation whose start function and exported call share a + /// deadline. Cancellation is checked before starting and every epoch; + /// compiled code is reusable, but none of the instance's state is shared. + pub fn instantiate_guarded( + &self, + program: &Program, + deadline: Instant, + pending: impl Fn() -> bool + Send + Sync + 'static, + ) -> Result { + let deadline = deadline.min(Instant::now() + self.limits.deadline); + self.instantiate_inner(program, Some((deadline, Box::new(pending)))) + } + + fn instantiate_inner( + &self, + program: &Program, + guard: Option<(Instant, Box bool + Send + Sync>)>, + ) -> Result { + if guard + .as_ref() + .is_some_and(|(deadline, pending)| Instant::now() >= *deadline || !pending()) + { + return Err(LoadError::Invalid( + "invocation cancelled or past its deadline".into(), + )); + } let limits = StoreLimitsBuilder::new() .memory_size(self.limits.memory_bytes) + .table_elements(self.limits.table_elements) .instances(1) .memories(1) .tables(1) @@ -290,7 +353,19 @@ impl Runtime { }, ); store.limiter(|state| &mut state.limits); - store.epoch_deadline_trap(); + let guard = + guard.map(|(deadline, pending)| Arc::new(InvocationGuard { deadline, pending })); + if let Some(guard) = guard.clone() { + store.epoch_deadline_callback(move |_| { + Ok(if !guard.live() { + wasmtime::UpdateDeadline::Interrupt + } else { + wasmtime::UpdateDeadline::Continue(1) + }) + }); + } else { + store.epoch_deadline_trap(); + } // Instantiation runs the start function and data initialisers: a // deadline covers it too. let deadline_ticks = self @@ -299,7 +374,7 @@ impl Runtime { .as_millis() .div_ceil(TICK.as_millis()) .max(1) as u64; - store.set_epoch_deadline(deadline_ticks); + store.set_epoch_deadline(if guard.is_some() { 1 } else { deadline_ticks }); let mut linker = Linker::new(&self.engine); linker .func_wrap( @@ -349,6 +424,7 @@ impl Runtime { deadline_ticks, io_bytes: self.limits.io_bytes, spent: false, + guard, _ticker: self.ticker.clone(), }) } @@ -449,6 +525,10 @@ impl Instance { if self.spent { return Err(CallError::Spent); } + if self.guard.as_ref().is_some_and(|guard| !guard.live()) { + self.spent = true; + return Err(CallError::Deadline); + } let f = self .functions .get(name) @@ -470,7 +550,11 @@ impl Instance { } fn run(&mut self, f: TypedFunc<(i32, i32), i64>, input: &[u8]) -> Result, CallError> { - self.store.set_epoch_deadline(self.deadline_ticks); + self.store.set_epoch_deadline(if self.guard.is_some() { + 1 + } else { + self.deadline_ticks + }); let len = input.len() as i32; let ptr = self.alloc.call(&mut self.store, len).map_err(trap)?; self.memory diff --git a/crates/wasm-host/tests/runtime.rs b/crates/wasm-host/tests/runtime.rs index 0bf449b7..60e46114 100644 --- a/crates/wasm-host/tests/runtime.rs +++ b/crates/wasm-host/tests/runtime.rs @@ -116,7 +116,10 @@ fn a_function_gets_its_input_and_returns_its_output() { fn a_guest_error_comes_back_as_its_text_and_the_instance_goes_on() { let runtime = Runtime::new(limits(), None).unwrap(); let mut guest = instance(&runtime); - assert_eq!(guest.call("fail", b""), Err(CallError::Guest("nope".into()))); + assert_eq!( + guest.call("fail", b""), + Err(CallError::Guest("nope".into())) + ); assert!(!guest.spent()); assert_eq!(guest.call("upper", b"still here").unwrap(), b"STILL HERE"); } @@ -253,3 +256,140 @@ fn compiled_code_is_cached_by_digest() { ); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn table_initial_size_and_growth_have_an_element_cap() { + let runtime = Runtime::new( + Limits { + table_elements: 8, + ..limits() + }, + None, + ) + .unwrap(); + let guest = |initial: usize| { + wat::parse_str(format!( + r#"(module + (memory (export "memory") 1) (table {initial} funcref) + (func (export "octo_alloc") (param i32) (result i32) (i32.const 0)) + (func (export "octo_free") (param i32 i32)) + (func (export "grow") (param i32 i32) (result i64) + (drop (table.grow (ref.null func) (i32.const 9))) (i64.const 1)))"# + )) + .unwrap() + }; + let program = runtime.load(&guest(9)).unwrap(); + assert!( + runtime.instantiate(&program).is_err(), + "initial table must respect the cap" + ); + let program = runtime.load(&guest(1)).unwrap(); + let error = runtime + .instantiate(&program) + .unwrap() + .call("grow", b"") + .unwrap_err(); + assert!(matches!(error, CallError::Trap(_)), "{error}"); +} + +#[test] +fn guarded_invocations_observe_cancellation_and_the_absolute_deadline() { + use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }; + let runtime = Runtime::new( + Limits { + deadline: Duration::from_secs(2), + ..limits() + }, + None, + ) + .unwrap(); + let program = runtime.load(&wat::parse_str(GUEST).unwrap()).unwrap(); + assert!(runtime + .instantiate_guarded(&program, Instant::now(), || true) + .is_err()); + assert!(runtime + .instantiate_guarded(&program, Instant::now() + Duration::from_secs(2), || false) + .is_err()); + let pending = Arc::new(AtomicBool::new(true)); + let check = pending.clone(); + let mut instance = runtime + .instantiate_guarded( + &program, + Instant::now() + Duration::from_secs(2), + move || check.load(Ordering::Acquire), + ) + .unwrap(); + let cancel = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(40)); + pending.store(false, Ordering::Release); + }); + let started = Instant::now(); + assert_eq!(instance.call("spin", b""), Err(CallError::Deadline)); + assert!( + started.elapsed() < Duration::from_secs(1), + "cancellation interrupts active guest code" + ); + cancel.join().unwrap(); + let mut instance = runtime + .instantiate_guarded(&program, Instant::now() + Duration::from_millis(30), || { + true + }) + .unwrap(); + std::thread::sleep(Duration::from_millis(40)); + assert_eq!( + instance.call("spin", b""), + Err(CallError::Deadline), + "call does not restart an expired budget" + ); +} + +#[test] +fn concurrent_compilation_publishes_only_complete_cache_entries() { + use std::sync::{Arc, Barrier}; + let dir = + std::env::temp_dir().join(format!("octosense-wasm-cache-race-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let runtime = Arc::new(Runtime::new(limits(), Some(dir.clone())).unwrap()); + let bytes = Arc::new(wat::parse_str(GUEST).unwrap()); + let start = Arc::new(Barrier::new(4)); + let workers: Vec<_> = (0..4) + .map(|_| { + let (runtime, bytes, start) = (runtime.clone(), bytes.clone(), start.clone()); + std::thread::spawn(move || { + start.wait(); + let program = runtime.load(&bytes).unwrap(); + assert_eq!( + runtime + .instantiate(&program) + .unwrap() + .call("upper", b"parallel") + .unwrap(), + b"PARALLEL" + ); + }) + }) + .collect(); + for worker in workers { + worker.join().unwrap(); + } + let program = runtime.load(&bytes).unwrap(); + assert!(program.from_cache()); + assert_eq!( + runtime + .instantiate(&program) + .unwrap() + .call("upper", b"cached") + .unwrap(), + b"CACHED" + ); + assert!(std::fs::read_dir(&dir).unwrap().all(|entry| entry + .unwrap() + .path() + .extension() + .unwrap() + == "cwasm")); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/wasm-phone-smoke/Cargo.toml b/crates/wasm-phone-smoke/Cargo.toml new file mode 100644 index 00000000..9ca493a1 --- /dev/null +++ b/crates/wasm-phone-smoke/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "octosense-wasm-phone-smoke" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +# Android needs a library entry point. The fixture uses the normal signed +# Store, script-tool dispatcher and Wasm service without alternate grants. +[lib] +path = "src/main.rs" +test = false +bench = false +doctest = false + +[[bin]] +name = "octosense-wasm-phone-smoke" +path = "src/main.rs" + +[features] +default = ["acceptance-fixtures"] +acceptance-fixtures = ["octosense-shell/acceptance-fixtures", "octosense-shell/wasm-lab"] + +[dependencies] +makepad-widgets.workspace = true +makepad-app-module.workspace = true +octosense-appstore.workspace = true +octosense-app-hub.workspace = true +octosense-app-policy.workspace = true +octosense-shell.workspace = true +serde_json.workspace = true + +[build-dependencies] +serde_json.workspace = true diff --git a/crates/wasm-phone-smoke/build.rs b/crates/wasm-phone-smoke/build.rs new file mode 100644 index 00000000..9e1c0412 --- /dev/null +++ b/crates/wasm-phone-smoke/build.rs @@ -0,0 +1,67 @@ +use std::{env, fs, path::Path, process::Command}; + +fn git(root: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .args(args) + .current_dir(root) + .output() + .unwrap(); + assert!( + output.status.success(), + "Cannot read fixture source identity" + ); + String::from_utf8(output.stdout).unwrap().trim().to_owned() +} + +fn main() { + let manifest = env::var_os("CARGO_MANIFEST_DIR").unwrap(); + let root = Path::new(&manifest).ancestors().nth(2).unwrap(); + let revision = git(root, &["rev-parse", "HEAD"]); + let runtime = root.join(".sources/makepad"); + let lock: serde_json::Value = + serde_json::from_slice(&fs::read(root.join("runtime-patches.lock.json")).unwrap()).unwrap(); + let tree = git(&runtime, &["write-tree"]); + assert_eq!( + Some(tree.as_str()), + lock["makepad"]["tree"].as_str(), + "Prepare the pinned runtime before building the fixture" + ); + let runtime_clean = Command::new("git") + .args(["diff", "--quiet", "--"]) + .current_dir(&runtime) + .status() + .unwrap() + .success(); + let dirty = !runtime_clean + || !git(root, &["status", "--porcelain", "--untracked-files=normal"]).is_empty(); + println!("cargo:rustc-env=WASM_FIXTURE_REVISION={revision}"); + println!("cargo:rustc-env=WASM_FIXTURE_RUNTIME_TREE={tree}"); + println!("cargo:rustc-env=WASM_FIXTURE_DIRTY={dirty}"); + for path in [ + "src", + "build.rs", + "../shell/examples/connected_support/mod.rs", + "../../runtime-patches.lock.json", + "../../.sources/makepad/widgets/src/splash_host.rs", + ] { + println!("cargo:rerun-if-changed={path}"); + } + for name in ["HEAD", "index"] { + println!( + "cargo:rerun-if-changed={}", + git(root, &["rev-parse", "--git-path", name]) + ); + } + let symbolic = Command::new("git") + .args(["symbolic-ref", "-q", "HEAD"]) + .current_dir(root) + .output() + .unwrap(); + if symbolic.status.success() { + let name = String::from_utf8(symbolic.stdout).unwrap(); + println!( + "cargo:rerun-if-changed={}", + git(root, &["rev-parse", "--git-path", name.trim()]) + ); + } +} diff --git a/crates/wasm-phone-smoke/src/main.rs b/crates/wasm-phone-smoke/src/main.rs new file mode 100644 index 00000000..5ec174fe --- /dev/null +++ b/crates/wasm-phone-smoke/src/main.rs @@ -0,0 +1,238 @@ +//! Synthetic signed-app Wasm containment and raw-response acceptance host. +//! Uses normal Store admission, the script-tool dispatcher and Wasm service. +//! It does not start a model, use an account or confer device consent. +use makepad_app_module::AppModule; +use makepad_widgets::*; +use octosense_appstore::{host_api, script_tools, services}; +use serde_json::{json, Value}; +use std::{ + path::PathBuf, + sync::{Arc, Mutex}, + time::Duration, +}; +#[path = "../../shell/examples/connected_support/mod.rs"] +mod connected_support; +app_main!(App, font_set: International); + +script_mod! { + use mod.prelude.widgets.* + use mod.widgets.* + startup() do #(App::script_component(vm)) { + ui: Root { main_window := Window { + window.inner_size: vec2(640, 650) + body +: {flow: Overlay + app := Splash {width: Fill height: Fill} + sheet := Splash {width: Fill height: Fill visible: false} + } + }} + } +} + +#[derive(Script, ScriptHook)] +struct App { + #[live] + ui: WidgetRef, + #[rust] + app: String, + #[rust] + root: PathBuf, + #[rust] + receipt: Option, + #[rust] + launch: Option, + #[rust] + assets: Option, + #[rust] + registration: Option, + #[rust] + card: SplashRef, + #[rust] + sheet: SplashRef, + #[rust] + timer: Option, + #[rust] + replies: Arc>>>, + #[rust] + preliminary: Value, + #[rust] + admitted_prompts: bool, + #[rust] + suspended: bool, +} + +impl MatchEvent for App { + fn handle_startup(&mut self, cx: &mut Cx) { + #[cfg(not(target_os = "android"))] + let arg = + |name: &str| std::env::args().find_map(|a| a.strip_prefix(name).map(str::to_owned)); + #[cfg(not(target_os = "android"))] + let root = + PathBuf::from(arg("--app-data=").expect("Pass an isolated --app-data directory")); + #[cfg(not(target_os = "android"))] + let bundle = PathBuf::from(arg("--bundle=").expect("Pass --bundle=")); + #[cfg(target_os = "android")] + let data = PathBuf::from(cx.get_data_dir().expect("Android private data directory")) + .join("wasm-phone-lab"); + #[cfg(target_os = "android")] + let (root, bundle) = (data.join("apps"), data.join("bundle")); + let preview = std::env::args().any(|a| a == "--preview"); + octosense_appstore::set_data_root(root.clone()); + let policy = if preview { + let manifest = std::fs::read_to_string(bundle.join("manifest.json")).unwrap(); + let digest = octosense_app_policy::digest_dir(&bundle).unwrap(); + octosense_app_policy::admit_and_resolve_dir( + &manifest, + &digest, + &octosense_app_policy::HostLimits::default().with_require_signature(false), + &octosense_app_policy::RefuseAllSignatures, + ) + .expect("Preview admission") + } else { + let installed = + connected_support::install(&[bundle.clone()], &root).expect("Signed installation"); + // Only this isolated fixture uses the temporary legacy catalog. + // The Wasm worker rechecks it through normal host admission. + std::env::set_var("OCTOSENSE_HUB_CATALOG", "legacy"); + std::env::set_var( + "OCTOSENSE_HUB_ANCHOR", + installed["anchor"].as_str().unwrap(), + ); + let launch = connected_support::open(&root, "org.octosense.samples.wasmprobe").unwrap(); + let policy = launch.policy.clone(); + self.launch = Some(launch); + policy + }; + assert_eq!(policy.app_id, "org.octosense.samples.wasmprobe"); + self.app = policy.app_id.clone(); + self.root = root.join(".host"); + let bundle = self + .launch + .as_ref() + .map(|l| l.bundle().to_path_buf()) + .unwrap_or(bundle); + let mut settings = policy.isolate_settings(&root); + self.admitted_prompts = settings.host_prompts; + std::fs::create_dir_all(&settings.jail_root).unwrap(); + let assets = octosense_app_policy::AssetServer::start_with_static(&bundle, &[]).unwrap(); + settings.hosts.push(assets.allowlist_entry()); + let source = octosense_app_policy::script_source(&bundle, assets.origin()) + .unwrap() + .unwrap(); + self.card = self.ui.splash(cx, ids!(app)); + self.sheet = self.ui.splash(cx, ids!(sheet)); + octosense_app_policy::splash_adapter::apply(&self.card, cx, &settings); + octosense_appstore::apply_device_consent(cx, &bundle, &self.card).unwrap(); + self.card.set_text(cx, &source); + self.assets = Some(assets); + self.registration = script_tools::bind(cx, &self.app, &bundle, &self.card).unwrap(); + self.timer = Some(cx.start_interval(0.05)); + if preview { + return; + } + #[cfg(not(target_os = "android"))] + let receipt = PathBuf::from(arg("--receipt=").expect("Pass a new --receipt file")); + #[cfg(target_os = "android")] + let receipt = data.join("receipt.json"); + self.receipt = Some(receipt); + assert!( + !self.receipt.as_ref().unwrap().exists(), + "Receipt must be a new file" + ); + let submit = |name, args, account| { + script_tools::submit( + &self.app, + name, + args, + account, + "native-acceptance", + Duration::from_secs(15), + Box::new(|_| {}), + ) + }; + let wrong_account = submit("wasmprobe.inspect", json!({}), "other-account").unwrap_err(); + let undeclared = submit("wasmprobe.not_declared", json!({}), "device").unwrap_err(); + let invalid_args = + submit("wasmprobe.inspect", json!({"injected":true}), "device").unwrap_err(); + self.preliminary = json!({"wrong_account":wrong_account,"undeclared_tool":undeclared,"invalid_arguments":invalid_args}); + let out = self.replies.clone(); + script_tools::submit( + &self.app, + "wasmprobe.inspect", + json!({}), + "device", + "native-acceptance", + Duration::from_secs(15), + Box::new(move |r| out.lock().unwrap().push(r)), + ) + .unwrap(); + } +} + +impl AppMain for App { + fn script_mod(vm: &mut ScriptVm) -> ScriptValue { + makepad_widgets::theme_mod(vm); + script_eval!(vm, {mod.theme = mod.themes.light}); + makepad_widgets::widgets_mod(vm); + widget_async::set_splash_theme(widget_async::SplashTheme::Light); + octosense_appstore::cardapp::CARD_MODULE.register(vm); + octosense_shell::platform_services::register(); + octosense_shell::wasm_service::register(); + self::script_mod(vm) + } + fn handle_event(&mut self, cx: &mut Cx, event: &Event) { + self.match_event(cx, event); + if matches!(event, Event::Pause | Event::Background) { + self.suspended = true; + } + if matches!(event, Event::Resume | Event::Foreground) { + self.suspended = false; + } + // Match the production CardAppView baseline; the test must not hide + // provenance bugs by leaving this false forever after its first tool. + let may_prompt = self.admitted_prompts + && !self.suspended + && !script_tools::pending_for(cx, &self.app, &self.card); + self.card.set_host_prompts(cx, may_prompt); + let sheet_up = self.sheet.borrow().is_some_and(|s| s.view.visible); + if sheet_up && services::is_sheet_input_event(event) { + self.sheet.handle_event(cx, event, &mut Scope::empty()); + } else { + self.ui.handle_event(cx, event, &mut Scope::empty()); + } + script_tools::pump(cx, &self.app, &self.card); + services::pump(cx, &self.app, &self.root, &self.card, &self.sheet); + octosense_shell::platform_services::handle_event(cx, event); + let reply = self.replies.lock().unwrap().pop(); + if let Some(reply) = reply { + let path = self.receipt.take().expect("One acceptance reply"); + self.registration = None; + let closed = script_tools::submit( + &self.app, + "wasmprobe.inspect", + json!({}), + "device", + "native-acceptance", + Duration::from_secs(1), + Box::new(|_| {}), + ) + .unwrap_err(); + let result = json!({"schema":1,"platform":host_api::platform(), + "proof":"signed app -> app_tool -> host.request -> isolated Wasm invocation", + "signed_install":true,"source_revision":env!("WASM_FIXTURE_REVISION"), + "source_dirty":env!("WASM_FIXTURE_DIRTY") != "false", + "runtime_tree":env!("WASM_FIXTURE_RUNTIME_TREE"),"bundle_digest":self.launch.as_ref().unwrap().manifest.integrity.bundle_blake3, + "tool_result":reply,"refusals":self.preliminary,"closed_app":closed, + "host_sheet_visible":self.sheet.borrow().is_some_and(|s| s.view.visible), + "not_verified":["live model or peer relay", "performance benchmark", "release APK"]}); + use std::io::Write; + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .unwrap() + .write_all(&serde_json::to_vec_pretty(&result).unwrap()) + .unwrap(); + println!("HOST_API_LAB_COMPLETE"); + } + } +} diff --git a/desktop/README.md b/desktop/README.md index 188b330d..2b98c274 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. @@ -183,6 +187,7 @@ What a package contains and how it is found at run time: - **System apps** (News, Photos, Maps, Camera, Mail, AI providers) are already in the binary: App Hub packs the bundles `system-apps.json` selects at build time. Nothing else is read from `apps/` or `desktop/config/`. - **The octos kernel.** The script builds octos at the revision `Cargo.lock` pins with `tools/kernel-artifact.py --host`'s steps and stages it with its `stage` (which checks the binary's `--version`). It ships as `octos-kernel` beside the executable (`Contents/MacOS/` in the app), with its receipt `octos-kernel.json` among the resources; the kernel service runs it only when the receipt names the pinned revision and the binary's SHA-256 ([Build and run](#build-and-run)). `--no-kernel` ships none, and the app then runs without an assistant. `target/octosense-package/receipt.json` records the version, resource crates and the kernel's receipt. - **Private-path-free.** Paths in the binaries are remapped (`--remap-path-prefix` for the home directory, `CARGO_HOME` and the checkout) and debug info stripped (symbol names stay, for readable backtraces). Crates also embed their source directories as plain strings, which remapping does not reach, so build from a directory outside any user's home, with `CARGO_HOME` outside it too (the release workflow does). `tools/release-scan.py` fails on `/Users/…`, `C:\Users\…` and homes other than a CI runner's (`/home/runner`, `C:\Users\runneradmin`), `*.local` hosts, private IPv4 addresses, the scanning account's and host's names and any `RELEASE_SCAN_EXTRA` pattern, inside the `.app`, `.dmg`, `.deb`, `.AppImage`, `.zip` and NSIS installers. +- **Package inspection does not run the AppImage.** The scanner reads its type-2 ELF boundary and extracts its filesystem with the host's `unsquashfs` (`squashfs-tools`, installed by the Linux build and release jobs). Missing tools, malformed headers and extraction failures stop publication. The GitHub publication token is scoped to the tag-verification and upload steps; it is absent during artifact inspection. Packaging also removes the inline `APPLE_API_KEY_P8` signing credential from build subprocesses. - **Identity.** Product name **OctoSense**, identifier `org.octosense.desktop` (`desktop/packaging/release.json`), icon from `desktop/packaging/icons/` (`make_icons.py` renders it). Android keeps `dev.makepad.octosense`. ### Cutting a desktop release diff --git a/desktop/README.zh-CN.md b/desktop/README.zh-CN.md index 37f23a8a..7427de66 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 的绿色八瓣标识。 @@ -180,6 +184,7 @@ python3 tools/release-scan.py target/octosense-package/dist/* # refuse private - **系统应用**(新闻、相册、地图、相机、邮件、AI 提供商)已在二进制中:App Hub 在构建时打包 `system-apps.json` 选中的应用包。运行时不再读取 `apps/` 或 `desktop/config/`。 - **octos 内核。** 脚本按 `tools/kernel-artifact.py --host` 的步骤构建 `Cargo.lock` 固定版本的 octos,并用其 `stage` 放置(会检查二进制的 `--version`)。内核以 `octos-kernel` 放在可执行文件旁(应用中为 `Contents/MacOS/`),收据 `octos-kernel.json` 随资源一起;只有收据中的版本与固定版本一致且二进制 SHA-256 相符时,内核服务才运行它(见[构建与运行](#构建与运行))。`--no-kernel` 不附带内核,应用随后在没有助手的情况下运行。`target/octosense-package/receipt.json` 记录版本、资源 crate 和内核收据。 - **不含私有路径。** 二进制中的路径被重映射(对主目录、`CARGO_HOME` 和检出目录使用 `--remap-path-prefix`),并去掉调试信息(保留符号名,便于阅读回溯)。crate 还会以普通字符串嵌入源码目录,重映射无法处理,所以要在任何用户主目录之外构建,`CARGO_HOME` 也放在外面(发布工作流就是这样做的)。`tools/release-scan.py` 会在 `.app`、`.dmg`、`.deb`、`.AppImage`、`.zip` 和 NSIS 安装包内部查找:`/Users/…`、`C:\Users\…` 以及 CI 运行器之外的主目录(`/home/runner`、`C:\Users\runneradmin` 除外)、`*.local` 主机名、私有 IPv4 地址、执行扫描的账户名和主机名,以及任何 `RELEASE_SCAN_EXTRA` 模式,发现即失败。 +- **包检查不执行 AppImage。** 扫描器读取 type-2 ELF 边界,再用主机的 `unsquashfs` 解压文件系统(由 Linux 构建与发布任务安装 `squashfs-tools`)。缺少工具、头部格式错误或解压失败都会阻止发布。GitHub 发布令牌仅提供给标签校验和上传步骤,不进入包检查步骤。打包脚本也会从构建子进程的环境中移除内联签名凭据 `APPLE_API_KEY_P8`。 - **标识。** 产品名 **OctoSense**,标识符 `org.octosense.desktop`(`desktop/packaging/release.json`),图标来自 `desktop/packaging/icons/`(由 `make_icons.py` 生成)。Android 仍为 `dev.makepad.octosense`。 ### 发布桌面版本 diff --git a/desktop/packaging/release.json b/desktop/packaging/release.json index 40b57c28..717966b3 100644 --- a/desktop/packaging/release.json +++ b/desktop/packaging/release.json @@ -25,7 +25,8 @@ "entitlements": "macos/entitlements.plist" }, "deb": { - "section": "utils" + "section": "utils", + "depends": ["libgtk-3-0t64 | libgtk-3-0", "libwebkit2gtk-4.1-0 | libwebkit2gtk-4.0-37"] }, "nsis": { "installMode": "currentUser" diff --git a/desktop/scripts/package.py b/desktop/scripts/package.py index 1e3a9742..c0cf29a3 100644 --- a/desktop/scripts/package.py +++ b/desktop/scripts/package.py @@ -68,7 +68,7 @@ RECEIPT_NAME = "octos-kernel.json" # cargo-packager signs when these are set; the build never does (see above). SIGNING_ENV = ("APPLE_SIGNING_IDENTITY", "APPLE_CERTIFICATE", "APPLE_CERTIFICATE_PASSWORD", "APPLE_API_KEY", - "APPLE_API_ISSUER", "APPLE_API_KEY_PATH", "APPLE_ID", "APPLE_PASSWORD", "APPLE_TEAM_ID", + "APPLE_API_ISSUER", "APPLE_API_KEY_PATH", "APPLE_API_KEY_P8", "APPLE_ID", "APPLE_PASSWORD", "APPLE_TEAM_ID", "WINDOWS_CERTIFICATE", "WINDOWS_CERTIFICATE_PASSWORD", "WINDOWS_CERTIFICATE_THUMBPRINT") DEFAULT_FORMATS = {"macos": ["app", "dmg"], "windows": ["nsis"], "linux": ["deb", "appimage"]} # Where Makepad reads packaged resources from, per OS (see the table above). @@ -332,7 +332,10 @@ def main(argv=None): if os_name == "linux": depends = debian_depends([binaries_dir / PACKAGE, *([sidecar.with_name(f"{KERNEL_NAME}-{triple}")] if sidecar else [])]) if depends: - config.setdefault("deb", {})["depends"] = depends + # GTK/WebKit are loaded dynamically and do not appear in shlibdeps. + # Preserve explicit runtime dependencies from release.json. + deb = config.setdefault("deb", {}) + deb["depends"] = list(dict.fromkeys([*deb.get("depends", []), *depends])) # Relative paths in the config resolve from its directory, so the # generated copy sits beside release.json (git-ignored). generated = PACKAGING / ".release.generated.json" diff --git a/desktop/scripts/test_package.py b/desktop/scripts/test_package.py index e5450d9d..116837d2 100644 --- a/desktop/scripts/test_package.py +++ b/desktop/scripts/test_package.py @@ -91,6 +91,8 @@ def test_the_release_config_is_filled_in_per_build(self): base = json.loads((HERE.parent / "packaging/release.json").read_text()) self.assertEqual(base["identifier"], "org.octosense.desktop") self.assertEqual(base["productName"], "OctoSense") + self.assertIn("libwebkit2gtk-4.1-0 | libwebkit2gtk-4.0-37", base["deb"]["depends"]) + self.assertIn("libgtk-3-0t64 | libgtk-3-0", base["deb"]["depends"]) for icon in base["icons"]: self.assertTrue((HERE.parent / "packaging" / icon).is_file(), icon) config = package.packager_config(base, version="0.2.0", binaries_dir=Path("/t/release"), out_dir=Path("/t/dist"), @@ -107,11 +109,13 @@ def test_the_release_config_is_filled_in_per_build(self): def test_the_build_never_sees_a_signing_variable(self): base = {"PATH": "/bin", "RUSTFLAGS": "-C x", "APPLE_SIGNING_IDENTITY": "Developer ID Application: X (T)", - "APPLE_CERTIFICATE": "secret", "APPLE_API_KEY_PATH": "/k.p8", "WINDOWS_CERTIFICATE_THUMBPRINT": "AB12"} + "APPLE_CERTIFICATE": "secret", "APPLE_API_KEY_PATH": "/k.p8", "APPLE_API_KEY_P8": "synthetic-p8", + "WINDOWS_CERTIFICATE_THUMBPRINT": "AB12"} env = package.build_env({"MAKEPAD_PACKAGE_DIR": "."}, base) self.assertEqual(env["PATH"], "/bin") self.assertEqual(env["MAKEPAD_PACKAGE_DIR"], ".") self.assertNotIn("RUSTFLAGS", env, "folded into CARGO_ENCODED_RUSTFLAGS") + self.assertNotIn("APPLE_API_KEY_P8", env, "the inline signing credential must not reach a build") for name in package.SIGNING_ENV: self.assertNotIn(name, env) self.assertIn("APPLE_SIGNING_IDENTITY", base, "the caller's environment is not modified") diff --git a/desktop/src/main.rs b/desktop/src/main.rs index fd048dbf..aae64457 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -5,4 +5,29 @@ 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 fn app_main() { + Cx::prefer_x11_for_embedded_browser(); + linux_entry::start(); +} + +// This source is also the desktop library entry; keep both entry functions public. +#[cfg(target_os = "linux")] +pub fn main() { + app_main(); +} diff --git a/docs/adr/0011-apps-own-functions-in-webassembly.md b/docs/adr/0011-apps-own-functions-in-webassembly.md index 241d69f6..fdedbe55 100644 --- a/docs/adr/0011-apps-own-functions-in-webassembly.md +++ b/docs/adr/0011-apps-own-functions-in-webassembly.md @@ -37,9 +37,9 @@ outside their own input and output. Cranelift when they load. Compiled code is cached in the host directory, keyed by the module's digest and the engine's compatibility hash. Every call has a deadline (2 s, epoch interruption against wall-clock time); - memory (256 MiB), the wasm stack (512 KiB), the module (8 MiB) and input - and output (16 MiB) have caps. A trap or a deadline ends the call with an - error, never the process, and spends the instance: the next call gets a + memory (256 MiB), table elements (16,384), the wasm stack (512 KiB), + the module (8 MiB) and runtime input/output (16 MiB) have caps. A trap or + a deadline ends the call with an error, never the process, and spends the instance: the next call gets a fresh one, because a Rust guest's stack pointer and allocator may be half-updated. 3. **The `wasm` host service** (shell feature `wasm-lab`). `wasm.` @@ -48,8 +48,31 @@ outside their own input and output. anything else as JSON; JSON output comes back as data, anything else as `{"text": …}`. Modules come only from the calling app's own admitted, digest-checked bundle, never from an argument, and the service checks the - admitted manifest's grant itself. Each app has one worker thread, so no - call runs on the UI thread and a slow function holds up only its own app. + admitted manifest's grant itself. Each invocation gets a fresh instance, + including after successful calls and guest-returned errors: memory, globals, + tables and logs cannot carry one caller's input into another call. Only + compiled Programs are reused. The low-level runtime's reusable `Instance` + API remains available for benchmarks; the app service never reuses it. + + The service admits at most four active workers, four queued requests per + app, 1 MiB serialized input per request and 16 MiB total buffered input + (including executing calls). Full queues return an error without blocking + the UI. Workers release Programs and exit after five idle seconds. A + request has a ten-second budget including queueing/loading; guest start + and function execution share at most two seconds, with cancellation + checked at each epoch. Compilation itself is not interruptible, but an + expired or cancelled request cannot proceed from compilation to execution. + Queueing clones a host-owned admission identity; it does no bundle IO, + signature verification or digest work on the UI thread. The first cold + request binds that identity when the worker first verifies admission: + `ServiceCall` does not carry the originating UI's loaded-revision token. + Later queued requests retain that verified revision. The worker rechecks + admission before execution and before delivery; changed bundles/grants or + signed withdrawal discard the result and cached Program references, and + invalidate the old identity. A fresh request can then load the new code; + already queued old requests remain refused. A closing worker keeps its + global slot until its queue and compiled Programs have been dropped. + The disk cache contains compiled code only, never guest state. 4. **The `wasm` capability** joins App Hub's closed capability list. A store says: "Runs its own functions in a sandbox on this device; they reach no files, network or other apps." @@ -78,6 +101,9 @@ CoreMark on the M5 Max (higher is faster; native Rust is about 45,000): ## Measurements (Wasm Lab, `apps/wasmlab`) +These are the original runtime measurements, before the service switched to +fresh instances for every call. They are not a new end-to-end service benchmark. + Light algorithms only: Markdown to HTML (pulldown-cmark), free slots around busy times, fuzzy ranking (strsim), a line diff (similar). @@ -146,12 +172,14 @@ Two platform findings: (about 17× slower than Cranelift). A shipped system app's functions could be translated to native code when the app is built; a store app's could not. OpenHarmony's JIT policy is unknown (**unverified**). -- The limits are per call and per instance. Nothing yet stops an app from - keeping one core busy with back-to-back calls, or from using 256 MiB in - each of its eight modules; a per-app CPU and memory budget is follow-up - work. A request that App Hub has timed out still runs in the worker. -- An app updated while the shell runs keeps its old functions until the - shell restarts; workers live as long as the shell. +- Guest memory is no longer retained across calls or across the eight modules: + each worker holds at most one live instance, and only four workers may run. + Continuous admitted requests can still keep those workers busy; CPU fairness + across applications and a bounded disk-cache eviction policy remain follow-up + work. Compiled Programs also consume host memory beyond the linear-memory cap. +- Runtime and shell service regression tests cover actual Wasm state retention, + table growth, cancellation, input/queue bounds, revision/grant changes and + signed withdrawal. This hardening has not been revalidated on a phone yet. ## Open questions diff --git a/docs/adr/0011-apps-own-functions-in-webassembly.zh-CN.md b/docs/adr/0011-apps-own-functions-in-webassembly.zh-CN.md index 0c1bec3d..76eba01c 100644 --- a/docs/adr/0011-apps-own-functions-in-webassembly.zh-CN.md +++ b/docs/adr/0011-apps-own-functions-in-webassembly.zh-CN.md @@ -26,15 +26,29 @@ Rust 写:解析器、排序或排程算法、差异比较、格式转换,或 serde 类型生成这一切。这一版不用组件模型,也不用 WASI。 2. **运行时。** `crates/wasm-host` 在 Wasmtime 49 中运行模块,模块加载时由 Cranelift 编译。编译结果缓存在宿主目录中,以模块摘要和引擎兼容性哈希为键。每次调用都有截止时间 - (2 秒,按实际经过的时间做 epoch 中断);内存(256 MiB)、wasm 栈(512 KiB)、模块 + (2 秒,按实际经过的时间做 epoch 中断);内存(256 MiB)、表元素(16,384 个)、wasm 栈(512 KiB)、模块 (8 MiB)以及输入输出(16 MiB)都有上限。陷阱或超时只让这次调用出错,绝不结束进程, 并作废这个实例:下次调用会得到新实例,因为 Rust 客体的栈指针和分配器可能只更新了一半。 3. **`wasm` 宿主服务**(外壳特性 `wasm-lab`)。`wasm.` 调用发起请求的应用自己的 - 函数;`wasm.functions` 列出这些函数、它们的加载情况和运行统计。字符串参数按文本传入, - 其他参数按 JSON 传入;JSON 输出作为数据返回,其他输出返回为 `{"text": …}`。模块只来自 - 发起请求的应用自己那个已准入、经过摘要校验的应用包,绝不来自参数;服务还会自己检查已准入 - 清单中的授权。每个应用有一个工作线程,所以没有调用在 UI 线程上运行,一个慢函数只会拖慢 - 它自己的应用。 + 函数;`wasm.functions` 列出这些函数、加载情况和运行统计。字符串参数按文本传入, + 其他参数按 JSON 传入;JSON 输出作为数据返回,其他输出返回 `{"text": …}`。 + 模块只来自调用应用自己通过准入、经过摘要检查的应用包,而不是请求参数指定的文件。 + 每次调用都使用全新实例,包括上次成功或返回客体错误的情况;内存、全局变量、表和 + 日志不能把某次调用的输入带到下一次。只复用编译后的 Program。底层运行时仍为基准 + 测试提供可复用的 `Instance` API,但应用服务不复用实例。 + + 服务最多允许四个活动工作线程,每个应用最多排队四个请求;每个请求序列化后的输入 + 不超过 1 MiB,所有排队及正在执行的输入合计不超过 16 MiB。队列已满时立即返回错误, + 不阻塞界面线程。工作线程空闲五秒后释放 Program 并退出。每个请求从排队到加载有 + 十秒总预算;客体启动函数与导出函数共享最多两秒的执行预算,每次 epoch 检查取消。 + 编译本身不能被中断,但过期或已取消的请求不会在编译后继续执行。 + 入队只复制宿主持有的准入身份引用,不在界面线程读取应用包、验签或计算摘要。 + 第一次冷启动请求在工作线程首次验证准入时绑定身份;`ServiceCall` 并不携带发起 + 请求的界面已加载版本的令牌。后续排队请求保留该已验证版本。工作线程在执行前和 + 交付结果前重新检查准入;应用包、权限发生变化或签名目录撤回应用时,丢弃结果和 + 缓存的 Program 引用,并使旧身份失效。此后的新请求可以加载新代码;已经排队的 + 旧请求仍会被拒绝。正在关闭的工作线程在释放队列和编译模块之前继续占用全局名额。 + 磁盘缓存只包含编译代码,不包含客体状态。 4. **`wasm` 能力**加入 App Hub 封闭的能力列表。商店的说明是:“在此设备上的沙盒中运行它 自带的函数;这些函数接触不到文件、网络或其他应用。” 5. **Agent 工具。** 工具可以写 `host_method: "wasm."`:应用的 Agent(以及在 @@ -60,6 +74,9 @@ M5 Max 上的 CoreMark(越高越快;原生 Rust 约 45,000): ## 测量(Wasm Lab,`apps/wasmlab`) +以下是服务改为每次调用使用全新实例之前的原始运行时测量,不代表本次加固后的 +端到端服务性能。 + 只有轻量算法:Markdown 转 HTML(pulldown-cmark)、在忙碌时段之间找空闲时段、模糊排序 (strsim)、按行差异比较(similar)。 @@ -115,10 +132,11 @@ Cranelift 与 LLVM 的代码生成,而不是向量化。Wasmtime 和 Cranelift - iOS 不允许应用使用 JIT,也不允许下载原生代码:那里只能用 Pulley(比 Cranelift 慢约 17 倍)。随系统发布的应用,其函数可以在构建应用时转成原生代码;商店应用不行。 OpenHarmony 的 JIT 策略未知(**未验证**)。 -- 限制是按调用、按实例计算的。目前还没有办法阻止一个应用用连续调用占满一个核心,或在 - 它的八个模块里各用 256 MiB;按应用的 CPU 和内存预算是后续工作。App Hub 已判定超时的 - 请求,仍会在工作线程里运行。 -- 外壳运行期间更新的应用,在外壳重启前仍使用旧函数;工作线程与外壳同寿命。 +- 客体内存不再跨调用或在八个模块间保留:每个工作线程最多有一个活动实例,最多 + 四个工作线程。持续准入的请求仍可占满这些线程;应用间 CPU 公平调度和磁盘编译缓存 + 的有界淘汰策略仍待实现。编译后的 Program 也会占用线性内存上限以外的宿主内存。 +- 运行时和外壳服务回归测试覆盖真实 Wasm 状态残留、表扩容、取消、输入/队列上限、 + 版本/权限变化及签名目录撤回。本次加固尚未在手机上重新验证。 ## 未决问题 diff --git a/docs/adr/0012-app-host-api-discovery.md b/docs/adr/0012-app-host-api-discovery.md index 7d926af7..096c3d0a 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 ad3599bf..c61ed59b 100644 --- a/docs/adr/0012-app-host-api-discovery.zh-CN.md +++ b/docs/adr/0012-app-host-api-discovery.zh-CN.md @@ -69,7 +69,11 @@ flowchart TD 改动横跨 App Hub 契约、分发器和运行器,OctoSense 中转、认证及设备服务,以及固定版本的 Makepad 授权补丁。必须一起发布并固定兼容组件,才能宣称已发布宿主支持这些要求。通过应用包检查本身不证明服务能够运行。 -macOS/Android 实现嵌入式后端登录;Windows/Linux 保留独立的外部浏览器认证路径,仍需平台执行验收。Windows/Linux 的嵌入式 `WebReader` 不受支持,必须明确报错。Google Android 登录仍不受支持。新设备适配器不宣称支持 Windows/Linux/iOS。不增加广泛系统访问、任意 Rust/原生动态库执行或 Wasm 加载。 +macOS/Android 实现嵌入式后端登录;Windows/Linux 保留独立的外部浏览器认证 +路径,仍需平台执行验收。后续[桌面浏览器扩展](../desktop-embedded-browser.zh-CN.md)为 Windows 和 +Linux X11/XWayland 提供普通 `WebReader`;原生 Wayland 或引擎缺失时明确报错。 +这不启用嵌入式后端认证。Google Android 登录仍不受支持。新设备适配器不宣称支持 +Windows/Linux/iOS。不增加广泛系统访问、任意 Rust/原生动态库执行或 Wasm 加载。 ## 证据与待完成验收 diff --git a/docs/architecture-walkthrough.md b/docs/architecture-walkthrough.md index a821af59..5c559f07 100644 --- a/docs/architecture-walkthrough.md +++ b/docs/architecture-walkthrough.md @@ -154,6 +154,18 @@ For agent calls resolving to `glance.publish`, including aliases such as `inbox. An installed app's agent also depends on its exact release remaining admitted. Guidance, tool offers and system-agent input read the current signed local catalog. The broker also checks `ToolHost::admit_turn` immediately before every actual `turn/start`, including cached conversations, queued input and retries; the relay checks both tool owner and calling app again before execution, including after a pending approval. A withdrawal takes effect after the next catalog fetch, even for cached peers. The Gmail dispatcher then releases the unavailable peer and retains its unfinished event for a later authorized retry. Saved user consent is unchanged; an unavailable app is not treated as a user denial. +The App Hub UI, agent admission, Glance isolates and backend registration watcher +use the host-selected `CatalogChannel`. Legacy catalogs use `catalog.json`; +the GitHub-attested channel uses `catalog-v2.json` and verifies its proof before +admitting an app. An existing v2 cache keeps that library on v2: malformed proof +or a missing selected cache is an error, never a reason to use the legacy file. +The default remains legacy until official v2 acceptance; the operator can select +`OCTOSENSE_HUB_CATALOG=github-v2`. Both cache names and their `.lock` counterparts +are host-owned names, not app storage IDs. A new channel or changed cache causes +the backend watcher to recheck connected apps; per-call admission still applies. +If a newer verified catalog cannot be saved, agents and Glance also honor the +process's highest verified sequence and refuse the older cache until it is saved. + The relay routes every call by the tool's owner: | Owner | Executor | diff --git a/docs/architecture-walkthrough.zh-CN.md b/docs/architecture-walkthrough.zh-CN.md index 521321e7..ced8e161 100644 --- a/docs/architecture-walkthrough.zh-CN.md +++ b/docs/architecture-walkthrough.zh-CN.md @@ -154,6 +154,16 @@ Agent 调用最终映射到 `glance.publish` 时(包括 `inbox.notify` 等别 已安装应用的 Agent 还要求该精确版本持续满足准入条件。加载指导文本、提供工具及接受系统 Agent 输入时都会读取当前本地签名目录。代理还会在每次实际发送 `turn/start` 前检查 `ToolHost::admit_turn`,包括缓存对话、排队输入与重试;中转在执行前再次检查工具所有者与调用应用,包括等待审批后恢复执行的情况。撤回在下一次获取目录后生效,缓存的 peer 也不能绕过。Gmail 分发器随后释放不可用的 peer,保留未完成事件以便之后经授权重试。用户之前保存的同意不会改变;应用不可用不等于用户拒绝。 +App Hub 界面、Agent 准入、Glance 隔离环境和后端注册监听器统一使用宿主选择的 +`CatalogChannel`。旧签名目录使用 `catalog.json`,GitHub 证明通道使用 +`catalog-v2.json`,应用准入前必须验证其证明。只要应用库已有 v2 缓存,就继续使用 +v2;证明无效或所选缓存缺失都会报错,不会回退读取旧目录。在官方 v2 验收完成前, +默认仍使用旧通道;运维者可设置 `OCTOSENSE_HUB_CATALOG=github-v2` 选择新通道。 +两个缓存文件名及各自的 `.lock` 文件名均属于宿主,不能作为应用存储 ID。 +通道切换或缓存更新会让后端监听器重新检查已连接应用,每次调用的准入检查仍会执行。 +如果已验证的新目录无法保存,Agent 和 Glance 也会遵守当前进程记录的最高已验证序号, +拒绝使用较旧的缓存,直到新目录成功保存。 + 中转按工具的所有者分派每一次调用: | 所有者 | 执行器 | diff --git a/docs/desktop-embedded-browser.md b/docs/desktop-embedded-browser.md new file mode 100644 index 00000000..8dadc6a3 --- /dev/null +++ b/docs/desktop-embedded-browser.md @@ -0,0 +1,130 @@ +# 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. | + +The `.deb` release configuration declares GTK 3 and WebKitGTK 4.1-or-4.0 +runtime dependencies because dynamic loading is invisible to `dpkg-shlibdeps`. +AppImage users need these libraries installed separately. Windows deployment +needs the supported WebView2 Runtime. This source change does not bundle either +engine. The Windows adapter deliberately requires the interfaces used to deny +native Save As and screen capture instead of silently omitting those gates. + +## 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 permission requests, +downloads and popups are denied. Pages cannot use these browser requests to grant +Splash camera, microphone or filesystem APIs. Linux also cancels file chooser and +print signals. Windows does not intercept HTML file-selection or print UI; their +native interaction remains unverified and must not be described as disabled. +A file selected for a website is separate from an app's host-service grants. +URL validation is not a DNS or network sandbox: a permitted public hostname can +resolve to a private address. Do not treat a reader as an SSRF isolation boundary. + +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. Policy-blocked navigation is + nonfatal: a denied iframe cannot hide the allowed parent document. Dynamic + titles are forwarded; Linux also forwards URI changes and releases its native + child after a page-requested close callback returns. A missing engine reaches the + failure path instead of leaving an invisible successful view. + +## Acceptance + +`crates/browser-smoke` is an unpublished native test host, separate from the +production shell, that mounts the actual `WebReader` widget. +`tools/browser-smoke.py` serves synthetic HTML on loopback and drives that host +through a private control directory. It checks real page JavaScript, DOM editing, +dynamic titles, a blocked iframe preserving the interactive parent, navigation +rejection before a forbidden HTTP request, accepted hide/show commands, stopped +execution after close, fresh cookies after reopen, and native network errors. +It never uses personal accounts or downloads engines. +The Windows workflow 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 +remains the real native browser with its normal sandbox. This test does not prove +hardware GPU performance, and production graphics selection stays unchanged. + +The shared URL-policy tests run with `cargo test --locked -p octosense-browser-smoke` +and cover restricted documents, public HTTPS, malformed/private URLs and exact +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 eleven checks using WebKitGTK 2.52.6 and Xvfb +21.1.22 with the engine sandbox enabled. The XQueryTree check proved that the GTK +plug is a child of the socket inside the Makepad window, with valid geometry; it +did not capture the display. The same driver also proved that page-requested +`window.close()` removes the native child and stops page heartbeats. Hide/show +command acceptance does not prove native overlay visibility; physical input, +HiDPI and closing during asynchronous Windows controller creation remain +unverified. The Windows CI receipt records its native acceptance result; a +cross-compile alone cannot satisfy that gate. Detailed receipts belong to the PR +and CI artifacts. The `.github/workflows/embedded-browser.yml` Windows job fails +when its installed engine is absent or incompatible; it never converts that into +a skipped success. Only synthetic receipts and engine-owned captures are uploaded. + +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..ed4b2cbc --- /dev/null +++ b/docs/desktop-embedded-browser.zh-CN.md @@ -0,0 +1,101 @@ +# 桌面嵌入式浏览器 + +[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 | 本次桌面改动不改变已有原生阅读器适配器。 | + +由于 `dpkg-shlibdeps` 看不到动态加载的库,`.deb` 发布配置显式声明 GTK 3 和 +WebKitGTK 4.1 或 4.0 运行库依赖。AppImage 用户需要另行安装这些库。Windows +部署需要提供受支持的 WebView2 Runtime。本次源码改动不打包任何引擎。Windows 适配器要求用于阻止原生“另存为”和屏幕捕获的接口, +不会在旧引擎上悄悄跳过这些限制。 + +## 应用与宿主边界 + +应用继续使用已有 `WebReader` 控件及权限检查。受限阅读器只能停留在已准入的 +文档(可改变片段位置)。拥有现有 `web` 授权的阅读器可以访问公开 HTTPS 页面。 +每次导航都重新检查,包括重定向和子框架。拒绝本地文件、外部应用协议、含账号 +凭据的 URL 与畸形地址。宿主明确准入的初始 HTTP 文档仍可使用,因此隔离的 +回环测试页面不会顺带授予任意本地地址访问权。 + +每个新视图使用新的私有浏览器会话。关闭会销毁原生控制器并停止页面执行; +再次打开使用新会话。隐藏或裁剪只移开可见覆盖层,保留当前页面。原生子视图 +在裁剪父窗口中保持完整文档尺寸,避免宿主滚动时网页按可见小块重新排版。 + +网页没有 OctoSense 工具桥。拒绝浏览器权限请求、下载及弹窗;这些请求不能授予 +Splash 摄像头、麦克风或文件 API 权限。Linux 还取消文件选择与打印信号。 +Windows 尚未拦截 HTML 文件选择或打印 UI,这些原生交互未验证,不能宣称已禁用。 +为网站选择文件与授予应用宿主服务权限是不同操作。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` 保留已有覆盖层生命周期,把原生加载、导航和失败事件转换为控件 + 状态。策略拒绝导航不会变成致命错误,因此被拒绝的子框架不会隐藏获准的父页面。 + 动态标题会转发给组件;Linux 也转发 URI 更新,并在页面关闭请求的回调返回后 + 释放原生子窗口。引擎缺失会进入错误状态,不会留下声称成功的不可见视图。 + +## 验收 + +`crates/browser-smoke` 是不发布的原生测试宿主,与生产 Shell 分开。 +`tools/browser-smoke.py` 在回环地址提供合成 HTML,通过私有控制目录驱动挂载真实 +`WebReader` 组件的测试宿主。检查真实页面 JavaScript、DOM 编辑、动态标题、拒绝 +子框架后父页面仍可交互、禁止导航是否在 HTTP 请求前被拦截、隐藏与恢复命令获接收、 +关闭后停止执行、重开后全新 Cookie,以及原生网络错误。不使用个人账户, +也不下载引擎。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 +性能,也不改变生产默认图形后端。 + +共享 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 内, +且几何尺寸有效,没有捕获整个显示器。同一驱动还证明页面调用 `window.close()` +后原生子窗口被移除,页面心跳停止。隐藏与恢复命令获接收不代表原生覆盖层可见性 +已验收;物理输入、HiDPI 和 Windows 控制器异步创建期间关闭仍未验证。 +Windows 的原生验收结果以 CI 回执为准,只有交叉编译通过不足以满足该门禁。 +详细回执记录在 PR 和 CI 工件中。`.github/workflows/embedded-browser.yml` 的 Windows 任务在 +引擎缺失或不兼容时失败,不把它算作跳过后通过。只上传合成回执与引擎自身的捕获。 + +修改浏览器文件时,本地 CI 合并工具同样要求 Windows 原生工作流在精确的 PR +提交上通过。macOS/Linux 本地通过不能代替这项证据。 diff --git a/native-apps.json b/native-apps.json index a334a854..149a88a7 100644 --- a/native-apps.json +++ b/native-apps.json @@ -100,7 +100,7 @@ "id": "apphub", "feature": "app-hub", "crate": "octosense-app-hub-app", - "source": { "git": "https://github.com/OctoSense-org/OctoSense-App-Hub", "rev": "2e5768964f15916cdb66cc6824f7671dafc6445a", "local": null }, + "source": { "git": "https://github.com/OctoSense-org/OctoSense-App-Hub", "rev": "ba36df0505e198f998201af312ff5184df98cae9", "local": null }, "module": ["octosense_app_hub_app::APP_HUB_MODULE", "octosense_app_hub_app::CARD_MODULE"], "bin": null, "default_features": true, diff --git a/runtime-patches.lock.json b/runtime-patches.lock.json index 7582e00e..df4b5d03 100644 --- a/runtime-patches.lock.json +++ b/runtime-patches.lock.json @@ -4,7 +4,7 @@ "base_revision": "32d6415fb7476345ad36ee4f98d6f844d1f07fd2", "patch": "tools/runtime-patches/makepad-settings.patch", "sha256": "b17352ebf59e50ab3db6ff1cb825c4e006bcdff47d83522e372b8fe585e57570", - "tree": "6105f0025e748857a620e3c368c6d572a2493555", + "tree": "6c080f31adb899611e6ebe132ccfa2eead06cf9b", "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.", @@ -23,10 +23,12 @@ "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.", + "Ordinary WebReader embeds sandboxed WebKitGTK in X11/XWayland and private WebView2 controllers on Windows, with bounded UI integration, URL policy, no app bridge and explicit engine errors. Native Wayland and Linux/Windows production embedded authentication remain unsupported.", + "Native host-response envelope keys are interned when created, so contained apps can forward raw success, error and capability-refusal replies as bounded JSON without first naming each field in script source.", + "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.", "A Splash isolate entry keeps its 64 ms wall-clock budget unless MAKEPAD_SPLASH_BUDGET_MS sets another (makepad#109). Only the shell test step sets it: ~1,000 tests in one process wait on shared locks and page faults, which a wall-clock budget counts against the script." ], "stacked": [ @@ -102,6 +104,14 @@ "patch": "tools/runtime-patches/makepad-async-prompt-provenance.patch", "sha256": "46bb7620ccb4f6a76a08d0333c0249d2619123da2a2649f55294fd4d35f0d905" }, + { + "patch": "tools/runtime-patches/makepad-desktop-webview.patch", + "sha256": "dda81412903a247525cf3750a00aab820aa1dea28722cc633cbdf65fd5770221" + }, + { + "patch": "tools/runtime-patches/makepad-host-response-json.patch", + "sha256": "a95cd969b2d034ca30d38bfdb6ca6a890aaef4f24c7034076ddf72eb4c3d829a" + }, { "patch": "tools/runtime-patches/makepad-splash-budget.patch", "sha256": "cebcae3327ec955a2b37eb1f3353e97160d702bd4081abb264006fee4ac17ee5" diff --git a/tools/browser-smoke.py b/tools/browser-smoke.py new file mode 100644 index 00000000..e7361255 --- /dev/null +++ b/tools/browser-smoke.py @@ -0,0 +1,325 @@ +#!/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 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) + parser.add_argument('--out', type=Path, required=True) + parser.add_argument('--host-arg', action='append', default=[]) + parser.add_argument('--require-snapshot', action='store_true') + parser.add_argument('--software-graphics', action='store_true', help='Use the built-in Windows WARP rasterizer (not a browser substitute)') + parser.add_argument('--require-xembed', action='store_true', help='Probe geometry on an isolated, owned Linux X display only') + args = parser.parse_args() + if args.software_graphics and os.name != 'nt': + parser.error('--software-graphics requires Windows') + binary = args.binary.resolve(strict=True) + root = args.out.resolve() + root.mkdir(mode=0o700, parents=True, exist_ok=False) + 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' + if args.software_graphics: + env['MAKEPAD_D3D11_WARP'] = '1' + process = None + sequence = 0 + failure = None + security_failures = [] + message_probe = None + message_control = 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') + 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 + + 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') + 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') + 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') + 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') + wait(lambda: any(e.get('kind') == 'navigation' and e.get('title') == 'Saved: Reviewed locally' for e in events()), 'dynamic page title notification') + checks.append('dynamic_title_notification') + + if args.require_snapshot: + capture = command('inspect') + 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 + if meta.get('hostBridgePresent'): + security_failures.append('Inspection found an OctoSense bridge') + if os.name == 'nt': + if meta.get('webMessageEnabled') is not False: + 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') + + before = len(events()) + command('eval', script="let frame=document.createElement('iframe');frame.src='/forbidden-frame';document.body.appendChild(frame)") + wait(lambda: any(e.get('kind') == 'policy_blocked' for e in events()[before:]), 'blocked iframe policy event') + assert not any(e.get('kind') == 'page_error' for e in events()[before:]), 'Blocked iframe failed the parent WebReader' + assert not any(e['path'] == '/forbidden-frame' for e in server_events()), 'Forbidden iframe reached server' + command('eval', script="document.querySelector('#message').value='Still interactive';document.querySelector('#submit').click()") + wait(lambda: any(e.get('body', {}).get('value') == 'Still interactive' for e in server_events()), 'parent reader after denied iframe') + time.sleep(.3) + assert not any(e.get('kind') == 'page_error' for e in events()[before:]), 'Denied iframe later failed its parent reader' + checks.append('blocked_iframe_preserves_parent_reader') + + before = len(events()) + command('eval', script="location.href='/forbidden'") + wait(lambda: any(e.get('kind') == 'policy_blocked' for e in events()[before:]), 'restricted navigation rejection') + assert not any(e['path'] == '/forbidden' for e in server_events()), 'Forbidden navigation reached server' + checks.append('restricted_navigation_blocked_before_request') + command('hide') + time.sleep(1.2) # WebReader's existing visibility watchdog detaches hidden views. + command('show') + checks.append('detach_and_reattach_commands_accepted') + 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()) + 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') + + if args.require_xembed: + before = len(events()) + command('eval', script="window.close()") + wait(lambda: any(e.get('kind') == 'page_error' and + e.get('description') == 'The embedded page closed' + for e in events()[before:]), 'page-requested close notification') + wait(lambda: inspect_embedding(allow_empty=True) is None, + 'page-requested close removes GtkPlug') + time.sleep(.7) + stopped = len(server_events()) + time.sleep(1) + assert len(server_events()) == stopped, 'Page-requested close left JavaScript running' + checks.append('page_requested_close_removes_child_and_stops_execution') + + 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 + if security_failures: + raise AssertionError('; '.join(security_failures)) + 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, '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, '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') + (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..6a243741 --- /dev/null +++ b/tools/browser_smoke_x11.py @@ -0,0 +1,60 @@ +"""Inspect the owned Xvfb fixture's actual XEmbed hierarchy; no screen capture.""" +import ctypes as c +import os + + +def inspect_embedding(*, allow_empty=False): + """Return live child geometry, or None when explicitly allowing a closed view.""" + 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) + if allow_empty and not plugs: + return None + 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 2eb72123..2ca6c6d5 100644 --- a/tools/ci_local.py +++ b/tools/ci_local.py @@ -78,6 +78,10 @@ # 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 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, " "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 b5288313..df321a1f 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") @@ -169,12 +188,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/connected-e2e/LINUX-PACKAGE.md b/tools/connected-e2e/LINUX-PACKAGE.md new file mode 100644 index 00000000..6b6aa89b --- /dev/null +++ b/tools/connected-e2e/LINUX-PACKAGE.md @@ -0,0 +1,41 @@ +# Linux desktop package acceptance + +The [immutable receipt](evidence/linux-package-37723207058.json) records eight +successful checks of the real `.deb` produced by [desktop package workflow +37723207058](https://github.com/OctoSense-org/OctoSense/actions/runs/37723207058), +artifact `11528131778`. This was an Ubuntu 22.04 package build. Its source was +the workflow's planned pull-request merge commit +`c15a0ab93d6d4991e5774e1b56028247bf9c7ac2`, rather than only the pull-request +head `9f8ce11c7cc890aa77131821c42a0ae61b46fc54`. + +The package was extracted with `dpkg-deb -x` into a new private test directory; +it was not installed into the host OS. The extracted executable ran against +its packaged resources in a disposable Xvfb display and D-Bus session. A +read-only bwrap overlay supplied already extracted test dependencies. The run +used a fresh OctoSense profile and no personal provider account. Neither +system packages nor global settings changed. + +The eight automated checks cover the packaged executable, isolated display, +continued native execution, native widget snapshot, rendered frame, App Hub +presence, resource loading and normal shutdown. Inspection of that actual +frame also confirmed the Google Calendar, GitHub Notes and Inbox Assistant +listing rows. The agent-consent dialog remained unanswered. All owned test +processes stopped, and the namespace exited successfully. + +This evidence does **not** establish App Hub installation, account sign-in, +OS-authenticated write approval, package-manager installation or a final +integrated release. The receipt binds both the package and executable hashes +and the reviewed frame hash. Raw profiles, logs and snapshots remain private. +Future package runs must create new receipts; do not update this record to +claim evidence for a different package or source revision. + +For reproduction, verify the downloaded artifact's `.deb` SHA-256 against the +receipt, extract it to a new directory, and launch `usr/bin/octosense` with +fresh `OCTOSENSE_HOME`, `MAKEPAD_HOME` and XDG directories. Use an isolated +X11 display, `MAKEPAD=linux-x11`, `MAKEPAD_REMOTE=on` and +`MAKEPAD_WM_TEST_APP=apphub`. The native instrument's `snap`, `g` and `quit` +endpoints provide the same structural snapshot, frame and normal shutdown +checks. Keep the instrument on loopback, do not approve the agent-consent +dialog, and retain a new private run directory for the raw evidence. The +original environment-specific launch helpers are not shipped as a portable +driver. diff --git a/tools/connected-e2e/LINUX-PACKAGE.zh-CN.md b/tools/connected-e2e/LINUX-PACKAGE.zh-CN.md new file mode 100644 index 00000000..392f6368 --- /dev/null +++ b/tools/connected-e2e/LINUX-PACKAGE.zh-CN.md @@ -0,0 +1,31 @@ +# Linux 桌面安装包验收 + +[不可变验收记录](evidence/linux-package-37723207058.json) 记录了真实 `.deb` +安装包的八项通过结果。安装包来自 [桌面打包任务 +37723207058](https://github.com/OctoSense-org/OctoSense/actions/runs/37723207058), +产物编号为 `11528131778`,构建环境为 Ubuntu 22.04。实际源码是任务指定的 +PR 合并提交 `c15a0ab93d6d4991e5774e1b56028247bf9c7ac2`,不能仅用 PR +分支提交 `9f8ce11c7cc890aa77131821c42a0ae61b46fc54` 代替。 + +测试通过 `dpkg-deb -x` 将安装包解包到新的私有目录,没有向宿主系统安装。 +真实可执行文件及其随包资源在独立 Xvfb 显示和 D-Bus 会话中运行,已有的 +解包测试依赖通过只读 bwrap 挂载提供。测试使用全新 OctoSense 配置,没有 +使用个人提供商账号,也没有修改系统软件包或全局设置。 + +八项自动检查覆盖可执行文件存在、独立显示、原生进程持续运行、控件快照、 +实际渲染画面、App Hub 显示、资源加载和正常退出。对实际画面的审阅还确认了 +Google Calendar、GitHub Notes 和 Inbox Assistant 三个列表项。界面上的 +代理授权对话框没有被确认。所有自有测试进程均已停止,命名空间正常退出。 + +这项证据**不证明**从 App Hub 安装应用、登录账号、经操作系统认证后执行写入、 +通过包管理器安装或最终集成发行版可用。记录绑定了安装包、可执行文件及已审阅 +画面的哈希。原始配置、日志和快照保持私有。后续安装包必须生成新的验收记录, +不能修改这份记录来代表其他版本。 + +复现时,先核对 `.deb` 的 SHA-256,再解包到新目录。为 `usr/bin/octosense` +设置全新的 `OCTOSENSE_HOME`、`MAKEPAD_HOME` 和 XDG 目录,并使用独立 X11 +显示、`MAKEPAD=linux-x11`、`MAKEPAD_REMOTE=on` 与 +`MAKEPAD_WM_TEST_APP=apphub`。原生 instrument 的 `snap`、`g`、`quit` 端点 +分别用于相同的结构快照、画面和正常退出检查。instrument 仅监听回环地址, +不要确认代理授权对话框;新的原始证据保存在新的私有运行目录。原测试环境专用的 +启动脚本没有作为可移植驱动发布。 diff --git a/tools/connected-e2e/README.md b/tools/connected-e2e/README.md index 2f88be0a..02bc82f1 100644 --- a/tools/connected-e2e/README.md +++ b/tools/connected-e2e/README.md @@ -144,3 +144,67 @@ 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) + +The separately built Linux desktop `.deb` passed eight package launch, render, +resource and shutdown checks. See the immutable [package receipt and scope](LINUX-PACKAGE.md) +([中文](LINUX-PACKAGE.zh-CN.md)); that run did not install an app or connect an account. + +`backend_login.py` uses a real host, browser and operating-system credential +vault. Only the backend HTTP server and its fictional users are synthetic; no +account or token is injected into the host. Linux and Windows use the supported +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. The native examples were rebuilt from `e9765575` with its current locked +dependencies; all 36 recorded host/OAuth/fixture/lockfile hashes match the +reviewed source. The final run includes the responsive fixture and bounded +failure diagnostics; those two test files changed after the native build, with +no Rust source changes. This validates the installed-app fixture, not the entire +shell or OS-authenticated business writes. + +Build the host/installer as above and the pinned Hub CLI with +`cargo build --locked --release -p octosense-app-hub --bin hub`. With an existing +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. +Both passed in [run 37730564370](https://github.com/OctoSense-org/OctoSense/actions/runs/37730564370). +The [Windows receipt](evidence/windows-backend-37730564370.json) ([中文](WINDOWS-BACKEND.zh-CN.md)) records all +seven backend behaviors, normal Credential Manager use and clean native/server +shutdown. Its 35 recorded source/lockfile hashes match the reviewed files. +The actual workflow checkout was merge commit +`95d1da069c01cebdb585eca3e75354e58833abd8`, with PR head `723b202e`. +That receipt preserves the original artifact hash and clarifies its incorrectly +named checkout-revision field. It does not claim Windows visual UX acceptance +or independent readback after credential deletion. + +The acceptance host copies its own consent-sheet link into a private test +file, and the harness opens that exact URL in a fresh browser. **The OS default +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. + +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 1debe4f2..5e1c4dd4 100644 --- a/tools/connected-e2e/README.zh-CN.md +++ b/tools/connected-e2e/README.zh-CN.md @@ -116,3 +116,43 @@ 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 在隔离显示/会话中运行;所有测试进程与虚构连接均已清理。 +回执绑定实际二进制及源码哈希。原生示例使用 `e9765575` 的当前锁定依赖重新构建, +记录的全部 36 个宿主/OAuth/测试/锁文件哈希均与审阅源码一致。最终运行包括响应式 +测试界面及有界故障诊断;这两个测试文件在原生构建后修改,Rust 源码没有变化。 +这验证已安装应用测试流程,不代表整个 shell 或 OS 认证业务写入通过。 + +按前文构建宿主/安装器,并用 +`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 不适合作为公开工件,只发布审查过的回执。 + +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/WINDOWS-BACKEND.zh-CN.md b/tools/connected-e2e/WINDOWS-BACKEND.zh-CN.md new file mode 100644 index 00000000..4fc669ba --- /dev/null +++ b/tools/connected-e2e/WINDOWS-BACKEND.zh-CN.md @@ -0,0 +1,24 @@ +# Windows 开发者后端登录验收 + +[任务 37730564370](https://github.com/OctoSense-org/OctoSense/actions/runs/37730564370) +通过了真实 Windows Credential Manager 生命周期检查,以及原生 OctoSense +测试宿主、已有 Edge 浏览器和合成 HTTP 后端之间的完整登录流程。 +[不可变验收记录](evidence/windows-backend-37730564370.json) 保存了二进制哈希、 +原始记录哈希、35 个与已审阅源码一致的文件哈希和七项通过结果。 + +七项行为包括:浏览器注册与密码登录、真实 PKCE 回调及受保护数据访问、 +轮换刷新凭据后的故障恢复、原生进程重启后从系统凭据库恢复连接、退出与再次 +登录、两个已签名应用之间的账号句柄隔离,以及撤销两个虚构连接。所有自有 +原生进程和合成服务器均已停止。凭据删除通过真实宿主断开连接接口请求; +本流程没有额外读取系统凭据库来独立确认删除。 + +实际工作流检出的是合并提交 `95d1da069c01cebdb585eca3e75354e58833abd8`, +PR 分支提交为 `723b202e76c62473aa0d95f16a58f38553a2882d`。原始记录误将 +OctoSense 检出版本字段命名为 `runtime_base_commit`;公开记录明确了这个 +含义,不能把它当作 Makepad 版本。 + +测试使用临时签名、真实 Store 安装和全新的浏览器上下文,没有向宿主注入 +账号或令牌。浏览器打开的是测试宿主从自己的原生授权链接复制出的精确 URL; +**没有测试操作系统默认浏览器的链接点击**。此结果也不证明 Windows 视觉 UX、 +Google/GitHub 真实账号登录,或经人和操作系统认证后执行写入。原始回调 URL、 +配置及诊断文件保持私有。复现命令见 [英文驱动说明](README.md#native-backend-browser-acceptance-2026-10-08)。 diff --git a/tools/connected-e2e/backend-login/main.splash b/tools/connected-e2e/backend-login/main.splash index 90393491..df986c75 100644 --- a/tools/connected-e2e/backend-login/main.splash +++ b/tools/connected-e2e/backend-login/main.splash @@ -53,6 +53,8 @@ SolidView {width: Fill height: Fill flow: Down padding: 24 spacing: 10 draw_bg.c Label {width: Fill text: "Synthetic test account. Credentials stay in the browser and host vault." draw_text.color: #43536c} account := Label {width: Fill text: "No account" draw_text.color: #172033} identity := Label {width: Fill text: "No protected data" draw_text.color: #172033} + status := Label {width: Fill text: "Ready" draw_text.color: #233854} + ScrollYView {width: Fill height: Fill flow: Down spacing: 10 Button {width: Fill height: 44 text: "Connect backend" on_click: || connect("backend", ["app.session"])} Button {width: Fill height: 44 text: "Connect in WebView" on_click: || connect_webview()} Button {width: Fill height: 44 text: "Connect GitHub" on_click: || connect("github", ["read:user"])} @@ -61,5 +63,5 @@ SolidView {width: Fill height: Fill flow: Down padding: 24 spacing: 10 draw_bg.c Button {width: Fill height: 44 text: "Protected identity" on_click: || identity()} Button {width: Fill height: 44 text: "Disconnect" on_click: || disconnect()} Button {width: Fill height: 44 text: "Probe other app" on_click: || probe()} - status := Label {width: Fill text: "Ready" draw_text.color: #233854} + } } diff --git a/tools/connected-e2e/backend_login.py b/tools/connected-e2e/backend_login.py index a4186be2..36828c14 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. @@ -12,8 +12,10 @@ import json import os from pathlib import Path +import re import shutil import subprocess +import sys import time import urllib.error import urllib.parse @@ -59,6 +61,30 @@ def call(self, route, **query): time.sleep(.2) +def failure_geometry(native): + """Bounded structural diagnostics; no widget text, URLs or log contents.""" + snapshot = native.call('snap').get('s', []) + windows = native.call('s').get('w', []) + allowed_ids = {'status', 'oauth_status', 'account', 'identity', 'app', 'sheet'} + def dimensions(value, length): + return value if isinstance(value, list) and len(value) == length and all( + type(n) in (int, float) and -1e9 <= n <= 1e9 for n in value) else None + rows = [] + for row in snapshot[:80]: + kind = row.get('ty', '') + rows.append({ + 'kind': kind if re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]{0,63}', kind) else 'other', + 'id': row.get('i') if row.get('i') in allowed_ids else None, + 'rect': dimensions(row.get('r'), 4), + }) + log = native.log_path.read_text(errors='replace') + return {'child_running': native.child.poll() is None, + 'window_sizes': [dimensions(window.get('sz'), 2) for window in windows[:4]], + 'snapshot_row_count': len(snapshot), 'rows': rows, + 'log_markers': {marker: log.count(marker) for marker in + ('ScriptError', 'callback error', 'on_render closure failed', 'panicked')}} + + def bundle(path, app, hub): path.mkdir() shutil.copyfile(ROOT / 'tools/connected-e2e/backend-login/main.splash', path / 'main.splash') @@ -71,7 +97,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 +109,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 +146,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'), @@ -133,6 +172,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() @@ -146,6 +186,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(): @@ -162,6 +203,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() @@ -280,6 +322,22 @@ def login(capture, register=False, label='login'): except Exception as error: receipt['result'] = 'FAIL' receipt['error_type'] = type(error).__name__ + if native: + try: + receipt['failure_geometry'] = failure_geometry(native) + statuses = [] + for row in native.rows(): + if row.get('i') not in ('status', 'oauth_status'): + 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: @@ -287,7 +345,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..8f1fee90 --- /dev/null +++ b/tools/connected-e2e/evidence/linux-backend-native.json @@ -0,0 +1,91 @@ +{ + "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": "389e797556f346dadb04e878f7a7efbc3be8b590d7d69079140a150231906ab4", + "installer_sha256": "fc094084e7f39f2e86c6bb68f2f0a1bdc01414957033e5b7528f723373bd0dfe", + "source_identity": "Archived source: file hashes below are authoritative", + "checks": [ + "Short-lived token forces refresh; transient protected-data failure followed by another refresh recovers using durably stored rotated credential", + "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": "032d20c3c259ea9b62f5fa510d04eac410912e33baff26b090ba94f318e4d355", + "tools/backend-login-fixture.py": "5c2695ad6da5d82b0c41782cefd77392130cc8c362ecf1170b87ea4512000424", + "tools/connected-e2e/native.py": "3182c430d9b3797a823a67403543d056891598b404cf7fda577836f09e7ae7b6", + "tools/connected-e2e/backend-login/main.splash": "817ace3dd8b87bfcecdbd9fffe4b8f13fb7721ed1240e0c849f267c0853c42e8", + "crates/shell/examples/connected-app-host.rs": "5e960ac06a96c5302f5f89e72ee6fd45bcfbf4c0a033f94ee67e1cb7a8bb6d92", + "crates/shell/examples/connected_support/mod.rs": "5e6a567808dfe34b21d9348afe9345a52ec9165f747504c623bdc9af6898ab2d", + "Cargo.lock": "05993722b356ca50434220d705affac333c73878e50a1e03f5e1419daa5c656b", + "crates/oauth-service/src/acceptance_calendar.rs": "e3f03818b554cbfdd1f7d9bcf22272e8ec46c25bffc69fd4498c690c137446ac", + "crates/oauth-service/src/acceptance_fixtures.rs": "af8b2c8187a35015ea42b59aa7b2ca8e5c4e4834efa0144259ca691f00bcfdf0", + "crates/oauth-service/src/acceptance_github.rs": "520fd3681f4831c35cf671f0d4883af8c7d9e63e748ec41ede96d54fcaa77c7d", + "crates/oauth-service/src/acceptance_inbox.rs": "efc33661cbb2801037e6db9a1416a8b7b480a549e05dbe790ba2338dfb037162", + "crates/oauth-service/src/api.rs": "0d2f6edb38874709b0ef8affc8602896f8065e59af3d6eff97b33daebb0cd13e", + "crates/oauth-service/src/api_tests.rs": "6f4809c941b048717d001a80190ad7a50db49cf24f38f5454d8cb2249c7d72c3", + "crates/oauth-service/src/authorize.rs": "cfcea9157e678e53d9787a03d4a46a5f1b27804a5596ff11aa1000e9ea30af4b", + "crates/oauth-service/src/backend.rs": "93a5ea1227936e3394239398a8f39294729fd3e0912340e1db6b6a701d294cbb", + "crates/oauth-service/src/backend_registry.rs": "dff1937066c90ab54cd65c22f3cf1499e7c20d55f7d7dc5d8c774fc151682aec", + "crates/oauth-service/src/backend_tests.rs": "9e45303b92bfb5992e25d844f1affdc9a556dc9c2d51effb16d1e15fbdcb603c", + "crates/oauth-service/src/calendar_cache.rs": "91efe0916a4fa73c3f36756cbbd6359c5ecc1122f7e090343b0dde3773d601e2", + "crates/oauth-service/src/host.rs": "401a3810d2f76ec8cbaf62b344f060ed9b073c751500fc8143eb7398bf8fd069", + "crates/oauth-service/src/host_api.rs": "a38e2fd7b0b24eda2d8c0f974b4032551e1286a905273eb6772d0475836da418", + "crates/oauth-service/src/host_backend.rs": "1839063da9a9f7534e79839fc807abe3b64143efab998678599a699604a8d066", + "crates/oauth-service/src/host_catalog.rs": "e8cb3e4b02cb95703e3650e1fe6db9ccda49e2ad4c84e2ebc19dcf5d37740760", + "crates/oauth-service/src/host_inbox.rs": "aa69422fd3fbe504035eb60fe4203222ebb9f1877884f0dbe77143f864c48ad8", + "crates/oauth-service/src/host_lifetime_tests.rs": "50904d91121ae3f16caf4c8c9008c66f9cd6b85e2af54558451be683079c662b", + "crates/oauth-service/src/host_operation_tests.rs": "ba0dc9cbbbcc592b1268be23d2e45e0cd8faa3e1ff7f202090cc7d1df3f45526", + "crates/oauth-service/src/host_vault_acceptance.rs": "ff672463d37614b7f64f20a92c5e0e0a3de213dacb7b478d81d1bc894b6afe2a", + "crates/oauth-service/src/inbox.rs": "ec34c7fe90e6c0d43c8d785a9e2443aec234fafb6356a4759b2b6992ff88437d", + "crates/oauth-service/src/inbox_events.rs": "a795b00448ff0b679b019bdaf5df1e8bdffe754bcd46c0a2cf55b30e4a906af9", + "crates/oauth-service/src/lib.rs": "c7f60fcd66072440635aeb475e3ea4fc95899470fe308e9ae30e6840b41ab79e", + "crates/oauth-service/src/oauth.rs": "b3a7edd30fcb0235db93befbea8653847c9899ed26d0681e070e48e6077d9318", + "crates/oauth-service/src/protocol.rs": "5495da22f0d50b8f89f8840bcfad1b8e2ec3227e9564f96e1552a4b7705917f0", + "crates/oauth-service/src/providers.rs": "a22b9bc5bd54536c0fabee41cd3f84f00a29ef474c64e851fe42ceb02853765d", + "crates/oauth-service/src/registration.rs": "33392b88f2444c9f88794add5b47cb30f9c731340dfba60dd3bec76385da2e62", + "crates/oauth-service/src/store.rs": "4f23735e6a14bc40145d9b1a9720a6efa20f89610bd7942ceab7eaea23fa8bc8", + "crates/oauth-service/src/transport.rs": "1db7518fa5ed853412e2bdddb836d45266c21f9a793d82e3351e7eabaa5d5960", + "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-08T05:03:32.191324+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, + "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", + "live Google/GitHub provider sign-in", + "physical or OS-authenticated business write approval" + ], + "source_comparison": { + "matching_current_files": 36, + "different_files": [], + "reason": "All recorded host, OAuth, fixture and lockfile hashes match the reviewed source; native binary built from e9765575, then only the driver and responsive fixture changed." + }, + "native_build_source_revision": "e9765575fcb102ccf2ca7354daab5eb005ce3526", + "runtime_patches_lock_sha256": "b0679f53d756dee9dc8ee6b0aa3e560fd407b75006b1d1d35a70436467ada43c" +} diff --git a/tools/connected-e2e/evidence/linux-package-37723207058.json b/tools/connected-e2e/evidence/linux-package-37723207058.json new file mode 100644 index 00000000..dd592f1b --- /dev/null +++ b/tools/connected-e2e/evidence/linux-package-37723207058.json @@ -0,0 +1,42 @@ +{ + "schema": 1, + "platform": "linux-x86_64", + "package_run": 37723207058, + "source_revision": "c15a0ab93d6d4991e5774e1b56028247bf9c7ac2", + "pull_request_head": "9f8ce11c7cc890aa77131821c42a0ae61b46fc54", + "source_identity_evidence": "Release desktop run37723207058 plan job113135689588 exact planned PR merge commit", + "deb_sha256": "08dc2761669e3477fa2ec2466dfa976c2a1eda4871b7f3b447a8a51d422a057f", + "binary_sha256": "ca92f9f8d9460c6a18648bb32c733c4c3976171f1a2a0e4cf27277c85f425553", + "personal_accounts_used": false, + "system_packages_changed": false, + "checks": { + "packaged_binary_present": true, + "isolated_x_display": true, + "desktop_stays_running": true, + "native_widget_snapshot_available": true, + "actual_packaged_frame_captured": true, + "app_hub_loaded": true, + "packaged_resources_found": true, + "normal_owned_shutdown": true + }, + "passed": true, + "owned_processes_stopped": true, + "namespace_exit": 0, + "test_scope": "Unprivileged package extraction; real Xvfb/Makepad launch, App Hub catalog rendering, clean shutdown. No agent consent or app installation.", + "artifact_id": 11528131778, + "package_kind": "deb", + "package_installation": "extracted without installing into the host OS", + "agent_consent_approved": false, + "screenshot_sha256": "92461d087d85ddbc808b22da686ea861e2c89823845e2e4040175beb144c0e5a", + "visual_observations": [ + "App Hub rendered Google Calendar, GitHub Notes and Inbox Assistant listing rows.", + "The native agent-consent dialog was visible and remained unanswered." + ], + "not_verified": [ + "App installation or launch from App Hub", + "Any provider sign-in or account access", + "OS-authenticated business writes", + "Package-manager installation or system-policy installation", + "Final integrated release" + ] +} diff --git a/tools/connected-e2e/evidence/windows-backend-37730564370.json b/tools/connected-e2e/evidence/windows-backend-37730564370.json new file mode 100644 index 00000000..8cf00d98 --- /dev/null +++ b/tools/connected-e2e/evidence/windows-backend-37730564370.json @@ -0,0 +1,83 @@ +{ + "schema": 1, + "result": "PASS", + "started_utc": "2026-10-08T05:25:07.457732+00:00", + "finished_utc": "2026-10-08T05:25:22.859975+00:00", + "case": "Real native backend login with synthetic server and actual browser callbacks", + "provider": "synthetic HTTP loopback; not live GitHub or Google", + "vault": "normal platform credential vault; no preseeded accounts or tokens", + "browser_handoff": "host-only LinkLabel URL copied privately by test example; exact URL opened in a fresh browser context; OS-default-browser click omitted", + "browser_engine": "msedge.exe", + "installation": "ephemeral fixture signing, actual Store install and prepared launch", + "input": "Makepad native instrument, default input acknowledgement, no input replay", + "binary_sha256": "68eb499c7dcfbb9b128e4df2ad0be83273d3aa347602749b5fc4f9a43f07be39", + "installer_sha256": "824b116b3c1a8b8c86aaf49951e483eebc6526dc459990d48f176484ff7216a4", + "visual_review": "pending", + "checks": [ + "Short-lived token forces refresh; transient protected-data failure followed by another refresh recovers using durably stored rotated credential", + "Fresh browser registration, password sign-in, real PKCE callback and protected identity", + "Native process restart restores same app-bound connection from platform vault and reloads protected data", + "Logout durably removes connection and protected identity is denied", + "Existing fictional user signs in again through browser without registration; fresh connection issued", + "Second signed app cannot access first handle before or after its own genuine browser login", + "Both fictional handles durably revoked through real host service; platform-vault deletion requested (deletion not independently read back)" + ], + "local_connections_revoked": true, + "vault_cleanup": "Deletion requested through host disconnect; no independent vault readback", + "native_processes_stopped": true, + "synthetic_server_stopped": true, + "workflow_run": 37730564370, + "artifact_id": 11530745579, + "pull_request_head": "723b202e76c62473aa0d95f16a58f38553a2882d", + "tested_checkout_revision": "95d1da069c01cebdb585eca3e75354e58833abd8", + "source_revision_note": "The original artifact names the OctoSense checkout revision runtime_base_commit; it is not a Makepad commit.", + "original_receipt_sha256": "35fb7e5df6267415a3081bce7450d1cb7044c6242adaabb29568b2f5d6292d91", + "source_sha256": { + "tools/connected-e2e/backend_login.py": "032d20c3c259ea9b62f5fa510d04eac410912e33baff26b090ba94f318e4d355", + "tools/backend-login-fixture.py": "5c2695ad6da5d82b0c41782cefd77392130cc8c362ecf1170b87ea4512000424", + "tools/connected-e2e/native.py": "3182c430d9b3797a823a67403543d056891598b404cf7fda577836f09e7ae7b6", + "tools/connected-e2e/backend-login/main.splash": "817ace3dd8b87bfcecdbd9fffe4b8f13fb7721ed1240e0c849f267c0853c42e8", + "crates/shell/examples/connected-app-host.rs": "5e960ac06a96c5302f5f89e72ee6fd45bcfbf4c0a033f94ee67e1cb7a8bb6d92", + "crates/shell/examples/connected_support/mod.rs": "5e6a567808dfe34b21d9348afe9345a52ec9165f747504c623bdc9af6898ab2d", + "Cargo.lock": "05993722b356ca50434220d705affac333c73878e50a1e03f5e1419daa5c656b", + "crates/oauth-service/src/acceptance_calendar.rs": "e3f03818b554cbfdd1f7d9bcf22272e8ec46c25bffc69fd4498c690c137446ac", + "crates/oauth-service/src/acceptance_fixtures.rs": "af8b2c8187a35015ea42b59aa7b2ca8e5c4e4834efa0144259ca691f00bcfdf0", + "crates/oauth-service/src/acceptance_github.rs": "520fd3681f4831c35cf671f0d4883af8c7d9e63e748ec41ede96d54fcaa77c7d", + "crates/oauth-service/src/acceptance_inbox.rs": "efc33661cbb2801037e6db9a1416a8b7b480a549e05dbe790ba2338dfb037162", + "crates/oauth-service/src/api.rs": "0d2f6edb38874709b0ef8affc8602896f8065e59af3d6eff97b33daebb0cd13e", + "crates/oauth-service/src/api_tests.rs": "6f4809c941b048717d001a80190ad7a50db49cf24f38f5454d8cb2249c7d72c3", + "crates/oauth-service/src/authorize.rs": "cfcea9157e678e53d9787a03d4a46a5f1b27804a5596ff11aa1000e9ea30af4b", + "crates/oauth-service/src/backend.rs": "93a5ea1227936e3394239398a8f39294729fd3e0912340e1db6b6a701d294cbb", + "crates/oauth-service/src/backend_registry.rs": "dff1937066c90ab54cd65c22f3cf1499e7c20d55f7d7dc5d8c774fc151682aec", + "crates/oauth-service/src/backend_tests.rs": "9e45303b92bfb5992e25d844f1affdc9a556dc9c2d51effb16d1e15fbdcb603c", + "crates/oauth-service/src/calendar_cache.rs": "91efe0916a4fa73c3f36756cbbd6359c5ecc1122f7e090343b0dde3773d601e2", + "crates/oauth-service/src/host.rs": "401a3810d2f76ec8cbaf62b344f060ed9b073c751500fc8143eb7398bf8fd069", + "crates/oauth-service/src/host_api.rs": "a38e2fd7b0b24eda2d8c0f974b4032551e1286a905273eb6772d0475836da418", + "crates/oauth-service/src/host_backend.rs": "1839063da9a9f7534e79839fc807abe3b64143efab998678599a699604a8d066", + "crates/oauth-service/src/host_catalog.rs": "e8cb3e4b02cb95703e3650e1fe6db9ccda49e2ad4c84e2ebc19dcf5d37740760", + "crates/oauth-service/src/host_inbox.rs": "aa69422fd3fbe504035eb60fe4203222ebb9f1877884f0dbe77143f864c48ad8", + "crates/oauth-service/src/host_lifetime_tests.rs": "50904d91121ae3f16caf4c8c9008c66f9cd6b85e2af54558451be683079c662b", + "crates/oauth-service/src/host_operation_tests.rs": "ba0dc9cbbbcc592b1268be23d2e45e0cd8faa3e1ff7f202090cc7d1df3f45526", + "crates/oauth-service/src/host_vault_acceptance.rs": "ff672463d37614b7f64f20a92c5e0e0a3de213dacb7b478d81d1bc894b6afe2a", + "crates/oauth-service/src/inbox.rs": "ec34c7fe90e6c0d43c8d785a9e2443aec234fafb6356a4759b2b6992ff88437d", + "crates/oauth-service/src/inbox_events.rs": "a795b00448ff0b679b019bdaf5df1e8bdffe754bcd46c0a2cf55b30e4a906af9", + "crates/oauth-service/src/lib.rs": "c7f60fcd66072440635aeb475e3ea4fc95899470fe308e9ae30e6840b41ab79e", + "crates/oauth-service/src/oauth.rs": "b3a7edd30fcb0235db93befbea8653847c9899ed26d0681e070e48e6077d9318", + "crates/oauth-service/src/protocol.rs": "5495da22f0d50b8f89f8840bcfad1b8e2ec3227e9564f96e1552a4b7705917f0", + "crates/oauth-service/src/providers.rs": "a22b9bc5bd54536c0fabee41cd3f84f00a29ef474c64e851fe42ceb02853765d", + "crates/oauth-service/src/registration.rs": "33392b88f2444c9f88794add5b47cb30f9c731340dfba60dd3bec76385da2e62", + "crates/oauth-service/src/store.rs": "4f23735e6a14bc40145d9b1a9720a6efa20f89610bd7942ceab7eaea23fa8bc8", + "crates/oauth-service/src/transport.rs": "1db7518fa5ed853412e2bdddb836d45266c21f9a793d82e3351e7eabaa5d5960" + }, + "source_hash_verification": { + "recorded": 35, + "matched_reviewed_source": 35 + }, + "not_verified": [ + "OS-default-browser link click", + "Visual UX acceptance", + "Successful person-mediated write approval", + "Live Google or GitHub login", + "Independent platform-vault deletion readback" + ] +} 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') diff --git a/tools/fixtures/host-api-lab/ANDROID.md b/tools/fixtures/host-api-lab/ANDROID.md new file mode 100644 index 00000000..172efe18 --- /dev/null +++ b/tools/fixtures/host-api-lab/ANDROID.md @@ -0,0 +1,39 @@ +# 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: + +The workspace Cargo configuration selects the supported AWS-LC CC builder for both Android arm64 and an Apple arm64 build host. The Android packager otherwise forces CMake for host-side bundle validation too. This avoids an extra CMake installation without disabling cryptography or assembly. The equivalent target-scoped host environment override was exercised in the Android build before being added to the configuration. + +```sh +MAKEPAD_FORCE_DEBUGGABLE=1 "$PACKAGER" makepad android \ + --sdk-path="$MAKEPAD_ANDROID_SDK" --abi=aarch64 \ + --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..0d96bdcd --- /dev/null +++ b/tools/fixtures/host-api-lab/ANDROID.zh-CN.md @@ -0,0 +1,39 @@ +# 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 源码: + +工作区 Cargo 配置为 Android arm64 目标和 Apple arm64 构建主机选择 AWS-LC 支持的 CC 构建器。否则,Android 打包器也会强制宿主侧的测试包验证依赖使用 CMake。此设置避免额外安装 CMake,不禁用密码学或汇编功能。在写入配置前,等效的宿主目标环境变量覆盖已用于实际 Android 构建。 + +```sh +MAKEPAD_FORCE_DEBUGGABLE=1 "$PACKAGER" makepad android \ + --sdk-path="$MAKEPAD_ANDROID_SDK" --abi=aarch64 \ + --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 6359901c..fb407e50 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 shows an app's own Splash tool calling Rust code that is already compiled into OctoSense. The tool reads the real macOS camera permission status, updates the app's screen and returns a structured answer to its native caller. It never captures media and never approves device access. It is not an App Hub submission, and it is not a way to load arbitrary Rust libraries. A call takes this path: @@ -50,7 +52,7 @@ From a host callback, which keeps the tool's background provenance, the tool del **Verified:** the `native-host-api` job of `.github/workflows/desktop.yml` ran these commands on a GitHub `macos-14` runner for the change that added this fixture, adding `--output` for its evidence directory, and they passed. -**Not covered:** real model reasoning, approving a permission with a physical press, camera capture, Android, the unsupported-platform answers on Linux and Windows, and publishing a compatible host binary. Separate runtime regression tests on the real Splash VM cover detached timers, paused tasks, HTTP and WebSocket callbacks, and the gates in the native device helpers; this fixture covers chained host callbacks. +**Not covered by this macOS run:** real model reasoning, approving a permission with a physical press, camera capture, Android, the unsupported-platform answers on Linux and Windows, and publishing a compatible host binary. Android has its [separate OnePlus 6 acceptance record](ANDROID.md). Separate runtime regression tests on the real Splash VM cover detached timers, paused tasks, HTTP and WebSocket callbacks, and the gates in the native device helpers; this fixture covers chained host callbacks. ## Reuse the pattern diff --git a/tools/fixtures/host-api-lab/README.zh-CN.md b/tools/fixtures/host-api-lab/README.zh-CN.md index 349eb044..036f502d 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 的应用,也不能用来加载任意 Rust 库。 一次调用经过以下路径: @@ -50,7 +52,7 @@ python3 tools/test-host-api-native.py --hub target/debug/hub **已验证**:`.github/workflows/desktop.yml` 的 `native-host-api` 任务在 GitHub `macos-14` 运行器上,为添加本测试示例的改动运行了上述命令(另加 `--output` 指定证据目录),全部通过。 -**未覆盖**:真实的模型推理、亲手点按批准权限、摄像头拍摄、Android、Linux 和 Windows 上“不支持该平台”的应答,以及发布兼容的宿主二进制文件。另有在真实的 Splash VM 上运行的运行时回归测试,覆盖分离的定时器、暂停的任务、HTTP 和 WebSocket 回调,以及原生设备辅助函数中的检查;本测试示例覆盖的是链式宿主回调。 +**本次 macOS 运行未覆盖**:真实的模型推理、亲手点按批准权限、摄像头拍摄、Android、Linux 和 Windows 上“不支持该平台”的应答,以及发布兼容的宿主二进制文件。Android 的结果见[单独的 OnePlus 6 验收记录](ANDROID.zh-CN.md)。另有在真实的 Splash VM 上运行的运行时回归测试,覆盖分离的定时器、暂停的任务、HTTP 和 WebSocket 回调,以及原生设备辅助函数中的检查;本测试示例覆盖的是链式宿主回调。 ## 复用这一模式 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/fixtures/release-scan/LICENSE b/tools/fixtures/release-scan/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/tools/fixtures/release-scan/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/tools/fixtures/release-scan/README.md b/tools/fixtures/release-scan/README.md new file mode 100644 index 00000000..eb2fba47 --- /dev/null +++ b/tools/fixtures/release-scan/README.md @@ -0,0 +1,9 @@ +# Public OctosCode release-scan fixtures + +These three unmodified files come from the Apache-2.0 licensed [OctosCode source](https://github.com/octos-org/octoscode-app/tree/5d0c2a0962218aa94497201ce1a39b7429f09e36). They contain synthetic design examples, not release-builder data. + +- `workspace.card`: `design/components/ws-group/page.card` +- `browser.json`: `design/stage-b/phase4/cards/p4-08/reference.ocr.json` +- `pairing.json`: `design/stage-b/phase4/cards/p4-03/reference.ocr.json` + +The complete-asset hashes and individual permitted match offsets are recorded in `tools/release-scan-public-assets.json`. Changing any byte invalidates the exception. diff --git a/tools/fixtures/release-scan/browser.json b/tools/fixtures/release-scan/browser.json new file mode 100644 index 00000000..d41244da --- /dev/null +++ b/tools/fixtures/release-scan/browser.json @@ -0,0 +1,108 @@ +{ + "width": 812.0, + "height": 1552.0, + "observations": [ + { + "bounds": [ + -0.0, + 74.0, + 351.8667, + 41.0465 + ], + "confidence": 1.0, + "text": "8. Choose a folder" + }, + { + "bounds": [ + 112.7778, + 216.5581, + 575.1667, + 51.8837 + ], + "confidence": 1.0, + "text": "Choose workspace folder" + }, + { + "bounds": [ + 31.5778, + 309.0, + 471.4111, + 29.3721 + ], + "confidence": 1.0, + "text": "/ > home \u203a dev \u203a code" + }, + { + "bounds": [ + 164.5911, + 426.1404, + 101.629, + 31.9983 + ], + "confidence": 1.0, + "text": "octos" + }, + { + "bounds": [ + 172.7028, + 530.444, + 250.3675, + 50.9473 + ], + "confidence": 1.0, + "text": "octoscode-app" + }, + { + "bounds": [ + 173.5896, + 649.426, + 94.9097, + 34.3341 + ], + "confidence": 1.0, + "text": "notes" + }, + { + "bounds": [ + 171.3349, + 762.1573, + 126.4857, + 36.7087 + ], + "confidence": 1.0, + "text": "scratch" + }, + { + "bounds": [ + 51.8778, + 866.0, + 356.3778, + 39.0 + ], + "confidence": 1.0, + "text": "3 hidden by the server" + }, + { + "bounds": [ + 71.834, + 1015.8643, + 578.2367, + 49.4239 + ], + "confidence": 1.0, + "text": "/home/user/code/octoscode-app" + }, + { + "bounds": [ + 252.6222, + 1148.0, + 263.9, + 43.0698 + ], + "confidence": 1.0, + "text": "Use this folder" + } + ], + "image_sha256": "2e0b9de79e8cd1e4164b67852c273fcf18e8e11ef75e87a2c0c71e7a245d7597", + "engine": "Apple Vision VNRecognizeTextRequest accurate, en-US (pyobjc)" +} diff --git a/tools/fixtures/release-scan/pairing.json b/tools/fixtures/release-scan/pairing.json new file mode 100644 index 00000000..ca00e618 --- /dev/null +++ b/tools/fixtures/release-scan/pairing.json @@ -0,0 +1,88 @@ +{ + "width": 812.0, + "height": 1552.0, + "observations": [ + { + "bounds": [ + 0.0, + -0.0, + 281.9444, + 41.0 + ], + "confidence": 1.0, + "text": "3. Link problem" + }, + { + "bounds": [ + 216.5333, + 187.0, + 374.4222, + 45.3488 + ], + "confidence": 1.0, + "text": "Pair with Octos" + }, + { + "bounds": [ + 110.5222, + 351.907, + 552.6111, + 38.3488 + ], + "confidence": 1.0, + "text": "This pairing link was already used." + }, + { + "bounds": [ + 110.5222, + 424.0, + 426.3, + 36.186 + ], + "confidence": 1.0, + "text": "Ask Octos for a new code." + }, + { + "bounds": [ + 51.7801, + 579.4133, + 106.2064, + 32.2433 + ], + "confidence": 1.0, + "text": "Server" + }, + { + "bounds": [ + 87.9667, + 676.7442, + 426.3, + 38.3488 + ], + "confidence": 1.0, + "text": "http://192.168.1.20:50190" + }, + { + "bounds": [ + 51.8778, + 809.8372, + 209.7667, + 31.5814 + ], + "confidence": 1.0, + "text": "Access token" + }, + { + "bounds": [ + 320.0208, + 1235.0225, + 142.6362, + 36.1643 + ], + "confidence": 1.0, + "text": "Connect" + } + ], + "image_sha256": "2b5d5f5886d5b1d5342973132341134f9369ea38ff76b13a7843357b2c4a17f7", + "engine": "Apple Vision VNRecognizeTextRequest accurate, en-US (pyobjc)" +} diff --git a/tools/fixtures/release-scan/workspace.card b/tools/fixtures/release-scan/workspace.card new file mode 100644 index 00000000..3410c526 --- /dev/null +++ b/tools/fixtures/release-scan/workspace.card @@ -0,0 +1,61 @@ +# ledger ws-group@1.0.0 +# level: L0 +# profile: ui/l0 +# Static design fixture, runtime native Kit composition. +theme light +copy t_ws0_name_text { class: user-copy, en: "octos" } +copy t_ws0_path_text { class: user-copy, en: "~/home/octos" } +copy t_ws1_name_text { class: user-copy, en: "octoscode-app" } +copy t_ws1_path_text { class: user-copy, en: "~/home/octos/octoscode-app" } +copy t_ws2_name_text { class: user-copy, en: "robrix2" } +copy t_ws2_path_text { class: user-copy, en: "~/home/octos/robrix2" } +copy t_ws3_name_text { class: user-copy, en: "octos-web" } +copy t_ws3_path_text { class: user-copy, en: "~/home/octos/octos-web" } +component Surface7a7bef822439(instance: text) { + view Kit(component: "Surface7a7bef822439", instance: instance) { slot } +} +component Vectore0a378fde04f(instance: text) { + view Kit(component: "Vectore0a378fde04f", instance: instance) +} +component Text404646513d4f(instance: text, text: text) { + view Kit(component: "Text404646513d4f", instance: instance, text: text) +} +component Textfd6a3ee4835f(instance: text, text: text) { + view Kit(component: "Textfd6a3ee4835f", instance: instance, text: text) +} +component Surfacec4c38149242a(instance: text) { + view Kit(component: "Surfacec4c38149242a", instance: instance) { slot } +} +component Texta90b9b77c268(instance: text, text: text) { + view Kit(component: "Texta90b9b77c268", instance: instance, text: text) +} +component Textfd1c766318fb(instance: text, text: text) { + view Kit(component: "Textfd1c766318fb", instance: instance, text: text) +} +component Text2cf7aad1d558(instance: text, text: text) { + view Kit(component: "Text2cf7aad1d558", instance: instance, text: text) +} + +view root Surface7a7bef822439(instance: "recent_group") { + Vectore0a378fde04f(instance: "ws_icon0") + Text404646513d4f(instance: "t_ws0_name", text: copy.t_ws0_name_text) + Textfd6a3ee4835f(instance: "t_ws0_path", text: copy.t_ws0_path_text) + Surfacec4c38149242a(instance: "hair_ws1") { + + } + Vectore0a378fde04f(instance: "ws_icon1") + Texta90b9b77c268(instance: "t_ws1_name", text: copy.t_ws1_name_text) + Textfd1c766318fb(instance: "t_ws1_path", text: copy.t_ws1_path_text) + Surfacec4c38149242a(instance: "hair_ws2") { + + } + Vectore0a378fde04f(instance: "ws_icon2") + Text404646513d4f(instance: "t_ws2_name", text: copy.t_ws2_name_text) + Text2cf7aad1d558(instance: "t_ws2_path", text: copy.t_ws2_path_text) + Surfacec4c38149242a(instance: "hair_ws3") { + + } + Vectore0a378fde04f(instance: "ws_icon3") + Text404646513d4f(instance: "t_ws3_name", text: copy.t_ws3_name_text) + Text2cf7aad1d558(instance: "t_ws3_path", text: copy.t_ws3_path_text) +} diff --git a/tools/fixtures/wasm-phone-lab/README.md b/tools/fixtures/wasm-phone-lab/README.md new file mode 100644 index 00000000..6b7c5ac4 --- /dev/null +++ b/tools/fixtures/wasm-phone-lab/README.md @@ -0,0 +1,29 @@ +# Wasm phone acceptance + +English | [简体中文](README.zh-CN.md) + +This synthetic app exercises the signed Store → app tool → `host.request` → Wasm service path. It uses no personal account or model. It checks fresh state after a successful call, guest error, trap and deadline; raw native responses must survive bounded JSON conversion. It also checks account scope, undeclared tools, invalid arguments, closed endpoints and absence of an approval sheet. + +Use a clean committed checkout and an assigned Android device. The fixture embeds its source commit and runtime tree when compiled; the driver refuses stale or dirty builds. It installs only a fresh `dev.makepad.octosense.hostapilab.*` package and stops that package afterward. It does not use or replace Home. The APK is a debuggable test build, not a release or performance benchmark. + +Use your existing Android SDK, JDK, ADB and pinned Makepad packager. Set `CARGO_MAKEPAD`, `MAKEPAD_ANDROID_SDK`, `ADB`, `AAPT2`, `HUB`, `WASM_TARGET` and `WASM_EVIDENCE` to your local paths; the evidence directory must not exist. From the repository root: + +```sh +python3 tools/setup.py --check --cargo +MAKEPAD_FORCE_DEBUGGABLE=1 CARGO_TARGET_DIR="$WASM_TARGET" "$CARGO_MAKEPAD" makepad android --sdk-path="$MAKEPAD_ANDROID_SDK" --abi=aarch64 --version-code=2026100703 --package-name=dev.makepad.octosense.hostapilab.wasm1 --app-label=OctoSenseWasmIsolationTest build -p octosense-wasm-phone-smoke --release --locked --offline +python3 tools/test-wasm-phone.py --adb "$ADB" --aapt2 "$AAPT2" --hub "$HUB" --apk "$WASM_TARGET/makepad-android-apk/octosense_wasm_phone_smoke/apk/octo_sense_wasm_isolation_test.apk" --out "$WASM_EVIDENCE" +``` + +The driver stamps a temporary copy. The native host creates ephemeral signing keys in memory and uses normal signature, digest, capability and tool admission. Keys and provider credentials are not published. Receipts record APK/source/runtime identities and individual results, without device serials or SDK paths. Keep an earlier receipt separate; choose a new package suffix for another install or remove only your previous test package. + +This isolated fixture explicitly selects its temporary legacy catalog before the Wasm worker rechecks admission. Normal OctoSense keeps the GitHub catalog default; this test does not establish production catalog publication or keyless App Hub UI acceptance. Apple arm64 Android build hosts use the workspace's target-scoped AWS-LC CC configuration described in the [Android build note](../host-api-lab/ANDROID.md). + +`state.wat` is the source of `bundle/fns/state.wasm`. To reproduce the checked-in module: + +```sh +cargo run --locked --offline -p octosense-wasm-host --example encode_phone_fixture -- tools/fixtures/wasm-phone-lab/state.wat tools/fixtures/wasm-phone-lab/bundle/fns/state.wasm +``` + +The guest deliberately remembers input if its instance is reused. Passing requires the host to create a new instance, including after success. Earlier 19-check evidence used explicit result fields and did not prove raw response forwarding; the repaired raw path adds that check, and the reproducible harness adds two compiled-identity checks. The [OnePlus 6 receipt](acceptance-oneplus6.json) records **22/22 checks passed** on Android 15, built from source `e67ce63ebca6054961899691e1644a54c2b4f081` with runtime tree `0fc8e29e2411fd7eb94d845ba16a261d2ebc9dfc`. The build and driver commands above were executed with local tool paths. Live model/peer relay, performance and release APK behavior remain unverified. + +The listing image is a separate native macOS capture of this fixture after its signed app tool completed, made in a hidden Makepad window from source `d3535a95`. It is not a phone screenshot and does not extend the 22-check OnePlus receipt to the later screenshot-only change. The image and refreshed fixture digest replace the earlier unrelated Host API Lab image. diff --git a/tools/fixtures/wasm-phone-lab/README.zh-CN.md b/tools/fixtures/wasm-phone-lab/README.zh-CN.md new file mode 100644 index 00000000..9bccb8b5 --- /dev/null +++ b/tools/fixtures/wasm-phone-lab/README.zh-CN.md @@ -0,0 +1,29 @@ +# Wasm 手机验收 + +[English](README.md) | 简体中文 + +这个合成应用验证签名商店准入 → 应用工具 → `host.request` → Wasm 服务的真实路径,不使用个人账户或模型。它检查成功调用、客体错误、陷阱和超时之后的新实例状态,以及未经重组的原生响应能否通过有大小限制的 JSON 转换。它也检查账户范围、未声明工具、无效参数、已关闭端点和审批面板未出现。 + +使用已提交且干净的检出目录和获准测试的 Android 设备。编译时把源码提交及运行时树标识嵌入测试应用,驱动拒绝过期或未提交的构建。只安装全新的 `dev.makepad.octosense.hostapilab.*` 包,并在结束后停止该包,不读取或替换 Home。这是可调试的测试 APK,不是发布包或性能基准。 + +使用已有 Android SDK、JDK、ADB 和固定版本的 Makepad 打包器。把 `CARGO_MAKEPAD`、`MAKEPAD_ANDROID_SDK`、`ADB`、`AAPT2`、`HUB`、`WASM_TARGET`、`WASM_EVIDENCE` 设置为本机路径;证据目录必须尚不存在。在仓库根目录运行: + +```sh +python3 tools/setup.py --check --cargo +MAKEPAD_FORCE_DEBUGGABLE=1 CARGO_TARGET_DIR="$WASM_TARGET" "$CARGO_MAKEPAD" makepad android --sdk-path="$MAKEPAD_ANDROID_SDK" --abi=aarch64 --version-code=2026100703 --package-name=dev.makepad.octosense.hostapilab.wasm1 --app-label=OctoSenseWasmIsolationTest build -p octosense-wasm-phone-smoke --release --locked --offline +python3 tools/test-wasm-phone.py --adb "$ADB" --aapt2 "$AAPT2" --hub "$HUB" --apk "$WASM_TARGET/makepad-android-apk/octosense_wasm_phone_smoke/apk/octo_sense_wasm_isolation_test.apk" --out "$WASM_EVIDENCE" +``` + +驱动只给临时副本重新计算摘要。原生宿主在内存中生成临时签名密钥,使用正常的签名、摘要、能力和工具准入检查,不发布密钥或服务商凭据。回执记录 APK、源码及运行时标识和各项结果,不包含设备序列号或 SDK 路径。保留旧回执;重新安装时选择新的包后缀,或只删除自己上一次的测试包。 + +这个独立测试会在 Wasm 工作线程重新检查准入前,明确选择其临时旧格式目录。正常 OctoSense 仍默认使用 GitHub 目录;本测试不能证明生产目录发布或免开发者密钥的 App Hub 界面验收。Apple arm64 主机的 Android 构建使用工作区按目标设置的 AWS-LC CC 配置,见 [Android 构建说明](../host-api-lab/ANDROID.zh-CN.md)。 + +`state.wat` 是 `bundle/fns/state.wasm` 的源码。重建已提交的模块: + +```sh +cargo run --locked --offline -p octosense-wasm-host --example encode_phone_fixture -- tools/fixtures/wasm-phone-lab/state.wat tools/fixtures/wasm-phone-lab/bundle/fns/state.wasm +``` + +客体在实例复用时会主动保留输入;通过测试要求宿主在每次调用(包括成功调用)之后创建新实例。先前的 19 项结果使用了显式组装的字段,不能证明原始响应转发。修复后的原始路径增加一项转发检查,可复现的公开测试还增加两项编译标识检查。[OnePlus 6 回执](acceptance-oneplus6.json)记录 Android 15 上 **22/22 项通过**,构建源码为 `e67ce63ebca6054961899691e1644a54c2b4f081`,运行时树为 `0fc8e29e2411fd7eb94d845ba16a261d2ebc9dfc`。上面的构建和驱动命令已使用本机工具路径执行。真实模型/对等代理转发、性能和发布 APK 行为仍未验证。 + +应用列表图片是单独在 macOS 隐藏 Makepad 窗口中捕获的真实界面,源码为 `d3535a95`,拍摄时此测试应用的签名应用工具已经完成。它不是手机截图,也不把 OnePlus 的 22 项回执扩展到后续仅替换图片的提交。该图片及更新后的测试包摘要替换了先前无关的 Host API Lab 图片。 diff --git a/tools/fixtures/wasm-phone-lab/acceptance-oneplus6.json b/tools/fixtures/wasm-phone-lab/acceptance-oneplus6.json new file mode 100644 index 00000000..59b88f84 --- /dev/null +++ b/tools/fixtures/wasm-phone-lab/acceptance-oneplus6.json @@ -0,0 +1,44 @@ +{ + "schema": 1, + "platform": "android", + "model": "ONEPLUS A6003", + "android": "15", + "package": "dev.makepad.octosense.hostapilab.wasm1", + "apk_sha256": "ffdadbfbc2b268c6007856dcc7f2a8b5e971f63076738a6d6026a1abecaebc5a", + "personal_accounts_used": false, + "model_started": false, + "source_revision": "e67ce63ebca6054961899691e1644a54c2b4f081", + "runtime_tree": "0fc8e29e2411fd7eb94d845ba16a261d2ebc9dfc", + "wasm_service_sha256": "8197e7ef731d69779fc1e002aa3d4318ee227869be5aa0ee9fa2ee5da58e565f", + "checks": { + "compiled_source_identity_matches": true, + "compiled_runtime_identity_matches": true, + "signed_native_android_install": true, + "real_script_tool_completed": true, + "bound_device_account": true, + "raw_host_envelopes_forwarded": true, + "successful_guest_call": true, + "success_does_not_retain_input": true, + "guest_error_received": true, + "guest_error_does_not_retain_input": true, + "guest_trap_received": true, + "fresh_instance_after_trap": true, + "guest_deadline_received": true, + "fresh_instance_after_deadline": true, + "functions_answered": true, + "fresh_instance_policy_reported": true, + "eight_distinct_guest_invocations": true, + "no_native_approval_sheet": true, + "cross_account_refused": true, + "undeclared_tool_refused": true, + "invalid_arguments_refused": true, + "closed_tool_endpoint_refused": true + }, + "passed": true, + "not_verified": [ + "live model or peer relay", + "performance benchmark", + "release APK" + ], + "owned_package_stopped": true +} diff --git a/tools/fixtures/wasm-phone-lab/bundle/AGENT.md b/tools/fixtures/wasm-phone-lab/bundle/AGENT.md new file mode 100644 index 00000000..4544dbd9 --- /dev/null +++ b/tools/fixtures/wasm-phone-lab/bundle/AGENT.md @@ -0,0 +1 @@ +This is a local synthetic Wasm containment test. Never request personal data or external services. No live model is started. diff --git a/tools/fixtures/wasm-phone-lab/bundle/assets/icon.svg b/tools/fixtures/wasm-phone-lab/bundle/assets/icon.svg new file mode 100644 index 00000000..605a5588 --- /dev/null +++ b/tools/fixtures/wasm-phone-lab/bundle/assets/icon.svg @@ -0,0 +1 @@ + diff --git a/tools/fixtures/wasm-phone-lab/bundle/fns/state.wasm b/tools/fixtures/wasm-phone-lab/bundle/fns/state.wasm new file mode 100644 index 00000000..a62efdcc Binary files /dev/null and b/tools/fixtures/wasm-phone-lab/bundle/fns/state.wasm differ diff --git a/tools/fixtures/wasm-phone-lab/bundle/listing.json b/tools/fixtures/wasm-phone-lab/bundle/listing.json new file mode 100644 index 00000000..2f36e107 --- /dev/null +++ b/tools/fixtures/wasm-phone-lab/bundle/listing.json @@ -0,0 +1,25 @@ +{ + "schema": 1, + "subtitle": "Wasm isolation acceptance fixture", + "description": "Synthetic signed fixture exercising fresh Wasm invocation state, guest errors, traps and deadlines. No accounts, model or external communication.", + "category": "utilities", + "keywords": [ + "developer", + "host-api" + ], + "screenshots": [ + "screenshots/01-main.png" + ], + "icon": "assets/icon.svg", + "platforms": [ + "android" + ], + "publisher": { + "name": "Local acceptance fixture", + "support": "https://github.com/OctoSense-org/OctoSense/issues", + "privacy_policy_url": "https://github.com/OctoSense-org/OctoSense" + }, + "release_notes": "Local validation only; ephemeral test signing keys.", + "age_rating": "all", + "license": "Apache-2.0" +} diff --git a/tools/fixtures/wasm-phone-lab/bundle/main.splash b/tools/fixtures/wasm-phone-lab/bundle/main.splash new file mode 100644 index 00000000..a9fcdba1 --- /dev/null +++ b/tools/fixtures/wasm-phone-lab/bundle/main.splash @@ -0,0 +1,36 @@ +fn app_tool(name, call_id) { + let request = mod.app_tools.request(call_id) + let results = {} + host.request("wasm.remember", "synthetic earlier input", fn(reply_0) { + results.remember = reply_0 + host.request("wasm.recall", {}, fn(reply_1) { + results.after_success = reply_1 + host.request("wasm.remember_fail", "synthetic failed input", fn(reply_2) { + results.guest_error = reply_2 + host.request("wasm.recall", {}, fn(reply_3) { + results.after_guest_error = reply_3 + host.request("wasm.trap", {}, fn(reply_4) { + results.trap = reply_4 + host.request("wasm.recall", {}, fn(reply_5) { + results.after_trap = reply_5 + host.request("wasm.spin", {}, fn(reply_6) { + results.deadline = reply_6 + host.request("wasm.recall", {}, fn(reply_7) { + results.after_deadline = reply_7 + host.request("wasm.functions", {}, fn(reply_8) { + ui.status.set_text("Wasm checks completed") + mod.app_tools.complete(call_id, {account: request.context.account results: results functions: reply_8}) + }) + }) + }) + }) + }) + }) + }) + }) + }) +} +SolidView {width: Fill height: Fill flow: Down padding: 28 spacing: 20 draw_bg.color: #xf5f7fa + Label {text: "Wasm isolation acceptance" draw_text.color: #x17243b draw_text.text_style.font_size: 26} + status := Label {text: "Running synthetic signed app tool" draw_text.color: #x48566b} +} diff --git a/tools/fixtures/wasm-phone-lab/bundle/manifest.json b/tools/fixtures/wasm-phone-lab/bundle/manifest.json new file mode 100644 index 00000000..821ac636 --- /dev/null +++ b/tools/fixtures/wasm-phone-lab/bundle/manifest.json @@ -0,0 +1,33 @@ +{ + "agent": { + "background": false, + "instructions": "AGENT.md", + "model": { + "local_only": false, + "needs": [ + "tool_calling" + ] + }, + "profile": "read-only", + "tools": [] + }, + "capabilities": [ + "wasm" + ], + "host_api": { + "required": { + "app_tools.dispatch": 1 + } + }, + "id": "org.octosense.samples.wasmprobe", + "integrity": { + "bundle_blake3": "162037c17ea778ddfd413c212062d07ab85d209eca75344bea361ff5f3d76fa7" + }, + "name": "Wasm isolation probe", + "requires": [ + "host-api-v1", + "script-tools-v1" + ], + "schema": 1, + "version": "0.1.0" +} diff --git a/tools/fixtures/wasm-phone-lab/bundle/screenshots/01-main.png b/tools/fixtures/wasm-phone-lab/bundle/screenshots/01-main.png new file mode 100644 index 00000000..012dd235 Binary files /dev/null and b/tools/fixtures/wasm-phone-lab/bundle/screenshots/01-main.png differ diff --git a/tools/fixtures/wasm-phone-lab/bundle/tools.json b/tools/fixtures/wasm-phone-lab/bundle/tools.json new file mode 100644 index 00000000..099dd6aa --- /dev/null +++ b/tools/fixtures/wasm-phone-lab/bundle/tools.json @@ -0,0 +1,39 @@ +{ + "schema": 1, + "tools": [ + { + "name": "wasmprobe.inspect", + "description": "Run synthetic Wasm isolation and containment checks.", + "implemented_by": "app", + "risk": "read", + "background": false, + "shareable": false, + "private_data": true, + "input_schema": { + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "output_schema": { + "type": "object", + "required": [ + "account", + "results", + "functions" + ], + "properties": { + "account": { + "type": "string" + }, + "results": { + "type": "object" + }, + "functions": { + "type": "object" + } + }, + "additionalProperties": false + } + } + ] +} diff --git a/tools/fixtures/wasm-phone-lab/state.wat b/tools/fixtures/wasm-phone-lab/state.wat new file mode 100644 index 00000000..ff722671 --- /dev/null +++ b/tools/fixtures/wasm-phone-lab/state.wat @@ -0,0 +1,17 @@ +(module + (memory (export "memory") 1) + (global $size (mut i32) (i32.const 0)) + (data (i32.const 8) "\01failed") + (func (export "octo_alloc") (param i32) (result i32) (i32.const 4096)) + (func (export "octo_free") (param i32 i32)) + (func $remember (param $p i32) (param $n i32) + (global.set $size (local.get $n)) + (memory.copy (i32.const 65) (local.get $p) (local.get $n))) + (func (export "remember") (param i32 i32) (result i64) + (call $remember (local.get 0) (local.get 1)) (i64.const 1)) + (func (export "remember_fail") (param i32 i32) (result i64) + (call $remember (local.get 0) (local.get 1)) (i64.const 34359738375)) + (func (export "recall") (param i32 i32) (result i64) + (i64.or (i64.const 274877906944) (i64.extend_i32_u (i32.add (global.get $size) (i32.const 1))))) + (func (export "trap") (param i32 i32) (result i64) unreachable) + (func (export "spin") (param i32 i32) (result i64) (loop $again (br $again)) (i64.const 1))) \ No newline at end of file diff --git a/tools/release-scan-public-assets.json b/tools/release-scan-public-assets.json new file mode 100644 index 00000000..45e84ca3 --- /dev/null +++ b/tools/release-scan-public-assets.json @@ -0,0 +1,886 @@ +{ + "repository": "https://github.com/octos-org/octoscode-app", + "revision": "5d0c2a0962218aa94497201ce1a39b7429f09e36", + "reason": "Synthetic UI example data embedded unchanged by crates/octoscode-module/build.rs; only the listed base-pattern spans in complete SHA-256-matching assets are admitted.", + "assets": [ + { + "path": "design/components/ws-group/page.card", + "size": 2616, + "sha256": "39607a6c88904ed7d22cb6a1364242608473a0c53af4e21b03a6da9854ce7ead", + "matches": [ + [ + 349, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 482, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 611, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ] + ] + }, + { + "path": "design/components/ws-group/page.design.splash", + "size": 2781, + "sha256": "43ea6e4dc89f927394f2f95a7c7ead497b5d6f1b5f967c02d7646e6a8cf1db24", + "matches": [ + [ + 1147, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 1829, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 2520, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ] + ] + }, + { + "path": "design/stage-b/phase4-new3/cards/phase4n3-02/contract.json", + "size": 17603, + "sha256": "3199436345d91dca6f653ae7de0d823de49ab5e35e0efe0c0f6d3fd419fc9d4c", + "matches": [ + [ + 3832, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 5006, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 7542, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 10734, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4-new3/cards/phase4n3-02/mapped.json", + "size": 12959, + "sha256": "10fb4d8df9f2676d646d1c4b6470006ec43f5a7e84b811fc3ce81ed68b5de263", + "matches": [ + [ + 2289, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 3208, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 5155, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 7606, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4-new3/cards/phase4n3-02/mapping.json", + "size": 17851, + "sha256": "93b6e4cd783dd0ff3ffaa51ed3f0a5afc5705a6d9705124d2cf9d4aadc2944fe", + "matches": [ + [ + 3696, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 4900, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 7231, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 10335, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4-new3/cards/phase4n3-02/observations.json", + "size": 15777, + "sha256": "0d001dfe3ecc4eab3d8c016d1ca4a113a2a615dbbb7347f4fcdd1f0b832e6213", + "matches": [ + [ + 1811, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 1963, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 2714, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 4442, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 4594, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 6376, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 15145, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4-new3/cards/phase4n3-02/page.card", + "size": 7719, + "sha256": "4660042e49774355c84ae46c56c98e952914d52c1f6a49c6909fd3d40fabc409", + "matches": [ + [ + 356, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 516, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 766, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 1065, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4-new3/cards/phase4n3-02/page.design.splash", + "size": 6433, + "sha256": "e85a357463209b73e534a53a93ae49782eec0cd19c269ada716a09c8ea5619c1", + "matches": [ + [ + 1099, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 1610, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 2638, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 3921, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4-new3/cards/phase4n3-02/reference.ocr.json", + "size": 3826, + "sha256": "bf3cc2671ca98616c7686e81266fb48bed3ce752767692c375300ebad55587c8", + "matches": [ + [ + 1013, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 1801, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 2787, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4-new3/cards/phase4n3-03/contract.json", + "size": 15061, + "sha256": "8f962eb158cf6528c384bed4d9704c52e8d92df1835bce3671df4131fe55c1e1", + "matches": [ + [ + 7588, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4-new3/cards/phase4n3-03/mapped.json", + "size": 11026, + "sha256": "db57f31239f5fba355bf53e714fd98c54208a63dee48ea35ba1c4334c921662e", + "matches": [ + [ + 5041, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4-new3/cards/phase4n3-03/mapping.json", + "size": 15538, + "sha256": "fe9946c8af443206e1a56e69fb9748d8aa785bf2fce3095526fca089b2783974", + "matches": [ + [ + 7891, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4-new3/cards/phase4n3-03/observations.json", + "size": 13693, + "sha256": "ade896ad2737365cf64916be0b4d312e0c7502ac36170d1772f7012533f59760", + "matches": [ + [ + 3496, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 3648, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4-new3/cards/phase4n3-03/page.card", + "size": 6837, + "sha256": "130e2c215a8a18f21b0641eaf5b6ca483e8c6a99cd384f674dad51977c88ff3f", + "matches": [ + [ + 576, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4-new3/cards/phase4n3-03/page.design.splash", + "size": 5440, + "sha256": "d6b032c5769139ae13fbcf1a79b5eb26d560f16efd84001ec0ec14f9ddffe4c9", + "matches": [ + [ + 2374, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4-new3/cards/phase4n3-03/reference.ocr.json", + "size": 3713, + "sha256": "5b56ebd8cd7fcf4d507eed14ec4c0cbf356b083dec755350d2b41946249268b1", + "matches": [ + [ + 616, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4-new3/cards/phase4n3-05/contract.json", + "size": 20573, + "sha256": "05586acd34df821cd2247d5aee57a0033b2fac6bb410b5064ba94e234764cff1", + "matches": [ + [ + 2557, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4-new3/cards/phase4n3-05/mapping.json", + "size": 18784, + "sha256": "16d936ab4e42a9be133c667770eeb1165800d4747c213bc9bd446535f4fef453", + "matches": [ + [ + 2165, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4-new3/cards/phase4n3-05/observations.json", + "size": 20072, + "sha256": "beb95bc0dd3ad4435c3b8fe381fdffe6094ebd741f60d6168764a3620b6bd185", + "matches": [ + [ + 1542, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ], + [ + 1706, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4-new3/cards/phase4n3-05/page.card", + "size": 8083, + "sha256": "739b2396d2f8d73a43b5882e9cd75a2a7682edca2797d0ba308765a072c8eafd", + "matches": [ + [ + 275, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4-new3/cards/phase4n3-05/page.design.splash", + "size": 7430, + "sha256": "213a49da2cb8664b868827d537154ad13e95b04922e568202c16fedd548503e2", + "matches": [ + [ + 660, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4-new3/cards/phase4n3-05/reference.ocr.json", + "size": 4808, + "sha256": "1a247ed39ac0ebd2f919a7512462d7ab2b3f902d284259e766c6f5ecb4f4a3f4", + "matches": [ + [ + 608, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4/cards/p4-03/contract.json", + "size": 6285, + "sha256": "44a306a3d05621885db632fccd9593fa8fa52ebe95bf59e85a05e42e697c6222", + "matches": [ + [ + 3076, + "private IPv4 address", + "552354592821b7e6b76f87d10c0cb4ff512ffed049dde0e40769a0bcf9f50c8d" + ] + ] + }, + { + "path": "design/stage-b/phase4/cards/p4-03/mapped.json", + "size": 9164, + "sha256": "49afb14ee34af8ae83517831f0709dd2bd0039816ac316d242c09a845c10d413", + "matches": [ + [ + 3287, + "private IPv4 address", + "552354592821b7e6b76f87d10c0cb4ff512ffed049dde0e40769a0bcf9f50c8d" + ] + ] + }, + { + "path": "design/stage-b/phase4/cards/p4-03/mapping.json", + "size": 7366, + "sha256": "d3722dc041edd293af1a0b2d5f00beb22ee137aa6bc4a491cb78b020498c6731", + "matches": [ + [ + 3583, + "private IPv4 address", + "552354592821b7e6b76f87d10c0cb4ff512ffed049dde0e40769a0bcf9f50c8d" + ] + ] + }, + { + "path": "design/stage-b/phase4/cards/p4-03/observations.json", + "size": 6869, + "sha256": "a8075ee54ae49a2ec6bfdd6bff32314f2aa803bcd2236c8e8904997f8d8142af", + "matches": [ + [ + 6669, + "private IPv4 address", + "552354592821b7e6b76f87d10c0cb4ff512ffed049dde0e40769a0bcf9f50c8d" + ] + ] + }, + { + "path": "design/stage-b/phase4/cards/p4-03/page.card", + "size": 4456, + "sha256": "da5aca2cd16d05388a62d8c13d62e1a69aca4897351e4ddf88089c5dcc370310", + "matches": [ + [ + 447, + "private IPv4 address", + "552354592821b7e6b76f87d10c0cb4ff512ffed049dde0e40769a0bcf9f50c8d" + ] + ] + }, + { + "path": "design/stage-b/phase4/cards/p4-03/page.design.splash", + "size": 3428, + "sha256": "c975010bb55747e2c0db1169621ca30b3c7ed72403389b0afb98ee227e45f5e0", + "matches": [ + [ + 1736, + "private IPv4 address", + "552354592821b7e6b76f87d10c0cb4ff512ffed049dde0e40769a0bcf9f50c8d" + ] + ] + }, + { + "path": "design/stage-b/phase4/cards/p4-03/reference.ocr.json", + "size": 1555, + "sha256": "ad239e092161c59b2f38dbe0d2131e5809d5df4bc3cabac4cfcd337b4d58f720", + "matches": [ + [ + 1038, + "private IPv4 address", + "552354592821b7e6b76f87d10c0cb4ff512ffed049dde0e40769a0bcf9f50c8d" + ] + ] + }, + { + "path": "design/stage-b/phase4/cards/p4-05/contract.json", + "size": 5361, + "sha256": "c77b9ed20ba97f7a8c5488363b359c8f9d9a0b6337ff301505d12495df0cd9cc", + "matches": [ + [ + 2571, + "private IPv4 address", + "552354592821b7e6b76f87d10c0cb4ff512ffed049dde0e40769a0bcf9f50c8d" + ] + ] + }, + { + "path": "design/stage-b/phase4/cards/p4-05/mapped.json", + "size": 8345, + "sha256": "8a482af17c38d43717db3396b047d17a9e05b0b82b6226acfc78b66fe37bf51a", + "matches": [ + [ + 2475, + "private IPv4 address", + "552354592821b7e6b76f87d10c0cb4ff512ffed049dde0e40769a0bcf9f50c8d" + ] + ] + }, + { + "path": "design/stage-b/phase4/cards/p4-05/mapping.json", + "size": 6852, + "sha256": "23d07c3c93c5546f8bd6a5c09939423334ad21d184d23fd62229d568594985df", + "matches": [ + [ + 3373, + "private IPv4 address", + "552354592821b7e6b76f87d10c0cb4ff512ffed049dde0e40769a0bcf9f50c8d" + ] + ] + }, + { + "path": "design/stage-b/phase4/cards/p4-05/observations.json", + "size": 6839, + "sha256": "5529a8ae98208a0d64c7085331d78a924f87dcaa6d3e11e0a59ea740a0de1b22", + "matches": [ + [ + 1397, + "private IPv4 address", + "552354592821b7e6b76f87d10c0cb4ff512ffed049dde0e40769a0bcf9f50c8d" + ], + [ + 1563, + "private IPv4 address", + "552354592821b7e6b76f87d10c0cb4ff512ffed049dde0e40769a0bcf9f50c8d" + ] + ] + }, + { + "path": "design/stage-b/phase4/cards/p4-05/page.card", + "size": 3565, + "sha256": "d219f933522ec67da181063c5d2ec83f8d2c6e4ad9a8c6c8e3fd02d1a34adac7", + "matches": [ + [ + 272, + "private IPv4 address", + "552354592821b7e6b76f87d10c0cb4ff512ffed049dde0e40769a0bcf9f50c8d" + ] + ] + }, + { + "path": "design/stage-b/phase4/cards/p4-05/page.design.splash", + "size": 3236, + "sha256": "2e46f580bf7af726f07550ab2c5e1d1d86461856878d8534bc068c62d101a50d", + "matches": [ + [ + 1269, + "private IPv4 address", + "552354592821b7e6b76f87d10c0cb4ff512ffed049dde0e40769a0bcf9f50c8d" + ] + ] + }, + { + "path": "design/stage-b/phase4/cards/p4-05/reference.ocr.json", + "size": 1683, + "sha256": "53e83db51c4ba80d88d9f473cbb315b7cd4f767e02f396708d7afb7a577fc9e7", + "matches": [ + [ + 831, + "private IPv4 address", + "552354592821b7e6b76f87d10c0cb4ff512ffed049dde0e40769a0bcf9f50c8d" + ] + ] + }, + { + "path": "design/stage-b/phase4/cards/p4-08/contract.json", + "size": 7972, + "sha256": "11999891ee368e1bb7e6c08f6224b15587f2c7dd49a341be86204add7ddb79cf", + "matches": [ + [ + 6205, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4/cards/p4-08/mapped.json", + "size": 12150, + "sha256": "87a42d5730a0fa09e7cdc46df6d8fb3e08531966b8a50e19dcba087e16f44635", + "matches": [ + [ + 6848, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4/cards/p4-08/mapping.json", + "size": 11016, + "sha256": "79256ffb82772e2cf4631dde93c64206c2ba9d80b8b0ff6c28e966ac2a58ac0a", + "matches": [ + [ + 8831, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4/cards/p4-08/observations.json", + "size": 9862, + "sha256": "95eb3557b86aa24c1a3f5f3feb0ea0e11d2b00ed6c504681c5aeaad97bf45f76", + "matches": [ + [ + 9651, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4/cards/p4-08/page.card", + "size": 5594, + "sha256": "675bf7a590f115108df2ef010a199e25d2e89d9472c954d0495d05a4585f47d4", + "matches": [ + [ + 598, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4/cards/p4-08/page.design.splash", + "size": 4648, + "sha256": "483a6c27555fc509b4fbdc5b1ecbf84698f60596885497d3914ebdcc7d563957", + "matches": [ + [ + 3719, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/phase4/cards/p4-08/reference.ocr.json", + "size": 1886, + "sha256": "9dc7d7ef00faa120c3741b3f7916a3e23bebc03f460c7d4ad50632765d44a136", + "matches": [ + [ + 1519, + "Linux home directory", + "768d8e132161678a3434401db0b60a6497856a93891b95d7852d3b79f6fae087" + ] + ] + }, + { + "path": "design/stage-b/setup/cards/setup-04/contract.json", + "size": 12227, + "sha256": "b4196d341a4b2bb73406280d25d36b91cfe16eb9315e02494ec2a7de18d233e2", + "matches": [ + [ + 6277, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 7879, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 9479, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ] + ] + }, + { + "path": "design/stage-b/setup/cards/setup-04/mapped.json", + "size": 16006, + "sha256": "3a2e751c38a509645f0e4b224237fe38d33031e3dc17836ae10417c7011a1e8c", + "matches": [ + [ + 5852, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 7454, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 9055, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ] + ] + }, + { + "path": "design/stage-b/setup/cards/setup-04/mapping.json", + "size": 12224, + "sha256": "ae48dd4bd3363f95ae49d027c85fe2c055f691f389684f11382dc91b86303d76", + "matches": [ + [ + 6515, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 8097, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 9680, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ] + ] + }, + { + "path": "design/stage-b/setup/cards/setup-04/observations.json", + "size": 12690, + "sha256": "6cca71508bd0b1974c657627fb820546dbafd0b89d2872ba701b58cc1cc1278c", + "matches": [ + [ + 3916, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 4087, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 5161, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 5327, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 6395, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 6563, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ] + ] + }, + { + "path": "design/stage-b/setup/cards/setup-04/page.card", + "size": 6300, + "sha256": "08ce2175447c7605a3874a03dc8e69d40841e661e51b60b90b2309a8bd050609", + "matches": [ + [ + 651, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 784, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 913, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ] + ] + }, + { + "path": "design/stage-b/setup/cards/setup-04/page.design.splash", + "size": 4899, + "sha256": "3d33cba900cc78da0cc69f8e04c2f975c3a1ad202d102f45c9be333fe8cf5704", + "matches": [ + [ + 2441, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 3096, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 3750, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ] + ] + }, + { + "path": "design/stage-b/setup/cards/setup-04/reference.ocr.json", + "size": 3049, + "sha256": "148d645e5e1ec2f473b40bb49ccb08c12d987252d90b9c95169ef6396c04b6b7", + "matches": [ + [ + 1623, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 2031, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 2439, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ] + ] + }, + { + "path": "design/stage-b/setup/pipeline-output/service-cards/setup/ws-group/page.card", + "size": 2616, + "sha256": "39607a6c88904ed7d22cb6a1364242608473a0c53af4e21b03a6da9854ce7ead", + "matches": [ + [ + 349, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 482, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 611, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ] + ] + }, + { + "path": "design/stage-b/setup/pipeline-output/service-cards/setup/ws-group/page.design.splash", + "size": 2781, + "sha256": "43ea6e4dc89f927394f2f95a7c7ead497b5d6f1b5f967c02d7646e6a8cf1db24", + "matches": [ + [ + 1147, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 1829, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ], + [ + 2520, + "Linux home directory", + "097c4e083d4cc3cc6a34c1d819c63e1a758fce67f364b5fcd3be8b9418ec643f" + ] + ] + } + ] +} diff --git a/tools/release-scan.py b/tools/release-scan.py index 0d2f0774..661fd812 100644 --- a/tools/release-scan.py +++ b/tools/release-scan.py @@ -6,15 +6,16 @@ Every file is read as bytes, including the files inside the packages a release ships: `.app` bundles and directories, `.dmg` images (mounted with -hdiutil on macOS), `.deb` (ar + tar), `.AppImage` (`--appimage-extract` on -Linux), `.zip`, `.tar.*`, and NSIS installers (7-Zip, when installed; the +hdiutil on macOS), `.deb` (ar + tar), `.AppImage` (trusted `unsquashfs`, +from squashfs-tools), `.zip`, `.tar.*`, and NSIS installers (7-Zip, when installed; the installed files themselves are scanned before packaging on Windows, since the release workflow scans the staged payload too). In a directory, a symbolic link's target is read too. An unreadable container fails the scan rather than being skipped. -An AppImage's own bytes are read except for its squashfs filesystem, which -is read through its extracted files and links instead. That filesystem is +An AppImage is never executed, including to find its payload: its type-2 +ELF section table determines the offset. Its own bytes are read except for +its squashfs filesystem, which is read through its extracted files and links instead. That filesystem is zstd-compressed, and zstd keeps a block's literal bytes back to back, so the raw bytes hold text that no file has (in octosense 0.1.0's AppImage, a `/home/` path from a literal run followed by the start of the next run). @@ -38,11 +39,15 @@ 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 seams below and individual synthetic design-example spans +inside complete assets whose SHA-256 matches release-scan-public-assets.json. """ import argparse import getpass +import hashlib import io +import json import os from pathlib import Path import re @@ -53,6 +58,7 @@ import tarfile import tempfile import zipfile +from functools import lru_cache GENERIC_ACCOUNTS = {"runner", "runneradmin", "root", "admin", "administrator", "user", "builder", "build", "vagrant", "ubuntu", "ec2-user", "github", "ci", "localhost"} @@ -74,6 +80,39 @@ "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" +) + +# 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)" +) + +# Public OctosCode 5d0c2a0 UI examples pooled by the macOS linker. chrome.rs +# seed_board2() supplies the two demo workspace paths (2936/2939), next to +# its theme names (2568-2577). screens/browser.rs supplies the path field +# placeholder (733/780), button text and ID (736-738). Only the /home/user/ +# match spans inside these complete proven sequences qualify, never those +# paths standing alone or a different field's private value. +OCTOSCODE_PLACEHOLDER_SEAMS = ( + re.compile(rb"(?P/home/user/)src/octoscode-app" + rb"(?P/home/user/)src/octosSystemSolarizedSlateClaudeCodexLight"), + re.compile(rb"(?P/home/user/)codeUse this folderb1_br_use"), +) + BASE_PATTERNS = [ ("macOS user directory", rb"/Users/[^/\s\x00\"']+"), ("Windows user directory", rb"[A-Za-z]:[\\/]{1,2}Users[\\/]{1,2}(?!runneradmin[\\/])[^\\/\s\x00\"']+"), @@ -122,10 +161,70 @@ def mask(match): return text if len(text) <= 4 else text[:3] + "*" * min(len(text) - 3, 12) + f" ({len(text)} chars)" +@lru_cache(maxsize=1) +def public_asset_matches(): + """Reviewed public examples, indexed by rule and matched bytes' digest. + + This is deliberately not a path/address allowlist. The whole containing + asset must match, and only the recorded offsets for a base rule qualify. + Identity and --extra rules never consult this table. Missing or malformed + metadata is an error, not permission to skip scanning. + """ + manifest = json.loads(Path(__file__).with_name("release-scan-public-assets.json").read_text()) + index = {} + for asset in manifest["assets"]: + size, digest = asset["size"], asset["sha256"] + if not isinstance(size, int) or not 0 < size <= 1_000_000 or not re.fullmatch(r"[0-9a-f]{64}", digest): + raise ValueError("invalid reviewed public asset") + for offset, label, match_digest in asset["matches"]: + if (not isinstance(offset, int) or not 0 <= offset < size + or label not in ("Linux home directory", "private IPv4 address") + or not re.fullmatch(r"[0-9a-f]{64}", match_digest)): + raise ValueError("invalid reviewed public asset match") + index.setdefault((label, match_digest), []).append((offset, size, digest)) + return index + + +def is_public_asset_match(data, label, match, verified): + if label not in ("Linux home directory", "private IPv4 address"): + return False + key = (label, hashlib.sha256(match.group(0)).hexdigest()) + for offset, size, digest in public_asset_matches().get(key, ()): + start = match.start() - offset + end = start + size + if start < 0 or end > len(data) or match.end() > end: + continue + candidate = (start, end, digest) + if candidate not in verified: + verified[candidate] = hashlib.sha256(memoryview(data)[start:end]).hexdigest() == digest + if verified[candidate]: + return True + return False + + def scan_bytes(data, where, patterns, findings): + verified_assets = {} for label, regex in patterns: + source_seams = set() + if label == "Linux home directory": + source_seams = { + (seam.start("next") - len(b"/home/main_desktop_ui.rs"), seam.start("next") + 1) + for seam in RINX_SOURCE_SEAM.finditer(data) + } + source_seams.update( + seam.span(group) + for pattern in OCTOSCODE_PLACEHOLDER_SEAMS + for seam in pattern.finditer(data) + for group in seam.groupdict() + ) + elif label == "mDNS .local host name": + source_seams = {seam.span("host") for seam in MAIL_LITERAL_SEAM.finditer(data)} seen = set() for m in regex.finditer(data): + if (m.start(), m.end()) in source_seams: + continue + if is_public_asset_match(data, label, m, verified_assets): + continue if m.group(0) in seen: continue seen.add(m.group(0)) @@ -169,31 +268,73 @@ def ar_members(data): pos += 60 + size + (size % 2) +def appimage_filesystem(data): + """Return the bounded type-2 SquashFS span without running the artifact. + + libappimage's ElfFile::getSize uses the later of the section-table end + and the last section's end. Read both ELF classes and byte orders, and + refuse unsupported/invalid layouts instead of guessing an offset. + https://github.com/AppImageCommunity/libappimage/blob/master/src/libappimage/utils/ElfFile.cpp + """ + if len(data) < 16 or data[:4] != b"\x7fELF" or data[8:11] != b"AI\x02": + raise ValueError("not a type-2 ELF AppImage") + if data[4] not in (1, 2) or data[5] not in (1, 2) or data[6] != 1: + raise ValueError("unsupported AppImage ELF class, byte order or version") + order = "little" if data[5] == 1 else "big" + header, shoff_pos, word, ehsize_pos, entsize_pos, count_pos, section_size, section_pos = ( + (52, 32, 4, 40, 46, 48, 40, 16) if data[4] == 1 else + (64, 40, 8, 52, 58, 60, 64, 24)) + + def integer(pos, size): + if pos < 0 or pos + size > len(data): + raise ValueError("truncated AppImage ELF header") + return int.from_bytes(data[pos:pos + size], order) + + if len(data) < header or integer(ehsize_pos, 2) != header: + raise ValueError("invalid AppImage ELF header size") + shoff, entsize, count = integer(shoff_pos, word), integer(entsize_pos, 2), integer(count_pos, 2) + if shoff < header or entsize != section_size or not count: + raise ValueError("unsupported AppImage ELF section table") + table_end = shoff + entsize * count + if table_end > len(data): + raise ValueError("truncated AppImage ELF section table") + last = table_end - entsize + section_end = integer(last + section_pos, word) + integer(last + section_pos + word, word) + offset = max(table_end, section_end) + superblock = data[offset:offset + 96] + if len(superblock) != 96 or superblock[:4] != b"hsqs": + raise ValueError("no squashfs filesystem at the AppImage ELF boundary") + if superblock[28:32] != b"\x04\x00\x00\x00": + raise ValueError("unsupported AppImage squashfs version") + used = int.from_bytes(superblock[40:48], "little") + end = offset + used + if used < 96 or end > len(data): + raise ValueError("truncated or invalid AppImage squashfs size") + return offset, end + + def scan_appimage(path, data, patterns, findings): - """An AppImage: the runtime before its squashfs, and anything after it, - as bytes; the squashfs through `--appimage-extract` (see the module - docstring). Only a machine that can run it can extract it (Linux).""" + """Scan the runtime, trusted-tool-extracted filesystem and trailing bytes. + Neither the input runtime nor any extracted program is executed.""" name = path.name + offset, end = appimage_filesystem(data) + extractor = shutil.which("unsquashfs") + if not extractor: + raise RuntimeError(f"{name}: install squashfs-tools to scan AppImages (unsquashfs missing)") + scan_bytes(data[:offset], f"{name} (runtime)", patterns, findings) + scan_bytes(data[end:], f"{name} (after its filesystem)", patterns, findings) with tempfile.TemporaryDirectory() as temp: - copy = Path(temp) / name - shutil.copy2(path, copy) - copy.chmod(0o755) - - def run(option): - try: - return subprocess.run([str(copy), option], cwd=temp, check=True, capture_output=True).stdout - except OSError as e: # not an executable format this machine runs - raise RuntimeError(f"{name}: an AppImage can only be extracted on Linux ({e})") from e - - offset = int(run("--appimage-offset")) - superblock = data[offset:offset + 48] - if len(superblock) < 48 or superblock[:4] != b"hsqs": - raise RuntimeError(f"{name}: no squashfs filesystem at offset {offset}") - end = offset + int.from_bytes(superblock[40:48], "little") # the superblock's bytes_used - scan_bytes(data[:offset], f"{name} (runtime)", patterns, findings) - scan_bytes(data[end:], f"{name} (after its filesystem)", patterns, findings) - run("--appimage-extract") - return 1 + scan_tree(Path(temp) / "squashfs-root", name, patterns, findings) + # Extract precisely the bytes checked above, even if the caller's + # original path is replaced meanwhile. The copy is never executable. + copy = Path(temp) / "input.AppImage" + copy.write_bytes(data) + destination = Path(temp) / "squashfs-root" + subprocess.run([extractor, "-strict-errors", "-no-progress", "-no-xattrs", + "-processors", "2", "-offset", str(offset), "-dest", str(destination), str(copy)], + check=True, capture_output=True, env={"PATH": os.defpath, "LC_ALL": "C"}) + if not destination.is_dir() or destination.is_symlink(): + raise RuntimeError(f"{name}: unsquashfs did not produce a filesystem directory") + return 1 + scan_tree(destination, name, patterns, findings) def scan_artifact(path, patterns, findings, dmg_bytes_only=False): diff --git a/tools/runtime-patches/makepad-desktop-webview.patch b/tools/runtime-patches/makepad-desktop-webview.patch new file mode 100644 index 00000000..f1bdbcfc --- /dev/null +++ b/tools/runtime-patches/makepad-desktop-webview.patch @@ -0,0 +1,2330 @@ +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/event/event.rs b/platform/src/event/event.rs +index db8830e66..ac73ebfba 100644 +--- a/platform/src/event/event.rs ++++ b/platform/src/event/event.rs +@@ -591,6 +591,14 @@ pub struct NativeSystemBrowserPageError { + pub url: String, + } + ++/// A navigation or browser operation was refused without failing the loaded page. ++/// In particular, a blocked subframe must not hide its permitted parent reader. ++#[derive(Clone, Debug, Default)] ++pub struct NativeSystemBrowserPolicyBlocked { ++ pub browser_id: u64, ++ pub description: String, ++} ++ + /// Result of a native file-picker dialog, carrying the `call_id` its + /// `CxOsOp::OpenFileDialog` was issued with. + #[derive(Clone, Debug, Default)] +diff --git a/platform/src/lib.rs b/platform/src/lib.rs +index 6e041dd51..ce292b230 100644 +--- a/platform/src/lib.rs ++++ 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..3a2190f6a +--- /dev/null ++++ b/platform/src/os/linux/linux_webkit.rs +@@ -0,0 +1,524 @@ ++//! Optional WebKitGTK browser, embedded by XEmbed in an existing X11 window. ++//! ++//! Only system libraries are loaded; ordinary Makepad builds require no GTK or ++//! 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, NativeSystemBrowserPolicyBlocked}, ++ 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_network_error_quark: unsafe extern "C" fn() -> u32, ++ webkit_policy_error_quark: unsafe extern "C" fn() -> u32, ++ webkit_web_context_set_tls_errors_policy: unsafe extern "C" fn(Object, c_int), ++ webkit_web_view_new_with_context: unsafe extern "C" fn(Object) -> Object, ++ webkit_web_view_get_settings: unsafe extern "C" fn(Object) -> Object, ++ webkit_settings_set_javascript_can_open_windows_automatically: unsafe extern "C" fn(Object, c_int), ++ webkit_settings_set_enable_developer_extras: unsafe extern "C" fn(Object, c_int), ++ webkit_web_view_set_zoom_level: unsafe extern "C" fn(Object, f64), ++ webkit_web_view_load_uri: unsafe extern "C" fn(Object, *const c_char), ++ webkit_web_view_load_html: unsafe extern "C" fn(Object, *const c_char, *const c_char), ++ webkit_web_view_stop_loading: unsafe extern "C" fn(Object), ++ webkit_web_view_get_uri: unsafe extern "C" fn(Object) -> *const c_char, ++ webkit_web_view_get_title: unsafe extern "C" fn(Object) -> *const c_char, ++ webkit_web_view_is_loading: unsafe extern "C" fn(Object) -> c_int, ++ webkit_web_view_run_javascript: unsafe extern "C" fn(Object, *const c_char, Object, Option, Object), ++ webkit_web_view_get_back_forward_list: unsafe extern "C" fn(Object) -> Object, ++ webkit_back_forward_list_get_nth_item: unsafe extern "C" fn(Object, c_int) -> Object, ++ webkit_web_view_go_to_back_forward_list_item: unsafe extern "C" fn(Object, Object), ++ webkit_navigation_policy_decision_get_request: unsafe extern "C" fn(Object) -> Object, ++ webkit_response_policy_decision_get_request: unsafe extern "C" fn(Object) -> Object, ++ webkit_response_policy_decision_is_main_frame_main_resource: unsafe extern "C" fn(Object) -> c_int, ++ webkit_response_policy_decision_is_mime_type_supported: unsafe extern "C" fn(Object) -> c_int, ++ webkit_uri_request_get_uri: unsafe extern "C" fn(Object) -> *const c_char, ++ webkit_policy_decision_use: unsafe extern "C" fn(Object), ++ webkit_policy_decision_ignore: unsafe extern "C" fn(Object), ++ webkit_permission_request_deny: unsafe extern "C" fn(Object), ++ webkit_file_chooser_request_cancel: unsafe extern "C" fn(Object), ++ webkit_download_cancel: unsafe extern "C" fn(Object), ++ webkit_authentication_request_cancel: unsafe extern "C" fn(Object), ++} ++thread_local! { ++ static API: RefCell, &'static str>>> = const { RefCell::new(None) }; ++} ++fn api() -> Result, &'static str> { ++ API.with(|slot| slot.borrow_mut().get_or_insert_with(Api::load).clone()) ++} ++ ++/// Run a bounded amount of GLib work. Never wait for events or a web process. ++pub(crate) fn pump() { ++ API.with(|slot| { ++ let api = match &*slot.borrow() { Some(Ok(api)) => api.clone(), _ => return }; ++ for _ in 0..16 { ++ if unsafe { (api.g_main_context_iteration)(ptr::null_mut(), 0) } == 0 { break; } ++ } ++ }); ++} ++ ++pub(crate) fn unavailable(browser_id: u64, auth: bool, description: &str) { ++ if auth { ++ Cx::post_action(NativeAuthBrowserEvent { browser_id, kind: "error".into(), url: String::new() }); ++ } else { ++ Cx::post_action(NativeSystemBrowserPageError { ++ browser_id, code: -1, description: description.into(), url: String::new(), ++ }); ++ } ++} ++ ++struct State { ++ api: Rc, ++ id: u64, ++ policy: RefCell, ++ navigable: bool, ++ finished: Cell, ++} ++impl State { ++ fn blocked(&self) { ++ if !self.finished.get() { ++ Cx::post_action(NativeSystemBrowserPolicyBlocked { ++ browser_id: self.id, ++ description: "The embedded browser blocked a disallowed navigation or operation".into(), ++ }); ++ } ++ } ++ fn error(&self, description: &str) { ++ if !self.finished.get() { unavailable(self.id, false, description); } ++ } ++ unsafe fn navigation(&self, view: Object) { ++ if self.finished.get() { return; } ++ Cx::post_action(NativeSystemBrowserNavigation { ++ browser_id: self.id, ++ url: string((self.api.webkit_web_view_get_uri)(view)), ++ title: string((self.api.webkit_web_view_get_title)(view)), ++ loading: (self.api.webkit_web_view_is_loading)(view) != 0, ++ error: None, ++ }); ++ } ++} ++unsafe fn string(value: *const c_char) -> String { ++ if value.is_null() { String::new() } else { CStr::from_ptr(value).to_string_lossy().into_owned() } ++} ++unsafe fn state<'a>(value: Object) -> &'a State { &*(value as *const State) } ++ ++unsafe extern "C" fn decide(_view: Object, decision: Object, kind: c_int, data: Object) -> c_int { ++ let s = state(data); ++ if s.finished.get() || kind == 1 { // Never create a popup/auxiliary browsing context. ++ (s.api.webkit_policy_decision_ignore)(decision); ++ s.blocked(); ++ return 1; ++ } ++ let (request, main) = match kind { ++ 0 => ((s.api.webkit_navigation_policy_decision_get_request)(decision), false), ++ 2 => ((s.api.webkit_response_policy_decision_get_request)(decision), ++ (s.api.webkit_response_policy_decision_is_main_frame_main_resource)(decision) != 0), ++ _ => { (s.api.webkit_policy_decision_ignore)(decision); return 1; } ++ }; ++ let url = string((s.api.webkit_uri_request_get_uri)(request)); ++ let policy = s.policy.borrow(); ++ let result = policy.decide(&url, main); ++ let supported = kind != 2 || (s.api.webkit_response_policy_decision_is_mime_type_supported)(decision) != 0; ++ match result { ++ NavigationDecision::Allow if supported => (s.api.webkit_policy_decision_use)(decision), ++ _ => { (s.api.webkit_policy_decision_ignore)(decision); s.blocked(); } ++ } ++ 1 ++} ++unsafe extern "C" fn load_changed(view: Object, _event: c_int, data: Object) { state(data).navigation(view); } ++unsafe extern "C" fn property_changed(view: Object, _property: Object, data: Object) { state(data).navigation(view); } ++unsafe extern "C" fn load_failed(_view: Object, _event: c_int, _uri: *const c_char, error: *const GError, data: Object) -> c_int { ++ let s = state(data); ++ // Intentional stops and policy cancellations do not fail a retained page. ++ if !error.is_null() && (((*error).domain == (s.api.webkit_network_error_quark)() && (*error).code == 302) ++ || ((*error).domain == (s.api.webkit_policy_error_quark)() && (*error).code == 102)) { return 1; } ++ s.error("The embedded browser could not load the page"); ++ 1 // Never display an engine-generated error page carrying a sensitive URL. ++} ++#[repr(C)] ++struct GError { domain: u32, code: c_int, message: *mut c_char } ++unsafe extern "C" fn tls_failed(_view: Object, _uri: *const c_char, _certificate: Object, _errors: c_uint, data: Object) -> c_int { ++ state(data).error("The embedded browser rejected an invalid TLS certificate"); ++ 1 ++} ++unsafe extern "C" fn process_terminated(_view: Object, _reason: c_int, data: Object) { ++ state(data).error("The embedded browser process exited"); ++} ++unsafe extern "C" fn deny_permission(_view: Object, request: Object, data: Object) -> c_int { ++ let s = state(data); (s.api.webkit_permission_request_deny)(request); s.blocked(); 1 ++} ++unsafe extern "C" fn deny_file(_view: Object, request: Object, data: Object) -> c_int { ++ let s = state(data); (s.api.webkit_file_chooser_request_cancel)(request); s.blocked(); 1 ++} ++unsafe extern "C" fn deny_authenticate(_view: Object, request: Object, data: Object) -> c_int { ++ let s = state(data); (s.api.webkit_authentication_request_cancel)(request); s.blocked(); 1 ++} ++unsafe extern "C" fn deny_download(_context: Object, download: Object, data: Object) { ++ let s = state(data); (s.api.webkit_download_cancel)(download); s.blocked(); ++} ++unsafe extern "C" fn deny_popup(_view: Object, _action: Object, data: Object) -> Object { ++ state(data).blocked(); ptr::null_mut() ++} ++unsafe extern "C" fn deny_dialog(_view: Object, _dialog: Object, data: Object) -> c_int { ++ state(data).blocked(); 1 ++} ++unsafe extern "C" fn deny_context_menu(_view: Object, _menu: Object, _event: Object, _hit: Object, _data: Object) -> c_int { 1 } ++unsafe extern "C" fn deny_fullscreen(_view: Object, _data: Object) -> c_int { 1 } ++unsafe extern "C" fn deny_print(_view: Object, _operation: Object, _data: Object) -> c_int { 1 } ++unsafe extern "C" fn closed(_view: Object, data: Object) { ++ let s = state(data); ++ if !s.finished.replace(true) { ++ unavailable(s.id, false, "The embedded page closed"); ++ } ++ // The X11 owner removes this view after GLib returns, never while its ++ // callback still borrows State. ++} ++ ++enum Document { Url(String, bool), Html(String, String) } ++ ++pub(crate) struct LinuxSystemBrowser { ++ state: Box, ++ view: Object, ++ plug: Object, ++ context: Object, ++ signals: Vec<(Object, c_ulong)>, ++ display: *mut x::Display, ++ socket: x::Window, ++ plug_id: x::Window, ++ xembed: x::Atom, ++ parent: x::Window, ++ attached: Option, ++ visible: bool, ++ focused: bool, ++ has_allocation: bool, ++ pending_document: Option, ++} ++impl LinuxSystemBrowser { ++ pub(crate) fn new(display: *mut x::Display, id: u64, url: &str, navigable: bool) -> Result { ++ let policy = BrowserPolicy::reader(url, navigable)?; ++ let api = api()?; ++ unsafe { ++ // Start under an UNMAPPED child of root. No independent GTK toplevel ++ // becomes visible, even if loading starts before the first draw. ++ let root = x::XRootWindow(display, x::XDefaultScreen(display)); ++ let socket = x::XCreateWindow(display, root, 0, 0, 1, 1, 0, 0, x::InputOutput, ptr::null_mut(), 0, ptr::null_mut()); ++ if socket == 0 { return Err("Could not create the browser child window"); } ++ XSelectInput(display, socket, (x::SubstructureNotifyMask | x::StructureNotifyMask | x::FocusChangeMask) as c_long); ++ XSync(display, 0); // GTK uses a separate X connection: socket must exist first. ++ let context = (api.webkit_web_context_new_ephemeral)(); ++ if context.is_null() { x::XDestroyWindow(display, socket); return Err("Could not create an isolated WebKit context"); } ++ (api.webkit_web_context_set_tls_errors_policy)(context, 1); // WEBKIT_TLS_ERRORS_POLICY_FAIL, never IGNORE. ++ let view = (api.webkit_web_view_new_with_context)(context); ++ let plug = (api.gtk_plug_new)(socket); ++ if view.is_null() || plug.is_null() { ++ if !view.is_null() { (api.gtk_widget_destroy)(view); } ++ if !plug.is_null() { (api.gtk_widget_destroy)(plug); } ++ (api.g_object_unref)(context); ++ x::XDestroyWindow(display, socket); ++ return Err("Could not create the embedded WebKit view"); ++ } ++ let settings = (api.webkit_web_view_get_settings)(view); ++ (api.webkit_settings_set_javascript_can_open_windows_automatically)(settings, 0); ++ (api.webkit_settings_set_enable_developer_extras)(settings, 0); ++ let mut browser = Self { ++ state: Box::new(State { api: api.clone(), id, policy: RefCell::new(policy), navigable, finished: Cell::new(false) }), ++ view, plug, context, signals: Vec::new(), display, socket, plug_id: 0, ++ xembed: x::XInternAtom(display, c"_XEMBED".as_ptr(), 0), parent: root, ++ attached: None, visible: false, focused: false, ++ has_allocation: false, pending_document: Some(Document::Url(url.to_owned(), false)), ++ }; ++ macro_rules! signal { ($object:expr, $name:literal, $callback:ident) => {{ ++ let handler = (api.g_signal_connect_data)($object, concat!($name, "\0").as_ptr().cast(), ++ std::mem::transmute::<*const (), Callback>($callback as *const ()), ++ (&*browser.state as *const State).cast_mut().cast(), None, 0); ++ browser.signals.push(($object, handler)); ++ }}; } ++ signal!(view, "decide-policy", decide); ++ signal!(view, "load-changed", load_changed); ++ signal!(view, "notify::title", property_changed); ++ signal!(view, "notify::uri", property_changed); ++ signal!(view, "load-failed", load_failed); ++ signal!(view, "load-failed-with-tls-errors", tls_failed); ++ signal!(view, "web-process-terminated", process_terminated); ++ signal!(view, "permission-request", deny_permission); ++ signal!(view, "run-file-chooser", deny_file); ++ signal!(view, "authenticate", deny_authenticate); ++ signal!(view, "create", deny_popup); ++ signal!(view, "script-dialog", deny_dialog); ++ signal!(view, "context-menu", deny_context_menu); ++ signal!(view, "enter-fullscreen", deny_fullscreen); ++ signal!(view, "print", deny_print); ++ signal!(view, "close", closed); ++ signal!(context, "download-started", deny_download); ++ (api.gtk_container_add)(plug, view); ++ (api.gtk_widget_show_all)(plug); ++ browser.plug_id = (api.gtk_plug_get_id)(plug); ++ // Establish GTK's child on its X connection before the Makepad ++ // connection sends XEmbed messages or geometry requests to it. ++ (api.gdk_display_sync)((api.gdk_display_get_default)()); ++ browser.send_xembed(0, 0, socket as c_long, 0); // EMBEDDED_NOTIFY, protocol 0. ++ Ok(browser) ++ } ++ } ++ ++ fn send_xembed(&self, message: c_long, detail: c_long, data1: c_long, data2: c_long) { ++ if self.plug_id == 0 { return; } ++ unsafe { ++ let mut event: x::XEvent = std::mem::zeroed(); ++ event.xclient = x::XClientMessageEvent { ++ type_: x::ClientMessage as c_int, serial: 0, send_event: 1, ++ display: self.display, window: self.plug_id, message_type: self.xembed, format: 32, ++ data: x::XClientMessageEvent__bindgen_ty_1 { l: [0, message, detail, data1, data2] }, ++ }; ++ x::XSendEvent(self.display, self.plug_id, 0, 0, &mut event); ++ x::XFlush(self.display); ++ } ++ } ++ ++ pub(crate) fn update(&mut self, window: WindowId, parent: x::Window, full: Rect, clip: Rect, dpi: f64, visible: bool) { ++ if self.state.finished.get() || !visible || clip.size.x <= 0.0 || clip.size.y <= 0.0 ++ || full.size.x <= 0.0 || full.size.y <= 0.0 || !dpi.is_finite() ++ || ![full.pos.x, full.pos.y, full.size.x, full.size.y, clip.pos.x, clip.pos.y, clip.size.x, clip.size.y].iter().all(|v| v.is_finite()) { ++ self.hide(); return; ++ } ++ unsafe { ++ if self.parent != parent { ++ self.hide(); ++ XReparentWindow(self.display, self.socket, parent, 0, 0); ++ self.parent = parent; ++ } ++ self.attached = Some(window); ++ // The socket clips the plug, preserving the complete document's ++ // layout while parent scroll/clip changes its visible rectangle. ++ x::XMoveWindow(self.display, self.socket, coord(clip.pos.x), coord(clip.pos.y)); ++ x::XResizeWindow(self.display, self.socket, extent(clip.size.x), extent(clip.size.y)); ++ let scale = (self.state.api.gtk_widget_get_scale_factor)(self.plug).max(1) as f64; ++ let allocation = Allocation { x: 0, y: 0, width: extent(full.size.x / scale) as c_int, height: extent(full.size.y / scale) as c_int }; ++ (self.state.api.gtk_widget_set_size_request)(self.plug, allocation.width, allocation.height); ++ // The host may intentionally be hidden (remote instrumentation). ++ // GTK then receives no frame-clock allocation before page load. ++ // Allocate the sole child explicitly so its first document has the ++ // same viewport as the real native child, including in hidden mode. ++ (self.state.api.gtk_widget_size_allocate)(self.plug, &allocation); ++ (self.state.api.gtk_widget_size_allocate)(self.view, &allocation); ++ (self.state.api.webkit_web_view_set_zoom_level)(self.view, (dpi / scale).clamp(0.25, 5.0)); ++ x::XMoveWindow(self.display, self.plug_id, coord(full.pos.x - clip.pos.x), coord(full.pos.y - clip.pos.y)); ++ x::XResizeWindow(self.display, self.plug_id, extent(full.size.x), extent(full.size.y)); ++ if !self.visible { ++ x::XMapWindow(self.display, self.socket); ++ self.send_xembed(1, 0, 0, 0); // WINDOW_ACTIVATE, no focus stealing. ++ self.visible = true; ++ } ++ x::XFlush(self.display); ++ } ++ self.has_allocation = true; ++ if let Some(document) = self.pending_document.take() { ++ match document { ++ Document::Url(url, replace) => self.set_url(&url, replace), ++ Document::Html(html, base) => self.set_html(&html, &base), ++ } ++ } ++ } ++ pub(crate) fn window(&self) -> Option { self.attached } ++ pub(crate) fn is_finished(&self) -> bool { self.state.finished.get() } ++ pub(crate) fn hide(&mut self) { ++ if self.visible { ++ self.focus(false); ++ self.send_xembed(2, 0, 0, 0); ++ unsafe { XUnmapWindow(self.display, self.socket); x::XFlush(self.display); } ++ self.visible = false; ++ } ++ } ++ pub(crate) fn detach(&mut self) { ++ self.hide(); ++ unsafe { ++ let root = x::XRootWindow(self.display, x::XDefaultScreen(self.display)); ++ XReparentWindow(self.display, self.socket, root, 0, 0); ++ self.parent = root; ++ x::XFlush(self.display); ++ } ++ self.attached = None; ++ } ++ pub(crate) fn focus(&mut self, focused: bool) { ++ if focused && !self.visible { return; } ++ if self.focused == focused { return; } ++ self.focused = focused; ++ self.send_xembed(if focused { 4 } else { 5 }, 0, 0, 0); ++ unsafe { ++ if focused { ++ x::XSetInputFocus(self.display, self.plug_id, 2, 0); ++ (self.state.api.gtk_widget_grab_focus)(self.view); ++ } else if self.visible { ++ x::XSetInputFocus(self.display, self.parent, 2, 0); ++ } ++ } ++ } ++ pub(crate) fn active(&mut self, active: bool) { ++ if self.visible { self.send_xembed(if active { 1 } else { 2 }, 0, 0, 0); } ++ if !active { self.focused = false; self.send_xembed(5, 0, 0, 0); } ++ } ++ pub(crate) fn xembed_message(&mut self, window: x::Window, atom: x::Atom, message: c_long) -> bool { ++ if window != self.socket || atom != self.xembed { return false; } ++ match message { 3 => self.focus(true), 6 | 7 => self.focus(false), _ => {} } ++ true ++ } ++ pub(crate) fn set_url(&mut self, url: &str, replace: bool) { ++ if self.state.finished.get() { return; } ++ if !self.has_allocation { self.pending_document = Some(Document::Url(url.to_owned(), replace)); return; } ++ match BrowserPolicy::reader(url, self.state.navigable) { ++ Ok(policy) => *self.state.policy.borrow_mut() = policy, ++ Err(message) => { self.state.error(message); return; } ++ } ++ if !matches!(self.state.policy.borrow().decide(url, true), NavigationDecision::Allow) { ++ self.state.blocked(); return; ++ } ++ let Ok(uri) = CString::new(url) else { self.state.error("Invalid browser URL"); return; }; ++ unsafe { ++ // A native replace is implemented inside the existing document; ++ // normal navigation policies still enforce its destination. ++ if replace { ++ let quoted = js_string(url); ++ self.eval_js(&format!("window.location.replace({quoted})")); ++ } else { (self.state.api.webkit_web_view_load_uri)(self.view, uri.as_ptr()); } ++ } ++ } ++ pub(crate) fn set_html(&mut self, html: &str, base: &str) { ++ if self.state.finished.get() { return; } ++ if !self.has_allocation { self.pending_document = Some(Document::Html(html.to_owned(), base.to_owned())); return; } ++ let Ok(policy) = BrowserPolicy::reader(base, self.state.navigable) else { self.state.error("Invalid browser base URL"); return; }; ++ let (Ok(html), Ok(base)) = (CString::new(html), CString::new(base)) else { return; }; ++ *self.state.policy.borrow_mut() = policy; ++ unsafe { (self.state.api.webkit_web_view_load_html)(self.view, html.as_ptr(), base.as_ptr()); } ++ } ++ pub(crate) fn eval_js(&self, js: &str) { ++ // Only the owning native host can evaluate script; no page-to-host bridge. ++ if self.state.finished.get() { return; } ++ if let Ok(js) = CString::new(js) { ++ unsafe { (self.state.api.webkit_web_view_run_javascript)(self.view, js.as_ptr(), ptr::null_mut(), None, ptr::null_mut()); } ++ } ++ } ++ pub(crate) fn history_go(&self, delta: i32) { ++ if self.state.finished.get() { return; } ++ unsafe { ++ let list = (self.state.api.webkit_web_view_get_back_forward_list)(self.view); ++ let item = (self.state.api.webkit_back_forward_list_get_nth_item)(list, delta); ++ if !item.is_null() { (self.state.api.webkit_web_view_go_to_back_forward_list_item)(self.view, item); } ++ } ++ } ++} ++impl Drop for LinuxSystemBrowser { ++ fn drop(&mut self) { ++ self.hide(); ++ self.state.finished.set(true); ++ unsafe { ++ // Disconnect before destroying GTK objects or releasing callback ++ // state. Web process signals may arrive during destruction. ++ for (object, signal) in self.signals.drain(..) { (self.state.api.g_signal_handler_disconnect)(object, signal); } ++ (self.state.api.webkit_web_view_stop_loading)(self.view); ++ let gdk_display = (self.state.api.gdk_display_get_default)(); ++ // A window-manager close can already have destroyed the native ++ // parent. Trap only GTK teardown's expected stale-child errors. ++ (self.state.api.gdk_x11_display_error_trap_push)(gdk_display); ++ (self.state.api.gtk_widget_destroy)(self.plug); ++ // GTK and Makepad use separate X connections. Complete the plug's ++ // unmap/destroy before destroying its socket, otherwise the server ++ // can remove the child before GTK's queued UnmapWindow arrives. ++ (self.state.api.gdk_display_sync)(gdk_display); ++ (self.state.api.gdk_x11_display_error_trap_pop_ignored)(gdk_display); ++ (self.state.api.g_object_unref)(self.context); ++ x::XDestroyWindow(self.display, self.socket); ++ x::XFlush(self.display); ++ } ++ } ++} ++fn coord(value: f64) -> c_int { value.round().clamp(-32768.0, 32767.0) as c_int } ++fn extent(value: f64) -> c_uint { value.ceil().clamp(1.0, 32767.0) as c_uint } ++fn js_string(value: &str) -> String { ++ let mut out = String::from("\""); ++ for character in value.chars() { ++ match character { ++ '"' => out.push_str("\\\""), '\\' => out.push_str("\\\\"), ++ '\n' => out.push_str("\\n"), '\r' => out.push_str("\\r"), ++ c if c.is_control() || c == '\u{2028}' || c == '\u{2029}' => out.push_str(&format!("\\u{:04x}", c as u32)), ++ c => out.push(c), ++ } ++ } ++ out.push('"'); out ++} ++#[link(name = "X11")] ++extern "C" { ++ fn XSelectInput(display: *mut x::Display, window: x::Window, mask: c_long) -> c_int; ++ fn XSync(display: *mut x::Display, discard: c_int) -> c_int; ++ fn XReparentWindow(display: *mut x::Display, window: x::Window, parent: x::Window, x: c_int, y: c_int) -> c_int; ++ fn XUnmapWindow(display: *mut x::Display, window: x::Window) -> c_int; ++} +diff --git a/platform/src/os/linux/mod.rs b/platform/src/os/linux/mod.rs +index e234e0fee..1994e6a98 100644 +--- a/platform/src/os/linux/mod.rs ++++ b/platform/src/os/linux/mod.rs +@@ -2,6 +2,8 @@ + pub mod opengl_cx; + #[cfg(not(any(linux_direct, target_env = "ohos", target_os = "android")))] + pub mod file_dialog; ++#[cfg(not(any(linux_direct, target_env = "ohos", target_os = "android")))] ++pub(crate) mod linux_webkit; + // The wayland-* crates are dependencies of the linux-gnu targets only, so + // this has to carry the same gate as its siblings: Android and OHOS are + // `os::linux` too, and would otherwise fail to resolve `wayland_client`. +diff --git a/platform/src/os/linux/wayland/linux_wayland.rs b/platform/src/os/linux/wayland/linux_wayland.rs +index 9fa1ec6a8..94021ab60 100644 +--- a/platform/src/os/linux/wayland/linux_wayland.rs ++++ b/platform/src/os/linux/wayland/linux_wayland.rs +@@ -184,6 +184,7 @@ fn state_event_callback(&mut self, state: &mut WaylandState, event: XlibEvent) - + } + } + match event { ++ XlibEvent::BrowserMessage { .. } => {} // XEmbed is X11-only. + XlibEvent::WindowGotFocus(window_id) => { + // repaint all window passes. Metal sometimes doesnt flip buffers when hidden/no focus + let mut cx = self.cx.borrow_mut(); +@@ -674,6 +675,14 @@ fn handle_platform_ops(&self, state: &mut WaylandState) -> EventFlow { + } + } + match op { ++ CxOsOp::SpawnSystemBrowser { browser_id, .. } => { ++ super::super::linux_webkit::unavailable(browser_id.0, false, ++ "Embedded WebKitGTK requires X11/XWayland; relaunch with --linux-backend=x11 in a session with DISPLAY"); ++ } ++ CxOsOp::SpawnAuthBrowser { browser_id, .. } => { ++ super::super::linux_webkit::unavailable(browser_id.0, true, ++ "Embedded authentication is unavailable on Linux; use the external browser"); ++ } + CxOsOp::CreateWindow(window_id) => { + #[cfg(not(use_vulkan))] + let gl_cx = cx.os.opengl_cx.as_ref().unwrap(); +diff --git a/platform/src/os/linux/windowing_backend.rs b/platform/src/os/linux/windowing_backend.rs +index 499c6dc3c..f7e8af444 100644 +--- a/platform/src/os/linux/windowing_backend.rs ++++ b/platform/src/os/linux/windowing_backend.rs +@@ -11,6 +11,13 @@ + /// text, matching the common Windows and GTK defaults. + pub const PIXELS_PER_WHEEL_DETENT: f64 = 60.0; + ++static PREFER_X11_FOR_EMBEDDED_BROWSER: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); ++ ++fn prefers_x11_for_embedded_browser() -> bool { ++ !cfg!(use_vulkan) && env_var_is_nonempty("DISPLAY") ++ && PREFER_X11_FOR_EMBEDDED_BROWSER.load(std::sync::atomic::Ordering::Relaxed) ++} ++ + fn env_var_is_nonempty(name: &str) -> bool { + std::env::var_os(name).is_some_and(|value| !value.is_empty()) + } +@@ -42,6 +49,12 @@ fn detect_windowing_protocol() -> WindowingProtocol { + return protocol; + } + ++ // Hosts that require embedded WebKit may opt into XWayland. Explicit user ++ // choices above always win, and Vulkan still requires native Wayland. ++ if prefers_x11_for_embedded_browser() { ++ return WindowingProtocol::X11; ++ } ++ + // Check for Wayland first + if env_var_is_nonempty("WAYLAND_DISPLAY") { + return WindowingProtocol::Wayland; +@@ -63,6 +76,13 @@ pub enum WindowingProtocol { + } + + impl Cx { ++ /// Call before starting the UI event loop to prefer X11/XWayland when an X ++ /// display exists. Needed for WebKitGTK XEmbed; no environment is mutated. ++ /// An explicit --linux-backend choice and the Vulkan requirement take priority. ++ pub fn prefer_x11_for_embedded_browser() { ++ PREFER_X11_FOR_EMBEDDED_BROWSER.store(true, std::sync::atomic::Ordering::Relaxed); ++ } ++ + pub fn event_loop(cx: Rc>) { + #[cfg(use_vulkan)] + if is_stdin_loop_mode() { +@@ -123,6 +143,8 @@ pub fn event_loop(cx: Rc>) { + println!("Reason: --stdin-loop mode uses X11 stdin backend"); + } else if forced_windowing_protocol_from_args() == Some(WindowingProtocol::X11) { + println!("Reason: --linux-backend=x11 override"); ++ } else if prefers_x11_for_embedded_browser() { ++ println!("Reason: host preference for embedded WebKitGTK via X11/XWayland"); + } else if env_var_is_nonempty("DISPLAY") { + println!("Reason: DISPLAY environment variable is set"); + } else { +diff --git a/platform/src/os/linux/x11/linux_x11.rs b/platform/src/os/linux/x11/linux_x11.rs +index b5c58e27e..c445ec3b0 100644 +--- a/platform/src/os/linux/x11/linux_x11.rs ++++ b/platform/src/os/linux/x11/linux_x11.rs +@@ -1,6 +1,7 @@ + use { + self::super::super::{ + egl_sys, ++ linux_webkit::{self, LinuxSystemBrowser}, + linux_video_playback::{poll_pending_gstreamer_teardowns, GStreamerVideoPlayer}, + linux_video_player::{ + collect_linux_video_player_events, prepare_desktop_linux_video, LinuxPrepareResult, +@@ -48,12 +49,14 @@ pub fn x11_event_loop(cx: Rc>) { + + pub struct X11Cx { + pub cx: Rc>, ++ browsers: std::collections::HashMap, + } + + impl X11Cx { + pub fn event_loop_impl(cx: Rc>) { + let mut x11_cx = X11Cx { + cx: cx.clone(), ++ browsers: Default::default(), + }; + cx.borrow_mut().self_ref = Some(cx.clone()); + cx.borrow_mut().os_type = OsType::LinuxWindow(LinuxWindowParams { +@@ -73,7 +76,11 @@ pub fn event_loop_impl(cx: Rc>) { + return EventFlow::Wait; + } + let mut opengl_windows = opengl_windows.borrow_mut(); +- x11_cx.xlib_event_callback(xlib_app, events, &mut *opengl_windows) ++ let flow = x11_cx.xlib_event_callback(xlib_app, events, &mut *opengl_windows); ++ // XlibApp closes the Display as soon as Exit is returned. ++ // Dispose child sockets while that connection is still valid. ++ if matches!(flow, EventFlow::Exit) { x11_cx.browsers.clear(); } ++ flow + } + })); + +@@ -112,7 +119,13 @@ fn xlib_event_callback( + } + + match event { ++ XlibEvent::BrowserMessage { window, atom, message } => { ++ for browser in self.browsers.values_mut() { ++ if browser.xembed_message(window, atom, message) { break; } ++ } ++ } + XlibEvent::WindowGotFocus(window_id) => { ++ for browser in self.browsers.values_mut().filter(|browser| browser.window() == Some(window_id)) { browser.active(true); } + // repaint all window passes. Metal sometimes doesnt flip buffers when hidden/no focus + let mut cx = self.cx.borrow_mut(); + for window in opengl_windows.iter_mut() { +@@ -123,6 +136,7 @@ fn xlib_event_callback( + cx.call_event_handler(&Event::WindowGotFocus(window_id)); + } + XlibEvent::WindowLostFocus(window_id) => { ++ for browser in self.browsers.values_mut().filter(|browser| browser.window() == Some(window_id)) { browser.active(false); } + let mut cx = self.cx.borrow_mut(); + cx.call_event_handler(&Event::WindowLostFocus(window_id)); + } +@@ -152,6 +166,7 @@ fn xlib_event_callback( + cx.call_event_handler(&Event::WindowGeomChange(re)); + } + XlibEvent::WindowClosed(wc) => { ++ self.browsers.retain(|_, browser| browser.window() != Some(wc.window_id)); + let window_id = wc.window_id; + self.close_popup_children(opengl_windows, xlib_app, window_id); + +@@ -224,6 +239,7 @@ fn xlib_event_callback( + } + } + XlibEvent::MouseDown(mut e) => { ++ for browser in self.browsers.values_mut() { browser.focus(false); } + let mut cx = self.cx.borrow_mut(); + cx.dpi_override_scale(&mut e.abs, e.window_id); + cx.fingers.process_tap_count(e.abs, e.time); +@@ -308,6 +324,10 @@ fn xlib_event_callback( + cx.call_event_handler(&Event::TextCut(e)) + } + XlibEvent::Timer(e) => { ++ if e.timer_id == 0 { ++ linux_webkit::pump(); ++ self.browsers.retain(|_, browser| !browser.is_finished()); ++ } + let mut cx = self.cx.borrow_mut(); + if e.timer_id == 0 { + if SignalToUI::check_and_clear_ui_signal() { +@@ -567,6 +587,7 @@ fn handle_platform_ops( + opengl_windows.push(opengl_window); + } + CxOsOp::CloseWindow(window_id) => { ++ self.browsers.retain(|_, browser| browser.window() != Some(window_id)); + drop(cx); + self.close_popup_children(opengl_windows, xlib_app, window_id); + cx = self.cx.borrow_mut(); +@@ -596,7 +617,7 @@ fn handle_platform_ops( + } + } + } +- CxOsOp::Quit => ret = EventFlow::Exit, ++ CxOsOp::Quit => { self.browsers.clear(); ret = EventFlow::Exit; }, + CxOsOp::MinimizeWindow(window_id) => { + if let Some(window) = + opengl_windows.iter_mut().find(|w| w.window_id == window_id) +@@ -912,6 +933,53 @@ fn handle_platform_ops( + let _ = player.select_audio_track(index); + } + } ++ CxOsOp::SpawnSystemBrowser { browser_id, url, navigable } => { ++ // Respawn discards prior page state, callbacks and context. ++ self.browsers.remove(&browser_id); ++ match LinuxSystemBrowser::new(xlib_app.display, browser_id.0, &url, navigable) { ++ Ok(browser) => { self.browsers.insert(browser_id, browser); } ++ Err(error) => linux_webkit::unavailable(browser_id.0, false, error), ++ } ++ } ++ CxOsOp::SpawnAuthBrowser { browser_id, .. } => { ++ self.browsers.remove(&browser_id); ++ // WebKitGTK cannot reliably distinguish a main-frame callback ++ // before its network request. Keep authentication external; ++ // never send a callback token to octosense.invalid. ++ linux_webkit::unavailable(browser_id.0, true, ++ "Embedded authentication is unavailable on Linux; use the external browser"); ++ } ++ CxOsOp::UpdateSystemBrowser { browser_id, area, visible } => { ++ let target = area.draw_list_id() ++ .and_then(|id| cx.draw_lists[id].draw_pass_id) ++ .and_then(|id| cx.get_pass_window_id(id)) ++ .and_then(|id| opengl_windows.iter().find(|window| window.window_id == id) ++ .and_then(|window| window.xlib_window.window.map(|native| (id, native)))); ++ if let Some(browser) = self.browsers.get_mut(&browser_id) { ++ if let Some((id, parent)) = target { ++ let window = &cx.windows[id]; ++ let full = window.layout_rect_to_physical_pixels(area.rect(&cx)); ++ let clip = window.layout_rect_to_physical_pixels(area.clipped_rect(&cx)); ++ browser.update(id, parent, full, clip, window.window_geom.dpi_factor, visible); ++ } else { browser.hide(); } ++ } ++ } ++ CxOsOp::DetachSystemBrowser { browser_id } => { ++ if let Some(browser) = self.browsers.get_mut(&browser_id) { browser.detach(); } ++ } ++ CxOsOp::SetSystemBrowserUrl { browser_id, url, replace } => { ++ if let Some(browser) = self.browsers.get_mut(&browser_id) { browser.set_url(&url, replace); } ++ } ++ CxOsOp::SetSystemBrowserHtml { browser_id, html, base_url } => { ++ if let Some(browser) = self.browsers.get_mut(&browser_id) { browser.set_html(&html, &base_url); } ++ } ++ CxOsOp::EvalSystemBrowserJs { browser_id, js } => { ++ if let Some(browser) = self.browsers.get(&browser_id) { browser.eval_js(&js); } ++ } ++ CxOsOp::SystemBrowserHistoryGo { browser_id, delta } => { ++ if let Some(browser) = self.browsers.get(&browser_id) { browser.history_go(delta); } ++ } ++ CxOsOp::CloseSystemBrowser { browser_id } => { self.browsers.remove(&browser_id); } + CxOsOp::AttachCameraNativePreview { .. } + | CxOsOp::UpdateCameraNativePreview { .. } + | CxOsOp::DetachCameraNativePreview { .. } => { +diff --git a/platform/src/os/linux/x11/xlib_app.rs b/platform/src/os/linux/x11/xlib_app.rs +index c3308698e..62a67d4bd 100644 +--- a/platform/src/os/linux/x11/xlib_app.rs ++++ b/platform/src/os/linux/x11/xlib_app.rs +@@ -1170,6 +1170,9 @@ pub unsafe fn event_loop_poll(&mut self) { + } + x11_sys::ClientMessage => { + let event = event.xclient; ++ if event.format == 32 { ++ self.do_callback(XlibEvent::BrowserMessage { window: event.window, atom: event.message_type, message: event.data.l[1] }); ++ } + if event.message_type == self.atoms.wm_protocols + && event.data.l[0] as c_ulong == self.atoms.wm_delete_window + { +@@ -1222,6 +1225,7 @@ pub unsafe fn event_loop_poll(&mut self) { + if event.mode == x11_sys::NotifyGrab + || event.mode == x11_sys::NotifyUngrab + || event.detail == x11_sys::NotifyPointer ++ || event.detail == 2 // NotifyInferior: focus stays inside an embedded child. + { + continue; + } +@@ -1244,6 +1248,7 @@ pub unsafe fn event_loop_poll(&mut self) { + if event.xfocus.mode == x11_sys::NotifyGrab + || event.xfocus.mode == x11_sys::NotifyUngrab + || event.xfocus.detail == x11_sys::NotifyPointer ++ || event.xfocus.detail == 2 // NotifyInferior: XEmbed child, same app window. + { + continue; + } +diff --git a/platform/src/os/linux/x11/xlib_event.rs b/platform/src/os/linux/x11/xlib_event.rs +index 6e69c6605..84372712a 100644 +--- a/platform/src/os/linux/x11/xlib_event.rs ++++ b/platform/src/os/linux/x11/xlib_event.rs +@@ -15,6 +15,7 @@ pub enum XlibEvent { + WindowClosed(WindowClosedEvent), + PopupDismissed(PopupDismissedEvent), + Paint, ++ BrowserMessage { window: std::os::raw::c_ulong, atom: std::os::raw::c_ulong, message: std::os::raw::c_long }, + + MouseDown(MouseDownEvent), + MouseUp(MouseUpEvent), +diff --git a/platform/src/os/windows/d3d11.rs b/platform/src/os/windows/d3d11.rs +index f060468ab..6beec0f15 100644 +--- a/platform/src/os/windows/d3d11.rs ++++ b/platform/src/os/windows/d3d11.rs +@@ -33,7 +33,7 @@ + Graphics::{ + Direct3D::{ + Fxc::D3DCompile, D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST, +- D3D_DRIVER_TYPE_UNKNOWN, D3D_FEATURE_LEVEL_11_0, D3D_SRV_DIMENSION, ++ D3D_DRIVER_TYPE_UNKNOWN, D3D_DRIVER_TYPE, D3D_FEATURE_LEVEL_11_0, D3D_SRV_DIMENSION, + D3D_SRV_DIMENSION_TEXTURECUBE, + }, + Direct3D11::{ +@@ -2542,15 +2542,19 @@ fn create_device_tier() -> windows_core::Result<( + // before a hybrid-GPU transition or a driver reinstall keeps handing back the + // adapter that went away. Recovery always starts from a fresh factory. + let factory: IDXGIFactory2 = CreateDXGIFactory2(DXGI_CREATE_FACTORY_FLAGS(0))?; +- let adapter = factory.EnumAdapters(0)?; ++ // Explicit diagnostics/CI opt-in to Windows' built-in software ++ // rasterizer. Normal hosts still select their hardware adapter. ++ let warp = std::env::var_os("MAKEPAD_D3D11_WARP").is_some_and(|value| value == "1"); ++ let adapter = if warp { None } else { Some(factory.EnumAdapters(0)?) }; + let mut device: Option = None; + let mut context: Option = None; + let mut query: Option = None; + D3D11CreateDevice( +- &adapter, +- D3D_DRIVER_TYPE_UNKNOWN, ++ adapter.as_ref(), ++ if warp { D3D_DRIVER_TYPE(5) /* D3D_DRIVER_TYPE_WARP */ } else { D3D_DRIVER_TYPE_UNKNOWN }, + HMODULE(std::ptr::null_mut()), +- D3D11_CREATE_DEVICE_FLAG(0x800 | 0x20), // VIDEO_SUPPORT | BGRA_SUPPORT ++ // WARP does not require the hardware video decoding interface. ++ D3D11_CREATE_DEVICE_FLAG(if warp { 0x20 } else { 0x800 | 0x20 }), + Some(&[D3D_FEATURE_LEVEL_11_0]), + D3D11_SDK_VERSION, + Some(&mut device), +diff --git a/platform/src/os/windows/mod.rs b/platform/src/os/windows/mod.rs +index 19aea0eb4..9ec64eaec 100644 +--- a/platform/src/os/windows/mod.rs ++++ b/platform/src/os/windows/mod.rs +@@ -14,6 +14,7 @@ + pub mod win32_event; + pub mod win32_screen; + pub mod win32_window; ++pub mod windows_webview; + pub mod windows_media; + pub mod windows_media_engine_notify; + pub mod windows_mf_source_reader; +diff --git a/platform/src/os/windows/windows.rs b/platform/src/os/windows/windows.rs +index c0404864b..19a5b124a 100644 +--- a/platform/src/os/windows/windows.rs ++++ b/platform/src/os/windows/windows.rs +@@ -24,6 +24,7 @@ + win32_window::Win32Window, + windows_game_input::WindowsGameInput, + windows_media::CxWindowsMedia, ++ windows_webview::WindowsSystemBrowser, + windows_video_player::WindowsUnifiedVideoPlayer, + }, + }, +@@ -164,6 +165,7 @@ fn win32_event_callback( + // close path; no other code may synthesize a WindowClosed, or the app + // would see it twice. + let window_id = wc.window_id; ++ self.os.system_browsers.retain(|_, browser| browser.attached_window() != Some(window_id)); + // Cascade-close popups parented to this window. Their own WindowClosed + // arrives via the queued authentic event; removing the D3d11Window now + // makes the app's `WindowHandle::close()` response a no-op. +@@ -990,6 +992,7 @@ fn handle_platform_ops( + }); + } + CxOsOp::CloseWindow(window_id) => { ++ self.os.system_browsers.retain(|_, browser| browser.attached_window() != Some(window_id)); + // The authentic WindowClosed event this triggers delivers + // Event::WindowClosed and the exit check; firing it here would double it. + // Remove the D3d11Window now so later ops cannot touch the destroyed hwnd. +@@ -1290,6 +1293,51 @@ fn handle_platform_ops( + player.set_playback_rate(rate); + } + } ++ CxOsOp::SpawnSystemBrowser { browser_id, url, navigable } => { ++ self.os.system_browsers.insert(browser_id, WindowsSystemBrowser::new(browser_id, &url, navigable)); ++ } ++ CxOsOp::SpawnAuthBrowser { browser_id, url, callback_url, inspectable: _ } => { ++ self.os.system_browsers.insert(browser_id, WindowsSystemBrowser::new_auth(browser_id, &url, &callback_url)); ++ } ++ CxOsOp::UpdateSystemBrowser { browser_id, area, visible } => { ++ let placement = area.draw_list_id() ++ .and_then(|id| self.draw_lists[id].draw_pass_id) ++ .and_then(|pass| self.get_pass_window_id(pass)) ++ .and_then(|id| d3d11_windows.iter().find(|w| w.window_id == id).map(|w| ++ (id, w.win32_window.hwnd, w.win32_window.get_dpi_factor()))); ++ if let Some((window, hwnd, dpi)) = placement { ++ let rect = area.rect(self); ++ let clip = area.clipped_rect(self); ++ if let Some(browser) = self.os.system_browsers.get_mut(&browser_id) { ++ browser.update(window, hwnd, rect, clip, dpi, visible); ++ } ++ } else if let Some(browser) = self.os.system_browsers.get_mut(&browser_id) { ++ browser.detach(); ++ } ++ } ++ CxOsOp::DetachSystemBrowser { browser_id } => { ++ if let Some(browser) = self.os.system_browsers.get_mut(&browser_id) { browser.detach(); } ++ } ++ CxOsOp::SetSystemBrowserUrl { browser_id, url, replace } => { ++ if let Some(browser) = self.os.system_browsers.get_mut(&browser_id) { browser.set_url(&url, replace); } ++ } ++ CxOsOp::SetSystemBrowserHtml { browser_id, html, base_url } => { ++ if let Some(browser) = self.os.system_browsers.get_mut(&browser_id) { browser.set_html(&html, &base_url); } ++ } ++ CxOsOp::EvalSystemBrowserJs { browser_id, js } => { ++ if let Some(browser) = self.os.system_browsers.get(&browser_id) { browser.eval_js(&js); } ++ } ++ CxOsOp::SystemBrowserHistoryGo { browser_id, delta } => { ++ if let Some(browser) = self.os.system_browsers.get(&browser_id) { browser.history_go(delta); } ++ } ++ CxOsOp::CloseSystemBrowser { browser_id } => { self.os.system_browsers.remove(&browser_id); } ++ CxOsOp::InspectSystemBrowser { browser_id, result_path, snapshot_path, scroll_y } => { ++ if let Some(browser) = self.os.system_browsers.get(&browser_id) { ++ browser.inspect(result_path, snapshot_path, scroll_y); ++ } else { ++ let _ = std::fs::write(result_path, r#"{"error":"Browser is closed"}"#); ++ } ++ } + // Track selection is currently implemented on Linux GStreamer only. + CxOsOp::SelectVideoTrack(_, _) | CxOsOp::SelectAudioTrack(_, _) => {} + CxOsOp::AttachCameraNativePreview { .. } +@@ -1424,6 +1472,7 @@ pub struct CxOs { + pub(crate) game_input_events: GameInputEventChannel, + pub(crate) windows_game_input: Option, + pub(crate) video_players: HashMap, ++ pub(crate) system_browsers: HashMap, + pub(crate) async_hlsl_compile: crate::os::windows::d3d11::AsyncHlslCompile, + pub(crate) stdin_timers: crate::os::shared_framebuf::PollTimers, + /// Earliest time the device-loss recovery may try again, and how many tries this outage +diff --git a/platform/src/os/windows/windows_webview.rs b/platform/src/os/windows/windows_webview.rs +new file mode 100644 +index 000000000..bf1ca2e78 +--- /dev/null ++++ b/platform/src/os/windows/windows_webview.rs +@@ -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 ++//! closed views. Pages never receive host objects or an IPC bridge. ++use crate::{ ++ cx::Cx, ++ event::{NativeAuthBrowserEvent, NativeSystemBrowserNavigation, NativeSystemBrowserPageError, NativeSystemBrowserPolicyBlocked}, ++ makepad_live_id::LiveId, ++ makepad_math::Rect, ++ system_browser::{BrowserPolicy, NavigationDecision}, ++ window::WindowId, ++}; ++use std::{ ++ cell::RefCell, ++ rc::Rc, ++ sync::atomic::{AtomicU64, Ordering}, ++}; ++use webview2_com::{Microsoft::Web::WebView2::Win32::*, *}; ++use webview2_windows::{ ++ core::{w, Interface, Result, BOOL, PCWSTR, PWSTR}, ++ Win32::{ ++ Foundation::{E_FAIL, HWND, RECT}, ++ System::{ ++ Com::{ ++ CoTaskMemFree, IStream, StructuredStorage::CreateStreamOnHGlobal, STATFLAG_NONAME, ++ STATSTG, STREAM_SEEK_SET, ++ }, ++ LibraryLoader::GetModuleHandleW, ++ }, ++ UI::WindowsAndMessaging::*, ++ }, ++}; ++ ++static NEXT_PROFILE: AtomicU64 = AtomicU64::new(1); ++ ++type Shared = Rc>; ++ ++struct State { ++ id: LiveId, ++ policy: Option, ++ navigable: bool, ++ auth: bool, ++ auth_finished: bool, ++ closed: bool, ++ creating: bool, ++ observed_web_messages: u64, ++ web_message_observer_active: bool, ++ attached_window: Option, ++ child: HWND, ++ bounds: RECT, ++ visible: bool, ++ controller: Option, ++ webview: Option, ++ pending: Document, ++ inline: bool, ++} ++ ++#[derive(Clone)] ++enum Document { ++ Url(String, bool), ++ Html(String), ++} ++ ++pub(crate) struct WindowsSystemBrowser { ++ state: Shared, ++} ++ ++impl WindowsSystemBrowser { ++ pub(crate) fn new(id: LiveId, url: &str, navigable: bool) -> Self { ++ Self::create(id, url, navigable, None) ++ } ++ ++ pub(crate) fn new_auth(id: LiveId, url: &str, callback: &str) -> Self { ++ Self::create(id, url, false, Some(callback)) ++ } ++ ++ fn create(id: LiveId, url: &str, navigable: bool, callback: Option<&str>) -> Self { ++ let policy = if let Some(callback) = callback { ++ BrowserPolicy::auth(url, callback) ++ } else { ++ BrowserPolicy::reader(url, navigable) ++ }; ++ let state = Rc::new(RefCell::new(State { ++ id, ++ policy: policy.ok(), ++ navigable, ++ auth: callback.is_some(), ++ auth_finished: false, ++ closed: false, ++ creating: false, ++ observed_web_messages: 0, ++ web_message_observer_active: false, ++ attached_window: None, ++ child: HWND::default(), ++ bounds: RECT::default(), ++ visible: false, ++ controller: None, ++ webview: None, ++ pending: Document::Url(url.into(), false), ++ inline: false, ++ })); ++ if state.borrow().policy.is_none() { ++ report_error(&state, "The browser URL is not permitted", -1); ++ } ++ Self { state } ++ } ++ ++ pub(crate) fn attached_window(&self) -> Option { ++ self.state.borrow().attached_window ++ } ++ ++ pub(crate) fn update( ++ &mut self, ++ window: WindowId, ++ parent: windows::Win32::Foundation::HWND, ++ rect: Rect, ++ clip: Rect, ++ dpi: f64, ++ visible: bool, ++ ) { ++ let parent = HWND(parent.0); ++ // Keep the WebView's full document size while the outer HWND clips it ++ // to the widget's visible area (scrolling, portals and covered edges). ++ let pixel = |v: f64| (v * dpi).round().clamp(i32::MIN as f64, i32::MAX as f64) as i32; ++ let visible = visible && clip.size.x > 0.0 && clip.size.y > 0.0; ++ let bounds = RECT { ++ left: pixel(rect.pos.x - clip.pos.x), ++ top: pixel(rect.pos.y - clip.pos.y), ++ right: pixel(rect.pos.x - clip.pos.x + rect.size.x), ++ bottom: pixel(rect.pos.y - clip.pos.y + rect.size.y), ++ }; ++ let (mut child, old_window, controller, creating, closed) = { ++ let mut state = self.state.borrow_mut(); ++ let old = state.attached_window; ++ state.attached_window = Some(window); ++ state.bounds = bounds; ++ state.visible = visible; ++ ( ++ state.child, ++ old, ++ state.controller.clone(), ++ state.creating, ++ state.closed, ++ ) ++ }; ++ if closed { ++ return; ++ } ++ unsafe { ++ if child.is_invalid() { ++ let result = CreateWindowExW( ++ WINDOW_EX_STYLE::default(), ++ w!("STATIC"), ++ w!(""), ++ WS_CHILD | WS_CLIPCHILDREN | WS_CLIPSIBLINGS, ++ 0, ++ 0, ++ 0, ++ 0, ++ Some(parent), ++ None, ++ GetModuleHandleW(None).ok().map(Into::into), ++ None, ++ ); ++ match result { ++ Ok(hwnd) => { ++ child = hwnd; ++ self.state.borrow_mut().child = hwnd; ++ } ++ Err(error) => { ++ report_error( ++ &self.state, ++ "Cannot attach the embedded browser", ++ error.code().0, ++ ); ++ return; ++ } ++ } ++ } else if old_window != Some(window) { ++ if let Err(error) = SetParent(child, Some(parent)) { ++ report_error( ++ &self.state, ++ "Cannot reattach the embedded browser", ++ error.code().0, ++ ); ++ return; ++ } ++ } ++ if let Err(error) = SetWindowPos( ++ child, ++ None, ++ pixel(clip.pos.x), ++ pixel(clip.pos.y), ++ pixel(clip.size.x).max(0), ++ pixel(clip.size.y).max(0), ++ SWP_NOACTIVATE | SWP_NOZORDER, ++ ) { ++ report_error( ++ &self.state, ++ "Cannot resize the embedded browser", ++ error.code().0, ++ ); ++ } ++ let _ = ShowWindow(child, if visible { SW_SHOWNA } else { SW_HIDE }); ++ if let Some(controller) = controller { ++ if let Err(error) = controller ++ .SetBounds(bounds) ++ .and_then(|_| controller.SetIsVisible(visible)) ++ .and_then(|_| controller.NotifyParentWindowPositionChanged()) ++ { ++ report_error( ++ &self.state, ++ "Cannot update the embedded browser", ++ error.code().0, ++ ); ++ } ++ } else if !creating { ++ self.state.borrow_mut().creating = true; ++ if let Err(error) = start_environment(&self.state, child) { ++ report_error( ++ &self.state, ++ "WebView2 could not start; install the Microsoft Edge WebView2 Runtime", ++ error.code().0, ++ ); ++ } ++ } ++ } ++ } ++ ++ pub(crate) fn detach(&mut self) { ++ let (child, controller) = { ++ let mut state = self.state.borrow_mut(); ++ state.visible = false; ++ // Keep ownership tied to the window so its destruction also closes ++ // detached views. The next update may reparent the hidden child. ++ (state.child, state.controller.clone()) ++ }; ++ unsafe { ++ if let Some(controller) = controller { ++ let _ = controller.SetIsVisible(false); ++ } ++ if !child.is_invalid() { ++ let _ = ShowWindow(child, SW_HIDE); ++ } ++ } ++ } ++ ++ pub(crate) fn set_url(&mut self, url: &str, replace: bool) { ++ let accepted = { ++ let mut state = self.state.borrow_mut(); ++ if state.auth { ++ !state.auth_finished ++ && state ++ .policy ++ .as_ref() ++ .is_some_and(|p| matches!(p.decide(url, true), NavigationDecision::Allow)) ++ } else if let Ok(policy) = BrowserPolicy::reader(url, state.navigable) { ++ state.policy = Some(policy); ++ true ++ } else { ++ false ++ } ++ }; ++ if !accepted { ++ report_blocked(&self.state); ++ return; ++ } ++ { ++ let mut state = self.state.borrow_mut(); ++ state.inline = false; ++ state.pending = Document::Url(url.into(), replace); ++ } ++ load_pending(&self.state); ++ } ++ ++ pub(crate) fn set_html(&mut self, html: &str, base_url: &str) { ++ if self.state.borrow().auth { ++ report_blocked(&self.state); ++ return; ++ } ++ // NavigateToString keeps an opaque about:blank origin. A base element ++ // resolves relative resources but never grants a host origin or IPC. ++ if html.len() > 2 * 1024 * 1024 { ++ report_error(&self.state, "Embedded HTML exceeds the 2 MiB limit", -1); ++ return; ++ } ++ let html = if base_url.is_empty() || base_url == "about:blank" { ++ html.to_owned() ++ } else if public_resource(base_url) { ++ format!("{}", html_attribute(base_url), html) ++ } else { ++ report_blocked(&self.state); ++ return; ++ }; ++ { ++ let mut state = self.state.borrow_mut(); ++ state.inline = true; ++ state.pending = Document::Html(html); ++ } ++ load_pending(&self.state); ++ } ++ ++ pub(crate) fn eval_js(&self, js: &str) { ++ let state = self.state.borrow(); ++ if state.auth || state.closed || js.len() > 1024 * 1024 { ++ return; ++ } ++ let webview = state.webview.clone(); ++ drop(state); ++ if let Some(webview) = webview { ++ if let Err(error) = unsafe { execute(&webview, js) } { ++ report_error( ++ &self.state, ++ "The embedded browser script failed", ++ error.code().0, ++ ); ++ } ++ } ++ } ++ ++ pub(crate) fn history_go(&self, delta: i32) { ++ // The host's Back/Forward control is allowed in auth, but arbitrary ++ // script evaluation remains forbidden. Every traversal is rechecked. ++ let (auth, finished, webview) = { ++ let s = self.state.borrow(); ++ (s.auth, s.closed || s.auth_finished, s.webview.clone()) ++ }; ++ if finished { ++ return; ++ } ++ if auth { ++ if let Some(webview) = webview { ++ let result = unsafe { ++ match delta { ++ -1 => webview.GoBack(), ++ 1 => webview.GoForward(), ++ _ => return, ++ } ++ }; ++ if let Err(error) = result { ++ report_error( ++ &self.state, ++ "Browser history is unavailable", ++ error.code().0, ++ ); ++ } ++ } ++ } else { ++ self.eval_js(&format!("history.go({delta})")); ++ } ++ } ++ ++ /// Host-owned native acceptance instrumentation. It is unavailable on ++ /// authentication views and writes only to the native caller's paths. ++ pub(crate) fn inspect( ++ &self, ++ result_path: String, ++ snapshot_path: Option, ++ scroll_y: Option, ++ ) { ++ let (webview, allowed) = { ++ let s = self.state.borrow(); ++ let width = s.bounds.right.saturating_sub(s.bounds.left); ++ let height = s.bounds.bottom.saturating_sub(s.bounds.top); ++ ( ++ s.webview.clone(), ++ !s.auth && !s.closed && width > 0 && height > 0 && width <= 2048 && height <= 2048, ++ ) ++ }; ++ if !allowed { ++ let _ = std::fs::write( ++ result_path, ++ r#"{"error":"Browser inspection is unavailable or exceeds 2048px"}"#, ++ ); ++ return; ++ } ++ let Some(webview) = webview else { ++ let _ = std::fs::write(result_path, r#"{"error":"Browser is not ready"}"#); ++ return; ++ }; ++ // Read the actual native policy. The chrome.webview namespace remains ++ // present when messaging is disabled; namespace presence is not a grant. ++ let settings = unsafe { ++ (|| -> Result<(bool, bool)> { ++ let settings = webview.Settings()?; ++ let mut messages = BOOL::default(); ++ let mut objects = BOOL::default(); ++ settings.IsWebMessageEnabled(&mut messages)?; ++ settings.AreHostObjectsAllowed(&mut objects)?; ++ Ok((messages.as_bool(), objects.as_bool())) ++ })() ++ }; ++ let Ok((web_message_enabled, host_objects_allowed)) = settings else { ++ 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});")) ++ .unwrap_or_default(); ++ let script = wide(&format!( ++ r#"{scroll}({{ ++ readyState:document.readyState,title:document.title.slice(0,512), ++ scrollY:window.scrollY,scrollHeight:document.documentElement.scrollHeight, ++ viewportHeight:window.innerHeight,viewportWidth:window.innerWidth, ++ hostBridgePresent:typeof window.octos!=='undefined'||typeof window.octos_native!=='undefined', ++ webMessageEnabled:{web_message_enabled},hostObjectsAllowed:{host_objects_allowed}, ++ webMessageObserverActive:{web_message_observer_active},observedWebMessages:{observed_web_messages} ++ }})"# ++ )); ++ let weak = Rc::downgrade(&self.state); ++ let capture_view = webview.clone(); ++ let error_path = result_path.clone(); ++ let result = unsafe { ++ webview.ExecuteScript( ++ PCWSTR(script.as_ptr()), ++ &ExecuteScriptCompletedHandler::create(Box::new(move |result, metadata| { ++ let Some(state) = weak.upgrade() else { ++ return Ok(()); ++ }; ++ if state.borrow().closed { ++ return Ok(()); ++ } ++ if result.is_err() || metadata.len() > 16 * 1024 { ++ let _ = std::fs::write( ++ &result_path, ++ r#"{"error":"Browser inspection failed"}"#, ++ ); ++ return Ok(()); ++ } ++ if let Some(path) = snapshot_path { ++ if let Err(error) = ++ capture(&capture_view, path, result_path.clone(), metadata) ++ { ++ let _ = std::fs::write( ++ result_path, ++ format!( ++ "{{\"error\":\"Browser capture failed ({})\"}}", ++ error.code().0 ++ ), ++ ); ++ } ++ } else { ++ let _ = std::fs::write(result_path, metadata); ++ } ++ Ok(()) ++ })), ++ ) ++ }; ++ if result.is_err() { ++ let _ = std::fs::write(error_path, r#"{"error":"Browser inspection failed"}"#); ++ } ++ } ++ ++ pub(crate) fn cleanup(&mut self) { ++ let (controller, child, cancel) = { ++ let mut state = self.state.borrow_mut(); ++ if state.closed { ++ return; ++ } ++ state.closed = true; ++ state.webview.take(); ++ let cancel = state.auth && !state.auth_finished; ++ state.auth_finished = true; ++ let child = std::mem::take(&mut state.child); ++ (state.controller.take(), child, cancel) ++ }; ++ if cancel { ++ auth_event(&self.state, "cancelled", String::new()); ++ } ++ unsafe { ++ if let Some(controller) = controller { ++ close_controller(&controller); ++ } ++ if !child.is_invalid() { ++ let _ = DestroyWindow(child); ++ } ++ } ++ } ++} ++ ++impl Drop for WindowsSystemBrowser { ++ fn drop(&mut self) { ++ self.cleanup(); ++ } ++} ++ ++unsafe fn close_controller(controller: &ICoreWebView2Controller) { ++ // InPrivate never persists cookies/storage. Delete the fresh profile as ++ // well, so its on-disk bookkeeping does not accumulate across app cards. ++ if let Ok(profile) = controller ++ .CoreWebView2() ++ .and_then(|view| view.cast::()) ++ .and_then(|view| view.Profile()) ++ .and_then(|profile| profile.cast::()) ++ { ++ let _ = profile.Delete(); ++ } ++ let _ = controller.Close(); ++} ++ ++unsafe fn start_environment(state: &Shared, child: HWND) -> Result<()> { ++ let weak = Rc::downgrade(state); ++ let options: ICoreWebView2EnvironmentOptions = CoreWebView2EnvironmentOptions::default().into(); ++ // Never attach an OS account or share a browser profile with another app ++ // card. Controllers use a fresh InPrivate profile below, including auth. ++ options.SetAllowSingleSignOnUsingOSPrimaryAccount(false)?; ++ let folder = std::env::var_os("LOCALAPPDATA") ++ .ok_or_else(|| webview2_windows::core::Error::from(E_FAIL))?; ++ let folder = std::path::PathBuf::from(folder) ++ .join("Makepad") ++ .join("WebView2"); ++ let folder = wide(&folder.to_string_lossy()); ++ CreateCoreWebView2EnvironmentWithOptions( ++ PCWSTR::null(), ++ PCWSTR(folder.as_ptr()), ++ &options, ++ &CreateCoreWebView2EnvironmentCompletedHandler::create(Box::new( ++ move |result, environment| { ++ let Some(state) = weak.upgrade() else { ++ return Ok(()); ++ }; ++ if state.borrow().closed { ++ return Ok(()); ++ } ++ let result = result ++ .and_then(|_| { ++ environment.ok_or_else(|| webview2_windows::core::Error::from(E_FAIL)) ++ }) ++ .and_then(|environment| create_controller(&state, &environment, child)); ++ if let Err(error) = result { ++ report_error( ++ &state, ++ "WebView2 is unavailable or does not support isolated browser profiles", ++ error.code().0, ++ ); ++ } ++ Ok(()) ++ }, ++ )), ++ ) ++} ++ ++unsafe fn create_controller( ++ state: &Shared, ++ environment: &ICoreWebView2Environment, ++ child: HWND, ++) -> Result<()> { ++ let version: ICoreWebView2Environment10 = environment.cast()?; ++ let options = version.CreateCoreWebView2ControllerOptions()?; ++ options.SetIsInPrivateModeEnabled(true)?; ++ let profile = wide(&format!( ++ "makepad-{}-{}", ++ std::process::id(), ++ NEXT_PROFILE.fetch_add(1, Ordering::Relaxed) ++ )); ++ options.SetProfileName(PCWSTR(profile.as_ptr()))?; ++ let weak = Rc::downgrade(state); ++ let environment = environment.clone(); ++ version.CreateCoreWebView2ControllerWithOptions( ++ child, ++ &options, ++ &CreateCoreWebView2ControllerCompletedHandler::create(Box::new( ++ move |result, controller| { ++ let Some(state) = weak.upgrade() else { ++ if let Some(controller) = controller { ++ close_controller(&controller); ++ } ++ return Ok(()); ++ }; ++ if state.borrow().closed { ++ if let Some(controller) = controller { ++ close_controller(&controller); ++ } ++ return Ok(()); ++ } ++ let result = result ++ .and_then(|_| { ++ controller.ok_or_else(|| webview2_windows::core::Error::from(E_FAIL)) ++ }) ++ .and_then(|controller| { ++ let webview = controller.CoreWebView2()?; ++ if let Err(error) = configure(&state, &environment, &webview) { ++ close_controller(&controller); ++ return Err(error); ++ } ++ let (bounds, visible) = { ++ let s = state.borrow(); ++ (s.bounds, s.visible) ++ }; ++ if let Err(error) = controller ++ .SetBounds(bounds) ++ .and_then(|_| controller.SetIsVisible(visible)) ++ { ++ close_controller(&controller); ++ return Err(error); ++ } ++ { ++ let mut s = state.borrow_mut(); ++ s.controller = Some(controller); ++ s.webview = Some(webview); ++ } ++ load_pending(&state); ++ Ok(()) ++ }); ++ if let Err(error) = result { ++ report_error( ++ &state, ++ "Cannot initialize the embedded browser safely", ++ error.code().0, ++ ); ++ } ++ Ok(()) ++ }, ++ )), ++ ) ++} ++ ++unsafe fn configure( ++ state: &Shared, ++ environment: &ICoreWebView2Environment, ++ webview: &ICoreWebView2, ++) -> Result<()> { ++ let settings = webview.Settings()?; ++ settings.SetAreHostObjectsAllowed(false)?; ++ settings.SetIsWebMessageEnabled(false)?; ++ // 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)?; ++ settings.SetIsStatusBarEnabled(false)?; ++ settings ++ .cast::()? ++ .SetAreBrowserAcceleratorKeysEnabled(false)?; ++ let autofill: ICoreWebView2Settings4 = settings.cast()?; ++ autofill.SetIsPasswordAutosaveEnabled(false)?; ++ autofill.SetIsGeneralAutofillEnabled(false)?; ++ let mut token = 0; ++ for main_frame in [true, false] { ++ let weak = Rc::downgrade(state); ++ let handler = NavigationStartingEventHandler::create(Box::new(move |_, args| { ++ let Some(args) = args else { ++ return Ok(()); ++ }; ++ let Some(state) = weak.upgrade() else { ++ return args.SetCancel(true); ++ }; ++ let url = read_string(|p| args.Uri(p))?; ++ let decision = { ++ let s = state.borrow(); ++ if s.closed || s.auth_finished { ++ NavigationDecision::Block ++ } else if s.inline && url == "about:blank" && main_frame { ++ NavigationDecision::Allow ++ } else { ++ s.policy ++ .as_ref() ++ .map(|p| p.decide(&url, main_frame)) ++ .unwrap_or(NavigationDecision::Block) ++ } ++ }; ++ match decision { ++ NavigationDecision::Allow => { ++ if main_frame { ++ navigation(&state, &url, true, None); ++ } ++ } ++ NavigationDecision::Block => { ++ args.SetCancel(true)?; ++ report_blocked(&state); ++ } ++ NavigationDecision::Callback => { ++ args.SetCancel(true)?; ++ let emit = { ++ let mut s = state.borrow_mut(); ++ let emit = !s.auth_finished; ++ s.auth_finished = true; ++ emit ++ }; ++ if emit { ++ auth_event(&state, "callback", url); ++ } ++ } ++ } ++ Ok(()) ++ })); ++ if main_frame { ++ webview.add_NavigationStarting(&handler, &mut token)?; ++ } else { ++ webview.add_FrameNavigationStarting(&handler, &mut token)?; ++ } ++ } ++ let weak = Rc::downgrade(state); ++ webview.add_NavigationCompleted( ++ &NavigationCompletedEventHandler::create(Box::new(move |sender, args| { ++ let (Some(state), Some(sender), Some(args)) = (weak.upgrade(), sender, args) else { ++ return Ok(()); ++ }; ++ if state.borrow().closed || state.borrow().auth_finished { ++ return Ok(()); ++ } ++ let mut success = BOOL::default(); ++ args.IsSuccess(&mut success)?; ++ if success.as_bool() { ++ let url = read_string(|p| sender.Source(p))?; ++ let title = read_string(|p| sender.DocumentTitle(p))?; ++ navigation(&state, &url, false, Some(title)); ++ } else { ++ let mut status = COREWEBVIEW2_WEB_ERROR_STATUS::default(); ++ args.WebErrorStatus(&mut status)?; ++ // Cancelled policy navigations are reported by NavigationStarting. ++ if status != COREWEBVIEW2_WEB_ERROR_STATUS_OPERATION_CANCELED { ++ report_error(&state, "The embedded page failed to load", status.0); ++ } ++ } ++ Ok(()) ++ })), ++ &mut token, ++ )?; ++ let weak = Rc::downgrade(state); ++ webview.add_DocumentTitleChanged( ++ &DocumentTitleChangedEventHandler::create(Box::new(move |sender, _| { ++ let (Some(state), Some(sender)) = (weak.upgrade(), sender) else { ++ return Ok(()); ++ }; ++ let url = read_string(|p| sender.Source(p))?; ++ let title = read_string(|p| sender.DocumentTitle(p))?; ++ navigation(&state, &url, false, Some(title)); ++ Ok(()) ++ })), ++ &mut token, ++ )?; ++ webview.add_NewWindowRequested( ++ &NewWindowRequestedEventHandler::create(Box::new(|_, args| { ++ if let Some(args) = args { ++ args.SetHandled(true)?; ++ } ++ Ok(()) ++ })), ++ &mut token, ++ )?; ++ webview.add_PermissionRequested( ++ &PermissionRequestedEventHandler::create(Box::new(|_, args| { ++ if let Some(args) = args { ++ args.SetState(COREWEBVIEW2_PERMISSION_STATE_DENY)?; ++ if let Ok(args) = args.cast::() { ++ args.SetSavesInProfile(false)?; ++ } ++ } ++ Ok(()) ++ })), ++ &mut token, ++ )?; ++ // Require APIs that can block native downloads and client-certificate UI; ++ // an old runtime fails closed instead of silently weakening the boundary. ++ webview.cast::()?.add_DownloadStarting( ++ &DownloadStartingEventHandler::create(Box::new(|_, args| { ++ if let Some(args) = args { ++ args.SetCancel(true)?; ++ args.SetHandled(true)?; ++ } ++ Ok(()) ++ })), ++ &mut token, ++ )?; ++ webview ++ .cast::()? ++ .add_ClientCertificateRequested( ++ &ClientCertificateRequestedEventHandler::create(Box::new(|_, args| { ++ if let Some(args) = args { ++ args.SetCancel(true)?; ++ args.SetHandled(true)?; ++ } ++ Ok(()) ++ })), ++ &mut token, ++ )?; ++ webview ++ .cast::()? ++ .add_BasicAuthenticationRequested( ++ &BasicAuthenticationRequestedEventHandler::create(Box::new(|_, args| { ++ if let Some(args) = args { ++ args.SetCancel(true)?; ++ } ++ Ok(()) ++ })), ++ &mut token, ++ )?; ++ webview ++ .cast::()? ++ .add_LaunchingExternalUriScheme( ++ &LaunchingExternalUriSchemeEventHandler::create(Box::new(|_, args| { ++ if let Some(args) = args { ++ args.SetCancel(true)?; ++ } ++ Ok(()) ++ })), ++ &mut token, ++ )?; ++ webview ++ .cast::()? ++ .add_ServerCertificateErrorDetected( ++ &ServerCertificateErrorDetectedEventHandler::create(Box::new(|_, args| { ++ if let Some(args) = args { ++ args.SetAction(COREWEBVIEW2_SERVER_CERTIFICATE_ERROR_ACTION_CANCEL)?; ++ } ++ Ok(()) ++ })), ++ &mut token, ++ )?; ++ webview.cast::()?.add_SaveAsUIShowing( ++ &SaveAsUIShowingEventHandler::create(Box::new(|_, args| { ++ if let Some(args) = args { ++ args.SetCancel(true)?; ++ args.SetSuppressDefaultDialog(true)?; ++ } ++ Ok(()) ++ })), ++ &mut token, ++ )?; ++ // Screen sharing is a separate WebView2 event, not PermissionRequested. ++ // It bubbles from subframes as well; never show the native screen picker. ++ webview ++ .cast::()? ++ .add_ScreenCaptureStarting( ++ &ScreenCaptureStartingEventHandler::create(Box::new(|_, args| { ++ if let Some(args) = args { ++ args.SetCancel(true)?; ++ args.SetHandled(true)?; ++ } ++ Ok(()) ++ })), ++ &mut token, ++ )?; ++ let weak = Rc::downgrade(state); ++ webview.add_ProcessFailed( ++ &ProcessFailedEventHandler::create(Box::new(move |_, _| { ++ if let Some(state) = weak.upgrade() { ++ report_error(&state, "The embedded browser process stopped", -1); ++ } ++ Ok(()) ++ })), ++ &mut token, ++ )?; ++ // Filter fetches as well as top-level navigation. Auth is confined to its ++ // declared origin; readers may fetch public HTTPS resources, never local ++ // files or literal private-network endpoints. DNS rebinding is not solved ++ // by URL policy alone and is not advertised as an OS network sandbox. ++ webview.AddWebResourceRequestedFilter(w!("*"), COREWEBVIEW2_WEB_RESOURCE_CONTEXT_ALL)?; ++ let weak = Rc::downgrade(state); ++ let environment = environment.clone(); ++ webview.add_WebResourceRequested( ++ &WebResourceRequestedEventHandler::create(Box::new(move |_, args| { ++ let Some(args) = args else { ++ return Ok(()); ++ }; ++ let url = read_string(|p| args.Request()?.Uri(p))?; ++ let allowed = weak.upgrade().is_some_and(|state| { ++ let s = state.borrow(); ++ !s.closed ++ && !s.auth_finished ++ && if s.auth { ++ s.policy.as_ref().is_some_and(|p| { ++ matches!(p.decide(&url, false), NavigationDecision::Allow) ++ }) ++ } else { ++ public_resource(&url) ++ || s.policy.as_ref().is_some_and(|p| { ++ matches!(p.decide(&url, false), NavigationDecision::Allow) ++ }) ++ || url == "about:blank" ++ || url.starts_with("data:") ++ || url.starts_with("blob:") ++ } ++ }); ++ if !allowed { ++ let response = environment.CreateWebResourceResponse( ++ None::<&IStream>, ++ 403, ++ w!("Blocked"), ++ w!("Content-Type: text/plain\r\n"), ++ )?; ++ args.SetResponse(&response)?; ++ } ++ Ok(()) ++ })), ++ &mut token, ++ )?; ++ Ok(()) ++} ++ ++fn load_pending(state: &Shared) { ++ let (webview, document, allowed) = { ++ let s = state.borrow(); ++ ( ++ s.webview.clone(), ++ s.pending.clone(), ++ !s.closed && !s.auth_finished && (s.policy.is_some() || s.inline), ++ ) ++ }; ++ if !allowed { ++ return; ++ } ++ let Some(webview) = webview else { ++ return; ++ }; ++ let result = unsafe { ++ match document { ++ Document::Url(url, true) => { ++ execute(&webview, &format!("location.replace({})", js_string(&url))) ++ } ++ Document::Url(url, false) => { ++ let url = wide(&url); ++ webview.Navigate(PCWSTR(url.as_ptr())) ++ } ++ Document::Html(html) => { ++ let html = wide(&html); ++ webview.NavigateToString(PCWSTR(html.as_ptr())) ++ } ++ } ++ }; ++ if let Err(error) = result { ++ report_error(state, "Cannot load the embedded page", error.code().0); ++ } ++} ++ ++unsafe fn capture( ++ webview: &ICoreWebView2, ++ path: String, ++ result_path: String, ++ metadata: String, ++) -> Result<()> { ++ let stream = CreateStreamOnHGlobal(Default::default(), true)?; ++ let output = stream.clone(); ++ webview.CapturePreview( ++ COREWEBVIEW2_CAPTURE_PREVIEW_IMAGE_FORMAT_PNG, ++ &stream, ++ &CapturePreviewCompletedHandler::create(Box::new(move |result| { ++ let result = result.and_then(|_| { ++ let mut stat = STATSTG::default(); ++ output.Stat(&mut stat, STATFLAG_NONAME)?; ++ if stat.cbSize == 0 || stat.cbSize > 32 * 1024 * 1024 { ++ return Err(webview2_windows::core::Error::from(E_FAIL)); ++ } ++ output.Seek(0, STREAM_SEEK_SET, None)?; ++ let mut bytes = vec![0u8; stat.cbSize as usize]; ++ let mut read = 0; ++ output ++ .Read( ++ bytes.as_mut_ptr().cast(), ++ bytes.len() as u32, ++ Some(&mut read), ++ ) ++ .ok()?; ++ if read as usize != bytes.len() { ++ return Err(webview2_windows::core::Error::from(E_FAIL)); ++ } ++ std::fs::write(path, bytes) ++ .map_err(|_| webview2_windows::core::Error::from(E_FAIL))?; ++ Ok(()) ++ }); ++ // The metadata file is the completion marker: the PNG is fully ++ // written first, so acceptance never races a partial snapshot. ++ let text = if result.is_ok() { ++ metadata ++ } else { ++ r#"{"error":"Browser capture failed"}"#.into() ++ }; ++ let _ = std::fs::write(result_path, text); ++ Ok(()) ++ })), ++ ) ++} ++ ++unsafe fn execute(webview: &ICoreWebView2, js: &str) -> Result<()> { ++ let js = wide(js); ++ webview.ExecuteScript( ++ PCWSTR(js.as_ptr()), ++ &ExecuteScriptCompletedHandler::create(Box::new(|result, _| result)), ++ ) ++} ++ ++unsafe fn read_string(read: impl FnOnce(*mut PWSTR) -> Result<()>) -> Result { ++ let mut raw = PWSTR::null(); ++ let result = read(&mut raw); ++ let value = if result.is_ok() { ++ raw.to_string().unwrap_or_default() ++ } else { ++ String::new() ++ }; ++ CoTaskMemFree(Some(raw.0.cast())); ++ result.map(|_| value) ++} ++ ++fn public_resource(url: &str) -> bool { ++ BrowserPolicy::reader("about:blank", true) ++ .is_ok_and(|p| matches!(p.decide(url, false), NavigationDecision::Allow)) ++} ++ ++fn wide(value: &str) -> Vec { ++ value.encode_utf16().chain(Some(0)).collect() ++} ++fn js_string(value: &str) -> String { ++ let mut result = String::from("\""); ++ for unit in value.encode_utf16() { ++ use std::fmt::Write; ++ let _ = write!(result, "\\u{unit:04x}"); ++ } ++ result.push('"'); ++ result ++} ++fn html_attribute(value: &str) -> String { ++ value ++ .replace('&', "&") ++ .replace('"', """) ++ .replace('<', "<") ++ .replace('>', ">") ++} ++fn auth_event(state: &Shared, kind: &str, url: String) { ++ Cx::post_action(NativeAuthBrowserEvent { ++ browser_id: state.borrow().id.0, ++ kind: kind.into(), ++ url, ++ }); ++} ++fn report_blocked(state: &Shared) { ++ let s = state.borrow(); ++ if s.closed || s.auth_finished { ++ return; ++ } ++ if s.auth { ++ drop(s); ++ auth_event(state, "blocked", String::new()); ++ } else { ++ Cx::post_action(NativeSystemBrowserPolicyBlocked { ++ browser_id: s.id.0, ++ description: "Navigation is not permitted in this embedded browser".into(), ++ }); ++ } ++} ++fn report_error(state: &Shared, description: &str, code: i32) { ++ let s = state.borrow(); ++ if s.closed || s.auth_finished { ++ return; ++ } ++ if s.auth { ++ drop(s); ++ auth_event(state, "error", String::new()); ++ } else { ++ Cx::post_action(NativeSystemBrowserPageError { ++ browser_id: s.id.0, ++ code, ++ description: description.into(), ++ url: String::new(), ++ }); ++ } ++} ++fn navigation(state: &Shared, url: &str, loading: bool, title: Option) { ++ let s = state.borrow(); ++ if s.auth || s.closed { ++ return; ++ } ++ Cx::post_action(NativeSystemBrowserNavigation { ++ browser_id: s.id.0, ++ url: url.chars().take(16384).collect(), ++ title: title.unwrap_or_default().chars().take(2048).collect(), ++ loading, ++ error: None, ++ }); ++} +diff --git a/platform/src/system_browser.rs b/platform/src/system_browser.rs +new file mode 100644 +index 000000000..1fe6ef745 +--- /dev/null ++++ b/platform/src/system_browser.rs +@@ -0,0 +1,123 @@ ++//! Navigation boundaries shared by the desktop embedded-browser adapters. ++//! The caller has already checked the app's initial URL grant. Native page ++//! navigations must not turn that grant into file, device or external-app access. ++use std::net::IpAddr; ++use url::{Host, Url}; ++ ++#[derive(Clone, Copy, Debug, PartialEq, Eq)] ++pub enum NavigationDecision { ++ Allow, ++ Block, ++ /// Intercept before loading; deliver only through the host auth event. ++ Callback, ++} ++ ++#[derive(Clone)] ++pub struct BrowserPolicy { ++ initial: Url, ++ navigable: bool, ++ callback: Option, ++} ++ ++impl BrowserPolicy { ++ pub fn reader(initial: &str, navigable: bool) -> Result { ++ let initial = address(initial).ok_or("Invalid embedded page address")?; ++ if !page_scheme(&initial) && initial.as_str() != "about:blank" { ++ return Err("Embedded pages require an HTTP or HTTPS address"); ++ } ++ Ok(Self { initial, navigable, callback: None }) ++ } ++ ++ pub fn auth(initial: &str, callback: &str) -> Result { ++ let initial = address(initial).filter(auth_scheme) ++ .ok_or("Invalid authentication origin")?; ++ let callback = address(callback).filter(auth_scheme) ++ .filter(|url| url.query().is_none() && url.fragment().is_none()) ++ .ok_or("Invalid authentication callback")?; ++ Ok(Self { initial, navigable: false, callback: Some(callback) }) ++ } ++ ++ pub fn is_auth(&self) -> bool { self.callback.is_some() } ++ ++ pub fn decide(&self, requested: &str, main_frame: bool) -> NavigationDecision { ++ let Some(requested) = address(requested) else { return NavigationDecision::Block; }; ++ if let Some(callback) = &self.callback { ++ if requested.origin() == callback.origin() && requested.path() == callback.path() { ++ return if main_frame && requested.fragment().is_none() { ++ NavigationDecision::Callback ++ } else { NavigationDecision::Block }; ++ } ++ return if auth_scheme(&requested) && requested.origin() == self.initial.origin() { ++ NavigationDecision::Allow ++ } else { NavigationDecision::Block }; ++ } ++ // Fragment changes stay inside the same document; redirects or links ++ // to another path/query do not inherit a read-only document grant. ++ if same_document(&self.initial, &requested) { ++ return NavigationDecision::Allow; ++ } ++ if self.navigable && public_https(&requested) { ++ NavigationDecision::Allow ++ } else { NavigationDecision::Block } ++ } ++} ++ ++fn address(value: &str) -> Option { ++ if value.is_empty() || value.len() > 16_384 ++ || value.chars().any(|c| c.is_control() || c.is_whitespace() || c == '\\') ++ { return None; } ++ let parsed = Url::parse(value).ok()?; ++ if !parsed.username().is_empty() || parsed.password().is_some() { return None; } ++ Some(parsed) ++} ++ ++fn page_scheme(url: &Url) -> bool { ++ matches!(url.scheme(), "http" | "https") && url.host().is_some() ++} ++ ++fn auth_scheme(url: &Url) -> bool { ++ url.host().is_some() && (url.scheme() == "https" ++ || (url.scheme() == "http" && url.host_str() == Some("127.0.0.1"))) ++} ++ ++fn same_document(initial: &Url, requested: &Url) -> bool { ++ let mut initial = initial.clone(); ++ let mut requested = requested.clone(); ++ initial.set_fragment(None); ++ requested.set_fragment(None); ++ initial == requested ++} ++ ++fn public_https(url: &Url) -> bool { ++ if url.scheme() != "https" { return false; } ++ match url.host() { ++ Some(Host::Ipv4(ip)) => public_ip(ip.into()), ++ Some(Host::Ipv6(ip)) => public_ip(ip.into()), ++ Some(Host::Domain(host)) => { ++ let host = host.trim_end_matches('.'); ++ host.contains('.') && !["localhost", "internal", "local", "lan", "home.arpa"] ++ .iter().any(|name| host == *name || host.strip_suffix(name).is_some_and(|prefix| prefix.ends_with('.'))) ++ } ++ None => false, ++ } ++} ++ ++fn public_ip(ip: IpAddr) -> bool { ++ match ip { ++ IpAddr::V4(ip) => { ++ let [a, b, ..] = ip.octets(); ++ !(ip.is_loopback() || ip.is_private() || ip.is_link_local() ++ || ip.is_unspecified() || ip.is_broadcast() || ip.is_documentation() ++ || ip.is_multicast() || a == 0 || a >= 240 ++ || (a == 100 && (64..128).contains(&b))) ++ } ++ IpAddr::V6(ip) => { ++ if let Some(v4) = ip.to_ipv4_mapped() { return public_ip(v4.into()); } ++ let parts = ip.segments(); ++ !(ip.is_loopback() || ip.is_unspecified() || ip.is_multicast() ++ || (parts[0] & 0xfe00) == 0xfc00 || (parts[0] & 0xffc0) == 0xfe80 ++ || (parts[0] & 0xffc0) == 0xfec0 ++ || (parts[0] == 0x2001 && parts[1] == 0xdb8)) ++ } ++ } ++} +diff --git a/widgets/src/web_reader.rs b/widgets/src/web_reader.rs +index 44240521e..97589c92a 100644 +--- a/widgets/src/web_reader.rs ++++ b/widgets/src/web_reader.rs +@@ -1,7 +1,7 @@ + //! A web page inside a script app: the platform's own web view (WKWebView, +-//! Android's WebView) glued to this widget's rect, driven from script. +-//! Linux/Windows have no SystemBrowser adapter: open returns false with an +-//! explicit error rather than claiming success for an invisible browser. ++//! Android's WebView, WebKitGTK on Linux X11/XWayland, or WebView2 on Windows) ++//! glued to this widget's rect, driven from script. Missing native runtimes ++//! and native Wayland report a page error rather than opening another browser. + //! + //! ```text + //! reader := WebReader{width: Fill height: Fill on_error: || show(ui.reader.error())} +@@ -174,7 +174,7 @@ pub fn open(&mut self, cx: &mut Cx, url: &str) -> bool { + } + + fn open_on_platform(&mut self, cx: &mut Cx, url: &str, platform: &str) -> bool { +- if !matches!(platform, "macos" | "ios" | "android") { ++ if !matches!(platform, "macos" | "ios" | "android" | "linux" | "windows") { + self.report(cx, "Embedded web pages are unavailable on this platform; this host has no native WebReader adapter".into()); + return false; + } +@@ -421,6 +421,12 @@ fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + self.auth_changed(cx); + } + } ++ if let Some(blocked) = action.downcast_ref::() { ++ if blocked.browser_id == self.browser_id().0.get_value() && self.open && !self.auth_mode { ++ // A denied iframe, popup or permission leaves the permitted page usable. ++ self.report(cx, blocked.description.clone()); ++ } ++ } + if let Some(err) = action.downcast_ref::() { + if err.browser_id == self.browser_id().0.get_value() && self.open { + if self.auth_mode { +@@ -466,7 +472,7 @@ fn unsupported_platform_never_starts_browser_lifecycle() { + let value = vm.eval(script! { use mod.widgets.* WebReader {} }); + WebReader::script_from_value(vm, value) + }); +- for platform in ["linux", "windows", "freebsd", "unknown"] { ++ for platform in ["freebsd", "tvos", "unknown"] { + assert!(!reader.open_on_platform(&mut cx, "https://example.test/", platform)); + assert!(!reader.open); + assert!(!reader.spawned); diff --git a/tools/runtime-patches/makepad-host-response-json.patch b/tools/runtime-patches/makepad-host-response-json.patch new file mode 100644 index 00000000..5cb7555d --- /dev/null +++ b/tools/runtime-patches/makepad-host-response-json.patch @@ -0,0 +1,133 @@ +diff --git a/widgets/src/splash_host.rs b/widgets/src/splash_host.rs +index 257498cfc..15d9c8a76 100644 +--- a/widgets/src/splash_host.rs ++++ b/widgets/src/splash_host.rs +@@ -6,7 +6,7 @@ + //! This module is the ONE doorway for all of that: an isolate's script asks, + //! the embedding host (the launcher) decides and answers. No policy lives + //! here; an undrained or unrecognized request simply never resolves, and a +-//! host that says no answers `{ok: false}`. ++//! host that says no answers `{is_ok: false}`. + //! + //! Registered in isolates as `mod.host` (bound as `host` by the Splash + //! prefix, like `fs`): +@@ -218,10 +218,12 @@ pub fn splash_host_respond( + }; + let obj = vm.bx.heap.new_object(); + let trap = vm.bx.threads.cur().trap.pass(); ++ // Intern the native keys so the complete envelope can round-trip as JSON ++ // even when the app never names a field in its source. + // `is_ok`, never `ok`: `ok` is a script keyword and unusable as a field. +- vm.bx.heap.set_value(obj, id!(is_ok).into(), ok.into(), trap); +- vm.bx.heap.set_value(obj, id!(data).into(), data, trap); +- vm.bx.heap.set_value(obj, id!(error).into(), error, trap); ++ vm.bx.heap.set_value(obj, id_lut!(is_ok).into(), ok.into(), trap); ++ vm.bx.heap.set_value(obj, id_lut!(data).into(), data, trap); ++ vm.bx.heap.set_value(obj, id_lut!(error).into(), error, trap); + if !crate::splash_policy::may_run(heap_key) { + delivered = false; + return; +@@ -336,9 +338,9 @@ pub fn script_mod(vm: &mut ScriptVm) { + let obj = vm.bx.heap.new_object(); + let error = vm.bx.heap.new_string_from_str(&reason); + let trap = vm.bx.threads.cur().trap.pass(); +- vm.bx.heap.set_value(obj, id!(is_ok).into(), false.into(), trap); +- vm.bx.heap.set_value(obj, id!(data).into(), NIL, trap); +- vm.bx.heap.set_value(obj, id!(error).into(), error, trap); ++ vm.bx.heap.set_value(obj, id_lut!(is_ok).into(), false.into(), trap); ++ vm.bx.heap.set_value(obj, id_lut!(data).into(), NIL, trap); ++ vm.bx.heap.set_value(obj, id_lut!(error).into(), error, trap); + vm.call(callback.as_object().into(), &[obj.into()]); + (0.0f64).into() + } +@@ -413,6 +415,90 @@ pub fn script_mod(vm: &mut ScriptVm) { + mod tests { + use super::*; + ++ #[test] ++ fn raw_service_envelopes_round_trip_without_script_field_lookups() { ++ const CHILD: &str = "MAKEPAD_TEST_RAW_HOST_ENVELOPE"; ++ if std::env::var_os(CHILD).is_none() { ++ for mode in ["success", "error", "refusal"] { ++ let output = std::process::Command::new(std::env::current_exe().unwrap()) ++ .args(["--exact", "splash_host::tests::raw_service_envelopes_round_trip_without_script_field_lookups", "--nocapture"]) ++ .env(CHILD, mode).output().unwrap(); ++ assert!( ++ output.status.success(), ++ "{mode}: {}\n{}", ++ String::from_utf8_lossy(&output.stdout), ++ String::from_utf8_lossy(&output.stderr) ++ ); ++ } ++ return; ++ } ++ use crate::*; ++ let mode = std::env::var(CHILD).unwrap(); ++ let mut cx = Cx::new(Box::new(|_, _| {})); ++ let root = cx.with_vm(|vm| { ++ crate::script_mod(vm); ++ let value = script_eval!(vm, { use mod.widgets.* View { card := Splash{} } }); ++ WidgetRef::script_from_value(vm, value) ++ }); ++ assert!( ++ [id!(is_ok), id!(data), id!(error)] ++ .iter() ++ .any(|id| id.as_string(|text| text.is_none())), ++ "fresh-process control: the script must not have interned every native envelope key" ++ ); ++ let card = root.splash(&mut cx, ids!(card)); ++ card.set_host_caps(&mut cx, vec!["fixture".into()]); ++ card.set_policy(&mut cx, Some(vec![]), Some(100_000)); ++ let service = if mode == "refusal" { ++ "ungranted.read" ++ } else { ++ "fixture.read" ++ }; ++ // In particular, do not reference any envelope field in this source: ++ // field access would intern its spelling and mask the native bug. ++ card.set_text( ++ &mut cx, ++ &format!( ++ r#" ++ host.request("{service}", {{}}, fn(reply) {{ ++ host.request("fixture.forward", reply, nil) ++ }}) ++ Label{{text:"raw response fixture"}} ++ "# ++ ), ++ ); ++ let heap = card.isolate_heap_key(&mut cx).unwrap(); ++ if mode != "refusal" { ++ let initial = take_splash_host_requests_for(&[heap]).pop().unwrap(); ++ let result = if mode == "success" { ++ Ok(r#"{"unknown_payload_key": [-2, "value"]}"#) ++ } else { ++ Err("synthetic failure") ++ }; ++ splash_host_respond(&mut cx, heap, initial.req_id, result); ++ } ++ let forwarded = take_splash_host_requests_for(&[heap]); ++ assert_eq!( ++ forwarded.len(), ++ 1, ++ "a raw envelope remains bounded, serializable JSON" ++ ); ++ assert_eq!(forwarded[0].service, "fixture.forward"); ++ let value: serde_json::Value = serde_json::from_str(&forwarded[0].args_json).unwrap(); ++ assert_eq!(value["is_ok"], mode == "success"); ++ if mode == "success" { ++ assert_eq!( ++ value["data"]["unknown_payload_key"], ++ serde_json::json!([-2, "value"]) ++ ); ++ assert!(value["error"].is_null()); ++ } else { ++ assert!(value["data"].is_null()); ++ assert!(value["error"].as_str().is_some_and(|text| !text.is_empty())); ++ } ++ gc_bridge(&[heap]); ++ } ++ + #[test] + fn delayed_background_callbacks_keep_nested_requests_nonprompting() { + use crate::*; diff --git a/tools/test-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() diff --git a/tools/test-wasm-phone.py b/tools/test-wasm-phone.py new file mode 100644 index 00000000..41ec4dfb --- /dev/null +++ b/tools/test-wasm-phone.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""Run the signed Host API Lab on an assigned Android phone in a fresh package. + +Synthetic Wasm input only. No account, model, permission grant or production Home change. +The native host uses the same app-tool/host-service code as the desktop lab. +""" +import argparse +import hashlib +import io +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import tarfile +import time + +ROOT = Path(__file__).resolve().parents[1] + + +def main(): + os.umask(0o077) + parser = argparse.ArgumentParser(description=__doc__) + for key in ('adb', 'apk', 'aapt2', 'hub', 'out'): + parser.add_argument('--' + key, type=Path, required=True) + parser.add_argument('--serial', help='Assigned phone only; omitted from evidence') + parser.add_argument('--package', default='dev.makepad.octosense.hostapilab.wasm1') + args = parser.parse_args() + if not re.fullmatch(r'dev\.makepad\.octosense\.hostapilab(?:\.[a-z0-9_]+)*', args.package): + parser.error('Use a separate hostapilab package, never Home') + source_revision = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT, text=True).strip() + if subprocess.check_output(['git', 'status', '--porcelain', '--untracked-files=normal'], cwd=ROOT): + parser.error('Build and run the fixture from a clean committed source tree') + runtime_tree = json.loads((ROOT / 'runtime-patches.lock.json').read_text())['makepad']['tree'] + args.out.mkdir(mode=0o700, parents=True, exist_ok=False) + base = [str(args.adb)] + (['-s', args.serial] if args.serial else []) + + def adb(*values, data=None, checked=True): + return subprocess.run(base + list(values), input=data, check=checked, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=45) + + devices = adb('devices').stdout.decode().splitlines()[1:] + if not args.serial and sum(line.endswith('\tdevice') for line in devices) != 1: + parser.error('Select the assigned phone with --serial') + model = adb('shell', 'getprop', 'ro.product.model').stdout.decode().strip() + android = adb('shell', 'getprop', 'ro.build.version.release').stdout.decode().strip() + present = adb('shell', 'pm', 'path', args.package, checked=False) + if present.returncode not in (0, 1) or present.stderr.strip() or present.stdout.strip(): + parser.error('Expected a fresh, absent package; select a new suffix') + badging = subprocess.run([str(args.aapt2), 'dump', 'badging', str(args.apk)], + check=True, capture_output=True).stdout.decode() + if f"name='{args.package}'" not in badging or 'application-debuggable' not in badging: + parser.error('APK must match the separate debuggable package') + bundle = args.out / 'bundle' + shutil.copytree(ROOT / 'tools/fixtures/wasm-phone-lab/bundle', bundle) + listing = json.loads((bundle / 'listing.json').read_text()) + listing['platforms'] = ['android'] + (bundle / 'listing.json').write_text(json.dumps(listing, indent=2) + '\n') + subprocess.run([str(args.hub), 'stamp', str(bundle)], check=True, capture_output=True) + archive = io.BytesIO() + with tarfile.open(fileobj=archive, mode='w') as tar: + for path in sorted(bundle.rglob('*')): + if path.is_symlink() or not (path.is_file() or path.is_dir()): + raise ValueError('Fixture accepts regular files and directories only') + tar.add(path, arcname='wasm-phone-lab/bundle/' + str(path.relative_to(bundle)), recursive=False) + installed = False + receipt = {'schema': 1, 'platform': 'android', 'model': model, 'android': android, + 'package': args.package, 'apk_sha256': hashlib.sha256(args.apk.read_bytes()).hexdigest(), + 'personal_accounts_used': False, 'model_started': False, 'source_revision': source_revision, 'runtime_tree': runtime_tree, 'wasm_service_sha256': hashlib.sha256((ROOT/'crates/shell/src/wasm_service.rs').read_bytes()).hexdigest(), 'checks': {}} + try: + adb('install', str(args.apk)) + installed = True + adb('shell', 'run-as', args.package, 'mkdir', '-p', 'files') + adb('shell', '-T', 'run-as', args.package, 'tar', '-xf', '-', '-C', 'files', data=archive.getvalue()) + adb('shell', 'am', 'start', '-n', args.package + '/.MakepadApp') + deadline = time.monotonic() + 90 + while time.monotonic() < deadline: + result = adb('exec-out', 'run-as', args.package, 'cat', + 'files/wasm-phone-lab/receipt.json', checked=False) + if result.returncode == 0: + try: + native = json.loads(result.stdout) + break + except ValueError: + pass + time.sleep(.25) + else: + raise AssertionError('Native phone host did not produce a receipt') + (args.out / 'native-result.json').write_text(json.dumps(native, indent=2) + '\n') + checks = receipt['checks'] + + def require(name, condition): + checks[name] = bool(condition) + if not condition: + raise AssertionError(name) + + require('compiled_source_identity_matches', native['source_revision'] == source_revision and native['source_dirty'] is False) + require('compiled_runtime_identity_matches', native['runtime_tree'] == runtime_tree) + require('signed_native_android_install', native['signed_install'] is True and native['platform'] == 'android') + require('real_script_tool_completed', 'Ok' in native['tool_result']) + data = native['tool_result']['Ok'] + require('bound_device_account', data['account'] == 'device') + raw_results = data['results'] + require('raw_host_envelopes_forwarded', all(set(r) == {'is_ok','data','error'} for r in raw_results.values()) and set(data['functions']) == {'is_ok','data','error'}) + results = {name: {'is_ok': r['is_ok'], 'text': r['data']['text'] if r['is_ok'] else '', 'error': r['error']} for name, r in raw_results.items()} + require('successful_guest_call', results['remember']['is_ok'] is True) + require('success_does_not_retain_input', results['after_success']['is_ok'] is True and results['after_success']['text'] == '') + require('guest_error_received', results['guest_error']['is_ok'] is False and 'failed' in results['guest_error']['error']) + require('guest_error_does_not_retain_input', results['after_guest_error']['is_ok'] is True and results['after_guest_error']['text'] == '') + require('guest_trap_received', results['trap']['is_ok'] is False and 'unreachable' in results['trap']['error'].lower()) + require('fresh_instance_after_trap', results['after_trap']['is_ok'] is True and results['after_trap']['text'] == '') + require('guest_deadline_received', results['deadline']['is_ok'] is False and 'deadline' in results['deadline']['error'].lower()) + require('fresh_instance_after_deadline', results['after_deadline']['is_ok'] is True and results['after_deadline']['text'] == '') + require('functions_answered', data['functions']['is_ok'] is True) + modules = data['functions']['data']['modules'] + require('fresh_instance_policy_reported', len(modules) == 1 and modules[0]['instance_policy'] == 'fresh-per-call') + require('eight_distinct_guest_invocations', modules[0]['invocations'] == 8) + require('no_native_approval_sheet', native['host_sheet_visible'] is False) + require('cross_account_refused', 'account_scope' in native['refusals']['wrong_account']) + require('undeclared_tool_refused', bool(native['refusals']['undeclared_tool'])) + require('invalid_arguments_refused', bool(native['refusals']['invalid_arguments'])) + require('closed_tool_endpoint_refused', bool(native['closed_app'])) + receipt['passed'] = True + receipt['not_verified'] = native['not_verified'] + except Exception as error: + receipt['passed'] = False + # Subprocess exceptions include their command (possibly --serial and + # private build paths). Only our fixed assertion messages are public. + receipt['error'] = str(error) if isinstance(error, AssertionError) else type(error).__name__ + raise + finally: + if installed: + try: + receipt['owned_package_stopped'] = adb( + 'shell', 'am', 'force-stop', args.package, checked=False).returncode == 0 + except subprocess.SubprocessError: + receipt['owned_package_stopped'] = False + (args.out / 'receipt.json').write_text(json.dumps(receipt, indent=2) + '\n') + print(json.dumps({'passed': receipt['passed'], 'checks': len(receipt['checks']), + 'owned_package_stopped': receipt['owned_package_stopped']})) + + +if __name__ == '__main__': + main() diff --git a/tools/test_backend_failure_diagnostics.py b/tools/test_backend_failure_diagnostics.py new file mode 100644 index 00000000..a1e560e1 --- /dev/null +++ b/tools/test_backend_failure_diagnostics.py @@ -0,0 +1,51 @@ +"""Failure receipts preserve geometry, never fixture text or raw log content.""" +import importlib.util +import json +from pathlib import Path +import sys +import tempfile +import unittest +from types import SimpleNamespace + +TOOLS = Path(__file__).resolve().parent +sys.path.insert(0, str(TOOLS / 'connected-e2e')) +spec = importlib.util.spec_from_file_location('backend_login_diagnostics', + TOOLS / 'connected-e2e/backend_login.py') +backend = importlib.util.module_from_spec(spec) +spec.loader.exec_module(backend) + + +class FailureDiagnosticsTests(unittest.TestCase): + def report(self, rows, windows): + with tempfile.TemporaryDirectory() as directory: + log = Path(directory) / 'native.log' + log.write_text('ScriptError: synthetic-sensitive-content https://example.test/?code=fictional') + native = SimpleNamespace(log_path=log, child=SimpleNamespace(poll=lambda: None), + call=lambda route: {'s': rows} if route == 'snap' else {'w': windows}) + return backend.failure_geometry(native) + + def test_reports_clipping_without_text_or_logs(self): + report = self.report([ + {'ty': 'Label', 'i': 'status', 'r': [24, 801, 382, 0], + 't': 'synthetic-sensitive-content'}, + {'ty': 'TextInput', 'i': 'synthetic-sensitive-content', 'r': [24, 40, 120, 40], + 't': 'synthetic-sensitive-content'}, + ], [{'sz': [430, 720], 'title': 'synthetic-sensitive-content'}]) + self.assertEqual(report['window_sizes'], [[430, 720]]) + self.assertEqual(report['rows'][0]['rect'], [24, 801, 382, 0]) + self.assertEqual(report['log_markers']['ScriptError'], 1) + output = json.dumps(report) + for secret in ('synthetic-sensitive-content', 'https:', 'code='): + self.assertNotIn(secret, output) + + def test_non_numeric_or_unbounded_geometry_cannot_be_published(self): + report = self.report([{'ty': 'https://example.test', 'i': 'private', + 'r': ['private', 1, 2, 3]}] * 100, + [{'sz': [float('nan'), 500]}] * 10) + self.assertEqual(len(report['rows']), 80) + self.assertEqual(report['rows'][0], {'kind': 'other', 'id': None, 'rect': None}) + self.assertEqual(report['window_sizes'], [None] * 4) + + +if __name__ == '__main__': + unittest.main() 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() diff --git a/tools/test_ci_local.py b/tools/test_ci_local.py index 75f18501..724a8e7e 100644 --- a/tools/test_ci_local.py +++ b/tools/test_ci_local.py @@ -24,6 +24,20 @@ def load(name, path): class WorkflowReader(unittest.TestCase): + def test_release_token_is_scoped_to_github_cli_steps(self): + workflow = ci.load_workflow("release-desktop.yml") + self.assertNotIn("GH_TOKEN", workflow.get("env", {})) + release = workflow["jobs"]["release"] + self.assertNotIn("GH_TOKEN", release.get("env", {})) + authenticated = [] + for step in release["steps"]: + if "GH_TOKEN" in step.get("env", {}): + authenticated.append(step["name"]) + self.assertNotIn("release-scan.py", step.get("run", "")) + if "release-scan.py" in step.get("run", ""): + self.assertNotIn("GH_TOKEN", step.get("env", {})) + self.assertEqual(authenticated, ["HEAD is the tag's commit", "Attach to the tag's draft release"]) + def test_reads_what_workflows_use(self): text = ( "name: X\n" @@ -224,6 +238,23 @@ def test_the_comment_names_the_commit(self): self.assertIn("| phone.yml / home | Test | PASS |", body) +class NativeRemoteEvidence(unittest.TestCase): + def test_unrelated_changes_do_not_query_native_runner(self): + with patch.object(merge, "gh_json") as query: + self.assertEqual(merge.remote_evidence_problems("b" * 40, ["docs/x.md"]), ([], [])) + query.assert_not_called() + + def test_only_success_on_the_exact_head_satisfies_native_gate(self): + head = "b" * 40 + changed = ["tools/browser-smoke.py"] + good = {"headSha": head, "status": "completed", "conclusion": "success"} + for runs in ([], [dict(good, headSha="a" * 40)], [dict(good, status="in_progress")], [dict(good, conclusion="skipped")], [dict(good, conclusion="failure")]): + with self.subTest(runs=runs), patch.object(merge, "gh_json", return_value=runs): + self.assertTrue(merge.remote_evidence_problems(head, changed)[0]) + with patch.object(merge, "gh_json", return_value=[good]): + self.assertEqual(merge.remote_evidence_problems(head, changed), ([], ["embedded-browser.yml"])) + + class LinuxHostPlan(unittest.TestCase): def where(self, workflows, linux_host): return {(w, j): where for w, j, _, where in ci.plan_jobs(workflows, linux_host)} diff --git a/tools/test_release_scan.py b/tools/test_release_scan.py index ee23b3c1..626f7eed 100644 --- a/tools/test_release_scan.py +++ b/tools/test_release_scan.py @@ -6,6 +6,7 @@ import tarfile import tempfile import unittest +from unittest import mock import zipfile ROOT = Path(__file__).resolve().parents[1] @@ -20,7 +21,93 @@ def findings(data, extra=()): return out +def appimage_bytes(runtime=b"", filesystem=b"", after=b"", elf_class=2, order="little", section_last=False): + """Synthetic type-2 ELF; extraction is mocked, never an executable fixture.""" + data = bytearray(4096) + data[:11] = b"\x7fELF" + bytes((elf_class, 1 if order == "little" else 2, 1, 0)) + b"AI\x02" + header, shoff_pos, word, ehsize_pos, entsize_pos, count_pos, size, section_pos = ( + (52, 32, 4, 40, 46, 48, 40, 16) if elf_class == 1 else (64, 40, 8, 52, 58, 60, 64, 24)) + + def put(pos, width, value): + data[pos:pos + width] = value.to_bytes(width, order) + + table = 2048 if section_last else 4096 - size + for pos, width, value in ((shoff_pos, word, table), (ehsize_pos, 2, header), + (entsize_pos, 2, size), (count_pos, 2, 1)): + put(pos, width, value) + if section_last: + put(table + section_pos, word, 3072) + put(table + section_pos + word, word, 1024) + data[header:header + len(runtime)] = runtime + superblock = bytearray(96) + superblock[:4] = b"hsqs" + superblock[28:32] = b"\x04\x00\x00\x00" + superblock[40:48] = (96 + len(filesystem)).to_bytes(8, "little") + return bytes(data + superblock + filesystem + after) + + class PatternTests(unittest.TestCase): + def test_only_proven_octoscode_placeholder_seams_are_ignored(self): + themes = b"/home/user/src/octoscode-app/home/user/src/octosSystemSolarizedSlateClaudeCodexLight" + folder = b"/home/user/codeUse this folderb1_br_use" + for known in (themes, folder): + self.assertEqual(findings(known), []) + for leak in (known + b"\x00/home/user/private", b"/home/user/private\x00" + known, + known.replace(b"/home/user/", b"/home/someone/", 1), + known[:-1] + b"!"): + self.assertTrue(findings(leak), leak) + self.assertTrue(findings(known, extra=[r"/home/user/"])) + for standalone in (b"/home/user/src/octoscode-app", b"/home/user/src/octos", b"/home/user/code"): + self.assertTrue(findings(standalone), standalone) + + def test_exact_public_design_assets_are_not_private_build_data(self): + fixture = ROOT / "tools/fixtures/release-scan" + for name in ("workspace.card", "browser.json", "pairing.json"): + data = (fixture / name).read_bytes() + self.assertEqual(findings(data), [], name) + self.assertEqual(findings(b"binary prefix\x00" + data + b"\x00binary suffix"), [], name) + + def test_public_asset_exceptions_are_whole_asset_and_match_scoped(self): + fixture = ROOT / "tools/fixtures/release-scan" + for name in ("workspace.card", "browser.json", "pairing.json"): + data = (fixture / name).read_bytes() + matches = [m.group(0) for label, regex in scan.compile_patterns(identity=False) + for m in regex.finditer(data) + if label in ("Linux home directory", "private IPv4 address")] + self.assertTrue(matches, name) + for literal in matches: + # An identical private-looking value outside the verified asset + # remains a finding, regardless of which occurrence comes first. + for combined in (literal + b"private\x00" + data, + data + b"\x00" + literal + b"private"): + self.assertTrue(findings(combined), name) + self.assertTrue(findings(literal + b"private"), name) + self.assertTrue(findings(literal), "a standalone identical literal must fail: " + name) + self.assertTrue(findings(b"!" + data[1:]), "one altered byte must invalidate " + name) + self.assertTrue(findings(data[:-1]), "truncation must invalidate " + name) + self.assertTrue(findings(data, extra=[scan.re.escape(matches[0].decode())]), name) + identity = [("the scanning account's name", scan.re.compile(scan.re.escape(matches[0])))] + out = [] + scan.scan_bytes(data, "public", identity, out) + self.assertTrue(out, "public assets must never suppress identity checks") + + def test_missing_or_malformed_public_asset_metadata_fails_closed(self): + with tempfile.TemporaryDirectory() as directory: + script = Path(directory) / "release-scan.py" + metadata = script.with_name("release-scan-public-assets.json") + with mock.patch.object(scan, "__file__", str(script)): + try: + scan.public_asset_matches.cache_clear() + with self.assertRaises(FileNotFoundError): + findings(b"/home/user/private") + for invalid in ("{", '{"assets":[{"size":1,"sha256":"invalid","matches":[]}]}'): + metadata.write_text(invalid) + scan.public_asset_matches.cache_clear() + with self.assertRaises(ValueError): + findings(b"/home/user/private") + finally: + scan.public_asset_matches.cache_clear() + def test_private_paths_and_hosts_are_found(self): for leak in (b"/Users/someone/src/app.rs", b"C:\\Users\\someone\\.cargo", b"c:/Users/someone/x", "C:\\Users\\".encode("utf-16-le"), b"/home/someone/.cargo/registry", b"built on studio.local", b"my-mac.local", b"http://studio.local/api", @@ -46,6 +133,38 @@ 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_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) @@ -109,36 +228,93 @@ def test_a_link_target_is_read(self): self.assertIn("OctoSense.app/leak (link target): Linux home directory", out) def test_an_appimage_is_read_except_its_compressed_filesystem(self): - def appimage(path, runtime=b"", filesystem=b"", after=b"", file=b"app", link=b"usr/bin/app"): - # A shell script standing in for the runtime: it answers the two - # options the scan uses. The filesystem starts at 4096 with a - # squashfs superblock whose bytes_used (offset 40) covers it. - script = (b"#!/bin/sh\n# " + runtime + b"\ncase \"$1\" in\n" - b" --appimage-offset) echo 4096 ;;\n" - b" --appimage-extract) mkdir -p squashfs-root/usr/bin && printf '%s' '" + file - + b"' > squashfs-root/usr/bin/app && ln -s '" + link + b"' squashfs-root/AppRun ;;\n" - b"esac\nexit 0\n") - squashfs = b"hsqs" + bytes(36) + (48 + len(filesystem)).to_bytes(8, "little") + filesystem - path.write_bytes(script.ljust(4096, b"\n") + squashfs + after) - with tempfile.TemporaryDirectory() as temp: path = Path(temp) / "octosense_0.1.0_x86_64.AppImage" - # zstd's literals back to back: "/home/" then the next run's "io/". - appimage(path, filesystem=b"\x28\xb5\x2f\xfd/home/io/\x91\x07") - code, out = self.run_scan(path) - self.assertEqual(code, 0, out) - for kind, where in ((dict(runtime=b"/Users/someone/runtime"), "AppImage (runtime)"), - (dict(after=b"/home/someone/x"), "AppImage (after its filesystem)"), - (dict(file=b"/Users/someone/src"), "AppImage/usr/bin/app"), - (dict(link=b"/home/someone/app"), "AppImage/AppRun (link target)")): - appimage(path, **kind) + def extract(command, **kwargs): + self.assertEqual(command[0], "/trusted/unsquashfs") + self.assertEqual(command[command.index("-offset") + 1], "4096") + self.assertIn("-strict-errors", command) + self.assertTrue(kwargs["check"]) + self.assertNotIn("GH_TOKEN", kwargs["env"]) + self.assertNotIn("RELEASE_SCAN_EXTRA", kwargs["env"]) + self.assertEqual(Path(command[-1]).read_bytes(), path.read_bytes()) + self.assertEqual(Path(command[-1]).stat().st_mode & 0o111, 0) + root = Path(command[command.index("-dest") + 1]) + (root / "usr/bin").mkdir(parents=True) + (root / "usr/bin/app").write_bytes(file) + (root / "AppRun").symlink_to(link.decode()) + + with mock.patch.object(scan.shutil, "which", return_value="/trusted/unsquashfs"), \ + mock.patch.object(scan.subprocess, "run", side_effect=extract): + file, link = b"app", b"usr/bin/app" + # Compressed literal runs are not logical file content. + path.write_bytes(appimage_bytes(filesystem=b"\x28\xb5\x2f\xfd/home/io/\x91\x07")) code, out = self.run_scan(path) - self.assertEqual(code, 1, kind) - self.assertIn(where, out) - path.write_bytes(b"#!/bin/sh\necho 0\n") - code, out = self.run_scan(path) - self.assertEqual(code, 1) - self.assertIn("no squashfs filesystem", out) + self.assertEqual(code, 0, out) + for kind, where in ((dict(runtime=b"/Users/someone/runtime"), "AppImage (runtime)"), + (dict(after=b"/home/someone/x"), "AppImage (after its filesystem)"), + (dict(file=b"/Users/someone/src"), "AppImage/usr/bin/app"), + (dict(link=b"/home/someone/app"), "AppImage/AppRun (link target)")): + file, link = kind.get("file", b"app"), kind.get("link", b"usr/bin/app") + path.write_bytes(appimage_bytes(**{k: v for k, v in kind.items() if k not in ("file", "link")})) + code, out = self.run_scan(path) + self.assertEqual(code, 1, kind) + self.assertIn(where, out) + + def test_appimage_never_executes_an_artifact_to_find_its_offset(self): + with tempfile.TemporaryDirectory() as temp: + path, marker = Path(temp) / "malicious.AppImage", Path(temp) / "exfiltrated" + # The old scanner chmod'ed and ran this with inherited credentials. + path.write_text('#!/bin/sh\nprintf "%s" "$GH_TOKEN" > "$ATTACK_MARKER"\necho 4096\n') + path.chmod(0o755) + with mock.patch.dict(scan.os.environ, {"GH_TOKEN": "synthetic-token", "ATTACK_MARKER": str(marker)}), \ + mock.patch.object(scan.subprocess, "run", wraps=scan.subprocess.run) as run: + code, out = self.run_scan(path) + self.assertEqual(code, 1, out) + self.assertIn("not a type-2 ELF AppImage", out) + run.assert_not_called() + self.assertFalse(marker.exists(), "no executable artifact may inherit the publication token") + + def test_appimage_elf_classes_byte_orders_and_section_layouts(self): + for elf_class in (1, 2): + for order in ("little", "big"): + for section_last in (False, True): + with self.subTest(elf_class=elf_class, order=order, section_last=section_last): + self.assertEqual(scan.appimage_filesystem(appimage_bytes( + elf_class=elf_class, order=order, section_last=section_last)), (4096, 4192)) + + def test_appimage_malformed_headers_fail_before_any_extractor(self): + good = appimage_bytes() + cases = [good[:15], good[:60], good[:4090], good[:4191]] + for pos, data in ((8, b"AI\x01"), (4, b"\x03"), (5, b"\x00"), (6, b"\x00"), + (52, bytes(2)), (58, bytes(2)), (60, bytes(2)), (40, (2**64-1).to_bytes(8, "little")), + (4096, b"nope"), (4096 + 28, bytes(4)), + (4096 + 40, (95).to_bytes(8, "little")), + (4096 + 40, (2**64-1).to_bytes(8, "little"))): + cases.append(good[:pos] + data + good[pos + len(data):]) + with tempfile.TemporaryDirectory() as temp, mock.patch.object(scan.subprocess, "run") as run: + path = Path(temp) / "bad.AppImage" + for data in cases: + path.write_bytes(data) + code, out = self.run_scan(path) + self.assertEqual(code, 1, out) + run.assert_not_called() + + def test_appimage_missing_failed_or_empty_extractor_fails_closed(self): + with tempfile.TemporaryDirectory() as temp: + path = Path(temp) / "test.AppImage" + path.write_bytes(appimage_bytes()) + with mock.patch.object(scan.shutil, "which", return_value=None): + code, out = self.run_scan(path) + self.assertEqual(code, 1, out) + self.assertIn("unsquashfs missing", out) + with mock.patch.object(scan.shutil, "which", return_value="/trusted/unsquashfs"): + with mock.patch.object(scan.subprocess, "run", side_effect=scan.subprocess.CalledProcessError(1, "unsquashfs")): + self.assertEqual(self.run_scan(path)[0], 1) + with mock.patch.object(scan.subprocess, "run"): + code, out = self.run_scan(path) + self.assertEqual(code, 1, out) + self.assertIn("did not produce a filesystem directory", out) def test_a_clean_artifact_passes(self): with tempfile.TemporaryDirectory() as temp: