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/Cargo.lock b/Cargo.lock index f4c8f3a7..731da9d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8016,6 +8016,19 @@ dependencies = [ "serde_json", ] +[[package]] +name = "octosense-host-api-smoke" +version = "0.1.0" +dependencies = [ + "makepad-app-module", + "makepad-widgets", + "octosense-app-hub", + "octosense-app-policy", + "octosense-appstore", + "octosense-shell", + "serde_json", +] + [[package]] name = "octosense-kernel" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 68f2262e..32194898 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ members = [ "crates/oauth-service", "crates/markdown-editor", "crates/browser-smoke", + "crates/host-api-smoke", "crates/wasm-host", "apps/reference", "apps/news/host-service", diff --git a/crates/host-api-smoke/Cargo.toml b/crates/host-api-smoke/Cargo.toml new file mode 100644 index 00000000..6132e019 --- /dev/null +++ b/crates/host-api-smoke/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "octosense-host-api-smoke" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +# Android needs a library entry point. Reuse the exact desktop acceptance +# host; this package supplies no alternate API implementation or grant path. +[lib] +path = "../shell/examples/host-api-lab.rs" +test = false +bench = false +doctest = false + +[[bin]] +name = "octosense-host-api-smoke" +path = "../shell/examples/host-api-lab.rs" + +[features] +default = ["acceptance-fixtures"] +acceptance-fixtures = ["octosense-shell/acceptance-fixtures"] + +[dependencies] +makepad-widgets.workspace = true +makepad-app-module.workspace = true +octosense-appstore.workspace = true +octosense-app-hub.workspace = true +octosense-app-policy.workspace = true +octosense-shell.workspace = true +serde_json.workspace = true diff --git a/crates/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/examples/host-api-lab.rs b/crates/shell/examples/host-api-lab.rs index 577b4d8a..5d5dd4dd 100644 --- a/crates/shell/examples/host-api-lab.rs +++ b/crates/shell/examples/host-api-lab.rs @@ -61,11 +61,19 @@ struct App { impl MatchEvent for App { fn handle_startup(&mut self, cx: &mut Cx) { + #[cfg(not(target_os = "android"))] let arg = |name: &str| std::env::args().find_map(|a| a.strip_prefix(name).map(str::to_owned)); + #[cfg(not(target_os = "android"))] let root = PathBuf::from(arg("--app-data=").expect("Pass an isolated --app-data directory")); + #[cfg(not(target_os = "android"))] let bundle = PathBuf::from(arg("--bundle=").expect("Pass --bundle=")); + #[cfg(target_os = "android")] + let data = PathBuf::from(cx.get_data_dir().expect("Android private data directory")) + .join("host-api-lab"); + #[cfg(target_os = "android")] + let (root, bundle) = (data.join("apps"), data.join("bundle")); let preview = std::env::args().any(|a| a == "--preview"); octosense_appstore::set_data_root(root.clone()); let policy = if preview { @@ -117,9 +125,13 @@ impl MatchEvent for App { if preview { return; } - self.receipt = Some(PathBuf::from( + #[cfg(not(target_os = "android"))] + let receipt = PathBuf::from( arg("--receipt=").expect("Pass a new --receipt file"), - )); + ); + #[cfg(target_os = "android")] + let receipt = data.join("receipt.json"); + self.receipt = Some(receipt); assert!( !self.receipt.as_ref().unwrap().exists(), "Receipt must be a new file" @@ -206,7 +218,7 @@ impl AppMain for App { "signed_install":true,"bundle_digest":self.launch.as_ref().unwrap().manifest.integrity.bundle_blake3, "tool_result":reply,"refusals":self.preliminary,"closed_app":closed, "host_sheet_visible":self.sheet.borrow().is_some_and(|s| s.view.visible), - "not_verified":["model or peer relay consent", "physical OS permission approval", "camera capture", "Android runtime"]}); + "not_verified":["model or peer relay consent", "physical OS permission approval", "camera capture"]}); use std::io::Write; std::fs::OpenOptions::new() .write(true) diff --git a/tools/ci_local.py b/tools/ci_local.py index e0a3371d..23279605 100644 --- a/tools/ci_local.py +++ b/tools/ci_local.py @@ -71,6 +71,8 @@ # Workflows ci-local deliberately does not run, and why. --check-drift skips # them (an entry naming no workflow, or one ci-local runs, is drift). NOT_LOCAL = { + "platform-vault.yml": "actual Windows Credential Manager and backend browser acceptance need a Windows session; " + "compilation or another OS is not native account evidence", "embedded-browser.yml": "native Windows WebView2 acceptance needs a Windows desktop and installed engine; " "ci-local-merge requires a successful GitHub run on the exact PR head", "release-desktop.yml": "a release workflow (tag push or manual run): signed packages for three OSes, " diff --git a/tools/connected-e2e/README.md b/tools/connected-e2e/README.md index 2f88be0a..7f21fb08 100644 --- a/tools/connected-e2e/README.md +++ b/tools/connected-e2e/README.md @@ -144,3 +144,55 @@ copy. No provider write occurs. Normal Notes admission/provider and soak drivers remain the commands above. Android developer-backend login has its own isolated package and real-form procedure: [Android backend acceptance](ANDROID-BACKEND.md). It does not authorize Google embedded login. + +## Native backend browser acceptance (2026-10-08) + +`backend_login.py` uses a real host, browser and operating-system credential +vault. Only the backend HTTP server and its fictional users are synthetic; no +account or token is injected into the host. Linux and Windows use the supported +external-browser route with an ephemeral loopback callback. Embedded WebReader +acceptance is a separate test and does not establish backend sign-in. + +The [Linux receipt](evidence/linux-backend-native.json) records seven successful +behaviors: browser registration/sign-in and PKCE callback, protected identity, +rotated refresh recovery, native restart, logout, repeat login and isolation +between two signed apps. MiniBrowser/WebKitGTK 2.52.6 and native Secret Service +ran in an isolated display/session; all owned processes and fictional +connections were cleaned up. The receipt binds the actual binary and source +hashes. 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. +The vault job passed; the added Windows browser journey is pending execution. + +The acceptance host copies its own consent-sheet link into a private test +file, and the harness opens that exact URL in a fresh browser. **The OS default +browser link click is not tested.** Synthetic Makepad clicks cannot approve a +business write; these tests do not prove a physical press or OS-authenticated +approval. The disposable fictional account is not Google/GitHub provider +acceptance. Keep the entire run directory private: raw callback URLs and +fictional tokens are not suitable for public artifacts. Publish only reviewed +receipts. + +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/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/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..f0af6110 --- /dev/null +++ b/tools/fixtures/host-api-lab/ANDROID.md @@ -0,0 +1,37 @@ +# Host API Lab on Android + +English | [简体中文](ANDROID.zh-CN.md) + +The [receipt](evidence/android/receipt.json) records **14/14 checks on a OnePlus 6 running Android 15**. The [native result](evidence/android/native-result.json) comes from the actual phone process. A signed ordinary bundle calls its own Splash tool, which discovers a host API and reads Android's camera permission status through the production service. The fixture rejects cross-account calls, undeclared capabilities/tools, invalid arguments, uncompiled Rust functions, background permission prompts and calls after the tool endpoint closes. + +This proves the native app/tool/service path. It does not start a model or peer agent, capture a camera image, approve a permission, log in to a provider, or establish physical approval. The test package was stopped after completion. Normal Home and personal profiles were not changed. + +Use an assigned phone and the existing Makepad Android SDK/JDK. From the repository root, prepare the pinned runtime and build its packager: + +```sh +python3 tools/setup.py +cargo build --release --offline --manifest-path .sources/makepad/tools/cargo_makepad/Cargo.toml +``` + +Set `JAVA_HOME` and `ANDROID_HOME` to existing tool installations. Set `PACKAGER` to that built `cargo-makepad` executable and `MAKEPAD_ANDROID_SDK` to the existing Makepad Android SDK. Build a separate debuggable package; `octosense-host-api-smoke` uses the same Rust source as the desktop `host-api-lab` example: + +```sh +MAKEPAD_FORCE_DEBUGGABLE=1 "$PACKAGER" makepad android \ + --sdk-path="$MAKEPAD_ANDROID_SDK" --abi=aarch64 \ + --package-name=dev.makepad.octosense.hostapilab \ + --app-label='Host API Lab' \ + build -p octosense-host-api-smoke --release --locked --offline +``` + +Set `APK` to the aligned APK reported by the packager, `HUB` to the built compatible App Hub CLI, and choose a new `EVIDENCE` directory. Run the tested driver: + +```sh +python3 tools/test-host-api-android.py \ + --adb "$ANDROID_HOME/platform-tools/adb" \ + --aapt2 "$ANDROID_HOME/build-tools/35.0.0/aapt2" \ + --apk "$APK" --hub "$HUB" --out "$EVIDENCE" +``` + +The driver checks the APK's package and debuggable flag, refuses an already installed package, copies only the public synthetic bundle, stamps its Android listing, and collects the native result. The phone host creates ephemeral signing keys and admits the bundle through the normal signed Store path. No grants or credentials are injected. If multiple devices are attached, supply `--serial` for the assigned phone; serials are omitted from evidence. For another run, choose a fresh `.hostapilab.` package consistently in the build and driver's `--package` argument. + +The driver force-stops only its own package after success or failure. It leaves that isolated test package/data available for inspection. The published evidence contains no device serial, personal account, credential or local checkout path. The commands above were exercised using existing local tool/cache paths; they install no build tools. diff --git a/tools/fixtures/host-api-lab/ANDROID.zh-CN.md b/tools/fixtures/host-api-lab/ANDROID.zh-CN.md new file mode 100644 index 00000000..e8a0b928 --- /dev/null +++ b/tools/fixtures/host-api-lab/ANDROID.zh-CN.md @@ -0,0 +1,37 @@ +# Android 上的 Host API Lab + +[English](ANDROID.md) | 简体中文 + +[验收记录](evidence/android/receipt.json)确认:**OnePlus 6、Android 15,14 项检查全部通过**。[原生结果](evidence/android/native-result.json)来自实际手机进程。普通签名应用调用自己的 Splash 工具,通过生产环境宿主服务发现 API 并读取 Android 摄像头权限状态。测试同时验证跨账户调用、未声明的能力和工具、无效参数、未编译进宿主的 Rust 函数、后台权限弹窗以及工具关闭后的调用都会被拒绝。 + +这验证了原生应用、工具和宿主服务之间的执行链路。测试没有启动模型或 peer agent,没有拍照、批准权限、登录提供商,也不能证明物理输入审批。测试完成后只停止了自己的测试包,没有修改正常 Home 或个人资料。 + +使用获准测试的手机和已有的 Makepad Android SDK/JDK。在仓库根目录准备固定版本运行时及打包工具: + +```sh +python3 tools/setup.py +cargo build --release --offline --manifest-path .sources/makepad/tools/cargo_makepad/Cargo.toml +``` + +将 `JAVA_HOME`、`ANDROID_HOME` 指向已有工具,将 `PACKAGER` 指向生成的 `cargo-makepad`,将 `MAKEPAD_ANDROID_SDK` 指向已有 Makepad Android SDK。构建独立、可调试的测试包。`octosense-host-api-smoke` 与桌面 `host-api-lab` 示例共用 Rust 源码: + +```sh +MAKEPAD_FORCE_DEBUGGABLE=1 "$PACKAGER" makepad android \ + --sdk-path="$MAKEPAD_ANDROID_SDK" --abi=aarch64 \ + --package-name=dev.makepad.octosense.hostapilab \ + --app-label='Host API Lab' \ + build -p octosense-host-api-smoke --release --locked --offline +``` + +将 `APK` 指向打包器输出的已对齐 APK,`HUB` 指向已构建的兼容 App Hub CLI,并选择全新的 `EVIDENCE` 目录,然后运行已在手机上验证的驱动: + +```sh +python3 tools/test-host-api-android.py \ + --adb "$ANDROID_HOME/platform-tools/adb" \ + --aapt2 "$ANDROID_HOME/build-tools/35.0.0/aapt2" \ + --apk "$APK" --hub "$HUB" --out "$EVIDENCE" +``` + +驱动检查 APK 包名与可调试标记,拒绝覆盖已安装的包,只复制公开合成测试应用,为 Android listing 重新计算摘要并收集原生结果。手机宿主在内存中创建临时签名密钥,通过正常签名 Store 流程接纳应用,没有注入权限或凭据。多台设备连接时,用 `--serial` 指定获准设备;验收记录不包含设备序列号。再次测试时,构建和驱动的 `--package` 参数应统一使用新的 `.hostapilab.` 包名。 + +无论成功或失败,驱动最后只停止自己的测试包,保留独立测试包及其数据供检查。公开记录不含设备序列号、个人账户、凭据或本地源码路径。上述命令已使用已有工具和缓存实际运行,不会安装构建工具。 diff --git a/tools/fixtures/host-api-lab/README.md b/tools/fixtures/host-api-lab/README.md index 2e4bb682..9d65e7a3 100644 --- a/tools/fixtures/host-api-lab/README.md +++ b/tools/fixtures/host-api-lab/README.md @@ -2,6 +2,8 @@ English | [简体中文](README.zh-CN.md) +[Android reproduction and OnePlus 6 results](ANDROID.md): the same native host completed all 14 phone checks, without a model, account login or permission approval. + This development fixture demonstrates an app-owned Splash tool calling Rust already compiled into OctoSense. It reads the real macOS camera permission status, updates the same app UI, and returns a structured answer to its native diff --git a/tools/fixtures/host-api-lab/README.zh-CN.md b/tools/fixtures/host-api-lab/README.zh-CN.md index 1587cd91..9ee768b7 100644 --- a/tools/fixtures/host-api-lab/README.zh-CN.md +++ b/tools/fixtures/host-api-lab/README.zh-CN.md @@ -2,6 +2,8 @@ [English](README.md) | 简体中文 +[Android 复现步骤与 OnePlus 6 结果](ANDROID.zh-CN.md):同一原生宿主完成了全部 14 项手机检查,没有启动模型、登录账户或批准权限。 + 此开发测试示例展示:应用自己的 Splash 工具调用已编译进 OctoSense 的 Rust 服务,读取真实 macOS 相机权限状态,更新同一应用的界面,并把结构化 结果返回给原生调用方。它不会采集媒体或批准设备访问,不是 App Hub 投稿, diff --git a/tools/fixtures/host-api-lab/evidence/android/native-result.json b/tools/fixtures/host-api-lab/evidence/android/native-result.json new file mode 100644 index 00000000..ef247b02 --- /dev/null +++ b/tools/fixtures/host-api-lab/evidence/android/native-result.json @@ -0,0 +1,109 @@ +{ + "bundle_digest": "ce4a5b1ca73b733413a553e69cf0d710f04b3888c833504dec2878c9f67e8d6d", + "closed_app": "app_not_running: open this app before calling its script tools", + "host_sheet_visible": false, + "not_verified": [ + "model or peer relay consent", + "physical OS permission approval", + "camera capture" + ], + "platform": "android", + "proof": "signed app -> app_tool -> host.request -> native OS permission status", + "refusals": { + "invalid_arguments": "invalid_arguments: $: injected is not a declared field", + "undeclared_tool": "tool_not_declared: the running bundle does not implement this tool", + "wrong_account": "account_scope: app account changed" + }, + "schema": 1, + "signed_install": true, + "tool_result": { + "Ok": { + "account": "device", + "background_error": "camera.permission.request is unavailable to agents/background surfaces", + "background_prompt_allowed": false, + "calls": 1, + "camera": { + "app_consent": false, + "app_policy_granted": true, + "background_permission": false, + "capability": "camera", + "location_read_supported": false, + "os_permission": "not_determined", + "scope": "host_api_v1_device_access", + "supported": true + }, + "discovery": { + "authorization": "checked-on-call", + "configured": null, + "descriptor": { + "agent_access": "allowed", + "capability": "camera", + "input_schema": { + "additionalProperties": false, + "type": "object" + }, + "name": "camera.permission.status", + "output_schema": { + "properties": { + "app_consent": { + "type": "boolean" + }, + "app_policy_granted": { + "type": "boolean" + }, + "background_permission": { + "const": false + }, + "capability": { + "type": "string" + }, + "location_read_supported": { + "type": "boolean" + }, + "os_permission": { + "enum": [ + "granted", + "not_determined", + "denied", + "settings_required", + "unsupported" + ] + }, + "scope": { + "const": "host_api_v1_device_access" + }, + "supported": { + "type": "boolean" + } + }, + "required": [ + "capability", + "supported", + "app_policy_granted", + "app_consent", + "os_permission" + ], + "type": "object" + }, + "platforms": [ + "android", + "macos" + ], + "summary": "Read this app's device consent and the OS permission without prompting", + "version": 1 + }, + "implemented": true, + "supported": true + }, + "microphone_allowed": false, + "microphone_error": "this app was not granted \"microphone\", which \"microphone.permission.status\" needs", + "missing": { + "authorization": "unavailable", + "configured": null, + "implemented": false, + "method": "apilab.custom_rust", + "supported": false + } + } + } +} diff --git a/tools/fixtures/host-api-lab/evidence/android/receipt.json b/tools/fixtures/host-api-lab/evidence/android/receipt.json new file mode 100644 index 00000000..40950694 --- /dev/null +++ b/tools/fixtures/host-api-lab/evidence/android/receipt.json @@ -0,0 +1,36 @@ +{ + "schema": 1, + "platform": "android", + "model": "ONEPLUS A6003", + "android": "15", + "package": "dev.makepad.octosense.hostapilab", + "apk_sha256": "14b3dd6ea65338927da3f321e359f284dd4cc422fcc72340f82884530283ea65", + "personal_accounts_used": false, + "model_started": false, + "checks": { + "signed_native_android_install": true, + "real_script_tool_completed": true, + "bound_device_account": true, + "native_camera_status": true, + "declared_capability_is_not_consent": true, + "runtime_api_discovery": true, + "uncompiled_rust_function_refused": true, + "undeclared_microphone_refused": true, + "background_permission_prompt_refused": true, + "no_native_approval_sheet": true, + "cross_account_refused": true, + "undeclared_tool_refused": true, + "invalid_arguments_refused": true, + "closed_tool_endpoint_refused": true + }, + "passed": true, + "not_verified": [ + "model or peer relay consent", + "physical OS permission approval", + "camera capture" + ], + "owned_package_stopped": true, + "runtime_tree": "7133ac8e40a37402cb30f623e613683ca2c54f91", + "tested_source_base": "9f8ce11c7cc890aa77131821c42a0ae61b46fc54", + "fixture_source": "Android adaptation in this change" +} diff --git a/tools/test-host-api-android.py b/tools/test-host-api-android.py new file mode 100644 index 00000000..5673577f --- /dev/null +++ b/tools/test-host-api-android.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Run the signed Host API Lab on an assigned Android phone in a fresh package. + +No account, model, permission grant, camera capture or production Home change. +The native host uses the same app-tool/host-service code as the desktop lab. +""" +import argparse +import hashlib +import io +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import tarfile +import time + +ROOT = Path(__file__).resolve().parents[1] + + +def main(): + os.umask(0o077) + parser = argparse.ArgumentParser(description=__doc__) + for key in ('adb', 'apk', 'aapt2', 'hub', 'out'): + parser.add_argument('--' + key, type=Path, required=True) + parser.add_argument('--serial', help='Assigned phone only; omitted from evidence') + parser.add_argument('--package', default='dev.makepad.octosense.hostapilab') + args = parser.parse_args() + if not re.fullmatch(r'dev\.makepad\.octosense\.hostapilab(?:\.[a-z0-9_]+)*', args.package): + parser.error('Use a separate hostapilab package, never Home') + args.out.mkdir(mode=0o700, parents=True, exist_ok=False) + base = [str(args.adb)] + (['-s', args.serial] if args.serial else []) + + def adb(*values, data=None, checked=True): + return subprocess.run(base + list(values), input=data, check=checked, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=45) + + devices = adb('devices').stdout.decode().splitlines()[1:] + if not args.serial and sum(line.endswith('\tdevice') for line in devices) != 1: + parser.error('Select the assigned phone with --serial') + model = adb('shell', 'getprop', 'ro.product.model').stdout.decode().strip() + android = adb('shell', 'getprop', 'ro.build.version.release').stdout.decode().strip() + present = adb('shell', 'pm', 'path', args.package, checked=False) + if present.returncode not in (0, 1) or present.stderr.strip() or present.stdout.strip(): + parser.error('Expected a fresh, absent package; select a new suffix') + badging = subprocess.run([str(args.aapt2), 'dump', 'badging', str(args.apk)], + check=True, capture_output=True).stdout.decode() + if f"name='{args.package}'" not in badging or 'application-debuggable' not in badging: + parser.error('APK must match the separate debuggable package') + bundle = args.out / 'bundle' + shutil.copytree(ROOT / 'tools/fixtures/host-api-lab/bundle', bundle) + listing = json.loads((bundle / 'listing.json').read_text()) + listing['platforms'] = ['android'] + (bundle / 'listing.json').write_text(json.dumps(listing, indent=2) + '\n') + subprocess.run([str(args.hub), 'stamp', str(bundle)], check=True, capture_output=True) + archive = io.BytesIO() + with tarfile.open(fileobj=archive, mode='w') as tar: + for path in sorted(bundle.rglob('*')): + if path.is_symlink() or not (path.is_file() or path.is_dir()): + raise ValueError('Fixture accepts regular files and directories only') + tar.add(path, arcname='host-api-lab/bundle/' + str(path.relative_to(bundle)), recursive=False) + installed = False + receipt = {'schema': 1, 'platform': 'android', 'model': model, 'android': android, + 'package': args.package, 'apk_sha256': hashlib.sha256(args.apk.read_bytes()).hexdigest(), + 'personal_accounts_used': False, 'model_started': False, 'checks': {}} + try: + adb('install', str(args.apk)) + installed = True + adb('shell', 'run-as', args.package, 'mkdir', '-p', 'files') + adb('shell', '-T', 'run-as', args.package, 'tar', '-xf', '-', '-C', 'files', data=archive.getvalue()) + adb('shell', 'am', 'start', '-n', args.package + '/.MakepadApp') + deadline = time.monotonic() + 90 + while time.monotonic() < deadline: + result = adb('exec-out', 'run-as', args.package, 'cat', + 'files/host-api-lab/receipt.json', checked=False) + if result.returncode == 0: + try: + native = json.loads(result.stdout) + break + except ValueError: + pass + time.sleep(.25) + else: + raise AssertionError('Native phone host did not produce a receipt') + (args.out / 'native-result.json').write_text(json.dumps(native, indent=2) + '\n') + checks = receipt['checks'] + + def require(name, condition): + checks[name] = bool(condition) + if not condition: + raise AssertionError(name) + + require('signed_native_android_install', native['signed_install'] is True and native['platform'] == 'android') + require('real_script_tool_completed', 'Ok' in native['tool_result']) + data = native['tool_result']['Ok'] + require('bound_device_account', data['account'] == 'device') + require('native_camera_status', data['camera']['supported'] is True and data['camera']['os_permission'] in ('granted', 'not_determined', 'denied', 'settings_required')) + require('declared_capability_is_not_consent', data['camera']['app_policy_granted'] is True and data['camera']['app_consent'] is False) + require('runtime_api_discovery', data['discovery']['supported'] is True and data['discovery']['descriptor']['name'] == 'camera.permission.status') + require('uncompiled_rust_function_refused', data['missing']['implemented'] is False and data['missing']['supported'] is False) + require('undeclared_microphone_refused', data['microphone_allowed'] is False and data['microphone_error']) + require('background_permission_prompt_refused', data['background_prompt_allowed'] is False and 'background' in data['background_error']) + require('no_native_approval_sheet', native['host_sheet_visible'] is False) + require('cross_account_refused', 'account_scope' in native['refusals']['wrong_account']) + require('undeclared_tool_refused', bool(native['refusals']['undeclared_tool'])) + require('invalid_arguments_refused', bool(native['refusals']['invalid_arguments'])) + require('closed_tool_endpoint_refused', bool(native['closed_app'])) + receipt['passed'] = True + receipt['not_verified'] = native['not_verified'] + except Exception as error: + receipt['passed'] = False + # Subprocess exceptions include their command (possibly --serial and + # private build paths). Only our fixed assertion messages are public. + receipt['error'] = str(error) if isinstance(error, AssertionError) else type(error).__name__ + raise + finally: + if installed: + try: + receipt['owned_package_stopped'] = adb( + 'shell', 'am', 'force-stop', args.package, checked=False).returncode == 0 + except subprocess.SubprocessError: + receipt['owned_package_stopped'] = False + (args.out / 'receipt.json').write_text(json.dumps(receipt, indent=2) + '\n') + print(json.dumps({'passed': receipt['passed'], 'checks': len(receipt['checks']), + 'owned_package_stopped': receipt['owned_package_stopped']})) + + +if __name__ == '__main__': + main() 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()