diff --git a/AGENTS.md b/AGENTS.md index 9a289bb6e..fd668c081 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,7 @@ On a machine that already has clones of Makepad, OctoScript or Octoscript-Makepa 9. **Keep docs honest and bilingual.** Every command in a doc was run; anything not run is marked **unverified**. User-facing docs are `README.md` plus `README.zh-CN.md`, linked by the single switcher line under the title (`English | [简体中文](README.zh-CN.md)`), and change together. `AGENTS.md` files are English only. 10. **Keep signing keys, keystores, tokens and personal paths out of the repository.** `rom/tests/test_no_local_paths.py` checks for local paths. 11. **App icon shapes belong to the shell style.** Use `octosense::style::AppIconDraw` on shell app-icon surfaces; never draw a bundle PNG/SVG directly. Keep new identities in the shared platform frame policy (`crates/shell/src/octosense/icon_frame.rs`), including App Hub and Assistant. Supply square artwork with its important content in the central safe area, without baked platform corners or shadows. Verify Android, macOS and iOS styles, including PNG/SVG and transparent artwork. Native Android package icons already have the system's mask and badges; do not mask them again. See [app icon guidelines](apps/README.md#launcher-icon-artwork). +12. **Capability declarations describe usage.** `capabilities` and `network.hosts` do not grant or deny execution of public APIs. Apply this consistently to Splash, app-agent tools and components. Keep verified bundle and profile identity, device consent, OS permissions, account scope, trusted write review, storage jail/quota and inter-app tool sharing. `requires` and `host_api.required` remain compatibility checks. Capture intent is explicit (`audio`/`library` options), never inferred from API availability. See [capabilities and execution boundaries](docs/capabilities.md). ## Architecture documentation and code walkthroughs diff --git a/Cargo.lock b/Cargo.lock index e5e88a4a5..8a0300500 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11383,8 +11383,8 @@ dependencies = [ [[package]] name = "octosense-app-contract" -version = "1.10.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=40abb23b50d5c3e546c50d1d91ab27258a203eaf#40abb23b50d5c3e546c50d1d91ab27258a203eaf" +version = "1.11.0" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=8347a489141c0e24cab564883db7c4592935a8b2#8347a489141c0e24cab564883db7c4592935a8b2" dependencies = [ "blake3", "serde", @@ -11395,7 +11395,7 @@ dependencies = [ [[package]] name = "octosense-app-hub" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=40abb23b50d5c3e546c50d1d91ab27258a203eaf#40abb23b50d5c3e546c50d1d91ab27258a203eaf" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=8347a489141c0e24cab564883db7c4592935a8b2#8347a489141c0e24cab564883db7c4592935a8b2" dependencies = [ "base64 0.22.1", "blake3", @@ -11414,6 +11414,7 @@ dependencies = [ "sigstore-trust-root", "sigstore-types", "sigstore-verify", + "toml 0.8.23", "ureq 3.4.2", "wasmparser 0.258.3", ] @@ -11421,7 +11422,7 @@ dependencies = [ [[package]] name = "octosense-app-hub-app" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=40abb23b50d5c3e546c50d1d91ab27258a203eaf#40abb23b50d5c3e546c50d1d91ab27258a203eaf" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=8347a489141c0e24cab564883db7c4592935a8b2#8347a489141c0e24cab564883db7c4592935a8b2" dependencies = [ "makepad-app-module", "makepad-widgets", @@ -11449,7 +11450,7 @@ dependencies = [ [[package]] name = "octosense-app-policy" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=40abb23b50d5c3e546c50d1d91ab27258a203eaf#40abb23b50d5c3e546c50d1d91ab27258a203eaf" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=8347a489141c0e24cab564883db7c4592935a8b2#8347a489141c0e24cab564883db7c4592935a8b2" dependencies = [ "makepad-widgets", "octosense-app-contract", @@ -11471,7 +11472,7 @@ dependencies = [ [[package]] name = "octosense-appstore" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=40abb23b50d5c3e546c50d1d91ab27258a203eaf#40abb23b50d5c3e546c50d1d91ab27258a203eaf" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=8347a489141c0e24cab564883db7c4592935a8b2#8347a489141c0e24cab564883db7c4592935a8b2" dependencies = [ "makepad-app-module", "makepad-widgets", diff --git a/Cargo.toml b/Cargo.toml index af437d780..6e913e699 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,7 +91,7 @@ octosense-reference = { path = "apps/reference" } makepad-sheets = { git = "https://github.com/OctoSense-org/makepad.git", rev = "d653bc4493619ab979ccff165cf48c0c63b77d19", default-features = false } makepad-terminal = { git = "https://github.com/OctoSense-org/makepad.git", rev = "d653bc4493619ab979ccff165cf48c0c63b77d19" } octosense-appcard = { path = "apps/appcard/module" } -octosense-app-hub-app = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "40abb23b50d5c3e546c50d1d91ab27258a203eaf" } +octosense-app-hub-app = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "8347a489141c0e24cab564883db7c4592935a8b2" } makepad-calculator = { git = "https://github.com/OctoSense-org/makepad.git", rev = "d653bc4493619ab979ccff165cf48c0c63b77d19", default-features = false } makepad-clock = { git = "https://github.com/OctoSense-org/makepad.git", rev = "d653bc4493619ab979ccff165cf48c0c63b77d19", default-features = false } makepad-notes = { git = "https://github.com/OctoSense-org/makepad.git", rev = "d653bc4493619ab979ccff165cf48c0c63b77d19", default-features = false } @@ -130,17 +130,17 @@ octoscript-makepad = { git = "https://github.com/OctoSense-org/Octoscript-Makepa octoscript-node = { git = "https://github.com/OctoSense-org/Octoscript-Makepad.git", rev = "6cf2d90aa33ff4ae603f49c7557f3ecf986207f0" } # --- 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 = "40abb23b50d5c3e546c50d1d91ab27258a203eaf" } +octosense-appstore = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "8347a489141c0e24cab564883db7c4592935a8b2" } # 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 = "40abb23b50d5c3e546c50d1d91ab27258a203eaf" } -octosense-app-hub = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "40abb23b50d5c3e546c50d1d91ab27258a203eaf" } +octosense-app-policy = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "8347a489141c0e24cab564883db7c4592935a8b2" } +octosense-app-hub = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "8347a489141c0e24cab564883db7c4592935a8b2" } # 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.10" +octosense-app-contract = "1.11" # --- 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 @@ -437,7 +437,7 @@ ruma = { git = "https://github.com/project-robius/ruma.git", branch = "tsp" } # -i octos-core` still sees one octos source. [patch.crates-io] # Keep the contract identical to the pinned SDK for this release candidate. -octosense-app-contract = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "40abb23b50d5c3e546c50d1d91ab27258a203eaf" } +octosense-app-contract = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "8347a489141c0e24cab564883db7c4592935a8b2" } nix = { git = "https://github.com/octos-org/octos.git", rev = "18fcd3f16e527d2b601d7ae244f056fb711bb6b8" } # pdfcraft's bootstrap PDF stack: its workspace patches these five diff --git a/README.md b/README.md index 10b37255c..90180f09c 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ English | [简体中文](README.zh-CN.md) [Host OS API status](docs/host-os-api-status.md) tracks file, location and external-link support, existing services, remaining gaps and validation limits. +[Capabilities and execution boundaries](docs/capabilities.md) explains declaration-only APIs, consent, accounts and app isolation. + **Desktop download:** [OctoSense 0.1.0-rc.2](https://github.com/OctoSense-org/OctoSense/releases/tag/desktop-v0.1.0-rc.2) supports the current App Hub catalog, GitHub-attested app releases and the public host APIs declared by SDK 1.10.0. Choose the asset for your operating system and architecture; the release notes record prerequisites, signing status and platform validation. This desktop release does not update Android Home. Run the [live Mail → card → Calendar demo](docs/demos/mail-calendar/README.md) with your own mailbox: pinned source, selective policy, fictional test inputs, human draft edits and Calendar navigation. diff --git a/README.zh-CN.md b/README.zh-CN.md index c21387574..78f78906f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -6,6 +6,8 @@ [宿主 OS API 状态](docs/host-os-api-status.zh-CN.md)记录文件、定位和外部链接支持,以及已有服务、剩余缺口和验证范围。 +[能力声明与执行边界](docs/capabilities.zh-CN.md)说明声明式 API、用户同意、账户和应用隔离。 + **桌面版下载**:[OctoSense 0.1.0-rc.2](https://github.com/OctoSense-org/OctoSense/releases/tag/desktop-v0.1.0-rc.2) 支持当前的 App Hub 签名目录、带 GitHub 证明的应用 Release 及 SDK 1.10.0 声明的公开宿主 API。请选择与操作系统和架构对应的文件;运行前置条件、签名状态和各平台验收范围见发行说明。此桌面版本不会更新 Android Home。 用自己的邮箱[复现真实邮件 → 卡片 → Calendar 演示](docs/demos/mail-calendar/README.zh-CN.md):固定源码版本、重要邮件策略、虚构测试输入、人工修改草稿和日历跳转。 diff --git a/apps/ai-providers/host-service/README.md b/apps/ai-providers/host-service/README.md index fcdbfd136..3098ee7ed 100644 --- a/apps/ai-providers/host-service/README.md +++ b/apps/ai-providers/host-service/README.md @@ -1,5 +1,7 @@ # octosense-llm-service: the `llm` host service +English | [简体中文](README.zh-CN.md) + The AI providers app (`os.ai-providers`, [../bundle](../bundle)) edits the LLM providers the AppCard assistant runs on, through this service. The app never sees a key, a PIN or a QR: keys are typed on a host-owned sheet, the @@ -219,24 +221,22 @@ unreadable to the kernel. A key the keychain refuses also stays in the profile. ## Tests -From `apps/ai-providers`: +From the repository root, after `python3 tools/setup.py`: ```sh -cargo test --workspace +cargo test --locked -p octosense-llm-config -p octosense-llm-service +cargo test --locked -p octosense-llm-service --features octos-core ``` -The workspace resolves Makepad, Octoscript-Makepad and Octoscript from -checkouts beside this repository (`../makepad` and so on, as the shells do), -and the makepad one must carry the contained-app runtime the shells build with -(splash host requests and sheets). To use another checkout without editing -the manifest, pass a `--config` file with `[patch."https://github.com/OctoSense-org/makepad.git"]` -entries pointing at it. The macOS keychain test runs only when asked: -`cargo test -p octosense-llm-service -- --ignored keychain`. +The root workspace pins Makepad, Octoscript-Makepad and Octoscript once; +setup prepares their `.sources/` worktrees. Do not add local source patches. +The macOS keychain test runs only when explicitly requested: +`cargo test --locked -p octosense-llm-service -- --ignored keychain`. ## The `model` service (`model.complete`) The same crate offers a second family, `model` ([src/complete](src/complete)): -contained apps granted the `model` capability make a direct, one-shot, +admitted contained apps make a direct, one-shot, schema-checked call to the person's providers (OctoSense ADR 0002, "Direct one-shot model calls"). The app names a model class (`fast` or `strong`), a task, an input and a JSON Schema; the host picks the provider in the @@ -247,11 +247,19 @@ charges a per-app daily budget (defaults: 6 calls a minute, 100 calls and never sees the provider, the model id or the key. The method table and the error codes are in [src/complete/mod.rs](src/complete/mod.rs). -`crates/ai-host` registers it with the `llm` service: - -```rust -octosense_llm_service::register_model(&options, octosense_llm_service::complete::Options::default()); -``` +The shell registers it through [`crates/ai-host`](../../../crates/ai-host). +Capability declarations describe usage: an app can call `model.complete` +without listing `model`. The host must supply `complete::Options::grants` +with a verified app-identity and exact host-directory check. Missing admission +fails closed; reading an app-provided manifest is not sufficient. The shell +also supplies an active account lookup. An account app that is signed out +gets no device-scope fallback; changing accounts invalidates pending results. + +Direct `model` calls do not create a persistent app agent. An agent needs +separate opt-in and user consent. Model workers retain request/schema limits, +per-app budgets, concurrency bounds and cancellation checks, and recheck +admission and account state before delivery. The stable `capability` error +code now reports denied caller admission, not a missing declaration. Tests use a fake transport: `cargo test --locked -p octosense-llm-service --test complete`. A live check against DeepSeek (costs a fraction of a cent): diff --git a/apps/ai-providers/host-service/README.zh-CN.md b/apps/ai-providers/host-service/README.zh-CN.md new file mode 100644 index 000000000..cb3330fc3 --- /dev/null +++ b/apps/ai-providers/host-service/README.zh-CN.md @@ -0,0 +1,85 @@ +# octosense-llm-service:`llm` 和 `model` 宿主服务 + +[English](README.md) | 简体中文 + +AI providers 系统应用 `os.ai-providers` 通过 `llm` 服务配置 Shell 共用的模型供应商。普通应用通过 `model` 进行一次性的结构化模型调用;持久应用 Agent 则通过 `ai-host` 和 `app-peers` 使用同一个内核。这三个接口承担不同职责。 + +密钥、PIN 和供应商二维码由宿主界面处理,脚本应用不会得到这些秘密。方法定义见 [src/lib.rs](src/lib.rs) 和 [src/complete/mod.rs](src/complete/mod.rs)。 + +## 供应商配置 + +模型目录来自 `octosense_llm_config::catalog`。`llm.families` 和 `llm.models` 为选择器提供模型名称、上下文窗口和价格。宿主设置向导依次选择模型家族、模型、服务地址和协议、API 密钥,然后测试并保存。测试失败时可以明确选择不测试直接保存。 + +官方路线不保存单独的 route 对象;自定义路线保存 octos 识别的 `route_id`、`label`、`base_url` 和 `api_key_env`。模型调用按照用户设置的主供应商和备用顺序执行。 + +Shell 先配置唯一的 `octosense-kernel`,再注册服务。启用 `octos-core` 时,服务写入同一个内核目录的 `profiles/_main.json`,供应商变更后重启内核,消费者随后重新连接。没有该 feature 时服务只更新配置。 + +Shell 通常通过 [`crates/ai-host`](../../../crates/ai-host) 完成注册。`Options` 可设置: + +| 配置 | 作用 | +| --- | --- | +| `core_dir` | 内核使用的配置目录 | +| `vault` | 平台秘密存储;测试可使用隔离实现 | +| `scanner` | 手机摄像头扫描,桌面可不提供 | +| `image_picker` | 从系统文件或照片选择器导入二维码图片 | +| `image_drops` | 桌面将拖放的图片交给服务 | +| `on_changed` | 保存、排序、删除、导入后的回调;内核重启后触发,可能在工作线程运行 | + +`llm` 属于宿主的供应商管理接口,仍仅服务系统应用。删除普通应用的能力声明检查,不会让商店应用读取或配置用户的供应商密钥。 + +## 扫码、图片与导入 + +`QrScanner`、`QrImagePicker` 是 Shell 提供的适配器。服务本身不链接 Makepad。Shell 在 UI 线程打开扫描器或系统选择器,完成回调必须恰好调用一次;取消保留原界面,失败显示原因。 + +图片以原始 PNG/JPEG 字节传给服务,在工作线程中解码和查找二维码。解码前拒绝超过 20 MB 或 4000 万像素的图片。识别尝试原图、二值化和缩放副本;成功后在宿主界面输入 PIN。桌面拖放同样进入此流程,不将文件直接交给脚本 isolate。 + +导出界面展示 `OCTOS1E:` 代码及 PIN,最长五分钟;关闭或到期后清除。测试可以缩短有效期,不能延长。界面使用单次定时器,避免切换 Splash 内容后旧的重复定时器继续运行。 + +导入只增加或更新,不删除已有供应商,也不改变已有顺序: + +- 没有现有配置时,代码中的第一项成为主供应商。 +- 已有相同模型和路线时,保留位置并更新密钥。 +- 新路线追加为备用;不同密钥使用独立 `api_key_env` 槽,避免覆盖另一条路线。 +- 重复导入相同代码不产生变化;删除供应商时,仅在没有其他路线引用其槽时才删除该槽。 + +界面显示已添加、已更新或已存在的结果,不将密钥展示给应用。 + +## 密钥存储 + +| 平台 | 保存位置 | profile 中的值 | +| --- | --- | --- | +| macOS | 登录钥匙串,服务 `octos`,账号为环境变量名 | `keychain:` | +| Linux | `/secrets/`,权限 0600 | `keychain:` | +| Android、iOS 等 | 应用私有 profile,权限 0600 | 密钥本身 | +| 开发覆盖 `OCTOSENSE_LLM_VAULT=file` | profile | 密钥本身 | + +Android 内核从 profile 的 `env_vars` 读取密钥,因此不能直接使用它无法解密的 Android Keystore 密文。平台钥匙串拒绝写入时,密钥仍保存在私有 profile。这是当前实现边界;不能把脚本应用不能读取密钥误写为所有平台都使用硬件加密存储。 + +## 一次性 `model` 调用 + +`model.complete` 接受任务、输入、JSON Schema,以及可选的 `fast`/`strong` 模型类别和 URL 输出选项。宿主选择供应商,校验返回 JSON,不合格时重试一次。应用不指定供应商或密钥,不建立工具调用、浏览、记忆或持久对话。 + +主要限制包括:任务 4 KiB、应用输入 32 KiB、结构化输出 16 KiB;默认每个应用每分钟 6 次、每天 100 次和 100,000 tokens。账本保存在 `/model/ledger.json`。`model.budget` 返回调用应用的预算。工具箱的模型调用也共用这条预算路径。 + +`capabilities` 是使用说明,省略 `model` 不阻止调用。授权仍由宿主完成: + +- Shell 必须通过 `complete::Options::grants` 验证已准入应用、bundle 身份和精确宿主目录。没有回调时拒绝;直接读取应用写入的 manifest 不能授权。 +- Shell 提供当前账号范围。有账号功能的应用未登录时,不会回退到设备范围;账号切换使旧结果失效。 +- 工作线程保持并发、大小、速率和预算限制,并在回复前再次检查准入、账号与取消状态。 +- 错误码 `capability` 为兼容保持不变,但现在表示宿主未准入该调用方,而不是缺少能力声明。 + +直接调用 `model` 不会自动启用持久 Agent。应用 Agent 另有启用意图和用户同意,跨应用工具仍需要共享授权和审批。 + +## 验证 + +在仓库根目录运行 `python3 tools/setup.py`,然后: + +```sh +cargo test --locked -p octosense-llm-config -p octosense-llm-service +cargo test --locked -p octosense-llm-service --features octos-core +cargo test --locked -p octosense-llm-service --test complete +``` + +模型测试使用假传输,覆盖省略能力声明后的调用、缺少宿主准入时拒绝、输出校验、预算和取消。媒体测试还检查账号切换、准入撤销、并发限制及迟到回复。这些测试不验证真实模型供应商或手机。 + +真实供应商示例 `model_complete_live` 会产生真实请求和费用,只有明确进行该项验收时才运行。macOS 钥匙串测试也默认忽略,需显式选择 `-- --ignored keychain`。仓库只使用根目录统一固定的依赖及 `.sources/`,不增加个人机器路径或本地 patch。 diff --git a/apps/ai-providers/host-service/src/complete/mod.rs b/apps/ai-providers/host-service/src/complete/mod.rs index bd32ac3ea..992209f77 100644 --- a/apps/ai-providers/host-service/src/complete/mod.rs +++ b/apps/ai-providers/host-service/src/complete/mod.rs @@ -3,8 +3,8 @@ //! //! An app's AI keeps its main path through its own octos agent and the //! toolbox's templates. For a bounded job (classify this, summarize that -//! into three lines, pull these fields out of a message) an app granted -//! `model` calls, through `host.request`: +//! into three lines, pull these fields out of a message) an admitted app +//! calls through `host.request`, independently of capability declarations: //! //! | method | args | answer | //! |---|---|---| @@ -120,7 +120,7 @@ impl Class { /// Why a call was refused. The code is stable; the message is for people. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Code { - /// The app was not granted `model`. + /// The host did not admit this app/profile (legacy stable error code). Capability, /// The person has no usable AI provider. NoProvider, @@ -391,7 +391,7 @@ impl Transport for Http { } } -/// Whether an app was granted `model` (`app id`, the Card runner's host +/// Whether this app is admitted by the host (`app id`, the Card runner's host /// directory). pub type Grants = Arc bool + Send + Sync>; /// Host-derived active account scope, never taken from script arguments. @@ -405,37 +405,14 @@ fn system_clock() -> u64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0) } -/// The default grant check: the app's own verified manifest, where App Hub -/// put it next to the host directory (`//bundle/manifest.json` -/// for an installed app, `/.system//*/manifest.json` for a -/// system app), lists `model`. The Card runner's isolate already refuses a -/// `model.*` request from an app whose policy lacks the capability; this is -/// the service's own check behind it, so a caller the runner does not -/// police (a future host path) is not served on trust. -pub fn manifest_grants(app_id: &str, host_dir: &Path) -> bool { - let valid = !app_id.is_empty() && app_id.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) && !app_id.starts_with('.'); - let Some(root) = host_dir.parent().filter(|_| valid) else { return false }; - let lists_model = |path: PathBuf| { - std::fs::read_to_string(path) - .ok() - .and_then(|text| serde_json::from_str::(&text).ok()) - .is_some_and(|m| m["id"] == app_id && m["capabilities"].as_array().is_some_and(|c| c.iter().any(|c| c == FAMILY))) - }; - if lists_model(root.join(app_id).join("bundle").join("manifest.json")) { - return true; - } - std::fs::read_dir(root.join(".system").join(app_id)) - .map(|dirs| dirs.flatten().any(|d| lists_model(d.path().join("manifest.json")))) - .unwrap_or(false) -} - /// What a shell hands [`register_with`]. `Options::default()` reads nothing /// and grants nobody: set `providers` (the llm service's registration does). #[derive(Clone, Default)] pub struct Options { pub providers: Option>, pub transport: Option>, - /// `None`: [`manifest_grants`]. + /// Verified caller and exact host-profile admission. Missing callback + /// fails closed; a manifest declaration alone is never proof of identity. pub grants: Option, pub limits: Option, /// Where the ledger lives before the first call names the host dir. @@ -731,7 +708,7 @@ impl HostService for ModelService { pub fn register_with(options: Options) -> Arc { let host = Arc::new(ModelHost::new(&options)); *HOST.lock().unwrap() = Some(host.clone()); - let grants = options.grants.clone().unwrap_or_else(|| Arc::new(manifest_grants)); + let grants = options.grants.clone().unwrap_or_else(|| Arc::new(|_, _| false)); octosense_appstore::services::register_host_service(Box::new(ModelService { host: host.clone(), grants })); host } diff --git a/apps/ai-providers/host-service/tests/complete.rs b/apps/ai-providers/host-service/tests/complete.rs index 6ce22cd02..938588cba 100644 --- a/apps/ai-providers/host-service/tests/complete.rs +++ b/apps/ai-providers/host-service/tests/complete.rs @@ -323,7 +323,7 @@ fn no_provider_is_named_as_such() { } #[test] -fn an_app_without_the_capability_is_refused_before_anything_else() { +fn an_unadmitted_app_is_refused_before_anything_else() { let rig = Rig::new("cap", vec![deepseek("deepseek-v4-flash")]); for method in ["complete", "budget"] { let err = rig.call("com.example.other", method, args()).unwrap_err(); @@ -332,64 +332,39 @@ fn an_app_without_the_capability_is_refused_before_anything_else() { assert!(rig.fake.seen().is_empty()); } -/// The Card runner's isolate gate (Makepad's `splash_policy`, which App Hub -/// feeds each app's resolved policy) lets `model.*` out only for an app whose -/// policy lists `model`: `llm` or a neighbouring name is not enough, and -/// `model` grants nothing else. Behind it, the service's default grant reads -/// the same manifest, so an app the gate admits is served and one it would -/// refuse is refused again here. +/// Admission is the host's verified identity check, independently of whether +/// the bundle describes model usage. These fixtures never access a real provider. #[test] -fn the_card_runner_gate_and_the_service_agree_on_the_model_capability() { - use octosense_appstore::makepad_widgets::splash_policy::{service_allowed, set_policy_for_heap}; - let without = NEXT.fetch_add(2, Ordering::Relaxed); - let with = without + 1; - set_policy_for_heap(without, vec!["storage".into(), "llm".into(), "models".into()], Vec::new(), None); - for method in ["model.complete", "model.budget"] { - assert!(service_allowed(without, method).is_err(), "{method}"); +fn admitted_model_calls_do_not_require_a_capability_declaration() { + let rig = Rig::new("declarative-model", vec![deepseek("deepseek-v4-flash")]); + let manifest = rig.dir.join(APP).join("bundle/manifest.json"); + std::fs::create_dir_all(manifest.parent().unwrap()).unwrap(); + for capabilities in [json!(["model"]), json!([])] { + std::fs::write(&manifest, json!({"id":APP,"capabilities":capabilities}).to_string()).unwrap(); + assert!(rig.call(APP, "budget", json!({})).is_ok()); + rig.fake.says(GOOD); + assert_eq!(rig.complete(args()).unwrap()["output"], serde_json::from_str::(GOOD).unwrap()); } - set_policy_for_heap(with, vec!["model".into()], Vec::new(), None); - for method in ["model.complete", "model.budget"] { - assert!(service_allowed(with, method).is_ok(), "{method}"); - } - assert!(service_allowed(with, "llm.list").is_err(), "model grants nothing else"); - - // The service's own check, the default one, over manifests where App Hub - // puts them: the granted app is served, the other refused. - let rig = Rig::with("gate", vec![deepseek("deepseek-v4-flash")], |o| o.grants(complete::manifest_grants)); - let write = |id: &str, caps: &[&str]| { - let path = rig.dir.join(id).join("bundle/manifest.json"); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(path, json!({"id": id, "capabilities": caps}).to_string()).unwrap(); - }; - write(APP, &["storage", "model"]); - write("com.example.other", &["storage", "llm"]); - assert!(rig.call(APP, "budget", json!({})).is_ok()); - let err = rig.call("com.example.other", "budget", json!({})).unwrap_err(); - assert!(err.starts_with("capability: "), "{err}"); - assert!(rig.fake.seen().is_empty()); + let other = rig.dir.join("com.example.other/bundle/manifest.json"); + std::fs::create_dir_all(other.parent().unwrap()).unwrap(); + std::fs::write(other, json!({"id":"com.example.other","capabilities":["model"]}).to_string()).unwrap(); + assert!(rig.call("com.example.other", "budget", json!({})).unwrap_err().starts_with("capability:")); + assert_eq!(rig.fake.seen().len(), 2); } #[test] -fn the_default_grant_reads_the_apps_own_manifest() { - let root = std::env::temp_dir().join(format!("model-grants-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&root); - let host_dir = root.join(".host"); - let write = |path: PathBuf, caps: &[&str], id: &str| { - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(path, json!({"id": id, "capabilities": caps}).to_string()).unwrap(); - }; - write(root.join("com.a/bundle/manifest.json"), &["model"], "com.a"); - write(root.join("com.b/bundle/manifest.json"), &["storage", "llm"], "com.b"); - write(root.join(".system/os.notes/0123456789abcdef/manifest.json"), &["model"], "os.notes"); - // A manifest that names another id grants nothing. - write(root.join("com.c/bundle/manifest.json"), &["model"], "com.a"); - assert!(complete::manifest_grants("com.a", &host_dir)); - assert!(!complete::manifest_grants("com.b", &host_dir)); - assert!(complete::manifest_grants("os.notes", &host_dir)); - assert!(!complete::manifest_grants("com.c", &host_dir)); - assert!(!complete::manifest_grants("../com.a", &host_dir)); - assert!(!complete::manifest_grants("com.missing", &host_dir)); - let _ = std::fs::remove_dir_all(&root); +fn a_manifest_without_host_admission_never_authorizes_model_calls() { + let rig = Rig::with("missing-admission", vec![deepseek("deepseek-v4-flash")], |mut options| { + options.grants = None; + options + }); + let manifest = rig.dir.join(APP).join("bundle/manifest.json"); + std::fs::create_dir_all(manifest.parent().unwrap()).unwrap(); + std::fs::write(manifest, json!({"id":APP,"capabilities":["model"]}).to_string()).unwrap(); + for method in ["complete", "budget"] { + assert!(rig.call(APP, method, args()).unwrap_err().starts_with("capability:")); + } + assert!(rig.fake.seen().is_empty()); } #[test] diff --git a/apps/camera/README.md b/apps/camera/README.md new file mode 100644 index 000000000..f2a8d7f81 --- /dev/null +++ b/apps/camera/README.md @@ -0,0 +1,72 @@ +# Camera + +English | [简体中文](README.zh-CN.md) + +The bundled `os.camera` app uses Makepad's native `CameraPreview` widget. +Preview frames stay native; `on_capture` returns the app-relative path of a +completed capture. The shell supplies the app's storage and device identity. +Camera and microphone access still require the app's host consent and the +platform's OS permission. Background callbacks cannot gain permission-prompt +authority by completing after the app returns to the foreground. + +The manifest requires `host-api-v1` and `camera.capture_intent@1`; older +hosts refuse this bundle instead of ignoring capture options. The read-only +`camera.capture_intent` method reports the defaults and supported options. +The app requests host/OS camera permission before opening the preview and +microphone permission before recording with audio. The shell's unified device +permission adapter currently supports Android and macOS; other targets report +unsupported rather than bypassing consent. + +## Capture intent + +Capability declarations describe expected API usage; making an API available +does not select recording or export behavior. Each call chooses its options: + +```text +ui.cam.capture() // local JPEG; no library export +ui.cam.capture({library: false}) // same behavior, explicit +ui.cam.record_start() // local silent video, if supported +ui.cam.record_start({audio: true library: false}) +ui.cam.record_stop() +``` + +Both options default to `false`. Only boolean values are accepted; strings, +numbers, unknown fields and extra arguments return `false` and report an +explanation through `error()` / `on_error`. `audio` is a recording option, +not a still-photo option. Requested audio is refused when microphone consent +is missing; it does not silently become a silent recording. The options are +retained while asynchronous OS permission checks run, alongside the request's +original foreground/background authority. + +`library: true` explicitly requests an additional gallery export. It requires +foreground authority and a backend that implements that export; unsupported +platforms reject it **before capture**. OpenHarmony has an existing ArkTS gallery +handoff. Android, Windows and Apple capture paths do not gain gallery export +from this change. Gallery handoff is not a delivery receipt. + +The bundled app explicitly saves locally and requests audio for video mode. +Its recent-thumbnail list holds at most 30 paths. Trimming that list does not +delete capture files or assume a gallery copy exists. Existing storage quotas +still apply; a full or missing app storage directory causes a visible error. + +## Platform limits and validation + +This change does not add capture backends. Android supports its existing still +capture but reports video recording as unsupported. Windows has a bounded +still-capture worker and rejects recording. macOS lacks a camera recording +adapter. OpenHarmony uses its existing capture/recording and gallery bridge. +Exact platform support must be checked on the target device. + +The runtime patch is +[`makepad-camera-capture-intent.patch`](../../tools/runtime-patches/makepad-camera-capture-intent.patch). +It extends existing CameraPreview tests with strict option parsing, defaults, +missing microphone consent, background export refusal, pending intent retention, +and unsupported export before file creation. From the prepared runtime: + +```sh +MAKEPAD_HIDE_WINDOWS=1 cargo test --locked -p makepad-widgets camera_preview::tests -- --test-threads=1 +``` + +These are logic tests: they open no camera hardware and do not prove GPU +rendering, OS dialogs, audio recording or gallery delivery. Live device +acceptance remains separate. See the [OS API status](../../docs/host-os-api-status.md). diff --git a/apps/camera/README.zh-CN.md b/apps/camera/README.zh-CN.md new file mode 100644 index 000000000..6c8466a65 --- /dev/null +++ b/apps/camera/README.zh-CN.md @@ -0,0 +1,56 @@ +# Camera + +[English](README.md) | 简体中文 + +内置的 `os.camera` 使用 Makepad 原生 `CameraPreview` 控件。预览帧不经过脚本, +`on_capture` 在拍摄完成后提供应用内的相对路径。Shell 提供应用存储与设备身份。 +相机和麦克风仍需逐应用宿主授权及 OS 权限;后台回调不能因为应用后来回到前台, +就获得弹出权限对话框的权限。 + +manifest 要求 `host-api-v1` 和 `camera.capture_intent@1`,旧宿主会拒绝这个 +bundle,而不是忽略拍摄选项。只读的 `camera.capture_intent` 方法报告默认值和支持 +的选项。应用打开预览前请求宿主/OS 相机授权,有声录像前请求麦克风授权。Shell 的 +统一设备授权适配器目前支持 Android 和 macOS;其他平台明确报告不支持,不绕过授权。 + +## 明确拍摄意图 + +能力声明描述预计使用的 API;API 可用并不代表应用选择录音或导出。每次调用指定选项: + +```text +ui.cam.capture() // 本地 JPEG,不导出图库 +ui.cam.capture({library: false}) // 明确选择相同结果 +ui.cam.record_start() // 平台支持时录制本地无声视频 +ui.cam.record_start({audio: true library: false}) +ui.cam.record_stop() +``` + +两个选项均默认 `false`。只接受布尔值;字符串、数字、未知字段或多余参数会返回 +`false`,并通过 `error()` / `on_error` 说明原因。`audio` 只适用于录像,不适用于 +照片。明确请求声音却未获得麦克风授权时拒绝请求,不会悄悄改成无声录像。异步 OS +授权期间保存原始选项和前台/后台来源,后续调用不能修改已等待授权的拍摄请求。 + +`library: true` 明确要求额外导出到图库。它需要前台权限和实际支持导出的后端; +不支持的平台在**开始拍摄前**拒绝。OpenHarmony 有现有的 ArkTS 图库交接;本次 +改动不会为 Android、Windows 或 Apple 增加图库导出。图库交接不代表保存成功的回执。 + +内置应用明确选择本地保存,视频模式明确请求声音。最近缩略图列表最多保留 30 个路径, +缩减列表不会删除拍摄文件,也不会假设图库已有副本。存储额度仍然有效;存储已满或不存在 +时显示错误。 + +## 平台边界和验证 + +本次不新增拍摄后端。Android 使用已有静态拍摄,录像明确报告不支持;Windows 使用 +有界静态拍摄工作线程,不支持录像;macOS 没有相机录像适配器。OpenHarmony 沿用已有 +拍摄、录像和图库桥接。具体能力仍需在目标设备上验证。 + +运行时补丁为 +[`makepad-camera-capture-intent.patch`](../../tools/runtime-patches/makepad-camera-capture-intent.patch)。 +它扩展现有 CameraPreview 测试,覆盖选项类型、默认行为、缺少麦克风授权、后台导出拒绝、 +等待授权期间保留意图,以及不支持导出时在创建文件前失败。在准备好的运行时中运行: + +```sh +MAKEPAD_HIDE_WINDOWS=1 cargo test --locked -p makepad-widgets camera_preview::tests -- --test-threads=1 +``` + +这些是逻辑测试,不打开相机硬件,也不验证 GPU 绘制、OS 对话框、录音或图库交付。 +真实设备验收另行进行。参见 [OS API 状态](../../docs/host-os-api-status.zh-CN.md)。 diff --git a/apps/camera/bundle/main.splash b/apps/camera/bundle/main.splash index ef017b8c7..6056c7cd0 100644 --- a/apps/camera/bundle/main.splash +++ b/apps/camera/bundle/main.splash @@ -1,6 +1,7 @@ let mode = "photo" // photo | video let flash = "off" // off | auto | on let recording = false +let permission_pending = false let rec_started = 0 let rec_timer = nil let shots = [] // app-relative paths, newest last @@ -46,21 +47,34 @@ fn captured(){ let path = ui.cam.last() if path == "" { return } shots.push(path) - // The newest few stay in the app; the photo library keeps them all. - while shots.len() > keep { - let old = shots[0] - shots.remove(0) - if fs.exists(old) { fs.remove(old) } - } + // Bound thumbnail history only. Captures remain in app storage; no + // platform-independent photo-library export has been requested. + while shots.len() > keep { shots.remove(0) } save_shots() show_thumb() } fn shutter(){ - if mode == "photo" { ui.cam.capture(); return } + if mode == "photo" { ui.cam.capture({library: false}); return } if recording { stop_recording() } else { start_recording() } } +fn permission_failed(r){ + permission_pending = false + if r.is_ok { ui.toast.set_text("Permission was not granted. Open Camera and try again.") } + else { ui.toast.set_text(r.error) } + ui.retry_camera.set_visible(true) +} fn start_recording(){ - if !ui.cam.record_start() { return } + if permission_pending { return } + permission_pending = true + host.request("microphone.permission.request", {}, fn(r){ + permission_pending = false + if r.is_ok && r.data.app_consent && r.data.os_permission == "granted" { + if mode == "video" && !recording && ui.cam.is_running() { begin_recording() } + } else { permission_failed(r) } + }) +} +fn begin_recording(){ + if !ui.cam.record_start({audio: true library: false}) { return } recording = true rec_started = time_now() ui.rec.set_text("00:00") @@ -103,7 +117,7 @@ fn open_viewer(){ ui.full.load_image_from_data_async(fs.read_bytes(viewing)) ui.viewer_note.set_text("") } else { - ui.viewer_note.set_text("Video saved to your photo library") + ui.viewer_note.set_text("Video saved in Camera app storage") } ui.viewer.set_visible(true) } @@ -116,8 +130,14 @@ fn failed(){ ui.toast.set_text(ui.cam.error()) ui.retry_camera.set_visible(true) } fn retry_camera(){ + if permission_pending { return } + permission_pending = true ui.toast.set_text("") ui.retry_camera.set_visible(false) - ui.cam.start() + host.request("camera.permission.request", {}, fn(r){ + permission_pending = false + if r.is_ok && r.data.app_consent && r.data.os_permission == "granted" { ui.cam.start() } + else { permission_failed(r) } + }) } fn boot(){ load_shots() @@ -125,7 +145,7 @@ fn boot(){ ui.zooms.render() ui.modes.render() ui.shutter_face.render() - ui.cam.start() + retry_camera() } start_timeout(0.05, || boot()) diff --git a/apps/camera/bundle/manifest.json b/apps/camera/bundle/manifest.json index 2ba6e0658..e858a04fb 100644 --- a/apps/camera/bundle/manifest.json +++ b/apps/camera/bundle/manifest.json @@ -3,11 +3,37 @@ "id": "os.camera", "version": "1.0.0", "name": "Camera", - "integrity": { "bundle_blake3": "" }, - "capabilities": ["storage", "camera", "microphone", "library", "glance"], + "integrity": { + "bundle_blake3": "" + }, + "capabilities": [ + "storage", + "camera", + "microphone", + "library", + "glance" + ], "agent": { "profile": "read-only", - "tools": ["ask_user_question"], - "model": { "needs": ["tool_calling"] } + "tools": [ + "ask_user_question" + ], + "model": { + "needs": [ + "tool_calling" + ] + } + }, + "requires": [ + "host-api-v1" + ], + "host_api": { + "required": { + "camera.capture_intent": 1 + }, + "optional": { + "camera.permission.request": 1, + "microphone.permission.request": 1 + } } } diff --git a/crates/ai-host/README.md b/crates/ai-host/README.md index 480ba8924..0e978f542 100644 --- a/crates/ai-host/README.md +++ b/crates/ai-host/README.md @@ -1,5 +1,7 @@ # octosense-ai-host: the shell's AI services +English | [简体中文](README.zh-CN.md) + > **Where this fits.** This crate is the shell's side of the octos kernel: it owns the kernel service, offers each granted native module its `OctosAppService` (from `crates/app-peers`), and serves script apps' `host.request("octos.*")` through the `octos` host service. Every path from an app into octos goes through it; apps never talk to the kernel. Diagrams of the processes, an app agent's two lanes and a tool call with its approval: [How it fits together](../../README.md#how-it-fits-together); the details: [docs/architecture.md](../../docs/architecture.md) and [ADR 0004](../../docs/adr/0004-native-apps-hosting-and-peers.md). One entry point for what every OctoSense shell (desktop/, phone/) hosts: @@ -13,8 +15,8 @@ One entry point for what every OctoSense shell (desktop/, phone/) hosts: and image picker, desktop open panel and drops, elsewhere a pasted code); - **the `model` host service** (`model.complete`, implemented in `apps/ai-providers/host-service/src/complete/`): one-shot model calls over - the same providers for apps granted the `model` capability, with per-app - budgets; + the same providers for admitted apps, with per-app budgets and active + account scope. Omitting `model` from capabilities does not deny a call; - **script apps' agents**: the `octos` host service (`src/contained.rs`, below), one host-owned peer `card.` per app; - **native apps' assistant access** (Rinx ADR 0007): a scoped @@ -53,9 +55,9 @@ ai_host::shutdown(); `octos-kernel` on a desktop, `Program(path)`, `None`; `KernelSource::platform()` picks); `qr_import: QrImport` (`platform()` or `paste_only()`); `policy: Policy` -(`Policy::shipped()` grants each native app the `octos.*` services listed in -its `native-apps.json` entry (`agent.octos`), through the generated -`src/native_agents.rs`). +(`Policy::shipped()` uses `native-apps.json`'s `agent.octos` to identify +native assistant offers through generated `src/native_agents.rs`; a +consented offer receives all four supported methods). Features: `octos-core` (the kernel, app-peers broker, llm restart; native mobile targets always have it — `cfg(kernel)`, set by build.rs), `llm` @@ -67,7 +69,7 @@ the same name). `start` registers `ContainedOctos` (family `octos`) in App Hub's host-service registry where the shell hosts a kernel. It is how every script app, system -or store, has an agent: +or store, can use an opted-in agent: - **The peer.** One host-owned octos peer per app, `card.` (`PEER_PREFIX`, `peer_id`), launched through @@ -82,10 +84,14 @@ or store, has an agent: The service is registered even when off, so an app hears why. - **The app's own calls.** `host.request("octos.session.open" | "octos.session.history" | "octos.turn.start" | "octos.turn.interrupt")`, - only the names its manifest declares (`set_declared`, else - `NOT_DECLARED`); text at most 32 KiB, replies at most 2 MiB. None of - today's system apps with an agent (News, Mail, Calendar) declares one: - their agents are driven by the shell. + the fixed public method set for any admitted, opted-in, consented agent. + `set_caller_admitted` checks the current bundle and exact host profile; + `set_declared` returns `None` for an app without an agent, including when + the public API is available to every app. An agent block, admitted tools, + or a supported assistant declaration opts an app in. An empty declaration + list on an opted-in agent is allowed. Missing identity/consent callbacks + fail closed. Text is limited to 32 KiB, replies to 2 MiB; arbitrary kernel + methods and app-selected sessions, profiles or accounts remain refused. - **The shell's calls.** `prepare` (the shell prepares every allowed app's peer at startup and when it is allowed, so the system agent's `peer_list` shows it), `conversation` (the person's lane, for the "Ask " panel and @@ -102,23 +108,35 @@ authorizes each call and routes it to the toolbox's executor. This crate adds only the toolbox's part (`src/toolbox_peers.rs`, over `crates/toolbox`'s `peer` module): -| Declared and granted | Offered (risk), each `app: "toolbox"` | +| Script app's exact `agent.tools` request | Offered tool (risk), `app: "toolbox"` | | --- | --- | -| neither | nothing | -| `research` | `workflow.run` (read), `workflow.fork` (act), `toolbox.search` (read), `toolbox.web_read` (read) | -| `crawl`, with `max_depth` and `max_pages` above 0 in the scope | `toolbox.deep_crawl` (read) | +| no toolbox tool requested | nothing | +| `workflow.run` / `workflow.fork` | that requested tool only (read / act) | +| `toolbox.search` / `toolbox.web_read` | that requested tool only (read) | +| `toolbox.deep_crawl`, with positive `max_depth` and `max_pages` | `toolbox.deep_crawl` (read) | - `catalog()`: every toolbox tool, `shareable`, owned by `toolbox`; the relay declares it once and grants each app its `ToolboxGrant::tools()`. -- `ToolboxGrant`: what the app declares AND the person granted - (`ToolboxGrant::new(app, declared, granted, scope)`). A native module's - declared capabilities are reviewed with the shell (`for_module`). A script - app's manifest (`for_manifest`: `research`/`crawl` in `capabilities`, the - scope in octos's `Scope` shape under the top-level `research` object, App - Hub #26's shape) is, **temporarily**, granted only to system apps (`os.*`) - until the host reads App Hub's verified grant. The shells' App Hub pin - (`d2ca3a30`) already includes #26; the code still keeps the `os.*` gate - (`system_app_only`). No system app declares `research` or `crawl` yet. +- `ToolboxGrant::for_manifest` reads exact shared tool requests from an + admitted, digest-checked manifest's `agent.tools`. It ignores `capabilities`: + omitted disclosures do not deny a requested tool, and a `research`/`crawl` + disclosure alone grants none. The top-level `research` object still bounds + resource use with octos's `Scope` fields. Store and system apps follow the + same policy. Selecting search never also grants workflow writes or crawling. +- Native `for_module` retains the shell's compiled, reviewed family offer; + `ToolboxGrant::new(app, declared, granted, scope)` intersects that host + selection. Neither path bypasses agent consent or relay approval. + +For example, this manifest excerpt requests only search and bounded crawling: + +```json +{ + "capabilities": [], + "agent": {"profile": "read-only", "tools": ["toolbox.search", "toolbox.deep_crawl"]}, + "research": {"max_depth": 2, "max_pages": 5} +} +``` + - `ToolboxExecutor`: the relay's executor for the `toolbox` owner. It checks the calling app's grant again (a forged `toolbox.deep_crawl` is `not_granted`), runs the call with the app's `AppContext` (id, grants, @@ -129,10 +147,9 @@ only the toolbox's part (`src/toolbox_peers.rs`, over `crates/toolbox`'s - Consent (the #120 first-use sheet) is the relay's: no toolbox tool is offered to an app, or run for it, before the person allowed its agent. -Nothing else is held back: octos's own generic tools (`deep_research` among -them) are the kernel's, and which of them a peer gets is its `generic_tools` -list: exactly the kernel tools its manifest names and the person granted, -which the broker sets with every registration. +Kernel tools are separate from shared toolbox tools. Contained apps keep +only the kernel tool names the host contract permits (`ask_user_question`), +selected in `agent.tools`; toolbox access never adds arbitrary kernel tools. Which shells build it: the phone's default features include `toolbox-peers` (`phone/Cargo.toml`, generated from `native-apps.json`); the desktop's do @@ -174,7 +191,8 @@ inputs; they are not separate kernel system-message roles. Tool grants and the original `TurnTrigger` remain the authorization boundary, including for incoming email. Incoming text cannot replace the host's structured guidance fields. -`cargo test --locked -p octosense-ai-host --features octos-core,llm` passed -39 tests with isolated host/data directories and hidden windows. The new -guidance test checks account mismatch, payload limits and provisioning without -preparing a peer. No provider or device was exercised. +The guidance tests use isolated host/data directories and fake peers. They +check account mismatch, payload limits and provisioning without preparing a +peer. The declaration regressions exercise omitted/subset declarations, +agent opt-in, host-profile mismatch, consent and signed-out accounts. These +are scripted checks; they do not verify a live provider or phone. diff --git a/crates/ai-host/README.zh-CN.md b/crates/ai-host/README.zh-CN.md new file mode 100644 index 000000000..e6bf574be --- /dev/null +++ b/crates/ai-host/README.zh-CN.md @@ -0,0 +1,92 @@ +# octosense-ai-host:Shell 的 AI 服务 + +[English](README.md) | 简体中文 + +这个 crate 是桌面和 Home 共用的 AI 接入层。Shell 负责内核、供应商配置、应用身份和授权;应用通过受限服务使用 AI,不直接连接内核。完整关系见[架构文档](../../docs/architecture.md)和 [ADR 0004](../../docs/adr/0004-native-apps-hosting-and-peers.md)。 + +## 入口和职责 + +- `start(Host::platform(data_dir))` 配置内核和服务;首个消费者连接时才启动内核。一个进程共用一个内核,`shutdown()` 结束它。 +- `handle_event`、`handle_drop`、`qr_image_result` 处理供应商设置界面的扫码、选图和拖放。 +- `llm` 是 AI providers 系统应用的配置服务;密钥只在宿主界面中输入,普通应用得不到密钥。 +- `model.complete` 是一次性的结构化模型调用。宿主选择供应商并执行预算、输出格式及大小限制;它不会建立持久的应用 Agent。 +- `contained` 提供脚本应用的 `octos` 服务;`offer` 在原生模块创建期间注入受限的助手接口。模块关闭时释放该实例的上下文,不停止其他应用的内核。 +- `module_peer` 连接原生模块的 Makepad `OctosPeer` 与 Shell 的 peer link,使模块和独立进程使用同一协议。 + +`Host` 配置数据目录、内核来源、扫码方式和 `Policy`。Android 使用 APK 内的内核,OpenHarmony 使用进程内内核,桌面使用明确指定或随包发布且通过版本校验的内核;没有可用内核时不会创建助手。`octos-core`、`llm`、`toolbox-peers` 分别控制相应组件;具体平台依赖见 [Cargo.toml](Cargo.toml)。 + +## 声明、身份和用户同意 + +`capabilities` 描述应用计划使用的 API,不是宿主服务的权限开关。省略 `model` 或某个 `octos.*` 名称,本身不会拒绝调用。但以下边界仍然有效: + +| 边界 | 实现 | +| --- | --- | +| 已准入的应用及其宿主目录 | Shell 检查当前 bundle、应用 ID 和精确的 `/.host`;缺少检查回调时拒绝 | +| Agent 是否存在 | `agent` 对象、已准入的工具或受支持的助手声明表示主动启用;普通应用不会因为 API 可用就自动获得 Agent | +| 用户同意 | 首次使用提示,Settings 中可关闭;关闭后立即释放现有 peer | +| 当前账号 | 无账号功能的应用使用设备范围;有账号功能的应用未登录时返回 `SIGN_IN`,不能回退到设备范围 | +| 方法和输入范围 | 只接受公开方法和允许的参数;应用不能指定内核 session、profile、其他账号或任意 RPC | +| 跨应用工具 | 工具共享授权、审批和资源范围独立检查,公开助手接口不会自动授予这些工具 | + +原生应用的 `native-apps.json` 中 `agent.octos` 用来识别助手接入意图。应用启用且用户同意后,接口包含全部四个公开方法,不按声明列表拆分权限。 + +## 脚本应用的 Agent + +每个应用和账号有一个宿主持有的 peer,脚本应用的 ID 前缀为 `card.`。其所有者是系统 Agent。Shell 在用户允许后调用 `prepare` 准备 peer,因此后台事件和系统 Agent 不必等待应用第一次调用 `octos`。 + +公开方法为: + +- `octos.session.open`:打开应用对话。 +- `octos.session.history`:读取人类和系统两条对话通道的合并历史。 +- `octos.turn.start`:提交文本及触发来源;文本最多 32 KiB。 +- `octos.turn.interrupt`:停止当前工作。 + +回复最多 2 MiB。人类对话使用 peer 的独立 request context,与系统 Agent 的 `peer/input` 通道并行;它们共享可读的最近历史,保留发言者和触发来源。审批由 Shell 处理,脚本无法自行批准。 + +`set_declared` 为兼容保留原名:返回 `None` 表示没有启用 Agent,返回空集合仍可表示有 Agent。它不再按集合裁剪方法。`set_caller_admitted` 验证应用和宿主目录,`set_account_of` 提供当前账号。 + +`Policy::shipped()` 默认使用 `ContainedGate::Consent`。开发覆盖 `OCTOSENSE_CONTAINED_APPS=1` 仅跳过首次同意,不跳过应用准入或 Agent 启用;`0` 关闭脚本助手服务。关闭时服务仍注册,以返回明确错误。 + +## 系统工具箱 + +启用 `toolbox-peers` 时,工具箱通过 Shell 的工具中继服务应用 Agent。系统应用和已准入的商店应用遵循相同规则,没有额外的 `os.*` 限制。 + +| 脚本应用在 `agent.tools` 中请求的精确名称 | 提供的工具 | +| --- | --- | +| 没有请求工具箱工具 | 无 | +| `workflow.run` / `workflow.fork` | 仅请求的那个工具 | +| `toolbox.search` / `toolbox.web_read` | 仅请求的那个工具 | +| `toolbox.deep_crawl`,且 `max_depth`、`max_pages` 大于 0 | `toolbox.deep_crawl` | + +`ToolboxGrant::for_manifest` 从经过准入和摘要校验的 manifest 读取 `agent.tools`,忽略 `capabilities`。省略能力说明不会禁用已请求的工具,仅说明 `research`/`crawl` 也不会获得工具。顶层 `research` 对象仍使用 octos 的 `Scope` 字段限制资源范围;只请求搜索不会连带获得工作流写入、网页读取或爬取工具。 + +例如,下面的 manifest 片段只请求搜索和有范围限制的爬取: + +```json +{ + "capabilities": [], + "agent": {"profile": "read-only", "tools": ["toolbox.search", "toolbox.deep_crawl"]}, + "research": {"max_depth": 2, "max_pages": 5} +} +``` + +原生应用的 `for_module` 保留随宿主编译、经审阅的工具能力选择。中继仍检查 Agent 同意、跨应用工具授权及必要审批;执行器再次核对精确工具名称,取消的调用不返回结果。内核工具单独管理:受限应用只能选择宿主合约允许的名称(`ask_user_question`),工具箱访问不开放任意内核工具。 + +模板模型调用与 `model.complete` 共用供应商和预算。结果写入宿主持有的 `/.host/toolbox/`,供 Glance 的 `sys.digest` 读取。手机默认启用工具箱;桌面可通过同名 feature 启用。手机的隐藏系统 WebView 可辅助读取需要渲染的网页。 + +## 宿主提供的 Agent 指导 + +`contained::set_guidance(app_id, account, TrustedGuidance)` 在 peer 准备前或两次调用之间设置指令和技能文本。最多 16 KiB、16 个技能;下一次调用使用新快照,不删除历史。切换账号或撤销 Agent 会清除内存指导。 + +这些内容是宿主提供的文本,不是内核技能安装机制,也不是独立的模型 system-message 角色。应用请求和指导分别序列化;工具授权和原始触发来源仍是安全边界。 + +## 验证 + +从仓库根目录准备依赖后运行: + +```sh +cargo test --locked -p octosense-ai-host --features octos-core,llm +cargo test --locked -p octosense-ai-host --features toolbox-peers --test toolbox_peers +``` + +声明相关测试覆盖未声明或只声明部分方法、未启用 Agent、错误宿主目录、未同意、账号切换和未登录。工具箱测试使用脚本内核与假供应商;这些结果不代表真实模型或手机验收。真实内核测试需要显式设置 `OCTOS_APP_PEERS_TEST_KERNEL`,否则可选测试不会验证真实内核。 diff --git a/crates/ai-host/src/contained.rs b/crates/ai-host/src/contained.rs index 53740c2f9..c0ff705f8 100644 --- a/crates/ai-host/src/contained.rs +++ b/crates/ai-host/src/contained.rs @@ -1,9 +1,9 @@ //! `octos` for contained apps: the Card runner's assistant service. //! -//! A store app (an App Hub bundle the Card runner hosts) that declares -//! `octos.*` services calls `host.request("octos.turn.start", {text}, …)`. -//! The isolate's gate already refused every service the manifest did not -//! declare; what reaches this service is handed to ONE octos peer per app, +//! An admitted app with an opted-in, consented agent calls +//! `host.request("octos.turn.start", {text}, …)`. Capability declarations +//! describe intended use; identity, consent, account scope and the public +//! method whitelist authorize access. Calls reach ONE octos peer per app, //! owned by the shell's system agent on the shell's kernel — the same //! host-owned peer contract Rinx uses (ADR 0007), named `card.` so a //! store app can never share a native module's peer or memory. @@ -26,9 +26,10 @@ //! **Consent and grants** (ADR 0004 §4). The service follows //! [`crate::Policy::contained_gate`]: behind the person's consent at first //! use (the shipped default), off, or on for every app (the developer -//! override `OCTOSENSE_CONTAINED_APPS=1`). An app's peer is granted only -//! the `octos.*` services its manifest declares ([`declared`]), and turning -//! its agent off in Settings releases the live peer at once ([`revoke`]). +//! override `OCTOSENSE_CONTAINED_APPS=1`). An admitted, opted-in agent gets +//! the four public assistant methods ([`declared`]); the override never +//! bypasses admission or opts a plain app in. Turning its agent off in +//! Settings releases the live peer at once ([`revoke`]). //! //! **An agent for every app that declares one** (ADR 0004 §4). A script //! app's peer does not wait for the app to call `octos`: once the person @@ -36,9 +37,9 @@ //! its tools registered), so the system agent's `peer_list` shows it and //! `peer_send_input` reaches it, and the shell's "Ask " panel opens //! the app's conversation on it ([`conversation`]). The app's own `octos` -//! calls use the same peer, still gated by the services its manifest -//! declares; an app that declares none (News: it ships `tools.json` only) -//! never calls it, and has its agent all the same. +//! calls use the same peer and supported methods. An app that declares +//! none of those names can still opt in through an agent block or admitted +//! tools; an app with no opt-in never obtains an agent. //! //! The logic does not need a kernel: peers come from a [`PeerFactory`]. The //! shell's factory (`cfg(kernel)`) launches them through @@ -103,12 +104,12 @@ pub const UNSUPPORTED_ARGS: &str = "Unsupported Octos arguments"; pub const BAD_TEXT: &str = "Provide text (at most 32 KiB)"; pub const NO_SHEET: &str = "The assistant has no sheet; octos calls come from the app"; pub const NO_CONSENT: &str = "Waiting for the person to allow this app's agent (OctoSense asks the first time)"; -pub const NOT_DECLARED: &str = "This app's manifest does not declare that assistant service"; +pub const NOT_DECLARED: &str = "This app has not opted in to an assistant"; +pub const NOT_ADMITTED: &str = "The assistant caller is not an admitted app in this host profile"; /// The shell's consent at first use (ADR 0004 §4): whether an app may have /// its agent now; asking the person the first time is the shell's part. -/// Unset (a host without a consent surface, and this crate's tests): no -/// gate beyond the policy switch. +/// A host without a consent surface fails closed under the Consent policy. static CONSENT: std::sync::OnceLock bool> = std::sync::OnceLock::new(); /// The shell installs its consent check once, at startup. @@ -116,30 +117,32 @@ pub fn set_consent(check: fn(&str) -> bool) { let _ = CONSENT.set(check); } -/// The `octos.*` services an app's manifest declares (`None`: the shell -/// knows no such app). Unset (this crate's tests): all of them. +/// Verify the current bundle identity and exact host profile on every script +/// request. This is separate from agent opt-in and the person's consent. +static CALLER_ADMITTED: std::sync::OnceLock bool> = std::sync::OnceLock::new(); + +pub fn set_caller_admitted(check: fn(&str, &std::path::Path) -> bool) { + let _ = CALLER_ADMITTED.set(check); +} + +/// Agent discovery from the admitted app registry. `None` means no opted-in +/// agent; `Some`, including an empty declaration list, means it has one. static DECLARED: std::sync::OnceLock = std::sync::OnceLock::new(); -/// Maps an app id to the `octos.*` services its manifest declares. pub type DeclaredLookup = fn(&str) -> Option>; -/// The shell installs its manifest lookup once, at startup. +/// Compatibility name: declarations remain disclosure metadata. Discovery must +/// still opt an app in through its agent block, tools, or assistant declaration. pub fn set_declared(lookup: DeclaredLookup) { let _ = DECLARED.set(lookup); } -/// What `app_id` may be granted: the `octos.*` services its manifest -/// declares, and only those (ADR 0004 §4). +/// Public assistant methods for an opted-in agent. Merely exposing the public +/// API never creates an agent for an app that has not opted in. Unknown methods +/// are refused by the parser and broker, independently of manifest contents. pub fn declared(app_id: &str) -> Result, String> { - let all = || octosense_app_peers::OCTOS_SERVICES.iter().map(|s| s.to_string()).collect::>(); - let services = match DECLARED.get() { - None => all(), - Some(lookup) => lookup(app_id).ok_or(NOT_DECLARED)?.intersection(&all()).cloned().collect(), - }; - if services.is_empty() { - return Err(NOT_DECLARED.into()); - } - Ok(services) + DECLARED.get().and_then(|lookup| lookup(app_id)).ok_or(NOT_DECLARED)?; + Ok(peer_services()) } /// Where the shell's own preparations get peers ([`prepare`]): the factory @@ -151,12 +154,9 @@ pub fn set_factory(factory: Arc) { *FACTORY.lock().unwrap_or_else(|e| e.into_inner()) = Some(factory); } -/// The services a peer is launched with: all of them, for the shell's own -/// surfaces (the "Ask " panel reads history, starts turns and stops -/// them). What the APP may call is still only what its manifest declares, -/// twice over: [`ContainedOctos`] checks [`declared`] before any call -/// reaches the peer, and the app's own context is opened with exactly -/// those services (the broker checks every call against its context's). +/// The fixed public assistant surface. Both the host's conversation panel and +/// an admitted app with an opted-in, consented agent use these four methods. +/// Neither can add an arbitrary kernel RPC by naming it in a manifest. fn peer_services() -> BTreeSet { octosense_app_peers::OCTOS_SERVICES.iter().map(|s| s.to_string()).collect() } @@ -313,7 +313,7 @@ pub fn revoke(app_id: &str) -> bool { /// Where contained apps' peers come from. pub trait PeerFactory: Send + Sync { /// The scoped assistant service for `app_id`'s peer `peer_id`, with - /// exactly `services` (its manifest's `octos.*`), or `None` when this + /// exactly `services` (the supported public surface), or `None` when this /// device cannot give one. fn launch(&self, peer_id: &str, app_id: &str, services: &BTreeSet) -> Option>; } @@ -413,8 +413,8 @@ impl ContainedOctos { let context = app.service.open_conversation(ContextSpec { account: account_of(app_id).ok_or(SIGN_IN)?, instance: format!("{}-g{}", app.peer, app.generation), - // The app's own handle: only what its manifest declares, even - // on a peer the shell launched with every service for its panel. + // The supported assistant surface for the admitted, consented app; + // account and context leases remain broker-enforced. services: services.clone(), })?; app.context = Some(context.clone()); @@ -490,12 +490,15 @@ impl HostService for ContainedOctos { if call.from_sheet { return reply.send(Err(NO_SHEET.into())); } + if CALLER_ADMITTED.get().is_none_or(|admitted| !admitted(&call.app_id, &call.host_dir)) { + return reply.send(Err(NOT_ADMITTED.into())); + } match self.gate { crate::ContainedGate::Off => return reply.send(Err(TURNED_OFF.into())), // The developer override asks nobody. crate::ContainedGate::Everyone => {} crate::ContainedGate::Consent => { - if CONSENT.get().is_some_and(|granted| !granted(&call.app_id)) { + if CONSENT.get().is_none_or(|granted| !granted(&call.app_id)) { return reply.send(Err(NO_CONSENT.into())); } } @@ -504,7 +507,7 @@ impl HostService for ContainedOctos { Ok(op) => op, Err(e) => return reply.send(Err(e)), }; - // Only what the manifest declares, whatever the isolate let through. + // Require an opted-in agent, then expose only the public method set. let services = match declared(&call.app_id) { Ok(s) if s.contains(&call.service) => s, Ok(_) => return reply.send(Err(NOT_DECLARED.into())), @@ -528,7 +531,7 @@ pub(crate) struct KernelPeers; #[cfg(kernel)] impl PeerFactory for KernelPeers { fn launch(&self, peer_id: &str, app_id: &str, services: &BTreeSet) -> Option> { - // Only the manifest's `octos.*` services, never all of them. + // The host's supported assistant surface, independently of disclosure metadata. let services: Vec<&str> = services.iter().map(String::as_str).collect(); crate::host_policy().allow(peer_id, services.iter().copied()); let broker = octosense_app_peers::hosted::launch(peer_id, app_id, services.iter().copied(), crate::host_policy())?; diff --git a/crates/ai-host/src/contained/tests.rs b/crates/ai-host/src/contained/tests.rs index 82931ea64..53d860756 100644 --- a/crates/ai-host/src/contained/tests.rs +++ b/crates/ai-host/src/contained/tests.rs @@ -218,6 +218,10 @@ fn ask(app: &str, service: &str, args: Value, from_sheet: bool) -> Result std::sync::MutexGuard<'static, ()> { let guard = SERIAL.lock().unwrap_or_else(|e| e.into_inner()); reset_for_tests(); + set_declared(manifests); + set_consent(|app| app != "com.example.no-consent"); + set_caller_admitted(|app, root| app != "com.example.unadmitted" + && root == std::env::temp_dir().join("octosense-contained-tests")); guard } @@ -426,33 +430,33 @@ fn policy_contained_apps_follow_consent() { } /// The shell's manifest lookup, as a test sets it once for the whole crate: -/// every app declares everything but these two. +/// known agents may have full, partial or empty declaration metadata. fn manifests(app: &str) -> Option> { match app { "com.example.reader" => Some(["octos.session.open", "octos.session.history", "not.octos"].iter().map(|s| s.to_string()).collect()), "com.example.unknown" => None, + "com.example.undeclared" => Some(BTreeSet::new()), _ => Some(OCTOS_SERVICES.iter().map(|s| s.to_string()).collect()), } } #[test] -fn contained_apps_get_only_the_octos_services_their_manifest_declares() { +fn opted_in_apps_get_public_assistant_methods_independent_of_declarations() { let _g = serial(); set_declared(manifests); let peers = Peers::new(Turn::Reply(json!({"turn_id": "t1", "text": "ok"}))); register(true, &peers); ask("com.example.reader", "octos.session.open", json!({}), false).expect("declared"); - // The peer serves the shell's panel too; the app's own context holds - // only what its manifest declares, never all four. - assert_eq!(peers.granted.lock().unwrap().len(), 1); + // These declarations omit turn.start, and the second opted-in agent has + // no octos declaration at all. Both have the same public method surface. + ask("com.example.reader", "octos.turn.start", json!({"text": "hi"}), false).expect("public method"); + ask("com.example.undeclared", "octos.turn.start", json!({"text": "hi"}), false).expect("opted-in agent without declarations"); let specs = peers.service("card.com.example.reader").specs.lock().unwrap().clone(); assert_eq!(specs.len(), 1); - assert_eq!(specs[0].services, ["octos.session.history", "octos.session.open"].iter().map(|s| s.to_string()).collect::>(), "never all four"); - let err = ask("com.example.reader", "octos.turn.start", json!({"text": "hi"}), false).unwrap_err(); - assert_eq!(err, NOT_DECLARED); + assert_eq!(specs[0].services, OCTOS_SERVICES.iter().map(|s| s.to_string()).collect::>()); let err = ask("com.example.unknown", "octos.session.open", json!({}), false).unwrap_err(); assert_eq!(err, NOT_DECLARED); - assert_eq!(peers.ids(), vec!["card.com.example.reader".to_string()], "no peer for an app the shell does not know"); + assert_eq!(peers.ids(), vec!["card.com.example.reader".to_string(), "card.com.example.undeclared".to_string()], "no peer without agent opt-in"); } #[test] @@ -475,8 +479,8 @@ fn turning_an_agent_off_releases_its_live_peer_at_once() { /// prepares a consented app's peer without the app calling `octos` (News /// ships tools.json and declares no `octos.*`); the app's own calls, the /// shell's panel and the preparation share ONE peer; the panel's -/// conversation holds every service while the app's own context holds only -/// what it declares; revoking releases the peer and a later preparation +/// conversation and the app's own context hold the public method surface; +/// revoking releases the peer and a later preparation /// gets a fresh one. #[test] fn the_shell_prepares_a_consented_apps_peer_and_its_panel_shares_it() { @@ -500,13 +504,12 @@ fn the_shell_prepares_a_consented_apps_peer_and_its_panel_shares_it() { assert!(panel.is_open()); assert_eq!(news.conversations.load(Ordering::SeqCst), 1); assert_eq!(news.specs.lock().unwrap()[0].services.len(), OCTOS_SERVICES.len()); - // An app that also calls `octos` itself uses that peer, with only what - // it declares. + // An app that also calls `octos` itself uses that same scoped peer. prepare("com.example.reader").unwrap(); ask("com.example.reader", "octos.session.open", json!({}), false).expect("declared"); assert_eq!(peers.ids(), vec!["card.os.news".to_string(), "card.com.example.reader".to_string()], "no second peer"); let reader = peers.service("card.com.example.reader").specs.lock().unwrap().clone(); - assert_eq!(reader[0].services, ["octos.session.history", "octos.session.open"].iter().map(|s| s.to_string()).collect::>()); + assert_eq!(reader[0].services, OCTOS_SERVICES.iter().map(|s| s.to_string()).collect::>()); // Turned off: released at once; allowed again, a fresh peer. assert!(revoke("os.news")); assert!(news.released.load(Ordering::SeqCst)); @@ -671,3 +674,19 @@ fn a_bound_card_conversation_cannot_follow_a_changed_or_signed_out_account() { assert_eq!(mail.conversations.load(Ordering::SeqCst), 1, "no new context for a stale binding"); set_account_of(None); } + +#[test] +fn declaration_changes_never_bypass_consent_or_verified_caller_identity() { + let _g = serial(); + let peers = Peers::new(Turn::Reply(json!({}))); + register(true, &peers); + assert_eq!(ask("com.example.no-consent", "octos.turn.start", json!({"text":"hi"}), false).unwrap_err(), NO_CONSENT); + assert_eq!(ask("com.example.unadmitted", "octos.session.open", json!({}), false).unwrap_err(), NOT_ADMITTED); + assert!(peers.ids().is_empty()); + let heap = NEXT_HEAP.fetch_add(1, Ordering::SeqCst); + dispatch(ServiceCall { app_id: APP.into(), service: "octos.session.open".into(), args: json!({}), from_sheet: false, + may_prompt: true, host_dir: std::env::temp_dir().join("another-host-profile") }, heap, 1, &mut NoSheets); + let (_, _, result) = take_replies_for(&[heap]).pop().expect("synchronous identity refusal"); + assert_eq!(result.unwrap_err(), NOT_ADMITTED); + assert!(peers.ids().is_empty(), "wrong host profile never creates a peer"); +} diff --git a/crates/ai-host/src/lib.rs b/crates/ai-host/src/lib.rs index 138858bc9..1daf970ba 100644 --- a/crates/ai-host/src/lib.rs +++ b/crates/ai-host/src/lib.rs @@ -30,19 +30,19 @@ //! - **The `llm` host service** (feature `llm`) the AI providers system app //! (`os.ai-providers`) calls, writing the kernel's profile under //! [`core_dir`], with the platform's [`QrImport`]. -//! - **The `model` host service** (feature `llm`): contained apps granted -//! `model` make one-shot, schema-checked calls to the person's providers +//! - **The `model` host service** (feature `llm`): admitted contained apps +//! make one-shot, schema-checked calls to the person's providers //! within a per-app budget (`octosense_llm_service::complete`). //! - **Apps' assistant access** (Rinx ADR 0007): when the shell creates a -//! native module instance whose declared `octos.*` services the host -//! [`Policy`] grants, [`offer`] makes ONE octos peer for that app (owned by +//! native module instance that opts in and the host [`Policy`] allows, +//! [`offer`] makes ONE octos peer for that app (owned by //! the shell's system agent, on the shell's kernel) and offers the instance //! a scoped service for the duration of its `create` only. A module that //! declares or is granted nothing gets nothing, and no peer is allocated. //! Dropping the [`Assistant`] releases the instance's leases and //! interrupts its peer's running work; the kernel and other apps go on. //! - **The `octos` host service** ([`contained`]): contained apps the Card -//! runner hosts that declare `octos.*` get their own peer (`card.`) +//! runner hosts with an opted-in agent get their own peer (`card.`) //! under the same contract, while [`Policy::contained_apps`] is on. The //! shell also prepares that peer for every app whose agent the person //! allowed, whether or not the app calls `octos` (`contained::prepare`), @@ -145,16 +145,16 @@ pub enum ContainedGate { #[default] Off, /// Each app once the person allowed its agent (`consent::granted`, - /// ADR 0004 §4; the first call asks), with only the `octos.*` services - /// its manifest declares. + /// ADR 0004 §4; the first call asks), with the supported public methods. + /// Admission and genuine agent opt-in are still required. Consent, - /// Every app, without asking: `OCTOSENSE_CONTAINED_APPS=1`, a developer - /// override. Still only the declared services. + /// Every admitted app with an opted-in agent, without asking: + /// `OCTOSENSE_CONTAINED_APPS=1`, a developer override. Everyone, } -/// Which native modules may use the assistant, and with which `octos.*` -/// services (exact names; others are ignored). +/// Which native modules opt in to the assistant. Exact public `octos.*` +/// names establish the offer; they no longer divide methods into permissions. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Policy { grants: Vec<(String, Vec)>, @@ -177,9 +177,9 @@ impl Policy { /// /// The `octos` service for contained apps follows consent in the /// shipped policy: an app gets its agent once the person allowed it at - /// first use (ADR 0004 section 4), with the services its manifest - /// declares. `OCTOSENSE_CONTAINED_APPS` is the developer override: `1` - /// on for every app without asking, `0` off. + /// first use (ADR 0004 section 4), with all supported public methods. + /// `OCTOSENSE_CONTAINED_APPS` is the developer override: `1` skips consent + /// for admitted, opted-in apps; `0` turns this access off. pub fn shipped() -> Self { let gate = contained_gate_from(std::env::var("OCTOSENSE_CONTAINED_APPS").ok().as_deref()); native_agents::NATIVE_AGENTS @@ -414,9 +414,9 @@ fn register_llm(core_dir: Option, import: QrImport) -> bool { })); octosense_llm_service::register_with(options.clone()); // `model` (ADR 0002, `model.complete`): contained apps' one-shot model - // calls over the same providers, with per-app budgets. Apps granted the - // `model` capability only; the ledger lives in the Card runner's host - // dir, attached at the first call. + // calls over the same providers, with per-app budgets. Admission and + // active account scope remain mandatory; declarations are descriptive. + // The ledger lives in the Card runner's host dir, attached at first use. let mut model = octosense_llm_service::complete::Options::default().grants(|app, root| { let grants = MODEL_GRANTS.lock().unwrap().clone(); grants.is_some_and(|grants| grants(app, root)) @@ -426,7 +426,7 @@ fn register_llm(core_dir: Option, import: QrImport) -> bool { scope.and_then(|scope| scope(app, root)) })); octosense_llm_service::register_model(&options, model); - log!("model: service registered (one-shot calls; granted apps only)"); + log!("model: service registered (one-shot calls; admitted apps only)"); true } diff --git a/crates/ai-host/src/toolbox_peers.rs b/crates/ai-host/src/toolbox_peers.rs index 199be76a9..fb9264575 100644 --- a/crates/ai-host/src/toolbox_peers.rs +++ b/crates/ai-host/src/toolbox_peers.rs @@ -6,16 +6,15 @@ //! The broker registers and the relay routes; this module adds only what is //! the toolbox's: //! -//! - **What an app is offered** ([`ToolboxGrant`]): exactly what it declares -//! AND the person granted. `research` gives `workflow.run` (read), -//! `workflow.fork` (act), `toolbox.search` (read) and `toolbox.web_read` -//! (read); `crawl`, with `max_depth`/`max_pages` above 0 in its scope, -//! gives `toolbox.deep_crawl` (read). Each is marked with its owning app, +//! - **What an app is offered** ([`ToolboxGrant`]): the exact shared tool +//! names selected in its admitted `agent.tools`, after agent consent. +//! `toolbox.deep_crawl` also requires `max_depth`/`max_pages` above 0 in +//! the research scope. Each is marked with its owning app, //! `toolbox` ([`octosense_toolbox::peer::OWNER`]); the relay declares the //! toolbox's [`catalog`] once and grants each app its [`ToolboxGrant::tools`]. -//! Nothing is excluded beyond that: octos's generic tools (its -//! `deep_research` among them) stay the kernel's, and the broker registers -//! with `generic_tools` omitted. +//! Octos's generic tools stay the kernel's. The broker registers the +//! host's explicit `generic_tools` selection separately; toolbox access +//! never grants arbitrary kernel tools. //! - **How a call runs** ([`ToolboxExecutor`], the relay's executor for the //! `toolbox` owner): the toolbox with the calling app's [`AppContext`] (id, //! grants, octos `Scope`, and its host-owned folder), over the octos @@ -38,15 +37,12 @@ //! **Where grants come from.** A native module declares `research`/`crawl` //! among its capabilities, compiled in and reviewed with the shell //! ([`ToolboxGrant::for_module`]; no manifest scope, so octos's defaults and -//! no crawl limits). A script app declares them in its manifest's -//! `capabilities`, with its scope in the manifest's top-level [`SCOPE_KEY`] -//! object, App Hub #26's shape ([`ToolboxGrant::for_manifest`]). -//! **TEMPORARY, until the shells' App Hub pin includes App Hub #26 (which -//! checks and pins those capabilities and lets the person grant them) and -//! the host reads its verified `AppPolicy::research`:** a script app's -//! declaration is granted only to system apps (`os.*`, shipped inside the -//! shell and reviewed with it), so a store app cannot grant itself research -//! by writing it into its manifest ([`system_app_only`]). +//! no crawl limits). A script app requests exact tools in `agent.tools`, +//! with its scope in the manifest's top-level [`SCOPE_KEY`] object +//! ([`ToolboxGrant::for_manifest`]). Its capabilities are disclosure only. +//! The shell supplies an admitted, digest-checked manifest. Store apps and +//! system apps follow the same toolbox policy. The relay still requires +//! consent, actual inter-app grants and the declared resource scope. use octosense_app_peers::host_tools::{HostToolCall, ToolExecutor, ToolOutcome, ToolReply}; use octosense_llm_service::complete::{self, Class, Code, ModelHost}; @@ -65,9 +61,8 @@ use tokio::sync::{mpsc, Notify}; pub use octosense_toolbox::peer::OWNER; -/// The manifest object that holds the `research`/`crawl` scope (octos's -/// `Scope` fields): App Hub #26's shape (`capabilities: ["research", -/// "crawl"]` plus one top-level `research` object). +/// The manifest object that holds the research resource scope (octos's +/// `Scope` fields), independently of capability disclosure. pub const SCOPE_KEY: &str = "research"; /// Every toolbox tool, as the relay's catalog declares it (owned by @@ -76,7 +71,7 @@ pub fn catalog() -> Vec { Library::builtin().map(|l| peer::catalog(&l)).unwrap_or_default() } -/// An app's toolbox grant: what it declares AND the person granted. +/// An app's toolbox grant and scope, narrowed to its selected shared tools. #[derive(Clone, Debug, PartialEq)] pub struct ToolboxGrant { /// `research` and/or `crawl`. @@ -84,12 +79,15 @@ pub struct ToolboxGrant { pub scope: Scope, /// Why a declaration was not granted, for the log. pub notes: Vec, + /// Script apps request individual shared tools, not a whole family. + /// None retains the native host's reviewed family selection. + selected_tools: Option>, } impl ToolboxGrant { /// Nothing granted. pub fn none(note: Option) -> Self { - Self { grants: BTreeSet::new(), scope: scope::unrestricted(), notes: note.into_iter().collect() } + Self { grants: BTreeSet::new(), scope: scope::unrestricted(), notes: note.into_iter().collect(), selected_tools: None } } /// The toolbox capabilities in both `declared` and `granted`, with the @@ -110,7 +108,7 @@ impl ToolboxGrant { return Self::none(None); } match scope::parse(scope.unwrap_or(&json!({}))) { - Ok(scope) => Self { grants, scope, notes: Vec::new() }, + Ok(scope) => Self { grants, scope, notes: Vec::new(), selected_tools: None }, Err(why) => Self::none(Some(format!("{app_id}: its research scope is refused ({why})"))), } } @@ -122,27 +120,26 @@ impl ToolboxGrant { Self::new(app_id, capabilities.iter().copied(), capabilities.iter().copied(), None) } - /// A script app's grant from its manifest: `research`/`crawl` in its - /// `capabilities`, the scope under [`SCOPE_KEY`]. A manifest for another - /// id, a scope octos refuses, or (for now) an app that is not a system - /// app gets nothing. + /// A script app's exact shared tool requests from `agent.tools`, with + /// scope under [`SCOPE_KEY`]. Capability declarations never grant tools + /// or deny requests. A mismatched id or invalid scope gets nothing. + /// The caller supplies an admitted manifest; the relay checks consent + /// and sharing, and the executor rechecks this exact tool set. pub fn for_manifest(app_id: &str, manifest: &Value) -> Self { if manifest.get("id").and_then(Value::as_str) != Some(app_id) { return Self::none(Some(format!("{app_id}: the manifest names another app"))); } - let declared: Vec<&str> = manifest["capabilities"] + let selected: BTreeSet = manifest["agent"]["tools"] .as_array() - .map(|c| c.iter().filter_map(Value::as_str).collect()) + .map(|tools| tools.iter().filter_map(Value::as_str) + .filter(|name| [peer::RUN, peer::FORK, peer::SEARCH, peer::WEB_READ, peer::DEEP_CRAWL].contains(name)) + .map(str::to_owned).collect()) .unwrap_or_default(); - let wants = declared.iter().any(|c| *c == RESEARCH || *c == CRAWL); - if wants && !system_app_only(app_id) { - return Self::none(Some(format!( - "{app_id} declares research or crawl, but App Hub does not verify those capabilities on this pin yet: until it does only system apps (os.*) get them" - ))); - } - // TEMPORARY: until App Hub's verified grant, a system app's - // declaration is its grant. - Self::new(app_id, declared.iter().copied(), declared.iter().copied(), manifest.get(SCOPE_KEY)) + let families: BTreeSet<&str> = selected.iter() + .map(|name| if name == peer::DEEP_CRAWL { CRAWL } else { RESEARCH }).collect(); + let mut grant = Self::new(app_id, families.iter().copied(), families.iter().copied(), manifest.get(SCOPE_KEY)); + grant.selected_tools = Some(selected); + grant } pub fn is_empty(&self) -> bool { @@ -166,16 +163,12 @@ impl ToolboxGrant { /// The toolbox tool names this grant offers. pub fn tools(&self) -> BTreeSet<&'static str> { - peer::tool_names(&self.context("", PathBuf::new())) + peer::tool_names(&self.context("", PathBuf::new())).into_iter() + .filter(|name| self.selected_tools.as_ref().is_none_or(|selected| selected.contains(*name))) + .collect() } } -/// TEMPORARY (see the module docs): whether a script app `app_id` may have -/// what it declares without App Hub's check. System apps only. -pub fn system_app_only(app_id: &str) -> bool { - app_id.starts_with("os.") -} - /// The toolbox's `ModelClient` over the `model` service's host: the /// person's providers, the app's daily budget, one ledger /// (`/.host/model/ledger.json`). diff --git a/crates/ai-host/tests/toolbox_peers.rs b/crates/ai-host/tests/toolbox_peers.rs index 9e31b4a1f..0128cbe75 100644 --- a/crates/ai-host/tests/toolbox_peers.rs +++ b/crates/ai-host/tests/toolbox_peers.rs @@ -220,8 +220,8 @@ fn case(template: &str, name: &str) -> FixtureCase { serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap() } -fn manifest(capabilities: &[&str], scope: Option) -> Value { - let mut m = json!({"id": APP, "capabilities": capabilities}); +fn manifest(tools: &[&str], scope: Option) -> Value { + let mut m = json!({"id": APP, "capabilities": [], "agent": {"tools": tools}}); if let Some(scope) = scope { m["research"] = scope; } @@ -252,34 +252,37 @@ fn news(root: &Path, data: FixtureData, grant: ToolboxGrant) -> (Broker, Arc Vec { registered_names(&news(&root, data.clone(), grant).1) }; let crawl_scope = Some(json!({"max_depth": 2, "max_pages": 5})); - // research: the templates and the single research tools. - assert_eq!(names(ToolboxGrant::for_manifest(APP, &manifest(&["storage", "research"], None))), [RUN, FORK, SEARCH, WEB_READ]); - // research and crawl, with crawl limits. - assert_eq!( - names(ToolboxGrant::for_manifest(APP, &manifest(&["research", "crawl"], crawl_scope.clone()))), - [RUN, FORK, SEARCH, WEB_READ, DEEP_CRAWL] - ); - // crawl alone: only the crawl. - assert_eq!(names(ToolboxGrant::for_manifest(APP, &manifest(&["crawl"], crawl_scope.clone()))), [DEEP_CRAWL]); - // crawl without limits in the scope: no crawling. - assert!(names(ToolboxGrant::for_manifest(APP, &manifest(&["research", "crawl"], None))).iter().all(|n| n != DEEP_CRAWL)); - // No grant: the empty set is still registered. - assert!(names(ToolboxGrant::for_manifest(APP, &manifest(&["storage", "net"], None))).is_empty()); - // Declared but not granted, or granted but not declared: nothing. + let requested = [RUN, FORK, SEARCH, WEB_READ, DEEP_CRAWL]; + // Omitted, empty, and partial family disclosures have identical behavior. + for capabilities in [None, Some(json!([])), Some(json!(["research"])), Some(json!(["research", "crawl"]))] { + let mut m = manifest(&requested, crawl_scope.clone()); + if let Some(capabilities) = capabilities { m["capabilities"] = capabilities; } + else { m.as_object_mut().unwrap().remove("capabilities"); } + assert_eq!(names(ToolboxGrant::for_manifest(APP, &m)), requested); + } + // Selecting search never grants a write, workflow run, reader, or crawler. + assert_eq!(names(ToolboxGrant::for_manifest(APP, &manifest(&[SEARCH], crawl_scope.clone()))), [SEARCH]); + assert_eq!(names(ToolboxGrant::for_manifest(APP, &manifest(&[DEEP_CRAWL], crawl_scope.clone()))), [DEEP_CRAWL]); + // Scope limits remain mandatory even for an explicitly requested crawl. + assert!(names(ToolboxGrant::for_manifest(APP, &manifest(&[DEEP_CRAWL], None))).is_empty()); + // A disclosure alone, unknown tools, or a plain app grants no shared tool. + let mut no_requests = manifest(&[], crawl_scope.clone()); + no_requests["capabilities"] = json!(["research", "crawl"]); + assert!(names(ToolboxGrant::for_manifest(APP, &no_requests)).is_empty()); + no_requests["agent"] = Value::Null; + assert!(names(ToolboxGrant::for_manifest(APP, &no_requests)).is_empty()); + assert!(names(ToolboxGrant::for_manifest(APP, &manifest(&["toolbox.unknown", "mail.send"], None))).is_empty()); + // The native host's explicit reviewed family offer is unchanged. assert!(names(ToolboxGrant::new(APP, ["research", "crawl"], ["storage"], crawl_scope.as_ref())).is_empty()); - assert!(names(ToolboxGrant::new(APP, ["storage"], ["research", "crawl"], crawl_scope.as_ref())).is_empty()); - // Declared and granted in part: only the intersection. assert_eq!(names(ToolboxGrant::new(APP, ["research", "crawl"], ["crawl"], crawl_scope.as_ref())), [DEEP_CRAWL]); - // A native module: what it declares (reviewed with the shell). assert_eq!(ToolboxGrant::for_module("rinx", &["octos.turn.start"]).tools(), BTreeSet::new()); assert_eq!(ToolboxGrant::for_module("reference", &["research"]).tools(), BTreeSet::from([RUN, FORK, SEARCH, WEB_READ])); - // Each registered tool names its owning app and is what the kernel takes. - let full = ToolboxGrant::for_manifest(APP, &manifest(&["research", "crawl"], crawl_scope)); + let full = ToolboxGrant::for_manifest(APP, &manifest(&requested, crawl_scope)); for d in registered(&news(&root, data, full).1) { assert_eq!(d["app"], OWNER, "{d}"); assert_eq!(d["risk"], if d["name"] == FORK { "act" } else { "read" }, "{d}"); @@ -293,7 +296,7 @@ fn no_toolbox_tool_is_offered_or_run_before_consent() { let root = temp("consent"); let case = case("news-digest", "city-infrastructure"); let executor = fixture_executor(&root, case.fixture.clone()); - executor.set_grant(APP, ToolboxGrant::for_manifest(APP, &manifest(&["research"], None))); + executor.set_grant(APP, ToolboxGrant::for_manifest(APP, &manifest(&[RUN, FORK, SEARCH, WEB_READ], None))); let host = relay(executor); host.consent.store(false, Ordering::SeqCst); let (broker, script) = bound(host.clone()); @@ -312,27 +315,46 @@ fn no_toolbox_tool_is_offered_or_run_before_consent() { } #[test] -fn until_app_hub_verifies_the_grant_only_system_apps_get_what_their_manifest_declares() { - let store = json!({"id": "com.example.news", "capabilities": ["research", "crawl"], "research": {"max_depth": 1, "max_pages": 3}}); +fn admitted_store_agents_use_the_same_toolbox_policy_as_system_agents() { + let store = json!({"id": "com.example.news", "capabilities": [], "agent": {"tools": [RUN, FORK, SEARCH, WEB_READ, DEEP_CRAWL]}, "research": {"max_depth": 1, "max_pages": 3}}); let grant = ToolboxGrant::for_manifest("com.example.news", &store); - assert!(grant.is_empty()); - assert!(grant.notes[0].contains("only system apps"), "{:?}", grant.notes); + assert_eq!(grant.grants, BTreeSet::from(["research".to_owned(), "crawl".to_owned()])); + assert_eq!(grant.tools(), BTreeSet::from([RUN, FORK, SEARCH, WEB_READ, DEEP_CRAWL])); + assert!(grant.notes.is_empty()); // A manifest for another app, or a scope octos refuses, grants nothing. - assert!(ToolboxGrant::for_manifest("os.mail", &manifest(&["research"], None)).is_empty()); - let bad = ToolboxGrant::for_manifest(APP, &manifest(&["research"], Some(json!({"languages": ["en"]})))); + assert!(ToolboxGrant::for_manifest("os.mail", &manifest(&[RUN, FORK, SEARCH, WEB_READ], None)).is_empty()); + let bad = ToolboxGrant::for_manifest(APP, &manifest(&[RUN, FORK, SEARCH, WEB_READ], Some(json!({"languages": ["en"]})))); assert!(bad.is_empty() && bad.notes[0].contains("old toolbox shape"), "{:?}", bad.notes); - let good = ToolboxGrant::for_manifest(APP, &manifest(&["research"], Some(json!({"langs": ["en"], "max_age_days": 2})))); + let good = ToolboxGrant::for_manifest(APP, &manifest(&[RUN, FORK, SEARCH, WEB_READ], Some(json!({"langs": ["en"], "max_age_days": 2})))); assert_eq!(good.grants, BTreeSet::from(["research".to_owned()])); assert_eq!(good.scope.langs, ["en"]); } +#[test] +fn a_search_request_never_grants_other_research_tools_to_a_forged_call() { + let root = temp("exact-tools"); + let executor = fixture_executor(&root, case("news-digest", "city-infrastructure").fixture); + executor.set_grant(APP, ToolboxGrant::for_manifest(APP, &manifest(&[SEARCH], Some(json!({"max_depth": 2, "max_pages": 5}))))); + assert_eq!(executor.tools(APP), BTreeSet::from([SEARCH])); + for name in [RUN, FORK, WEB_READ, DEEP_CRAWL] { + let sent = Arc::new(Mutex::new(Vec::::new())); + let answer = sent.clone(); + let mut forged = HostToolCall::parse(&json!({"peer": "news-1", "session_id": "s", "turn_id": "t", "call_id": "forged", "name": name, "app": OWNER, "caller": {"kind": "app_peer"}, "args": {}})).unwrap(); + forged.calling_app = APP.into(); + executor.execute(forged, ToolReply::new("forged", move |value| answer.lock().unwrap().push(value))); + assert_eq!(sent.lock().unwrap()[0]["error"]["kind"], "not_granted", "{name}"); + } + assert!(!root.join(".host/toolbox/os.news").exists(), "refusals never start a worker or create results"); + let _ = std::fs::remove_dir_all(root); +} + // ---- calls ---------------------------------------------------------------------- #[test] fn a_workflow_run_is_executed_by_the_toolbox_and_its_result_lands_in_the_host_folder() { let root = temp("run"); let case = case("news-digest", "city-infrastructure"); - let (_broker, script, _) = news(&root, case.fixture.clone(), ToolboxGrant::for_manifest(APP, &manifest(&["research"], None))); + let (_broker, script, _) = news(&root, case.fixture.clone(), ToolboxGrant::for_manifest(APP, &manifest(&[RUN, FORK, SEARCH, WEB_READ], None))); let id = call(&script, RUN, json!({"id": "news-digest", "params": case.params, "run_id": "glance"})); let result = wait(&script, &id); assert_eq!(result["ok"], true, "{result}"); @@ -373,7 +395,7 @@ fn a_workflow_run_is_executed_by_the_toolbox_and_its_result_lands_in_the_host_fo fn calls_outside_the_scope_are_refused() { let root = temp("scope"); let case = case("news-digest", "city-infrastructure"); - let grant = ToolboxGrant::for_manifest(APP, &manifest(&["research"], Some(json!({"langs": ["en"], "domains_deny": ["example.invalid"]})))); + let grant = ToolboxGrant::for_manifest(APP, &manifest(&[RUN, FORK, SEARCH, WEB_READ], Some(json!({"langs": ["en"], "domains_deny": ["example.invalid"]})))); let (_broker, script, _) = news(&root, case.fixture.clone(), grant); let id = call(&script, SEARCH, json!({"query": "infrastructure", "lang": "fr"})); let refused = wait(&script, &id); @@ -396,7 +418,7 @@ fn calls_outside_the_scope_are_refused() { fn an_app_without_crawl_cannot_crawl_even_with_a_forged_call() { let root = temp("crawl"); let data = case("news-digest", "city-infrastructure").fixture; - let grant = ToolboxGrant::for_manifest(APP, &manifest(&["research"], Some(json!({"max_depth": 2, "max_pages": 5})))); + let grant = ToolboxGrant::for_manifest(APP, &manifest(&[RUN, FORK, SEARCH, WEB_READ], Some(json!({"max_depth": 2, "max_pages": 5})))); let executor = fixture_executor(&root, data); executor.set_grant(APP, grant); let (_broker, script) = bound(relay(executor.clone())); @@ -423,7 +445,7 @@ fn a_cancelled_run_is_stopped_and_never_answered() { for search in &mut data.searches { search.delay_ms = 5_000; } - let (_broker, script, host) = news(&root, data, ToolboxGrant::for_manifest(APP, &manifest(&["research"], None))); + let (_broker, script, host) = news(&root, data, ToolboxGrant::for_manifest(APP, &manifest(&[RUN, FORK, SEARCH, WEB_READ], None))); let id = call(&script, RUN, json!({"id": "news-digest", "params": case.params, "run_id": "cancelled"})); std::thread::sleep(Duration::from_millis(200)); assert_eq!(host.executor.running(), 1); @@ -475,7 +497,7 @@ fn template_model_calls_use_the_model_service_and_the_apps_daily_budget() { let provider = Arc::new(FakeProvider::default()); let host = model_host(provider.clone()); let client = ModelHostClient::new(host.clone(), &root); - let grant = ToolboxGrant::for_manifest(APP, &manifest(&["research"], None)); + let grant = ToolboxGrant::for_manifest(APP, &manifest(&[RUN, FORK, SEARCH, WEB_READ], None)); let app = grant.app_context(APP, &root).unwrap(); let ctx = CallContext { app: Arc::new(app), diff --git a/crates/ai-host/tests/toolbox_real_kernel.rs b/crates/ai-host/tests/toolbox_real_kernel.rs index 3d08f0b48..2fdb5dc76 100644 --- a/crates/ai-host/tests/toolbox_real_kernel.rs +++ b/crates/ai-host/tests/toolbox_real_kernel.rs @@ -133,8 +133,12 @@ fn a_real_peer_is_offered_exactly_its_granted_toolbox_tools_after_consent_and_a_ Ok(PeerToolbox::from_host(Library::builtin().map_err(|e| e.to_string())?, fixture::host(&data), backend)) }), ); - // News declares research (and not crawl); research is granted. - executor.set_grant("os.news", ToolboxGrant::for_manifest("os.news", &json!({"id": "os.news", "capabilities": ["research"]}))); + // This admitted fixture requests the four research tools explicitly; + // omitted capability disclosures do not deny those shared tool requests. + executor.set_grant("os.news", ToolboxGrant::for_manifest("os.news", &json!({ + "id": "os.news", "capabilities": [], + "agent": {"tools": ["workflow.run", "workflow.fork", "toolbox.search", "toolbox.web_read"]} + }))); let host = Arc::new(Relay { executor, consent: AtomicBool::new(false), ran: Mutex::new(Vec::new()) }); let services: BTreeSet = OCTOS_SERVICES.iter().map(|s| s.to_string()).collect(); diff --git a/crates/app-peers/README.md b/crates/app-peers/README.md index 826b6c09c..104a723fc 100644 --- a/crates/app-peers/README.md +++ b/crates/app-peers/README.md @@ -1,5 +1,7 @@ # octosense-app-peers: host-owned octos app peers +English | [简体中文](README.zh-CN.md) + > **Where this fits.** The broker is the host connection for app agents: one host-owned peer per (app, account), owned by the system agent. It starts the system agent's `peer/input` turns on the peer's session (`…#peer-`), runs the person's turns in a separate request context opened with `share_history` (`…#peerctx-`; octos#2636), registers the app's tools and hands every `peer/tool/call`, approval and question to the shell, and applies the 10-minute prompt deadline and the person's Stop. Diagrams of the processes, an app agent's two lanes and a tool call with its approval: [How it fits together](../../README.md#how-it-fits-together); the details: [docs/architecture.md](../../docs/architecture.md) and [ADR 0004](../../docs/adr/0004-native-apps-hosting-and-peers.md). Rinx [ADR 0007](https://github.com/hagency-org/Rinx/blob/main/docs/adr/0007-host-owned-octos-app-peers.md): @@ -7,8 +9,9 @@ an OctoSense shell runs ONE octos kernel and ONE provider profile ([`crates/kernel`](../kernel)). An app with an agent gets ONE octos peer per account, owned by the shell's system agent. A native app that declares assistant services (the exact `octos.*` names App Hub publishes) and that -host policy grants gets its peer and a scoped service handle injected at -module creation. A script app with an agent (News, Mail, Calendar today) +host policy allows gets its peer and a scoped service handle injected at +module creation. These declarations opt the native app in; they do not +limit it to a subset of the four public assistant methods. A script app with an agent (News, Mail, Calendar today) gets its peer `card.` from the shell's `octos` host service (`crates/ai-host/src/contained.rs`), which launches it through `hosted::launch` as well. The app talks with its agent in @@ -20,7 +23,14 @@ both lanes and its history merges them (octos UPCR-2026-034). It also opens plain request contexts of that peer for per-client work (`open_context`: one per client instance, e.g. a Rinx mini app). It never sees raw kernel protocol, provider settings or credentials, and it never starts a -kernel. An app without granted assistant services allocates no peer. +kernel. An app without an agent opt-in or host consent allocates no peer. +For script apps, the host also verifies the bundle and exact host profile; +an agent block or admitted tools can opt in without `octos.*` declarations. + +The broker still intersects each host-issued context's requested services +with its supported surface. This is a context lease, not a manifest +permission. Context/account ownership, cancellation and tool approvals +remain enforced; a manifest cannot add kernel methods or another app's tools. The kernel side is octos UPCR-2026-034 (`peer/prepare` host binding with an app/account memory namespace and `resume`, `peer/context/open|close`, @@ -161,10 +171,8 @@ inputs; they are not separate kernel system-message roles. Tool grants and the original `TurnTrigger` remain the authorization boundary, including for incoming email. Incoming text cannot replace the host's structured guidance fields. -Validation for this bridge: `cargo test --locked -p octosense-app-peers ---features broker` passed (26 unit, 62 broker, one ancillary test); after adding -the account-clear regression, `cargo test --locked -p octosense-app-peers ---features broker guidance` passed all three guidance unit tests and the broker -integration test. These use scripted connectors, not a provider or phone. The -integration test covers incoming approval provenance, unchanged request data, -all three entry points and updates without peer recreation. +The guidance tests use scripted connectors, not a provider or phone. They +cover incoming approval provenance, unchanged request data, all three entry +points, account clearing and updates without peer recreation. The hosted +service tests cover the public method set after opt-in and host consent; +unknown methods and apps with no agent offer remain refused. diff --git a/crates/app-peers/README.zh-CN.md b/crates/app-peers/README.zh-CN.md new file mode 100644 index 000000000..1ec1f0009 --- /dev/null +++ b/crates/app-peers/README.zh-CN.md @@ -0,0 +1,72 @@ +# octosense-app-peers:宿主持有的应用 peer + +[English](README.md) | 简体中文 + +这个 crate 把应用 Agent 接到 Shell 的同一个 octos 内核。每个应用和账号有一个 peer,由系统 Agent 持有;应用获得受限接口,不获得内核令牌、供应商密钥或原始内核协议。参见[架构文档](../../docs/architecture.md)、[ADR 0004](../../docs/adr/0004-native-apps-hosting-and-peers.md)和 [Rinx ADR 0007](https://github.com/hagency-org/Rinx/blob/main/docs/adr/0007-host-owned-octos-app-peers.md)。 + +## Agent、上下文和任务的区别 + +peer 表示应用和账号的身份及其持久记忆,不等于一个固定的 Rust 线程。Broker 管理其连接、上下文、请求和事件路由;运行中的异步任务执行具体请求。 + +同一个应用 Agent 有两条通道: + +- 系统 Agent 通过 `peer/input` 使用 peer 的 session。 +- 人类通过 `open_conversation` 打开独立 request context,使用 `share_history`。它与系统通道并行,每条消息保留通道和发言者。 + +此外,`open_context` 为某个客户端实例创建独立上下文,例如 Rinx 的 mini app。每个上下文都有租约,调用前和回复前都验证账号与上下文是否仍有效;过期回复被丢弃。 + +## 声明不再划分助手方法权限 + +原生模块通过公开的 `octos.*` 名称表示启用助手,宿主策略和用户同意决定是否提供服务。启用后提供全部四个公开方法:`octos.session.open`、`octos.session.history`、`octos.turn.start`、`octos.turn.interrupt`。声明一个方法不会把助手限制为这个方法。 + +脚本应用也可以通过 `agent` 对象或已准入的工具启用 Agent,而不声明 `octos.*`。Shell 验证其 bundle 和精确的宿主目录。没有启用 Agent 或没有宿主同意的应用不会创建 peer。 + +Broker 仍将宿主创建的上下文所请求的方法与公开接口相交。这是上下文租约边界,不是 manifest 权限。manifest 无法增加内核 RPC,公开助手接口也不会自动授予另一个应用的工具。 + +## crate 组成 + +| feature | 内容 | +| --- | --- | +| 默认 | `contract` 接口及 `injection` 的 offer/claim/withdraw;应用可以只依赖这一层 | +| `broker` | peer 绑定、上下文、租约校验、事件路由、取消和过期回复过滤 | +| `octos-core` | Shell 的 `CoreConnector`、宿主策略及 `hosted::launch` | +| `ws` | 明确指定远程 octos 服务的 `WsConnector` | + +Shell 在模块创建期间 `offer` 接口,应用用自己的实例 scope `claim`;创建结束撤回公开入口,已持有的服务随实例存活。实例关闭调用 `release`。没有服务时应用不能自行启动另一个内核来绕开宿主策略。 + +Shell 的系统 session 是 `_main:api:octosense#system`。内核创建 peer 时发放的宿主令牌写入 `/../app-peers/.peer`,目录模式 0700、文件模式 0600,位于应用范围外。记录同时保存工作目录,恢复时使用相同目录。 + +peer 的记忆命名空间为 `app//acct-`。账号标签是规范化账号的 FNV-1a 哈希,不是应用存储目录使用的 SHA-256 哈希。脚本 peer 使用 `card.`,不与原生模块共享身份或记忆。 + +## 生命周期和审批 + +- `confirm: host` 的工具调用交给 Shell 的审批路由;`confirm: app` 交给工具所属应用的确认界面。系统 Agent 不能代替人类批准。 +- `release()` 关闭实例上下文、停止运行中的调用;最后一个实例关闭时撤销 peer 工具路由,系统 Agent 不能继续向已关闭应用发送工作。记忆保留用于下次启动。 +- 删除账号或卸载应用时,宿主调用 purge,使用记录的 peer 身份和令牌删除对应记忆。忙碌时重试;未成功的记录保留。仅退出登录不会 purge。 +- 有账号工作目录的应用,可以让人类对话通过 `read_parent` 只读访问该账号目录。普通客户端上下文仍限制在自己的目录中。 +- 审批或问题默认十分钟到期,结果是拒绝或放弃,不是自动同意;宽限期后停止仍运行的调用。 +- 人类对话的 Stop 可以中断 peer 工作;Shell 的某些界面使用单通道停止。完成的调用撤回尚未处理的工具审批和问题。 + +## 宿主提供的指导 + +Shell 通过 `ai-host::contained::set_guidance` 设置某应用和账号的指令及技能文本。Broker 在 request context、人类对话、系统 `peer/input` 每次开始时取快照;更新作用于下一次调用,不重建 peer 或删除历史。上限为 16 KiB 和 16 个技能,切换账号或撤销服务时清除。 + +这不是内核原生技能安装,也不会创建独立的 system-message 角色。原始请求数据、触发来源和工具授权保持不变,外部邮件内容不能替换宿主提供的结构化指导字段。 + +## 验证 + +在仓库根目录运行: + +```sh +cargo test --locked -p octosense-app-peers --features octos-core,ws +``` + +这些单元和脚本连接器测试覆盖上下文与账号隔离、授权、取消、历史通道、指导更新,以及启用后的完整公开方法集合。它们不代表真实供应商或手机验收。 + +真实内核可选测试需要同一版本的内核和本地脚本模型: + +```sh +OCTOS_APP_PEERS_TEST_KERNEL=/path/to/octos cargo test --locked -p octosense-app-peers --features octos-core --test real_kernel -- --nocapture +``` + +未提供内核时,可选测试跳过真实内核验证。Shell 不接受缺少 host-owned peer 协议的内核,也不会偷偷改用普通共享记忆 session。 diff --git a/crates/app-peers/src/hosted.rs b/crates/app-peers/src/hosted.rs index ad048f1f8..17c2a3142 100644 --- a/crates/app-peers/src/hosted.rs +++ b/crates/app-peers/src/hosted.rs @@ -1,4 +1,4 @@ -//! The shell side: an app's scoped assistant service from its declaration. +//! The shell side: a scoped assistant service for an opted-in, allowed app. use std::collections::{BTreeSet, HashMap}; use std::sync::{Arc, Mutex}; @@ -16,15 +16,17 @@ pub fn system_session() -> String { format!("{SHARED_PROFILE}:api:octosense#system") } -/// Which apps the shell lets use the assistant, and with which services. -/// Host policy and the person's grants; an app not listed gets nothing. +/// Which opted-in apps the shell lets use the assistant. Nonempty supported +/// names establish that offer; an app not listed gets nothing. Methods come +/// from the fixed public surface, not a subset of declaration metadata. #[derive(Default)] pub struct HostPolicy { grants: Mutex>>, } impl HostPolicy { - /// Allow `module` the listed services (exact names; others ignored). + /// Allow an opted-in `module` to use the public assistant surface. + /// At least one supported method is required; unknown names are ignored. pub fn allow<'a>(&self, module: &str, services: impl IntoIterator) { self.grants .lock() @@ -51,8 +53,9 @@ impl HostPolicy { } } -/// The effective assistant services of a module: declared ∩ supported ∩ -/// granted. Empty means no assistant (and no peer) for it. +/// An opted-in native module with the host's consent gets the fixed public +/// assistant surface. Declarations describe usage; they do not divide that +/// surface into permissions. No declaration or no host grant means no peer. pub fn effective_services<'a>( module: &str, declared: impl IntoIterator, @@ -60,7 +63,8 @@ pub fn effective_services<'a>( ) -> BTreeSet { let declared = octos_services_in(declared); let granted = policy.granted(module); - declared.intersection(&granted).cloned().collect() + if declared.is_empty() || granted.is_empty() { return BTreeSet::new(); } + octos_services_in(crate::contract::OCTOS_SERVICES) } /// The scoped service for one instance of `module`, or `None` when it has @@ -146,7 +150,7 @@ mod tests { use super::*; #[test] - fn a_module_gets_only_declared_and_granted_services() { + fn a_consented_opted_in_module_gets_the_supported_public_surface() { let policy = HostPolicy::default(); policy.allow( "rinx", @@ -167,7 +171,7 @@ mod tests { effective_services("rinx", declared, &policy) .into_iter() .collect::>(), - ["octos.session.history", "octos.session.open"] + ["octos.session.history", "octos.session.open", "octos.turn.interrupt", "octos.turn.start"] ); assert!( effective_services("maps", ["octos.turn.start"], &policy).is_empty(), diff --git a/crates/shell/examples/connected-app-host.rs b/crates/shell/examples/connected-app-host.rs index f090654bc..48057af2b 100644 --- a/crates/shell/examples/connected-app-host.rs +++ b/crates/shell/examples/connected-app-host.rs @@ -240,6 +240,9 @@ impl MatchEvent for App { self.sheet = self.ui.splash(cx, ids!(sheet)); let app = self.card.clone(); octosense_app_policy::splash_adapter::apply(&app, cx, &settings); + app.set_host_tag(cx, Some(self.app.clone())); + octosense_appstore::apply_device_consent(cx, &bundle, &app) + .expect("Apply the admitted app's device consent boundary"); app.set_text(cx, &source); } } diff --git a/crates/shell/examples/host-api-lab.rs b/crates/shell/examples/host-api-lab.rs index 9bb1feff1..693ddf44c 100644 --- a/crates/shell/examples/host-api-lab.rs +++ b/crates/shell/examples/host-api-lab.rs @@ -227,6 +227,12 @@ impl AppMain for App { ) .unwrap_err(); let mut checks = os_batch_checks(&reply); + checks["native_storage_identity_scoped"] = json!(self.card.borrow_mut() + .and_then(|mut card| card.isolate_heap_key(cx)) + .is_some_and(|heap| { + splash_storage::storage_for_heap(heap, &self.app).is_some() + && splash_storage::storage_for_heap(heap, "org.example.other").is_none() + })); checks .as_object_mut() .unwrap() @@ -352,16 +358,10 @@ fn public_service_checks(reply: &Result) -> serde_json::Map Option { } #[cfg(any(feature = "app-hub", native_mobile))] { - if crate::host_tools::script_apps::grants(app, "auth") { - return octosense_oauth_service::host::active_connection(&storage.layout().apps_root().join(".host"),app).map(|connection| connection.handle); + if let Some(connection) = octosense_oauth_service::host::active_connection( + &storage.layout().apps_root().join(".host"), app, + ) { + return Some(connection.handle); } octosense_mail_service::active_account(&storage.layout().apps_root().join(".host"), app) } @@ -415,7 +417,7 @@ pub fn install(storage: &'static Arc) { } /// Last check inside the Mail service's atomic send claim. It deliberately -/// rechecks local admission, grants and account metadata: no broker locks, +/// rechecks local admission and account metadata: no broker locks, /// draft calls, UI callbacks or networking. Account metadata writers hold the claim lock too. #[cfg(any(feature = "app-hub", native_mobile))] fn mail_claim_allowed(storage: &Storage, host_dir: &Path, app: &str, account: &str) -> Result<(), String> { @@ -424,9 +426,7 @@ fn mail_claim_allowed(storage: &Storage, host_dir: &Path, app: &str, account: &s } if app != "os.mail" { crate::host_tools::admission::check(app)?; - if !crate::host_tools::script_apps::grants(app, "mail") { - return Err("This app no longer has the Mail capability".into()); - } + crate::host_tools::script_apps::admitted_host(app, host_dir)?; } mail_account_claim_allowed(storage, host_dir, app, account) } diff --git a/crates/shell/src/app_storage/mod.rs b/crates/shell/src/app_storage/mod.rs index 3e6d97115..9f4f88a66 100644 --- a/crates/shell/src/app_storage/mod.rs +++ b/crates/shell/src/app_storage/mod.rs @@ -773,13 +773,10 @@ pub fn host() -> Option<&'static Arc> { } /// Offer `module`'s storage to the instance `scope` for its `create`, when -/// it declares the `storage` capability and the host storage is set up. +/// the host storage is set up. Declarations do not control access to its jail. /// Returns whether an offer was made; the caller withdraws it afterwards. -pub fn offer(module_id: &str, capabilities: &[&str], scope: &str) -> bool { +pub fn offer(module_id: &str, _capabilities: &[&str], scope: &str) -> bool { let Some(host) = host() else { return false }; - if !capabilities.contains(&"storage") { - return false; - } match host.open(module_id) { Ok(storage) => { crate::ai_host::app_peers::storage::offer(module_id, scope, storage); diff --git a/crates/shell/src/approvals/mod.rs b/crates/shell/src/approvals/mod.rs index 83dcdcc9d..946374c5f 100644 --- a/crates/shell/src/approvals/mod.rs +++ b/crates/shell/src/approvals/mod.rs @@ -169,7 +169,7 @@ pub fn init(home: &Path) { let a = Approvals::in_home(home); *STATE.lock().unwrap_or_else(|e| e.into_inner()) = Some(a); // Contained apps' `octos` service asks consent at first use too, and - // grants only the `octos.*` services the app's manifest declares. + // requires a genuine opted-in agent; declarations describe intended use. crate::ai_host::contained::set_consent(consent_for_contained); crate::ai_host::contained::set_declared(crate::apps::declared_octos); } @@ -263,6 +263,7 @@ pub fn consent_for_module(app: &str, label: &str, capabilities: &[&str]) -> bool /// [`consent_for_module`] on one `Approvals`. pub fn module_gate(a: &mut Approvals, app: &str, label: &str, capabilities: &[&str]) -> bool { + if !capabilities.iter().any(|c| crate::ai_host::app_peers::OCTOS_SERVICES.contains(c)) { return false; } let all = a.router.hooks().grants_all(app); if a.consent.granted(app, all) { return true; @@ -283,7 +284,10 @@ pub fn consent_for_contained(app: &str) -> bool { // must never inherit the native-module defaults (device file access) // merely because it calls the same octos service. let Ok(loaded) = crate::host_tools::script_apps::guidance(app) else { return false }; - let octos: Vec = loaded.families.iter().filter(|c| c.starts_with("octos.")).cloned().collect(); + // Discovery includes an agent block or admitted tools, even with no + // octos declarations. Public service names never grant shared tools. + if crate::apps::declared_octos(app).is_none() { return false; } + let octos: Vec = crate::ai_host::app_peers::OCTOS_SERVICES.iter().map(|c| c.to_string()).collect(); return with(|a| contained_gate(a, app, &loaded.manifest, &octos)).unwrap_or(false); } #[cfg(not(any(feature = "app-hub", native_mobile)))] @@ -293,6 +297,7 @@ pub fn consent_for_contained(app: &str) -> bool { #[cfg(any(feature = "app-hub", native_mobile, test))] fn contained_gate(a: &mut Approvals, app: &str, manifest: &serde_json::Value, granted: &[String]) -> bool { if manifest["id"].as_str() != Some(app) { return false; } + if !granted.iter().any(|c| crate::ai_host::app_peers::OCTOS_SERVICES.contains(&c.as_str())) { return false; } let Some(name) = manifest["name"].as_str().filter(|name| !name.trim().is_empty()) else { return false }; let all = a.router.hooks().grants_all(app); if a.consent.granted(app, all) { return true; } diff --git a/crates/shell/src/approvals/tests.rs b/crates/shell/src/approvals/tests.rs index d8b300065..8a61663c6 100644 --- a/crates/shell/src/approvals/tests.rs +++ b/crates/shell/src/approvals/tests.rs @@ -1083,6 +1083,27 @@ fn contained_consent_describes_the_admitted_name_and_account_workspace() { assert!(wrong.consent.prompt().is_none()); } +#[test] +fn agent_consent_does_not_require_method_declarations_or_create_plain_app_agents() { + let app = "org.example.agent"; + let supported: Vec = crate::ai_host::app_peers::OCTOS_SERVICES.iter().map(|s| s.to_string()).collect(); + for caps in [json!([]), json!(["octos.session.open"])] { + let manifest = json!({"id":app,"name":"Example","agent":{},"capabilities":caps}); + let mut a = super::Approvals::memory(); + assert!(!super::contained_gate(&mut a, app, &manifest, &supported)); + assert_eq!(a.consent.prompt().unwrap().app, app); + a.consent.set(&ApprovalGesture::sheet_tap(), app, true, T0); + assert!(super::contained_gate(&mut a, app, &manifest, &supported)); + assert!(!super::contained_gate(&mut a, app, &manifest, &[]), "removing agent opt-in cannot inherit old consent"); + a.consent.turn_off(app, T0); + assert!(!super::contained_gate(&mut a, app, &manifest, &supported)); + } + let mut plain = super::Approvals::memory(); + plain.consent.set(&ApprovalGesture::sheet_tap(), "plain", true, T0); + assert!(!super::module_gate(&mut plain, "plain", "Plain", &["model", "octos.unknown"])); + assert!(plain.consent.prompt().is_none()); +} + // ---------------------------------------------------------------- external clients (G1) fn external(id: &str, tool: ToolSpec) -> Request { diff --git a/crates/shell/src/apps.rs b/crates/shell/src/apps.rs index dc38c9f53..aba8003be 100644 --- a/crates/shell/src/apps.rs +++ b/crates/shell/src/apps.rs @@ -202,7 +202,7 @@ pub fn register_mail_services() { octosense_mail_service::register() } // `mail.notify` (Mail's agent's tool): the shell's notice card, as - // Mail, only when its manifest was granted `glance`. + // Mail, after verifying the publishing app's admission. octosense_mail_service::on_notify(Some(std::sync::Arc::new(crate::glance_notice::notify))); octosense_mail_service::on_publish_card(Some(std::sync::Arc::new(|app: &str, args: serde_json::Value| crate::glance::publish_mail_l0_for(app, &args)))); octosense_mail_service::drafts::on_change(Some(std::sync::Arc::new(makepad_widgets::makepad_platform::SignalToUI::set_ui_signal))); @@ -223,19 +223,18 @@ pub fn register_host_services() { register_mail_services(); static ONCE: std::sync::Once = std::sync::Once::new(); ONCE.call_once(|| { + // Capability declarations describe intended use. Admission still binds + // every call to a verified app and the current host profile. crate::ai_host::set_model_grants(|app, host_dir| { - crate::host_tools::script_apps::admitted_bundle(app) - .and_then(|(root, bundle)| { - if root.join(".host") != host_dir { return Err("Model caller belongs to another app root".into()); } - crate::host_tools::script_apps::from_bundle(&bundle) - }) - .is_ok_and(|bundle| bundle.families.contains("model")) + crate::host_tools::script_apps::admitted_host(app, host_dir).is_ok() }); - crate::ai_host::set_model_scope(|app, _| Some( + crate::ai_host::contained::set_caller_admitted(|app, host_dir| { + crate::host_tools::script_apps::admitted_host(app, host_dir).is_ok() + }); + crate::ai_host::set_model_scope(|app, _| { crate::app_storage::lifecycle::contained_account(app) .map(|account| crate::app_storage::account_hash(&account)) - .unwrap_or_else(|| crate::app_storage::DEVICE.into()) - )); + }); crate::platform_services::register(); crate::device_calendar::register(|app| { crate::app_storage::lifecycle::contained_account(app) @@ -243,8 +242,14 @@ pub fn register_host_services() { }); crate::files_service::register(); crate::audio_service::register(); + // App Hub admits bundles before the first CardModule is opened. The + // linked card runner already supports app tools at that point, so its + // ABI must not depend on a card's lazy VM registration. + octosense_appstore::register_policy_runtime_features(); + octosense_appstore::host_api::register_runtime_feature("app_tools.dispatch", 1); octosense_appstore::host_api::register_runtime_feature("storage.binary_write", 1); octosense_appstore::host_api::register_runtime_feature("video.playback_controls", 1); + octosense_appstore::host_api::register_runtime_feature("camera.capture_intent", 1); octosense_markdown_editor::register(); crate::connected_review::register(); octosense_oauth_service::host_inbox::register_with_review_hook(crate::connected_review::sheet); @@ -256,18 +261,8 @@ pub fn register_host_services() { crate::connected_events::start(); crate::connected_backends::register(); octosense_oauth_service::host::register(std::sync::Arc::new(|app, provider, scopes| { - use octosense_oauth_service::Provider; - let granted = |family| crate::host_tools::script_apps::grants(app, family); - granted("auth") && scopes.iter().all(|scope| match provider { - Provider::Github => scope == "read:user" || granted("github"), - Provider::Backend => scope == "app.session", - Provider::Google => match scope.as_str() { - "openid" | "email" | "profile" => true, - s if s.starts_with("https://www.googleapis.com/auth/calendar.") => granted("gcalendar"), - s if s.starts_with("https://www.googleapis.com/auth/gmail.") => granted("gmail"), - _ => false, - }, - }) + crate::host_tools::script_apps::admitted(app) + && provider.validate_scopes(&scopes.iter().cloned().collect::>()).is_ok() })); // Provider sign-in sheets name the app from its admitted manifest and // show GitHub's one-time code in a host-held panel (ADR 0010). @@ -479,13 +474,14 @@ pub fn agent_apps() -> Vec { } /// The `octos.*` services script app `app_id`'s manifest declares (`None`: -/// no such app here). The contained `octos` service grants only these. +/// no opted-in agent here). The list is disclosure metadata; an admitted, +/// consented agent exposes the four supported assistant methods independently. pub fn declared_octos(app_id: &str) -> Option> { script_agent_apps().into_iter().find(|a| a.id == app_id).map(|a| a.octos.into_iter().collect()) } fn octos_of(capabilities: &serde_json::Value) -> Vec { - capabilities.as_array().into_iter().flatten().filter_map(|c| c.as_str()).filter(|c| c.starts_with("octos.")).map(str::to_string).collect() + capabilities.as_array().into_iter().flatten().filter_map(|c| c.as_str()).filter(|c| crate::ai_host::app_peers::OCTOS_SERVICES.contains(c)).map(str::to_string).collect() } #[cfg(any(feature = "app-hub", native_mobile))] @@ -539,6 +535,7 @@ fn script_agent_apps() -> Vec { pub fn script_agent_app(manifest_path: &Path, id: &str, name: &str) -> Option { let text = std::fs::read_to_string(manifest_path).ok()?; let manifest: serde_json::Value = serde_json::from_str(&text).ok()?; + if manifest["id"].as_str() != Some(id) { return None; } let octos = octos_of(&manifest["capabilities"]); let agent_block = manifest.get("agent").is_some_and(serde_json::Value::is_object); let declares = !octos.is_empty() || agent_block || bundle_ships_agent(manifest_path.parent()?, &text); @@ -878,6 +875,53 @@ pub(crate) fn installed_bundle(root: &Path, app_id: &str) -> PathBuf { #[cfg(test)] mod tests { + /// A fresh host must accept a compatible app before either App Hub or + /// CardModule creates its first VM. Otherwise installs or cold launches + /// fail until another app happens to register the same runtime ABI. + #[cfg(any(feature = "app-hub", native_mobile))] + #[test] + fn cold_host_advertises_the_installed_card_contract() { + if crate::module_host::run_with_isolated_module_data( + "apps::tests::cold_host_advertises_the_installed_card_contract", + ) { + return; + } + let manifest = octosense_app_policy::AppManifest::parse(&serde_json::json!({ + "schema": 1, "id": "org.example.coldcard", "name": "Cold card", "version": "1.0.0", + "integrity": {"bundle_blake3": "0".repeat(64)}, + "capabilities": [], "requires": ["host-api-v1", "script-tools-v1"], + "host_api": {"required": {"app_tools.dispatch": 1, "runtime.describe": 1}} + }).to_string()).unwrap(); + assert!(octosense_appstore::host_api::check_manifest(&manifest).is_err()); + register_host_services(); + octosense_appstore::host_api::check_manifest(&manifest) + .expect("compatible card installs and reopens without warming a module first"); + } + + #[test] + fn script_agent_discovery_requires_matching_identity_and_genuine_opt_in() { + let root = std::env::temp_dir().join(format!("shell-agent-discovery-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + let path = root.join("manifest.json"); + let app = "org.example.notes"; + for manifest in [ + serde_json::json!({"id":app,"capabilities":[],"agent":{}}), + serde_json::json!({"id":app,"capabilities":["octos.session.open"]}), + ] { + std::fs::write(&path, manifest.to_string()).unwrap(); + assert!(script_agent_app(&path, app, "Notes").is_some()); + assert!(script_agent_app(&path, "org.example.other", "Other").is_none()); + } + for manifest in [ + serde_json::json!({"id":app,"capabilities":[],"agent":null}), + serde_json::json!({"id":app,"capabilities":["octos.unrestricted"]}), + ] { + std::fs::write(&path, manifest.to_string()).unwrap(); + assert!(script_agent_app(&path, app, "Notes").is_none()); + } + std::fs::remove_dir_all(root).unwrap(); + } + /// The assistant pane's own process is a registry row (F10 starts it /// from there) but no list shows it as an app; the apps people pick /// are listed. diff --git a/crates/shell/src/audio_service/README.md b/crates/shell/src/audio_service/README.md index a18ac38e4..bfbe38914 100644 --- a/crates/shell/src/audio_service/README.md +++ b/crates/shell/src/audio_service/README.md @@ -25,8 +25,10 @@ error/cancellation. `format` is `wav` for recording and null for playback. the user heard the audio. `starting` becomes `recording`/`playing` only when device callbacks deliver frames. Missing frames fail within five seconds. -Request the `microphone` and `storage` capabilities to record, and `audio` plus -`storage` to play. Declare `requires: ["host-api-v1"]` and the exact methods in +Declare `microphone` and `storage` for recording, and `audio` plus `storage` +for playback disclosure. These declarations do not gate calls; app admission, +the storage jail and microphone consent are checked independently. +Declare `requires: ["host-api-v1"]` and the exact methods in `host_api.required` at major version 1. `runtime.describe` exposes the supported methods. Unsupported platforms omit these methods. All seven are foreground-only, including calls through an app agent; none is an agent-tool alias. diff --git a/crates/shell/src/audio_service/README.zh-CN.md b/crates/shell/src/audio_service/README.zh-CN.md index 34fee967e..9f3bfc966 100644 --- a/crates/shell/src/audio_service/README.zh-CN.md +++ b/crates/shell/src/audio_service/README.zh-CN.md @@ -22,7 +22,8 @@ 实际听到声音的证明。设备回调产生帧后,`starting` 才变为 `recording` 或 `playing`;五秒内没有收到帧会明确失败。 -录音需声明 `microphone` 与 `storage`,播放需声明 `audio` 与 `storage`。 +录音声明 `microphone` 与 `storage`,播放声明 `audio` 与 `storage`,用于说明用途。 +省略声明不会阻止调用;应用准入、存储隔离和麦克风同意仍须独立检查。 同时声明 `requires: ["host-api-v1"]`,并在 `host_api.required` 中列出所用 方法的主版本 1。`runtime.describe` 返回支持的方法;不支持的平台不会列出。 全部七个方法均仅供前台使用,包括经应用代理转发的调用;不提供代理工具别名。 @@ -53,7 +54,7 @@ 播放从同一沙箱读取最多 1 MiB,支持 PCM16/float32 WAV、MP3、FLAC、Ogg Vorbis。 解码限制为单声道或双声道、六十秒及 2,880,000 帧。文件导入导出另用 `files` -API,网络下载仍需应用正常的 HTTP 授权。本接口面向短音频,不提供音乐流媒体 +API,网络下载通过应用的 HTTP 接口。本接口面向短音频,不提供音乐流媒体 或后台媒体服务。 MorningBrief 的自定义 `llm.speech`、`llm.speak`、`llm.listen_*` 补丁仍需要 diff --git a/crates/shell/src/audio_service/mod.rs b/crates/shell/src/audio_service/mod.rs index 1f9032869..ee5b4d013 100644 --- a/crates/shell/src/audio_service/mod.rs +++ b/crates/shell/src/audio_service/mod.rs @@ -404,8 +404,8 @@ fn live(session: &Session, foreground: Option<&str>, background: bool) -> bool { ) }) } -fn admission(app: &str, kind: Kind) -> Result { - let loaded = crate::host_tools::script_apps::guidance(app) +fn admission(app: &str, host: &std::path::Path) -> Result { + let loaded = crate::host_tools::script_apps::admitted_host(app, host) .map_err(|_| "permission_denied: App is no longer admitted")?; if !loaded.manifest["requires"] .as_array() @@ -415,16 +415,6 @@ fn admission(app: &str, kind: Kind) -> Result { "host_requirement_missing: Declare host-api-v1 for native audio sessions".into(), ); } - let family = if kind == Kind::Record { - "microphone" - } else { - "audio" - }; - if !loaded.families.contains(family) || !loaded.families.contains("storage") { - return Err( - "permission_denied: Audio requires its device capability and storage grant".into(), - ); - } Ok(loaded.manifest) } @@ -444,7 +434,7 @@ fn run_worker( if worker_shared.cancel.load(Ordering::Acquire) { return Err("cancelled: App closed before audio preparation".into()); } - let manifest = admission(&app, kind)?; + let manifest = admission(&app, &host)?; let revision = if kind == Kind::Record { Some(crate::platform_services::microphone_consent(&host, &app)?) } else { @@ -489,7 +479,7 @@ fn run_worker( break; } if policy_check.elapsed() >= Duration::from_secs(1) { - if admission(&app, kind).ok().as_ref() != manifest.as_ref() { + if admission(&app, &host).ok().as_ref() != manifest.as_ref() { worker_shared.invalid.store(true, Ordering::Release); failure = Some("permission_denied: App admission changed".into()); break; diff --git a/crates/shell/src/audio_service/tests.rs b/crates/shell/src/audio_service/tests.rs index 979ec5866..429eec11d 100644 --- a/crates/shell/src/audio_service/tests.rs +++ b/crates/shell/src/audio_service/tests.rs @@ -1,6 +1,31 @@ use super::*; use makepad_widgets::*; +#[test] +fn declaration_does_not_replace_audio_admission_or_microphone_consent() { + const CHILD: &str = "OCTOSENSE_AUDIO_DECLARATION_TEST"; + if std::env::var_os(CHILD).is_none() { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "audio_service::tests::declaration_does_not_replace_audio_admission_or_microphone_consent", "--nocapture"]) + .env(CHILD, "1").output().unwrap(); + assert!(output.status.success(), "{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); + return; + } + let root = std::env::temp_dir().join(format!("audio-declaration-{}", uuid::Uuid::new_v4())); + octosense_appstore::set_data_root(root.clone()); + for (app, capabilities) in [ + ("os.audiodeclared", &["audio", "microphone", "storage"][..]), + ("os.audioundeclared", &[][..]), + ] { + crate::host_tools::script_apps::tests::declaration_fixture(app, capabilities); + assert!(admission(app, &root.join(".host")).is_ok()); + assert!(admission(app, &root.join("other-profile/.host")).is_err()); + assert!(crate::platform_services::microphone_consent(&root.join(".host"), app).is_err(), "a declaration never supplies consent"); + } + assert!(admission("os.notinstalled", &root.join(".host")).is_err()); + std::fs::remove_dir_all(root).unwrap(); +} + #[test] fn paths_arguments_and_capture_duration_are_bounded_before_device_work() { assert!(parse( diff --git a/crates/shell/src/connected_backends.rs b/crates/shell/src/connected_backends.rs index acf74811f..a051644e5 100644 --- a/crates/shell/src/connected_backends.rs +++ b/crates/shell/src/connected_backends.rs @@ -107,13 +107,8 @@ fn resolve_admitted( return Err("Backend declaration belongs to another app".into()); } let parsed = octosense_app_contract::AppManifest::parse(&manifest.to_string())?; - if !parsed - .capabilities - .iter() - .any(|capability| capability == "auth") - || !parsed.storage.accounts - { - return Err("This app has no account authentication grant".into()); + if !parsed.storage.accounts { + return Err("This app does not configure account storage".into()); } // Validate through the shared contract first; the OAuth crate's local // representation deliberately does not depend on the UI/store crate graph. @@ -205,7 +200,7 @@ mod tests { "operations":{"notes.list":{"method":"GET","path":"/api/notes"}}}}) } #[test] - fn resolver_only_accepts_matching_profile_identity_and_admitted_grants() { + fn resolver_accepts_declaration_only_capabilities_but_keeps_profile_and_account_scope() { let root = std::env::temp_dir().join(format!("backend-resolver-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(root.join(".host")).unwrap(); let id = "org.example.backend"; @@ -234,9 +229,12 @@ mod tests { .unwrap() .is_none() ); - let mut denied = manifest(); - denied["capabilities"] = serde_json::json!([]); - assert!(resolve_admitted(&root.join(".host"), &root, id, || Ok(denied)).is_err()); + let mut undeclared = manifest(); + undeclared["capabilities"] = serde_json::json!([]); + assert!(resolve_admitted(&root.join(".host"), &root, id, || Ok(undeclared)).unwrap().is_some()); + let mut no_accounts = manifest(); + no_accounts["storage"]["accounts"] = serde_json::json!(false); + assert!(resolve_admitted(&root.join(".host"), &root, id, || Ok(no_accounts)).is_err()); std::fs::remove_dir_all(root).unwrap(); } #[test] diff --git a/crates/shell/src/connected_events.rs b/crates/shell/src/connected_events.rs index 2bf3665ec..37db9ce4e 100644 --- a/crates/shell/src/connected_events.rs +++ b/crates/shell/src/connected_events.rs @@ -16,9 +16,7 @@ fn trigger(app: &str) -> String { fn background_guidance(app: &str) -> bool { match crate::host_tools::script_apps::guidance(app) { Ok(loaded) => loaded.background - && loaded.triggers.iter().any(|t| t == &trigger(app)) - && loaded.families.contains("gmail") - && loaded.families.contains("auth"), + && loaded.triggers.iter().any(|t| t == &trigger(app)), Err(_) => { // A withdrawn/tampered release cannot keep its cached peer alive. // Release its contexts without changing the person's saved consent. diff --git a/crates/shell/src/device_calendar/README.md b/crates/shell/src/device_calendar/README.md index f0de34e05..5e36b503a 100644 --- a/crates/shell/src/device_calendar/README.md +++ b/crates/shell/src/device_calendar/README.md @@ -2,6 +2,8 @@ English | [简体中文](README.zh-CN.md) + +The `device_calendar` declaration is disclosure, not authorization. Admission, `host-api-v1`, account scope, per-app consent, OS permission and native review remain independent requirements. `device_calendar.*` gives installed App Hub apps bounded access to calendars already configured in the host OS. It uses EventKit on macOS and CalendarProvider in Android Home. It does not log into Google or create calendars. The bundled `calendar.*` service and the OAuth-backed `gcalendar.*` service keep their separate identities and storage. This is source implementation, not a claim that an existing release contains it. A compatible App Hub contract must admit `device_calendar`, and the host must register this service. Check `runtime.list` and `runtime.describe`, then `device_calendar.permission.status`. Windows, Linux, iOS and Android packagings without the Home adapter report unsupported; discovery describes supported platforms. OS-account acceptance remains unverified until explicitly tested with a synthetic test calendar. diff --git a/crates/shell/src/device_calendar/README.zh-CN.md b/crates/shell/src/device_calendar/README.zh-CN.md index 6fa876adb..522667c95 100644 --- a/crates/shell/src/device_calendar/README.zh-CN.md +++ b/crates/shell/src/device_calendar/README.zh-CN.md @@ -2,6 +2,8 @@ [English](README.md) | 简体中文 +`device_calendar` 声明用于说明用途,不是授权。应用准入、`host-api-v1`、账户范围、用户同意、系统权限和原生审核仍是独立要求。 + `device_calendar.*` 为安装的 App Hub 应用提供有范围和数量限制的系统日历访问。macOS 使用 EventKit,Android Home 使用 CalendarProvider。它读取操作系统已配置的日历,不负责登录 Google 或创建日历。内置 `calendar.*` 与基于 OAuth 的 `gcalendar.*` 保持独立。 这里描述的是源代码实现,不代表现有发布版已经包含它。App Hub 合约必须支持 `device_calendar`,宿主也必须注册该服务。先通过 `runtime.list`、`runtime.describe` 和 `device_calendar.permission.status` 检查实际能力。Windows、Linux、iOS 和不含 Home 适配器的 Android 打包明确报告不支持。只有在专用测试日历上完成验证后,才能确认真实 OS 权限和日历操作;目前不能将编译通过称为设备验收通过。 diff --git a/crates/shell/src/device_calendar/mod.rs b/crates/shell/src/device_calendar/mod.rs index 369f072b4..a5f3e76de 100644 --- a/crates/shell/src/device_calendar/mod.rs +++ b/crates/shell/src/device_calendar/mod.rs @@ -63,8 +63,7 @@ fn admitted(call: &ServiceCall) -> bool { crate::host_tools::script_apps::from_bundle(&bundle) }) .is_ok_and(|bundle| { - bundle.families.contains(FAMILY) - && bundle.manifest["requires"] + bundle.manifest["requires"] .as_array() .is_some_and(|a| a.iter().any(|v| v == "host-api-v1")) }) @@ -263,7 +262,7 @@ impl HostService for DeviceCalendarService { } fn begin(call: ServiceCall, reply: Replier, host: &mut dyn ServiceHost) -> Result<(), String> { if !admitted(&call) { - return Err("permission_denied: Declare device_calendar and requires host-api-v1".into()); + return Err("permission_denied: App must be admitted in this host and require host-api-v1".into()); } let account = scope(&call.app_id)?; let grant = match store::get(&call.host_dir, &call.app_id, &account) { @@ -1001,6 +1000,28 @@ fn execute_local(context: &Context, command: &Command, deadline: Instant) -> Res mod tests { use super::*; #[test] + fn admission_ignores_declarations_but_keeps_the_host_identity() { + const CHILD: &str = "OCTOSENSE_CALENDAR_DECLARATION_TEST"; + if std::env::var_os(CHILD).is_none() { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "device_calendar::tests::admission_ignores_declarations_but_keeps_the_host_identity", "--nocapture"]) + .env(CHILD, "1").output().unwrap(); + assert!(output.status.success(), "{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); + return; + } + let root = std::env::temp_dir().join(format!("calendar-declaration-{}", uuid::Uuid::new_v4())); + octosense_appstore::set_data_root(root.clone()); + for (app, capabilities) in [("os.caldeclared", &["device_calendar"][..]), ("os.calundeclared", &[][..])] { + crate::host_tools::script_apps::tests::declaration_fixture(app, capabilities); + let mut call = ServiceCall { app_id: app.into(), service: "device_calendar.permission.status".into(), args: json!({}), from_sheet: false, may_prompt: true, host_dir: root.join(".host") }; + assert!(admitted(&call)); + call.host_dir = root.join("foreign/.host"); + assert!(!admitted(&call)); + assert!(scope(app).is_err(), "admission must not manufacture a connected account"); + } + std::fs::remove_dir_all(root).unwrap(); + } + #[test] fn native_review_is_single_use_and_cannot_claim_loading_or_finished() { let mut phase = Phase::Ready(Prepared::Permission); assert!(claim_ready(&mut phase).is_ok()); diff --git a/crates/shell/src/files_service/README.md b/crates/shell/src/files_service/README.md index dbcf8c610..672088a8a 100644 --- a/crates/shell/src/files_service/README.md +++ b/crates/shell/src/files_service/README.md @@ -2,6 +2,8 @@ English | [简体中文](README.zh-CN.md) `files` connects a foreground app to the native document picker. An import copies + +Capability declarations describe intended use. File calls require current app admission; `storage_granted` reports whether the calling isolate has live app storage. Native chooser interaction, the storage jail and quota remain required. one selected document into that app's existing Splash filesystem; an export saves a snapshot of an existing app file. Apps receive an app-relative path and byte count, never a host path or Android provider URI. No separate blob store is created. @@ -38,7 +40,7 @@ nothing is left after cleaning, and on Android, whose document loader does not query a display name yet. Cancelling a document/image dialog returns `{"cancelled":true}`. Read failures, unsupported -platforms, missing grants, full storage, and busy transfers are errors. Imports +platforms, invalid app admission, full storage, and busy transfers are errors. Imports require a new destination: they cannot overwrite an existing app document. The app can read imported bytes with `fs.read_bytes(path)`, pass that array to an image widget, or copy it with `fs.write_bytes(other_path, fs.read_bytes(path))`. diff --git a/crates/shell/src/files_service/README.zh-CN.md b/crates/shell/src/files_service/README.zh-CN.md index 9ec39d9a2..6182addf1 100644 --- a/crates/shell/src/files_service/README.zh-CN.md +++ b/crates/shell/src/files_service/README.zh-CN.md @@ -1,6 +1,8 @@ # 受限应用的文件导入与导出 [English](README.md) | 简体中文 +能力声明说明用途。文件调用需要应用仍通过准入检查;`storage_granted` 表示调用隔离环境是否具有有效的应用存储。原生选择器交互、存储隔离和配额仍然生效。 + `files` 将前台应用接入系统文档选择器。导入会将一个所选文档复制到应用已有的 Splash 文件系统;导出会保存已有应用文件的快照。应用只收到应用内路径和字节数, 不会得到宿主路径或 Android 内容提供器 URI,也不会创建第二套文件存储。 diff --git a/crates/shell/src/files_service/mod.rs b/crates/shell/src/files_service/mod.rs index 5778452fc..6e57c0889 100644 --- a/crates/shell/src/files_service/mod.rs +++ b/crates/shell/src/files_service/mod.rs @@ -100,9 +100,6 @@ enum Operation { } impl Operation { - fn needs_storage(&self) -> bool { - !matches!(self, Self::Share { .. }) - } /// What the native loader may read for this selection: the operation's /// own bound, never more than the app's whole storage quota. fn selection_limit(&self, quota: u64) -> u64 { @@ -231,12 +228,9 @@ fn parse_operation(method: &str, args: &Value) -> Result { Ok(Operation::Export { path, name }) } -fn admission(app: &str, storage: bool) -> Result { - let loaded = crate::host_tools::script_apps::guidance(app) +fn admission(app: &str, host: &std::path::Path) -> Result { + let loaded = crate::host_tools::script_apps::admitted_host(app, host) .map_err(|_| "permission_denied: App is no longer admitted")?; - if !loaded.families.contains("files") || (storage && !loaded.families.contains("storage")) { - return Err("permission_denied: File transfer requires files and storage grants".into()); - } Ok(loaded.manifest) } @@ -265,7 +259,7 @@ struct Work { impl Work { fn authorized(&self) -> Result<(), String> { check_authorization(self.reply.is_pending(), self.manifest.as_ref(), || { - admission(&self.call.app_id, self.operation.needs_storage()) + admission(&self.call.app_id, &self.call.host_dir) }) } fn storage(&self) -> Result { @@ -348,14 +342,15 @@ impl HostService for FilesService { return; } // Status is intentionally nonprompting and does not need storage. - reply.send(admission(&call.app_id, false).map(|_| { + let heap = reply.isolate_key(); + reply.send(admission(&call.app_id, &call.host_dir).map(|_| { json!({ "import_supported":file_dialogs::native_file_bytes_supported(), "export_supported":file_dialogs::native_file_bytes_supported(), "max_file_bytes":MAX_FILE_BYTES, "max_import_bytes":MAX_IMPORT, "foreground_required":true, "photo_pick_supported":file_dialogs::native_file_bytes_supported(), "text_share_supported":cfg!(target_os="android"), "max_share_text_bytes":MAX_SHARE_TEXT, - "storage_granted":crate::host_tools::script_apps::grants(&call.app_id,"storage") + "storage_granted":splash_storage::storage_for_heap(heap, &call.app_id).is_some() }) })); return; @@ -476,7 +471,7 @@ pub fn handle_event(cx: &mut Cx, event: &Event) { work.reply.send(Err("foreground_required: Return to this app before choosing or sharing a file".into())); return; } - work.manifest = match admission(&work.call.app_id, work.operation.needs_storage()) { + work.manifest = match admission(&work.call.app_id, &work.call.host_dir) { Ok(m) => Some(m), Err(e) => { work.reply.send(Err(e)); @@ -510,6 +505,7 @@ pub fn handle_event(cx: &mut Cx, event: &Event) { let reply = work.reply.clone(); let app = work.call.app_id.clone(); let manifest = work.manifest.clone(); + let host_dir = work.call.host_dir.clone(); // The native loader can outlive Pending: a provider read already // in progress may block even after close/timeout revokes the reply. // Keep its one-transfer slot until the guard/worker is dropped. @@ -521,7 +517,7 @@ pub fn handle_event(cx: &mut Cx, event: &Event) { .set_access_guard(FileDialogAccessGuard::new(move || { let _keep_slot = &reservation; check_authorization(reply.is_pending(), manifest.as_ref(), || { - admission(&app, true) + admission(&app, &host_dir) }) .is_ok() })); diff --git a/crates/shell/src/files_service/tests.rs b/crates/shell/src/files_service/tests.rs index c7967a4cb..b74287947 100644 --- a/crates/shell/src/files_service/tests.rs +++ b/crates/shell/src/files_service/tests.rs @@ -166,9 +166,7 @@ fn sharing_has_bounded_text_no_file_authority_and_truthful_handoff() { parse_operation("share", &json!({"text":text})).unwrap(), Operation::Share { text: text.into() } ); - assert!(!parse_operation("share", &json!({"text":text})) - .unwrap() - .needs_storage()); + assert!(matches!(parse_operation("share", &json!({"text":text})).unwrap(), Operation::Share { .. })); for args in [ json!({"text":""}), json!({"text":"a\0b"}), @@ -278,7 +276,7 @@ fn a_selected_document_is_staged_off_the_ui_thread_and_linked_in_against_the_liv octosense_appstore::set_data_root(root.join("apps")); let dir = crate::host_tools::script_apps::tests::stamped_bundle("camera", APP, |dir, manifest| { manifest["id"] = json!(APP); - manifest["capabilities"] = json!(["files", "storage"]); + manifest["capabilities"] = json!([]); manifest["requires"] = json!(["host-api-v1"]); manifest["storage"] = json!({"max_bytes": 8 << 20}); manifest.as_object_mut().unwrap().remove("agent"); @@ -295,7 +293,7 @@ fn a_selected_document_is_staged_off_the_ui_thread_and_linked_in_against_the_liv pack: Box::leak(packed.pack_json.into_boxed_str()), assets: &[], }); - let manifest = admission(APP, true).unwrap(); + let manifest = admission(APP, &root.join("apps/.host")).unwrap(); let jail = root.join("jail"); std::fs::create_dir_all(&jail).unwrap(); @@ -341,7 +339,7 @@ fn a_selected_document_is_staged_off_the_ui_thread_and_linked_in_against_the_liv args: json!({"path": path}), from_sheet: false, may_prompt: true, - host_dir: root.join(".host"), + host_dir: root.join("apps/.host"), }, heap, request, diff --git a/crates/shell/src/glance.rs b/crates/shell/src/glance.rs index fedd4e299..7c405ce7a 100644 --- a/crates/shell/src/glance.rs +++ b/crates/shell/src/glance.rs @@ -64,20 +64,11 @@ //! notification carries ([`GlanceNote::key`], [`NoteTargets`], [`card`]). //! The shell drains them with [`take_notifications`]. //! -//! **Who may call.** A contained app publishes only when it holds the -//! `glance` capability (App Hub's `KNOWN_CAPABILITIES`; the store tells the -//! person "Show cards on your glance screen"). The Card runner's gate -//! (Makepad's `splash_policy::service_allowed`) lets a `glance.*` request -//! out of an app's isolate only when the app's resolved policy grants -//! `glance`, so a call the runner hands [`GlanceService`] from the app -//! itself holds the grant. A host sheet runs under no app's policy, so a -//! call from a sheet holds none, and [`Caller::Contained`] records which it -//! is. Every method refuses a contained caller without the grant. System -//! apps are no exception: they run under their own manifest's policy like -//! any installed app, so a system app that publishes requests `glance` in -//! its manifest, as Mail requests `mail`. Native modules are the shell's own -//! code and publish by the id the shell hosts them as. The capability only -//! decides who may publish; the limits above hold for every caller. +//! **Who may call.** Contained callers have a verified app identity in the +//! current host profile. Manifest capabilities describe usage and do not grant +//! publication. Host sheets cannot publish as an app. Native modules publish +//! by the identity the shell hosts them as. Ownership, content validation, +//! account binding and the limits above hold independently of declarations. //! //! **The feed.** The system agent will rank and trim; until then the shell //! scrolls all retained cards by priority, then recency ([`shown`]); only @@ -111,8 +102,8 @@ pub const STORE_BYTES: usize = 32 * 1024 * 1024; #[derive(Clone, Debug, PartialEq, Eq)] pub enum Caller { /// A contained app, by the manifest id the Card runner runs it under, - /// and whether its policy grants `glance` (see the module docs). - Contained { app: String, granted: bool }, + /// and whether its identity is admitted (see the module docs). + Contained { app: String, admitted: bool }, /// A native module or the shell itself, by the id the shell hosts it as. Native(String), } @@ -123,14 +114,14 @@ impl Caller { Caller::Contained { app: id, .. } | Caller::Native(id) => id, } } - /// A contained app whose policy grants `glance`. - pub fn granted(app: impl Into) -> Caller { - Caller::Contained { app: app.into(), granted: true } + /// A contained caller whose identity has been verified by the host. + pub fn admitted(app: impl Into) -> Caller { + Caller::Contained { app: app.into(), admitted: true } } /// Whether this caller may use the glance service at all. pub fn may_use(&self) -> Result<(), String> { match self { - Caller::Contained { app, granted: false } => Err(format!("{app} was not granted the glance capability")), + Caller::Contained { app, admitted: false } => Err(format!("{app} was not an admitted app publisher")), _ => Ok(()), } } @@ -970,18 +961,17 @@ impl octosense_appstore::services::HostService for GlanceService { } fn call(&mut self, call: octosense_appstore::services::ServiceCall, reply: octosense_appstore::services::Replier, _host: &mut dyn octosense_appstore::services::ServiceHost) { if call.method() == "take_open" { - if call.from_sheet || !call.may_prompt { + if call.from_sheet || !call.may_prompt || crate::host_tools::script_apps::admitted_host(&call.app_id, &call.host_dir).is_err() { reply.send(Err("Open routes belong to the foreground app".into())); } else { reply.send(Ok(json!({"route":crate::glance_routes::take(&call.app_id)}))); } return; } - // The identity is the runner's, never the app's arguments. A call from - // the app's own isolate passed the runner's gate, which requires the - // `glance` capability; a host sheet's isolate has no app policy and - // holds no grant. - let caller = Caller::Contained { app: call.app_id.clone(), granted: !call.from_sheet }; + // The identity is the runner's, never the app's arguments. Verify + // admission and the host profile; declarations do not grant access. + // Host sheets cannot act as an app publisher. + let caller = Caller::Contained { app: call.app_id.clone(), admitted: !call.from_sheet && crate::host_tools::script_apps::admitted_host(&call.app_id, &call.host_dir).is_ok() }; reply.send(request(&caller, &call.service, &call.args)); } } @@ -992,19 +982,19 @@ pub fn register() { } /// Publish a card for contained app `app` from one of its host services: -/// a tool call runs outside the app's isolate, where the Card runner's gate -/// does not, so the grant is the app's admitted manifest's `glance` -/// (`script_apps::grants`). Calendar's cards and every app's notice +/// a tool call runs outside the app's isolate, so recheck its admitted +/// identity. Capability declarations do not gate publication. Calendar's +/// cards and every app's notice /// (glance_notice.rs) go out this way. #[cfg(any(feature = "app-hub", native_mobile))] pub fn publish_for(app: &str, args: &Value) -> Result { - let caller = Caller::Contained { app: app.to_string(), granted: crate::host_tools::script_apps::grants(app, "glance") }; + let caller = Caller::Contained { app: app.to_string(), admitted: crate::host_tools::script_apps::admitted(app) }; request(&caller, "glance.publish", args) } #[cfg(any(feature = "app-hub", native_mobile))] pub fn withdraw_for(app: &str, id: &str) -> Result<(), String> { - let caller = Caller::Contained {app:app.into(),granted:crate::host_tools::script_apps::grants(app,"glance")}; + let caller = Caller::Contained {app:app.into(),admitted:crate::host_tools::script_apps::admitted(app)}; request(&caller,"glance.withdraw",&json!({"card_id":id})).map(|_| ()) } @@ -1022,7 +1012,7 @@ fn restore_calendar_publications() { static RESTORED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); if RESTORED.load(Ordering::Acquire) { return; } let Some(host) = crate::app_storage::host() else { return; }; - if !crate::host_tools::script_apps::grants("os.calendar","glance") { return; } + if !crate::host_tools::script_apps::admitted("os.calendar") { return; } let root = host.layout().apps_root().join(".host"); let now = now_ms(); let Ok(publications) = octosense_calendar_service::publications(&root,now) else { return; }; @@ -1033,7 +1023,7 @@ fn restore_calendar_publications() { args["notify"] = json!(false); args["expires"] = json!(EXPIRES_MAX_S); let mut temporary = GlanceStore::default(); - if temporary.publish(&Caller::granted("os.calendar"),&args,now).is_err() { continue; } + if temporary.publish(&Caller::admitted("os.calendar"),&args,now).is_err() { continue; } let Some(mut card) = temporary.cards.pop() else { continue; }; card.published_ms = p.published; card.expires_ms = p.expires; if with_store(|store| store.restore_publication(card,now)) { changed(); } @@ -1085,7 +1075,7 @@ pub fn publish_mail_l0_for(app: &str, args: &Value) -> Result { let mut binding: crate::mail_card::Binding = serde_json::from_value(metadata.clone()).map_err(|_| "Invalid Mail host binding")?; binding.card_id = args["card_id"].as_str().ok_or("Missing card_id")?.to_string(); binding.validate()?; - let caller = Caller::Contained { app: app.to_string(), granted: crate::host_tools::script_apps::grants(app, "glance") }; + let caller = Caller::Contained { app: app.to_string(), admitted: crate::host_tools::script_apps::admitted(app) }; caller.may_use()?; if crate::mail_card::completed(&binding) { // A repair/retry of a completed message must not resurrect its card or @@ -1117,7 +1107,7 @@ pub(crate) fn restore_mail_publication(args: &Value, binding: crate::mail_card:: let source = args["source"].as_str().ok_or("Missing L0 source")?; check_generated_l0(source)?; check_generated_data(source, &args["data"])?; - let caller = Caller::Contained { app: binding.publisher.clone(), granted: crate::host_tools::script_apps::grants(&binding.publisher, "glance") }; + let caller = Caller::Contained { app: binding.publisher.clone(), admitted: crate::host_tools::script_apps::admitted(&binding.publisher) }; let mut temporary = GlanceStore::default().with_digest_root(crate::glance_digest::digest_root()); let mut args = args.clone(); args["notify"] = json!(false); @@ -1142,7 +1132,7 @@ pub(crate) fn restore_notification_for(app: &str, args: &Value, account: &str, p } let now = now_ms(); if expires <= now || published >= expires {return Err("Expired publication".into());} - let caller = Caller::Contained {app:app.into(),granted:crate::host_tools::script_apps::grants(app,"glance")}; + let caller = Caller::Contained {app:app.into(),admitted:crate::host_tools::script_apps::admitted(app)}; let mut temporary = GlanceStore::default().with_digest_root(crate::glance_digest::digest_root()); let mut args = args.clone(); args["notify"]=json!(false); args["expires"]=json!(EXPIRES_MAX_S); temporary.publish(&caller,&args,now)?; @@ -1170,7 +1160,7 @@ pub(crate) fn restore_mail_notification(args: &Value, account: &str, published: let source = args["source"].as_str().ok_or("Missing source")?; check_generated_l0(source)?; check_generated_data(source, &args["data"])?; - let caller = Caller::Contained { app:"os.mail".into(), granted:crate::host_tools::script_apps::grants("os.mail", "glance") }; + let caller = Caller::Contained { app:"os.mail".into(), admitted:crate::host_tools::script_apps::admitted("os.mail") }; let mut temporary = GlanceStore::default().with_digest_root(crate::glance_digest::digest_root()); let mut args = args.clone(); args["notify"] = json!(false); @@ -1301,7 +1291,7 @@ pub fn publish_demo_if_asked() { crate::glance_chat::set_demo_mail(true); seed_demo_mail_chat("os.mail"); for args in demo_mail_publishes() { - if let Err(e) = request(&Caller::granted("os.mail"), "glance.publish", &args) { + if let Err(e) = request(&Caller::admitted("os.mail"), "glance.publish", &args) { makepad_widgets::log!("glance: demo mail card refused: {e}"); } } @@ -1311,7 +1301,7 @@ pub fn publish_demo_if_asked() { #[cfg(any(feature = "app-hub", native_mobile))] for &(app, card_id, title, body) in DEMO_NOTICES { let args = crate::glance_notice::publish_args(app, &json!({"title": title, "body": body, "card_id": card_id}), now_ms()); - if let Err(e) = args.and_then(|args| request(&Caller::granted(app), "glance.publish", &args)) { + if let Err(e) = args.and_then(|args| request(&Caller::admitted(app), "glance.publish", &args)) { makepad_widgets::log!("glance: demo notice {card_id} refused: {e}"); } } @@ -1321,7 +1311,7 @@ pub fn publish_demo_if_asked() { "card_id": "digest", "title": "News digest", "source": source, "data": data, "priority": 70, "open": {"app": "news"} }); - if let Err(e) = request(&Caller::granted("os.news"), "glance.publish", &args) { + if let Err(e) = request(&Caller::admitted("os.news"), "glance.publish", &args) { makepad_widgets::log!("glance: demo digest refused: {e}"); } }); @@ -1394,7 +1384,7 @@ mod tests { } fn news() -> Caller { - Caller::granted("os.news") + Caller::admitted("os.news") } fn args(card_id: &str) -> Value { let (source, data) = demo_digest(); @@ -1444,7 +1434,7 @@ mod tests { let shown = store.shown(1_000, usize::MAX); assert_eq!((shown[0].app.as_str(), shown[0].open_app.as_str()), ("os.news", "news")); // Another app sees, replaces and withdraws only its own cards. - let maps = Caller::granted("os.maps"); + let maps = Caller::admitted("os.maps"); assert_eq!(store.list(&maps, 1_000).unwrap(), json!([])); assert_eq!(store.withdraw(&maps, &json!({"card_id": "digest"}), 1_000).unwrap()["withdrawn"], false); assert_eq!(store.len(), 1); @@ -1458,11 +1448,11 @@ mod tests { use octosense_calendar_service as cal; let mut store = GlanceStore::default(); let event = cal::event_card_args("Dentist", "Fri 2 Oct", "15:00\u{2013}16:00", "Main St", "Bring the form", "ev-1", 70); - store.publish(&Caller::granted("os.calendar"), &event, 1_000).expect("Calendar event template must be admitted"); + store.publish(&Caller::admitted("os.calendar"), &event, 1_000).expect("Calendar event template must be admitted"); let now = cal::parse_time("2026-10-01T09:00").unwrap(); let events = vec![cal::Event { id: "a".into(), title: "Standup".into(), start: "2026-10-02T09:30".into(), end: None, location: String::new(), notes: String::new(), request_id: String::new(), timezone: String::new() }]; let agenda = cal::agenda_card_args(&events, 7, now); - assert!(store.publish(&Caller::granted("os.calendar"), &agenda, 1_000).is_ok()); + assert!(store.publish(&Caller::admitted("os.calendar"), &agenda, 1_000).is_ok()); let shown = store.shown(1_000, usize::MAX); assert!(shown.iter().all(|c| c.app == "os.calendar" && c.open_app == "calendar")); } @@ -1474,11 +1464,11 @@ mod tests { fn mails_notice_card_is_admitted_as_mails_own() { let mut store = GlanceStore::default(); let args = crate::glance_notice::publish_args("os.mail", &json!({"title": "Hello", "body": "From the system agent", "card_id": "hello"}), 1).unwrap(); - let ok = store.publish(&Caller::granted("os.mail"), &args, 1_000).unwrap(); + let ok = store.publish(&Caller::admitted("os.mail"), &args, 1_000).unwrap(); assert_eq!(ok["card_id"], "hello"); let shown = store.shown(1_000, usize::MAX); assert_eq!((shown[0].app.as_str(), shown[0].open_app.as_str()), ("os.mail", "mail")); - let ungranted = Caller::Contained { app: "os.mail".into(), granted: false }; + let ungranted = Caller::Contained { app: "os.mail".into(), admitted: false }; assert!(store.publish(&ungranted, &args, 1_000).is_err()); } @@ -1491,11 +1481,11 @@ mod tests { for app in ["os.news", "os.photos", "os.maps", "os.youtube", "os.camera", "os.calendar", "os.ai-providers"] { let mut store = GlanceStore::default(); let args = crate::glance_notice::publish_args(app, &json!({"title": "Hello", "body": "From the system agent", "card_id": "hello"}), 1).unwrap(); - let ok = store.publish(&Caller::granted(app), &args, 1_000); + let ok = store.publish(&Caller::admitted(app), &args, 1_000); assert!(ok.is_ok(), "{app}: {ok:?}"); let shown = store.shown(1_000, usize::MAX); assert_eq!((shown[0].app.as_str(), shown[0].open_app.as_str()), (app, app.strip_prefix("os.").unwrap())); - assert!(store.publish(&Caller::granted("os.mail"), &args, 1_000).unwrap_err().contains("opens the app that published it"), "{app}"); + assert!(store.publish(&Caller::admitted("os.mail"), &args, 1_000).unwrap_err().contains("opens the app that published it"), "{app}"); } } @@ -1558,7 +1548,7 @@ mod tests { let err = store.publish(&news(), &brief(&other), now()).unwrap_err(); assert!(err.contains("own app's digests"), "{err}"); // Maps naming itself gets its own (empty) folder, never News's. - let maps = Caller::granted("os.maps"); + let maps = Caller::admitted("os.maps"); let mut args = brief(&NEWS_BRIEF_CARD.replace("app: \"os.news\"", "app: \"os.maps\"")); args["open"] = Value::Null; store.publish(&maps, &args, now()).unwrap(); @@ -1606,19 +1596,19 @@ mod tests { } #[test] - fn a_contained_app_needs_the_glance_capability_whoever_it_is() { + fn a_contained_publisher_needs_admission_whoever_it_is() { let mut store = GlanceStore::default(); - // A store app with the grant publishes like a system app, under the + // An admitted store app publishes like a system app, under the // same limits, and its card opens only itself. let mut a = args("d"); a["open"] = json!({"app": "com.example.news"}); - assert!(store.publish(&Caller::granted("com.example.news"), &a, 0).is_ok()); - // Without the grant, no app may publish, list or withdraw; being a - // system app is not a grant. + assert!(store.publish(&Caller::admitted("com.example.news"), &a, 0).is_ok()); + // Without admission, no app may publish, list or withdraw; a + // system-looking app id is not sufficient. for app in ["com.example.other", "os.maps"] { - let ungranted = Caller::Contained { app: app.into(), granted: false }; + let ungranted = Caller::Contained { app: app.into(), admitted: false }; let err = store.publish(&ungranted, &args("d"), 0).unwrap_err(); - assert!(err.contains("not granted the glance capability"), "{err}"); + assert!(err.contains("not an admitted app publisher"), "{err}"); assert!(store.list(&ungranted, 0).is_err()); assert!(store.withdraw(&ungranted, &json!({"card_id": "d"}), 0).is_err()); } @@ -1627,21 +1617,6 @@ mod tests { assert_eq!(store.len(), 2); } - /// The grant is the Card runner's: its isolate gate lets `glance.*` out - /// only for an app whose resolved policy lists `glance` (a prefix or a - /// neighbouring family is not enough). - #[test] - fn the_runner_gate_admits_glance_only_with_the_capability() { - use makepad_widgets::splash_policy::{service_allowed, set_policy_for_heap}; - set_policy_for_heap(9201, vec!["storage".into(), "news".into()], Vec::new(), None); - assert!(service_allowed(9201, "glance.publish").is_err()); - set_policy_for_heap(9202, vec!["glance".into()], Vec::new(), None); - for method in ["glance.publish", "glance.withdraw", "glance.list"] { - assert!(service_allowed(9202, method).is_ok(), "{method}"); - } - assert!(service_allowed(9202, "news.list").is_err(), "glance grants nothing else"); - } - #[test] fn l2_and_invalid_cards_are_refused() { let mut store = GlanceStore::default(); @@ -1692,7 +1667,7 @@ mod tests { a["notify"] = json!("yes"); assert!(GlanceStore::default().publish(&news(), &a, 0).unwrap_err().contains("notify")); a["notify"] = json!(true); - request(&Caller::granted("os.notifytest"), "glance.publish", &{ + request(&Caller::admitted("os.notifytest"), "glance.publish", &{ a["open"] = Value::Null; a }) @@ -1700,7 +1675,7 @@ mod tests { let notes = take_notifications(); let note = notes.iter().find(|n| n.app == "os.notifytest").expect("queued"); assert_eq!((note.key.as_str(), note.title.as_str()), ("os.notifytest/notify-test", "News digest")); - request(&Caller::granted("os.notifytest"), "glance.withdraw", &json!({"card_id": "notify-test"})).unwrap(); + request(&Caller::admitted("os.notifytest"), "glance.withdraw", &json!({"card_id": "notify-test"})).unwrap(); } /// The fake Mail cards pass the L0 admission and publish as `os.mail`, @@ -1713,9 +1688,9 @@ mod tests { // publish it. let mut ana = demo_mail_publishes().remove(0); ana["open"] = Value::Null; - let err = GlanceStore::default().publish(&Caller::granted("os.mailtest"), &ana, 0).unwrap_err(); + let err = GlanceStore::default().publish(&Caller::admitted("os.mailtest"), &ana, 0).unwrap_err(); assert!(err.contains("own app's agent only (os.mailtest), not os.mail"), "{err}"); - let mail = Caller::granted("os.mail"); + let mail = Caller::admitted("os.mail"); let mut targets = NoteTargets::default(); let mut keys = Vec::new(); for (i, mut args) in demo_mail_publishes().into_iter().enumerate() { @@ -1761,12 +1736,12 @@ mod tests { view root Surface(pad: .page) {\n Col(gap: 8) {\n for m in convo.entries key m.id { ChatEntry(text: m.text, role: m.role) }\n }\n}\n"; let forged = json!({"convo": {"status": "ready", "count": 1, "entries": [{"id": "x", "role": "model", "text": "FORGED ENTRY", "at": 0}]}}); let mut store = GlanceStore::default(); - store.publish(&Caller::granted("os.chatpub"), &json!({"card_id": "c", "title": "t", "source": card, "data": forged}), 0).unwrap(); + store.publish(&Caller::admitted("os.chatpub"), &json!({"card_id": "c", "title": "t", "source": card, "data": forged}), 0).unwrap(); let published = store.card("os.chatpub/c", 0).unwrap(); assert!(!published.body.contains("FORGED ENTRY"), "{}", published.body); // The host's own entry is. crate::glance_chat::store().seed_if_empty("os.chatpub", "main", &[(crate::glance_chat::Role::Model, "The host's entry")], 0); - store.publish(&Caller::granted("os.chatpub"), &json!({"card_id": "c", "title": "t", "source": card, "data": forged}), 1).unwrap(); + store.publish(&Caller::admitted("os.chatpub"), &json!({"card_id": "c", "title": "t", "source": card, "data": forged}), 1).unwrap(); assert!(store.card("os.chatpub/c", 1).unwrap().body.contains("The host's entry")); } @@ -1808,7 +1783,7 @@ mod tests { // Another app has its own window. let mut maps = args("digest"); maps["open"] = json!({"app": "maps"}); - assert!(store.publish(&Caller::granted("os.maps"), &maps, 2_000).is_ok()); + assert!(store.publish(&Caller::admitted("os.maps"), &maps, 2_000).is_ok()); // The window slides. assert!(store.publish(&news(), &args("digest"), 1_000 + RATE_WINDOW_MS).is_ok()); } @@ -1841,7 +1816,7 @@ mod tests { store.publish(&news(), &incoming, 2).unwrap(); let mut maps = args("commute"); maps["open"] = Value::Null; - store.publish(&Caller::granted("os.maps"), &maps, 3).unwrap(); + store.publish(&Caller::admitted("os.maps"), &maps, 3).unwrap(); let one_card = store.cards.iter().map(GlanceCard::retained_bytes).max().unwrap(); store.trim_payloads(Some("os.news/incoming"), one_card, usize::MAX); assert!(store.card("os.news/older", 3).is_none()); @@ -1973,7 +1948,7 @@ mod tests { let mut a = args(&format!("c{i}")); a["priority"] = json!(p); a["open"] = Value::Null; - store.publish(&Caller::granted(app), &a, t).unwrap(); + store.publish(&Caller::admitted(app), &a, t).unwrap(); } } let shown = store.shown(t, usize::MAX); @@ -1988,6 +1963,18 @@ mod tests { #[cfg(feature = "app-hub")] #[test] fn the_card_runner_dispatch_carries_the_callers_identity() { + const CHILD: &str = "OCTOSENSE_GLANCE_ADMISSION_TEST"; + if std::env::var_os(CHILD).is_none() { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "glance::tests::the_card_runner_dispatch_carries_the_callers_identity", "--nocapture"]) + .env(CHILD, "1").output().unwrap(); + assert!(output.status.success(), "{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); + return; + } + let root = std::env::temp_dir().join(format!("glance-admission-{}", uuid::Uuid::new_v4())); + octosense_appstore::set_data_root(root.clone()); + crate::host_tools::script_apps::tests::declaration_fixture("os.news", &["glance"]); + crate::host_tools::script_apps::tests::declaration_fixture("os.glanceundeclared", &[]); use octosense_appstore::services::{dispatch, take_replies_for, ServiceCall, ServiceHost}; struct NoSheets; impl ServiceHost for NoSheets { @@ -1995,7 +1982,7 @@ mod tests { fn close_sheet(&mut self) {} } register(); - let call = |app: &str, service: &str, args: Value| ServiceCall { app_id: app.into(), service: service.into(), args, from_sheet: false, may_prompt: true, host_dir: std::env::temp_dir() }; + let call = |app: &str, service: &str, args: Value| ServiceCall { app_id: app.into(), service: service.into(), args, from_sheet: false, may_prompt: true, host_dir: root.join(".host") }; let from_sheet = |app: &str, service: &str, args: Value| ServiceCall { from_sheet: true, ..call(app, service, args) }; let mut spoof = args("dispatch-test"); spoof["app"] = json!("os.mail"); @@ -2004,13 +1991,24 @@ mod tests { assert!(refused[0].2.as_ref().unwrap_err().contains("caller"), "{refused:?}"); // A host sheet over an app runs under no app policy: no grant. dispatch(from_sheet("os.news", "glance.publish", args("dispatch-test")), 9102, 1, &mut NoSheets); - assert!(take_replies_for(&[9102])[0].2.as_ref().unwrap_err().contains("not granted the glance capability")); + assert!(take_replies_for(&[9102])[0].2.as_ref().unwrap_err().contains("not an admitted app publisher")); dispatch(call("os.news", "glance.publish", args("dispatch-test")), 9103, 1, &mut NoSheets); assert!(take_replies_for(&[9103])[0].2.is_ok()); assert!(shown().iter().any(|c| c.key() == "os.news/dispatch-test" && c.open_app == "news")); dispatch(call("os.news", "glance.withdraw", json!({"card_id": "dispatch-test"})), 9104, 1, &mut NoSheets); assert!(take_replies_for(&[9104])[0].2.as_ref().unwrap().contains("true")); assert!(!shown().iter().any(|c| c.key() == "os.news/dispatch-test")); + let mut undeclared = args("undeclared-test"); + undeclared["open"] = Value::Null; + dispatch(call("os.glanceundeclared", "glance.publish", undeclared.clone()), 9105, 1, &mut NoSheets); + assert!(take_replies_for(&[9105])[0].2.is_ok(), "a declaration is not a publication grant"); + let mut foreign = call("os.glanceundeclared", "glance.publish", undeclared); + foreign.host_dir = root.join("foreign/.host"); + dispatch(foreign, 9106, 1, &mut NoSheets); + assert!(take_replies_for(&[9106])[0].2.is_err(), "host profile identity still matters"); + dispatch(call("os.glanceundeclared", "glance.withdraw", json!({"card_id":"undeclared-test"})), 9107, 1, &mut NoSheets); + assert!(take_replies_for(&[9107])[0].2.is_ok()); + let _ = std::fs::remove_dir_all(root); } #[test] fn publication_account_is_host_metadata_and_stale_accounts_cannot_open() { @@ -2018,7 +2016,7 @@ mod tests { let mut input = args("account-bound"); input.as_object_mut().unwrap().remove("open"); input["account"] = json!("forged-account"); - store.publish(&Caller::granted("os.account-probe"), &input, 0).unwrap(); + store.publish(&Caller::admitted("os.account-probe"), &input, 0).unwrap(); let mut card = store.card("os.account-probe/account-bound", 0).unwrap(); assert_ne!(card.account.as_deref(), Some("forged-account")); card.account = Some("signed-out-test-account".into()); diff --git a/crates/shell/src/glance_card.rs b/crates/shell/src/glance_card.rs index 23c9019a3..37716459e 100644 --- a/crates/shell/src/glance_card.rs +++ b/crates/shell/src/glance_card.rs @@ -1309,22 +1309,34 @@ fn isolate_of(cx: &mut Cx, splash: &SplashRef) -> Option bool { #[cfg(any(feature = "app-hub", native_mobile))] if contained { - match admitted_app_isolate(cx, app) { - Ok(mut settings) => { - // Glance is a separate isolate: apply the same opt-in device - // consent gate as the full app before evaluating its source. - let requires_consent = crate::host_tools::script_apps::guidance(app) - .map(|loaded| loaded.manifest["requires"].as_array().is_some_and(|features| - features.iter().any(|feature| feature.as_str() == Some("host-api-v1")))) - .unwrap_or(true); - splash.set_device_consent(cx, requires_consent); - let admitted_prompts = settings.host_prompts; - settings.host_prompts = false; - let applied = octosense_app_policy::splash_adapter::apply(splash, cx, &settings); + // A legacy manifest must not inherit the shell's OS permissions. + // Keep this fail-closed state if admission or the manifest reload fails. + splash.set_device_consent(cx, true); + splash.set_host_caps(cx, Vec::new()); + splash.set_host_tag(cx, None); + let seated = (|| { + let mut settings = admitted_app_isolate(cx, app)?; + let (_, bundle) = crate::host_tools::script_apps::admitted_bundle(app)?; + let admitted_prompts = settings.host_prompts; + settings.host_prompts = false; + let applied = octosense_app_policy::splash_adapter::apply(splash, cx, &settings); + // The generic adapter enables only non-device APIs. Use the same + // host-consent and capture-intent setup as the full app, before any + // card code runs. Identity never comes from the published body. + splash.set_host_tag(cx, Some(app.to_string())); + octosense_appstore::apply_device_consent(cx, &bundle, splash)?; + Ok::<_, String>((admitted_prompts, applied)) + })(); + match seated { + Ok((admitted_prompts, applied)) => { log!("glance: {app}'s tile runs under its policy: {} capability(ies), {} host(s)", applied.capabilities, applied.hosts); return admitted_prompts; } - Err(e) => log!("glance: {app}'s tile runs with no grants: {e}"), + Err(e) => { + splash.set_host_caps(cx, Vec::new()); + splash.set_host_tag(cx, None); + log!("glance: {app}'s tile runs with no grants: {e}"); + } } } let _ = (app, contained); @@ -1438,7 +1450,7 @@ mod tests { } /// A system app, registered from a pack made on the fly, whose manifest - /// grants `glance` and `storage` and nothing else. It is registered as + /// declares `glance` and `storage`. It is registered as /// a test's, so the catalog tests running beside these leave it out. #[cfg(feature = "app-hub")] fn register_test_app(id: &'static str) { @@ -1472,6 +1484,47 @@ mod tests { splash.borrow_mut().unwrap().isolate_heap_key(cx).expect("the card runs in its own isolate") } + /// Admission and consent registries are process-wide. Exercise the real + /// bundle loader in a private profile, independently of parallel tests. + #[cfg(feature = "app-hub")] + struct AppProfile(std::path::PathBuf); + #[cfg(feature = "app-hub")] + impl AppProfile { + fn isolated(test: &str) -> Option { + const CHILD: &str = "OCTOSENSE_GLANCE_PROFILE_TEST"; + if std::env::var(CHILD).ok().as_deref() != Some(test) { + let name = format!("glance_card::tests::{test}"); + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", &name, "--nocapture"]) + .env(CHILD, test).output().unwrap(); + assert!(output.status.success(), "{}\n{}", + String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); + return None; + } + let root = std::env::temp_dir().join(format!("glance-profile-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&root).unwrap(); + std::env::set_var("OCTOSENSE_APP_DATA", &root); + octosense_appstore::set_data_root(root.clone()); + Some(Self(root)) + } + } + #[cfg(feature = "app-hub")] + impl Drop for AppProfile { + fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } + } + + #[cfg(feature = "app-hub")] + fn queued_glance_request(cx: &mut Cx, splash: &SplashRef) -> splash_host::SplashHostRequest { + let vm = isolate_of(cx, splash).unwrap(); + widget_async::with_isolate(cx, vm, |cx| cx.with_vm(|vm| { + script_eval!(vm, {mod.host.request("glance.list", {}, nil)}); + })); + let heap = heap_of(cx, splash); + let mut requests = splash_host::take_splash_host_requests_for(&[heap]); + assert_eq!(requests.len(), 1); + requests.pop().unwrap() + } + #[test] #[cfg(feature = "app-hub")] fn selected_glance_style_reaches_existing_cards_without_replacing_state() { @@ -1538,39 +1591,53 @@ mod tests { tiles.sweep(&mut cx, &[]); } - /// A contained app's tile runs under that app's resolved policy, the one - /// its Card runner applies: its grants pass the isolate's gate, and - /// nothing it was not granted does. A native module's tile gets none. + /// A contained app's tile retains its admitted identity and public APIs, + /// independently of declarations, without inheriting device consent. + /// A native module's tile never becomes a contained app host surface. #[cfg(feature = "app-hub")] #[test] fn a_tile_runs_under_its_apps_policy() { + let Some(_profile) = AppProfile::isolated("a_tile_runs_under_its_apps_policy") else { return }; use makepad_widgets::splash_policy::service_allowed; register_test_app("os.glancetile"); let mut cx = tile_cx(); let mut tiles = GlanceTiles::default(); let app = tiles.open(&mut cx, "os.glancetile/c", "os.glancetile", true, &"View{}".into()); let heap = heap_of(&mut cx, &app); - assert!(service_allowed(heap, "glance.list").is_ok(), "the app's grant"); - assert!(service_allowed(heap, "mail.send").is_err(), "not granted to the app"); + assert_eq!(queued_glance_request(&mut cx, &app).app_tag, "os.glancetile"); + assert!(service_allowed(heap, "glance.list").is_ok(), "declared public API"); + // Public host requests bypass declaration checks. service_allowed is + // the device/private-runtime gate, not the public service dispatcher. + let vm = isolate_of(&mut cx, &app).unwrap(); + widget_async::with_isolate(&mut cx, vm, |cx| cx.with_vm(|vm| { + script_eval!(vm, {mod.host.request("mail.send", {}, nil)}); + })); + let requests = splash_host::take_splash_host_requests_for(&[heap]); + assert_eq!(requests.len(), 1, "an undeclared public method reaches the host boundary"); + assert_eq!(requests[0].app_tag, "os.glancetile"); + assert_eq!(requests[0].service, "mail.send"); + assert!(!requests[0].may_prompt, "public API availability cannot authorize a review or send"); + assert!(service_allowed(heap, "camera.preview").unwrap_err().starts_with("authorization_required:"), + "an admitted app still needs its own device consent"); let native = tiles.open(&mut cx, "news/c", "news", false, &"View{}".into()); let heap = heap_of(&mut cx, &native); + assert!(queued_glance_request(&mut cx, &native).app_tag.is_empty()); assert!(service_allowed(heap, "glance.list").is_err(), "a native module's tile has no grants"); + tiles.sweep(&mut cx, &[]); } #[cfg(feature = "app-hub")] #[test] fn only_foreground_workspaces_prompt_and_host_sheet_is_modal_and_retired() { + let Some(_profile) = AppProfile::isolated("only_foreground_workspaces_prompt_and_host_sheet_is_modal_and_retired") else { return }; register_test_app("os.glanceforeground"); crate::glance::register(); widget_async::register_splash_isolate_mod(|vm| {script_mod(vm);}); let mut cx = tile_cx(); let ask = |cx: &mut Cx, splash: &SplashRef| { - let vm = isolate_of(cx, splash).unwrap(); - widget_async::with_isolate(cx, vm, |cx| cx.with_vm(|vm| { - script_eval!(vm, {mod.host.request("glance.list", {}, nil)}); - })); - let heap = heap_of(cx, splash); - splash_host::take_splash_host_requests_for(&[heap]).pop().unwrap() + let request = queued_glance_request(cx, splash); + assert_eq!(request.app_tag, "os.glanceforeground"); + request }; let mut background = GlanceTiles::default(); let summary = background.open(&mut cx, "summary", "os.glanceforeground", true, &"View{}".into()); @@ -1747,6 +1814,16 @@ mod tests { #[cfg(feature = "app-hub")] #[test] fn input_reaches_the_card_and_its_requests_go_out_as_the_app() { + const CHILD: &str = "OCTOSENSE_GLANCE_INPUT_ADMISSION_TEST"; + if std::env::var_os(CHILD).is_none() { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "glance_card::tests::input_reaches_the_card_and_its_requests_go_out_as_the_app", "--nocapture"]) + .env(CHILD, "1").output().unwrap(); + assert!(output.status.success(), "{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); + return; + } + let root = std::env::temp_dir().join(format!("glance-input-admission-{}", uuid::Uuid::new_v4())); + octosense_appstore::set_data_root(root.clone()); register_test_app("os.glanceinput"); crate::glance::register(); widget_async::register_splash_isolate_mod(|vm| { @@ -1760,6 +1837,7 @@ mod tests { // The request the card queued goes out on the next event. tiles.handle_event(&mut cx, &Event::Signal); assert!(crate::glance::shown().iter().any(|c| c.key() == "os.glanceinput/typed" && c.contained), "published as the tile's app"); + let _ = std::fs::remove_dir_all(root); } fn mail_session(card_id: &str) -> L0Session { @@ -1980,7 +2058,7 @@ mod tests { let args = serde_json::json!({"card_id":"navigation", "title":"Appointment", "source":source, "data":{"info":{"url1":"app://calendar/event/fixture"}}, "open":{"app":"calendar","route":"event/fixture"}}); let mut store = GlanceStore::default(); - store.publish(&Caller::granted("os.calendar"), &args, 0).unwrap(); + store.publish(&Caller::admitted("os.calendar"), &args, 0).unwrap(); let card = store.card("os.calendar/navigation", 0).unwrap(); let mut live = LiveCards::default(); live.prepare_native("tile", &card); @@ -2026,7 +2104,7 @@ mod tests { crate::glance_chat::store().seed_if_empty("os.mail", "panel-taps", &[(Role::User, "Earlier?"), (Role::Model, "Net 30.")], 0); let publish = serde_json::json!({"card_id": "panel", "title": title, "source": source, "data": data}); let mut store = GlanceStore::default(); - store.publish(&Caller::granted("os.mail"), &publish, 0).unwrap(); + store.publish(&Caller::admitted("os.mail"), &publish, 0).unwrap(); let card = store.card("os.mail/panel", 0).unwrap(); let key = card.key(); @@ -2067,7 +2145,7 @@ mod tests { assert!(answered.contains("When do they need it?") && answered.contains(DEMO_ANSWER), "{answered}"); // A newer publish of the card starts it over, as published. - store.publish(&Caller::granted("os.mail"), &publish, 1).unwrap(); + store.publish(&Caller::admitted("os.mail"), &publish, 1).unwrap(); let newer = store.card("os.mail/panel", 1).unwrap(); assert!(!live.body(&key, &newer, "glance panel").contains(DEMO_ANSWER), "back to the brief"); // A tile the surface drops takes its session and its queued taps. @@ -2087,7 +2165,7 @@ mod tests { use crate::glance::{Caller, GlanceStore}; let (_, _, source, data) = crate::glance::demo_mail().into_iter().find(|c| c.0 == "ups-lamp").unwrap(); let mut store = GlanceStore::default(); - let shop = Caller::granted("com.example.shop"); + let shop = Caller::admitted("com.example.shop"); store.publish(&shop, &serde_json::json!({"card_id": "parcel", "title": "Your parcel", "source": source, "data": data}), 0).unwrap(); let card = store.card("com.example.shop/parcel", 0).unwrap(); let mut cx = tile_cx(); diff --git a/crates/shell/src/host_tools/areas.rs b/crates/shell/src/host_tools/areas.rs index 1be8a471c..f7e429357 100644 --- a/crates/shell/src/host_tools/areas.rs +++ b/crates/shell/src/host_tools/areas.rs @@ -131,7 +131,7 @@ impl AreaEnv for ShellEnv { } } let policy = admitted_policy(app)?; - let quota = JailQuota { bytes: Some(policy.storage_bytes), storage: policy.capabilities.contains("storage") }; + let quota = JailQuota { bytes: Some(policy.storage_bytes), storage: true }; if system { SYSTEM.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).insert(app.to_string(), quota); } diff --git a/crates/shell/src/host_tools/script_apps.rs b/crates/shell/src/host_tools/script_apps.rs index ff689e675..d6526203a 100644 --- a/crates/shell/src/host_tools/script_apps.rs +++ b/crates/shell/src/host_tools/script_apps.rs @@ -18,11 +18,9 @@ //! is `implemented_by: "host-service"` runs on the host service of its //! namespace (`news.list` → the `news` service), exactly as the app's own //! `host.request("news.list", …)` would: with the app's identity, never -//! from a sheet, and only when the app's manifest was granted that family, -//! or the family is a system app's own namespace (`os.calendar` and its -//! `calendar` service, which ship with the shell; `os.photos`'s `photos` -//! service, which answers `photos.info` on the photo engine and -//! `photos.notify` through the shell's notice hook, `glance_notice`). +//! from a sheet. Manifest families describe usage and do not gate calls. +//! The relay checks tool ownership and sharing; each host service retains +//! its identity, account, consent and review requirements. //! A declared tool whose engine this build leaves out never runs: Photos' //! `photos.info` on Home, where the photo engine is desktop only (ADR 0013) //! and the shell's notice service answers Photos' `notify`, is refused as @@ -61,7 +59,7 @@ pub struct Loaded { pub generic: Vec, /// Other apps' tools its agent asks for (owner resolved at grant time). pub asks: Vec, - /// Its tools that run on host services, and the families it was granted. + /// Its tools that run on host services, and the families it declares. pub host_service_tools: BTreeSet, pub script_tools: BTreeSet, pub host_methods: HashMap, @@ -146,8 +144,7 @@ pub(crate) fn glance_template(app: &str, name: &str) -> Result { return Err("Choose a Splash template basename from this app's admitted bundle".into()); } let (_, bundle) = admitted_bundle(app)?; - let loaded = from_bundle(&bundle)?; - if !loaded.families.contains("glance") {return Err("This app has no Glance grant".into());} + from_bundle(&bundle)?; let path = bundle.join(name); if !std::fs::symlink_metadata(&path).is_ok_and(|m|m.is_file() && m.len() <= 256 * 1024) { return Err("The admitted Glance template is missing or too large".into()); @@ -217,13 +214,32 @@ pub(crate) fn admitted_bundle(app: &str) -> Result<(PathBuf, PathBuf), String> { Ok((root, bundle)) } -/// Whether `app`'s admitted manifest was granted the capability `family`: -/// for a host service acting for the app outside its isolate (a tool call), -/// where the Card runner's gate does not run. +/// Whether an admitted manifest declares a family, for disclosure only. +/// Execution must check identity, consent and account scope independently. pub fn grants(app: &str, family: &str) -> bool { admitted_bundle(app).and_then(|(_, bundle)| from_bundle(&bundle)).is_ok_and(|loaded| loaded.families.contains(family)) } +/// The app still has a verified, admitted bundle. Capability declarations +/// describe its intended use; they do not grant access to a host service. +pub fn admitted(app: &str) -> bool { + guidance(app).is_ok() +} + +/// Bind a host request to the current admitted app and host profile before +/// inspecting its account, opening a device, or reading private host state. +pub fn admitted_host(app: &str, host_dir: &Path) -> Result { + let (root, bundle) = admitted_bundle(app)?; + if root.join(".host") != host_dir { + return Err("Host request belongs to another app profile".into()); + } + let loaded = from_bundle(&bundle)?; + if loaded.manifest["id"].as_str() != Some(app) { + return Err("Host request belongs to another app identity".into()); + } + Ok(loaded) +} + // ------------------------------------------------------------ the executor /// Runs a script app's host-service tools as the app's own @@ -233,7 +249,7 @@ pub struct HostServiceExecutor { /// The tools that run on a host service. pub tools: BTreeSet, pub methods: HashMap, - /// The capability families the app's manifest was granted. + /// Declared capability families, retained as disclosure metadata. pub families: BTreeSet, /// The directory App Hub hands every host service (`/.host`). pub host_dir: PathBuf, @@ -433,14 +449,9 @@ impl HostServiceExecutor { return; } let family = method.split('.').next().unwrap_or(""); - // A system app's own namespace is its own host service: both ship - // with the shell (Calendar's `calendar`, which App Hub's closed - // capability list does not name). Any other family needs the grant. - let own = self.app.strip_prefix(octosense_appstore::system::SYSTEM_ID_PREFIX) == Some(family); - if !self.families.contains(family) && !own { - reply.finish(ToolOutcome::error("not_granted", format!("{} was not granted the {family} service", self.app))); - return; - } + // Tool ownership/sharing was checked by the relay. A manifest family + // is disclosure, not a second grant. The service keeps its own + // identity, account, consent and foreground-review boundaries. let mut args = match scoped_args(&self.app, &call) { Ok(args) => args, Err(message) => { @@ -648,6 +659,24 @@ pub(crate) mod tests { use crate::ai_host::app_peers::host_tools::HostToolCall; use octosense_appstore::services::{HostService, Replier}; + /// Register a real packed fixture for declaration/identity boundary tests. + /// Each caller runs in a child process before setting App Hub's data root. + pub(crate) fn declaration_fixture(id: &'static str, capabilities: &[&str]) { + let dir = stamped_bundle("camera", id, |dir, manifest| { + manifest["id"] = json!(id); + manifest["capabilities"] = json!(capabilities); + manifest["requires"] = json!(["host-api-v1"]); + for key in ["agent", "host_api"] { manifest.as_object_mut().unwrap().remove(key); } + for path in ["tools.json", "AGENT.md"] { let _ = std::fs::remove_file(dir.join(path)); } + std::fs::write(dir.join("main.splash"), "use mod.widgets.*\nApp { Label { text: \"Fixture\" } }\n").unwrap(); + }); + let packed = octosense_app_hub::pack::pack_system_app(&dir).unwrap(); + std::fs::remove_dir_all(dir).unwrap(); + octosense_appstore::system::register_system_app(octosense_appstore::system::SystemApp { + id, name: "Declaration fixture", pack: Box::leak(packed.pack_json.into_boxed_str()), assets: &[], + }); + } + /// A copy of `apps//bundle` stamped as App Hub packs it (the /// manifest carries the bundle's digest), with `edit` applied first. pub(crate) fn stamped_bundle(name: &str, tag: &str, edit: impl FnOnce(&Path, &mut Value)) -> PathBuf { @@ -1083,16 +1112,13 @@ pub(crate) mod tests { } #[test] - fn admitted_alias_dispatches_with_owner_identity_and_checks_actual_family() { + fn admitted_alias_dispatches_with_owner_identity_without_a_family_declaration() { octosense_appstore::services::register_host_service(Box::new(Probe)); - let mut exec=HostServiceExecutor {app:"org.example.notes".into(), + let exec=HostServiceExecutor {app:"org.example.notes".into(), tools:["notes.lookup".to_owned()].into_iter().collect(), methods:HashMap::from([("notes.lookup".into(),"g3probe.echo".into())]), families:["notes".to_owned()].into_iter().collect(),host_dir:std::env::temp_dir()}; let (r,sent)=reply();exec.execute(call("notes.lookup"),r); - assert_eq!(sent.lock().unwrap()[0]["error"]["kind"],"not_granted"); - exec.families.insert("g3probe".into()); - let (r,sent)=reply();exec.execute(call("notes.lookup"),r); for _ in 0..50 {poll();if !sent.lock().unwrap().is_empty(){break;} std::thread::sleep(std::time::Duration::from_millis(10));} let answer=sent.lock().unwrap(); assert_eq!(answer[0]["data"]["app"],"org.example.notes"); @@ -1235,8 +1261,8 @@ pub(crate) mod tests { } /// The executor runs a host-service tool as the app's own - /// `host.request` would (its identity, never a sheet), only for a - /// granted family; the answer reaches the call through `poll`. + /// `host.request` would (its identity, never a sheet); the answer reaches + /// the call through `poll`. The tool offer still bounds callable tools. #[test] fn a_host_service_tool_runs_as_the_apps_own_request() { octosense_appstore::services::register_host_service(Box::new(Probe)); @@ -1261,17 +1287,21 @@ pub(crate) mod tests { assert_eq!(got[0]["data"], json!({"app": "os.g3probe", "service": "g3probe.echo", "args": {"q": 1}, "from_sheet": false})); let (r, sent) = reply(); exec.execute(call("other.x"), r); - assert_eq!(sent.lock().unwrap()[0]["error"]["kind"], "not_granted", "a family the manifest was not granted"); + for _ in 0..50 { + poll(); + if !sent.lock().unwrap().is_empty() { break; } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!(sent.lock().unwrap()[0]["error"]["message"].as_str().unwrap().contains("no service answers"), "unavailable service, not a declaration denial"); let (r, sent) = reply(); exec.execute(call("g3probe.in_script"), r); assert_eq!(sent.lock().unwrap()[0]["error"]["kind"], "app_tool_unavailable"); } - /// A system app's own namespace is its own service, granted or not - /// (Calendar's `calendar`); a store app's needs the grant like any - /// other family. + /// System and installed apps use the same host dispatch path even when + /// neither declares the tool's service family. #[test] - fn a_system_apps_own_namespace_needs_no_grant() { + fn system_and_installed_tools_dispatch_without_family_declarations() { octosense_appstore::services::register_host_service(Box::new(Probe)); let run = |app: &str| { let exec = HostServiceExecutor { app: app.into(), tools: ["g3probe.echo".to_string()].into_iter().collect(), methods: Default::default(), families: Default::default(), host_dir: std::env::temp_dir() }; @@ -1288,6 +1318,6 @@ pub(crate) mod tests { got }; assert_eq!(run("os.g3probe")[0]["ok"], true, "its own service"); - assert_eq!(run("com.example.g3probe")[0]["error"]["kind"], "not_granted", "a store app is granted nothing by its id"); + assert_eq!(run("com.example.g3probe")[0]["ok"], true, "an admitted tool uses the same dispatcher regardless of declarations"); } } diff --git a/crates/shell/src/host_tools/toolbox.rs b/crates/shell/src/host_tools/toolbox.rs index e6ddca576..583deabd9 100644 --- a/crates/shell/src/host_tools/toolbox.rs +++ b/crates/shell/src/host_tools/toolbox.rs @@ -7,8 +7,8 @@ //! (`octosense_ai_host::toolbox_peers::catalog`: `workflow.run`, //! `workflow.fork`, `toolbox.search`, `toolbox.web_read`, //! `toolbox.deep_crawl`, each `app: "toolbox"` and `shareable`); -//! - each app is granted exactly the ones it declares AND the person -//! granted ([`grant_module`], [`grant_manifest`]), so the broker's +//! - script apps request exact shared names in `agent.tools`; native modules +//! use their compiled, reviewed offer ([`grant_module`], [`grant_manifest`]), so the broker's //! registration (after every prepare and reconnect) carries them, marked //! with their owner; //! - they are offered only once the person allowed the app's agent (the @@ -63,8 +63,8 @@ pub fn grant_module(app: &str, capabilities: &[&str]) { set_grant(app, ToolboxGrant::for_module(app, capabilities)); } -/// A script app's grant from its manifest (App Hub #26's shape; system -/// apps only until the shells' App Hub pin verifies it). +/// A script app's exact `agent.tools` requests and bounded research scope +/// from its admitted manifest. Capability declarations are disclosure only. pub fn grant_manifest(app: &str, manifest: &Value) { set_grant(app, ToolboxGrant::for_manifest(app, manifest)); } diff --git a/crates/shell/src/mail_background.rs b/crates/shell/src/mail_background.rs index 68e2611ce..e2951992d 100644 --- a/crates/shell/src/mail_background.rs +++ b/crates/shell/src/mail_background.rs @@ -271,7 +271,7 @@ fn active() -> Vec { if !publication_access( app, crate::agents::access(app), - crate::host_tools::script_apps::grants(app, "glance"), + crate::host_tools::script_apps::admitted(app), ) { return None; } diff --git a/crates/shell/src/platform_services/README.md b/crates/shell/src/platform_services/README.md index e881f61b7..b6856cd3e 100644 --- a/crates/shell/src/platform_services/README.md +++ b/crates/shell/src/platform_services/README.md @@ -4,7 +4,7 @@ English | [简体中文](README.zh-CN.md) An installed script app can ask a compatible OctoSense host for camera, microphone and location access. Query API discovery on the actual host; fresh location sampling described here belongs to the unreleased OS wiring batch. Three separate checks decide whether an app can use a device: -- **Capability.** The app's manifest declares `camera`, `microphone` or `location`. This is the most the app can ever get. +- **Admission.** The host verifies the app and host profile. Manifest capabilities describe usage; they do not supply consent. - **App consent.** The person allows this app on a native host sheet. Consent covers this app on all of its accounts, and no other app. - **OS permission.** The operating system grants it to OctoSense as a whole, so on its own it authorizes no installed app. @@ -12,7 +12,7 @@ This module's device service (`DeviceService`) implements permission status, req ## Declare what the app uses -Require `host-api-v1` and declare each capability the app uses. This manifest fragment asks for the camera and location: +Require `host-api-v1`; capability declarations describe what the app uses and do not deny omitted families. This manifest fragment asks for the camera and location: ```json { @@ -21,12 +21,13 @@ Require `host-api-v1` and declare each capability the app uses. This manifest fr } ``` -The host puts the consent check in place before it runs any of the app's source. To keep the app off hosts that lack a method, list the method and its exact version in `host_api.required`; App Hub then refuses to install or launch the app on such a host. For a method the app can do without, check at run time with `runtime.describe`, which needs the `runtime` capability. +The host puts the consent check in place before it runs any of the app's source. To keep the app off hosts that lack a method, list the method and its exact version in `host_api.required`; App Hub then refuses to install or launch the app on such a host. For a method the app can do without, check at run time with `runtime.describe`, without a capability grant. ## Methods | Method | Arguments | What it does | | --- | --- | --- | +| `camera.capture_intent` | `{}` | Version 1 describes explicit `audio`/`library` capture options and their safe defaults; no hardware access or prompt. Require this method before relying on these options. | | `camera.permission.status` | `{}` | Reads the app's consent and the OS camera permission. Never prompts. | | `camera.permission.request` | `{}` | From the app in the foreground: shows the host's consent sheet, then the OS dialog if needed. | | `camera.permission.revoke` | `{}` | Withdraws this app's consent and stops its running `CameraPreview`. The OS permission for OctoSense is unchanged. | @@ -36,7 +37,7 @@ The host puts the consent check in place before it runs any of the app's source. | `location.sample` | Optional `timeout_ms`, `max_age_ms`, `max_accuracy_m` | Android/macOS foreground app only: waits for a fix meeting the requested age and accuracy, without raising permission UI. | | `location.sample.cancel` | `{}` | Cancels every pending sample belonging to this app; returns `cancelled`, the count. Other apps are unaffected. | -Beyond `host-api-v1`, `status` and `revoke` need only the capability. `request` is how the app gets consent, and `location.get` and `location.sample` need both consent and the OS permission. +Beyond `host-api-v1`, `status` and `revoke` need a verified admitted identity. `request` is how the app gets consent, and `location.get` and `location.sample` need both consent and the OS permission. ## Request access @@ -50,7 +51,7 @@ host.request("camera.permission.request", {}, fn(r) { }) ``` -This is an API example, not a published sample app. The result reports the three checks separately: `app_policy_granted` (the capability), `app_consent` and `os_permission`. `os_permission` is `granted`, `not_determined`, `denied` or `settings_required` (denied permanently; only the system settings can change it). If the app already has consent, the host skips its sheet; if OctoSense already has the OS permission, no OS dialog appears. +This is an API example, not a published sample app. The result reports the three checks separately: `app_policy_granted` (the admitted identity; the field name is retained for compatibility), `app_consent` and `os_permission`. `os_permission` is `granted`, `not_determined`, `denied` or `settings_required` (denied permanently; only the system settings can change it). If the app already has consent, the host skips its sheet; if OctoSense already has the OS permission, no OS dialog appears. The consent sheet offers **Not now** and **Continue**. **Continue** accepts only a physical press: synthetic input from Makepad automation or ADB does not count, and an app cannot mount its own copy of the sheet's widget to approve itself. **Not now** closes the sheet on any input and ends the request with `cancelled`. A sheet left open for 5 minutes expires, and the request fails with `timeout`. @@ -64,11 +65,11 @@ An agent, a background card (such as the app's card on the Glance screen), and t ## Older device paths -For an app that requires `host-api-v1`, the same consent also gates the older paths: `CameraPreview`, `sys.request_location`, `sys.gps` and the map's GPS reads. The host supplies the app's identity and caches consent per capability. After the host starts, the cache denies each capability until the app calls one of that capability's permission methods, which loads its saved consent. Call the matching `status` method before using these paths: `camera.permission.status` before `CameraPreview`, `microphone.permission.status` before recording sound, and `location.permission.status` before `sys.gps` or a map GPS read. +Every contained app's older device paths also require per-app consent: `CameraPreview`, `sys.request_location`, `sys.gps` and the map's GPS reads. The host supplies the app's identity and caches consent per capability. After the host starts, the cache denies each capability until the app calls one of that capability's permission methods, which loads its saved consent. Call the matching `status` method before using these paths: `camera.permission.status` before `CameraPreview`, `microphone.permission.status` before recording sound, and `location.permission.status` before `sys.gps` or a map GPS read. -An agent or a background card cannot use these paths to raise an OS dialog either. `CameraPreview` checks the OS permission before it previews or records, and only a request that starts in the foreground and is still there when the check returns may prompt. Background code can still use an OS permission that OctoSense already has, as long as the app has consent. In an app that requires `host-api-v1`, the camera never treats a timeout as approval. `sys.request_location` can prompt, so it needs the foreground; background code can read `sys.gps`, or call `location.get`, once the app has consent. +An agent or a background card cannot use these paths to raise an OS dialog either. `CameraPreview` checks the OS permission before it previews or records, and only a request that starts in the foreground and is still there when the check returns may prompt. Background code can still use an OS permission that OctoSense already has, as long as the app has consent. The camera never treats a timeout as approval for a contained app. `sys.request_location` can prompt, so it needs the foreground; background code can read `sys.gps`, or call `location.get`, once the app has consent. -Apps that do not require `host-api-v1` keep the earlier manifest-only rules, and their calls to these methods fail with `host_requirement_missing`. +Apps that do not require `host-api-v1` still cannot borrow the shell's device permissions. Their permission-method calls fail with `host_requirement_missing`; add that compatibility requirement to use this API. ## Fresh location samples @@ -80,14 +81,14 @@ The result has `latitude`, `longitude`, `accuracy_m`, `timestamp` (Unix seconds) `location.permission.status` reports `location_sample_supported`; its older `location_read_supported` field still describes the legacy `location.get` reader. The host uses Android LocationManager / macOS CoreLocation events and a nonprompting start operation. It checks OS authorization before acquisition and again before delivering the result; sampling never opens permission UI. Agents/background cards cannot start samples. This API does not provide a watch or background location. -`location.sample.cancel` cancels this app's samples across its surfaces. Closing the originating isolate, revoking consent, losing the app capability, timeout or host backgrounding also stops acquisition. A 250 ms timer bounds cleanup when no native event arrives. The sampler releases its stream ownership once no sample is acquiring. Native route/location requests retain separate ownership, so either caller can stop without interrupting the other; the platform keeps its existing best-accuracy request. The separate Android legacy GPS feed and `location.get@1` response remain unchanged. +`location.sample.cancel` cancels this app's samples across its surfaces. Closing the originating isolate, revoking consent, losing app admission, timeout or host backgrounding also stops acquisition. A 250 ms timer bounds cleanup when no native event arrives. The sampler releases its stream ownership once no sample is acquiring. Native route/location requests retain separate ownership, so either caller can stop without interrupting the other; the platform keeps its existing best-accuracy request. The separate Android legacy GPS feed and `location.get@1` response remain unchanged. ## Errors | Error | When | | --- | --- | | `host_requirement_missing` | The manifest does not require `host-api-v1`. | -| `permission_denied` | The manifest lacks the capability, or the app was removed, lost the capability or had its consent changed while the request waited. | +| `permission_denied` | The app/profile is no longer admitted, or its consent changed while the request waited. | | `invalid_arguments` | Arguments are not an object, include unknown fields, or exceed the documented bounds. Methods other than `location.sample` require `{}`. | | `method_unavailable` | The method is not one of those listed above, for example `camera.get`. | | `authorization_required` | `location.get` ran without app consent or without the OS permission, or a `request` arrived when the host could not prompt, for example while it was in the background. | @@ -101,7 +102,7 @@ The result has `latitude`, `longitude`, `accuracy_m`, `timestamp` (Unix seconds) ## How the host runs a request -The device service queues each native operation on the shell's UI event loop and matches the OS result to its request ID. Before it answers, it checks again that the app is still installed with the capability and that its consent revision has not changed. It drops a request that ended in the meantime because the app closed or App Hub timed it out. Revoking consent invalidates every older request still waiting for approval, and one app's denial never clears another app's consent. +The device service queues each native operation on the shell's UI event loop and matches the OS result to its request ID. Before it answers, it checks again that the app is still admitted in this host profile and that its consent revision has not changed. It drops a request that ended in the meantime because the app closed or App Hub timed it out. Revoking consent invalidates every older request still waiting for approval, and one app's denial never clears another app's consent. On Android, the OS permission dialog can pause the activity. A request already waiting for that dialog survives the pause; every other queued request, and every consent sheet not yet approved, is cancelled. diff --git a/crates/shell/src/platform_services/README.zh-CN.md b/crates/shell/src/platform_services/README.zh-CN.md index 5daf795b5..56d3dc27a 100644 --- a/crates/shell/src/platform_services/README.zh-CN.md +++ b/crates/shell/src/platform_services/README.zh-CN.md @@ -4,7 +4,7 @@ 已安装的脚本应用可以向兼容的 OctoSense 宿主申请摄像头、麦克风和位置权限。请查询实际宿主的 API 发现结果;本文描述的新鲜位置采样属于尚未发布的 OS 接线批次。应用能否使用设备,由三项相互独立的检查决定: -- **能力。** 应用清单声明 `camera`、`microphone` 或 `location`。这是应用能得到的上限。 +- **准入。** 宿主验证应用及配置目录。清单中的能力仅说明用途,不能代替用户同意。 - **应用授权。** 用户在宿主的原生面板上允许这个应用。授权覆盖该应用的所有账户,不覆盖其他应用。 - **系统权限。** 操作系统授予 OctoSense 本身的权限。单凭它,任何已安装应用都无权使用设备。 @@ -12,7 +12,7 @@ ## 声明应用要用的能力 -在清单中要求 `host-api-v1`,并声明应用用到的每项能力。下面这段清单片段申请摄像头和位置: +在清单中要求 `host-api-v1`;能力声明描述用途,省略声明不拒绝调用。下面的片段描述摄像头和位置用途: ```json { @@ -21,12 +21,13 @@ } ``` -宿主在运行应用的任何源码之前就启用设备授权检查。如果不想让应用装到缺少某个方法的宿主上,把该方法及其确切版本写进 `host_api.required`;App Hub 会在安装和启动时检查,宿主缺少该方法就拒绝安装或启动。对于应用可以不依赖的方法,改在运行时用 `runtime.describe` 查询;这需要 `runtime` 能力。 +宿主在运行应用的任何源码之前就启用设备授权检查。如果不想让应用装到缺少某个方法的宿主上,把该方法及其确切版本写进 `host_api.required`;App Hub 会在安装和启动时检查,宿主缺少该方法就拒绝安装或启动。对于应用可以不依赖的方法,改在运行时用 `runtime.describe` 查询;不需要能力授权。 ## 方法 | 方法 | 参数 | 作用 | | --- | --- | --- | +| `camera.capture_intent` | `{}` | 版本 1 描述明确的 `audio`/`library` 拍摄参数及安全默认值;不访问硬件、不弹窗。依赖这些参数前应要求此方法。 | | `camera.permission.status` | `{}` | 读取应用授权和系统摄像头权限,从不弹窗。 | | `camera.permission.request` | `{}` | 仅限前台应用:显示宿主的授权面板,需要时再显示系统权限对话框。 | | `camera.permission.revoke` | `{}` | 撤销本应用的授权,并停止它正在运行的 `CameraPreview`。系统授予 OctoSense 的权限不变。 | @@ -36,7 +37,7 @@ | `location.sample` | 可选 `timeout_ms`、`max_age_ms`、`max_accuracy_m` | 仅限 Android/macOS 前台应用:等待符合时效和精度要求的位置,不弹出权限界面。 | | `location.sample.cancel` | `{}` | 取消当前应用全部待完成采样,返回数量 `cancelled`;不影响其他应用。 | -除了 `host-api-v1`,`status` 和 `revoke` 只需要相应的能力。`request` 是应用获得授权的途径,`location.get` 和 `location.sample` 则同时需要应用授权和系统权限。 +除了 `host-api-v1`,`status` 和 `revoke` 需要已验证的应用身份。`request` 是应用获得授权的途径,`location.get` 和 `location.sample` 则同时需要应用授权和系统权限。清单能力仅说明用途,省略声明不会阻止调用。 ## 申请权限 @@ -50,7 +51,7 @@ host.request("camera.permission.request", {}, fn(r) { }) ``` -这是 API 示例,不是已发布的示例应用。返回结果分别报告三项检查:`app_policy_granted`(能力)、`app_consent` 和 `os_permission`。`os_permission` 的取值为 `granted`、`not_determined`、`denied` 或 `settings_required`(用户已永久拒绝,只能到系统设置中更改)。应用已有授权时,宿主不再显示面板;OctoSense 已有系统权限时,也不会再弹出系统对话框。 +这是 API 示例,不是已发布的示例应用。返回结果分别报告三项检查:`app_policy_granted`(应用身份已通过准入检查;字段名为兼容保留)、`app_consent` 和 `os_permission`。`os_permission` 的取值为 `granted`、`not_determined`、`denied` 或 `settings_required`(用户已永久拒绝,只能到系统设置中更改)。应用已有授权时,宿主不再显示面板;OctoSense 已有系统权限时,也不会再弹出系统对话框。 授权面板提供 **Not now** 和 **Continue** 两个按钮,其中 **Continue** 只接受亲手点按:Makepad 自动化或 ADB 发出的合成输入都不算数,应用也不能挂载面板控件的副本来批准自己。**Not now** 接受任何输入,它会关闭面板,并以 `cancelled` 结束申请。面板打开 5 分钟后过期,申请以 `timeout` 失败。 @@ -66,11 +67,11 @@ Agent、后台卡片(例如应用在速览栏上的卡片),以及它们启 ## 旧的设备接口 -对于要求 `host-api-v1` 的应用,同一份授权也约束这些旧接口:`CameraPreview`、`sys.request_location`、`sys.gps` 和地图的 GPS 读取。应用身份由宿主提供,授权按能力分别缓存。宿主启动后,缓存对每项能力一律拒绝,直到应用调用该能力的某个权限方法,把保存的授权加载进来。所以使用这些接口之前,请先调用对应能力的 `status`:使用 `CameraPreview` 之前调用 `camera.permission.status`,录制声音之前调用 `microphone.permission.status`,读取 `sys.gps` 或地图 GPS 之前调用 `location.permission.status`。 +每个受限应用的旧设备接口也需要按应用的同意:`CameraPreview`、`sys.request_location`、`sys.gps` 和地图的 GPS 读取。应用身份由宿主提供,授权按能力分别缓存。宿主启动后,缓存对每项能力一律拒绝,直到应用调用该能力的某个权限方法,把保存的授权加载进来。所以使用这些接口之前,请先调用对应能力的 `status`:使用 `CameraPreview` 之前调用 `camera.permission.status`,录制声音之前调用 `microphone.permission.status`,读取 `sys.gps` 或地图 GPS 之前调用 `location.permission.status`。 -Agent 和后台卡片同样不能借这些接口弹出系统对话框。`CameraPreview` 在预览或录像之前先检查系统权限;只有从前台发起、并且检查结果返回时仍在前台的请求才可以弹窗。只要应用已获授权,后台代码仍可使用 OctoSense 已有的系统权限。在要求 `host-api-v1` 的应用中,摄像头绝不会把超时当作批准。`sys.request_location` 可能弹窗,因此必须在前台调用;应用获得授权后,后台代码可以读取 `sys.gps` 或调用 `location.get`。 +Agent 和后台卡片同样不能借这些接口弹出系统对话框。`CameraPreview` 在预览或录像之前先检查系统权限;只有从前台发起、并且检查结果返回时仍在前台的请求才可以弹窗。只要应用已获授权,后台代码仍可使用 OctoSense 已有的系统权限。对受限应用,摄像头绝不会把超时当作批准。`sys.request_location` 可能弹窗,因此必须在前台调用;应用获得授权后,后台代码可以读取 `sys.gps` 或调用 `location.get`。 -没有要求 `host-api-v1` 的应用沿用此前只看清单的规则,它们调用这些方法会以 `host_requirement_missing` 失败。 +没有要求 `host-api-v1` 的应用同样不能借用外壳的设备权限;调用权限方法时返回 `host_requirement_missing`,使用本 API 前应添加这项兼容性要求。 ## 新鲜位置采样 @@ -80,14 +81,14 @@ Agent 和后台卡片同样不能借这些接口弹出系统对话框。`CameraP `location.permission.status` 的 `location_sample_supported` 表示新采样接口可用;旧字段 `location_read_supported` 仍只表示 `location.get` 可用。宿主接入 Android LocationManager / macOS CoreLocation 事件,使用不弹窗的启动操作,在采样前和交付结果前分别检查系统权限。采样不会转为权限申请;Agent 和后台卡片不能启动采样。本次不提供持续订阅或后台定位。 -`location.sample.cancel` 取消本应用各界面的全部待完成采样。来源隔离环境关闭、应用授权撤销、能力移除、超时或宿主进入后台都会停止采样。没有原生事件时,250 毫秒计时器仍会执行清理。没有待采样请求后释放采样器的位置流所有权;原生路线/位置请求保留独立所有权,任何一方停止都不会中断另一方,平台维持原有的最高精度请求;独立的 Android 旧 GPS 流和 `location.get@1` 响应保持不变。 +`location.sample.cancel` 取消本应用各界面的全部待完成采样。来源隔离环境关闭、应用授权撤销、应用失去准入资格、超时或宿主进入后台都会停止采样。没有原生事件时,250 毫秒计时器仍会执行清理。没有待采样请求后释放采样器的位置流所有权;原生路线/位置请求保留独立所有权,任何一方停止都不会中断另一方,平台维持原有的最高精度请求;独立的 Android 旧 GPS 流和 `location.get@1` 响应保持不变。 ## 错误 | 错误 | 出现时机 | | --- | --- | | `host_requirement_missing` | 清单没有要求 `host-api-v1`。 | -| `permission_denied` | 清单缺少该能力;或在请求等待期间,应用已卸载、失去该能力,或授权已变化。 | +| `permission_denied` | 应用或配置目录不再通过准入检查,或请求等待期间用户同意发生变化。 | | `invalid_arguments` | 参数不是对象、包含未知字段或超出规定范围。除 `location.sample` 外的方法都要求 `{}`。 | | `method_unavailable` | 方法不在上表之列,例如 `camera.get`。 | | `authorization_required` | 在没有应用授权或没有系统权限时调用了 `location.get`;或 `request` 到达时宿主无法弹窗,例如宿主正处于后台。 | @@ -101,7 +102,7 @@ Agent 和后台卡片同样不能借这些接口弹出系统对话框。`CameraP ## 宿主如何处理请求 -设备服务把每个原生操作排入 Shell 的 UI 事件循环,并按请求 ID 匹配系统返回的结果。回复之前,它会再次确认应用仍已安装、仍有该能力,且授权版本没有变化。如果请求在此期间已经结束(应用已关闭,或 App Hub 已判定超时),服务会直接丢弃它。撤销授权会使所有仍在等待批准的旧请求失效;一个应用遭到拒绝,也不会清除另一个应用的授权。 +设备服务把每个原生操作排入 Shell 的 UI 事件循环,并按请求 ID 匹配系统返回的结果。回复之前,它会再次确认应用仍在当前宿主中通过准入检查,且授权版本没有变化。如果请求在此期间已经结束(应用已关闭,或 App Hub 已判定超时),服务会直接丢弃它。撤销授权会使所有仍在等待批准的旧请求失效;一个应用遭到拒绝,也不会清除另一个应用的授权。 在 Android 上,系统权限对话框可能让 Activity 暂停。已在等待该对话框结果的请求会保留下来;其他排队中的请求,以及所有尚未批准的授权面板,都会取消。 diff --git a/crates/shell/src/platform_services/location.rs b/crates/shell/src/platform_services/location.rs index 68fc4f28a..ecc0db3ff 100644 --- a/crates/shell/src/platform_services/location.rs +++ b/crates/shell/src/platform_services/location.rs @@ -113,7 +113,7 @@ fn keep(work: &Work, check_authority: bool) -> bool { return false; } let failure = if check_authority && (!work.policy_allows() || !work.consent_still_valid()) { - Some("permission_denied: Location consent or the app's capability changed") + Some("permission_denied: Location consent or app admission changed") } else { None }; diff --git a/crates/shell/src/platform_services/mod.rs b/crates/shell/src/platform_services/mod.rs index 4b79ae931..07309e6b3 100644 --- a/crates/shell/src/platform_services/mod.rs +++ b/crates/shell/src/platform_services/mod.rs @@ -1,7 +1,8 @@ //! Device APIs for installed apps, backed by Makepad's native permission bridge. //! -//! Manifest capability, retained per-app consent, and OS authorization are three -//! different checks. An OS grant to the shell never authorizes another app. +//! Manifest capabilities describe usage. Verified admission, retained per-app +//! consent and OS authorization are independent checks. An OS grant to the +//! shell never authorizes another app. //! All native requests run on the UI thread; agents cannot open permission UI. use makepad_widgets::{ makepad_platform::{ @@ -75,7 +76,7 @@ impl Work { true } fn policy_allows(&self) -> bool { - crate::host_tools::script_apps::grants(&self.call.app_id, self.family) + crate::host_tools::script_apps::admitted_host(&self.call.app_id, &self.call.host_dir).is_ok() } fn current_grant(&self) -> Result { consent::get(&self.call.host_dir, &self.call.app_id, self.family) @@ -156,6 +157,13 @@ pub fn register() { services::register_host_service(Box::new(DeviceService { family })); } } +fn camera_capture_intent() -> Value { + json!({"abi":1,"defaults":{"audio":false,"library":false}, + "options":{"capture":["library"],"record_start":["audio","library"]}, + "library_export_supported":cfg!(target_env = "ohos"), + "library_export_requires_foreground":true,"microphone_consent_required":true}) +} + struct DeviceService { family: &'static str, } @@ -165,8 +173,15 @@ impl HostService for DeviceService { } fn api_methods(&self) -> Vec { use services::{AgentAccess, HostApiMethod}; + let capture_intent = if self.family == "camera" { + vec![HostApiMethod::new("camera.capture_intent", 1, "camera", + "Describe explicit per-capture audio/library options; availability never records or exports", + json!({"type":"object","additionalProperties":false}), + json!({"type":"object","required":["abi","defaults","options","library_export_supported","library_export_requires_foreground","microphone_consent_required"]})) + .with_agent_access(AgentAccess::Allowed)] + } else { vec![] }; if !permission_supported() { - return vec![]; + return capture_intent; } let input = json!({"type":"object","additionalProperties":false}); let output = json!({"type":"object","required":["capability","supported","app_policy_granted","app_consent","os_permission"], @@ -206,6 +221,7 @@ impl HostService for DeviceService { .with_platforms(&["android", "macos"]) .with_agent_access(AgentAccess::Allowed), ]; + methods.extend(capture_intent); if self.family == "location" && cfg!(target_os = "android") { methods.push(HostApiMethod::new("location.get",1,"location", "Read Android's last-known location after app consent and a fresh OS permission check; fix age is unknown",input, @@ -275,12 +291,16 @@ impl HostService for DeviceService { )); return; } - if !crate::host_tools::script_apps::grants(&call.app_id, self.family) { + if crate::host_tools::script_apps::admitted_host(&call.app_id, &call.host_dir).is_err() { reply.send(Err( - "permission_denied: The installed app has no grant for this capability".into(), + "permission_denied: The app or host profile is no longer admitted".into(), )); return; } + if self.family == "camera" && call.method() == "capture_intent" { + reply.send(Ok(camera_capture_intent())); + return; + } let opted_in = crate::host_tools::script_apps::guidance(&call.app_id).is_ok_and(|loaded| { loaded.manifest["requires"] .as_array() diff --git a/crates/shell/src/platform_services/tests.rs b/crates/shell/src/platform_services/tests.rs index 34614d101..2c35b29c5 100644 --- a/crates/shell/src/platform_services/tests.rs +++ b/crates/shell/src/platform_services/tests.rs @@ -1,5 +1,162 @@ use super::*; +#[cfg(feature = "app-hub")] +#[test] +fn glance_devices_use_host_consent_for_declared_and_legacy_apps() { + const CHILD: &str = "OCTOSENSE_GLANCE_DEVICE_CONSENT_TEST"; + if std::env::var_os(CHILD).is_none() { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "platform_services::tests::glance_devices_use_host_consent_for_declared_and_legacy_apps", + "--nocapture", + ]) + .env(CHILD, "1") + .output() + .unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + return; + } + + use makepad_widgets::{splash_host, splash_policy}; + let home = Home::new(); + let host = home.0.join(".host"); + std::env::set_var("OCTOSENSE_APP_DATA", &home.0); + octosense_appstore::set_data_root(home.0.clone()); + register(); + octosense_appstore::host_api::register_runtime_feature("camera.capture_intent", 1); + for (app, capabilities, requires) in [ + ("os.glancelegacy", json!([]), json!([])), + ( + "os.glancedevices", + json!(["camera", "microphone", "location", "glance"]), + json!(["host-api-v1"]), + ), + ] { + let bundle = crate::host_tools::script_apps::tests::stamped_bundle( + "camera", + app, + |bundle, manifest| { + manifest["id"] = json!(app); + manifest["capabilities"] = capabilities; + manifest["requires"] = requires; + for key in ["agent", "host_api"] { + manifest.as_object_mut().unwrap().remove(key); + } + for file in ["tools.json", "AGENT.md"] { + let _ = std::fs::remove_file(bundle.join(file)); + } + std::fs::write(bundle.join("main.splash"), "View{}\n").unwrap(); + }, + ); + let packed = octosense_app_hub::pack::pack_system_app(&bundle).unwrap(); + std::fs::remove_dir_all(bundle).unwrap(); + octosense_appstore::system::register_system_app(octosense_appstore::system::SystemApp { + id: app, + name: "Glance device fixture", + pack: Box::leak(packed.pack_json.into_boxed_str()), + assets: &[], + }); + } + + let mut cx = Cx::new(Box::new(|_, _| {})); + cx.with_vm(|vm| { + makepad_widgets::script_mod(vm); + crate::glance_card::script_mod(vm); + }); + let mut tiles = crate::glance_card::GlanceTiles::default(); + let body: std::sync::Arc = r#" + fn probe_consent() { mod.host.request("camera.permission.request", {}, nil) } + View{} + "# + .into(); + let ask = |cx: &mut Cx, splash: &makepad_widgets::SplashRef| { + assert!(splash.call_script_fn( + cx, + makepad_widgets::LiveId::from_str("probe_consent"), + &[], + )); + let heap = splash.isolate_heap_key(cx).unwrap(); + let mut requests = splash_host::take_splash_host_requests_for(&[heap]); + assert_eq!(requests.len(), 1); + requests.pop().unwrap() + }; + for app in ["os.glancelegacy", "os.glancedevices"] { + let splash = tiles.open(&mut cx, app, app, true, &body); + let heap = tiles.heap_key(&mut cx, app).unwrap(); + let request = ask(&mut cx, &splash); + assert_eq!(request.app_tag, app); + assert!(!request.may_prompt); + for (family, method) in [ + ("camera", "camera.preview"), + ("microphone", "microphone.record"), + ("location", "location.get"), + ] { + assert!( + splash_policy::service_allowed(heap, method) + .unwrap_err() + .starts_with("authorization_required:"), + "{app}: neither declarations nor another app's consent authorize {family}" + ); + // A host-owned consent change, not an OS grant or script argument. + consent::set(&host, app, family, true, None).unwrap(); + assert!(splash_policy::service_allowed(heap, method).is_ok()); + } + } + + let app = "os.glancedevices"; + let heap = tiles.heap_key(&mut cx, app).unwrap(); + consent::set(&host, app, "camera", false, None).unwrap(); + assert!(splash_policy::service_allowed(heap, "camera.preview").is_err()); + let legacy_heap = tiles.heap_key(&mut cx, "os.glancelegacy").unwrap(); + assert!(splash_policy::service_allowed(legacy_heap, "camera.preview").is_ok()); + let splash = tiles.open(&mut cx, app, app, true, &body); + let request = ask(&mut cx, &splash); + + struct NoSheet; + impl services::ServiceHost for NoSheet { + fn open_sheet(&mut self, _: String) { + panic!("A Glance summary must not open a permission sheet"); + } + fn close_sheet(&mut self) {} + } + services::dispatch( + ServiceCall { + app_id: request.app_tag, + service: request.service, + args: serde_json::from_str(&request.args_json).unwrap(), + from_sheet: false, + may_prompt: request.may_prompt, + host_dir: host, + }, + request.heap_key, + request.req_id, + &mut NoSheet, + ); + let replies = services::take_replies_for(&[heap]); + assert_eq!(replies.len(), 1); + let error = replies[0].2.as_ref().unwrap_err(); + if permission_supported() { + assert_eq!( + error, + "camera.permission.request is unavailable to agents/background surfaces" + ); + } + + let splash = tiles.open(&mut cx, "missing", "os.glancemissing", true, &body); + let missing = tiles.heap_key(&mut cx, "missing").unwrap(); + let request = ask(&mut cx, &splash); + assert!(request.app_tag.is_empty()); + assert!(!request.may_prompt); + assert!(splash_policy::service_allowed(missing, "camera.preview").is_err()); + tiles.sweep(&mut cx, &[]); +} + struct Home(PathBuf); impl Home { fn new() -> Self { @@ -101,7 +258,7 @@ fn descriptors_do_not_claim_unimplemented_location_or_background_authorization() let service = DeviceService { family }; let methods = service.api_methods(); if !permission_supported() { - assert!(methods.is_empty()); + assert_eq!(methods.len(), usize::from(family == "camera")); continue; } let request = methods @@ -256,7 +413,7 @@ fn location_sampling_broker_lifecycle() { let dir = crate::host_tools::script_apps::tests::stamped_bundle("camera", id, |dir, manifest| { manifest["id"] = json!(id); - manifest["capabilities"] = json!(["location"]); + manifest["capabilities"] = if id == "os.locationone" { json!(["location"]) } else { json!([]) }; manifest["requires"] = json!(["host-api-v1"]); manifest.as_object_mut().unwrap().remove("agent"); for path in ["tools.json", "AGENT.md"] { @@ -280,7 +437,7 @@ fn location_sampling_broker_lifecycle() { let host_dir = root.0.join(".host"); for app in ["os.locationone", "os.locationtwo"] { consent::set(&host_dir, app, "location", true, None).unwrap(); - assert!(crate::host_tools::script_apps::grants(app, "location")); + assert!(crate::host_tools::script_apps::admitted(app)); } services::register_host_service(Box::new(DeviceService { family: "location" })); let mut cx = Cx::new(Box::new(|_, _| {})); @@ -514,3 +671,18 @@ fn location_sampling_broker_lifecycle() { assert_eq!(out[0].2.as_ref().unwrap_err(), location::TIMEOUT_ERROR); } } + +#[test] +fn camera_capture_intent_discovery_matches_the_safe_runtime_defaults() { + let method = DeviceService { family: "camera" }.api_methods().into_iter() + .find(|method| method.name == "camera.capture_intent").unwrap(); + assert_eq!(method.version, 1); + assert_eq!(method.agent_access, services::AgentAccess::Allowed); + assert_eq!(method.input_schema["additionalProperties"], false); + let result = camera_capture_intent(); + assert_eq!(result["defaults"], json!({"audio":false,"library":false})); + assert_eq!(result["options"]["capture"], json!(["library"])); + assert_eq!(result["options"]["record_start"], json!(["audio","library"])); + assert_eq!(result["library_export_supported"], cfg!(target_env = "ohos")); + assert_eq!(result["microphone_consent_required"], true); +} diff --git a/crates/shell/src/system_app_theme_tests.rs b/crates/shell/src/system_app_theme_tests.rs index 5150086cd..7ab2f5fad 100644 --- a/crates/shell/src/system_app_theme_tests.rs +++ b/crates/shell/src/system_app_theme_tests.rs @@ -31,14 +31,20 @@ let toast = "" let timers = [] let stopped = [] let stop_requests = 0 +let microphone_allowed = true +let host = {{request: fn(service, args, reply){{ + assert(service == "microphone.permission.request") + reply({{is_ok: true data: {{app_consent: microphone_allowed os_permission: "granted"}}}}) +}}}} fn time_now() {{ 123 }} fn start_interval(seconds, callback) {{ timers.push(callback); timers.len() }} fn stop_timer(timer) {{ stopped.push(timer) }} let widget = {{render: fn(){{}} set_text: fn(text){{}}}} let ui = {{ cam: {{ - record_start: fn(){{ native_recording = true; true }} + record_start: fn(options){{ assert(options.audio && !options.library); native_recording = true; true }} record_stop: fn(){{ stop_requests += 1; native_recording = false }} + is_running: fn(){{ true }} is_recording: fn(){{ native_recording }} error: fn(){{ native_error }} set_aspect: fn(aspect){{}} @@ -78,6 +84,11 @@ assert(mode == "video") stop_recording() assert(!recording && !timer_visible && rec_timer == nil) assert(stopped.len() == 2 && stopped[1] == 2 && stop_requests == 1) +// Rejected app consent cannot start another native recording or UI timer. +microphone_allowed = false +start_recording() +assert(!recording && !native_recording && !timer_visible && rec_timer == nil) +assert(timers.len() == 2 && retry_visible && !permission_pending) true ;"#), ..Default::default() diff --git a/crates/shell/src/wasm_service.rs b/crates/shell/src/wasm_service.rs index 8bbcf6d0b..a91b73b8e 100644 --- a/crates/shell/src/wasm_service.rs +++ b/crates/shell/src/wasm_service.rs @@ -14,9 +14,9 @@ //! //! **Whose code.** A module comes only from the calling app's own admitted //! bundle (digest-checked, [`script_apps::admitted_bundle`]): no argument -//! names a file, and no app reaches another's functions. The Card runner's -//! gate and the tool executor both require the app's `wasm` capability, and -//! the service checks the admitted manifest again before it loads anything. +//! names a file, and no app reaches another's functions. Capabilities are +//! declarations; the service verifies admission before loading or executing +//! code and before delivering a result. //! //! **Where it runs.** A bounded worker per active app caches compiled Programs, //! never guest instances. Every invocation starts with fresh memory/globals/tables. @@ -33,7 +33,7 @@ //! or the worker's exit (a minute without calls) ends it. Its filesystem is //! the app's storage, the jail its script's `fs.*` sees, decided per call by //! the same rules as an engine's ([`crate::host_tools::areas::app_area`]): -//! none without the `storage` capability or a signed-in account, and what +//! none without an available app storage area or required signed-in account; what //! is left of the quota is what a call may add (a write past it fails inside //! the component, as a full disk). @@ -310,9 +310,6 @@ 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, @@ -659,9 +656,9 @@ struct Lab { /// Compile an installed or updated app's functions into the disk cache in /// the background, so that its first call does not wait for Cranelift (ADR /// 0014 phase 3; a phone takes about 0.4 s for a 433 KiB module). From its -/// admitted bundle only, and only with the `wasm` grant; one app at a time, -/// on a thread of its own, never in a worker's place. A call that comes -/// first compiles the same code itself: the cache takes either. +/// admitted bundle only; capability declarations do not gate warming. One +/// app at a time, on its own thread, never in a worker's place. A call that +/// comes first or finds a slow cache read compiles the same verified code. pub fn warm(app: &str) { static QUEUE: Mutex<(Vec, bool)> = Mutex::new((Vec::new(), false)); { @@ -1091,6 +1088,10 @@ fn round(value: f64) -> f64 { (value * 10.0).round() / 10.0 } +#[cfg(test)] +#[path = "wasm_shared_acceptance.rs"] +mod shared_acceptance; + #[cfg(test)] mod tests { use super::*; @@ -1417,7 +1418,7 @@ mod tests { ship_state("os.wasmstate", "3.0.0", false) }) .unwrap_err(); - assert!(error.contains("not granted"), "{error}"); + assert!(error.contains("admission changed"), "{error}"); assert!( lab.is_none(), "revocation drops cached Programs before delivery" @@ -1652,7 +1653,7 @@ mod tests { std::env::set_var("OCTOSENSE_APP_DATA", &root); octosense_appstore::set_data_root(root.clone()); ship("os.wasmlab", "service", |_, _| {}); - // The same bundle without the capability (and so without its tools). + // The same functions without declarations or an app agent. ship("os.wasmplain", "plain", |dir, manifest| { manifest["capabilities"] = json!([]); manifest.as_object_mut().unwrap().remove("agent"); @@ -1766,19 +1767,13 @@ mod tests { "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}" - ); + // Declarations do not gate execution; ownership and admission still do. + let ranked = request( + "os.wasmplain", "wasm.fuzzy_rank", + json!({"query": "m", "items": ["Mail"]}), &host_dir, + ).unwrap(); + assert_eq!(ranked["ranked"][0]["item"], "Mail"); + assert!(request("os.wasmmissing", "wasm.fuzzy_rank", json!({}), &host_dir).is_err()); // And a sheet never reaches it. let heap = NEXT_HEAP.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let call = ServiceCall { @@ -1808,8 +1803,8 @@ mod tests { /// the registries are process-wide): they answer with typed JSON under /// either spelling of their names; one instance keeps its state between /// calls until a trap spends it; and its files are the app's own - /// storage, under what is left of its quota, and absent without the - /// storage capability. + /// storage, under what is left of its quota, and absent when the host + /// supplies no storage area. /// A shared component the app pins (App Hub ADR 0003, ADR 0014 phase 4) /// loads beside the app's own functions, here none: its functions answer /// as `.`, from an instance of the app's own, and @@ -1954,8 +1949,8 @@ mod tests { let bundle = root.join("bundle"); std::fs::create_dir_all(bundle.join("fns")).unwrap(); std::fs::copy(NOTES_COMPONENT, bundle.join("fns/notes.wasm")).unwrap(); - // The apps' storage as the shell keeps it: a 4 KiB ceiling for one, - // no storage capability for the other. + // Explicit host fixtures: a 4 KiB storage ceiling for one app, + // no storage area for the other. These are not manifest grants. let storage = crate::app_storage::Storage::with_file_secrets( crate::app_storage::Layout::new(&root.join("home")).unwrap(), ); @@ -2100,7 +2095,7 @@ mod tests { assert_eq!(described["modules"][0]["storage_left"], 0); assert_eq!(described["modules"][0]["instances"], 2); - // Without the storage capability, no folder at all. + // Without a host-provided storage area, no folder at all. let mut plain = Lab::from_bundle(PLAIN, runtime, admission, || Ok(())).unwrap(); let (_, plain_reply) = pending_reply(PLAIN, &host_dir); let error = plain @@ -2121,7 +2116,7 @@ mod tests { /// as a system app that requires `wasm-components-v1` (App Hub's /// admission reads the component's imports), its functions answer the /// app's script with typed JSON, its instance keeps its state from one - /// request to the next, and without `storage` it has no folder. + /// request to the next, and without a host storage area it has no folder. #[test] fn a_shipped_component_answers_its_apps_script() { const CHILD: &str = "OCTOSENSE_TEST_WASM_SHIPPED_COMPONENT"; @@ -2261,9 +2256,9 @@ mod tests { let _ = std::fs::remove_dir_all(root); } - /// A component's `octosense:host` calls reach its app's granted host - /// services, dispatched on the UI thread as its script's would be (a - /// thread here plays the UI's part); nothing else, and never `wasm.*`. + /// A component's `octosense:host` calls use the same admitted app identity + /// and public host services as its script, dispatched on the UI thread + /// (a thread here plays that part); recursive `wasm.*` calls are refused. #[test] fn a_components_host_calls_reach_the_host_as_its_apps_script_does() { struct Echo; @@ -2347,17 +2342,18 @@ mod tests { let _ = std::fs::remove_dir_all(root); } - /// An installed app's functions are compiled before its first call, in - /// a process of its own (the apps root and the registries are - /// process-wide): the first call loads them from the cache. + /// Background compilation produces code the first request can actually + /// deserialize from a ready cache. The test prepares cache file I/O; + /// it does not measure fresh first-run UI performance. The app root and + /// registries are process-wide, so this fixture runs in its own process. #[test] - fn an_installed_apps_first_call_loads_from_the_cache() { + fn an_installed_apps_precompiled_code_loads_from_a_ready_cache() { const CHILD: &str = "OCTOSENSE_TEST_WASM_WARM"; if std::env::var_os(CHILD).is_none() { let output = std::process::Command::new(std::env::current_exe().unwrap()) .args([ "--exact", - "wasm_service::tests::an_installed_apps_first_call_loads_from_the_cache", + "wasm_service::tests::an_installed_apps_precompiled_code_loads_from_a_ready_cache", "--nocapture", ]) .env(CHILD, "1") @@ -2398,6 +2394,15 @@ mod tests { std::thread::sleep(Duration::from_millis(10)); } assert!(cached(), "warm compiled nothing into {}", cache.display()); + // A slow first open deliberately falls back to source compilation. + // This fixture instead requires a real cache hit, after file I/O is + // ready; blocked readers are covered by wasm-host's deterministic tests. + for entry in std::fs::read_dir(&cache).unwrap() { + let path = entry.unwrap().path(); + if path.extension().is_some_and(|ext| ext == "cwasm") { + std::fs::read(path).unwrap(); + } + } let ranked = request( "os.wasmlab", "wasm.fuzzy_rank", diff --git a/crates/shell/src/wasm_shared_acceptance.rs b/crates/shell/src/wasm_shared_acceptance.rs new file mode 100644 index 000000000..48882da40 --- /dev/null +++ b/crates/shell/src/wasm_shared_acceptance.rs @@ -0,0 +1,371 @@ +//! A local catalog rehearsal through the real Store and shell dispatcher. +//! Ephemeral legacy signatures deliberately exercise installation offline; +//! this is not proof of GitHub attestation or public catalog publication. +use super::*; +use octosense_app_hub::{ + check_bundle, check_component, component_entry_for, components::ComponentRelease, entry_for, + sign_manifest, Catalog, ComponentEntry, Entry, HubKey, PublisherKeys, Status, Store, +}; +use octosense_app_policy::{digest_dir, AppManifest, HostLimits}; +use octosense_appstore::{components, host_api, services, source::Origin}; + +const NOTES: &[u8] = include_bytes!("../../wasm-host/tests/fixtures/notes.component.wasm"); +const HOSTCALL: &[u8] = include_bytes!("../../wasm-host/tests/fixtures/hostcall.component.wasm"); +const FIRST: &str = "org.example.sharedfirst"; +const SECOND: &str = "org.example.sharedsecond"; + +struct Scratch(PathBuf); +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +struct NoSheet; +impl ServiceHost for NoSheet { + fn open_sheet(&mut self, _: String) { + panic!("A component must never open an approval sheet"); + } + fn close_sheet(&mut self) {} +} + +/// Run the normal asynchronous dispatcher and the UI's component host-call +/// pump. A caller-supplied app id never replaces the admitted app's identity. +fn call(app: &str, method: &str, args: Value, root: &Path) -> Result { + static HEAP: AtomicUsize = AtomicUsize::new(1 << 42); + let heap = HEAP.fetch_add(1, Ordering::Relaxed); + services::dispatch( + ServiceCall { + app_id: app.into(), + service: method.into(), + args, + from_sheet: false, + may_prompt: true, + host_dir: root.join(".host"), + }, + heap, + 1, + &mut NoSheet, + ); + let limit = Instant::now() + Duration::from_secs(15); + while Instant::now() < limit { + pump_host_calls(); + if let Some((_, _, answer)) = services::take_replies_for(&[heap]).pop() { + return answer.and_then(|v| serde_json::from_str(&v).map_err(|e| e.to_string())); + } + std::thread::sleep(Duration::from_millis(2)); + } + services::cancel_heap(heap); + panic!("No answer to {app} {method}"); +} + +fn component(id: &str, bytes: &[u8]) -> ComponentEntry { + let release = ComponentRelease::from_draft( + serde_json::from_value(json!({ + "component": {"schema":1,"id":id,"version":"1.0.0","name":"Acceptance component", + "license":"MIT","publisher":{"name":"Example","support":"https://example.test/s", + "privacy_policy_url":"https://example.test/privacy"}}, + "listing":{"description":"Synthetic local shared-component acceptance fixture"} + })) + .unwrap(), + bytes, + ) + .unwrap(); + let report = check_component(&release, bytes, false, None).unwrap(); + assert!(report.passed(), "{}", report.render()); + // The production publication gate must still refuse this unsigned fixture. + assert!(!check_component(&release, bytes, true, None) + .unwrap() + .passed()); + component_entry_for( + &release, + bytes, + &report, + "dev:acceptance", + "", + "", + &octosense_app_hub::today(), + ) + .unwrap() +} + +fn bundle(root: &Path, app: &str, pins: &Value, publisher: &HubKey) -> PathBuf { + let path = root.join("mirror").join(app); + std::fs::create_dir_all(&path).unwrap(); + std::fs::write( + path.join("main.splash"), + "Label{text: \"Shared component acceptance\"}", + ) + .unwrap(); + std::fs::write(path.join("icon.svg"), r##""##).unwrap(); + std::fs::write(path.join("listing.json"), json!({ + "schema":1,"description":"Synthetic fixture","category":"utilities", + "screenshots":["icon.svg"],"icon":"icon.svg","platforms":["macos","linux","windows","android"], + "age_rating":"all","publisher":{"name":"Example","support":"https://example.test/s", + "privacy_policy_url":"https://example.test/privacy"} + }).to_string()).unwrap(); + let mut manifest = AppManifest::parse( + &json!({ + "schema":1,"id":app,"version":"1.0.0","name":"Shared component acceptance", + "integrity":{"bundle_blake3":digest_dir(&path).unwrap()}, + "capabilities":["wasm","storage"],"requires":["wasm-shared-components-v1"], + "components":pins + }) + .to_string(), + ) + .unwrap(); + sign_manifest(publisher, &mut manifest, "acceptance-publisher").unwrap(); + std::fs::write( + path.join("manifest.json"), + serde_json::to_vec(&manifest).unwrap(), + ) + .unwrap(); + assert!( + !path.join("fns").exists(), + "A shared-only app must need no fns directory" + ); + path +} + +#[test] +fn installed_shared_components_keep_app_identity_and_revalidate_live_calls() { + const CHILD: &str = "OCTOSENSE_TEST_INSTALLED_SHARED_COMPONENTS"; + if std::env::var_os(CHILD).is_none() { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "wasm_service::shared_acceptance::installed_shared_components_keep_app_identity_and_revalidate_live_calls", "--nocapture"]) + .env(CHILD, "1").output().unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + output.status.success(), + "{stdout}\n{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + stdout.contains("1 passed"), + "Child test must actually execute: {stdout}" + ); + return; + } + assert!( + SHARED_RESOLVER.lock().unwrap().is_none(), + "Never bypass the installed resolver" + ); + let scratch = Scratch(std::env::temp_dir().join(format!( + "octosense-shared-acceptance-{}", + std::process::id() + ))); + std::fs::create_dir(&scratch.0).unwrap(); + let root = scratch.0.join("apps"); + std::fs::create_dir(&root).unwrap(); + let anchor = HubKey::generate(); + let working = HubKey::generate(); + let publisher = HubKey::generate(); + let certificate = anchor.certify(&working.public_hex()).unwrap(); + std::env::set_var("OCTOSENSE_APP_DATA", &root); + std::env::set_var("OCTOSENSE_HUB_CATALOG", "legacy"); + std::env::set_var("OCTOSENSE_HUB_ANCHOR", anchor.public_hex()); + octosense_appstore::set_data_root(root.clone()); + let notes = component("org.example.sharednotes", NOTES); + let hostcall = component("org.example.sharedhost", HOSTCALL); + let entries = vec![notes.clone(), hostcall.clone()]; + let pins = json!([ + {"as":"md","id":notes.component.id,"version":"1.0.0","blake3":notes.component.wasm_blake3}, + {"as":"bridge","id":hostcall.component.id,"version":"1.0.0","blake3":hostcall.component.wasm_blake3} + ]); + let catalog = |sequence, apps: Vec, components: Vec| { + let mut catalog = Catalog::new(sequence, &octosense_app_hub::today(), apps); + catalog.components = components; + working.sign_catalog(&mut catalog, &certificate).unwrap(); + catalog + }; + let component_catalog = catalog(1, vec![], entries.clone()); + let keys = PublisherKeys::new().with("acceptance-publisher", &publisher.public_hex()); + let bundles: Vec<_> = [FIRST, SECOND] + .iter() + .map(|app| bundle(&scratch.0, app, &pins, &publisher)) + .collect(); + let apps: Vec<_> = bundles + .iter() + .map(|bundle| { + let report = check_bundle( + bundle, + &HostLimits::default(), + &keys, + Some(&component_catalog), + ) + .unwrap(); + assert!(report.passed(), "{}", report.render()); + entry_for( + bundle, + &report, + "acceptance-publisher", + &publisher.public_hex(), + "", + "", + &octosense_app_hub::today(), + ) + .unwrap() + }) + .collect(); + let encoded = serde_json::to_string(&catalog(1, apps.clone(), entries.clone())).unwrap(); + // A legacy rehearsal must never accidentally become a GitHub acceptance claim. + assert!( + Store::new(&anchor.public_hex(), &root, HostLimits::default()) + .with_github_catalog() + .accept_catalog(&encoded) + .is_err() + ); + let mut store = Store::new(&anchor.public_hex(), &root, HostLimits::default()) + .with_host_api_versions(host_api::available_versions()); + store + .accept_catalog_and_cache(&encoded, &root.join("catalog.json")) + .unwrap(); + assert!(store + .install_staged(FIRST, &bundles[0], &keys, &octosense_app_hub::today()) + .is_err()); + let mirror = scratch.0.join("mirror"); + let origin = Origin::Directory(mirror.clone()); + assert!(components::install(&store, &origin, &apps[0].manifest).is_err()); + for (entry, bytes) in [(¬es, NOTES), (&hostcall, HOSTCALL)] { + let artifact = mirror.join(&entry.artifact); + std::fs::create_dir_all(artifact.parent().unwrap()).unwrap(); + std::fs::write(artifact, bytes).unwrap(); + } + for ((app, bundle), entry) in [FIRST, SECOND].iter().zip(&bundles).zip(&apps) { + components::install(&store, &origin, &entry.manifest).unwrap(); + store + .install_staged(app, bundle, &keys, &octosense_app_hub::today()) + .unwrap(); + store + .validate_prepared_launch(&store.prepare_launch(app).unwrap()) + .unwrap(); + } + let first = components::resolved_in(&root, FIRST).unwrap(); + let second = components::resolved_in(&root, SECOND).unwrap(); + assert_eq!( + first[0].path, second[0].path, + "Deduplicate bytes, never instances" + ); + assert!(std::fs::metadata(&first[0].path) + .unwrap() + .permissions() + .readonly()); + let storage = crate::app_storage::Storage::with_file_secrets( + crate::app_storage::Layout::new(&scratch.0.join("home")).unwrap(), + ); + let mut env = areas::FixedEnv { + storage: Some(storage.clone()), + ..areas::FixedEnv::default() + }; + for app in [FIRST, SECOND] { + env.quotas.insert( + app.into(), + areas::JailQuota { + bytes: Some(4096), + storage: true, + }, + ); + } + *AREA_ENV.lock().unwrap() = Some(Arc::new(env)); + struct Echo; + impl HostService for Echo { + fn family(&self) -> &'static str { + "sharedacceptance" + } + fn call(&mut self, call: ServiceCall, reply: Replier, _: &mut dyn ServiceHost) { + reply.send(Ok( + json!({"app":call.app_id,"method":call.method(),"args":call.args, + "may_prompt":call.may_prompt,"from_sheet":call.from_sheet}), + )); + } + } + services::register_host_service(Box::new(Echo)); + register(); + let invoke = |app, method, args| call(app, method, args, &root); + assert_eq!( + invoke(FIRST, "wasm.md.to_html", json!("# Installed")).unwrap(), + "

Installed

\n" + ); + assert_eq!( + invoke(SECOND, "wasm.md.to-html", json!({"markdown":"*Second*"})).unwrap(), + "

Second

\n" + ); + assert_eq!(invoke(FIRST, "wasm.md.count", Value::Null).unwrap(), 1); + assert_eq!(invoke(FIRST, "wasm.md.count", json!({})).unwrap(), 2); + assert_eq!(invoke(SECOND, "wasm.md.count", json!({})).unwrap(), 1); + assert!(invoke(FIRST, "wasm.count", json!({})) + .unwrap_err() + .contains("no function")); + let described = invoke(FIRST, "wasm.functions", json!({})).unwrap(); + assert!(described["functions"] + .as_array() + .unwrap() + .contains(&json!("md.to_html"))); + assert!(described["functions"] + .as_array() + .unwrap() + .contains(&json!("bridge.call"))); + assert_eq!(described["modules"][0]["shared"]["id"], notes.component.id); + invoke( + FIRST, + "wasm.md.save_html", + json!({"markdown":"# Private","path":"private.html"}), + ) + .unwrap(); + assert_eq!( + invoke(FIRST, "wasm.md.read_file", json!("private.html")).unwrap(), + "

Private

\n" + ); + assert!(invoke(SECOND, "wasm.md.read_file", json!("private.html")).is_err()); + assert!(!storage + .layout() + .app(SECOND) + .unwrap() + .jail + .join("private.html") + .exists()); + for app in [FIRST, SECOND] { + let reply = invoke( + app, + "wasm.bridge.call", + json!([ + "sharedacceptance.echo", + "{\"app\":\"org.example.impersonated\"}" + ]), + ) + .unwrap(); + let reply: Value = serde_json::from_str(reply.as_str().unwrap()).unwrap(); + assert_eq!( + reply, + json!({"app":app,"method":"echo","args":{"app":"org.example.impersonated"},"may_prompt":false,"from_sheet":false}) + ); + assert!( + invoke(app, "wasm.bridge.call", json!(["wasm.md.count", "{}"])) + .unwrap_err() + .contains("cannot call wasm.*") + ); + } + // A running app must not continue using a cached instance when the shared + // bytes have changed or its component is withdrawn in the current catalog. + let component_path = &first[0].path; + std::fs::remove_file(component_path).unwrap(); + std::fs::write(component_path, b"corrupt").unwrap(); + assert!(invoke(FIRST, "wasm.md.count", json!({})).is_err()); + std::fs::remove_file(component_path).unwrap(); + assert!(invoke(SECOND, "wasm.md.count", json!({})).is_err()); + components::install(&store, &origin, &apps[0].manifest).unwrap(); + // Successful re-admission after repair proves errors do not poison the app. + assert!(invoke(FIRST, "wasm.md.count", json!({})) + .unwrap() + .is_number()); + let mut withdrawn = entries; + withdrawn[0].status = Status::Withdrawn("Synthetic withdrawal".into()); + let next = serde_json::to_string(&catalog(2, apps, withdrawn)).unwrap(); + store + .accept_catalog_and_cache(&next, &root.join("catalog.json")) + .unwrap(); + for app in [FIRST, SECOND] { + let error = invoke(app, "wasm.md.count", json!({})).unwrap_err(); + assert!(error.contains("withdrawn"), "{error}"); + } +} diff --git a/crates/wasm-host/README.md b/crates/wasm-host/README.md new file mode 100644 index 000000000..444c64969 --- /dev/null +++ b/crates/wasm-host/README.md @@ -0,0 +1,31 @@ +# OctoSense Wasm runtime + +English | [简体中文](README.zh-CN.md) + +`Runtime` checks and compiles an app's verified Wasm bytes, then creates +isolated module or component instances. See the [Wasm guide](../../docs/wasm.md) +for the guest ABI, allowed imports, storage and execution limits. + +Compiled code is an optional local cache, keyed by the source digest and engine +compatibility hash. A cache read gets at most 50 ms of waiting time. Across all +runtimes in one process, at most two reader threads may remain in flight; there +is no waiting queue. A timed-out reader keeps its slot until it finishes, then +drops any late result. Cache reads accept at most 64 MiB per artifact. This is a +cache optimization limit, not a new limit on valid Wasm source. + +If the cache is busy, slow, oversized or unreadable, both module and component +loads compile the verified source and skip cache writes for that load. They do +not rewrite a file whose read is still blocked. A promptly missing entry may +still be populated atomically; a promptly read but incompatible entry may be +recompiled and replaced. The cache directory is host-owned: serialized code is +never accepted from an app bundle as a trusted compiled artifact. + +Cache publication does not spawn warm-up reader threads. `precompile` uses the +same bounded reader path as ordinary loads. Invocation deadlines, cancellation, +guest memory limits and the host request deadline remain unchanged. Source +compilation still takes time; this change bounds waiting for cache reads, not +compilation or cache publication. + +The runtime tests cover deterministic blocked readers, saturation, slot release, +read errors and size limits; execution after fallback; and actual module and +component deserialization from a ready cache. diff --git a/crates/wasm-host/README.zh-CN.md b/crates/wasm-host/README.zh-CN.md new file mode 100644 index 000000000..c1d9110f0 --- /dev/null +++ b/crates/wasm-host/README.zh-CN.md @@ -0,0 +1,23 @@ +# OctoSense Wasm 运行时 + +[English](README.md) | 简体中文 + +`Runtime` 检查并编译应用经过验证的 Wasm 源字节,然后创建隔离的模块或组件实例。 +客户代码的 ABI、允许的导入、存储和执行限制见 [Wasm 指南](../../docs/wasm.zh-CN.md)。 + +编译结果是可选的本地缓存,以源文件摘要和引擎兼容性哈希为键。每次缓存读取最多等待 +50 毫秒。同一进程的所有运行时合计最多允许两个读取线程处于运行中,没有等待队列。 +读取超时后,线程仍占用名额,直到实际读取结束;迟到的结果会被丢弃。每个编译缓存文件 +最多读取 64 MiB。这是缓存优化的限制,不是对合法 Wasm 源码新增的限制。 + +缓存繁忙、读取过慢、文件过大或无法读取时,模块和组件都会编译经过验证的源字节, +并跳过本次加载的缓存写入,不会重写仍被阻塞读取的文件。快速确认不存在的缓存仍可 +原子写入;快速读取后发现不兼容的缓存可重新编译并替换。缓存目录由宿主管理,应用包 +提供的序列化编译结果不会被当作可信缓存加载。 + +缓存写入不再启动预热读取线程。`precompile` 与普通加载使用同一条有界读取路径。 +调用期限、取消逻辑、客户代码内存限制和宿主请求期限均保持不变。源代码编译仍需要 +时间;本次改动限制的是等待缓存读取的时间,不是编译或缓存写入时间。 + +运行时测试覆盖确定性阻塞读取、名额耗尽与释放、读取错误和大小限制、回退后的实际执行, +以及从已可快速读取的缓存反序列化并执行模块与组件。 diff --git a/crates/wasm-host/src/cache.rs b/crates/wasm-host/src/cache.rs new file mode 100644 index 000000000..c80a60363 --- /dev/null +++ b/crates/wasm-host/src/cache.rs @@ -0,0 +1,282 @@ +//! Optional compiled-code reads must not hold a Wasm request behind filesystem I/O. + +use std::io::{self, Read}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{mpsc, Arc, OnceLock}; +use std::time::Duration; + +const MAX_READERS: usize = 2; +const READ_WAIT: Duration = Duration::from_millis(50); +// A larger valid compiled artifact still works by compiling the verified source. +const MAX_BYTES: u64 = 64 << 20; + +pub(super) enum ReadResult { + Hit(Vec), + Missing, + Unavailable, +} + +#[derive(Default)] +pub(super) struct Readers { + active: AtomicUsize, +} + +struct Slot(Arc); + +impl Drop for Slot { + fn drop(&mut self) { + self.0.active.fetch_sub(1, Ordering::AcqRel); + } +} + +impl Readers { + pub(super) fn shared() -> Arc { + static READERS: OnceLock> = OnceLock::new(); + READERS.get_or_init(|| Arc::new(Self::default())).clone() + } + + pub(super) fn read(self: &Arc, path: PathBuf) -> ReadResult { + self.read_with(move || read_file(path), READ_WAIT) + } + + fn read_with( + self: &Arc, + read: impl FnOnce() -> io::Result> + Send + 'static, + wait: Duration, + ) -> ReadResult { + // There is no queue and no waiting for a slot. A stuck filesystem can + // retain at most MAX_READERS threads, including after callers time out. + if self + .active + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |n| { + (n < MAX_READERS).then_some(n + 1) + }) + .is_err() + { + return ReadResult::Unavailable; + } + let slot = Slot(self.clone()); + let (send, receive) = mpsc::sync_channel(1); + if std::thread::Builder::new() + .name("wasm-cache-read".into()) + .spawn(move || { + let _slot = slot; + // If the caller has timed out, send drops the late bytes. + // The slot remains held until the actual read finishes (or panics). + let _ = send.send(read()); + }) + .is_err() + { + return ReadResult::Unavailable; + } + match receive.recv_timeout(wait) { + Ok(Ok(bytes)) => ReadResult::Hit(bytes), + Ok(Err(error)) if error.kind() == io::ErrorKind::NotFound => ReadResult::Missing, + _ => ReadResult::Unavailable, + } + } +} + +fn read_file(path: PathBuf) -> io::Result> { + let file = std::fs::File::open(path)?; + let metadata = file.metadata()?; + if !metadata.is_file() || metadata.len() > MAX_BYTES { + return Err(io::Error::other("compiled cache exceeds its read limit")); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + file.take(MAX_BYTES + 1).read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_BYTES { + return Err(io::Error::other("compiled cache exceeds its read limit")); + } + Ok(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{component::Grants, Limits, Runtime}; + use std::sync::mpsc::{Receiver, Sender}; + use std::time::Instant; + + fn wait_until_released(readers: &Readers) { + let until = Instant::now() + Duration::from_secs(2); + while readers.active.load(Ordering::Acquire) != 0 { + assert!( + Instant::now() < until, + "cache reader did not release its slot" + ); + std::thread::yield_now(); + } + } + + // The read cannot finish until the test permits it: no filesystem timing + // assumptions and no sleeping worker which might finish before the assertion. + fn block_reader(readers: &Arc) -> (Sender<()>, Receiver<()>) { + let (release, released) = mpsc::channel(); + let (started, start) = mpsc::channel(); + let (finished, finish) = mpsc::channel(); + let began = Instant::now(); + assert!(matches!( + readers.read_with( + move || { + started.send(()).unwrap(); + released.recv().unwrap(); + finished.send(()).unwrap(); + Ok(vec![7; 4096]) + }, + Duration::from_millis(10), + ), + ReadResult::Unavailable + )); + assert!(began.elapsed() < Duration::from_secs(2)); + start.recv_timeout(Duration::from_secs(2)).unwrap(); + (release, finish) + } + + #[test] + fn timed_out_reads_keep_slots_bounded_and_release_after_late_completion() { + let readers = Arc::new(Readers::default()); + let blocked: Vec<_> = (0..MAX_READERS).map(|_| block_reader(&readers)).collect(); + assert_eq!(readers.active.load(Ordering::Acquire), MAX_READERS); + let unexpected_reads = Arc::new(AtomicUsize::new(0)); + for _ in 0..100 { + let started = unexpected_reads.clone(); + assert!(matches!( + readers.read_with( + move || { + started.fetch_add(1, Ordering::AcqRel); + Ok(Vec::new()) + }, + READ_WAIT + ), + ReadResult::Unavailable + )); + } + assert_eq!(unexpected_reads.load(Ordering::Acquire), 0); + assert_eq!(readers.active.load(Ordering::Acquire), MAX_READERS); + for (release, finished) in blocked { + release.send(()).unwrap(); + finished.recv_timeout(Duration::from_secs(2)).unwrap(); + } + // A late result must not leave either a blocked send or an occupied slot. + wait_until_released(&readers); + assert_eq!(unexpected_reads.load(Ordering::Acquire), 0); + assert!(matches!( + readers.read_with(|| Ok(vec![1, 2, 3]), Duration::from_secs(2)), + ReadResult::Hit(bytes) if bytes == [1, 2, 3] + )); + wait_until_released(&readers); + } + + #[test] + fn failure_and_panic_release_the_slot_but_only_missing_allows_publication() { + let readers = Arc::new(Readers::default()); + for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::NotFound] { + let result = readers.read_with(move || Err(kind.into()), Duration::from_secs(2)); + assert!(matches!( + (kind, result), + (io::ErrorKind::NotFound, ReadResult::Missing) + | (io::ErrorKind::PermissionDenied, ReadResult::Unavailable) + )); + wait_until_released(&readers); + } + assert!(matches!( + readers.read_with(|| panic!("injected reader panic"), Duration::from_secs(2)), + ReadResult::Unavailable + )); + wait_until_released(&readers); + } + + fn cache_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("wasm-cache-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn oversized_cache_is_unavailable_instead_of_allocated() { + let dir = cache_dir("oversized"); + let path = dir.join("large.cwasm"); + std::fs::File::create(&path) + .unwrap() + .set_len(MAX_BYTES + 1) + .unwrap(); + let readers = Arc::new(Readers::default()); + assert!(matches!( + readers.read_with(move || read_file(path), Duration::from_secs(2)), + ReadResult::Unavailable + )); + wait_until_released(&readers); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn blocked_readers_fall_back_for_module_and_component_without_cache_writes() { + let dir = cache_dir("fallback"); + let mut runtime = Runtime::new(Limits::default(), Some(dir.clone())).unwrap(); + // Isolate the gate so parallel cache tests cannot occupy these slots. + runtime.cache_readers = Arc::new(Readers::default()); + let blocked: Vec<_> = (0..MAX_READERS) + .map(|_| block_reader(&runtime.cache_readers)) + .collect(); + let module = wat::parse_str( + r#"(module + (memory (export "memory") 1) + (data (i32.const 0) "\00ok") + (func (export "octo_alloc") (param i32) (result i32) i32.const 32) + (func (export "octo_free") (param i32 i32)) + (func (export "echo") (param i32 i32) (result i64) i64.const 3))"#, + ) + .unwrap(); + let component = include_bytes!("../tests/fixtures/notes.component.wasm"); + let paths = [ + runtime.cache_path(&module).unwrap(), + runtime.cache_path(component).unwrap(), + ]; + for path in &paths { + std::fs::write(path, b"existing cache must stay untouched").unwrap(); + } + let program = runtime.load(&module).unwrap(); + assert!(!program.from_cache()); + assert_eq!( + runtime + .instantiate(&program) + .unwrap() + .call("echo", b"") + .unwrap(), + b"ok" + ); + let program = runtime.load_component(component).unwrap(); + assert!(!program.from_cache()); + let mut instance = runtime + .instantiate_component(&program, &Grants::default(), None) + .unwrap(); + assert_eq!( + instance + .call_json("to_html", &serde_json::json!("# Fallback")) + .unwrap(), + serde_json::json!("

Fallback

\n") + ); + for path in &paths { + assert_eq!( + std::fs::read(path).unwrap(), + b"existing cache must stay untouched" + ); + } + // Missing entries must not be published either while I/O is unavailable. + for path in &paths { + std::fs::remove_file(path).unwrap(); + } + assert!(!runtime.load(&module).unwrap().from_cache()); + assert!(!runtime.load_component(component).unwrap().from_cache()); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0); + for (release, finished) in blocked { + release.send(()).unwrap(); + finished.recv_timeout(Duration::from_secs(2)).unwrap(); + } + wait_until_released(&runtime.cache_readers); + std::fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/crates/wasm-host/src/component.rs b/crates/wasm-host/src/component.rs index a4c21f6e7..fdf7c0e9a 100644 --- a/crates/wasm-host/src/component.rs +++ b/crates/wasm-host/src/component.rs @@ -197,18 +197,21 @@ impl Runtime { } let cached = self.cache_path(bytes); let mut from_cache = false; - let component = match cached.as_ref().and_then(|path| std::fs::read(path).ok()) { + let component = match self.read_cache(cached.as_ref()) { // SAFETY: the cache holds only what `Component::serialize` wrote // for this engine (the key includes its compatibility hash), in a // directory that belongs to the shell. - Some(code) => match unsafe { Component::deserialize(&self.engine, &code) } { - Ok(component) => { - from_cache = true; - component + super::cache::ReadResult::Hit(code) => { + match unsafe { Component::deserialize(&self.engine, &code) } { + Ok(component) => { + from_cache = true; + component + } + Err(_) => self.compile_component(bytes, cached.as_ref())?, } - Err(_) => self.compile_component(bytes, cached.as_ref())?, - }, - None => self.compile_component(bytes, cached.as_ref())?, + } + super::cache::ReadResult::Missing => self.compile_component(bytes, cached.as_ref())?, + super::cache::ReadResult::Unavailable => self.compile_component(bytes, None)?, }; let (exports, skipped) = check_component(&self.engine, &component)?; let uses_network = component diff --git a/crates/wasm-host/src/lib.rs b/crates/wasm-host/src/lib.rs index 68f6b0312..0ddc3e6ee 100644 --- a/crates/wasm-host/src/lib.rs +++ b/crates/wasm-host/src/lib.rs @@ -34,6 +34,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; +mod cache; pub mod component; use sha2::{Digest, Sha256}; @@ -154,6 +155,7 @@ pub struct Runtime { engine: Engine, limits: Limits, cache_dir: Option, + cache_readers: Arc, ticker: Arc, } @@ -230,6 +232,7 @@ impl Runtime { engine, limits, cache_dir, + cache_readers: cache::Readers::shared(), ticker, }) } @@ -257,18 +260,21 @@ impl Runtime { } let cached = self.cache_path(bytes); let mut from_cache = false; - let module = match cached.as_ref().and_then(|path| std::fs::read(path).ok()) { + let module = match self.read_cache(cached.as_ref()) { // SAFETY: the cache holds only what `Module::serialize` wrote for // this engine (the key includes its compatibility hash), in a // directory that belongs to the shell. - Some(code) => match unsafe { Module::deserialize(&self.engine, &code) } { - Ok(module) => { - from_cache = true; - module + cache::ReadResult::Hit(code) => { + match unsafe { Module::deserialize(&self.engine, &code) } { + Ok(module) => { + from_cache = true; + module + } + Err(_) => self.compile(bytes, cached.as_ref())?, } - Err(_) => self.compile(bytes, cached.as_ref())?, - }, - None => self.compile(bytes, cached.as_ref())?, + } + cache::ReadResult::Missing => self.compile(bytes, cached.as_ref())?, + cache::ReadResult::Unavailable => self.compile(bytes, None)?, }; let functions = check(&module)?; Ok(Program { @@ -283,9 +289,6 @@ impl Runtime { /// that its first call loads from the cache instead of waiting for /// Cranelift (ADR 0014 phase 3). Without a cache it only checks. pub fn precompile(&self, bytes: &[u8]) -> Result { - if self.cache_path(bytes).is_some_and(|path| path.is_file()) { - return Ok(false); - } if component::is_component(bytes) { self.load_component(bytes) .map(|program| !program.from_cache()) @@ -309,44 +312,41 @@ impl Runtime { /// Writes compiled code to the cache atomically. fn publish_cache(&self, path: &PathBuf, code: &[u8]) { - { - { - // 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 - // that happen now rather than at the next load. - let path = path.clone(); - let _ = std::thread::Builder::new() - .name("wasm-cache".into()) - .spawn(move || drop(std::fs::read(path))); - } - let _ = std::fs::remove_file(tmp); - } + let Some(dir) = path.parent() else { return }; + if std::fs::create_dir_all(dir).is_err() { + return; + } + // Different apps may compile identical bytes concurrently. Never + // truncate another worker's staging file: deserialization is safe + // only 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 _ = (|| -> 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) + })(); + // No detached warm-up reads: every cache read shares the bounded gate. + let _ = std::fs::remove_file(tmp); + } + + fn read_cache(&self, path: Option<&PathBuf>) -> cache::ReadResult { + match path { + Some(path) => self.cache_readers.read(path.clone()), + None => cache::ReadResult::Unavailable, } } fn cache_path(&self, bytes: &[u8]) -> Option { let dir = self.cache_dir.as_ref()?; - std::fs::create_dir_all(dir).ok()?; let mut engine = std::collections::hash_map::DefaultHasher::new(); self.engine .precompile_compatibility_hash() diff --git a/crates/wasm-host/tests/component.rs b/crates/wasm-host/tests/component.rs index 5de409b86..4b35bec4f 100644 --- a/crates/wasm-host/tests/component.rs +++ b/crates/wasm-host/tests/component.rs @@ -488,3 +488,28 @@ fn a_component_that_imports_sockets_is_refused() { Ok(_) => panic!("loaded a component that imports sockets"), } } + +#[test] +fn compiled_component_is_cached_and_executes_after_runtime_restart() { + let dir = storage(); + let first = Runtime::new(Limits::default(), Some(dir.clone())).unwrap(); + assert!(first.precompile(NOTES).unwrap()); + drop(first); + // Exercise real cached-code deserialization after any first-open delay; + // deterministic slow reads are tested separately in cache::tests. + for entry in std::fs::read_dir(&dir).unwrap() { + std::fs::read(entry.unwrap().path()).unwrap(); + } + let second = Runtime::new(Limits::default(), Some(dir.clone())).unwrap(); + assert!(!second.precompile(NOTES).unwrap()); + let program = second.load_component(NOTES).unwrap(); + assert!(program.from_cache()); + let mut instance = second + .instantiate_component(&program, &Grants::default(), None) + .unwrap(); + assert_eq!( + instance.call_json("to_html", &json!("# Cached")).unwrap(), + json!("

Cached

\n") + ); + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/wasm-host/tests/runtime.rs b/crates/wasm-host/tests/runtime.rs index 60e46114b..5b8b7d995 100644 --- a/crates/wasm-host/tests/runtime.rs +++ b/crates/wasm-host/tests/runtime.rs @@ -4,6 +4,10 @@ use std::time::{Duration, Instant}; +// Keep the cache-hit assertion independent of the other test deliberately +// saturating the process-wide reader gate. Its own workers remain concurrent. +static CACHE_TEST: std::sync::Mutex<()> = std::sync::Mutex::new(()); + use octosense_wasm_host::{CallError, Instance, Limits, LoadError, Runtime}; const GUEST: &str = r#" @@ -237,12 +241,14 @@ fn input_over_the_limit_is_refused() { #[test] fn compiled_code_is_cached_by_digest() { + let _exclusive = CACHE_TEST.lock().unwrap(); let dir = std::env::temp_dir().join(format!("octosense-wasm-host-cache-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); let bytes = wat::parse_str(GUEST).unwrap(); let first = Runtime::new(limits(), Some(dir.clone())).unwrap(); assert!(!first.load(&bytes).unwrap().from_cache()); + prepare_cache_reads(&dir); let second = Runtime::new(limits(), Some(dir.clone())).unwrap(); let program = second.load(&bytes).unwrap(); assert!(program.from_cache()); @@ -348,6 +354,7 @@ fn guarded_invocations_observe_cancellation_and_the_absolute_deadline() { #[test] fn concurrent_compilation_publishes_only_complete_cache_entries() { + let _exclusive = CACHE_TEST.lock().unwrap(); use std::sync::{Arc, Barrier}; let dir = std::env::temp_dir().join(format!("octosense-wasm-cache-race-{}", std::process::id())); @@ -375,6 +382,7 @@ fn concurrent_compilation_publishes_only_complete_cache_entries() { for worker in workers { worker.join().unwrap(); } + prepare_cache_reads(&dir); let program = runtime.load(&bytes).unwrap(); assert!(program.from_cache()); assert_eq!( @@ -393,3 +401,11 @@ fn concurrent_compilation_publishes_only_complete_cache_entries() { == "cwasm")); let _ = std::fs::remove_dir_all(dir); } + +// These tests exercise actual deserialization, not a slow-filesystem fallback. +// Allow any first-open delay to finish before checking the bounded fast path. +fn prepare_cache_reads(dir: &std::path::Path) { + for entry in std::fs::read_dir(dir).unwrap() { + std::fs::read(entry.unwrap().path()).unwrap(); + } +} diff --git a/crates/wasm-phone-smoke/src/main.rs b/crates/wasm-phone-smoke/src/main.rs index 5ec174fed..e195f7252 100644 --- a/crates/wasm-phone-smoke/src/main.rs +++ b/crates/wasm-phone-smoke/src/main.rs @@ -12,6 +12,7 @@ use std::{ }; #[path = "../../shell/examples/connected_support/mod.rs"] mod connected_support; +mod shared; app_main!(App, font_set: International); script_mod! { @@ -58,10 +59,19 @@ struct App { admitted_prompts: bool, #[rust] suspended: bool, + #[rust] + shared: Option, } impl MatchEvent for App { fn handle_startup(&mut self, cx: &mut Cx) { + if let Some(config) = shared::config(cx) { + self.shared = Some( + shared::Acceptance::new(cx, &self.ui, config) + .expect("Real GitHub shared-component acceptance"), + ); + return; + } #[cfg(not(target_os = "android"))] let arg = |name: &str| std::env::args().find_map(|a| a.strip_prefix(name).map(str::to_owned)); @@ -181,6 +191,10 @@ impl AppMain for App { } fn handle_event(&mut self, cx: &mut Cx, event: &Event) { self.match_event(cx, event); + if let Some(shared) = &mut self.shared { + shared.handle_event(cx, event, &self.ui); + return; + } if matches!(event, Event::Pause | Event::Background) { self.suspended = true; } diff --git a/crates/wasm-phone-smoke/src/shared.rs b/crates/wasm-phone-smoke/src/shared.rs new file mode 100644 index 000000000..cfa3db36d --- /dev/null +++ b/crates/wasm-phone-smoke/src/shared.rs @@ -0,0 +1,349 @@ +//! Real GitHub catalog -> Store install -> Splash app tool -> shared component. +//! Uses only the two synthetic component-demo identities and a new private home. +//! No legacy channel, signing key, model, provider account, or grant substitution. +use makepad_widgets::*; +use octosense_app_hub::{PreparedLaunch, Store}; +use octosense_app_policy::{AssetServer, HostLimits}; +use octosense_appstore::{ + components, host_api, script_tools, services, + source::{CatalogChannel, Origin}, +}; +use serde_json::{json, Value}; +use std::{ + path::PathBuf, + sync::{Arc, Mutex}, + time::Duration, +}; + +const IDS: [&str; 2] = [ + "org.ymote.componentdemo.first", + "org.ymote.componentdemo.second", +]; + +pub struct Config { + mirror: PathBuf, + home: PathBuf, + receipt: PathBuf, +} + +pub fn config(cx: &Cx) -> Option { + #[cfg(target_os = "android")] + { + let directory = PathBuf::from(cx.get_data_dir()?).join("shared-components-lab"); + let mirror = directory.join("mirror"); + mirror.join("catalog-v2.json").exists().then(|| Config { + mirror, + home: directory.join("home"), + receipt: directory.join("receipt.json"), + }) + } + #[cfg(not(target_os = "android"))] + { + let _ = cx; + let argument = |prefix: &str| { + std::env::args().find_map(|arg| arg.strip_prefix(prefix).map(PathBuf::from)) + }; + argument("--shared-mirror=").map(|mirror| Config { + mirror, + home: argument("--shared-home=").expect("Use a new --shared-home directory"), + receipt: argument("--receipt=").expect("Use a new --receipt path"), + }) + } +} + +pub struct Acceptance { + root: PathBuf, + receipt: PathBuf, + launches: Vec, + card: SplashRef, + sheet: SplashRef, + assets: Option, + registration: Option, + replies: Arc>>>, + step: usize, + results: Vec, + receipt_data: Value, + completed: bool, + _timer: Timer, +} + +impl Acceptance { + pub fn new(cx: &mut Cx, ui: &WidgetRef, config: Config) -> Result { + if config.receipt.exists() || config.home.exists() { + return Err("The shared-component fixture requires a new home and receipt".into()); + } + if matches!(std::env::var("OCTOSENSE_HUB_CATALOG").ok().as_deref(), Some(value) if value != "github") + { + return Err("This acceptance refuses a legacy catalog override".into()); + } + let root = config.home.join("apps"); + std::fs::create_dir(&config.home).map_err(|e| e.to_string())?; + // The cache lock lives beside catalog-v2.json, before installation + // creates any app directories. Keep the entire store in this fresh home. + std::fs::create_dir(&root).map_err(|e| e.to_string())?; + // All process-wide paths are redirected before any storage service is + // initialized. Existing user profiles and native app homes are unused. + std::env::set_var("OCTOSENSE_HOME", &config.home); + std::env::set_var("OCTOSENSE_APP_DATA", &root); + std::env::set_var("OCTOSENSE_SECRETS", "file"); + let storage = octosense_shell::app_storage::init(Some(config.home.clone())) + .ok_or("Private app storage unavailable")?; + if storage.layout().apps_root() != root { + return Err("Fixture storage was already initialized elsewhere".into()); + } + octosense_appstore::set_data_root(root.clone()); + let channel = CatalogChannel::from_environment(&root)?; + if channel != CatalogChannel::GitHub { + return Err("Default GitHub trust channel is required".into()); + } + let origin = Origin::Directory(config.mirror); + let catalog = origin.catalog_for(channel)?; + let verified = octosense_app_hub::github_catalog::verify_document(catalog.as_bytes())?; + let mut store = Store::new( + octosense_appstore::DEFAULT_ANCHOR, + &root, + HostLimits::default(), + ) + .with_github_catalog() + .with_host_api_versions(host_api::available_versions()); + store.accept_catalog_and_cache(&catalog, &root.join("catalog-v2.json"))?; + let mut launches = Vec::new(); + for id in IDS { + let entry = store + .catalog() + .unwrap() + .entries + .iter() + .rev() + .find(|entry| entry.app_id() == id) + .ok_or_else(|| format!("Verified catalog has no {id}"))? + .clone(); + let staging = config.home.join(format!("staging-{id}")); + std::fs::create_dir(&staging).map_err(|e| e.to_string())?; + let bundle = origin.stage(&entry.artifact, &staging)?; + components::install(&store, &origin, &entry.manifest)?; + store.install_staged( + id, + &bundle, + &store.publisher_keys(), + &octosense_app_hub::today(), + )?; + let launch = store.prepare_launch(id)?; + store.validate_prepared_launch(&launch)?; + storage.installed(id); + launches.push(launch); + std::fs::remove_dir_all(staging).map_err(|e| e.to_string())?; + } + let first = serde_json::to_value(&launches[0].manifest).map_err(|e| e.to_string())?; + let second = serde_json::to_value(&launches[1].manifest).map_err(|e| e.to_string())?; + if first["capabilities"] + .as_array() + .is_some_and(|caps| !caps.is_empty()) + { + return Err("First consumer must exercise an empty capability declaration".into()); + } + if second["capabilities"] != json!(["storage", "wasm"]) + && second["capabilities"] != json!(["wasm", "storage"]) + { + return Err("Second consumer must declare wasm and storage".into()); + } + let first_components = components::resolved_in(&root, IDS[0])?; + let second_components = components::resolved_in(&root, IDS[1])?; + if first_components.len() != 2 || second_components.len() != 2 { + return Err("Expected two pinned shared components per app".into()); + } + for (a, b) in first_components.iter().zip(&second_components) { + if a.path != b.path + || a.blake3 != b.blake3 + || !std::fs::metadata(&a.path) + .map_err(|e| e.to_string())? + .permissions() + .readonly() + { + return Err("Shared bytes are not deduplicated and read-only".into()); + } + } + let receipt_data = json!({"schema":1,"platform":host_api::platform(), + "proof":"real GitHub catalog and publisher proofs -> Store install -> app tool -> Splash host.request -> shared component", + "source_revision":env!("WASM_FIXTURE_REVISION"),"source_dirty":env!("WASM_FIXTURE_DIRTY")!="false", + "runtime_tree":env!("WASM_FIXTURE_RUNTIME_TREE"),"catalog_sha256":verified.sha256(), + "catalog_sequence":verified.catalog().sequence,"catalog_channel":"github","legacy_fallback":false, + "apps":[{"id":IDS[0],"bundle_digest":launches[0].manifest.integrity.bundle_blake3,"declarations":first["capabilities"]}, + {"id":IDS[1],"bundle_digest":launches[1].manifest.integrity.bundle_blake3,"declarations":second["capabilities"]}], + "components":first_components.iter().map(|entry|json!({"alias":entry.alias,"id":entry.id,"version":entry.version,"blake3":entry.blake3})).collect::>(), + "deduplicated_readonly_components":true,"checks":{},"personal_accounts_used":false,"model_started":false, + "not_verified":["performance benchmark","live model relay","production Home upgrade"]}); + let mut acceptance = Self { + root, + receipt: config.receipt, + launches, + card: ui.splash(cx, ids!(app)), + sheet: ui.splash(cx, ids!(sheet)), + assets: None, + registration: None, + replies: Arc::default(), + step: 0, + results: vec![], + receipt_data, + completed: false, + _timer: cx.start_interval(0.03), + }; + acceptance.open_current(cx)?; + Ok(acceptance) + } + + fn open_current(&mut self, cx: &mut Cx) -> Result<(), String> { + self.registration.take(); + self.assets.take(); + let index = self.step % 2; + let launch = &self.launches[index]; + let id = IDS[index]; + let bundle = launch.bundle(); + let mut settings = launch.policy.isolate_settings(&self.root); + std::fs::create_dir_all(&settings.jail_root).map_err(|e| e.to_string())?; + let assets = AssetServer::start_with_static(bundle, &[])?; + settings.hosts.push(assets.allowlist_entry()); + let source = octosense_app_policy::script_source(bundle, assets.origin()) + .ok_or("Consumer has no script")??; + octosense_app_policy::splash_adapter::apply(&self.card, cx, &settings); + octosense_appstore::apply_device_consent(cx, bundle, &self.card)?; + self.card.set_text(cx, &source); + // Replacing the Splash view creates fresh draw areas. Invalidate the + // containing window too, so it presents the current app rather than + // retaining the previous app's frame in the hidden acceptance host. + cx.redraw_all(); + self.registration = script_tools::bind(cx, id, bundle, &self.card)?; + self.assets = Some(assets); + let replies = self.replies.clone(); + script_tools::submit( + id, + if index == 0 { + "first.inspect" + } else { + "second.inspect" + }, + json!({}), + "device", + "native-acceptance", + Duration::from_secs(45), + Box::new(move |reply| replies.lock().unwrap().push(reply)), + )?; + Ok(()) + } + + pub fn handle_event(&mut self, cx: &mut Cx, event: &Event, ui: &WidgetRef) { + ui.handle_event(cx, event, &mut Scope::empty()); + if self.completed { + return; + } + let id = IDS[self.step % 2]; + // No test grant and no approval sheet: component host calls use the + // production UI dispatcher with may_prompt=false. + self.card.set_host_prompts(cx, false); + script_tools::pump(cx, id, &self.card); + services::pump(cx, id, &self.root.join(".host"), &self.card, &self.sheet); + octosense_shell::wasm_service::pump_host_calls(); + octosense_shell::platform_services::handle_event(cx, event); + let reply = self.replies.lock().unwrap().pop(); + if let Some(reply) = reply { + match reply { + Ok(value) => self.results.push(value), + Err(error) => { + self.finish(Err(error)); + return; + } + } + if self.sheet.borrow().is_some_and(|sheet| sheet.view.visible) { + self.finish(Err("Unexpected host approval sheet".into())); + return; + } + self.step += 1; + if self.step == 4 { + let result = self.validate(); + self.finish(result); + } else if let Err(error) = self.open_current(cx) { + self.finish(Err(error)); + } + } + } + + fn validate(&mut self) -> Result<(), String> { + let mut checks = serde_json::Map::new(); + for (step, value) in self.results.iter().enumerate() { + let id = IDS[step % 2]; + let name = if step % 2 == 0 { "First" } else { "Second" }; + let expected = format!("

Component Demo · {name}

\n"); + let results = &value["results"]; + let expected_count = if step < 2 { 1 } else { 3 }; + let mut check = |name: &str, ok: bool| -> Result<(), String> { + checks.insert(format!("turn_{}_{}", step + 1, name), json!(ok)); + if ok { + Ok(()) + } else { + Err(format!("turn {}: {name}", step + 1)) + } + }; + check("app_identity", value["app"] == id)?; + check( + "retained_isolated_counter", + results["count_1"]["data"] == expected_count + && results["count_2"]["data"] == expected_count + 1, + )?; + check( + "markdown", + results["markdown"]["is_ok"] == true && results["markdown"]["data"] == expected, + )?; + check( + "private_storage_before_write", + if step < 2 { + results["before_write"]["is_ok"] == false + } else { + results["before_write"]["data"] == expected + }, + )?; + check( + "private_storage_after_write", + results["saved"]["is_ok"] == true && results["read_back"]["data"] == expected, + )?; + let descriptor = results["host_call"]["data"] + .as_str() + .and_then(|text| serde_json::from_str::(text).ok()) + .unwrap_or(Value::Null); + check( + "real_host_dispatch", + results["host_call"]["is_ok"] == true + && descriptor["descriptor"]["name"] == "runtime.describe" + && descriptor["implemented"] == true, + )?; + let names = results["functions"]["data"]["functions"].as_array(); + check( + "alias_discovery", + names.is_some_and(|names| { + names.contains(&json!("md.to_html")) && names.contains(&json!("bridge.call")) + }), + )?; + } + self.receipt_data["checks"] = Value::Object(checks); + Ok(()) + } + + fn finish(&mut self, result: Result<(), String>) { + use std::io::Write; + self.completed = true; + self.registration.take(); + self.receipt_data["passed"] = json!(result.is_ok()); + self.receipt_data["turns"] = json!(self.results); + if let Err(error) = result { + self.receipt_data["error"] = json!(error); + } + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&self.receipt) + .and_then(|mut file| { + file.write_all(&serde_json::to_vec_pretty(&self.receipt_data).unwrap()) + }) + .expect("Write private shared-component receipt"); + println!("SHARED_COMPONENT_ACCEPTANCE_COMPLETE"); + } +} diff --git a/desktop/docs/code-walkthrough.md b/desktop/docs/code-walkthrough.md index 7dd7e94de..ec11fd519 100644 --- a/desktop/docs/code-walkthrough.md +++ b/desktop/docs/code-walkthrough.md @@ -119,7 +119,8 @@ tests installation into the shell before publication. `agent_apps` and `register_host_services`. App Hub's native `CARD_MODULE` hosts these interpreted programs. The optional AppCard assistant has its own module. -Admission resolves manifest capability requests into policy. A script's +Admission verifies the bundle and resolves its metadata and resource limits. +Capability names disclose usage, not execution permission. A script's `host.request(...)` runs under that app's identity. App Hub's `crates/appstore/src/services.rs` defines `HostService`, `ServiceCall` and replies; the call supplies an app identity and host directory for the service to check. @@ -170,7 +171,8 @@ The shipped declarations provide these operations: | Photos, Maps, YouTube, Camera | `.notify` only; the shell's `NoticeService` handles each application's namespace. Camera is packaged by Home. | AI providers configures the host and currently declares no app agent. Calendar's -contained month/day view and editor use the explicit `calendar` capability. Its +contained month/day view and editor disclose `calendar` use; the service +checks their admitted app identity and data scope. Its UI and the agent tools above share one local event store. A saved event's Glance card opens that exact record in Calendar, and editing it refreshes its card. @@ -179,7 +181,7 @@ Follow [`glance_notice.rs`](../../crates/shell/src/glance_notice.rs) and for the shared notice. Mail and News keep their own services and install notice callbacks; `serve_system_apps` adds a `NoticeService` only for namespaces without a service. `publish_args` fills app name/icon, time, title and body, then -`glance::publish_for` checks the app's `glance` grant. The notice opens its app and +`glance::publish_for` checks current publisher admission and account identity. The notice opens its app and sets `notify: true`. Calendar keeps its own event and agenda card templates. The broader [`glance.publish`](../../crates/shell/src/glance.rs) API accepts either @@ -205,7 +207,7 @@ Keep the data boundaries visible when adding a tool: | Boundary | Access path | | --- | --- | -| Script storage | Runtime storage APIs under the app's capability and jail. | +| Script storage | Runtime storage APIs in the app's bounded private jail, even when `storage` is omitted. | | Agent workspace | The peer's app/account folder through granted file tools and shell policy. | | Host-service database | Explicit Rust methods/tools for Calendar events, Mail cache or News data. Credentials stay with host sheets and vaults. | diff --git a/desktop/docs/code-walkthrough.zh-CN.md b/desktop/docs/code-walkthrough.zh-CN.md index f4a6097aa..dacc96499 100644 --- a/desktop/docs/code-walkthrough.zh-CN.md +++ b/desktop/docs/code-walkthrough.zh-CN.md @@ -100,7 +100,7 @@ MAKEPAD_APP_CONFIG='{"mail_demo":true}' cargo run --locked --release -p octosens `register_host_services`。App Hub 的原生 `CARD_MODULE` 托管这些解释执行程序; 可选的 AppCard 助手使用自己的模块。 -准入将 manifest 的能力请求解析为策略。脚本的 `host.request(...)` 按应用身份执行。 +准入验证应用包,解析元数据与资源上限。能力名称披露用途,不是执行许可。脚本的 `host.request(...)` 按应用身份执行。 App Hub 的 `crates/appstore/src/services.rs` 定义 `HostService`、`ServiceCall` 和回复机制; 调用携带应用身份和宿主目录,供服务检查。只有明确声明的 Agent 工具,才会把相应操作提供给模型。 @@ -145,14 +145,14 @@ sequenceDiagram | Photos、Maps、YouTube、Camera | 只有 `.notify`;Shell 的 `NoticeService` 处理各应用的命名空间。Camera 由 Home 打包。 | AI providers 配置宿主,目前不声明应用 Agent。Calendar 的月历、按日列表和编辑器 -使用显式 `calendar` 能力,与上述 Agent 工具共享本地日程存储。Glance 中已保存日程 +披露 `calendar` 用途,服务检查已准入应用身份与数据范围,并与上述 Agent 工具共享本地日程存储。Glance 中已保存日程 的卡片可打开 Calendar 中同一条记录;编辑日程也会刷新它的卡片。 共用通知的实现见 [`glance_notice.rs`](../../crates/shell/src/glance_notice.rs) 和 [`resources/glance/notice.card`](../../crates/shell/resources/glance/notice.card)。 Mail 和 News 保留自己的服务并安装通知回调;`serve_system_apps` 只为尚无服务的命名空间 注册 `NoticeService`。`publish_args` 填入应用名称/图标、时间、标题和正文, -再由 `glance::publish_for` 检查应用的 `glance` 授权。通知卡片可以打开所属应用, +再由 `glance::publish_for` 检查发布者当前准入状态与账户身份。通知卡片可以打开所属应用, 并设置 `notify: true`。Calendar 继续使用自己的事件和议程模板。 更通用的 [`glance.publish`](../../crates/shell/src/glance.rs) API 接受 L0 `source` @@ -175,7 +175,7 @@ Mail 和 News 保留自己的服务并安装通知回调;`serve_system_apps` | 边界 | 访问路径 | | --- | --- | -| 脚本存储 | 在应用能力和隔离目录限制下使用运行时 storage API。 | +| 脚本存储 | 在有配额的应用私有隔离目录中使用运行时 storage API,即使遗漏 `storage` 声明也一样。 | | Agent 工作目录 | 通过获授权的文件工具和 Shell 策略访问 peer 的应用/账户目录。 | | 宿主服务数据库 | 经明确的 Rust 方法/工具访问 Calendar 日程、Mail 缓存和 News 数据。凭据保留在宿主确认面板和保险库中。 | diff --git a/docs/adr/0014-app-components-in-webassembly.md b/docs/adr/0014-app-components-in-webassembly.md index ed93d0cec..1924f15de 100644 --- a/docs/adr/0014-app-components-in-webassembly.md +++ b/docs/adr/0014-app-components-in-webassembly.md @@ -2,7 +2,8 @@ English | [简体中文](0014-app-components-in-webassembly.zh-CN.md) -Status: Accepted (9 Oct 2026, "do 1 2 3 4"); phases 1 to 3 merged on +Status: Accepted; implementation and release acceptance are tracked separately. +Phases 1 to 3 merged on 10 Oct 2026 in OctoSense (#436, #451), App Hub (#186, #188, #189, #190) and App Flow (#180, #181). Phase 1, the runtime spike, is in `crates/wasm-host` (`src/component.rs`, `tests/component.rs`). Phase 2's @@ -16,8 +17,16 @@ App Hub #190 (shared components in the catalog, the gate and the store), and OctoSense's is the `wasm` service loading an app's pinned shared components (`wasm_service::shared_components`). App Flow's parts are the SDK and `tools/octo wasm`; iOS is not planned for now. It extends [ADR 0011](0011-apps-own-functions-in-webassembly.md): -core modules keep working as they do. How WebAssembly runs on `main`: -[WebAssembly in OctoSense](../wasm.md). +core modules keep working. Desktop rc.2 and Home beta.2 ship core modules only. +[Shared-component acceptance](../../tools/fixtures/wasm-phone-lab/README.md#real-github-shared-component-acceptance) +now records real publisher attestations, an admin-attested private dry-run catalog, +actual Store installation and Splash tool execution on macOS and OnePlus 6. +At clean source `c5f0c5c1`, both passed 28/28 native assertions; the drivers passed +12/12 and 13/13 checks. The public catalog stayed unchanged. Component and new +policy release acceptance remains pending; no live model, performance or +OpenHarmony device result is claimed. Current behavior: +[WebAssembly in OctoSense](../wasm.md) and +[capabilities and execution boundaries](../capabilities.md). ## Context @@ -74,15 +83,15 @@ component reaches only what its app may already reach. A worker holding one waits a minute for the next call, not five seconds. A trap or a deadline spends the instance; the next call gets a fresh one. An update, a grant change or a withdrawal discards it, as for modules. -4. **WASI scoped to the app's grants.** +4. **WASI scoped to the app's resources and account.** - **Always:** `wasi:clocks`, `wasi:random`, `wasi:io` and `wasi:cli`. stdout and stderr become the app's log lines (bounded). The environment, arguments and stdin are empty. - - **`wasi:filesystem`:** only with the app's `storage` capability (and a - signed-in account, for an app with accounts), decided per call by the - rules an engine's folder follows. Its storage folder is preopened as `/`, - read-write, and nothing else of the host's filesystem is visible. Without - the capability there are no preopens. + - **`wasi:filesystem`:** the app's available storage area, with its active + account for an account-scoped app, decided per call by the rules an + engine's folder follows. Its storage folder is preopened as `/`, + read-write, and nothing else of the host's filesystem is visible. No + available area means no preopen. `storage` is a disclosure declaration. - **The storage quota, per write.** A call may add what is left of the app's quota when it starts. The runtime replaces the WASI calls that grow a file (`write`, `set-size`, and the streams `write-via-stream` and @@ -104,9 +113,9 @@ component reaches only what its app may already reach. - **`octosense:host`, phase 3:** `request(service, args)` reaches the host services as the app's script's `host.request` does, dispatched on the UI thread and, since makepad#118 (OctoSense #450), not checked against the - families the manifest declares (a service that needs a grant checks it - itself), but with no sheet and no prompt (only methods a background - surface may call), and never `wasm.*`. + families the manifest declares. Each service retains its actual + app/account, consent, review and resource checks. Calls gain no sheet or + prompt (only methods a background surface may call), and never `wasm.*`. - **Never:** `wasi:sockets`, and any import outside these packages. A component that asks for one is refused when it loads (`LoadError::Import`) and by App Hub's gate. @@ -128,8 +137,8 @@ component reaches only what its app may already reach. 6. **Admission sees what a component reaches.** App Hub's gate reads each component's imports: - it refuses any outside the allowed set; - - it requires `storage` for `wasi:filesystem` and `net` for `wasi:http`, - so that the install sheet shows them; + - it records filesystem, HTTP and host imports for disclosure; it does not + require a matching capability declaration as execution permission; - it tells reviewers what the component reaches, for example "files in its app folder; the network". Components need a new app contract version. @@ -139,9 +148,9 @@ component reaches only what its app may already reach. | Phase | Scope | | --- | --- | | 1. Runtime spike (done here) | `crates/wasm-host::component`: load, check imports, list exports with WIT signatures, long-lived instances, JSON calls, the WASI subset above with the storage preopen, the deadline, the memory cap and logs. Tests run an unmodified crate (`pulldown-cmark`) built with plain cargo, and refuse a component that imports `wasi:sockets`. | -| 2. Usable by developers | The shell's `wasm` service: loading components from `fns/`, `wasm.` calls, per-app instances, the storage grant from the manifest, and storage quota accounting for component writes. A larger input limit for components. The guest SDK and its macro; `octo wasm new/build/doctor`; App Hub's gate check and contract version; docs and an example app. | -| 3. Reach and platforms | `wasi:http` outgoing to any host (declared with `net`, not enforced); an `octosense:host` import for host services under the same checks as `host.request`; compiling at install time, so a phone skips the first compile; Pulley (Wasmtime's interpreter) on iOS and, until its JIT policy is known, OpenHarmony; Windows once its CI runs the runtime's tests. | -| 4. Shared components | Reviewed, versioned components in App Hub's catalog that apps depend on, like npm packages. The installer verifies them, and every app still gets its own instance and grants. | +| 2. Usable by developers | The shell's `wasm` service: loading components from `fns/`, `wasm.` calls, per-app instances, the app's available storage area, and storage quota accounting for component writes. A larger input limit for components. The guest SDK and its macro; `octo wasm new/build/doctor`; App Hub's gate check and contract version; docs and an example app. | +| 3. Reach and platforms | `wasi:http` outgoing to any host (declared with `net`, not enforced); an `octosense:host` import for host services under the same checks as `host.request`; compiling at install time, so a phone skips the first compile; Pulley (Wasmtime's interpreter) on OpenHarmony until its JIT policy is known; Windows runtime tests in CI. iOS remains outside the current plan. | +| 4. Shared components | Reviewed, versioned components in App Hub's catalog that apps depend on, like npm packages. The installer verifies them, and every app still gets its own instance, account and storage boundaries. | ## Alternatives considered @@ -162,8 +171,10 @@ component reaches only what its app may already reach. - **The shell's dependency graph grows.** It gains `wasmtime-wasi` 49.0.2 (`p2` only) and the `component-model` feature. Both are linked wherever `wasm-host` already is (feature `wasm-functions`). -- **A component reaches only what its app could already reach:** its own - storage folder (with `storage`), and in phase 3 its declared hosts. Clocks +- **A component uses its app's resources and identity:** its available, + quota-bounded private storage folder, outbound HTTP and available host services + under the app/account and background-call checks. `storage`, `net` and + `network.hosts` disclose use; omitting them does not deny execution. Clocks and randomness are new to Wasm but available to every script. - **Persistent instances use memory for longer.** The per-app worker cap (`MAX_WORKERS`) and the memory cap bound it, and idle workers exit as today. @@ -210,5 +221,5 @@ Settled in phase 3: a component never calls a host service that opens a sheet or asks the person; such a call belongs to the script. - Async functions (WASI 0.3) and streaming bodies: after phase 3. -- iOS: a Home build with the service in Pulley; the Rust target is not - installed on the build machine yet. +- iOS is outside the current plan. Any later proposal needs a separate Home + build and device acceptance; Pulley tests do not establish iOS support. diff --git a/docs/adr/0014-app-components-in-webassembly.zh-CN.md b/docs/adr/0014-app-components-in-webassembly.zh-CN.md index d16d491bb..4abccff12 100644 --- a/docs/adr/0014-app-components-in-webassembly.zh-CN.md +++ b/docs/adr/0014-app-components-in-webassembly.zh-CN.md @@ -2,7 +2,7 @@ [English](0014-app-components-in-webassembly.md) | 简体中文 -状态:已接受(2026 年 10 月 9 日,“do 1 2 3 4”);第 1 至第 3 阶段于 2026 年 10 月 10 日合并到 OctoSense(#436、#451)、 +状态:已接受;实现与发布验收分别追踪。第 1 至第 3 阶段于 2026 年 10 月 10 日合并到 OctoSense(#436、#451)、 App Hub(#186、#188、#189、#190)和 App Flow(#180、#181)。第 1 阶段(运行时验证原型)位于 `crates/wasm-host`(`src/component.rs`、`tests/component.rs`)。第 2 阶段的运行时和服务 部分位于 `crates/wasm-host/src/component/files.rs` 和 `crates/shell/src/wasm_service.rs`, @@ -12,7 +12,12 @@ App Hub(#186、#188、#189、#190)和 App Flow(#180、#181)。第 1 阶 第 4 阶段中 App Hub 的部分是 App Hub #190(目录、审核和商店中的共享组件),OctoSense 的部分是 `wasm` 服务加载应用固定的共享组件(`wasm_service::shared_components`)。App Flow 的部分是 SDK 和 `tools/octo wasm`;iOS 暂不计划。 本 ADR 扩展 [ADR 0011](0011-apps-own-functions-in-webassembly.zh-CN.md):核心模块照旧可用。 -`main` 上 WebAssembly 的运行方式见 [OctoSense 中的 WebAssembly](../wasm.zh-CN.md)。 +桌面 rc.2 和 Home beta.2 仅包含核心模块。 +[共享组件验收](../../tools/fixtures/wasm-phone-lab/README.zh-CN.md#真实-github-共享组件验收) +现已记录真实发布者证明、带管理员证明的私有试运行目录,以及 macOS 和 OnePlus 6 上的实际商店安装和 Splash 工具执行。 +在干净源码 `c5f0c5c1` 上,两端原生断言均为 28/28 通过,驱动分别为 12/12 和 13/13 通过。 +公开目录保持不变。组件及新策略的发行验收仍待完成;不声称真实模型、性能或 OpenHarmony 设备已经通过。当前行为见 [OctoSense 中的 WebAssembly](../wasm.zh-CN.md)及 +[应用能力与执行边界](../capabilities.zh-CN.md)。 ## 背景 @@ -55,12 +60,12 @@ crates.io 上的大多数 crate 要么需要上述缺失能力中的某些(时 3. **实例保留状态。** 每个组件一个实例,在应用的 worker 存活期间一直存在,因此组件可以保存文档、 缓存或模型。持有实例的 worker 等待下一次调用一分钟,而不是五秒。陷阱(trap)或超时会使实例 作废,下一次调用会得到新实例。应用更新、授权变更或撤回时实例会被丢弃,与模块相同。 -4. **WASI 的范围与应用授权一致。** +4. **WASI 的范围由应用资源和账户决定。** - **始终提供:** `wasi:clocks`、`wasi:random`、`wasi:io` 和 `wasi:cli`。stdout 与 stderr 成为应用的日志行(有上限)。环境变量、参数和 stdin 都为空。 - - **`wasi:filesystem`:** 仅在应用有 `storage` 能力(有账户的应用还需已登录账户)时提供, - 每次调用都按引擎文件夹所遵循的规则决定。其存储文件夹以读写方式预打开为 `/`, - 主机文件系统的其余部分一概不可见。没有该能力时不预打开任何目录。 + - **`wasi:filesystem`:** 使用应用可用的存储目录;按账户隔离的应用需要当前已连接账户。 + 每次调用按引擎文件夹的规则决定。存储文件夹以读写方式预打开为 `/`,主机文件系统的 + 其余部分不可见。没有可用目录时不预打开。`storage` 是使用披露声明。 - **存储配额逐次写入计量。** 一次调用可以写入的量,是调用开始时应用配额的剩余部分。运行时把使文件 变大的 WASI 调用(`write`、`set-size`,以及 `write-via-stream` 与 `append-via-stream` 返回 的流)替换为计量增长的版本,把释放字节的调用(截断的 `open-at`、`unlink-file-at`)替换为 @@ -72,8 +77,8 @@ crates.io 上的大多数 crate 要么需要上述缺失能力中的某些(时 为公共 HTTPS 主机的做法,都已按该裁定否决。请求在客体之外等待,epoch 检查无法结束它,因此 请求的超时被限制在调用的截止时间内;导入 `wasi:http` 的组件每次调用有 10 秒。 - **`octosense:host`(第 3 阶段):** `request(service, args)` 像应用脚本的 `host.request` 一样调用 - 宿主服务,在 UI 线程上分派;自 makepad#118(OctoSense #450)起不检查清单声明的服务族(需要授权的 - 服务自己检查),但不打开面板、不询问用户(只能调用后台界面可以调用的方法),并且绝不调用 `wasm.*`。 + 宿主服务,在 UI 线程上分派;自 makepad#118(OctoSense #450)起不检查清单声明的服务族。 + 各服务保留实际的应用/账户、同意、审阅与资源检查,但不打开面板、不询问用户(只能调用后台界面可以调用的方法),并且绝不调用 `wasm.*`。 - **从不提供:** `wasi:sockets`,以及上述包以外的任何导入。请求这些导入的组件在加载时被拒绝 (`LoadError::Import`),App Hub 的审核闸门也会拒绝。 5. **无需编写 WIT。** @@ -88,7 +93,7 @@ crates.io 上的大多数 crate 要么需要上述缺失能力中的某些(时 - **分发:** SDK 发布到 crates.io(需维护者批准);发布前使用 git 依赖。 6. **审核能看到组件的访问范围。** App Hub 的闸门读取每个组件的导入: - 拒绝允许集合以外的导入; - - `wasi:filesystem` 需要 `storage` 能力,`wasi:http` 需要 `net` 能力,以便安装界面展示它们; + - 记录文件系统、HTTP 和宿主服务导入供披露,不把匹配的能力声明作为执行许可; - 告诉审核者组件能访问什么,例如“其应用文件夹中的文件;网络”。 组件需要新的应用合约版本。 @@ -97,9 +102,9 @@ crates.io 上的大多数 crate 要么需要上述缺失能力中的某些(时 | 阶段 | 范围 | | --- | --- | | 1. 运行时验证原型(本 PR) | `crates/wasm-host::component`:加载、检查导入、列出导出及其 WIT 签名、长期存活的实例、JSON 调用、上述 WASI 子集与存储预打开、超时、内存上限和日志。测试运行一个用普通 cargo 构建的、未作修改的 crate(`pulldown-cmark`),并拒绝导入 `wasi:sockets` 的组件。 | -| 2. 开发者可用 | shell 的 `wasm` 服务:从 `fns/` 加载组件、`wasm.` 调用、按应用的实例、来自清单的存储授权,以及组件写入的存储配额计量。提高组件的输入上限。guest SDK 及其宏;`octo wasm new/build/doctor`;App Hub 闸门检查与合约版本;文档与示例应用。 | -| 3. 访问能力与平台 | 可访问任何主机的 `wasi:http` 出站(以 `net` 声明,不在运行时强制);`octosense:host` 导入,以与 `host.request` 相同的检查调用主机服务;安装时编译,让手机跳过首次编译;iOS 使用 Pulley(Wasmtime 的解释器),OpenHarmony 在其 JIT 策略明确前也使用 Pulley;Windows 待其 CI 运行运行时测试后开启。 | -| 4. 共享组件 | App Hub 目录中经过审核、带版本的组件,应用可以像 npm 包一样依赖它们。安装器负责校验,每个应用仍有自己的实例和授权。 | +| 2. 开发者可用 | shell 的 `wasm` 服务:从 `fns/` 加载组件、`wasm.` 调用、按应用的实例、应用可用的存储目录,以及组件写入的存储配额计量。提高组件的输入上限。guest SDK 及其宏;`octo wasm new/build/doctor`;App Hub 闸门检查与合约版本;文档与示例应用。 | +| 3. 访问能力与平台 | 可访问任何主机的 `wasi:http` 出站(以 `net` 声明,不在运行时强制);`octosense:host` 导入,以与 `host.request` 相同的检查调用主机服务;安装时编译,让手机跳过首次编译;OpenHarmony 在其 JIT 策略明确前使用 Pulley(Wasmtime 的解释器);Windows 运行时测试在 CI 中运行。iOS 不在当前计划内。 | +| 4. 共享组件 | App Hub 目录中经过审核、带版本的组件,应用可以像 npm 包一样依赖它们。安装器负责校验,每个应用仍有自己的实例、账户和存储边界。 | ## 考虑过的替代方案 @@ -115,7 +120,8 @@ crates.io 上的大多数 crate 要么需要上述缺失能力中的某些(时 - **shell 的依赖图变大。** 新增 `wasmtime-wasi` 49.0.2(仅 `p2`)和 `component-model` 特性, 链接在 `wasm-host` 已链接的地方(特性 `wasm-functions`)。 -- **组件只能访问其应用本来就能访问的东西:** 自身的存储文件夹(需 `storage`),第 3 阶段起还有声明过的主机。 +- **组件使用所属应用的资源和身份:** 可用且有配额的应用私有存储、出站 HTTP,以及通过应用/账户和 + 后台调用检查的可用宿主服务。`storage`、`net` 和 `network.hosts` 用于披露用途,省略它们不会拒绝执行。 时钟和随机数对 Wasm 是新增的,但每个脚本本来就能使用。 - **常驻实例会更长时间占用内存。** 每应用 worker 上限(`MAX_WORKERS`)与内存上限约束其用量, 空闲的 worker 照旧退出。 @@ -156,4 +162,4 @@ crates.io 上的大多数 crate 要么需要上述缺失能力中的某些(时 第 3 阶段已定:组件绝不调用会打开面板或询问用户的宿主服务,这类调用属于脚本。 - 异步函数(WASI 0.3)和流式内容:第 3 阶段之后。 -- iOS:在 Pulley 中带这个服务构建 Home;构建机器上还没有安装 Rust 的 iOS 目标。 +- iOS 不在当前计划内。任何后续支持方案都需要独立的 Home 构建与设备验收;Pulley 的测试结果不能证明 iOS 可用。 diff --git a/docs/architecture-walkthrough.md b/docs/architecture-walkthrough.md index ff023c562..a0517e245 100644 --- a/docs/architecture-walkthrough.md +++ b/docs/architecture-walkthrough.md @@ -31,7 +31,7 @@ Where an app runs decides how it reaches its agent and where its tools execute. | Process app (the Terminal and Task, in a checkout build) | [clients.rs](../crates/shell/src/clients.rs), [hub.rs](../crates/shell/src/hub.rs) | The hub admits a child's socket only with the secret its launch read on stdin; `sandbox_policy` builds the OS sandbox. | | Script app (Calendar, Mail, every store app) | `system_card_apps` in `apps.rs`, then App Hub's `CARD_MODULE` | The `card` module, the Card runner, hosts every system and installed app, one isolate per instance. | -`system_card_apps` makes Calendar's launcher row and, the first time, registers the shell's host services (`register_host_services`). Calendar's `calendar` capability grants its contained UI access to the owning service. Month/day views, the editor and Calendar-owned tools all read and write `.host/calendar/events.json`. The app's Glance event card is a projection of that record; its in-card **Open Calendar** action uses the publication-bound `event/` route to open that same event in Calendar. Cross-app agent calls still require the separate grants described below. +`system_card_apps` makes Calendar's launcher row and, the first time, registers the shell's host services (`register_host_services`). Calendar's `calendar` declaration describes its service use; the contained UI calls as the admitted app under the service's actual identity and data rules. Month/day views, the editor and Calendar-owned tools all read and write `.host/calendar/events.json`. The app's Glance event card is a projection of that record; its in-card **Open Calendar** action uses the publication-bound `event/` route to open that same event in Calendar. Cross-app agent calls still require the separate grants described below. To run the desktop, follow its README's [Build and run](../desktop/README.md#build-and-run), which stages the pinned kernel with `python3 tools/kernel-artifact.py --host --stage target/release`. @@ -115,7 +115,7 @@ Every surface but the system chat opens the person's lane: | "Ask <app>" panel | [app_chat/mod.rs](../crates/shell/src/app_chat/mod.rs): `agents::conversation`, then `ContextOp::TurnFrom { trigger: TurnTrigger::Person }` | | A native app's own chat, through the injected service | `OctosAppService::open_conversation`. Rinx uses only `open_context`, for its mini apps' private contexts. | | Another native app's chat | Makepad's `OctosPeer` over the peer link: `octos.session.open` without a `client` ([peer_link/link.rs](../crates/shell/src/peer_link/link.rs)) | -| A script app's own chat | `host.request("octos.turn.start")`, served by `contained.rs` for the names its manifest declares | +| A script app's own chat | `host.request("octos.turn.start")`, served by `contained.rs` for the admitted app with agent consent and current account checks | | A card's Chat tab, or a card that declares `sys.chat` | [glance_chat.rs](../crates/shell/src/glance_chat.rs) and [l0-chat](../crates/l0-chat/src/lib.rs) | **Card workspaces** ([in-card chat](../README.md#in-card-chat)). [glance_sheet.rs](../crates/shell/src/glance_sheet.rs) shows an opened card full screen on the phone, centred on the desktop. If the publisher has an agent and the card declares no chat, `L0Session::for_card` ([glance_card.rs](../crates/shell/src/glance_card.rs)) adds a host-owned `WorkspaceChat` behind Card / Chat tabs. `chat_submit` sends each turn through `glance_chat::perform_bound` to the account that published the card (`agents::conversation_for_account`), with the card's data and local state as context (`ContextKind::Card`), never as tools. Mail's reply cards show Email / Chat over one saved draft; a Chat turn carries a one-use token (`drafts::issue_chat_edit`) that lets `mail.suggest_reply` save the edit ([Composed Mail cards](mail-composable-cards.md)). @@ -127,7 +127,7 @@ The trigger decides how far approvals trust a turn. Only the shell's own surface - the person allowed the app's agent; - its installed release is still admitted in the current signed local catalog (see the withdrawal checks in [section 7](#7-trace-a-tool-to-rust-code)); - its admitted `agent` block sets `background: true` and lists the trigger `.new_message`, where the app namespace is the last segment of the app id (Inbox Assistant's trigger is `inbox.new_message`); -- it declares `auth` and `gmail`; +- the collector verifies the admitted app; `auth` and `gmail` disclose usage rather than authorize delivery; - its active Google connection can read Gmail. No other app has events yet. @@ -150,7 +150,7 @@ Follow `calendar.events` from its declaration to the file it reads: `calendar.events` only reads, so nobody is asked. `calendar.remove_event` (`destructive`, `confirm: host`) is gated in octos first: the kernel raises a `host_tool` approval, which the broker hands to `ToolHost::host_tool_approval`, and only an approved call arrives. -For agent calls resolving to `glance.publish`, including aliases such as `inbox.notify`, the executor refuses raw `script`, mixed template/source payloads and executable or L1 source. An agent can select a reviewed bundle template with an `initial` data object, or supply valid declaration-only L0. Glance still checks the template's admitted bundle and the app's capabilities before rendering. This restriction applies to model-authored publications; an admitted foreground app retains its own reviewed Splash implementation. +For agent calls resolving to `glance.publish`, including aliases such as `inbox.notify`, the executor refuses raw `script`, mixed template/source payloads and executable or L1 source. An agent can select a reviewed bundle template with an `initial` data object, or supply valid declaration-only L0. Glance still checks the template's admitted bundle, publisher/account identity and resource limits before rendering; capability families are descriptive. This restriction applies to model-authored publications; an admitted foreground app retains its own reviewed Splash implementation. 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. diff --git a/docs/architecture-walkthrough.zh-CN.md b/docs/architecture-walkthrough.zh-CN.md index 6aef2c203..41f476953 100644 --- a/docs/architecture-walkthrough.zh-CN.md +++ b/docs/architecture-walkthrough.zh-CN.md @@ -31,7 +31,7 @@ | 进程应用(源码检出构建中的 Terminal 和 Task) | [clients.rs](../crates/shell/src/clients.rs)、[hub.rs](../crates/shell/src/hub.rs) | hub 只接纳出示了本次启动从 stdin 读到的密钥的子进程 socket;`sandbox_policy` 构建系统沙箱。 | | 脚本应用(日历、邮件、所有商店应用) | `apps.rs` 中的 `system_card_apps`,然后是 App Hub 的 `CARD_MODULE` | `card` 模块,也就是 Card runner,托管所有系统应用和已安装应用,每个实例一个隔离环境。 | -`system_card_apps` 生成日历的启动器条目,并在第一次时注册 Shell 的宿主服务(`register_host_services`)。日历的 `calendar` 能力让其隔离 UI 调用所属服务。月历、按日列表、编辑器及日历 Agent 工具共用 `.host/calendar/events.json`。Glance 日程卡片是同一记录的投影,卡片内的 **Open Calendar** 按钮通过绑定在发布记录中的 `event/` 路由,在真实日历应用中打开该日程。跨应用 Agent 调用仍须下文的独立授权。 +`system_card_apps` 生成日历的启动器条目,并在第一次时注册 Shell 的宿主服务(`register_host_services`)。日历的 `calendar` 声明披露服务用途;隔离 UI 以已准入应用身份调用,受服务实际身份和数据规则约束。月历、按日列表、编辑器及日历 Agent 工具共用 `.host/calendar/events.json`。Glance 日程卡片是同一记录的投影,卡片内的 **Open Calendar** 按钮通过绑定在发布记录中的 `event/` 路由,在真实日历应用中打开该日程。跨应用 Agent 调用仍须下文的独立授权。 运行方法见桌面端 README 的[构建与运行](../desktop/README.zh-CN.md#构建与运行),其中用 `python3 tools/kernel-artifact.py --host --stage target/release` 准备锁定版本的内核。 @@ -115,7 +115,7 @@ sequenceDiagram | “Ask <app>” 面板 | [app_chat/mod.rs](../crates/shell/src/app_chat/mod.rs):`agents::conversation`,然后是 `ContextOp::TurnFrom { trigger: TurnTrigger::Person }` | | 原生应用自己的对话(经注入的服务) | `OctosAppService::open_conversation`。Rinx 只用 `open_context`,供小程序的私有上下文使用。 | | 其他原生应用的对话 | Makepad 的 `OctosPeer`,经由 peer link:不带 `client` 的 `octos.session.open`([peer_link/link.rs](../crates/shell/src/peer_link/link.rs)) | -| 脚本应用自己的对话 | `host.request("octos.turn.start")`,由 `contained.rs` 应答,限于 manifest 声明的名称 | +| 脚本应用自己的对话 | `host.request("octos.turn.start")`,由 `contained.rs` 为已准入应用应答,并检查 Agent 同意与当前账户 | | 卡片的 Chat 标签页,或声明了 `sys.chat` 的卡片 | [glance_chat.rs](../crates/shell/src/glance_chat.rs) 和 [l0-chat](../crates/l0-chat/src/lib.rs) | **卡片工作区**([卡内对话](../README.zh-CN.md#卡内对话))。[glance_sheet.rs](../crates/shell/src/glance_sheet.rs) 显示打开的卡片:手机上全屏,桌面端居中。如果发布者有 Agent,而卡片没有声明对话,`L0Session::for_card`([glance_card.rs](../crates/shell/src/glance_card.rs))会在 Card / Chat 标签页后面加上宿主拥有的 `WorkspaceChat`。`chat_submit` 把每一轮经由 `glance_chat::perform_bound` 发往发布这张卡片的账户(`agents::conversation_for_account`),卡片的数据和本地状态只作为上下文(`ContextKind::Card`),从不作为工具。邮件回复卡片则用 Email / Chat 共用一份保存的草稿;Chat 的每一轮都带着一个一次性令牌(`drafts::issue_chat_edit`),让 `mail.suggest_reply` 能保存这次修改([可组合的邮件卡片](mail-composable-cards.zh-CN.md))。 @@ -127,7 +127,7 @@ sequenceDiagram - 用户已允许它的 Agent; - 它安装的这个版本在当前的本地签名目录中仍是已准入状态(见[第 7 节](#7-把工具追到-rust-代码)中的撤回检查); - 它已准入的 `agent` 块设置了 `background: true`,并列出触发器 `<应用短名>.new_message`,其中应用短名是应用 id 的最后一段(Inbox Assistant 的触发器是 `inbox.new_message`); -- 它声明了 `auth` 和 `gmail`; +- 收集器验证已准入应用;`auth` 和 `gmail` 披露用途,不授权事件投递; - 它当前的 Google 连接可以读取 Gmail。 其他应用还没有事件。 @@ -150,7 +150,7 @@ sequenceDiagram `calendar.events` 只读,所以不会询问任何人。`calendar.remove_event`(`destructive`、`confirm: host`)则先由 octos 把关:内核发起一个 `host_tool` 审批,代理把它交给 `ToolHost::host_tool_approval`,只有获批的调用才会到达。 -Agent 调用最终映射到 `glance.publish` 时(包括 `inbox.notify` 等别名),执行器拒绝原始 `script`、混合模板与源码的参数,以及可执行或 L1 源码。Agent 可以选择已审核应用包内的模板并提供 `initial` 数据对象,或提交合法的纯声明 L0。Glance 在渲染前仍会验证模板所在的已准入应用包及应用权限。这一限制针对模型生成的发布内容;已准入前台应用仍可运行自身经过审核的 Splash 实现。 +Agent 调用最终映射到 `glance.publish` 时(包括 `inbox.notify` 等别名),执行器拒绝原始 `script`、混合模板与源码的参数,以及可执行或 L1 源码。Agent 可以选择已审核应用包内的模板并提供 `initial` 数据对象,或提交合法的纯声明 L0。Glance 在渲染前仍验证模板所在的已准入应用包、发布者与账户身份及资源上限;能力族仅作披露。这一限制针对模型生成的发布内容;已准入前台应用仍可运行自身经过审核的 Splash 实现。 已安装应用的 Agent 还要求该精确版本持续满足准入条件。加载指导文本、提供工具及接受系统 Agent 输入时都会读取当前本地签名目录。代理还会在每次实际发送 `turn/start` 前检查 `ToolHost::admit_turn`,包括缓存对话、排队输入与重试;中转在执行前再次检查工具所有者与调用应用,包括等待审批后恢复执行的情况。撤回在下一次获取目录后生效,缓存的 peer 也不能绕过。Gmail 分发器随后释放不可用的 peer,保留未完成事件以便之后经授权重试。用户之前保存的同意不会改变;应用不可用不等于用户拒绝。 diff --git a/docs/architecture.md b/docs/architecture.md index 8603f5630..e98ce3f3d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -128,7 +128,7 @@ Release packages ship only `octosense` and the kernel, so there the Terminal run ### Script apps -System and store apps run in App Hub's Card runner (`CARD_MODULE`), one isolate per instance with a file jail and a quota, and reach the shell only through `host.request` for granted families. `desktop/system-apps.json` and `phone/system-apps.json` list each shell's system apps. +System and store apps run in App Hub's Card runner (`CARD_MODULE`), one isolate per instance with a file jail and a quota, and reach registered host services through `host.request`, attributed to the admitted app. Capability names disclose usage; actual identity, account, consent and review checks remain. `desktop/system-apps.json` and `phone/system-apps.json` list each shell's system apps. ```mermaid flowchart TB @@ -175,7 +175,7 @@ octos lists only prepared peers, so the shell tells the system agent the rest: a An app agent is a host-owned octos peer for one (app, account), owned by the system agent's session (octos UPCR-2026-034) and driven by a broker (`crates/app-peers/src/broker.rs`). Its memory namespace is `app//acct-`, and the broker refuses a kernel that does not confirm it. Its workspace is the account's folder ([section 6](#6-storage-and-secrets)), and its host token sits in a record under `/../app-peers`, owner-only on Unix. The broker registers its tools after every `peer/prepare` and reconnect; a peer whose registration fails runs no turn. -A script app's peer is `card.` (`crates/ai-host/src/contained.rs`). Apps without accounts act for `device`; Mail acts for the account signed in last, and a connected app for its active connection ([Connected accounts](#connected-accounts)). `apps::agent_apps` decides which apps have an agent, and an app granted nothing gets no broker. +A script app's peer is `card.` (`crates/ai-host/src/contained.rs`). Apps without accounts act for `device`; Mail acts for the account signed in last, and a connected app for its active connection ([Connected accounts](#connected-accounts)). `apps::agent_apps` decides which apps have an agent, and the person must enable an offered agent before it runs; an empty capability list alone does not remove its broker. - A script app's peer is prepared once the agent is allowed and at each startup (`agents::start`), so `peer_list` shows it while the app is closed. - A native app's peer belongs to its open instance. With several instances, the oldest drives it and the next takes over (`driver_of`, `take_over`). @@ -259,7 +259,7 @@ An app never sees the host token, and the shell stamps the app's identity on eve | --- | --- | --- | | Peer link (`crates/shell/src/peer_link/`) | App Hub, Calculator, Clock, Notes, Reminders, Weather, Terminal | Makepad's `OctosPeer` client. A process app's link rides its hub socket. A module's `OctosPeer::open` parks an in-memory channel, which `module_host` claims for that instance and the shell serves as the same frames (`peer_link::module_connected`). `serve_tools` answers the agent's tool calls. | | Injected service | Rinx | `ai_host::offer` before the module's `create`, and `injection::claim` inside it, give the instance a scoped `OctosAppService` (`open_conversation`, `open_context`). | -| `host.request("octos.*")` | store apps | The Card runner's `octos` host service (`crates/ai-host/src/contained.rs`): only the services the manifest declares, after first-use consent. | +| `host.request("octos.*")` | store apps | The Card runner's `octos` host service (`crates/ai-host/src/contained.rs`): the admitted app's supported methods, after agent consent and current-account checks. | On the peer link, the socket or module instance is the identity, and a call's account, context and client come from the shell's records of the contexts the app opened. When a process dies, its outstanding calls fail (`outcome_unknown` unless they only read), and the peer stays. @@ -271,7 +271,7 @@ The bus is Makepad's other AI model: one central conversation, the desktop's AI ## 4. Tools and grants -The manifest declares, the person grants at install, and the shell enforces on every call (ADR 0004 §12). A script app declares its tools in `tools.json`; a native app declares its agent in its `native-apps.json` entry (`agent.octos`, `tools`, `own_tools`, `system_tools`, `grants`, `generic_tools`, `budget`, `tool_policy`). The README's [What an app gives its agent](../README.md#what-an-app-gives-its-agent) explains most of them. `tool_policy` sets a tool's confirmation: the Terminal's `run` is `confirm: host` with `auto_approvable: false`, so no standing rule ever answers it. +Capability and network declarations disclose intended use; they do not grant or deny public API execution. Tool ownership and inter-app sharing, agent opt-in, account scope, device consent and per-call review remain actual authorization boundaries ([capabilities and execution boundaries](capabilities.md)). A script app declares its tools in `tools.json`; a native app declares its agent in its `native-apps.json` entry (`agent.octos`, `tools`, `own_tools`, `system_tools`, `grants`, `generic_tools`, `budget`, `tool_policy`). The README's [What an app gives its agent](../README.md#what-an-app-gives-its-agent) explains most of them. `tool_policy` sets a tool's confirmation: the Terminal's `run` is `confirm: host` with `auto_approvable: false`, so no standing rule ever answers it. | Source | Declared in | Runs on | Today | | --- | --- | --- | --- | @@ -279,7 +279,7 @@ The manifest declares, the person grants at install, and the shell enforces on e | octos's kernel tools | plain names in `agent.tools`; `agent.generic_tools` | octos | script apps only `ask_user_question`; Rinx files, memory and web; other native agents none | | `files.list`, `files.read`, `files.search` | the shell, on consented peers with a workspace (Unix) | the shell, over the caller's account folder | at most 128 KiB a read, 500 entries a listing, 100 matches a search | | Other apps' shareable tools | dotted names in `agent.tools`; `agent.grants` | the owning app, through the relay | News shares `news.list` and `news.read`; no app asks for one yet | -| The toolbox (`toolbox.*`, `workflow.*`) | the `research` and `crawl` capabilities | the shell ([`crates/toolbox`](../crates/toolbox/README.md)), feature `toolbox-peers`, default on phones | no app declares either yet | +| The toolbox (`toolbox.*`, `workflow.*`) | explicit names in `agent.tools`, with top-level `research` bounds | the shell ([`crates/toolbox`](../crates/toolbox/README.md)), feature `toolbox-peers`, default on phones | no app declares either yet | | `dev.run` | developer mode | the shell (`host_tools/dev_run.rs`) | covered apps only | An app's `AGENT.md` and skills are not tools: the broker sends their text with every turn as host guidance (`crates/app-peers/src/guidance.rs`). @@ -294,9 +294,9 @@ The relay (`crates/shell/src/host_tools/`) takes every `peer/tool/call` from the 4. **Confirm** a `confirm: app` call: acknowledge it to the kernel, then hand it to the owner's sheet ([section 5](#5-approvals)). 5. **Answer once,** checked against `output_schema` (at most 256 KiB); nothing runs after a cancel. Each call is audited, with a digest of its arguments, in `logs/tool-calls.jsonl`. -A script app's `implemented_by: "host-service"` tool runs on its namespace's host service, as the app, if the app was granted that family or owns it as a system app. The shell's `NoticeService` answers `.notify` for Photos, Maps, YouTube and Camera. A script app's `implemented_by: "app"` tool runs in the app itself: `ScriptAppExecutor` (`host_tools/script_apps.rs`) queues the call to App Hub's script-tool runner, which calls the app's `app_tool(name, call_id)` hook in its open full-app VM. The bundle must require `script-tools-v1`. A closed app answers `app_not_running`, and a destructive or outward call to a script tool marked `confirm: app` is refused `app_confirmation_unavailable` ([ADR 0012](adr/0012-app-host-api-discovery.md)). This is on `main` but in no release yet: `desktop-v0.1.0-beta.2` refuses every call to such a tool with `app_tool_unavailable`. +A script app's `implemented_by: "host-service"` tool runs its resolved host method as the admitted app. The relay checks tool ownership and sharing; the service keeps app/account scope, actual consent, review and availability checks. An omitted family declaration does not deny execution. The shell's `NoticeService` answers `.notify` for Photos, Maps, YouTube and Camera. A script app's `implemented_by: "app"` tool runs in the app itself: `ScriptAppExecutor` (`host_tools/script_apps.rs`) queues the call to App Hub's script-tool runner, which calls the app's `app_tool(name, call_id)` hook in its open full-app VM. The bundle must require `script-tools-v1`. A closed app answers `app_not_running`, and a destructive or outward call to a script tool marked `confirm: app` is refused `app_confirmation_unavailable` ([ADR 0012](adr/0012-app-host-api-discovery.md)). This is on `main` but in no release yet: `desktop-v0.1.0-beta.2` refuses every call to such a tool with `app_tool_unavailable`. -A store app's tool can instead map to a shared service with `host_method`, as Inbox Assistant's `inbox.message` maps to `gmail.message`. App Hub admits only the methods on its reviewed list, `SHARED_HOST_METHODS`: GitHub, Gmail and Google Calendar reads, Gmail draft edits and new-mail event decisions, and `glance.*`. Each needs its family's capability, `private_data: true` and at least its listed risk. The executor runs the method only if the app was granted its family, and for `github`, `gmail` and `gcalendar` it injects the app's active connection (`host_tools/script_apps.rs`). No method on the list opens a host sheet, so no tool can sign in, commit, save an event or send ([Connected accounts](#connected-accounts)). +A store app's tool can instead map to a shared service with `host_method`, as Inbox Assistant's `inbox.message` maps to `gmail.message`. App Hub admits only methods on its reviewed list, `SHARED_HOST_METHODS`, including GitHub, Gmail and Google Calendar reads, Gmail draft edits and new-mail event decisions, and `glance.*`. Each keeps the minimum risk and privacy requirements listed for that method. A matching family disclosure is not required to execute it. For `github`, `gmail` and `gcalendar`, the executor injects the app's active connection (`host_tools/script_apps.rs`), whose provider scopes and ownership remain checked. No method on the list opens a host sheet, so no tool can sign in, commit, save an event or send ([Connected accounts](#connected-accounts)). ### The system agent's tool set @@ -304,7 +304,7 @@ Before every kernel start, `enforce` (`crates/kernel/src/system_tools.rs`) write ### What an agent puts on the glance screen -The glance service (`crates/shell/src/glance.rs`) publishes every card as the calling app, under the account the host records, and only with the app's `glance` grant. An app may publish at most 6 times a minute. Both phone and desktop scroll all retained cards; neither a four-card publisher quota nor a six-row feed cutoff applies. Retained payloads have an 8 MiB per-app and 32 MiB overall budget. Under pressure, lower-priority older cards retire while the new valid publication remains available; the owning services retain their drafts and source mail. `mail.publish_card` can also bind a card to one of Mail's saved drafts, and a bound card cannot move to another account, email or draft. +The glance service (`crates/shell/src/glance.rs`) publishes every card as the calling app, under the account the host records, after verifying the publisher's current admission; `glance` is a usage disclosure. An app may publish at most 6 times a minute. Both phone and desktop scroll all retained cards; neither a four-card publisher quota nor a six-row feed cutoff applies. Retained payloads have an 8 MiB per-app and 32 MiB overall budget. Under pressure, lower-priority older cards retire while the new valid publication remains available; the owning services retain their drafts and source mail. `mail.publish_card` can also bind a card to one of Mail's saved drafts, and a bound card cannot move to another account, email or draft. On `main`, an agent's tool call that resolves to `glance.publish`, such as Inbox Assistant's `inbox.notify`, must name a template from the app's admitted bundle with an `initial` object, or send valid L0 source. Executable Splash (`script`), L1 source and mixed payloads are refused (`check_agent_publication` in `host_tools/script_apps.rs`). The app's own UI can still publish its reviewed Splash. `desktop-v0.1.0-beta.2` has no such check and accepts an agent's `script` card. @@ -314,7 +314,7 @@ On a phone, the glance feed draws compact summaries and runs no generated UI (`m A store app can use the person's GitHub or Google account, or sign the person in to its own backend, without an OctoSense account ([ADR 0010](adr/0010-shared-oauth-and-connected-apps.md)). [`crates/oauth-service`](../crates/oauth-service/README.md) implements the OAuth flows, the GitHub, Google and backend adapters and the connection store. `register_host_services` (`crates/shell/src/apps.rs`) registers its four host services: `auth` for sign-in and the app's connections, and `github`, `gmail` and `gcalendar` for GitHub and Google data. The crate's README lists their methods. -- **Declaration.** The app declares `auth`, each data family it uses (`github`, `gmail`, `gcalendar`) and `storage.accounts: true`. With `auth` alone, the app can still sign the person in for identity only (GitHub's `read:user`; Google's `openid`, `email` and `profile`), but it gets no GitHub or Google data: the host refuses any other scope whose family the app was not granted (`register_host_services`). +- **Disclosure and consent.** The app describes `auth` and its data families (`github`, `gmail`, `gcalendar`) and configures `storage.accounts: true`. These family names do not authorize provider access. `register_host_services` checks current app admission and valid provider scopes; the person authorizes requested scopes through the provider. An identity-only connection (GitHub's `read:user`; Google's `openid`, `email`, `profile`) has no mail/calendar/repository scopes just because a family is declared. Every data call still checks the actual connection and scopes. - **Identity.** The app sees only an opaque connection handle. Its peer acts for its active connection (`app_storage/lifecycle.rs`), so each connected account has its own agent. - **Configuration.** The OAuth client registrations belong to the host, never to an app. A distributor compiles them into its build from build variables such as `OCTOSENSE_GITHUB_CLIENT_ID` (`crates/oauth-service/src/registration.rs`); `desktop-v0.1.0-beta.2` downloads have none. An operator can replace the whole set with `clients.json` in App Hub's host directory, `/.host/oauth/clients.json`, where `` is `/apps` ([section 6](#6-storage-and-secrets)); a provider the file leaves out is turned off. Without a registration for the provider, sign-in fails with "GitHub sign-in is unavailable in this build. Check for an OctoSense update or contact its distributor." (or the same message naming Google). On beta.2, a missing `clients.json` gives "OAuth is not configured" instead. - **The app's own backend.** `auth.connect` with `{"provider":"backend","scopes":["app.session"]}` signs the person in to the app's own server, and `auth.backend.me` returns the identity that server verified (`crates/oauth-service/src/host_backend.rs`). On `main` (in no release yet), the app's signed bundle can declare its backend and named operations, which the app calls with `auth.backend.request` ([ADR 0012](adr/0012-app-host-api-discovery.md)); an app without that declaration uses the operator's registration in `/.host/oauth/backends.json`. `desktop-v0.1.0-beta.2` has no backend sign-in. The server's login page opens in a host-owned WebView on macOS and on Android 9 or later. On Windows and Linux, and on macOS with `"presentation":"browser"`, it opens in the browser instead (`presentation` in `host.rs`). iOS has no backend sign-in. @@ -416,7 +416,7 @@ The kernel's core dir, `~/.octosense/octos-home/.octos` on the desktop and `/acct-`,内核若不确认这个命名空间,代理就拒绝该内核。它的工作区是账户文件夹(见[第 6 节](#6-存储与机密)),宿主 token 保存在 `/../app-peers` 下的一条记录中,在 Unix 上仅所有者可读写。每次 `peer/prepare` 和重新连接之后,代理都会注册它的工具;注册失败的 peer 不运行任何回合。 -脚本应用的 peer 名为 `card.`(`crates/ai-host/src/contained.rs`)。不区分账户的应用以 `device` 身份行事;邮件以最近登录的账户行事,连接账户的应用以它当前的连接行事(见[已连接账户](#已连接账户))。哪些应用有 Agent 由 `apps::agent_apps` 决定;没有任何授权的应用得不到代理。 +脚本应用的 peer 名为 `card.`(`crates/ai-host/src/contained.rs`)。不区分账户的应用以 `device` 身份行事;邮件以最近登录的账户行事,连接账户的应用以它当前的连接行事(见[已连接账户](#已连接账户))。哪些应用有 Agent 由 `apps::agent_apps` 决定;用户须先启用已提供的 Agent,它才能运行;能力列表为空本身不会取消代理。 - 脚本应用的 peer 在 Agent 获准时准备好,此后每次启动时(`agents::start`)也会准备,所以即使应用关着,`peer_list` 也能看到它。 - 原生应用的 peer 属于它已打开的实例。同时打开多个实例时,最早的那个驱动 peer,它关闭后由下一个接管(`driver_of`、`take_over`)。 @@ -259,7 +259,7 @@ octos 把系统 Agent 的输入以 `peer/input {peer, session_id, input_id, turn | --- | --- | --- | | Peer link(`crates/shell/src/peer_link/`) | App Hub、Calculator、Clock、Notes、Reminders、Weather、Terminal | Makepad 的 `OctosPeer` 客户端。进程应用的链接走它的 hub 连接。模块的 `OctosPeer::open` 先暂存一条内存中的通道,`module_host` 把它认领给打开它的实例,Shell 再以同样的帧提供服务(`peer_link::module_connected`)。`serve_tools` 应答 Agent 的工具调用。 | | 注入的服务 | Rinx | 在模块 `create` 之前调用 `ai_host::offer`,在 `create` 中调用 `injection::claim`,实例由此得到一个限定范围的 `OctosAppService`(`open_conversation`、`open_context`)。 | -| `host.request("octos.*")` | 商店应用 | Card runner 的 `octos` 宿主服务(`crates/ai-host/src/contained.rs`):只限 manifest 声明的服务,并需首次使用同意。 | +| `host.request("octos.*")` | 商店应用 | Card runner 的 `octos` 宿主服务(`crates/ai-host/src/contained.rs`):供已准入应用调用受支持的方法,需 Agent 同意和当前账户检查。 | 在 peer link 上,身份就是那个套接字或模块实例;一次调用的账户、上下文和客户端,来自 Shell 对该应用所开上下文的记录。进程退出时,它未完成的调用失败(除只读调用外均为 `outcome_unknown`),peer 保留。 @@ -271,7 +271,7 @@ Rinx 在其锁定的版本中,只把注入的服务用于小程序的上下文 ## 4. 工具与授权 -manifest 声明,用户在安装时授权,Shell 在每次调用时强制执行(ADR 0004 §12)。脚本应用在 `tools.json` 中声明工具;原生应用在 `native-apps.json` 的条目中声明它的 Agent(`agent.octos`、`tools`、`own_tools`、`system_tools`、`grants`、`generic_tools`、`budget`、`tool_policy`)。其中大部分字段的含义见 README 的[应用要给 Agent 提供什么](../README.zh-CN.md#应用要给-agent-提供什么)。`tool_policy` 设定工具由谁确认:Terminal 的 `run` 是 `confirm: host` 且 `auto_approvable: false`,所以任何常设规则都不会替用户批准它。 +能力与网络声明披露预期用途,不授予或拒绝公开 API 的执行。工具归属、应用间共享、Agent 启用、账户作用域、设备同意和逐次审核仍是实际授权边界(见[能力与执行边界](capabilities.zh-CN.md))。脚本应用在 `tools.json` 中声明工具;原生应用在 `native-apps.json` 的条目中声明它的 Agent(`agent.octos`、`tools`、`own_tools`、`system_tools`、`grants`、`generic_tools`、`budget`、`tool_policy`)。其中大部分字段的含义见 README 的[应用要给 Agent 提供什么](../README.zh-CN.md#应用要给-agent-提供什么)。`tool_policy` 设定工具由谁确认:Terminal 的 `run` 是 `confirm: host` 且 `auto_approvable: false`,所以任何常设规则都不会替用户批准它。 | 来源 | 声明于 | 运行在 | 现状 | | --- | --- | --- | --- | @@ -279,7 +279,7 @@ manifest 声明,用户在安装时授权,Shell 在每次调用时强制执 | octos 的内核工具 | `agent.tools` 中不带点的名称;`agent.generic_tools` | octos | 脚本应用只有 `ask_user_question`;Rinx 有文件、记忆和网页工具;其他原生 Agent 没有 | | `files.list`、`files.read`、`files.search` | Shell,注册在已获同意、有工作区的 peer 上(Unix) | Shell,作用于调用方的账户文件夹 | 每次读取最多 128 KiB,每次列出最多 500 项,每次搜索最多 100 个匹配 | | 其他应用可共享的工具 | `agent.tools` 中带点的名称;`agent.grants` | 所属应用,经中转 | 新闻共享 `news.list` 和 `news.read`;还没有应用申请 | -| 工具箱(`toolbox.*`、`workflow.*`) | `research` 和 `crawl` 能力 | Shell([`crates/toolbox`](../crates/toolbox/README.md)),需 `toolbox-peers` feature,手机上默认开启 | 还没有应用声明 | +| 工具箱(`toolbox.*`、`workflow.*`) | `agent.tools` 中的确切名称,以及顶层 `research` 的范围限制 | Shell([`crates/toolbox`](../crates/toolbox/README.md)),需 `toolbox-peers` feature,手机上默认开启 | 还没有应用声明 | | `dev.run` | 开发者模式 | Shell(`host_tools/dev_run.rs`) | 仅限所覆盖的应用 | 应用的 `AGENT.md` 和技能不是工具:代理把它们的文字作为宿主指导随每一轮发送(`crates/app-peers/src/guidance.rs`)。 @@ -294,9 +294,9 @@ manifest 声明,用户在安装时授权,Shell 在每次调用时强制执 4. **确认** `confirm: app` 调用:先向内核确认收到,再交给所属应用的面板(见[第 5 节](#5-审批))。 5. **只回答一次**,结果要符合 `output_schema`(最多 256 KiB);取消之后什么都不再运行。每次调用都会连同参数摘要记录到 `logs/tool-calls.jsonl`。 -脚本应用中 `implemented_by: "host-service"` 的工具,以该应用的身份在其命名空间对应的宿主服务上运行,前提是应用获授了该服务族,或该服务族就是这个系统应用自己的。Shell 的 `NoticeService` 为相册、地图、YouTube 和相机应答 `.notify`。脚本应用中 `implemented_by: "app"` 的工具在应用自身中运行:`ScriptAppExecutor`(`host_tools/script_apps.rs`)把调用排入 App Hub 的脚本工具运行器,由运行器在应用已打开的完整应用 VM 中调用它的 `app_tool(name, call_id)` 钩子。应用包必须要求 `script-tools-v1`。应用关闭时返回 `app_not_running`;对标为 `confirm: app` 的脚本工具发起的破坏性或对外调用,会以 `app_confirmation_unavailable` 遭到拒绝([ADR 0012](adr/0012-app-host-api-discovery.zh-CN.md))。这项能力已在 `main` 上,但尚未进入任何发布版本:`desktop-v0.1.0-beta.2` 会以 `app_tool_unavailable` 拒绝对这类工具的所有调用。 +脚本应用中 `implemented_by: "host-service"` 的工具,以已准入应用的身份运行解析出的宿主方法。中转检查工具归属和共享;服务保留应用与账户作用域、实际同意、审核和可用性检查。遗漏服务族声明不会拒绝执行。Shell 的 `NoticeService` 为相册、地图、YouTube 和相机应答 `.notify`。脚本应用中 `implemented_by: "app"` 的工具在应用自身中运行:`ScriptAppExecutor`(`host_tools/script_apps.rs`)把调用排入 App Hub 的脚本工具运行器,由运行器在应用已打开的完整应用 VM 中调用它的 `app_tool(name, call_id)` 钩子。应用包必须要求 `script-tools-v1`。应用关闭时返回 `app_not_running`;对标为 `confirm: app` 的脚本工具发起的破坏性或对外调用,会以 `app_confirmation_unavailable` 遭到拒绝([ADR 0012](adr/0012-app-host-api-discovery.zh-CN.md))。这项能力已在 `main` 上,但尚未进入任何发布版本:`desktop-v0.1.0-beta.2` 会以 `app_tool_unavailable` 拒绝对这类工具的所有调用。 -商店应用的工具也可以用 `host_method` 映射到共享服务,例如 Inbox Assistant 的 `inbox.message` 映射到 `gmail.message`。App Hub 只准入经审查的列表 `SHARED_HOST_METHODS` 中的方法:GitHub、Gmail 和 Google Calendar 的读取,Gmail 草稿编辑和新邮件事件处理,以及 `glance.*`。每个方法都要求声明对应服务族的能力和 `private_data: true`,风险等级也不能低于列表规定的等级。执行器只在应用获授该服务族时运行这个方法;对 `github`、`gmail` 和 `gcalendar`,它还会注入应用当前的连接(`host_tools/script_apps.rs`)。列表中没有任何方法会打开宿主面板,所以任何工具都不能登录、提交、保存日程或发送(见[已连接账户](#已连接账户))。 +商店应用的工具也可以用 `host_method` 映射到共享服务,例如 Inbox Assistant 的 `inbox.message` 映射到 `gmail.message`。App Hub 只准入经审查的列表 `SHARED_HOST_METHODS` 中的方法,包括GitHub、Gmail 和 Google Calendar 的读取,Gmail 草稿编辑和新邮件事件处理,以及 `glance.*`。每个方法保留列表规定的最低风险与隐私要求,执行不要求匹配的服务族披露。对 `github`、`gmail` 和 `gcalendar`,执行器会注入应用当前的连接(`host_tools/script_apps.rs`),并继续检查其提供商 scope 和归属。列表中没有任何方法会打开宿主面板,所以任何工具都不能登录、提交、保存日程或发送(见[已连接账户](#已连接账户))。 ### 系统 Agent 的工具集 @@ -304,7 +304,7 @@ manifest 声明,用户在安装时授权,Shell 在每次调用时强制执 ### Agent 往 glance 屏幕上放什么 -glance 服务(`crates/shell/src/glance.rs`)以调用方应用的身份、在宿主记录的账户下发布每张卡片,而且只在应用有 `glance` 授权时才发布。一个应用每分钟最多发布 6 次。手机和桌面都能滚动浏览所有保留卡片,不再限制每应用四张卡片或信息流六行。保留负载的预算为每应用 8 MiB、合计 32 MiB;容量紧张时淘汰优先级较低的旧卡片,但保留新的有效发布;所属服务继续保存草稿和原邮件。`mail.publish_card` 还能把卡片绑定到邮件已保存的某份草稿上;绑定后的卡片不能再换到别的账户、邮件或草稿。 +glance 服务(`crates/shell/src/glance.rs`)以调用方应用的身份、在宿主记录的账户下发布每张卡片,并验证发布者当前仍已准入;`glance` 只是用途披露。一个应用每分钟最多发布 6 次。手机和桌面都能滚动浏览所有保留卡片,不再限制每应用四张卡片或信息流六行。保留负载的预算为每应用 8 MiB、合计 32 MiB;容量紧张时淘汰优先级较低的旧卡片,但保留新的有效发布;所属服务继续保存草稿和原邮件。`mail.publish_card` 还能把卡片绑定到邮件已保存的某份草稿上;绑定后的卡片不能再换到别的账户、邮件或草稿。 在 `main` 上,Agent 的工具调用若最终映射到 `glance.publish`(例如 Inbox Assistant 的 `inbox.notify`),必须指定应用已准入应用包中的模板并提供 `initial` 对象,或者提交合法的 L0 源码。可执行的 Splash(`script`)、L1 源码以及混合的参数都会被拒绝(`host_tools/script_apps.rs` 中的 `check_agent_publication`)。应用自己的界面仍可发布它经过审核的 Splash。`desktop-v0.1.0-beta.2` 没有这项检查,会接受 Agent 发布的 `script` 卡片。 @@ -314,7 +314,7 @@ glance 服务(`crates/shell/src/glance.rs`)以调用方应用的身份、在 商店应用不需要 OctoSense 账户,就能使用用户的 GitHub 或 Google 账户,或让用户登录应用自己的后端([ADR 0010](adr/0010-shared-oauth-and-connected-apps.zh-CN.md))。[`crates/oauth-service`](../crates/oauth-service/README.zh-CN.md) 实现了 OAuth 协议、GitHub、Google 和后端的适配器以及连接存储。`register_host_services`(`crates/shell/src/apps.rs`)注册它的四个宿主服务:`auth` 负责登录和应用的连接,`github`、`gmail`、`gcalendar` 提供 GitHub 和 Google 的数据。各服务的方法见该 crate 的 README。 -- **声明**。应用声明 `auth`、它用到的每个数据服务族(`github`、`gmail`、`gcalendar`),以及 `storage.accounts: true`。只声明 `auth` 时,应用仍能让用户仅为确认身份而登录(GitHub 的 `read:user`;Google 的 `openid`、`email` 和 `profile`),但拿不到任何 GitHub 或 Google 数据:其他 scope 所属的服务族若未获授,宿主一律拒绝(`register_host_services`)。 +- **披露与同意**。应用描述 `auth` 及数据服务族(`github`、`gmail`、`gcalendar`),并配置 `storage.accounts: true`。这些名称不授权访问提供商。`register_host_services` 检查当前应用准入与提供商 scope 是否有效;用户通过提供商授权所请求的 scope。仅身份连接(GitHub 的 `read:user`;Google 的 `openid`、`email`、`profile`)不会因声明某个服务族就获得邮件、日历或仓库 scope。每次数据调用仍检查真实连接和 scope。 - **身份**。应用只看到不透明的连接句柄。它的 peer 以它当前的连接行事(`app_storage/lifecycle.rs`),所以每个已连接账户都有自己的 Agent。 - **配置**。OAuth 客户端注册归宿主所有,从不由应用提供。发行方在构建时通过构建变量(例如 `OCTOSENSE_GITHUB_CLIENT_ID`)把注册编译进宿主(`crates/oauth-service/src/registration.rs`);`desktop-v0.1.0-beta.2` 的下载包不含任何注册。运维人员可以用 App Hub 宿主目录中的 `clients.json`(`/.host/oauth/clients.json`,其中 `` 即 `/apps`,见[第 6 节](#6-存储与机密))替换整套注册;文件中没有列出的提供商随之停用。缺少某个提供商的注册时,登录会失败并提示“GitHub sign-in is unavailable in this build. Check for an OctoSense update or contact its distributor.”(Google 的提示相同,只是换成 Google)。在 beta.2 上,缺少 `clients.json` 时提示的则是“OAuth is not configured”。 - **应用自己的后端**。`auth.connect` 带上 `{"provider":"backend","scopes":["app.session"]}`,就能让用户登录应用自己的服务器;`auth.backend.me` 返回该服务器验证过的身份(`crates/oauth-service/src/host_backend.rs`)。在 `main` 上(尚未进入任何发布版本),应用的签名应用包可以声明自己的后端和命名操作,应用用 `auth.backend.request` 调用这些操作([ADR 0012](adr/0012-app-host-api-discovery.zh-CN.md));没有这项声明的应用使用运维人员在 `/.host/oauth/backends.json` 中的注册。`desktop-v0.1.0-beta.2` 没有后端登录。在 macOS 和 Android 9 及以上版本上,服务器的登录页面显示在宿主拥有的 WebView 中;在 Windows 和 Linux 上,或在 macOS 上指定 `"presentation":"browser"` 时,改在浏览器中打开(见 `host.rs` 中的 `presentation`)。iOS 不支持后端登录。 @@ -416,7 +416,7 @@ Rinx(通过 `OctosAppService::set_account`)、邮件的宿主服务和 `auth | 每次调用都检查:授权、同意、审批、预算、审计 | | +------------------+ +------------------------------------------+ | | | 原生模块 | | Card runner:脚本应用各在隔离环境中 | | - | | 经审查,与 Shell | | (jail、配额、按授权 host.request) | | + | | 经审查,与 Shell | | (jail、配额、归属明确的 host.request) | | | | 共享内存:可信 | +------------------------------------------+ | | +------------------+ | +-------|------------------------------------------|-----------------------+ @@ -430,8 +430,8 @@ Rinx(通过 `OctosAppService::set_account`)、邮件的宿主服务和 `auth | 边界 | 由什么保证 | | --- | --- | -| 脚本应用 ↔ Shell | Card runner 的隔离环境、jail 和配额;`host.request` 只能访问已授权的服务族 | -| 应用自带的 WebAssembly 函数 ↔ Shell | 每次调用一个全新的 Wasmtime 实例,只能接触自己的内存和 `octo.log`,有截止时间以及内存、栈和大小上限;在 macOS、Linux 和 Android 的标准构建中存在([OctoSense 中的 WebAssembly](wasm.zh-CN.md)) | +| 脚本应用 ↔ Shell | Card runner 的隔离环境、jail 和配额;`host.request` 检查当前应用身份、账户作用域、实际同意与审核;声明不限制公开 API | +| 应用自带的 WebAssembly 函数 ↔ Shell | 核心模块每次调用使用全新的受限实例,只接收输入并调用 `octo.log`。组件保留按应用和账户隔离的实例,使用受限的 WASI 存储与网络,并以明确身份分派宿主调用;共享组件代码不可变,状态仍归各应用和账户私有。当前源码包含 macOS、Windows、Linux、Android 和 OpenHarmony(Pulley);平台及验证边界见 [OctoSense 中的 WebAssembly](wasm.zh-CN.md)。 | | 原生模块 ↔ Shell | 内存上没有隔离:靠对第一方代码的审查,以及模块边界的 panic 捕获 | | 进程应用 ↔ Shell | 独立的地址空间和系统沙箱:macOS 上是 Seatbelt,Linux 上是 Landlock 和 seccomp。**尚未实现**:Windows。 | | 应用 ↔ 内核 | 没有应用看得到宿主 token。只有 octos 客户端的内核端口使用 OUP,由路由器按编码范围约束(ADR 0003 第 9 条) | diff --git a/docs/capabilities.md b/docs/capabilities.md new file mode 100644 index 000000000..6027e357b --- /dev/null +++ b/docs/capabilities.md @@ -0,0 +1,60 @@ +# App capabilities and execution boundaries + +English | [简体中文](capabilities.zh-CN.md) + +This describes the declaration-only capability implementation in this source +tree. It does not claim that a downloaded release contains these changes. +Desktop 0.1.0-rc.2 predates the component model and this policy. + +## Declarations describe usage + +An app's `capabilities` and `network.hosts` describe its intended use for +discovery, store disclosure and review. Omitting a public API family does not +deny a call. Adding one does not supply user consent, connect an account or +authorize a write. Keep declarations accurate so users can understand the app. + +This applies to an app's Splash requests, its own Wasm functions and components, +and its agent's offered host-service tools. These entry points use the same app +identity, but retain their different interaction rules: an agent or component +cannot open a permission sheet or impersonate a person's input. + +`requires` and `host_api.required` are compatibility requirements. They still +prevent an app from installing on a host that cannot run its ABI or methods. +Use `runtime.describe` to discover optional methods on the current device. +Neither a declaration nor a Wasm wrapper creates an API absent from the host. + +## What still authorizes an operation + +| Boundary | What the host checks | +| --- | --- | +| App identity | Verified installed bundle, current admission and the host-owned app ID and profile directory. Withdrawal, tampering or replacement invalidates pending work. | +| Device access | Per-app consent and the operating system's permission. OctoSense's OS permission does not authorize every app. Permission requests need a foreground app. | +| Account data | The app's own connected account and current account binding. Script or model arguments cannot choose another app's account. | +| Private writes | Host-owned review and trusted user input for mail sends, Calendar changes and GitHub saves. A background callback cannot approve them. | +| App files | The app's storage jail, active account, quota and resource lifetime. A component sees the app's folder, not the host filesystem. | +| App assistants | A genuine app assistant offer, user consent and a supported public method. Declarations alone do not create an assistant. | +| Tools shared between apps | The offered tool, its owner, sharing approvals and risk rules. Removing a family check does not grant another app's tools. | +| Resource and platform limits | Linked service, supported platform, bounded input and output, deadlines, quotas and cancellation. | + +The runtime's private profile access and raw unowned agent notifications are +internal interfaces, not public app capabilities. Apps use the scoped host APIs +and the app-agent broker. Web content also keeps its own sandbox and request +validation; removing a manifest host list does not remove those boundaries. + +Before evaluating a card, App Hub's `apply_device_consent` binds the verified +launch bundle's app ID to its isolate and applies the device-consent boundary. +A storage folder alone is insufficient: native file and media services require +that same isolate identity when obtaining its storage handle. A failed launch +clears the previous identity; scripts cannot supply a replacement app ID. + +## Validation and release + +The acceptance matrix pairs declared and undeclared calls, then separately +tests denied consent, a foreign profile, stale accounts, unapproved writes, +storage escape and quota exhaustion. Component acceptance also checks actual +store resolution, alias calls, isolated state and withdrawal. + +Record unit tests, simulated connectors, platform tests, physical-device tests +and released artifacts separately. A successful build or a merged PR is not a +device acceptance result. See [WebAssembly in OctoSense](wasm.md) for component +support and [host OS API status](host-os-api-status.md) for device APIs. diff --git a/docs/capabilities.zh-CN.md b/docs/capabilities.zh-CN.md new file mode 100644 index 000000000..d3d85b794 --- /dev/null +++ b/docs/capabilities.zh-CN.md @@ -0,0 +1,51 @@ +# 应用能力声明与执行边界 + +[English](capabilities.md) | 简体中文 + +本文描述当前源码中“能力仅用于声明”的实现,不表示下载的发行版已经包含这些更改。 +桌面版 0.1.0-rc.2 早于组件模型及本策略。 + +## 声明用于说明用途 + +应用的 `capabilities` 和 `network.hosts` 用于能力发现、商店说明和审核。 +没有声明某个公开 API 家族,不会因此拒绝调用。添加声明也不代表用户同意、 +账户已连接或写操作获准。仍应准确填写声明,帮助用户理解应用的用途。 + +此规则适用于应用的 Splash 请求、自带的 Wasm 函数及组件,以及应用 Agent +提供的宿主服务工具。这些入口使用相同的应用身份,但交互限制不同:Agent 和 +组件不能打开权限面板,也不能冒充用户的真实输入。 + +`requires` 与 `host_api.required` 是兼容性要求。缺少所需 ABI 或方法的宿主 +仍会拒绝安装应用。可选方法通过 `runtime.describe` 查询。能力声明或 Wasm +封装都不能凭空提供宿主中不存在的 API。 + +## 操作仍须满足的条件 + +| 边界 | 宿主检查的内容 | +| --- | --- | +| 应用身份 | 已验证的安装包、当前准入状态,以及宿主提供的应用 ID 和配置目录。撤回、篡改或更新会使旧任务失效。 | +| 设备访问 | 每个应用的用户同意和操作系统权限。系统授予 OctoSense 的权限不自动覆盖所有应用。权限申请必须来自前台应用。 | +| 账户数据 | 应用自己的已连接账户及当前账户绑定。脚本或模型参数不能选择另一个应用的账户。 | +| 私密写操作 | 发送邮件、修改日历和保存 GitHub 内容仍需宿主审核面板及可信用户输入。后台回调不能批准。 | +| 应用文件 | 应用存储隔离目录、当前账户、配额和资源生命周期。组件只能看到应用目录,不能看到宿主文件系统。 | +| 应用助手 | 应用确实提供助手、用户同意,以及受支持的公开方法。能力声明本身不会创建助手。 | +| 跨应用工具 | 已提供的工具、其所有者、共享授权和风险规则。移除家族检查不等于授予其他应用的工具。 | +| 资源与平台限制 | 服务已经编译进宿主、平台支持、输入输出大小上限、期限、配额和取消机制。 | + +运行时读取宿主私有资料和发送无归属 Agent 通知的接口属于内部接口,不是公开 +应用能力。应用应使用带身份和账户范围的宿主 API 与应用 Agent 代理层。 +网页内容也保留自身的沙箱和请求校验;移除清单中的主机名单限制不等于移除这些边界。 + +执行卡片源码前,App Hub 的 `apply_device_consent` 将已验证启动包的应用 ID 绑定到隔离实例, +并启用设备同意边界。仅有存储目录还不够:原生文件和媒体服务获取存储句柄时,也要求该实例 +具有相同的应用身份。启动失败时会清除旧身份;脚本不能提供替代的应用 ID。 + +## 验证与发布 + +验收矩阵成对测试“已声明”和“未声明”的调用,再独立测试拒绝设备同意、外部 +配置目录、旧账户、未经批准的写操作、存储越界及配额耗尽。组件验收还检查实际 +商店解析、别名调用、实例状态隔离和撤回。 + +单元测试、模拟连接器、平台测试、实体设备测试和发布产物必须分别记录。 +构建成功或 PR 合并不代表设备验收通过。组件支持见 [WebAssembly 说明](wasm.zh-CN.md), +设备 API 见 [宿主 OS API 状态](host-os-api-status.zh-CN.md)。 diff --git a/docs/host-os-api-status.md b/docs/host-os-api-status.md index 8b39a00d4..bb1e609b3 100644 --- a/docs/host-os-api-status.md +++ b/docs/host-os-api-status.md @@ -24,7 +24,7 @@ Location sampling defaults to a 10-second timeout and a maximum age of 5 seconds External links use the same canonical URL for policy evaluation and OS launch. Control characters and malformed absolute URLs are refused; failures are logged without the URL. Native host callers retain valid absolute schemes. No `web.open` host-request method is introduced: this is the existing widget path, distinct from embedded browsing. Makepad source symbols: `normalize_external_url`, `LinkLabel::handle_event`, each platform's `CxOsApi::open_url`, `android_jni::to_java_open_url` and `MakepadActivity.openUrl`. Locate the pinned source and overlays through [runtime-patches.lock.json](../runtime-patches.lock.json) and [native-runtime.lock.json](../native-runtime.lock.json). -Unsupported capture backends emit `CameraCaptureResult::Failed` instead of silently ignoring the request. The current integration also adds a bounded [Windows still-capture worker](windows-camera-capture.md): nine actual-source worker tests and a Windows target typecheck passed, but no Windows camera hardware was exercised. Declare `camera` and `storage` without `library` there; Windows gallery export and video recording remain unsupported. Shared capture quota reservation is still incomplete. These checks do not validate native device callbacks, permission UI or privacy indicators. +Unsupported capture backends emit `CameraCaptureResult::Failed` instead of silently ignoring the request. The current integration also adds a bounded [Windows still-capture worker](windows-camera-capture.md): nine actual-source worker tests and a Windows target typecheck passed, but no Windows camera hardware was exercised. Capture options now express intent explicitly: `capture()` saves locally; `capture({library: true})` requests gallery export and is refused on Windows. Declarations do not turn export on. Windows gallery export and video recording remain unsupported. Shared capture quota reservation is still incomplete. These checks do not validate native device callbacks, permission UI or privacy indicators. ## Existing facilities and remaining OS work diff --git a/docs/host-os-api-status.zh-CN.md b/docs/host-os-api-status.zh-CN.md index 1e15389a2..ee3079b5f 100644 --- a/docs/host-os-api-status.zh-CN.md +++ b/docs/host-os-api-status.zh-CN.md @@ -24,7 +24,7 @@ 外部链接使用同一个规范化 URL 做策略判断和 OS 打开,拒绝控制字符及无效的绝对 URL;错误日志不包含 URL。可信的原生宿主调用仍可使用有效的绝对协议地址。本批没有新增名为 `web.open` 的宿主请求方法:补齐的是已有控件入口,与嵌入式浏览不同。Makepad 源码符号包括 `normalize_external_url`、`LinkLabel::handle_event`、各平台的 `CxOsApi::open_url`、`android_jni::to_java_open_url`、`MakepadActivity.openUrl`。固定源码及覆盖补丁由 [runtime-patches.lock.json](../runtime-patches.lock.json) 和 [native-runtime.lock.json](../native-runtime.lock.json) 定位。 -未实现拍照或录像的后端返回 `CameraCaptureResult::Failed`,不再静默忽略请求。当前集成还新增有界的 [Windows 静态拍摄工作线程](windows-camera-capture.zh-CN.md):九项真实源码工作线程测试和 Windows 目标类型检查已通过,但没有操作 Windows 相机硬件。该路径应声明 `camera`、`storage`,不声明 `library`;Windows 图库导出和录像仍不支持,共享拍摄存储配额预留也未补齐。这些检查不能验证原生设备回调、权限界面或隐私指示灯。 +未实现拍照或录像的后端返回 `CameraCaptureResult::Failed`,不再静默忽略请求。当前集成还新增有界的 [Windows 静态拍摄工作线程](windows-camera-capture.zh-CN.md):九项真实源码工作线程测试和 Windows 目标类型检查已通过,但没有操作 Windows 相机硬件。拍摄参数现在明确表达意图:`capture()` 只存本地;`capture({library: true})` 请求图库导出,在 Windows 上会被拒绝。清单声明不会自动开启导出。Windows 图库导出和录像仍不支持,共享拍摄存储配额预留也未补齐。这些检查不能验证原生设备回调、权限界面或隐私指示灯。 ## 已有能力与剩余 OS 工作 diff --git a/docs/released-component-acceptance.md b/docs/released-component-acceptance.md new file mode 100644 index 000000000..f32f2e1f3 --- /dev/null +++ b/docs/released-component-acceptance.md @@ -0,0 +1,71 @@ +# Test shared components in the desktop release + +[简体中文](released-component-acceptance.zh-CN.md) + +`tools/test-released-components.py` tests the **packaged macOS desktop**, using +App Hub's Search → Get → Install screens and its normal installed-app launcher. +It installs `org.ymote.componentdemo.first` and `.second` from the genuine +GitHub-attested rehearsal catalog, then restarts the shell for each app and +checks two runs of its component checks, counter state and synthetic HTML note. +One app declares no capabilities; the other declares `wasm` and `storage`. +Neither app uses an account or model. The public catalog is not modified. + +This complements `tools/test-shared-components.py`, whose native fixture tests +agent tool dispatch, concurrent instance isolation and immutable component +deduplication. Running the fixture is not evidence that a released shell works. +The released-shell test does not establish live-model behavior or an OS upgrade. + +## Release and acceptance order + +1. Merge the compatible host changes after their required checks. Create the + `desktop-v` tag on that reviewed commit. The existing + `.github/workflows/release-desktop.yml` builds the tag on macOS, Windows and + Linux, scans the packages and attaches them to a **draft** release. A manual + run must use that tag and `dry_run=false`; a branch-only dry run is not a + release. Signing and notarization depend on configured release secrets; + inspect the signing jobs rather than assuming either occurred. +2. Download the draft's macOS `.app.zip`, platform receipt and `SHA256SUMS`. + Verify the workflow's source commit and all published asset hashes. Run + `tools/release-scan.py` on the downloaded package. The local packaging script + expects its standard `target/release` output; use the workflow's clean build + environment, without an alternate Cargo target directory or build target. +3. Obtain the genuine signed mirror produced by the protected Hub catalog + dry-run. It contains `catalog-v2.json` and the exact approved artifacts. Do + not add a trust-anchor override, fake a proof or substitute a legacy catalog. +4. Run the hidden test with a **new** evidence directory: + + ```sh + python3 tools/test-released-components.py \ + --app-zip /path/to/OctoSense__macos_aarch64.app.zip \ + --sha256 \ + --tag desktop-v \ + --mirror /path/to/verified-candidate-mirror \ + --out /path/to/new-evidence-directory + ``` + + The driver verifies the archive digest and packaged version, extracts the + `.app` into that directory and leaves the installed personal OctoSense app + untouched. It uses a fresh `OCTOSENSE_HOME`, a separate kernel directory, + file vaults, the real GitHub catalog channel and hidden Makepad instrument + windows. It records hashes, assertions, logs and frames, then closes each + owned process. Its cleanup record checks observed child processes too; + only descendants whose process identity still matches can be terminated. + Inspect the frames as well as the JSON receipt. + + The optional App Hub agent prompt uses custom-drawn controls. The driver + pauses at `apphub-agent-consent.png`: inspect that actual frame, then put + the observed **Don't allow** button's window-local layout coordinates in + `refuse-apphub-agent.json`, for example `{"x": 526, "y": 620}` only if that + point matches the captured window. The driver records the instrument click + and verifies the resulting refusal before proceeding. Do not select Allow + or prepopulate approval files. This tests a local app, without enabling an + agent or configuring a provider. +5. Review all platform artifacts and their distinct test limits. Publish the + reviewed draft explicitly as a **prerelease** for an RC tag; the workflow + itself does not set the prerelease flag. No Windows/Linux installation or + physical-phone result follows from this Mac test. + +For developer rehearsal only, replace `--app-zip`, `--sha256` and `--tag` with +`--binary /path/to/octosense`. Its receipt records `release_archive_tested: false`. +No build, tag creation, download or release publication is performed by this +driver. An unexecuted recipe is not a passing acceptance result. diff --git a/docs/released-component-acceptance.zh-CN.md b/docs/released-component-acceptance.zh-CN.md new file mode 100644 index 000000000..b35e123c1 --- /dev/null +++ b/docs/released-component-acceptance.zh-CN.md @@ -0,0 +1,59 @@ +# 在桌面发行包中验收共享组件 + +[English](released-component-acceptance.md) + +`tools/test-released-components.py` 使用 **macOS 桌面发行包**,通过 App Hub 的 +Search → Get → Install 界面安装应用,再通过正常的已安装应用入口启动。 +它从带有真实 GitHub 证明的演练目录安装 `org.ymote.componentdemo.first` 和 +`.second`,分别重启 Shell,运行两轮组件检查,验证计数器状态和各自的合成 HTML +笔记。第一个应用没有声明能力,第二个声明 `wasm` 和 `storage`。 +两者都不使用账号或模型,也不会修改公开商店目录。 + +它补充 `tools/test-shared-components.py` 的原生测试,后者覆盖应用工具派发、 +并发实例隔离及不可变组件去重。原生测试通过不等于发行版 Shell 已通过验证。 +本发行包测试也不证明真实模型行为或操作系统升级成功。 + +## 发行与验收顺序 + +1. 所需检查通过后合并兼容主机代码,在审核过的提交上创建 + `desktop-v` 标签。现有 `.github/workflows/release-desktop.yml` + 构建 macOS、Windows 和 Linux 包,扫描产物并上传到**草稿发行版**。 + 手动运行必须指定该标签和 `dry_run=false`;仅构建分支的 dry-run 不是发行。 + 签名和公证依赖发行环境的配置,需要查看相关任务,不能默认已完成。 +2. 下载草稿的 macOS `.app.zip`、平台构建记录和 `SHA256SUMS`。核对工作流 + 源码提交及全部附件哈希,对下载包运行 `tools/release-scan.py`。 + 当前本地打包脚本使用标准 `target/release` 目录,应采用工作流的干净环境, + 不覆盖 Cargo 目标目录或编译目标。 +3. 使用 Hub 受保护目录 dry-run 生成的真实签名镜像,其中应包含 + `catalog-v2.json` 和审核过的原始产物。不要覆盖信任锚点、伪造证明, + 或替换为旧版目录。 +4. 使用**新的**证据目录运行隐藏窗口测试: + + ```sh + python3 tools/test-released-components.py \ + --app-zip /path/to/OctoSense__macos_aarch64.app.zip \ + --sha256 \ + --tag desktop-v \ + --mirror /path/to/verified-candidate-mirror \ + --out /path/to/new-evidence-directory + ``` + + 驱动先校验压缩包哈希和包内版本,将 `.app` 解压到证据目录,不修改个人 + 已安装的 OctoSense。它使用新的 `OCTOSENSE_HOME`、独立内核目录、文件凭据库、 + 真实 GitHub 目录通道和隐藏的 Makepad instrument 窗口。它记录哈希、断言、 + 日志和画面,最后关闭自己启动的进程。清理记录也会检查观察到的子进程, + 仅会终止进程身份仍然匹配的自有后代进程。请同时检查截图和 JSON 记录。 + + App Hub 的可选代理提示使用自绘控件。驱动会在 + `apphub-agent-consent.png` 暂停:检查实际画面,将 **Don't allow** 按钮的 + 窗口局部逻辑坐标写入 `refuse-apphub-agent.json`。例如 + `{"x": 526, "y": 620}` 仅适用于截图确实对应该位置的窗口。 + 驱动会记录 instrument 点击并验证拒绝结果,然后继续。不要选择 Allow, + 也不要预先写入批准文件。这是本地应用测试,不启用代理或配置模型供应商。 +5. 审核各平台产物及各自的验证范围。发布审核过的 RC 草稿时,明确设置为 + **prerelease**;工作流本身没有设置此标记。本 Mac 测试不代表 Windows/Linux + 安装或实体手机测试已经通过。 + +仅在开发演练时,可将 `--app-zip`、`--sha256` 和 `--tag` 替换为 +`--binary /path/to/octosense`,记录会标明 `release_archive_tested: false`。 +驱动不会构建、创建标签、下载或发布发行版。尚未执行的步骤不能算验收通过。 diff --git a/docs/wasm.md b/docs/wasm.md index f112c0ad2..8fcbd9420 100644 --- a/docs/wasm.md +++ b/docs/wasm.md @@ -4,16 +4,20 @@ English | [简体中文](wasm.zh-CN.md) WebAssembly turns up in four places in OctoSense. One is designed to run an app's own code: an app's own functions, which standard builds run on macOS, -Linux and Android, with limited support. The plug-ins of two engines, +Windows, Linux, Android and OpenHarmony, with limited support. The plug-ins of two engines, photocraft behind `photo` and vectorcraft behind `vector`, are closed: both services refuse every plug-in command. The other two are a makepad internal and a browser build that does not compile. For each, this page says whose module runs, what it can reach, which builds -have it and how it was checked. It describes `main` after #400 (8 October -2026). +have it and how it was checked. This page describes the current source tree. +Desktop 0.1.0-rc.2 and Home 0.1.0-beta.2 contain ADR 0011 core modules. +ADR 0014 shared components and the declaration-only changes below have passed +the dedicated macOS/OnePlus 6 fixture; their release is still pending. +Source, test and release status are separate. +See [capabilities and execution boundaries](capabilities.md). | Where | Whose module | Runtime | Builds | State | | --- | --- | --- | --- | --- | -| [An app's own functions](#an-apps-own-functions-the-wasm-service): the `wasm` service ([ADR 0011](adr/0011-apps-own-functions-in-webassembly.md)) | The app's own bundle, `fns/*.wasm`, under the `wasm` capability | Wasmtime 49, compiled by Cranelift (`crates/wasm-host`); components get WASI 0.2 from `wasmtime-wasi` | Every standard desktop and Home build on macOS, Windows, Linux, Android and OpenHarmony (feature `wasm-functions`; on OpenHarmony in Pulley, Wasmtime's interpreter); none for iOS | Accepted, limited support. Tests pass on macOS and Linux; a OnePlus 6 passed the phone acceptance. No release has it yet | +| [An app's own functions](#an-apps-own-functions-the-wasm-service): the `wasm` service ([ADR 0011](adr/0011-apps-own-functions-in-webassembly.md)) | The admitted app's `fns/*.wasm` or its exact shared-component pins | Wasmtime 49, compiled by Cranelift (`crates/wasm-host`); components get WASI 0.2 from `wasmtime-wasi` | Every standard desktop and Home build on macOS, Windows, Linux, Android and OpenHarmony (feature `wasm-functions`; on OpenHarmony in Pulley, Wasmtime's interpreter); none for iOS | Core modules shipped in desktop rc.2 and Home beta.2. Shared-component development-host acceptance passed on macOS and OnePlus 6; its release remains pending | | [Engine plug-ins](#engine-plug-ins-the-photo-and-vector-services): photocraft behind `photo`, vectorcraft behind `vector` ([ADR 0013](adr/0013-craft-engines-as-pinned-services.md)) | None: both services refuse every `plugin.*` command (#398, #405) | wasmi 2, an interpreter | Linked into every build with App Hub | Closed: no caller can install or run a plug-in | | [Splash's math compiler](#splash-math-and-compute-kernels) (makepad) | Generated by makepad from Splash code | makepad-stitch, an interpreter | Linked into every build | Not used: OctoSense links nothing that calls it | | [The shell in a browser](#the-shell-in-a-browser) | The shell itself, built for `wasm32-unknown-unknown` | The browser | None | Does not build | @@ -53,7 +57,7 @@ App developers start from App Flow's ### Components -[ADR 0014](adr/0014-app-components-in-webassembly.md) (Proposed) adds a second +[ADR 0014](adr/0014-app-components-in-webassembly.md) (Accepted; rollout incomplete) adds a second kind of `fns/.wasm`: a WebAssembly component, built from an ordinary Rust crate with `cargo build --target wasm32-wasip2`. The service tells the two kinds apart by the file's preamble, and a bundle may carry both. @@ -75,10 +79,10 @@ two kinds apart by the file's preamble, and a bundle may carry both. component that imports anything beyond `wasi:cli`, `wasi:clocks`, `wasi:filesystem`, `wasi:http`, `wasi:io`, `wasi:random` and `octosense:host`, such as sockets, is refused when it loads. -- **Files.** With the `storage` capability (and a signed-in account, for an - app with accounts), the app's own storage folder, the one its script's - `fs.*` sees, is the component's `/`. Nothing else on the device is - visible, and without the capability there is no filesystem. +- **Files.** The app's available storage folder, the one its script's `fs.*` + sees, is the component's `/`. Account-scoped apps need their active account. + Nothing else on the device is visible. Missing storage or a signed-out + account leaves no preopen; omitting the `storage` declaration does not. - **The storage quota.** A call may add what is left of the app's quota when the call starts. A write past it fails inside the component: `ftruncate` reports a full disk, and an ordinary write an I/O error, because wasi-libc @@ -89,17 +93,17 @@ two kinds apart by the file's preamble, and a bundle may carry both. can: an app's network declarations (`net`, `network.hosts`) are shown when it is installed and not enforced while it runs. The ruling of 8 October 2026 removed the per-app runtime gates; the OS and the host's API surface - are the boundary. (The app's Splash script still passes the Splash - runtime's URL gate until the Makepad fork drops it.) A component that + are the boundary. The pinned Splash runtime also no longer enforces the + manifest host list. A component that imports `wasi:http` gets 10 s a call instead of 2 s, and each request's connect, first-byte and between-bytes timeouts end with the call. - **Host services.** Through `octosense:host` ([`crates/wasm-host/wit/octosense-host.wit`](../crates/wasm-host/wit/octosense-host.wit)), - `request(service, args)` calls a host service as the app's script does: - dispatched on the UI thread, with no check of the families the manifest - declares (makepad#118; a service that needs a grant checks it itself), but - never with a sheet or a prompt, so only methods a background surface may - call. `wasm.*` is refused, since the app's worker is busy with + `request(service, args)` uses the admitted app's identity, dispatched on + the UI thread. Declaration lists do not gate the call. Each service still + checks its account, consent, review and resource boundaries. A component + never gains a sheet, prompt or trusted user input, so only methods a + background surface may call are available. `wasm.*` is refused, since the app's worker is busy with the call, and the call's deadline bounds the wait. ADR 0014 records what this costs. On an M-series Mac, the 313 KiB test @@ -117,7 +121,7 @@ ADR 0003, OctoSense ADR 0014 phase 4). The manifest pins it under beside the app's `fns/` and routes its functions as `wasm..`: with `{"as": "md", "id": "org.example.markdown", …}`, `wasm.md.to_html` calls the component's `to-html`. The app gets an instance of its own, under its own -grants, as for a component in its `fns/`. It may carry no `fns/` at all and +account and storage boundaries, as for a component in its `fns/`. It may carry no `fns/` at all and call only shared components. `wasm.functions` lists their functions with the alias, and names the component each comes from (`"shared": {"alias", "id", "version", "blake3"}`). @@ -132,21 +136,27 @@ app's components before the app itself. A missing, changed or withdrawn component stops the app's launch. `wasm_service::tests::a_pinned_shared_component_answers_as_alias_and_function` -runs this path with a stand-in resolver. No catalog has published a component -yet, so installing an app that pins one and calling it in a shell is -**unverified**. +is a focused test with a stand-in resolver. The separate +[real GitHub acceptance](../tools/fixtures/wasm-phone-lab/README.md#real-github-shared-component-acceptance) +uses actual publisher attestations and an admin-attested private dry-run +catalog, then installs two apps through the real Store and calls their shared +components through Splash tools. Both macOS and OnePlus 6 passed 28/28 native +assertions; their drivers passed 12/12 and 13/13 checks respectively. The public +catalog was unchanged. This establishes development-host installation and +execution, not a released-binary or live-model result. ### How a module reaches a device -- **A store app.** App Hub's gate (app contract 1.7.0) admits a `.wasm` file - only at `fns/.wasm` (the name `[a-z0-9_-]`, at most 64 characters), only - as a core module (it checks the file's first eight bytes), at most 8, and - only with the `wasm` capability. A `wasm` capability without modules is a - warning. The gate does not read a module's imports or exports: the shell - checks those when the module loads. The store tells the person: "Run its - own sandboxed functions on this device." The rules are in App Hub's +- **A store app.** App Hub's current gate (app contract 1.11) admits up to + 8 `.wasm` files at `fns/.wasm` (name `[a-z0-9_-]`, at most 64 + characters). It distinguishes core modules from components and checks + component imports and required ABIs. It verifies exact shared-component + versions, digests and publishing proofs. Omitting `wasm`, `storage` or + `net` is not an admission refusal. Keep those declarations accurate for + store disclosure; the runtime still validates code when it loads. + The rules are in App Hub's [publishing reference](https://github.com/OctoSense-org/OctoSense-App-Hub/blob/main/docs/PUBLISHING.md) - (the `functions` finding and the `wasm` capability). + (the `functions` finding, required ABIs and `wasm` usage disclosure). - **A system app.** It is packed into the shell like any other, by digest, from the packaging's system-app list. `desktop/system-apps-wasm-lab.json` and `phone/system-apps-wasm-lab.json` are the standard lists plus `wasmlab`. @@ -154,11 +164,11 @@ yet, so installing an app that pins one and calling it in a shell is ### The service The `wasm` host service (`crates/shell/src/wasm_service.rs`) is in every -standard desktop and Home build on macOS, Linux and Android: the shell's -`wasm-functions` feature, on by default in both packages (`wasm-lab` is its -former name). Builds for Windows, iOS and OpenHarmony leave the runtime out -(`crates/shell/Cargo.toml`, `crates/shell/build.rs`), and there a call answers -`no service answers "wasm" on this device`. It answers two kinds of request: +standard desktop and Home build on macOS, Windows, Linux, Android and +OpenHarmony: the shell's `wasm-functions` feature, on by default in both +packages (`wasm-lab` is its former name). iOS leaves the runtime out. A build +without the service answers `no service answers "wasm" on this device`. +It answers two kinds of request: - `wasm.functions`: the functions the app's modules export, how each module loaded (compiled, or from the cache, and how long it took) and how each @@ -171,9 +181,8 @@ former name). Builds for Windows, iOS and OpenHarmony leave the runtime out The code always comes from the calling app's own admitted, digest-checked bundle, never from an argument, and no app reaches another app's functions. -The Card runner's gate and the tool executor require the `wasm` capability, -and the service checks the admitted manifest's grant again before it loads -anything. +The service rechecks bundle admission and app/profile identity before loading +or executing code. Capability declarations do not authorize execution. Every call to a module gets a fresh instance: memory, globals, tables and log lines from one call never reach the next, including after a success or the @@ -205,13 +214,18 @@ A full queue or no free worker fails the request at once instead of making it wa When App Hub installs or updates an app, the shell compiles its functions into the cache in the background (`wasm_service::warm`): one app at a time, on a -thread of its own, from the admitted bundle and only with the `wasm` grant. The -app's first call then loads from the cache instead of waiting for Cranelift. +thread of its own, from the admitted bundle. When that work has finished and +the cache is promptly readable, the app's first call uses the compiled code. +Cache reads wait at most 50 ms, with at most two readers in flight per process. +A slow or unavailable cache falls back to compiling the verified source without +rewriting that cache entry. The request deadline remains 10 s; source compilation +still takes time. See the [runtime cache notes](../crates/wasm-host/README.md). ### Agent tools An app's `tools.json` may map a tool to one of its functions with -`"host_method": "wasm."`. That needs the `wasm` capability. Unlike +`"host_method": "wasm."`. The tool must be offered by its app; a +`wasm` declaration is not an execution grant. Unlike shared host methods it has no minimum risk and needs no `"private_data": true`: a module sees only the arguments it is given, and a component also its own app's folder, as the app's script does. Neither @@ -226,11 +240,13 @@ to `wasm.find_slots`, `wasm.fuzzy_rank` and `wasm.text_diff`, all `read`. | Linux | Runs. The runtime and service tests pass on x86_64, with traps caught by signals, and Wasm Lab ran in a headless desktop (8 October 2026). | | Android | Runs in Home's default build. Needs libc 0.2.190 or later, which `crates/wasm-host` requires: with 0.2.189 every trap ended the process. Validated on a Redmi Note 12 (ADR 0011) and by the [phone acceptance](#phone-acceptance) on a OnePlus 6. The runtime adds about 7.5 MiB of code; compiling a 433 KiB module takes about 0.4 s on a Snapdragon 685, and loading it from the cache 5–11 ms. | | Windows | Included (ADR 0014). [`wasm-windows.yml`](../.github/workflows/wasm-windows.yml) runs the runtime's tests and compiles the shell with the service on GitHub's `windows-2022`. A desktop run on Windows is **unverified**. | -| iOS | Not included. Apps may not generate code there, so the runtime would use Pulley, as on OpenHarmony; no iOS build with the service has been compiled yet. | +| iOS | Not included and not planned for now (ADR 0014). No iOS build with the service has been compiled. | | OpenHarmony | Included, in Pulley, Wasmtime's interpreter, until OpenHarmony's code-generation policy is known: Cranelift compiles to Pulley's bytecode and nothing runs as native code. Home's OpenHarmony release build compiled with the service (9 October 2026, `cargo-makepad makepad ohos … deveco -p octosense-home --release`); no device has run it (**unverified**). Pulley is about 32 times slower than Cranelift (ADR 0014). | -On a managed Mac, Microsoft Defender holds the first open of a freshly written -cache file for about a second; ADR 0011 has the measurements. +ADR 0011 records earlier cache-open measurements on a managed Mac. The full +desktop rehearsal also observed file-open stalls lasting about 10 s; its stack +sample identifies the file-open wait, not its cause. The bounded cache reader +prevents this optional cache wait from consuming the whole request deadline. ### Wasm Lab @@ -285,9 +301,9 @@ cd phone && cargo test --locked --features mobile-apps -p octosense-shell wasm_s `crates/wasm-host/tests/component-guest`: an unmodified crate (pulldown-cmark) called with JSON, records and snake_case names, state kept on one instance, clocks, randomness and bytes, files only in the - granted folder (also read-only), the storage budget, the deadline and the - memory cap; `fetch`, whose `wasi:http` reaches a local server only under a - granted host, and whose request to a server that never answers ends at the + app folder (also read-only), the storage budget, the deadline and the + memory cap; `fetch`, whose `wasi:http` reaches a local server without a + manifest host allowlist, and whose request to a server that never answers ends at the deadline; `hostcall`, which calls host services through the embedder; and a component that imports sockets refused. - The service tests cover the tool mapping, bounded input, a fresh instance @@ -295,9 +311,9 @@ cd phone && cargo test --locked --features mobile-apps -p octosense-shell wasm_s cancellation and the queue bounds, and the app's own functions answering its tools and its script. For a component they cover typed calls, its instance's state, the app's storage folder and its quota, an app without - the storage capability, a component shipped as a system app through App - Hub's admission, requests that reach only the app's hosts, host calls that - reach only its granted services, and an installed app's first call + available storage area, a component shipped as a system app through App + Hub's admission, network calls without a manifest allowlist, host calls + under the app's identity and service boundaries, and an installed app's first call loading from the cache. - `cargo run --release -p octosense-wasm-host --example measure_component` prints a component's timings against a module and native Rust. @@ -353,24 +369,26 @@ script tool, and `tools/test-wasm-phone.py` drives that on an assigned device. Its [receipt](../tools/fixtures/wasm-phone-lab/acceptance-oneplus6.json) records 22 of 22 checks passed on a OnePlus 6 with Android 15, including a fresh instance after a success, a guest error, a trap and the deadline. Its -source, e67ce63e, has the wasm service and runtime that `main` has. A live +source, e67ce63e, records the service and runtime tested at that time. A live model, performance and a release APK were not part of it. `state.wasm` rebuilds byte for byte with the fixture README's `encode_phone_fixture` command. ### Not done yet -- No release includes the service yet: the first desktop and Home releases - built from `main` after #400 will. -- iOS: building Home for iOS with the service (in Pulley). +- Releasing and validating binaries with ADR 0014 components and the new + declaration-only behavior. Core-module support already ships in + [desktop rc.2](https://github.com/OctoSense-org/OctoSense/releases/tag/desktop-v0.1.0-rc.2) + and [Home beta.2](https://github.com/OctoSense-org/OctoSense/releases/tag/home-v0.1.0-beta.2). - CPU fairness between apps, and a bound on the disk cache, which nothing evicts. - Compiling system apps' functions ahead: an installed app's are compiled at install, a system app's at its first call. -- Components on a phone: Android and OpenHarmony builds link the runtime, - but no component has run on a device yet (**unverified**). -- Shared components end to end: no App Hub catalog has published one yet, - so no installed app has pinned one (**unverified**). +- OpenHarmony component execution on a device (**unverified**); Android + shared-component execution passed on the OnePlus 6. +- Shared-component installation from the official public catalog and released + host binaries. The private, genuinely attested dry-run catalog passed the + development-host flow; its synthetic entries were not added to the public catalog. - Bytes are slow through JSON: a 1 MiB `list` costs about 18 ms there and back, as base64. @@ -475,4 +493,6 @@ source there. Treat these paths as unmaintained. - [ADR 0013](adr/0013-craft-engines-as-pinned-services.md): the engines behind `photo` and the other craft services. - App Hub's [publishing reference](https://github.com/OctoSense-org/OctoSense-App-Hub/blob/main/docs/PUBLISHING.md): - the `wasm` capability, the `functions` finding and `wasm.` tools. + `wasm` usage disclosure, required ABIs, the `functions` finding and + `wasm.` tools. Current 1.11 policy does not require a matching + capability-family declaration to admit or execute a public call. diff --git a/docs/wasm.zh-CN.md b/docs/wasm.zh-CN.md index 47d3f0cb6..e67c0433b 100644 --- a/docs/wasm.zh-CN.md +++ b/docs/wasm.zh-CN.md @@ -3,14 +3,17 @@ [English](wasm.md) | 简体中文 OctoSense 中有四处用到 WebAssembly。其中一处专为运行应用自己的代码而设计:应用自带的 -函数,标准构建在 macOS、Linux 和 Android 上运行它们,属于有限支持。两个引擎的插件已经关闭: +函数,标准构建在 macOS、Windows、Linux、Android 和 OpenHarmony 上运行它们,属于有限支持。两个引擎的插件已经关闭: `photo` 背后的 photocraft 和 `vector` 背后的 vectorcraft,这两个服务都拒绝所有插件命令。另外两处是 makepad 的内部实现,以及 一个无法编译的浏览器构建。本页逐一说明:运行的是谁的模块、它能接触什么、哪些构建包含它、 -如何检查过。内容对应 #400 之后的 `main`(2026 年 10 月 8 日)。 +如何检查过。本页描述当前源码。桌面版 0.1.0-rc.2 和 Home 0.1.0-beta.2 包含 ADR 0011 核心模块。 +ADR 0014 共享组件及下文的新声明策略已通过专用 macOS/OnePlus 6 测试,发行仍待完成。 +源码、测试与发行状态分别记录。 +参见[应用能力与执行边界](capabilities.zh-CN.md)。 | 位置 | 谁的模块 | 运行时 | 构建 | 状态 | | --- | --- | --- | --- | --- | -| 应用自带的函数:`wasm` 服务([ADR 0011](adr/0011-apps-own-functions-in-webassembly.zh-CN.md)) | 应用自己的应用包,`fns/*.wasm`,需要 `wasm` 能力 | Wasmtime 49,由 Cranelift 编译(`crates/wasm-host`);组件的 WASI 0.2 来自 `wasmtime-wasi` | macOS、Windows、Linux、Android 和 OpenHarmony 上的每个标准桌面版和 Home 构建(特性 `wasm-functions`;OpenHarmony 上在 Wasmtime 的解释器 Pulley 中运行);iOS 不包含 | 已接受,有限支持。测试在 macOS 和 Linux 上通过;一部 OnePlus 6 通过了手机验收。尚无发布版本包含它 | +| 应用自带的函数:`wasm` 服务([ADR 0011](adr/0011-apps-own-functions-in-webassembly.zh-CN.md)) | 已准入应用的 `fns/*.wasm` 或精确固定的共享组件 | Wasmtime 49,由 Cranelift 编译(`crates/wasm-host`);组件的 WASI 0.2 来自 `wasmtime-wasi` | macOS、Windows、Linux、Android 和 OpenHarmony 上的每个标准桌面版和 Home 构建(特性 `wasm-functions`;OpenHarmony 上在 Wasmtime 的解释器 Pulley 中运行);iOS 不包含 | 核心模块已进入桌面 rc.2 和 Home beta.2。共享组件已通过 macOS 和 OnePlus 6 开发宿主验收,发行仍待完成 | | 引擎插件:`photo` 背后的 photocraft、`vector` 背后的 vectorcraft([ADR 0013](adr/0013-craft-engines-as-pinned-services.zh-CN.md)) | 无:两个服务都拒绝所有 `plugin.*` 命令(#398、#405) | wasmi 2,解释器 | 链接进所有带 App Hub 的构建 | 已关闭:任何调用方都不能安装或运行插件 | | Splash 的数学编译器(makepad) | makepad 根据 Splash 代码生成 | makepad-stitch,解释器 | 链接进所有构建 | 未使用:OctoSense 没有链接任何调用它的代码 | | 浏览器中的外壳 | 外壳本身,为 `wasm32-unknown-unknown` 构建 | 浏览器 | 无 | 无法构建 | @@ -44,7 +47,7 @@ OctoSense 中有四处用到 WebAssembly。其中一处专为运行应用自己 ### 组件 -[ADR 0014](adr/0014-app-components-in-webassembly.zh-CN.md)(提议)增加了第二种 +[ADR 0014](adr/0014-app-components-in-webassembly.zh-CN.md)(已接受;发布尚未完成)增加了第二种 `fns/.wasm`:WebAssembly 组件,用 `cargo build --target wasm32-wasip2` 从普通的 Rust crate 构建。服务按文件头区分两者,一个应用包可以同时携带两种。 @@ -59,23 +62,23 @@ Rust crate 构建。服务按文件头区分两者,一个应用包可以同时 - **能接触什么。** 时钟和随机数。它的 stdout 和 stderr 成为应用的日志行,没有环境变量、 参数和 stdin。导入 `wasi:cli`、`wasi:clocks`、`wasi:filesystem`、`wasi:http`、`wasi:io`、 `wasi:random` 和 `octosense:host` 以外任何东西(例如套接字)的组件在加载时被拒绝。 -- **文件。** 应用有 `storage` 能力(有账户的应用还需已登录账户)时,应用自己的存储文件夹, - 即其脚本的 `fs.*` 看到的那个,就是组件的 `/`。设备上的其他东西一概不可见;没有这个能力 - 时没有文件系统。 +- **文件。** 应用可用的存储文件夹,即其脚本的 `fs.*` 看到的那个,就是组件的 `/`。 + 按账户隔离的应用需要当前已连接账户。设备上的其他路径不可见;存储不可用或所需账户已退出时 + 不预打开目录。省略 `storage` 声明不会取消存储。 - **存储配额。** 一次调用可以写入的量,是调用开始时应用配额的剩余部分。超出的写入会在组件 内部失败:`ftruncate` 报告磁盘已满,普通写入报告 I/O 错误,因为 wasi-libc 把任何失败的 流写入都报告为 I/O 错误。这时请求的错误以 "a write was refused: the storage budget is used up" 结尾。改写、截断和删除会归还字节,因此组件可以自己腾出空间。 - **网络。** 组件的 `wasi:http` 请求可以访问任何主机,与设备本身能访问的范围相同:应用的网络 声明(`net`、`network.hosts`)在安装时展示,运行时不强制。2026 年 10 月 8 日的裁定取消了按应用 - 的运行时闸门,边界是操作系统和宿主的 API 表面。(在 Makepad 分支去掉之前,应用的 Splash 脚本 - 仍要经过 Splash 运行时的 URL 闸门。)导入 `wasi:http` 的组件每次调用有 10 秒,而不是 2 秒; + 的运行时闸门,边界是操作系统和宿主的 API 表面。固定版本的 Splash 运行时也不再强制执行 + 清单中的主机列表。导入 `wasi:http` 的组件每次调用有 10 秒,而不是 2 秒; 每个请求的连接、首字节和字节间超时都随调用结束。 - **宿主服务。** 通过 `octosense:host` ([`crates/wasm-host/wit/octosense-host.wit`](../crates/wasm-host/wit/octosense-host.wit)), - 组件可以用 `request(service, args)` 像应用的脚本一样调用宿主服务:在 UI 线程上分派,不检查清单 - 声明的服务族(makepad#118;需要授权的服务自己检查),但绝不打开面板、不询问用户,因此只能调用 - 后台界面可以调用的方法。`wasm.*` + 组件通过 `request(service, args)` 以已准入应用的身份在 UI 线程上分派调用。 + 声明列表不作为执行许可;各服务仍检查账户、同意、审阅和资源边界。组件不能获得面板、权限提示 + 或可信用户输入,只能调用允许后台使用的方法。`wasm.*` 被拒绝,因为应用的工作线程正忙于这次调用;调用的截止时间限制等待时长。 ADR 0014 记录了开销。在 M 系列 Mac 上,313 KiB 的测试组件约 40 毫秒完成编译,从缓存加载 @@ -88,7 +91,7 @@ ADR 0014 记录了开销。在 M 系列 Mac 上,313 KiB 的测试组件约 40 `components` 中按别名、id、确切版本和 BLAKE3 摘要固定它,并在 `requires` 中声明 `wasm-shared-components-v1`。`wasm` 服务把每个固定的组件和应用的 `fns/` 一起加载,并以 `wasm.<别名>.<函数>` 路由它的函数:固定为 `{"as": "md", "id": "org.example.markdown", …}` 时, -`wasm.md.to_html` 调用该组件的 `to-html`。应用得到一个属于自己的实例,使用自己的授权,与 `fns/` 中的 +`wasm.md.to_html` 调用该组件的 `to-html`。应用得到自己的实例、账户和存储边界,与 `fns/` 中的 组件相同;应用也可以完全没有 `fns/`,只调用共享组件。`wasm.functions` 带别名列出这些函数,并说明每个 函数来自哪个组件(`"shared": {"alias", "id", "version", "blake3"}`)。 @@ -98,28 +101,29 @@ ADR 0014 记录了开销。在 M 系列 Mac 上,313 KiB 的测试组件约 40 摘要检查。这里不会下载任何东西:App Hub 的安装程序会先于应用本身下载它固定的组件。组件缺失、被修改或 被撤回时,应用无法启动。 -`wasm_service::tests::a_pinned_shared_component_answers_as_alias_and_function` 用替代的解析器运行这条 -路径。目前还没有目录发布过组件,因此安装固定了组件的应用并在 Shell 中调用它属于**未验证**。 +`wasm_service::tests::a_pinned_shared_component_answers_as_alias_and_function` 是使用替代解析器的定向测试。 +独立的[真实 GitHub 验收](../tools/fixtures/wasm-phone-lab/README.zh-CN.md#真实-github-共享组件验收) +使用真实发布者证明和带管理员证明的私有试运行目录,通过实际商店安装两个应用,再经 Splash 工具调用共享组件。 +macOS 和 OnePlus 6 的原生断言均为 28/28 通过,驱动分别为 12/12 和 13/13 通过。 +公开目录保持不变。这验证的是开发宿主的安装和执行,不是发行二进制或真实模型结果。 ### 模块如何到达设备 -- **商店应用。** App Hub 的审核(应用契约 1.7.0)只接受放在 `fns/.wasm` 的 - `.wasm` 文件(名称为 `[a-z0-9_-]`,最多 64 个字符),必须是核心模块(检查文件的前八个 - 字节),最多 8 个,并且应用必须声明 `wasm` 能力。声明了 `wasm` 能力却没有模块会得到一条 - 警告。审核不读取模块的导入和导出:外壳在加载模块时检查它们。商店会告诉用户: - "Run its own sandboxed functions on this device"(在本设备上运行自带的沙箱函数)。规则见 +- **商店应用。** App Hub 当前审核(应用契约 1.11)接受最多 8 个位于 `fns/.wasm` + 的文件(名称 `[a-z0-9_-]`,最多 64 字符)。它区分核心模块和组件,检查组件导入及所需 ABI, + 并验证共享组件的精确版本、摘要和发布证明。省略 `wasm`、`storage` 或 `net` 不导致准入拒绝。 + 为商店披露准确保留这些声明;运行时仍在加载时校验代码。规则见 App Hub 的[发布参考](https://github.com/OctoSense-org/OctoSense-App-Hub/blob/main/docs/PUBLISHING.md) - (`functions` 检查项和 `wasm` 能力)。 + (`functions` 检查项、所需 ABI 及 `wasm` 用途披露)。 - **系统应用。** 和其他系统应用一样,按摘要从打包方式的系统应用列表打包进外壳。 `desktop/system-apps-wasm-lab.json` 和 `phone/system-apps-wasm-lab.json` 就是标准 列表加上 `wasmlab`。 ### 服务 -`wasm` 宿主服务(`crates/shell/src/wasm_service.rs`)存在于 macOS、Linux 和 Android 上的 -每个标准桌面版和 Home 构建中:即外壳的 `wasm-functions` 特性,两个包都默认开启(`wasm-lab` -是它以前的名字)。Windows、iOS 和 OpenHarmony 的构建不包含这个运行时 -(`crates/shell/Cargo.toml`、`crates/shell/build.rs`),在那里调用会得到 +`wasm` 宿主服务(`crates/shell/src/wasm_service.rs`)存在于 macOS、Windows、Linux、Android +和 OpenHarmony 上的每个标准桌面版和 Home 构建中:外壳的 `wasm-functions` 特性在两个包中 +都默认开启(`wasm-lab` 是旧名)。iOS 不包含运行时。不含服务的构建返回 `no service answers "wasm" on this device`。它回答两类请求: - `wasm.functions`:应用的模块导出了哪些函数,每个模块如何加载(编译,还是取自缓存, @@ -129,8 +133,8 @@ ADR 0014 记录了开销。在 M 系列 Mac 上,313 KiB 的测试组件约 40 (见[组件](#组件))。函数自己返回的错误、陷阱或超时都是这次请求的错误。 代码总是来自发起调用的应用自己的、已准入且摘要校验过的应用包,绝不来自参数;任何应用都 -接触不到别的应用的函数。Card runner 的审核和工具执行器都要求 `wasm` 能力,服务在加载任何 -东西之前还会再次检查已准入清单中的授权。 +接触不到别的应用的函数。服务在加载或执行前复查应用包准入、应用身份和配置目录。 +能力声明不是执行许可。 每次调用模块都使用全新的实例:一次调用的内存、全局变量、表和日志行都不会进入下一次调用, 成功调用或函数自己返回错误之后也一样。组件则在调用之间保留一个实例(见[组件](#组件))。 @@ -158,13 +162,15 @@ ADR 0014 记录了开销。在 M 系列 Mac 上,313 KiB 的测试组件约 40 队列已满或没有空闲的工作线程时,请求会立即失败,不会等待。 App Hub 安装或更新应用时,外壳会在后台把它的函数编译进缓存(`wasm_service::warm`):一次一个 -应用,在单独的线程上,只编译已准入的应用包,并且只在应用有 `wasm` 授权时进行。这样应用的第一次 -调用就从缓存加载,不必等待 Cranelift 编译。 +应用,在单独的线程上,只编译已准入的应用包。当后台编译已完成且缓存可以及时读取时, +应用的第一次调用使用编译好的代码。缓存读取最多等待 50 毫秒,每个进程最多同时保留两个读取线程。 +缓存读取缓慢或不可用时,运行时改为编译已验证的源代码,不覆盖该缓存条目。请求期限仍是 +10 秒,源代码编译本身仍需时间。详见[运行时缓存说明](../crates/wasm-host/README.zh-CN.md)。 ### Agent 工具 应用的 `tools.json` 可以用 `"host_method": "wasm."` 把一个工具映射到自己的 -某个函数。这需要 `wasm` 能力。与共享宿主方法不同,它没有最低风险等级,也不需要 +某个函数。工具必须由应用提供;`wasm` 声明不是执行许可。与共享宿主方法不同,它没有最低风险等级,也不需要 `"private_data": true`:模块只能看到传给它的参数,组件还能看到自己应用的文件夹,与应用的 脚本相同。两者都接触不到共享数据。Wasm Lab 把 `wasmlab.find_slots`、 `wasmlab.rank` 和 `wasmlab.diff` 映射到 `wasm.find_slots`、`wasm.fuzzy_rank` 和 @@ -178,11 +184,12 @@ App Hub 安装或更新应用时,外壳会在后台把它的函数编译进缓 | Linux | 运行。运行时和服务的测试在 x86_64 上通过,陷阱由信号捕获;Wasm Lab 在无头桌面中运行过(2026 年 10 月 8 日)。 | | Android | 在 Home 的默认构建中运行。需要 libc 0.2.190 或更高版本,`crates/wasm-host` 已要求这一点:在 0.2.189 下,每个陷阱都会结束进程。已在 Redmi Note 12 上验证(ADR 0011),并在一部 OnePlus 6 上通过[手机验收](#手机验收)。运行时增加约 7.5 MiB 代码;在 Snapdragon 685 上编译一个 433 KiB 的模块约需 0.4 秒,从缓存加载需 5–11 毫秒。 | | Windows | 包含(ADR 0014)。[`wasm-windows.yml`](../.github/workflows/wasm-windows.yml) 在 GitHub 的 `windows-2022` 上运行运行时的测试,并编译带这个服务的外壳。在 Windows 上运行桌面版**未验证**。 | -| iOS | 不包含。那里的应用不能生成代码,所以运行时会像在 OpenHarmony 上一样使用 Pulley;但还没有编译过带这个服务的 iOS 构建。 | +| iOS | 不包含,目前也不计划支持(ADR 0014)。尚未编译过带此服务的 iOS 构建。 | | OpenHarmony | 包含,在 OpenHarmony 的代码生成策略明确之前于 Wasmtime 的解释器 Pulley 中运行:Cranelift 编译为 Pulley 字节码,没有任何东西以原生代码运行。Home 的 OpenHarmony 发布构建已带着这个服务编译通过(2026 年 10 月 9 日,`cargo-makepad makepad ohos … deveco -p octosense-home --release`);还没有在设备上运行过(**未验证**)。Pulley 比 Cranelift 慢约 32 倍(ADR 0014)。 | -在受管理的 Mac 上,Microsoft Defender 会把新写入的缓存文件的第一次打开拦住约一秒;具体 -测量见 ADR 0011。 +ADR 0011 记录了受管理 Mac 上较早的缓存打开耗时。完整桌面验收还观察到约 10 秒的文件打开阻塞; +线程采样确定了等待发生在文件打开操作,但没有确定原因。有界缓存读取避免了这个可选缓存等待 +耗尽整个请求期限。 ### Wasm Lab @@ -232,8 +239,8 @@ cd phone && cargo test --locked --features mobile-apps -p octosense-shell wasm_s 服务器发出的请求在截止时间结束;`hostcall`,它通过嵌入方调用宿主服务;以及拒绝导入套接字的组件。 - 服务测试覆盖工具映射、输入上限、每次调用使用全新实例、更新或授权变化或撤回之后的撤销、 取消和队列上限,以及应用自己的函数回答它的工具和脚本。对组件,测试覆盖带类型的调用、 - 实例的状态、应用的存储文件夹及其配额、没有存储能力的应用、作为系统应用经 App Hub 准入发布的 - 组件、只能访问应用主机的请求、只能到达已授权服务的宿主调用,以及已安装应用的第一次调用从缓存加载。 + 实例的状态、应用的存储文件夹及其配额、没有可用存储目录的应用、作为系统应用经 App Hub 准入发布的 + 组件、不受清单主机列表限制的请求、保留应用身份和服务边界的宿主调用,以及已安装应用的首次缓存加载。 - `cargo run --release -p octosense-wasm-host --example measure_component` 打印组件 相对于模块和原生 Rust 的耗时。 - `OCTOSENSE_WASM_PULLEY=1 cargo test --locked -p octosense-wasm-host --features pulley` @@ -247,7 +254,7 @@ cd phone && cargo test --locked --features mobile-apps -p octosense-shell wasm_s `phone.yml` 运行第一条命令,服务的测试则作为 Home 测试的一部分运行(`mobile-apps` 包含 `wasm-functions`);`tools/ci-local.sh --linux-host --offload` 在 Linux 构建主机上运行 两者。CI 中的每个桌面版和 Home 构建都包含这个服务,`tools/check-shell-graph.sh` 检查它的 -运行时恰好在 macOS、Linux 和 Android 上链接。 +运行时按当前目标平台配置链接。 ### 在命令行中调用函数 @@ -277,18 +284,19 @@ cargo run -q -p octosense-wasm-host --example wasm_call -- \ `tools/test-wasm-phone.py` 在指定的设备上驱动这一过程。它的 [验收记录](../tools/fixtures/wasm-phone-lab/acceptance-oneplus6.json)显示,在 Android 15 的 OnePlus 6 上 22 项检查全部通过,包括成功、客体错误、陷阱和超时之后都使用全新实例。它的 -源码 e67ce63e 与 `main` 的 wasm 服务和运行时相同。验收不包括实时模型、性能和发布版 APK。 +源码 e67ce63e 记录当时受测的服务和运行时。验收不包括实时模型、性能和发布版 APK。 用该夹具 README 中的 `encode_phone_fixture` 命令可以逐字节重建 `state.wasm`。 ### 尚未完成 -- 还没有任何发布版本包含这个服务:#400 之后从 `main` 构建的第一个桌面版和 Home 发布版 - 将会包含。 -- iOS:带这个服务(在 Pulley 中)构建 Home。 +- 发布并验证包含 ADR 0014 组件和新声明行为的二进制。核心模块支持已随 + [桌面 rc.2](https://github.com/OctoSense-org/OctoSense/releases/tag/desktop-v0.1.0-rc.2) + 和 [Home beta.2](https://github.com/OctoSense-org/OctoSense/releases/tag/home-v0.1.0-beta.2) 发行。 - 应用之间的 CPU 公平调度,以及磁盘缓存的上限:目前没有任何东西会清理它。 - 提前编译系统应用的函数:已安装应用的函数在安装时编译,系统应用的函数在第一次调用时编译。 -- 手机上的组件:Android 和 OpenHarmony 构建链接了运行时,但还没有组件在设备上运行过(**未验证**)。 -- 共享组件的完整流程:还没有 App Hub 目录发布过共享组件,因此还没有已安装的应用固定过它(**未验证**)。 +- OpenHarmony 设备上的组件执行(**未验证**);Android 共享组件执行已在 OnePlus 6 上通过。 +- 通过正式公开目录和发行宿主安装共享组件。带真实证明的私有试运行目录已通过开发宿主流程; + 其中的合成测试条目没有加入公开目录。 - 经 JSON 传字节很慢:1 MiB 的 `list` 以 base64 往返约需 18 毫秒。 ## 引擎插件:`photo` 和 `vector` 服务 @@ -370,4 +378,5 @@ getrandom 0.2 和 0.3、uuid、fs2、ring 以及 aws-lc-sys 都无法为该目 - [ADR 0013](adr/0013-craft-engines-as-pinned-services.zh-CN.md):`photo` 及其他 craft 服务背后的引擎。 - App Hub 的[发布参考](https://github.com/OctoSense-org/OctoSense-App-Hub/blob/main/docs/PUBLISHING.md): - `wasm` 能力、`functions` 检查项和 `wasm.` 工具。 + `wasm` 用途披露、所需 ABI、`functions` 检查项和 `wasm.` 工具。 + 当前 1.11 策略不以匹配的能力族声明作为公共调用准入或执行的条件。 diff --git a/docs/windows-camera-capture.md b/docs/windows-camera-capture.md index 945fc7e30..076a31e9b 100644 --- a/docs/windows-camera-capture.md +++ b/docs/windows-camera-capture.md @@ -9,11 +9,12 @@ It still needs the host's camera grant, OS camera access, and its storage jail. The backend does not add permissions, select another app's storage, or open a camera in response to a background tool call. -For this batch, declare **camera and storage without library**. The shared -widget currently turns a `library.write` grant into a request to export every -capture. Windows gallery export is unsupported and is refused before capture. -An unrestricted native CameraPreview may therefore request unsupported gallery -export by default. This change does not alter shared widget intent semantics. +Capture intent is explicit: `capture()` saves locally, as does +`capture({library: false})`. Manifest declarations do not request automatic +gallery export. `capture({library: true})` reports unsupported on Windows before +capture; the existing export adapter is OpenHarmony-only. On recording-capable +platforms, audio is requested with `record_start({audio: true})` and still needs +microphone consent. Without options, recording is silent and local. Video recording, pause, resume and stop also report unsupported explicitly. A lazy, long-lived worker accepts at most one still request per camera. The diff --git a/docs/windows-camera-capture.zh-CN.md b/docs/windows-camera-capture.zh-CN.md index de3f75f9a..d617516ef 100644 --- a/docs/windows-camera-capture.zh-CN.md +++ b/docs/windows-camera-capture.zh-CN.md @@ -7,10 +7,11 @@ Windows 运行时补丁为已打开的 Media Foundation 相机增加 只有 JPEG 保存成功才收到 `on_capture`。应用仍需要宿主相机授权、OS 相机权限和 自己的存储沙箱。后端不会增加授权、选择其他应用的存储,也不会因后台工具调用而打开相机。 -本批次应声明 **camera 和 storage,不声明 library**。共享控件目前把 -`library.write` 授权当成每次拍摄都导出图库的请求。Windows 图库导出尚不支持, -会在拍摄前明确拒绝。因此,无权限限制的原生 CameraPreview 默认也可能请求不支持的 -图库导出。本补丁不改变共享控件表达保存意图的方式。录像开始、暂停、继续和停止也明确报告不支持。 +拍摄意图由参数明确表达:`capture()` 和 `capture({library: false})` 都只保存到本地。 +清单声明不会自动触发图库导出。Windows 在拍摄前明确拒绝 +`capture({library: true})`;现有图库导出适配器仅支持 OpenHarmony。 +支持录像的平台通过 `record_start({audio: true})` 请求声音,仍需麦克风同意。 +不传参数时,录像默认为静音且只存本地。Windows 的录像开始、暂停、继续和停止也明确报告不支持。 每个相机按需启动长期工作线程,同时只接受一个静态拍摄请求。Media Foundation 回调只把请求后的下一帧复制到有界队列。JPEG 解码、编码和文件写入都在工作线程执行。 diff --git a/native-apps.json b/native-apps.json index bc9f63134..ef1cee455 100644 --- a/native-apps.json +++ b/native-apps.json @@ -114,7 +114,7 @@ "id": "apphub", "feature": "app-hub", "crate": "octosense-app-hub-app", - "source": { "git": "https://github.com/OctoSense-org/OctoSense-App-Hub", "rev": "40abb23b50d5c3e546c50d1d91ab27258a203eaf", "local": null }, + "source": { "git": "https://github.com/OctoSense-org/OctoSense-App-Hub", "rev": "8347a489141c0e24cab564883db7c4592935a8b2", "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 242214d89..5b9836137 100644 --- a/runtime-patches.lock.json +++ b/runtime-patches.lock.json @@ -4,7 +4,7 @@ "base_revision": "d653bc4493619ab979ccff165cf48c0c63b77d19", "patch": "tools/runtime-patches/makepad-settings.patch", "sha256": "3a79cddddc89dc4d064a9f0bdd17dc8111dcab9c796a334d95f61ecb9d5011aa", - "tree": "0e7e0dacab19db708793e63ded641cd7b3ea319c", + "tree": "a6fae94aa4d233503b14bc6d0a36bcbc2b264b44", "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.", @@ -46,7 +46,8 @@ "Video numeric controls accept both integer and floating Splash arguments while refusing nonnumeric, nonfinite and out-of-range values; native acceptance exposed the integer-seek regression.", "Android Video seeks to the closest frame on API26+ instead of the previous keyframe; older backends retain a bounded legacy fallback.", "A native host may stage up to 64 MiB into an app's jail (MAX_IMPORT_BYTES; prepare_import and commit_import), so files.import can take documents above the 1 MiB script write limit. Staged bytes never enter the script heap; the jail's byte quota and entry cap still apply, and script writes, photo picks and exports keep 1 MiB.", - "An OS resume restarts only the Video playback its pause stopped, including a start it cancelled; a player paused by the person, or paused or stopped by its app during the pause, stays paused (#406)." + "An OS resume restarts only the Video playback its pause stopped, including a start it cancelled; a player paused by the person, or paused or stopped by its app during the pause, stays paused (#406).", + "Camera capture and recording use explicit per-call boolean audio/library options, defaulting to silent app-local output. Permission availability never opts into recording audio or exporting captures; async microphone checks retain capture intent and background provenance. Unsupported library export fails before capture." ], "stacked": [ { @@ -180,6 +181,10 @@ { "patch": "tools/runtime-patches/makepad-video-resume-keeps-pause.patch", "sha256": "a9350a3e24d7d3a6a76bfd444860452f769bc834cd796d1a92c8e24c0be1c802" + }, + { + "patch": "tools/runtime-patches/makepad-camera-capture-intent.patch", + "sha256": "d056b76d2eb6b55aa9c96447a49fc90d32a9a235b6bc9a1ff125902a73c28208" } ] } diff --git a/tools/connected-e2e/native.py b/tools/connected-e2e/native.py index c2dc40a00..58bfeda61 100644 --- a/tools/connected-e2e/native.py +++ b/tools/connected-e2e/native.py @@ -15,14 +15,14 @@ class Native: - def __init__(self, binary, arguments, evidence, name): + def __init__(self, binary, arguments, evidence, name, *, env=None, cwd=None): self.evidence = Path(evidence) self.name = name self.log_path = self.evidence / (name + '.log') self.log = self.log_path.open('w') self.child = subprocess.Popen([str(binary), *arguments, '--remote'], - env={**os.environ, 'MAKEPAD_HIDE_WINDOWS': '1'}, - stdout=self.log, stderr=self.log) + env={**(os.environ if env is None else env), 'MAKEPAD_HIDE_WINDOWS': '1'}, + cwd=cwd, stdout=self.log, stderr=self.log) self.endpoint = None self.actions = [] deadline = time.monotonic() + 25 diff --git a/tools/fixtures/host-api-lab/ANDROID.md b/tools/fixtures/host-api-lab/ANDROID.md index c5a560d6d..96793c481 100644 --- a/tools/fixtures/host-api-lab/ANDROID.md +++ b/tools/fixtures/host-api-lab/ANDROID.md @@ -6,7 +6,7 @@ English | [简体中文](ANDROID.zh-CN.md) The separate [regression record](evidence/public-api-v0.4/regression.json) passed 1,051 shared-shell tests, three packaging checks and the native fixture build at `53bab40f`. SDK 1.10.0 is published; [host distribution status](../../../docs/host-os-api-status.md) is separate. These historical receipts do not validate final Desktop RC2 packages or update the published Home beta.1. The earlier [24-check batch record](evidence/os-api-batch1/receipt.json) retains its then-pending phone status, and the original 14-check record below is preserved. -The historical [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. +The historical [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. That historical fixture rejected undeclared capabilities as well as cross-account calls, undeclared tools, invalid arguments, uncompiled Rust functions, background permission prompts and calls after the tool endpoint closed. The current fixture instead requires native microphone status to be readable without a declaration, with app consent still false, and requires exact background recording refusal. Fresh receipts are needed to validate these new semantics. 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. diff --git a/tools/fixtures/host-api-lab/ANDROID.zh-CN.md b/tools/fixtures/host-api-lab/ANDROID.zh-CN.md index a3861b085..56c8949cc 100644 --- a/tools/fixtures/host-api-lab/ANDROID.zh-CN.md +++ b/tools/fixtures/host-api-lab/ANDROID.zh-CN.md @@ -6,7 +6,7 @@ 单独的[回归记录](evidence/public-api-v0.4/regression.json)在 `53bab40f` 上通过 1,051 项共享 Shell 测试、三个打包检查及原生测试应用构建。SDK 1.10.0 已发布;[宿主分发状态](../../../docs/host-os-api-status.zh-CN.md)单独记录。这些历史回执不验证最终 Desktop RC2 发行包,也不会更新已发布的 Home beta.1。早先的 [24 项批次记录](evidence/os-api-batch1/receipt.json)保留其当时待执行的真机状态,下文原始 14 项记录也继续保留。 -历史[验收记录](evidence/android/receipt.json)确认:**OnePlus 6、Android 15,14 项检查全部通过**。[原生结果](evidence/android/native-result.json)来自实际手机进程。普通签名应用调用自己的 Splash 工具,通过生产环境宿主服务发现 API 并读取 Android 摄像头权限状态。测试同时验证跨账户调用、未声明的能力和工具、无效参数、未编译进宿主的 Rust 函数、后台权限弹窗以及工具关闭后的调用都会被拒绝。 +历史[验收记录](evidence/android/receipt.json)确认:**OnePlus 6、Android 15,14 项检查全部通过**。[原生结果](evidence/android/native-result.json)来自实际手机进程。普通签名应用调用自己的 Splash 工具,通过生产环境宿主服务发现 API 并读取 Android 摄像头权限状态。当时的测试验证未声明的能力、跨账户调用、未声明的工具、无效参数、未编译进宿主的 Rust 函数、后台权限弹窗以及工具关闭后的调用都会被拒绝。当前测试改为要求未声明麦克风能力时仍可读取原生权限状态、应用授权仍为 false,并精确验证后台录音被拒绝。新语义需要新的验收回执。 这验证了原生应用、工具和宿主服务之间的执行链路。测试没有启动模型或 peer agent,没有拍照、批准权限、登录提供商,也不能证明物理输入审批。测试完成后只停止了自己的测试包,没有修改正常 Home 或个人资料。 diff --git a/tools/fixtures/host-api-lab/README.md b/tools/fixtures/host-api-lab/README.md index 884fe3ddc..91f20c907 100644 --- a/tools/fixtures/host-api-lab/README.md +++ b/tools/fixtures/host-api-lab/README.md @@ -10,7 +10,7 @@ A call takes this path: ```text temporary signed catalog → Store install / prepared launch - → isolated app with admitted capabilities + → isolated app with verified bundle identity → apilab.inspect → app_tool(name, call_id) → host.request("camera.permission.status", ...) → Rust DeviceService → Makepad native permission query → macOS @@ -31,8 +31,8 @@ composition without an account and background sending. Mail uses a synthetic transport in this fixture. It cannot deliver a message. Nine further checks discover photo selection, text sharing, playback, recording and the Video controls runtime ABI, -then refuse background media requests and recording without the microphone -capability. Sharing is advertised only on Android. No media device is opened. +then refuse background media requests, including recording. Microphone +permission status remains readable without a capability declaration or app consent. Sharing is advertised only on Android. No media device is opened. Together, these twenty checks supplement the ten OS batch checks below. They do not prove reading or writing real calendars, physical approval, or SMTP delivery. The [Mac receipt](evidence/public-api-v0.4/macos.json) records all 30 checks passing; the [OnePlus 6 receipt](evidence/public-api-v0.4/oneplus6.json) records all 44 Android checks passing, including the original 14 checks. These records bind the source and runtime hashes they name. The previous 14- and 24-check receipts remain historical records. @@ -61,24 +61,25 @@ The script copies the fixture, captures its native preview as the listing screen - The tool reads a real native permission status and updates the live screen. - A button on the app's screen reaches the same host service. - `runtime.describe` finds a compiled API, and reports a custom function that the host lacks as unavailable, without running anything. -- Without the `microphone` capability, the app cannot read the microphone status. +- Without a `microphone` declaration, the app reads native microphone status while `app_consent` stays false. Recording from the app tool is refused by the foreground-only boundary. - The tool's asynchronous host callback cannot open a permission sheet. - File status/import/export and location sampling are discoverable; binary storage is advertised as a runtime ABI, not a `host.request` method. - The live contained VM round-trips bytes `0, 127, 128, 255` through `fs.write_bytes` / `fs.read_bytes` and removes its temporary app file. -- File status reports the storage grant and byte limit; background import and export are refused before opening native UI. -- Without app consent, location sampling returns the exact `authorization_required` refusal from the runtime's consent gate. Its API descriptor also declares it foreground-only. +- File status reports the app's bounded storage availability and byte limit; background import and export are refused before opening native UI. +- The host binds the admitted app identity before evaluating its source. The live native storage handle exists for that identity and is refused for a different app. This adds `native_storage_identity_scoped` to the current check set; historical receipts above do not cover it. +- Location sampling from the tool's background callback returns the exact `location.sample is unavailable to agents/background surfaces` refusal. Its API descriptor also declares it foreground-only. - Calls for the wrong account, for an undeclared tool or with invalid input are refused. - After the app that owns the tool closes, a call fails with `app_not_running`. -From a host callback, which keeps the tool's background provenance, the tool deliberately calls `camera.permission.request` to prove that App Hub refuses it; nothing in the fixture can approve a permission. The capability, the app's consent and the OS permission stay separate checks. The Mac may already have granted OctoSense the camera permission, but the new profile must still report `app_consent: false`. +From a host callback, which keeps the tool's background provenance, the tool deliberately calls `camera.permission.request` to prove that App Hub refuses it; nothing in the fixture can approve a permission. Verified app identity, the app's consent and the OS permission stay separate checks. Capability declarations describe intended use; they do not authorize device access. The Mac may already have granted OctoSense the camera permission, but the new profile must still report `app_consent: false`. -The native location check proves refusal **without app consent**; it does not reach the host's foreground gate. The separate `platform_services::tests::location_sampling_broker_lifecycle` regression creates app consent in an isolated test store, dispatches a background sampling request through the real service broker, and requires the exact background refusal, with no queued request, permission check, consent review or running location sampler. That regression uses simulated permission results for its later lifecycle checks; it does not prove physical permission approval or a live location fix. +The current native location check proves that the host retains the callback's background provenance and refuses sampling before starting native work. Its receipt key remains `location_without_consent_refused` for compatibility, but its assertion now requires the exact background refusal above; the earlier `authorization_required` result belongs to the historical receipts below. The separate `platform_services::tests::location_sampling_broker_lifecycle` regression establishes app consent in an isolated test store and still requires that background refusal, with no queued request, permission check, consent review or running location sampler. That regression uses simulated permission results for its later lifecycle checks; neither check proves physical permission approval or a live location fix. The 0.4 release-mode Mac run passed **30/30 named OS and public-service checks**, signed tool completion, live UI updates and native button interaction at source `53bab40f`, runtime `fc938badf`. All three native captures were inspected; both owned host processes exited cleanly. The [Mac receipt](evidence/public-api-v0.4/macos.json) binds the result to source, runtime and binary hashes. The [OnePlus 6 run](evidence/public-api-v0.4/oneplus6.json) passed **44/44 checks on Android 15**, using an APK built from production source `13e3b21a` with the same runtime; the intervening `53bab40f` change is test-only and excluded from that APK. Its isolated package was force-stopped afterward. The separate [regression receipt](evidence/public-api-v0.4/regression.json) records **1,051/1,051 shared-shell tests, zero failures or ignored tests**, three packaging checks (desktop default/mobile and Home mobile), and the native fixture build at `53bab40f`. These receipts do not validate later Android Video Java changes or real-account/hardware actions. SDK 1.10.0 is published; [host distribution status](../../../docs/host-os-api-status.md) is separate. These historical receipts do not validate final Desktop RC2 packages or update the published Home beta.1. -The [earlier batch receipt](evidence/os-api-batch1/receipt.json) records ten OS checks at source `807f2bc8`; `evidence/android/` retains the original 14-check phone record. These historical results do not validate the current source. +The [earlier batch receipt](evidence/os-api-batch1/receipt.json) records ten OS checks at source `807f2bc8`; `evidence/android/` retains the original 14-check phone record. These historical results do not validate the current source. The current fixture replaces declaration-denial assertions with readable microphone status, absent app consent and exact background recording/location refusal; those semantics require a fresh receipt. **Previously 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. @@ -86,4 +87,4 @@ The [earlier batch receipt](evidence/os-api-batch1/receipt.json) records ten OS ## Reuse the pattern -Copy the tool declaration and the `app_tool` hook into your app, and request only the capabilities it uses. The hook runs in the open app's live VM, so a closed app cannot run these tools. A `host_method` tool instead maps straight to an allowlisted Rust service method. Neither mechanism compiles new native code from the bundle. List the methods the app needs in `host_api.required`, so that App Hub keeps it off incompatible hosts, and use `host_api.optional` with `runtime.describe` to offer a fallback when a method is missing. App Hub's [script tool reference](https://github.com/OctoSense-org/OctoSense-App-Hub/blob/main/docs/PUBLISHING.md#script-tool-execution-script-tools-v1) documents the hook, and [ADR 0012](../../../docs/adr/0012-app-host-api-discovery.md) describes the host boundary. +Copy the tool declaration and the `app_tool` hook into your app, and describe the capabilities it uses. Device consent and OS permission are separate runtime requirements. The hook runs in the open app's live VM, so a closed app cannot run these tools. A `host_method` tool instead maps straight to an allowlisted Rust service method. Neither mechanism compiles new native code from the bundle. List the methods the app needs in `host_api.required`, so that App Hub keeps it off incompatible hosts, and use `host_api.optional` with `runtime.describe` to offer a fallback when a method is missing. App Hub's [script tool reference](https://github.com/OctoSense-org/OctoSense-App-Hub/blob/main/docs/PUBLISHING.md#script-tool-execution-script-tools-v1) documents the hook, and [ADR 0012](../../../docs/adr/0012-app-host-api-discovery.md) describes the host boundary. diff --git a/tools/fixtures/host-api-lab/README.zh-CN.md b/tools/fixtures/host-api-lab/README.zh-CN.md index 68cd9ab9b..067f2f4d1 100644 --- a/tools/fixtures/host-api-lab/README.zh-CN.md +++ b/tools/fixtures/host-api-lab/README.zh-CN.md @@ -10,7 +10,7 @@ ```text 临时签名目录 → Store 安装 / 校验后启动 - → 带有已准入能力的隔离应用 + → 应用包身份通过验证的隔离应用 → apilab.inspect → app_tool(name, call_id) → host.request("camera.permission.status", ...) → Rust DeviceService → Makepad 原生权限查询 → macOS @@ -23,7 +23,7 @@ 当前版本增加公共 `device_calendar` 和 `mail` API,需要已发布的 [app-contract 1.10.0](https://crates.io/crates/octosense-app-contract/1.10.0) 声明及兼容的宿主实现,并保留之前的 `files` 能力。仅安装 SDK 不会提供这些宿主实现。新增十一项检查涵盖四个方法描述、原生日历权限状态、未获应用同意时拒绝列出日历、拒绝后台权限申请/日历选择/事件修改,以及无账户时拒绝准备邮件和拒绝后台发送。测试邮件服务使用合成传输,无法投递真实邮件。 -另外九项检查发现照片选择、文字分享、播放、录音 API 和 Video 控制运行时 ABI,并验证后台媒体请求和未声明麦克风能力的录音请求被拒绝。文字分享仅在 Android 上声明可用,不打开任何媒体设备。共二十项检查补充下文的十项 OS API 检查,不证明真实日历读写、亲手批准或 SMTP 投递。[Mac 回执](evidence/public-api-v0.4/macos.json)记录全部 30 项检查通过;[OnePlus 6 回执](evidence/public-api-v0.4/oneplus6.json)记录全部 44 项 Android 检查通过,其中包含原有 14 项。这些记录绑定各自列出的源码和运行时摘要;此前 14 项和 24 项记录继续作为历史证据保留。 +另外九项检查发现照片选择、文字分享、播放、录音 API 和 Video 控制运行时 ABI,并验证后台媒体请求(包括录音)被拒绝。未声明麦克风能力、未获得应用授权时,仍能读取麦克风权限状态。文字分享仅在 Android 上声明可用,不打开任何媒体设备。共二十项检查补充下文的十项 OS API 检查,不证明真实日历读写、亲手批准或 SMTP 投递。[Mac 回执](evidence/public-api-v0.4/macos.json)记录全部 30 项检查通过;[OnePlus 6 回执](evidence/public-api-v0.4/oneplus6.json)记录全部 44 项 Android 检查通过,其中包含原有 14 项。这些记录绑定各自列出的源码和运行时摘要;此前 14 项和 24 项记录继续作为历史证据保留。 ## 在 macOS 上运行 @@ -49,24 +49,25 @@ python3 tools/test-host-api-native.py --hub target/debug/hub - 工具读取真实的原生权限状态,并更新正在运行的界面。 - 应用界面上的按钮也能调用同一个宿主服务。 - `runtime.describe` 能发现已编译的 API;对于宿主没有的自定义函数,它报告不可用,而且不执行任何代码。 -- 没有 `microphone` 能力时,应用无法读取麦克风权限状态。 +- 没有 `microphone` 声明时,应用仍能读取原生麦克风权限状态,且 `app_consent` 保持为 false。来自应用工具的录音请求由仅限前台调用的边界拒绝。 - 工具的异步宿主回调不能打开权限面板。 - 能发现文件状态、导入、导出和定位采样 API;二进制存储被标明为运行时 ABI,而不是可通过 `host.request` 调用的方法。 - 真实的隔离 VM 用 `fs.write_bytes` / `fs.read_bytes` 往返读写字节 `0、127、128、255`,随后删除自己的临时文件。 -- 文件状态返回存储授权和字节上限;后台导入、导出在原生界面打开前被拒绝。 -- 未获得应用授权时,定位采样返回运行时授权检查的确切 `authorization_required` 错误。它的 API 描述也必须标明仅限前台调用。 +- 文件状态返回应用的有界存储可用性和字节上限;后台导入、导出在原生界面打开前被拒绝。 +- 来自工具后台回调的定位采样返回确切的 `location.sample is unavailable to agents/background surfaces` 拒绝错误。它的 API 描述也必须标明仅限前台调用。 +- 宿主在执行应用源码前绑定准入的应用身份。原生存储句柄仅对该身份可用,其他应用身份被拒绝。当前检查集新增 `native_storage_identity_scoped`;上述历史回执不覆盖这项检查。 - 账户不对、工具未声明或输入无效的调用都会遭到拒绝。 - 持有工具的应用关闭后,调用以 `app_not_running` 失败。 -工具会在宿主回调中刻意调用 `camera.permission.request`;这个回调保留了工具的后台来源,以此证明 App Hub 会拒绝这次申请。测试示例中没有任何环节能批准权限。能力、应用授权和系统权限始终是三项独立的检查。这台 Mac 可能早已授予 OctoSense 摄像头权限,但全新的测试配置目录仍必须报告 `app_consent: false`。 +工具会在宿主回调中刻意调用 `camera.permission.request`;这个回调保留了工具的后台来源,以此证明 App Hub 会拒绝这次申请。测试示例中没有任何环节能批准权限。应用身份、应用授权和系统权限是独立的检查。能力声明描述预期用途,不授权设备访问。这台 Mac 可能早已授予 OctoSense 摄像头权限,但全新的测试配置目录仍必须报告 `app_consent: false`。 -原生定位检查证明的是**没有应用授权时拒绝调用**,调用尚未到达宿主的前台限制检查。独立的 `platform_services::tests::location_sampling_broker_lifecycle` 回归测试会在隔离的测试存储中建立应用授权,再通过真实的服务代理发起后台采样请求。它要求返回确切的后台拒绝错误,而且没有排队请求、权限检查、授权审阅或正在运行的定位采样。该回归测试后续使用模拟权限结果检查生命周期,并不证明实际批准了权限或获得了真实定位。 +当前原生定位检查证明宿主保留回调的后台来源,并在启动原生工作之前拒绝采样。为兼容原有记录,回执键仍为 `location_without_consent_refused`,但断言现在要求上述确切的后台拒绝错误;早先的 `authorization_required` 结果属于下文的历史回执。独立的 `platform_services::tests::location_sampling_broker_lifecycle` 回归测试会在隔离测试存储中建立应用授权,并仍要求该后台拒绝错误,而且没有排队请求、权限检查、授权审阅或正在运行的定位采样。该回归测试后续使用模拟权限结果检查生命周期;两种检查均不证明实际批准了权限或获得了真实定位。 0.4 的 release 模式 Mac 运行在源码 `53bab40f`、运行时 `fc938badf` 上通过 **30/30 项具名 OS 和公共服务检查**,以及签名工具完成、实时 UI 更新和原生按钮操作。三张原生截图均已审视,两个测试宿主进程均正常退出。[Mac 回执](evidence/public-api-v0.4/macos.json)用源码、运行时和二进制摘要绑定结果。[OnePlus 6 运行](evidence/public-api-v0.4/oneplus6.json)在 **Android 15 上通过 44/44 项检查**,APK 使用生产源码 `13e3b21a` 和相同运行时;构建期间的 `53bab40f` 改动仅影响测试代码,不包含在该 APK 中。完成后已强制停止独立测试包。 单独的[回归回执](evidence/public-api-v0.4/regression.json)记录 `53bab40f` 上 **1,051/1,051 项共享 Shell 测试通过,失败和忽略项均为零**,同时通过三个打包检查(桌面默认/mobile、Home mobile)及原生测试应用构建。这些回执不验证之后的 Android Video Java 改动,也不验证真实账户或硬件操作。SDK 1.10.0 已发布;[宿主分发状态](../../../docs/host-os-api-status.zh-CN.md)单独记录。这些历史回执不验证最终 Desktop RC2 发行包,也不会更新已发布的 Home beta.1。 -[早先批次记录](evidence/os-api-batch1/receipt.json)记录源码 `807f2bc8` 的十项 OS 检查;`evidence/android/` 保留原始 14 项手机记录。这些历史结果不能验证当前源码。 +[早先批次记录](evidence/os-api-batch1/receipt.json)记录源码 `807f2bc8` 的十项 OS 检查;`evidence/android/` 保留原始 14 项手机记录。这些历史结果不能验证当前源码。当前测试将“未声明即拒绝”改为麦克风状态可读、应用尚未授权、后台录音与定位精确拒绝;这些语义需要新的验收回执。 **此前已验证**:`.github/workflows/desktop.yml` 的 `native-host-api` 任务在 GitHub `macos-14` 运行器上,为添加本测试示例的改动运行了上述命令(另加 `--output` 指定证据目录),全部通过。 @@ -74,4 +75,4 @@ python3 tools/test-host-api-native.py --hub target/debug/hub ## 复用这一模式 -把工具声明和 `app_tool` 钩子复制到你的应用中,并且只申请真正用到的能力。钩子在已打开应用的 VM 中运行,所以应用关闭后无法执行这些工具。`host_method` 工具则直接映射到白名单中的某个 Rust 服务方法。两种机制都不会从应用包编译新的原生代码。把应用必需的方法写进 `host_api.required`,App Hub 就不会把它装到不兼容的宿主上;再用 `host_api.optional` 配合 `runtime.describe`,在缺少某个方法时提供降级路径。App Hub 的[脚本工具参考](https://github.com/OctoSense-org/OctoSense-App-Hub/blob/main/docs/PUBLISHING.zh-CN.md#脚本工具执行script-tools-v1)介绍了这个钩子,[ADR 0012](../../../docs/adr/0012-app-host-api-discovery.zh-CN.md) 说明了宿主边界。 +把工具声明和 `app_tool` 钩子复制到你的应用中,并描述真正用到的能力。设备访问仍需运行时应用授权和系统权限。钩子在已打开应用的 VM 中运行,所以应用关闭后无法执行这些工具。`host_method` 工具则直接映射到白名单中的某个 Rust 服务方法。两种机制都不会从应用包编译新的原生代码。把应用必需的方法写进 `host_api.required`,App Hub 就不会把它装到不兼容的宿主上;再用 `host_api.optional` 配合 `runtime.describe`,在缺少某个方法时提供降级路径。App Hub 的[脚本工具参考](https://github.com/OctoSense-org/OctoSense-App-Hub/blob/main/docs/PUBLISHING.zh-CN.md#脚本工具执行script-tools-v1)介绍了这个钩子,[ADR 0012](../../../docs/adr/0012-app-host-api-discovery.zh-CN.md) 说明了宿主边界。 diff --git a/tools/fixtures/host-api-lab/bundle/main.splash b/tools/fixtures/host-api-lab/bundle/main.splash index c8acbfcb8..93bc4c944 100644 --- a/tools/fixtures/host-api-lab/bundle/main.splash +++ b/tools/fixtures/host-api-lab/bundle/main.splash @@ -3,7 +3,7 @@ let calls = 0 fn show_status(data) { calls = calls + 1 ui.status.set_text("Native OS permission: " + data.os_permission) - ui.policy.set_text("App capability: declared | App consent: " + data.app_consent.to_string()) + ui.policy.set_text("App identity: admitted | App consent: " + data.app_consent.to_string()) ui.count.set_text("Completed native queries: " + calls.to_string()) } fn read_status() { @@ -118,8 +118,9 @@ fn app_tool(name, call_id) { host.request("camera.permission.status", {}, fn(camera) { if !camera.is_ok { mod.app_tools.fail(call_id, camera.error); return } show_status(camera.data) - // No microphone capability is declared. This must be refused. + // Omitted disclosure does not block status. It must not grant consent. host.request("microphone.permission.status", {}, fn(microphone) { + if !microphone.is_ok { mod.app_tools.fail(call_id, microphone.error); return } // A tool callback must retain its background provenance. host.request("camera.permission.request", {}, fn(prompt) { ui.safety.set_text("Permission checks complete; no approval sheet opened") @@ -129,8 +130,7 @@ fn app_tool(name, call_id) { camera: camera.data discovery: discovery.data missing: missing.data - microphone_allowed: microphone.is_ok - microphone_error: microphone.error + microphone: microphone.data background_prompt_allowed: prompt.is_ok background_error: prompt.error calls: calls @@ -147,7 +147,7 @@ SolidView {width: Fill height: Fill flow: Down padding: 28 spacing: 20 draw_bg.c SolidView {width: Fill height: Fit flow: Down padding: 20 spacing: 16 draw_bg.color: #xffffff Label {text: "CAMERA PERMISSION" draw_text.color: #x48566b draw_text.text_style.font_size: 12} status := Label {width: Fill text: "Waiting for a signed native query" draw_text.color: #x17243b draw_text.text_style.font_size: 20} - policy := Label {width: Fill text: "The capability and OS permission are separate checks." draw_text.color: #x48566b} + policy := Label {width: Fill text: "App consent and OS permission are separate checks." draw_text.color: #x48566b} count := Label {text: "Completed native queries: 0" draw_text.color: #x48566b} refresh := Button {text: "Read permission status" on_click: || read_status()} } diff --git a/tools/fixtures/host-api-lab/bundle/tools.json b/tools/fixtures/host-api-lab/bundle/tools.json index 4b50b0cf0..5b6615835 100644 --- a/tools/fixtures/host-api-lab/bundle/tools.json +++ b/tools/fixtures/host-api-lab/bundle/tools.json @@ -21,8 +21,7 @@ "camera", "discovery", "missing", - "microphone_allowed", - "microphone_error", + "microphone", "background_prompt_allowed", "background_error", "calls", @@ -42,12 +41,6 @@ "missing": { "type": "object" }, - "microphone_allowed": { - "type": "boolean" - }, - "microphone_error": { - "type": "string" - }, "background_prompt_allowed": { "type": "boolean" }, @@ -63,6 +56,9 @@ }, "public_services": { "type": "object" + }, + "microphone": { + "type": "object" } }, "additionalProperties": false diff --git a/tools/fixtures/wasm-phone-lab/README.md b/tools/fixtures/wasm-phone-lab/README.md index 6b7c5ac41..f71510d21 100644 --- a/tools/fixtures/wasm-phone-lab/README.md +++ b/tools/fixtures/wasm-phone-lab/README.md @@ -27,3 +27,69 @@ cargo run --locked --offline -p octosense-wasm-host --example encode_phone_fixtu The guest deliberately remembers input if its instance is reused. Passing requires the host to create a new instance, including after success. Earlier 19-check evidence used explicit result fields and did not prove raw response forwarding; the repaired raw path adds that check, and the reproducible harness adds two compiled-identity checks. The [OnePlus 6 receipt](acceptance-oneplus6.json) records **22/22 checks passed** on Android 15, built from source `e67ce63ebca6054961899691e1644a54c2b4f081` with runtime tree `0fc8e29e2411fd7eb94d845ba16a261d2ebc9dfc`. The build and driver commands above were executed with local tool paths. Live model/peer relay, performance and release APK behavior remain unverified. The listing image is a separate native macOS capture of this fixture after its signed app tool completed, made in a hidden Makepad window from source `d3535a95`. It is not a phone screenshot and does not extend the 22-check OnePlus receipt to the later screenshot-only change. The image and refreshed fixture digest replace the earlier unrelated Host API Lab image. + +## Real GitHub shared-component acceptance + +The same native host also accepts a mirror containing a genuine GitHub-attested +`catalog-v2.json`, its two reviewed consumer bundles and pinned shared components +from [the synthetic publisher](https://github.com/ymote/octosense-component-demo). +This mode uses the default GitHub trust channel and actual Store admission; +it creates no legacy signatures and grants no additional capability. +The first consumer has no capability declarations; the second declares `wasm` +and `storage`. Both call the same component bytes in their own retained instance +and private storage. Four alternating app-tool turns check counters, Markdown, +file separation, alias discovery and a real component-to-host `runtime.describe` +round trip. No model or personal account runs. + +Prepare the mirror with the reviewed +[App Hub candidate helper](https://github.com/OctoSense-org/OctoSense-App-Hub/blob/main/docs/SHARED-COMPONENT-REHEARSAL.md) +and the protected admin workflow's `dry_run: true` envelope. The official catalog +is not changed. The following build and driver commands were executed with +local tool paths, desktop first and the assigned OnePlus 6 last. To repeat them, +use new evidence directories, an explicitly assigned OnePlus 6 serial and your +local tool paths: + +```sh +cargo build --locked -p octosense-wasm-phone-smoke +python3 tools/test-shared-components.py --host target/debug/octosense-wasm-phone-smoke \ + --mirror "$SHARED_MIRROR" --out "$SHARED_MAC_EVIDENCE" +MAKEPAD_FORCE_DEBUGGABLE=1 CARGO_TARGET_DIR="$WASM_TARGET" "$CARGO_MAKEPAD" makepad android \ + --sdk-path="$MAKEPAD_ANDROID_SDK" --abi=aarch64 --version-code=2026100901 \ + --package-name=dev.makepad.octosense.hostapilab.shared1 --app-label=OctoSenseSharedComponentTest \ + build -p octosense-wasm-phone-smoke --release --locked --offline +python3 tools/test-shared-components.py --adb "$ADB" --aapt2 "$AAPT2" --serial "$ONEPLUS_SERIAL" \ + --apk "$WASM_TARGET/makepad-android-apk/octosense_wasm_phone_smoke/apk/octo_sense_shared_component_test.apk" \ + --mirror "$SHARED_MIRROR" --out "$SHARED_PHONE_EVIDENCE" +``` + +The driver refuses an existing test package, binds its receipt to the clean +compiled source/runtime and removes only its own newly installed phone package. +The receipt excludes the device serial. The raw native receipt records the real +catalog payload digest, bundle/component identities and all four result objects. +The two modes test different contracts: core functions use fresh instances; +shared components retain app-private instances while sharing immutable bytes. +Neither mode is a performance benchmark or a release Home upgrade test. + +The completed run is bound to clean source +`c5f0c5c1948d9b730c707b409e9713af49e3e614` and Makepad runtime tree +`a6fae94aa4d233503b14bc6d0a36bcbc2b264b44`. The two components and two apps +were built and attested by [publisher workflow 38024551136](https://github.com/ymote/octosense-component-demo/actions/runs/38024551136) +for [v0.1.0](https://github.com/ymote/octosense-component-demo/releases/tag/v0.1.0). +[Protected admin workflow 38026083032](https://github.com/OctoSense-org/OctoSense-App-Hub/actions/runs/38026083032) +produced the real sequence-16 catalog envelope with `dry_run: true`, payload +SHA-256 `87bde245807a5ff6a1b3297c409d4ef6684414e47b038519a196feab29f42a7e`. +Its proof and all artifact digests were verified before either native run. +The public catalog stayed unchanged. + +| Platform | Native component assertions | Driver checks | Evidence | +| --- | --- | --- | --- | +| macOS, hidden Makepad window | 28/28 | 12/12, including the final app and counter visible | [Driver](evidence/shared-components/macos.json), [raw native results](evidence/shared-components/macos-native.json), [native capture](evidence/shared-components/completed.png) | +| OnePlus 6 | 28/28 | 13/13, including the assigned device, fresh isolated package and compiled identities | [Driver](evidence/shared-components/oneplus6.json), [raw native results](evidence/shared-components/oneplus6-native.json) | + +The receipts are copied without changes. They show actual Store installation, +Splash app-tool calls, alias discovery, a component-to-host call, deduplicated +read-only component bytes, and retained counters and files isolated per app. +The OnePlus test package was removed. No personal accounts or models were used, +and the installed Home was untouched. These are development-host receipts, +not released-binary acceptance. Live model relay, performance, OpenHarmony +device execution and a production Home upgrade remain outside this run. diff --git a/tools/fixtures/wasm-phone-lab/README.zh-CN.md b/tools/fixtures/wasm-phone-lab/README.zh-CN.md index 9bccb8b5b..c21c5ffa0 100644 --- a/tools/fixtures/wasm-phone-lab/README.zh-CN.md +++ b/tools/fixtures/wasm-phone-lab/README.zh-CN.md @@ -27,3 +27,51 @@ cargo run --locked --offline -p octosense-wasm-host --example encode_phone_fixtu 客体在实例复用时会主动保留输入;通过测试要求宿主在每次调用(包括成功调用)之后创建新实例。先前的 19 项结果使用了显式组装的字段,不能证明原始响应转发。修复后的原始路径增加一项转发检查,可复现的公开测试还增加两项编译标识检查。[OnePlus 6 回执](acceptance-oneplus6.json)记录 Android 15 上 **22/22 项通过**,构建源码为 `e67ce63ebca6054961899691e1644a54c2b4f081`,运行时树为 `0fc8e29e2411fd7eb94d845ba16a261d2ebc9dfc`。上面的构建和驱动命令已使用本机工具路径执行。真实模型/对等代理转发、性能和发布 APK 行为仍未验证。 应用列表图片是单独在 macOS 隐藏 Makepad 窗口中捕获的真实界面,源码为 `d3535a95`,拍摄时此测试应用的签名应用工具已经完成。它不是手机截图,也不把 OnePlus 的 22 项回执扩展到后续仅替换图片的提交。该图片及更新后的测试包摘要替换了先前无关的 Host API Lab 图片。 + +## 真实 GitHub 共享组件验收 + +同一个原生宿主还支持包含真实 GitHub 证明的 `catalog-v2.json`、两个已审阅的消费应用包及固定版本共享组件的镜像,数据来自[合成发布者](https://github.com/ymote/octosense-component-demo)。 +这个模式使用默认 GitHub 信任通道和实际商店准入,不创建旧格式签名,也不授予额外能力。 +第一个应用不声明能力;第二个声明 `wasm` 和 `storage`。两个应用调用同一份组件字节,但各自保留实例和私有存储。 +四次交替应用工具调用检查计数器、Markdown、文件隔离、别名发现,以及组件到宿主 `runtime.describe` 的真实往返,不启动模型或使用个人账户。 + +通过已审阅的 [App Hub 候选目录工具](https://github.com/OctoSense-org/OctoSense-App-Hub/blob/main/docs/SHARED-COMPONENT-REHEARSAL.zh-CN.md)和受保护的管理员工作流 `dry_run: true` 所生成的证明封装准备镜像,不改动正式目录。 +以下构建和驱动命令已使用本机工具路径执行,先验证隐藏桌面窗口,最后验证获准使用的 OnePlus 6。 +复现时使用新的证据目录、明确获准测试的 OnePlus 6 序列号和本机工具路径: + +```sh +cargo build --locked -p octosense-wasm-phone-smoke +python3 tools/test-shared-components.py --host target/debug/octosense-wasm-phone-smoke \ + --mirror "$SHARED_MIRROR" --out "$SHARED_MAC_EVIDENCE" +MAKEPAD_FORCE_DEBUGGABLE=1 CARGO_TARGET_DIR="$WASM_TARGET" "$CARGO_MAKEPAD" makepad android \ + --sdk-path="$MAKEPAD_ANDROID_SDK" --abi=aarch64 --version-code=2026100901 \ + --package-name=dev.makepad.octosense.hostapilab.shared1 --app-label=OctoSenseSharedComponentTest \ + build -p octosense-wasm-phone-smoke --release --locked --offline +python3 tools/test-shared-components.py --adb "$ADB" --aapt2 "$AAPT2" --serial "$ONEPLUS_SERIAL" \ + --apk "$WASM_TARGET/makepad-android-apk/octosense_wasm_phone_smoke/apk/octo_sense_shared_component_test.apk" \ + --mirror "$SHARED_MIRROR" --out "$SHARED_PHONE_EVIDENCE" +``` + +驱动拒绝已有的测试包,把回执绑定到干净的编译源码及运行时,只卸载本次新安装的测试包,不记录设备序列号。 +原生回执记录真实目录载荷摘要、应用包和组件标识,以及四次完整的结果对象。 +两种模式验证不同约定:核心函数使用新实例;共享组件共享不可变字节,但保留应用私有实例。 +两者都不是性能基准或正式 Home 升级测试。 + +本次完成的验收绑定到干净源码 `c5f0c5c1948d9b730c707b409e9713af49e3e614`, +Makepad 运行时树为 `a6fae94aa4d233503b14bc6d0a36bcbc2b264b44`。 +两个组件和两个应用由[发布者工作流 38024551136](https://github.com/ymote/octosense-component-demo/actions/runs/38024551136) +构建并取得证明,见 [v0.1.0 发布](https://github.com/ymote/octosense-component-demo/releases/tag/v0.1.0)。 +[受保护的管理员工作流 38026083032](https://github.com/OctoSense-org/OctoSense-App-Hub/actions/runs/38026083032) +以 `dry_run: true` 生成真实的第 16 版目录证明封装,载荷 SHA-256 为 +`87bde245807a5ff6a1b3297c409d4ef6684414e47b038519a196feab29f42a7e`。 +两次原生执行之前均验证了证明和全部产物摘要,公开目录保持不变。 + +| 平台 | 原生组件断言 | 驱动检查 | 证据 | +| --- | --- | --- | --- | +| macOS,隐藏 Makepad 窗口 | 28/28 | 12/12,包括最终应用和计数器可见 | [驱动](evidence/shared-components/macos.json)、[原始原生结果](evidence/shared-components/macos-native.json)、[原生截图](evidence/shared-components/completed.png) | +| OnePlus 6 | 28/28 | 13/13,包括指定设备、全新独立包和编译标识 | [驱动](evidence/shared-components/oneplus6.json)、[原始原生结果](evidence/shared-components/oneplus6-native.json) | + +回执未经改动直接复制,证明实际商店安装、Splash 应用工具调用、别名发现、组件到宿主调用、 +只读组件字节去重,以及各应用独立保留的计数器和文件。OnePlus 测试包已卸载,未使用个人账户或模型, +也未改动已安装的 Home。这些是开发宿主的回执,不是发行二进制验收。 +本次不包含真实模型转发、性能、OpenHarmony 设备执行或正式 Home 升级。 diff --git a/tools/fixtures/wasm-phone-lab/evidence/shared-components/completed.png b/tools/fixtures/wasm-phone-lab/evidence/shared-components/completed.png new file mode 100644 index 000000000..988e6371e Binary files /dev/null and b/tools/fixtures/wasm-phone-lab/evidence/shared-components/completed.png differ diff --git a/tools/fixtures/wasm-phone-lab/evidence/shared-components/macos-native.json b/tools/fixtures/wasm-phone-lab/evidence/shared-components/macos-native.json new file mode 100644 index 000000000..b3f371e70 --- /dev/null +++ b/tools/fixtures/wasm-phone-lab/evidence/shared-components/macos-native.json @@ -0,0 +1,1298 @@ +{ + "apps": [ + { + "bundle_digest": "aab50186682b24c201f5bfeb6a4a88c04215bf74e08f1d7fd62824f4926a45ac", + "declarations": [], + "id": "org.ymote.componentdemo.first" + }, + { + "bundle_digest": "2d0d6c3fc6b52db513f13d1f78079a683185413917486ef6a09c2c55fe50c971", + "declarations": [ + "wasm", + "storage" + ], + "id": "org.ymote.componentdemo.second" + } + ], + "catalog_channel": "github", + "catalog_sequence": 16, + "catalog_sha256": "87bde245807a5ff6a1b3297c409d4ef6684414e47b038519a196feab29f42a7e", + "checks": { + "turn_1_alias_discovery": true, + "turn_1_app_identity": true, + "turn_1_markdown": true, + "turn_1_private_storage_after_write": true, + "turn_1_private_storage_before_write": true, + "turn_1_real_host_dispatch": true, + "turn_1_retained_isolated_counter": true, + "turn_2_alias_discovery": true, + "turn_2_app_identity": true, + "turn_2_markdown": true, + "turn_2_private_storage_after_write": true, + "turn_2_private_storage_before_write": true, + "turn_2_real_host_dispatch": true, + "turn_2_retained_isolated_counter": true, + "turn_3_alias_discovery": true, + "turn_3_app_identity": true, + "turn_3_markdown": true, + "turn_3_private_storage_after_write": true, + "turn_3_private_storage_before_write": true, + "turn_3_real_host_dispatch": true, + "turn_3_retained_isolated_counter": true, + "turn_4_alias_discovery": true, + "turn_4_app_identity": true, + "turn_4_markdown": true, + "turn_4_private_storage_after_write": true, + "turn_4_private_storage_before_write": true, + "turn_4_real_host_dispatch": true, + "turn_4_retained_isolated_counter": true + }, + "components": [ + { + "alias": "md", + "blake3": "d7441f84609f1dc177a616be80141933ac729a2fe4cfb09906b8e445773c6122", + "id": "org.ymote.componentdemo.markdown", + "version": "0.1.0" + }, + { + "alias": "bridge", + "blake3": "db454eecc87522886a19dcbe25190d93e313fd8732d777e0e0aa483f0cdaaaca", + "id": "org.ymote.componentdemo.hostcall", + "version": "0.1.0" + } + ], + "deduplicated_readonly_components": true, + "legacy_fallback": false, + "model_started": false, + "not_verified": [ + "performance benchmark", + "live model relay", + "production Home upgrade" + ], + "passed": true, + "personal_accounts_used": false, + "platform": "macos", + "proof": "real GitHub catalog and publisher proofs -> Store install -> app tool -> Splash host.request -> shared component", + "runtime_tree": "a6fae94aa4d233503b14bc6d0a36bcbc2b264b44", + "schema": 1, + "source_dirty": false, + "source_revision": "c5f0c5c1948d9b730c707b409e9713af49e3e614", + "turns": [ + { + "app": "org.ymote.componentdemo.first", + "results": { + "before_write": { + "data": null, + "error": "No such file or directory (os error 44)", + "is_ok": false + }, + "count_1": { + "data": 1, + "error": null, + "is_ok": true + }, + "count_2": { + "data": 2, + "error": null, + "is_ok": true + }, + "functions": { + "data": { + "functions": [ + "bridge.call", + "md.analyze", + "md.append_text", + "md.count", + "md.delete_file", + "md.echo_bytes", + "md.file_info", + "md.grow", + "md.now_ms", + "md.random_u64", + "md.read_file", + "md.save_html", + "md.set_len", + "md.spin", + "md.to_html" + ], + "modules": [ + { + "bytes": 320651, + "exports": [ + { + "name": "analyze", + "params": [ + [ + "markdown", + "string" + ] + ], + "result": "record { words: u32, lines: u32, headings: list }", + "wit": "analyze" + }, + { + "name": "append_text", + "params": [ + [ + "path", + "string" + ], + [ + "text", + "string" + ] + ], + "result": "result", + "wit": "append-text" + }, + { + "name": "count", + "params": [], + "result": "u32", + "wit": "count" + }, + { + "name": "delete_file", + "params": [ + [ + "path", + "string" + ] + ], + "result": "result<_, string>", + "wit": "delete-file" + }, + { + "name": "echo_bytes", + "params": [ + [ + "data", + "list" + ] + ], + "result": "list", + "wit": "echo-bytes" + }, + { + "name": "file_info", + "params": [ + [ + "path", + "string" + ] + ], + "result": "result", + "wit": "file-info" + }, + { + "name": "grow", + "params": [ + [ + "megabytes", + "u32" + ] + ], + "result": "u32", + "wit": "grow" + }, + { + "name": "now_ms", + "params": [], + "result": "u64", + "wit": "now-ms" + }, + { + "name": "random_u64", + "params": [], + "result": "u64", + "wit": "random-u64" + }, + { + "name": "read_file", + "params": [ + [ + "path", + "string" + ] + ], + "result": "result", + "wit": "read-file" + }, + { + "name": "save_html", + "params": [ + [ + "markdown", + "string" + ], + [ + "path", + "string" + ] + ], + "result": "result", + "wit": "save-html" + }, + { + "name": "set_len", + "params": [ + [ + "path", + "string" + ], + [ + "len", + "u64" + ] + ], + "result": "result<_, string>", + "wit": "set-len" + }, + { + "name": "spin", + "params": [], + "result": null, + "wit": "spin" + }, + { + "name": "to_html", + "params": [ + [ + "markdown", + "string" + ] + ], + "result": "string", + "wit": "to-html" + } + ], + "file": "md (org.ymote.componentdemo.markdown 0.1.0)", + "from_cache": false, + "instance_policy": "kept-between-calls", + "instances": 1, + "invocations": 6, + "kind": "component", + "live": true, + "load_ms": 56.1, + "network": false, + "shared": { + "alias": "md", + "blake3": "d7441f84609f1dc177a616be80141933ac729a2fe4cfb09906b8e445773c6122", + "id": "org.ymote.componentdemo.markdown", + "version": "0.1.0" + }, + "skipped": [], + "storage": "app folder", + "storage_left": 16777183 + }, + { + "bytes": 42284, + "exports": [ + { + "name": "call", + "params": [ + [ + "service", + "string" + ], + [ + "args", + "string" + ] + ], + "result": "result", + "wit": "call" + } + ], + "file": "bridge (org.ymote.componentdemo.hostcall 0.1.0)", + "from_cache": false, + "instance_policy": "kept-between-calls", + "instances": 1, + "invocations": 1, + "kind": "component", + "live": true, + "load_ms": 11.8, + "network": false, + "shared": { + "alias": "bridge", + "blake3": "db454eecc87522886a19dcbe25190d93e313fd8732d777e0e0aa483f0cdaaaca", + "id": "org.ymote.componentdemo.hostcall", + "version": "0.1.0" + }, + "skipped": [], + "storage": "app folder", + "storage_left": 16777183 + } + ], + "stats": { + "bridge.call": { + "calls": 1, + "errors": 0, + "max_us": 679.8, + "mean_us": 679.8 + }, + "md.count": { + "calls": 2, + "errors": 0, + "max_us": 848.4, + "mean_us": 433.4 + }, + "md.read_file": { + "calls": 2, + "errors": 1, + "max_us": 705.8, + "mean_us": 587.5 + }, + "md.save_html": { + "calls": 1, + "errors": 0, + "max_us": 625.4, + "mean_us": 625.4 + }, + "md.to_html": { + "calls": 1, + "errors": 0, + "max_us": 182.3, + "mean_us": 182.3 + } + } + }, + "error": null, + "is_ok": true + }, + "host_call": { + "data": "{\"authorization\":\"checked-on-call\",\"configured\":null,\"descriptor\":{\"agent_access\":\"allowed\",\"capability\":\"runtime\",\"input_schema\":{\"additionalProperties\":false,\"properties\":{\"method\":{\"type\":\"string\"}},\"required\":[\"method\"],\"type\":\"object\"},\"name\":\"runtime.describe\",\"output_schema\":{\"type\":\"object\"},\"platforms\":[\"android\",\"macos\",\"windows\",\"linux\",\"ios\",\"openharmony\",\"web\"],\"summary\":\"Describe one host API or report that this host does not implement it.\",\"version\":1},\"implemented\":true,\"supported\":true}", + "error": null, + "is_ok": true + }, + "markdown": { + "data": "

Component Demo · First

\n", + "error": null, + "is_ok": true + }, + "read_back": { + "data": "

Component Demo · First

\n", + "error": null, + "is_ok": true + }, + "saved": { + "data": 33, + "error": null, + "is_ok": true + } + } + }, + { + "app": "org.ymote.componentdemo.second", + "results": { + "before_write": { + "data": null, + "error": "No such file or directory (os error 44)", + "is_ok": false + }, + "count_1": { + "data": 1, + "error": null, + "is_ok": true + }, + "count_2": { + "data": 2, + "error": null, + "is_ok": true + }, + "functions": { + "data": { + "functions": [ + "bridge.call", + "md.analyze", + "md.append_text", + "md.count", + "md.delete_file", + "md.echo_bytes", + "md.file_info", + "md.grow", + "md.now_ms", + "md.random_u64", + "md.read_file", + "md.save_html", + "md.set_len", + "md.spin", + "md.to_html" + ], + "modules": [ + { + "bytes": 320651, + "exports": [ + { + "name": "analyze", + "params": [ + [ + "markdown", + "string" + ] + ], + "result": "record { words: u32, lines: u32, headings: list }", + "wit": "analyze" + }, + { + "name": "append_text", + "params": [ + [ + "path", + "string" + ], + [ + "text", + "string" + ] + ], + "result": "result", + "wit": "append-text" + }, + { + "name": "count", + "params": [], + "result": "u32", + "wit": "count" + }, + { + "name": "delete_file", + "params": [ + [ + "path", + "string" + ] + ], + "result": "result<_, string>", + "wit": "delete-file" + }, + { + "name": "echo_bytes", + "params": [ + [ + "data", + "list" + ] + ], + "result": "list", + "wit": "echo-bytes" + }, + { + "name": "file_info", + "params": [ + [ + "path", + "string" + ] + ], + "result": "result", + "wit": "file-info" + }, + { + "name": "grow", + "params": [ + [ + "megabytes", + "u32" + ] + ], + "result": "u32", + "wit": "grow" + }, + { + "name": "now_ms", + "params": [], + "result": "u64", + "wit": "now-ms" + }, + { + "name": "random_u64", + "params": [], + "result": "u64", + "wit": "random-u64" + }, + { + "name": "read_file", + "params": [ + [ + "path", + "string" + ] + ], + "result": "result", + "wit": "read-file" + }, + { + "name": "save_html", + "params": [ + [ + "markdown", + "string" + ], + [ + "path", + "string" + ] + ], + "result": "result", + "wit": "save-html" + }, + { + "name": "set_len", + "params": [ + [ + "path", + "string" + ], + [ + "len", + "u64" + ] + ], + "result": "result<_, string>", + "wit": "set-len" + }, + { + "name": "spin", + "params": [], + "result": null, + "wit": "spin" + }, + { + "name": "to_html", + "params": [ + [ + "markdown", + "string" + ] + ], + "result": "string", + "wit": "to-html" + } + ], + "file": "md (org.ymote.componentdemo.markdown 0.1.0)", + "from_cache": true, + "instance_policy": "kept-between-calls", + "instances": 1, + "invocations": 6, + "kind": "component", + "live": true, + "load_ms": 1.4, + "network": false, + "shared": { + "alias": "md", + "blake3": "d7441f84609f1dc177a616be80141933ac729a2fe4cfb09906b8e445773c6122", + "id": "org.ymote.componentdemo.markdown", + "version": "0.1.0" + }, + "skipped": [], + "storage": "app folder", + "storage_left": 16777182 + }, + { + "bytes": 42284, + "exports": [ + { + "name": "call", + "params": [ + [ + "service", + "string" + ], + [ + "args", + "string" + ] + ], + "result": "result", + "wit": "call" + } + ], + "file": "bridge (org.ymote.componentdemo.hostcall 0.1.0)", + "from_cache": true, + "instance_policy": "kept-between-calls", + "instances": 1, + "invocations": 1, + "kind": "component", + "live": true, + "load_ms": 0.3, + "network": false, + "shared": { + "alias": "bridge", + "blake3": "db454eecc87522886a19dcbe25190d93e313fd8732d777e0e0aa483f0cdaaaca", + "id": "org.ymote.componentdemo.hostcall", + "version": "0.1.0" + }, + "skipped": [], + "storage": "app folder", + "storage_left": 16777182 + } + ], + "stats": { + "bridge.call": { + "calls": 1, + "errors": 0, + "max_us": 1431.8, + "mean_us": 1431.8 + }, + "md.count": { + "calls": 2, + "errors": 0, + "max_us": 475.8, + "mean_us": 246.6 + }, + "md.read_file": { + "calls": 2, + "errors": 1, + "max_us": 370.2, + "mean_us": 246.5 + }, + "md.save_html": { + "calls": 1, + "errors": 0, + "max_us": 375.8, + "mean_us": 375.8 + }, + "md.to_html": { + "calls": 1, + "errors": 0, + "max_us": 128.5, + "mean_us": 128.5 + } + } + }, + "error": null, + "is_ok": true + }, + "host_call": { + "data": "{\"authorization\":\"checked-on-call\",\"configured\":null,\"descriptor\":{\"agent_access\":\"allowed\",\"capability\":\"runtime\",\"input_schema\":{\"additionalProperties\":false,\"properties\":{\"method\":{\"type\":\"string\"}},\"required\":[\"method\"],\"type\":\"object\"},\"name\":\"runtime.describe\",\"output_schema\":{\"type\":\"object\"},\"platforms\":[\"android\",\"macos\",\"windows\",\"linux\",\"ios\",\"openharmony\",\"web\"],\"summary\":\"Describe one host API or report that this host does not implement it.\",\"version\":1},\"implemented\":true,\"supported\":true}", + "error": null, + "is_ok": true + }, + "markdown": { + "data": "

Component Demo · Second

\n", + "error": null, + "is_ok": true + }, + "read_back": { + "data": "

Component Demo · Second

\n", + "error": null, + "is_ok": true + }, + "saved": { + "data": 34, + "error": null, + "is_ok": true + } + } + }, + { + "app": "org.ymote.componentdemo.first", + "results": { + "before_write": { + "data": "

Component Demo · First

\n", + "error": null, + "is_ok": true + }, + "count_1": { + "data": 3, + "error": null, + "is_ok": true + }, + "count_2": { + "data": 4, + "error": null, + "is_ok": true + }, + "functions": { + "data": { + "functions": [ + "bridge.call", + "md.analyze", + "md.append_text", + "md.count", + "md.delete_file", + "md.echo_bytes", + "md.file_info", + "md.grow", + "md.now_ms", + "md.random_u64", + "md.read_file", + "md.save_html", + "md.set_len", + "md.spin", + "md.to_html" + ], + "modules": [ + { + "bytes": 320651, + "exports": [ + { + "name": "analyze", + "params": [ + [ + "markdown", + "string" + ] + ], + "result": "record { words: u32, lines: u32, headings: list }", + "wit": "analyze" + }, + { + "name": "append_text", + "params": [ + [ + "path", + "string" + ], + [ + "text", + "string" + ] + ], + "result": "result", + "wit": "append-text" + }, + { + "name": "count", + "params": [], + "result": "u32", + "wit": "count" + }, + { + "name": "delete_file", + "params": [ + [ + "path", + "string" + ] + ], + "result": "result<_, string>", + "wit": "delete-file" + }, + { + "name": "echo_bytes", + "params": [ + [ + "data", + "list" + ] + ], + "result": "list", + "wit": "echo-bytes" + }, + { + "name": "file_info", + "params": [ + [ + "path", + "string" + ] + ], + "result": "result", + "wit": "file-info" + }, + { + "name": "grow", + "params": [ + [ + "megabytes", + "u32" + ] + ], + "result": "u32", + "wit": "grow" + }, + { + "name": "now_ms", + "params": [], + "result": "u64", + "wit": "now-ms" + }, + { + "name": "random_u64", + "params": [], + "result": "u64", + "wit": "random-u64" + }, + { + "name": "read_file", + "params": [ + [ + "path", + "string" + ] + ], + "result": "result", + "wit": "read-file" + }, + { + "name": "save_html", + "params": [ + [ + "markdown", + "string" + ], + [ + "path", + "string" + ] + ], + "result": "result", + "wit": "save-html" + }, + { + "name": "set_len", + "params": [ + [ + "path", + "string" + ], + [ + "len", + "u64" + ] + ], + "result": "result<_, string>", + "wit": "set-len" + }, + { + "name": "spin", + "params": [], + "result": null, + "wit": "spin" + }, + { + "name": "to_html", + "params": [ + [ + "markdown", + "string" + ] + ], + "result": "string", + "wit": "to-html" + } + ], + "file": "md (org.ymote.componentdemo.markdown 0.1.0)", + "from_cache": false, + "instance_policy": "kept-between-calls", + "instances": 1, + "invocations": 12, + "kind": "component", + "live": true, + "load_ms": 56.1, + "network": false, + "shared": { + "alias": "md", + "blake3": "d7441f84609f1dc177a616be80141933ac729a2fe4cfb09906b8e445773c6122", + "id": "org.ymote.componentdemo.markdown", + "version": "0.1.0" + }, + "skipped": [], + "storage": "app folder", + "storage_left": 16777183 + }, + { + "bytes": 42284, + "exports": [ + { + "name": "call", + "params": [ + [ + "service", + "string" + ], + [ + "args", + "string" + ] + ], + "result": "result", + "wit": "call" + } + ], + "file": "bridge (org.ymote.componentdemo.hostcall 0.1.0)", + "from_cache": false, + "instance_policy": "kept-between-calls", + "instances": 1, + "invocations": 2, + "kind": "component", + "live": true, + "load_ms": 11.8, + "network": false, + "shared": { + "alias": "bridge", + "blake3": "db454eecc87522886a19dcbe25190d93e313fd8732d777e0e0aa483f0cdaaaca", + "id": "org.ymote.componentdemo.hostcall", + "version": "0.1.0" + }, + "skipped": [], + "storage": "app folder", + "storage_left": 16777183 + } + ], + "stats": { + "bridge.call": { + "calls": 2, + "errors": 0, + "max_us": 679.8, + "mean_us": 519.3 + }, + "md.count": { + "calls": 4, + "errors": 0, + "max_us": 848.4, + "mean_us": 227.8 + }, + "md.read_file": { + "calls": 4, + "errors": 1, + "max_us": 705.8, + "mean_us": 400.9 + }, + "md.save_html": { + "calls": 2, + "errors": 0, + "max_us": 625.4, + "mean_us": 425 + }, + "md.to_html": { + "calls": 2, + "errors": 0, + "max_us": 182.3, + "mean_us": 109.8 + } + } + }, + "error": null, + "is_ok": true + }, + "host_call": { + "data": "{\"authorization\":\"checked-on-call\",\"configured\":null,\"descriptor\":{\"agent_access\":\"allowed\",\"capability\":\"runtime\",\"input_schema\":{\"additionalProperties\":false,\"properties\":{\"method\":{\"type\":\"string\"}},\"required\":[\"method\"],\"type\":\"object\"},\"name\":\"runtime.describe\",\"output_schema\":{\"type\":\"object\"},\"platforms\":[\"android\",\"macos\",\"windows\",\"linux\",\"ios\",\"openharmony\",\"web\"],\"summary\":\"Describe one host API or report that this host does not implement it.\",\"version\":1},\"implemented\":true,\"supported\":true}", + "error": null, + "is_ok": true + }, + "markdown": { + "data": "

Component Demo · First

\n", + "error": null, + "is_ok": true + }, + "read_back": { + "data": "

Component Demo · First

\n", + "error": null, + "is_ok": true + }, + "saved": { + "data": 33, + "error": null, + "is_ok": true + } + } + }, + { + "app": "org.ymote.componentdemo.second", + "results": { + "before_write": { + "data": "

Component Demo · Second

\n", + "error": null, + "is_ok": true + }, + "count_1": { + "data": 3, + "error": null, + "is_ok": true + }, + "count_2": { + "data": 4, + "error": null, + "is_ok": true + }, + "functions": { + "data": { + "functions": [ + "bridge.call", + "md.analyze", + "md.append_text", + "md.count", + "md.delete_file", + "md.echo_bytes", + "md.file_info", + "md.grow", + "md.now_ms", + "md.random_u64", + "md.read_file", + "md.save_html", + "md.set_len", + "md.spin", + "md.to_html" + ], + "modules": [ + { + "bytes": 320651, + "exports": [ + { + "name": "analyze", + "params": [ + [ + "markdown", + "string" + ] + ], + "result": "record { words: u32, lines: u32, headings: list }", + "wit": "analyze" + }, + { + "name": "append_text", + "params": [ + [ + "path", + "string" + ], + [ + "text", + "string" + ] + ], + "result": "result", + "wit": "append-text" + }, + { + "name": "count", + "params": [], + "result": "u32", + "wit": "count" + }, + { + "name": "delete_file", + "params": [ + [ + "path", + "string" + ] + ], + "result": "result<_, string>", + "wit": "delete-file" + }, + { + "name": "echo_bytes", + "params": [ + [ + "data", + "list" + ] + ], + "result": "list", + "wit": "echo-bytes" + }, + { + "name": "file_info", + "params": [ + [ + "path", + "string" + ] + ], + "result": "result", + "wit": "file-info" + }, + { + "name": "grow", + "params": [ + [ + "megabytes", + "u32" + ] + ], + "result": "u32", + "wit": "grow" + }, + { + "name": "now_ms", + "params": [], + "result": "u64", + "wit": "now-ms" + }, + { + "name": "random_u64", + "params": [], + "result": "u64", + "wit": "random-u64" + }, + { + "name": "read_file", + "params": [ + [ + "path", + "string" + ] + ], + "result": "result", + "wit": "read-file" + }, + { + "name": "save_html", + "params": [ + [ + "markdown", + "string" + ], + [ + "path", + "string" + ] + ], + "result": "result", + "wit": "save-html" + }, + { + "name": "set_len", + "params": [ + [ + "path", + "string" + ], + [ + "len", + "u64" + ] + ], + "result": "result<_, string>", + "wit": "set-len" + }, + { + "name": "spin", + "params": [], + "result": null, + "wit": "spin" + }, + { + "name": "to_html", + "params": [ + [ + "markdown", + "string" + ] + ], + "result": "string", + "wit": "to-html" + } + ], + "file": "md (org.ymote.componentdemo.markdown 0.1.0)", + "from_cache": true, + "instance_policy": "kept-between-calls", + "instances": 1, + "invocations": 12, + "kind": "component", + "live": true, + "load_ms": 1.4, + "network": false, + "shared": { + "alias": "md", + "blake3": "d7441f84609f1dc177a616be80141933ac729a2fe4cfb09906b8e445773c6122", + "id": "org.ymote.componentdemo.markdown", + "version": "0.1.0" + }, + "skipped": [], + "storage": "app folder", + "storage_left": 16777182 + }, + { + "bytes": 42284, + "exports": [ + { + "name": "call", + "params": [ + [ + "service", + "string" + ], + [ + "args", + "string" + ] + ], + "result": "result", + "wit": "call" + } + ], + "file": "bridge (org.ymote.componentdemo.hostcall 0.1.0)", + "from_cache": true, + "instance_policy": "kept-between-calls", + "instances": 1, + "invocations": 2, + "kind": "component", + "live": true, + "load_ms": 0.3, + "network": false, + "shared": { + "alias": "bridge", + "blake3": "db454eecc87522886a19dcbe25190d93e313fd8732d777e0e0aa483f0cdaaaca", + "id": "org.ymote.componentdemo.hostcall", + "version": "0.1.0" + }, + "skipped": [], + "storage": "app folder", + "storage_left": 16777182 + } + ], + "stats": { + "bridge.call": { + "calls": 2, + "errors": 0, + "max_us": 1431.8, + "mean_us": 905.5 + }, + "md.count": { + "calls": 4, + "errors": 0, + "max_us": 475.8, + "mean_us": 133.4 + }, + "md.read_file": { + "calls": 4, + "errors": 1, + "max_us": 370.2, + "mean_us": 227.4 + }, + "md.save_html": { + "calls": 2, + "errors": 0, + "max_us": 375.8, + "mean_us": 310.3 + }, + "md.to_html": { + "calls": 2, + "errors": 0, + "max_us": 128.5, + "mean_us": 92.4 + } + } + }, + "error": null, + "is_ok": true + }, + "host_call": { + "data": "{\"authorization\":\"checked-on-call\",\"configured\":null,\"descriptor\":{\"agent_access\":\"allowed\",\"capability\":\"runtime\",\"input_schema\":{\"additionalProperties\":false,\"properties\":{\"method\":{\"type\":\"string\"}},\"required\":[\"method\"],\"type\":\"object\"},\"name\":\"runtime.describe\",\"output_schema\":{\"type\":\"object\"},\"platforms\":[\"android\",\"macos\",\"windows\",\"linux\",\"ios\",\"openharmony\",\"web\"],\"summary\":\"Describe one host API or report that this host does not implement it.\",\"version\":1},\"implemented\":true,\"supported\":true}", + "error": null, + "is_ok": true + }, + "markdown": { + "data": "

Component Demo · Second

\n", + "error": null, + "is_ok": true + }, + "read_back": { + "data": "

Component Demo · Second

\n", + "error": null, + "is_ok": true + }, + "saved": { + "data": 34, + "error": null, + "is_ok": true + } + } + } + ] +} \ No newline at end of file diff --git a/tools/fixtures/wasm-phone-lab/evidence/shared-components/macos.json b/tools/fixtures/wasm-phone-lab/evidence/shared-components/macos.json new file mode 100644 index 000000000..8ffbe1d68 --- /dev/null +++ b/tools/fixtures/wasm-phone-lab/evidence/shared-components/macos.json @@ -0,0 +1,45 @@ +{ + "schema": 1, + "source_revision": "c5f0c5c1948d9b730c707b409e9713af49e3e614", + "runtime_tree": "a6fae94aa4d233503b14bc6d0a36bcbc2b264b44", + "mirror_sha256": { + "artifacts/org.ymote.componentdemo.first-0.1.0.bundle/AGENT.md": "906939643fd742f8e6db8dd8f505c71a1593b9c843c8d01cc7c231fe295a140d", + "artifacts/org.ymote.componentdemo.first-0.1.0.bundle/icon.svg": "d031ea03898c1f459e5ac86eaea51d37eef021cef44c85add12f64163cb1ecfe", + "artifacts/org.ymote.componentdemo.first-0.1.0.bundle/listing.json": "5345e58a77833a286a5b9ecc65be887c3dcf372cbc65a9ebf269d71c6730a9f0", + "artifacts/org.ymote.componentdemo.first-0.1.0.bundle/main.splash": "5e764fbd3eedb6d0bad305f0013d66f4b481c2fedc8f5f3280dede43b3f252fc", + "artifacts/org.ymote.componentdemo.first-0.1.0.bundle/manifest.json": "738fd189c7e8e0747b97c18af3aaf49d6c632ba8b1b5a05c1b57ff4ef7a428f7", + "artifacts/org.ymote.componentdemo.first-0.1.0.bundle/preview.png": "16eaf55bd6c2b6855adf20ebf0b9041ad9be9e1088e3b10b7e1b9ed6994304c1", + "artifacts/org.ymote.componentdemo.first-0.1.0.bundle/tools.json": "25fc72dd248fd687cc60e605e5ea67d8f2515223df92d61ca171079b5804431e", + "artifacts/org.ymote.componentdemo.first-0.1.0.bundle.pack.json": "dce3991bfcc4ec342a8b4a61dece3f268d7b18551c2d557a1199e8ed26232980", + "artifacts/org.ymote.componentdemo.hostcall-0.1.0.wasm": "9a4cb02261033885a08eaeba490d1ca3bd81db9ebcdc77aff897d529afe6859e", + "artifacts/org.ymote.componentdemo.markdown-0.1.0.wasm": "2954a01883d376430348625a7ff26bcc7978c385d3e78d6e61bf74ff46ef455f", + "artifacts/org.ymote.componentdemo.second-0.1.0.bundle/AGENT.md": "c26a070abd33426c2ba4c789c298dfde661a38e2bc1cfd82e344bb04542c09a1", + "artifacts/org.ymote.componentdemo.second-0.1.0.bundle/icon.svg": "d031ea03898c1f459e5ac86eaea51d37eef021cef44c85add12f64163cb1ecfe", + "artifacts/org.ymote.componentdemo.second-0.1.0.bundle/listing.json": "5345e58a77833a286a5b9ecc65be887c3dcf372cbc65a9ebf269d71c6730a9f0", + "artifacts/org.ymote.componentdemo.second-0.1.0.bundle/main.splash": "d581213a8fbf995f93d2dbb3dc3e58373d8fbcb3cd9b10f5523cb2e24ece3c72", + "artifacts/org.ymote.componentdemo.second-0.1.0.bundle/manifest.json": "c67b88e71977efd7e1519f36abbe6122d4cf851d25375837224c66a0ea02a0b2", + "artifacts/org.ymote.componentdemo.second-0.1.0.bundle/preview.png": "14986429d20c20cf92ff2d900ab62b1e33b5fd2feb1b0d2311616696e9943799", + "artifacts/org.ymote.componentdemo.second-0.1.0.bundle/tools.json": "ebb2d25630f8f247c6c5ebfe0dab677ac079aadc87d0ff9df3df90d2de8d5c25", + "artifacts/org.ymote.componentdemo.second-0.1.0.bundle.pack.json": "d20b3688f7579ea840dd39fad82f9d29b375fc6968016e17a00fc79cbf6e0e33", + "catalog-v2.json": "7066fab468aa7b352c806c6871d6dea76e8d0ee64c3f3edd5a75879bd8aadb7a" + }, + "checks": { + "final_app_presented": true, + "final_counter_presented": true, + "native_platform": true, + "compiled_source_identity": true, + "compiled_runtime_identity": true, + "real_github_trust": true, + "deduplicated_readonly_components": true, + "empty_first_declarations": true, + "populated_second_declarations": true, + "all_four_native_turns": true, + "all_28_native_assertions": true, + "native_passed": true + }, + "phone_used": false, + "personal_accounts_used": false, + "public_catalog_modified": false, + "binary_sha256": "0c8e9e0bbbc122ff10fe3d74cb9db31f2ecfbb74789c09f5f21caba68bc36144", + "passed": true +} diff --git a/tools/fixtures/wasm-phone-lab/evidence/shared-components/oneplus6-native.json b/tools/fixtures/wasm-phone-lab/evidence/shared-components/oneplus6-native.json new file mode 100644 index 000000000..0071e7d70 --- /dev/null +++ b/tools/fixtures/wasm-phone-lab/evidence/shared-components/oneplus6-native.json @@ -0,0 +1,1298 @@ +{ + "apps": [ + { + "bundle_digest": "aab50186682b24c201f5bfeb6a4a88c04215bf74e08f1d7fd62824f4926a45ac", + "declarations": [], + "id": "org.ymote.componentdemo.first" + }, + { + "bundle_digest": "2d0d6c3fc6b52db513f13d1f78079a683185413917486ef6a09c2c55fe50c971", + "declarations": [ + "wasm", + "storage" + ], + "id": "org.ymote.componentdemo.second" + } + ], + "catalog_channel": "github", + "catalog_sequence": 16, + "catalog_sha256": "87bde245807a5ff6a1b3297c409d4ef6684414e47b038519a196feab29f42a7e", + "checks": { + "turn_1_alias_discovery": true, + "turn_1_app_identity": true, + "turn_1_markdown": true, + "turn_1_private_storage_after_write": true, + "turn_1_private_storage_before_write": true, + "turn_1_real_host_dispatch": true, + "turn_1_retained_isolated_counter": true, + "turn_2_alias_discovery": true, + "turn_2_app_identity": true, + "turn_2_markdown": true, + "turn_2_private_storage_after_write": true, + "turn_2_private_storage_before_write": true, + "turn_2_real_host_dispatch": true, + "turn_2_retained_isolated_counter": true, + "turn_3_alias_discovery": true, + "turn_3_app_identity": true, + "turn_3_markdown": true, + "turn_3_private_storage_after_write": true, + "turn_3_private_storage_before_write": true, + "turn_3_real_host_dispatch": true, + "turn_3_retained_isolated_counter": true, + "turn_4_alias_discovery": true, + "turn_4_app_identity": true, + "turn_4_markdown": true, + "turn_4_private_storage_after_write": true, + "turn_4_private_storage_before_write": true, + "turn_4_real_host_dispatch": true, + "turn_4_retained_isolated_counter": true + }, + "components": [ + { + "alias": "md", + "blake3": "d7441f84609f1dc177a616be80141933ac729a2fe4cfb09906b8e445773c6122", + "id": "org.ymote.componentdemo.markdown", + "version": "0.1.0" + }, + { + "alias": "bridge", + "blake3": "db454eecc87522886a19dcbe25190d93e313fd8732d777e0e0aa483f0cdaaaca", + "id": "org.ymote.componentdemo.hostcall", + "version": "0.1.0" + } + ], + "deduplicated_readonly_components": true, + "legacy_fallback": false, + "model_started": false, + "not_verified": [ + "performance benchmark", + "live model relay", + "production Home upgrade" + ], + "passed": true, + "personal_accounts_used": false, + "platform": "android", + "proof": "real GitHub catalog and publisher proofs -> Store install -> app tool -> Splash host.request -> shared component", + "runtime_tree": "a6fae94aa4d233503b14bc6d0a36bcbc2b264b44", + "schema": 1, + "source_dirty": false, + "source_revision": "c5f0c5c1948d9b730c707b409e9713af49e3e614", + "turns": [ + { + "app": "org.ymote.componentdemo.first", + "results": { + "before_write": { + "data": null, + "error": "No such file or directory (os error 44)", + "is_ok": false + }, + "count_1": { + "data": 1, + "error": null, + "is_ok": true + }, + "count_2": { + "data": 2, + "error": null, + "is_ok": true + }, + "functions": { + "data": { + "functions": [ + "bridge.call", + "md.analyze", + "md.append_text", + "md.count", + "md.delete_file", + "md.echo_bytes", + "md.file_info", + "md.grow", + "md.now_ms", + "md.random_u64", + "md.read_file", + "md.save_html", + "md.set_len", + "md.spin", + "md.to_html" + ], + "modules": [ + { + "bytes": 320651, + "exports": [ + { + "name": "analyze", + "params": [ + [ + "markdown", + "string" + ] + ], + "result": "record { words: u32, lines: u32, headings: list }", + "wit": "analyze" + }, + { + "name": "append_text", + "params": [ + [ + "path", + "string" + ], + [ + "text", + "string" + ] + ], + "result": "result", + "wit": "append-text" + }, + { + "name": "count", + "params": [], + "result": "u32", + "wit": "count" + }, + { + "name": "delete_file", + "params": [ + [ + "path", + "string" + ] + ], + "result": "result<_, string>", + "wit": "delete-file" + }, + { + "name": "echo_bytes", + "params": [ + [ + "data", + "list" + ] + ], + "result": "list", + "wit": "echo-bytes" + }, + { + "name": "file_info", + "params": [ + [ + "path", + "string" + ] + ], + "result": "result", + "wit": "file-info" + }, + { + "name": "grow", + "params": [ + [ + "megabytes", + "u32" + ] + ], + "result": "u32", + "wit": "grow" + }, + { + "name": "now_ms", + "params": [], + "result": "u64", + "wit": "now-ms" + }, + { + "name": "random_u64", + "params": [], + "result": "u64", + "wit": "random-u64" + }, + { + "name": "read_file", + "params": [ + [ + "path", + "string" + ] + ], + "result": "result", + "wit": "read-file" + }, + { + "name": "save_html", + "params": [ + [ + "markdown", + "string" + ], + [ + "path", + "string" + ] + ], + "result": "result", + "wit": "save-html" + }, + { + "name": "set_len", + "params": [ + [ + "path", + "string" + ], + [ + "len", + "u64" + ] + ], + "result": "result<_, string>", + "wit": "set-len" + }, + { + "name": "spin", + "params": [], + "result": null, + "wit": "spin" + }, + { + "name": "to_html", + "params": [ + [ + "markdown", + "string" + ] + ], + "result": "string", + "wit": "to-html" + } + ], + "file": "md (org.ymote.componentdemo.markdown 0.1.0)", + "from_cache": false, + "instance_policy": "kept-between-calls", + "instances": 1, + "invocations": 6, + "kind": "component", + "live": true, + "load_ms": 287.3, + "network": false, + "shared": { + "alias": "md", + "blake3": "d7441f84609f1dc177a616be80141933ac729a2fe4cfb09906b8e445773c6122", + "id": "org.ymote.componentdemo.markdown", + "version": "0.1.0" + }, + "skipped": [], + "storage": "app folder", + "storage_left": 16777183 + }, + { + "bytes": 42284, + "exports": [ + { + "name": "call", + "params": [ + [ + "service", + "string" + ], + [ + "args", + "string" + ] + ], + "result": "result", + "wit": "call" + } + ], + "file": "bridge (org.ymote.componentdemo.hostcall 0.1.0)", + "from_cache": false, + "instance_policy": "kept-between-calls", + "instances": 1, + "invocations": 1, + "kind": "component", + "live": true, + "load_ms": 60.3, + "network": false, + "shared": { + "alias": "bridge", + "blake3": "db454eecc87522886a19dcbe25190d93e313fd8732d777e0e0aa483f0cdaaaca", + "id": "org.ymote.componentdemo.hostcall", + "version": "0.1.0" + }, + "skipped": [], + "storage": "app folder", + "storage_left": 16777183 + } + ], + "stats": { + "bridge.call": { + "calls": 1, + "errors": 0, + "max_us": 2879.3, + "mean_us": 2879.3 + }, + "md.count": { + "calls": 2, + "errors": 0, + "max_us": 1282.3, + "mean_us": 659.2 + }, + "md.read_file": { + "calls": 2, + "errors": 1, + "max_us": 1138.1, + "mean_us": 1073.6 + }, + "md.save_html": { + "calls": 1, + "errors": 0, + "max_us": 878.6, + "mean_us": 878.6 + }, + "md.to_html": { + "calls": 1, + "errors": 0, + "max_us": 134.6, + "mean_us": 134.6 + } + } + }, + "error": null, + "is_ok": true + }, + "host_call": { + "data": "{\"authorization\":\"checked-on-call\",\"configured\":null,\"descriptor\":{\"agent_access\":\"allowed\",\"capability\":\"runtime\",\"input_schema\":{\"additionalProperties\":false,\"properties\":{\"method\":{\"type\":\"string\"}},\"required\":[\"method\"],\"type\":\"object\"},\"name\":\"runtime.describe\",\"output_schema\":{\"type\":\"object\"},\"platforms\":[\"android\",\"macos\",\"windows\",\"linux\",\"ios\",\"openharmony\",\"web\"],\"summary\":\"Describe one host API or report that this host does not implement it.\",\"version\":1},\"implemented\":true,\"supported\":true}", + "error": null, + "is_ok": true + }, + "markdown": { + "data": "

Component Demo \u00b7 First

\n", + "error": null, + "is_ok": true + }, + "read_back": { + "data": "

Component Demo \u00b7 First

\n", + "error": null, + "is_ok": true + }, + "saved": { + "data": 33, + "error": null, + "is_ok": true + } + } + }, + { + "app": "org.ymote.componentdemo.second", + "results": { + "before_write": { + "data": null, + "error": "No such file or directory (os error 44)", + "is_ok": false + }, + "count_1": { + "data": 1, + "error": null, + "is_ok": true + }, + "count_2": { + "data": 2, + "error": null, + "is_ok": true + }, + "functions": { + "data": { + "functions": [ + "bridge.call", + "md.analyze", + "md.append_text", + "md.count", + "md.delete_file", + "md.echo_bytes", + "md.file_info", + "md.grow", + "md.now_ms", + "md.random_u64", + "md.read_file", + "md.save_html", + "md.set_len", + "md.spin", + "md.to_html" + ], + "modules": [ + { + "bytes": 320651, + "exports": [ + { + "name": "analyze", + "params": [ + [ + "markdown", + "string" + ] + ], + "result": "record { words: u32, lines: u32, headings: list }", + "wit": "analyze" + }, + { + "name": "append_text", + "params": [ + [ + "path", + "string" + ], + [ + "text", + "string" + ] + ], + "result": "result", + "wit": "append-text" + }, + { + "name": "count", + "params": [], + "result": "u32", + "wit": "count" + }, + { + "name": "delete_file", + "params": [ + [ + "path", + "string" + ] + ], + "result": "result<_, string>", + "wit": "delete-file" + }, + { + "name": "echo_bytes", + "params": [ + [ + "data", + "list" + ] + ], + "result": "list", + "wit": "echo-bytes" + }, + { + "name": "file_info", + "params": [ + [ + "path", + "string" + ] + ], + "result": "result", + "wit": "file-info" + }, + { + "name": "grow", + "params": [ + [ + "megabytes", + "u32" + ] + ], + "result": "u32", + "wit": "grow" + }, + { + "name": "now_ms", + "params": [], + "result": "u64", + "wit": "now-ms" + }, + { + "name": "random_u64", + "params": [], + "result": "u64", + "wit": "random-u64" + }, + { + "name": "read_file", + "params": [ + [ + "path", + "string" + ] + ], + "result": "result", + "wit": "read-file" + }, + { + "name": "save_html", + "params": [ + [ + "markdown", + "string" + ], + [ + "path", + "string" + ] + ], + "result": "result", + "wit": "save-html" + }, + { + "name": "set_len", + "params": [ + [ + "path", + "string" + ], + [ + "len", + "u64" + ] + ], + "result": "result<_, string>", + "wit": "set-len" + }, + { + "name": "spin", + "params": [], + "result": null, + "wit": "spin" + }, + { + "name": "to_html", + "params": [ + [ + "markdown", + "string" + ] + ], + "result": "string", + "wit": "to-html" + } + ], + "file": "md (org.ymote.componentdemo.markdown 0.1.0)", + "from_cache": true, + "instance_policy": "kept-between-calls", + "instances": 1, + "invocations": 6, + "kind": "component", + "live": true, + "load_ms": 5, + "network": false, + "shared": { + "alias": "md", + "blake3": "d7441f84609f1dc177a616be80141933ac729a2fe4cfb09906b8e445773c6122", + "id": "org.ymote.componentdemo.markdown", + "version": "0.1.0" + }, + "skipped": [], + "storage": "app folder", + "storage_left": 16777182 + }, + { + "bytes": 42284, + "exports": [ + { + "name": "call", + "params": [ + [ + "service", + "string" + ], + [ + "args", + "string" + ] + ], + "result": "result", + "wit": "call" + } + ], + "file": "bridge (org.ymote.componentdemo.hostcall 0.1.0)", + "from_cache": true, + "instance_policy": "kept-between-calls", + "instances": 1, + "invocations": 1, + "kind": "component", + "live": true, + "load_ms": 1.6, + "network": false, + "shared": { + "alias": "bridge", + "blake3": "db454eecc87522886a19dcbe25190d93e313fd8732d777e0e0aa483f0cdaaaca", + "id": "org.ymote.componentdemo.hostcall", + "version": "0.1.0" + }, + "skipped": [], + "storage": "app folder", + "storage_left": 16777182 + } + ], + "stats": { + "bridge.call": { + "calls": 1, + "errors": 0, + "max_us": 1541.7, + "mean_us": 1541.7 + }, + "md.count": { + "calls": 2, + "errors": 0, + "max_us": 889.1, + "mean_us": 462.1 + }, + "md.read_file": { + "calls": 2, + "errors": 1, + "max_us": 1274.6, + "mean_us": 713.7 + }, + "md.save_html": { + "calls": 1, + "errors": 0, + "max_us": 5106.3, + "mean_us": 5106.3 + }, + "md.to_html": { + "calls": 1, + "errors": 0, + "max_us": 110.5, + "mean_us": 110.5 + } + } + }, + "error": null, + "is_ok": true + }, + "host_call": { + "data": "{\"authorization\":\"checked-on-call\",\"configured\":null,\"descriptor\":{\"agent_access\":\"allowed\",\"capability\":\"runtime\",\"input_schema\":{\"additionalProperties\":false,\"properties\":{\"method\":{\"type\":\"string\"}},\"required\":[\"method\"],\"type\":\"object\"},\"name\":\"runtime.describe\",\"output_schema\":{\"type\":\"object\"},\"platforms\":[\"android\",\"macos\",\"windows\",\"linux\",\"ios\",\"openharmony\",\"web\"],\"summary\":\"Describe one host API or report that this host does not implement it.\",\"version\":1},\"implemented\":true,\"supported\":true}", + "error": null, + "is_ok": true + }, + "markdown": { + "data": "

Component Demo \u00b7 Second

\n", + "error": null, + "is_ok": true + }, + "read_back": { + "data": "

Component Demo \u00b7 Second

\n", + "error": null, + "is_ok": true + }, + "saved": { + "data": 34, + "error": null, + "is_ok": true + } + } + }, + { + "app": "org.ymote.componentdemo.first", + "results": { + "before_write": { + "data": "

Component Demo \u00b7 First

\n", + "error": null, + "is_ok": true + }, + "count_1": { + "data": 3, + "error": null, + "is_ok": true + }, + "count_2": { + "data": 4, + "error": null, + "is_ok": true + }, + "functions": { + "data": { + "functions": [ + "bridge.call", + "md.analyze", + "md.append_text", + "md.count", + "md.delete_file", + "md.echo_bytes", + "md.file_info", + "md.grow", + "md.now_ms", + "md.random_u64", + "md.read_file", + "md.save_html", + "md.set_len", + "md.spin", + "md.to_html" + ], + "modules": [ + { + "bytes": 320651, + "exports": [ + { + "name": "analyze", + "params": [ + [ + "markdown", + "string" + ] + ], + "result": "record { words: u32, lines: u32, headings: list }", + "wit": "analyze" + }, + { + "name": "append_text", + "params": [ + [ + "path", + "string" + ], + [ + "text", + "string" + ] + ], + "result": "result", + "wit": "append-text" + }, + { + "name": "count", + "params": [], + "result": "u32", + "wit": "count" + }, + { + "name": "delete_file", + "params": [ + [ + "path", + "string" + ] + ], + "result": "result<_, string>", + "wit": "delete-file" + }, + { + "name": "echo_bytes", + "params": [ + [ + "data", + "list" + ] + ], + "result": "list", + "wit": "echo-bytes" + }, + { + "name": "file_info", + "params": [ + [ + "path", + "string" + ] + ], + "result": "result", + "wit": "file-info" + }, + { + "name": "grow", + "params": [ + [ + "megabytes", + "u32" + ] + ], + "result": "u32", + "wit": "grow" + }, + { + "name": "now_ms", + "params": [], + "result": "u64", + "wit": "now-ms" + }, + { + "name": "random_u64", + "params": [], + "result": "u64", + "wit": "random-u64" + }, + { + "name": "read_file", + "params": [ + [ + "path", + "string" + ] + ], + "result": "result", + "wit": "read-file" + }, + { + "name": "save_html", + "params": [ + [ + "markdown", + "string" + ], + [ + "path", + "string" + ] + ], + "result": "result", + "wit": "save-html" + }, + { + "name": "set_len", + "params": [ + [ + "path", + "string" + ], + [ + "len", + "u64" + ] + ], + "result": "result<_, string>", + "wit": "set-len" + }, + { + "name": "spin", + "params": [], + "result": null, + "wit": "spin" + }, + { + "name": "to_html", + "params": [ + [ + "markdown", + "string" + ] + ], + "result": "string", + "wit": "to-html" + } + ], + "file": "md (org.ymote.componentdemo.markdown 0.1.0)", + "from_cache": false, + "instance_policy": "kept-between-calls", + "instances": 1, + "invocations": 12, + "kind": "component", + "live": true, + "load_ms": 287.3, + "network": false, + "shared": { + "alias": "md", + "blake3": "d7441f84609f1dc177a616be80141933ac729a2fe4cfb09906b8e445773c6122", + "id": "org.ymote.componentdemo.markdown", + "version": "0.1.0" + }, + "skipped": [], + "storage": "app folder", + "storage_left": 16777183 + }, + { + "bytes": 42284, + "exports": [ + { + "name": "call", + "params": [ + [ + "service", + "string" + ], + [ + "args", + "string" + ] + ], + "result": "result", + "wit": "call" + } + ], + "file": "bridge (org.ymote.componentdemo.hostcall 0.1.0)", + "from_cache": false, + "instance_policy": "kept-between-calls", + "instances": 1, + "invocations": 2, + "kind": "component", + "live": true, + "load_ms": 60.3, + "network": false, + "shared": { + "alias": "bridge", + "blake3": "db454eecc87522886a19dcbe25190d93e313fd8732d777e0e0aa483f0cdaaaca", + "id": "org.ymote.componentdemo.hostcall", + "version": "0.1.0" + }, + "skipped": [], + "storage": "app folder", + "storage_left": 16777183 + } + ], + "stats": { + "bridge.call": { + "calls": 2, + "errors": 0, + "max_us": 2879.3, + "mean_us": 1967.4 + }, + "md.count": { + "calls": 4, + "errors": 0, + "max_us": 1282.3, + "mean_us": 347 + }, + "md.read_file": { + "calls": 4, + "errors": 1, + "max_us": 1138.1, + "mean_us": 813.7 + }, + "md.save_html": { + "calls": 2, + "errors": 0, + "max_us": 878.6, + "mean_us": 579.2 + }, + "md.to_html": { + "calls": 2, + "errors": 0, + "max_us": 134.6, + "mean_us": 107.1 + } + } + }, + "error": null, + "is_ok": true + }, + "host_call": { + "data": "{\"authorization\":\"checked-on-call\",\"configured\":null,\"descriptor\":{\"agent_access\":\"allowed\",\"capability\":\"runtime\",\"input_schema\":{\"additionalProperties\":false,\"properties\":{\"method\":{\"type\":\"string\"}},\"required\":[\"method\"],\"type\":\"object\"},\"name\":\"runtime.describe\",\"output_schema\":{\"type\":\"object\"},\"platforms\":[\"android\",\"macos\",\"windows\",\"linux\",\"ios\",\"openharmony\",\"web\"],\"summary\":\"Describe one host API or report that this host does not implement it.\",\"version\":1},\"implemented\":true,\"supported\":true}", + "error": null, + "is_ok": true + }, + "markdown": { + "data": "

Component Demo \u00b7 First

\n", + "error": null, + "is_ok": true + }, + "read_back": { + "data": "

Component Demo \u00b7 First

\n", + "error": null, + "is_ok": true + }, + "saved": { + "data": 33, + "error": null, + "is_ok": true + } + } + }, + { + "app": "org.ymote.componentdemo.second", + "results": { + "before_write": { + "data": "

Component Demo \u00b7 Second

\n", + "error": null, + "is_ok": true + }, + "count_1": { + "data": 3, + "error": null, + "is_ok": true + }, + "count_2": { + "data": 4, + "error": null, + "is_ok": true + }, + "functions": { + "data": { + "functions": [ + "bridge.call", + "md.analyze", + "md.append_text", + "md.count", + "md.delete_file", + "md.echo_bytes", + "md.file_info", + "md.grow", + "md.now_ms", + "md.random_u64", + "md.read_file", + "md.save_html", + "md.set_len", + "md.spin", + "md.to_html" + ], + "modules": [ + { + "bytes": 320651, + "exports": [ + { + "name": "analyze", + "params": [ + [ + "markdown", + "string" + ] + ], + "result": "record { words: u32, lines: u32, headings: list }", + "wit": "analyze" + }, + { + "name": "append_text", + "params": [ + [ + "path", + "string" + ], + [ + "text", + "string" + ] + ], + "result": "result", + "wit": "append-text" + }, + { + "name": "count", + "params": [], + "result": "u32", + "wit": "count" + }, + { + "name": "delete_file", + "params": [ + [ + "path", + "string" + ] + ], + "result": "result<_, string>", + "wit": "delete-file" + }, + { + "name": "echo_bytes", + "params": [ + [ + "data", + "list" + ] + ], + "result": "list", + "wit": "echo-bytes" + }, + { + "name": "file_info", + "params": [ + [ + "path", + "string" + ] + ], + "result": "result", + "wit": "file-info" + }, + { + "name": "grow", + "params": [ + [ + "megabytes", + "u32" + ] + ], + "result": "u32", + "wit": "grow" + }, + { + "name": "now_ms", + "params": [], + "result": "u64", + "wit": "now-ms" + }, + { + "name": "random_u64", + "params": [], + "result": "u64", + "wit": "random-u64" + }, + { + "name": "read_file", + "params": [ + [ + "path", + "string" + ] + ], + "result": "result", + "wit": "read-file" + }, + { + "name": "save_html", + "params": [ + [ + "markdown", + "string" + ], + [ + "path", + "string" + ] + ], + "result": "result", + "wit": "save-html" + }, + { + "name": "set_len", + "params": [ + [ + "path", + "string" + ], + [ + "len", + "u64" + ] + ], + "result": "result<_, string>", + "wit": "set-len" + }, + { + "name": "spin", + "params": [], + "result": null, + "wit": "spin" + }, + { + "name": "to_html", + "params": [ + [ + "markdown", + "string" + ] + ], + "result": "string", + "wit": "to-html" + } + ], + "file": "md (org.ymote.componentdemo.markdown 0.1.0)", + "from_cache": true, + "instance_policy": "kept-between-calls", + "instances": 1, + "invocations": 12, + "kind": "component", + "live": true, + "load_ms": 5, + "network": false, + "shared": { + "alias": "md", + "blake3": "d7441f84609f1dc177a616be80141933ac729a2fe4cfb09906b8e445773c6122", + "id": "org.ymote.componentdemo.markdown", + "version": "0.1.0" + }, + "skipped": [], + "storage": "app folder", + "storage_left": 16777182 + }, + { + "bytes": 42284, + "exports": [ + { + "name": "call", + "params": [ + [ + "service", + "string" + ], + [ + "args", + "string" + ] + ], + "result": "result", + "wit": "call" + } + ], + "file": "bridge (org.ymote.componentdemo.hostcall 0.1.0)", + "from_cache": true, + "instance_policy": "kept-between-calls", + "instances": 1, + "invocations": 2, + "kind": "component", + "live": true, + "load_ms": 1.6, + "network": false, + "shared": { + "alias": "bridge", + "blake3": "db454eecc87522886a19dcbe25190d93e313fd8732d777e0e0aa483f0cdaaaca", + "id": "org.ymote.componentdemo.hostcall", + "version": "0.1.0" + }, + "skipped": [], + "storage": "app folder", + "storage_left": 16777182 + } + ], + "stats": { + "bridge.call": { + "calls": 2, + "errors": 0, + "max_us": 1541.7, + "mean_us": 1135.1 + }, + "md.count": { + "calls": 4, + "errors": 0, + "max_us": 889.1, + "mean_us": 249.2 + }, + "md.read_file": { + "calls": 4, + "errors": 1, + "max_us": 1274.6, + "mean_us": 923.7 + }, + "md.save_html": { + "calls": 2, + "errors": 0, + "max_us": 5106.3, + "mean_us": 2693 + }, + "md.to_html": { + "calls": 2, + "errors": 0, + "max_us": 110.5, + "mean_us": 94.4 + } + } + }, + "error": null, + "is_ok": true + }, + "host_call": { + "data": "{\"authorization\":\"checked-on-call\",\"configured\":null,\"descriptor\":{\"agent_access\":\"allowed\",\"capability\":\"runtime\",\"input_schema\":{\"additionalProperties\":false,\"properties\":{\"method\":{\"type\":\"string\"}},\"required\":[\"method\"],\"type\":\"object\"},\"name\":\"runtime.describe\",\"output_schema\":{\"type\":\"object\"},\"platforms\":[\"android\",\"macos\",\"windows\",\"linux\",\"ios\",\"openharmony\",\"web\"],\"summary\":\"Describe one host API or report that this host does not implement it.\",\"version\":1},\"implemented\":true,\"supported\":true}", + "error": null, + "is_ok": true + }, + "markdown": { + "data": "

Component Demo \u00b7 Second

\n", + "error": null, + "is_ok": true + }, + "read_back": { + "data": "

Component Demo \u00b7 Second

\n", + "error": null, + "is_ok": true + }, + "saved": { + "data": 34, + "error": null, + "is_ok": true + } + } + } + ] +} diff --git a/tools/fixtures/wasm-phone-lab/evidence/shared-components/oneplus6.json b/tools/fixtures/wasm-phone-lab/evidence/shared-components/oneplus6.json new file mode 100644 index 000000000..2a75a845f --- /dev/null +++ b/tools/fixtures/wasm-phone-lab/evidence/shared-components/oneplus6.json @@ -0,0 +1,48 @@ +{ + "schema": 1, + "source_revision": "c5f0c5c1948d9b730c707b409e9713af49e3e614", + "runtime_tree": "a6fae94aa4d233503b14bc6d0a36bcbc2b264b44", + "mirror_sha256": { + "artifacts/org.ymote.componentdemo.first-0.1.0.bundle/AGENT.md": "906939643fd742f8e6db8dd8f505c71a1593b9c843c8d01cc7c231fe295a140d", + "artifacts/org.ymote.componentdemo.first-0.1.0.bundle/icon.svg": "d031ea03898c1f459e5ac86eaea51d37eef021cef44c85add12f64163cb1ecfe", + "artifacts/org.ymote.componentdemo.first-0.1.0.bundle/listing.json": "5345e58a77833a286a5b9ecc65be887c3dcf372cbc65a9ebf269d71c6730a9f0", + "artifacts/org.ymote.componentdemo.first-0.1.0.bundle/main.splash": "5e764fbd3eedb6d0bad305f0013d66f4b481c2fedc8f5f3280dede43b3f252fc", + "artifacts/org.ymote.componentdemo.first-0.1.0.bundle/manifest.json": "738fd189c7e8e0747b97c18af3aaf49d6c632ba8b1b5a05c1b57ff4ef7a428f7", + "artifacts/org.ymote.componentdemo.first-0.1.0.bundle/preview.png": "16eaf55bd6c2b6855adf20ebf0b9041ad9be9e1088e3b10b7e1b9ed6994304c1", + "artifacts/org.ymote.componentdemo.first-0.1.0.bundle/tools.json": "25fc72dd248fd687cc60e605e5ea67d8f2515223df92d61ca171079b5804431e", + "artifacts/org.ymote.componentdemo.first-0.1.0.bundle.pack.json": "dce3991bfcc4ec342a8b4a61dece3f268d7b18551c2d557a1199e8ed26232980", + "artifacts/org.ymote.componentdemo.hostcall-0.1.0.wasm": "9a4cb02261033885a08eaeba490d1ca3bd81db9ebcdc77aff897d529afe6859e", + "artifacts/org.ymote.componentdemo.markdown-0.1.0.wasm": "2954a01883d376430348625a7ff26bcc7978c385d3e78d6e61bf74ff46ef455f", + "artifacts/org.ymote.componentdemo.second-0.1.0.bundle/AGENT.md": "c26a070abd33426c2ba4c789c298dfde661a38e2bc1cfd82e344bb04542c09a1", + "artifacts/org.ymote.componentdemo.second-0.1.0.bundle/icon.svg": "d031ea03898c1f459e5ac86eaea51d37eef021cef44c85add12f64163cb1ecfe", + "artifacts/org.ymote.componentdemo.second-0.1.0.bundle/listing.json": "5345e58a77833a286a5b9ecc65be887c3dcf372cbc65a9ebf269d71c6730a9f0", + "artifacts/org.ymote.componentdemo.second-0.1.0.bundle/main.splash": "d581213a8fbf995f93d2dbb3dc3e58373d8fbcb3cd9b10f5523cb2e24ece3c72", + "artifacts/org.ymote.componentdemo.second-0.1.0.bundle/manifest.json": "c67b88e71977efd7e1519f36abbe6122d4cf851d25375837224c66a0ea02a0b2", + "artifacts/org.ymote.componentdemo.second-0.1.0.bundle/preview.png": "14986429d20c20cf92ff2d900ab62b1e33b5fd2feb1b0d2311616696e9943799", + "artifacts/org.ymote.componentdemo.second-0.1.0.bundle/tools.json": "ebb2d25630f8f247c6c5ebfe0dab677ac079aadc87d0ff9df3df90d2de8d5c25", + "artifacts/org.ymote.componentdemo.second-0.1.0.bundle.pack.json": "d20b3688f7579ea840dd39fad82f9d29b375fc6968016e17a00fc79cbf6e0e33", + "catalog-v2.json": "7066fab468aa7b352c806c6871d6dea76e8d0ee64c3f3edd5a75879bd8aadb7a" + }, + "checks": { + "assigned_oneplus6": true, + "test_package_absent": true, + "isolated_debuggable_apk": true, + "native_platform": true, + "compiled_source_identity": true, + "compiled_runtime_identity": true, + "real_github_trust": true, + "deduplicated_readonly_components": true, + "empty_first_declarations": true, + "populated_second_declarations": true, + "all_four_native_turns": true, + "all_28_native_assertions": true, + "native_passed": true + }, + "phone_used": true, + "personal_accounts_used": false, + "public_catalog_modified": false, + "apk_sha256": "e573b02c28566ba767338a3a8d13d3b21d0958177f8de6d53ab4e562ed3f8523", + "package": "dev.makepad.octosense.hostapilab.shared1", + "passed": true, + "owned_test_package_removed": true +} diff --git a/tools/runtime-patches/makepad-camera-capture-intent.patch b/tools/runtime-patches/makepad-camera-capture-intent.patch new file mode 100644 index 000000000..40e3b4c93 --- /dev/null +++ b/tools/runtime-patches/makepad-camera-capture-intent.patch @@ -0,0 +1,395 @@ +diff --git a/widgets/src/camera_preview.rs b/widgets/src/camera_preview.rs +index dfedf1a5a..5c7db9850 100644 +--- a/widgets/src/camera_preview.rs ++++ b/widgets/src/camera_preview.rs +@@ -7,7 +7,9 @@ + //! on_error: || say(ui.cam.error())} + //! ui.cam.start() ui.cam.stop() ui.cam.switch() ui.cam.facing() + //! ui.cam.capture() // a JPEG into the app's storage +-//! ui.cam.record_start() ui.cam.record_stop() // an MP4 ++//! ui.cam.capture({library: true}) // explicit gallery export, where supported ++//! ui.cam.record_start({audio: true}) // an MP4 with explicitly requested sound ++//! ui.cam.record_stop() + //! ui.cam.set_zoom(2.0) ui.cam.set_flash("off"|"on"|"auto"|"torch") + //! ui.cam.set_exposure(-1.0) ui.cam.focus(x, y) // x, y 0..1 in the preview + //! ui.cam.set_aspect(16 / 9) // the viewfinder shape; reopens the camera +@@ -19,9 +21,12 @@ + //! + //! Frames never pass through script: the preview is a platform texture, and + //! script hears one call per finished capture. What an app under a policy +-//! gets: `camera` to open it at all; captures land in its own storage jail; +-//! `microphone` adds sound to a recording; `library` also offers each capture +-//! to the system photo library. ++//! gets: camera access is still checked; captures land in its own storage jail. ++//! Microphone permission and library availability do not select behavior: ++//! each call must request `audio: true` or `library: true` explicitly. Both ++//! default to false. Requested audio still needs microphone consent and OS ++//! permission. Library export requires foreground authority and a supported ++//! backend; unsupported export fails before taking the capture. + //! + //! The camera is a device the person can see is on, so it is released as soon + //! as it is not needed: when the preview has not drawn for a second (its tile +@@ -57,6 +62,21 @@ + } + } + ++/// Per-call intent, independent of the APIs the host makes available. ++#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] ++struct CaptureIntent { ++ audio: bool, ++ library: bool, ++} ++ ++#[derive(Clone, Copy, Debug)] ++struct AudioPermissionRequest { ++ id: i32, ++ prompted: bool, ++ may_prompt: bool, ++ intent: CaptureIntent, ++} ++ + const WATCHDOG_SECONDS: f64 = 0.5; + /// How long a camera may be without a permission answer before it opens + /// anyway: a bare desktop binary never gets the prompt's completion, and the +@@ -229,7 +249,7 @@ pub struct CameraPreview { + #[rust] + permission_may_prompt: bool, + #[rust] +- audio_permission_request: Option<(i32, bool, bool)>, ++ audio_permission_request: Option, + #[rust] + timer: Option, + #[rust] +@@ -461,8 +481,28 @@ fn capture_path(&self, cx: &Cx, kind: &str, ext: &str) -> Option<(String, String + Some((relative, real)) + } + +- fn capture(&mut self, cx: &mut Cx) -> bool { ++ fn validate_capture_intent(&mut self, cx: &mut Cx, intent: CaptureIntent) -> bool { ++ let failure = if intent.library && !crate::splash_policy::device_prompt_allowed(self.heap_key()) { ++ Some("foreground_required: open the app to export a capture to the photo library") ++ } else if intent.library && !cfg!(target_env = "ohos") { ++ Some("unsupported_platform: photo-library export is unavailable; use library: false to keep the capture in app storage") ++ } else if intent.library && !self.granted("library.write") { ++ Some("authorization_required: the host has not enabled photo-library export") ++ } else if intent.audio && !self.granted("microphone.record") { ++ Some("authorization_required: request microphone.permission.request from the foreground app before recording audio") ++ } else { ++ None ++ }; ++ if let Some(error) = failure { ++ self.fail(cx, error.into()); ++ return false; ++ } ++ true ++ } ++ ++ fn capture(&mut self, cx: &mut Cx, intent: CaptureIntent) -> bool { + if !self.granted("camera.preview") { self.stop(cx); return false; } ++ if !self.validate_capture_intent(cx, intent) { return false; } + let (Some(input), State::Running) = (self.input, self.state) else { + self.fail(cx, "the camera is not running".into()); + return false; +@@ -472,13 +512,13 @@ fn capture(&mut self, cx: &mut Cx) -> bool { + return false; + }; + self.last = relative; +- let library = self.granted("library.write"); +- cx.camera_capture(input, CameraCaptureRequest::Photo { path: real, library }); ++ cx.camera_capture(input, CameraCaptureRequest::Photo { path: real, library: intent.library }); + true + } + +- fn record_start(&mut self, cx: &mut Cx) -> bool { ++ fn record_start(&mut self, cx: &mut Cx, intent: CaptureIntent) -> bool { + if !self.granted("camera.preview") { self.stop(cx); return false; } ++ if !self.validate_capture_intent(cx, intent) { return false; } + let (Some(_), State::Running) = (self.input, self.state) else { + self.fail(cx, "the camera is not running".into()); + return false; +@@ -486,31 +526,34 @@ fn record_start(&mut self, cx: &mut Cx) -> bool { + if self.recording.is_some() || self.audio_permission_request.is_some() { + return true; + } +- let audio = self.granted("microphone.record"); +- if audio && crate::splash_policy::device_consent_required(self.heap_key()) { +- self.audio_permission_request = Some((cx.check_permission(Permission::AudioInput), false, +- crate::splash_policy::device_prompt_allowed(self.heap_key()))); ++ if intent.audio && crate::splash_policy::device_consent_required(self.heap_key()) { ++ self.audio_permission_request = Some(AudioPermissionRequest { ++ id: cx.check_permission(Permission::AudioInput), ++ prompted: false, ++ may_prompt: crate::splash_policy::device_prompt_allowed(self.heap_key()), ++ intent, ++ }); + return true; + } +- if audio { cx.request_permission(Permission::AudioInput); } +- self.begin_recording(cx, audio) ++ if intent.audio { cx.request_permission(Permission::AudioInput); } ++ self.begin_recording(cx, intent) + } + +- fn begin_recording(&mut self, cx: &mut Cx, audio: bool) -> bool { +- if !self.granted("camera.preview") || (audio && !self.granted("microphone.record")) { ++ fn begin_recording(&mut self, cx: &mut Cx, intent: CaptureIntent) -> bool { ++ if !self.granted("camera.preview") || (intent.audio && !self.granted("microphone.record")) { + self.stop(cx); + return false; + } ++ if !self.validate_capture_intent(cx, intent) { return false; } + let (Some(input), State::Running) = (self.input, self.state) else { return false; }; + let Some((relative, real)) = self.capture_path(cx, "VID", "mp4") else { + self.fail(cx, "this app's storage is full or missing".into()); + return false; + }; +- self.recording_audio = audio; ++ self.recording_audio = intent.audio; + self.recording = Some(relative); + self.set_recording_input(Some(input)); +- let library = self.granted("library.write"); +- cx.camera_capture(input, CameraCaptureRequest::StartVideo { path: real, audio, library }); ++ cx.camera_capture(input, CameraCaptureRequest::StartVideo { path: real, audio: intent.audio, library: intent.library }); + true + } + +@@ -606,6 +649,41 @@ fn arg_string(vm: &mut ScriptVm, args: ScriptValue, index: usize) -> Option Result { ++ let Some(args) = args.as_object() else { ++ return if args.is_nil() { Ok(CaptureIntent::default()) } else { Err("bad_request: pass an options object with boolean audio/library fields") }; ++ }; ++ match vm.bx.heap.vec_len(args) { ++ 0 => return Ok(CaptureIntent::default()), ++ 1 => {}, ++ _ => return Err("bad_request: capture accepts one options object"), ++ } ++ let value = vm.bx.heap.vec_value_if_exist(args, 0).unwrap_or(NIL); ++ let Some(options) = value.as_object() else { ++ return Err("bad_request: capture options must be an object, such as {library: false}"); ++ }; ++ if vm.bx.heap.vec_len(options) != 0 || vm.bx.heap.map_len(options) > 2 { ++ return Err("bad_request: only boolean audio/library capture options are supported"); ++ } ++ let mut fields = Vec::new(); ++ vm.bx.heap.object_data(options).map_iter(|key, value| fields.push((key, value))); ++ let mut intent = CaptureIntent::default(); ++ for (key, value) in fields { ++ let name = key.as_id().or_else(|| vm.bx.heap.cast_to_owned_string(key, "reading capture option").map(|key| LiveId::from_str(&key))); ++ let Some(value) = value.as_bool() else { ++ return Err("bad_request: audio and library must be true or false, not strings or numbers"); ++ }; ++ match name { ++ Some(name) if name == live_id!(library) => intent.library = value, ++ Some(name) if name == live_id!(audio) && recording => intent.audio = value, ++ _ => return Err("bad_request: capture supports library; record_start supports audio and library"), ++ } ++ } ++ Ok(intent) ++} ++ + impl Widget for CameraPreview { + fn script_call(&mut self, vm: &mut ScriptVm, method: LiveId, args: ScriptValue) -> ScriptAsyncResult { + let ret = |v: ScriptValue| ScriptAsyncResult::Return(v); +@@ -635,8 +713,18 @@ fn script_call(&mut self, vm: &mut ScriptVm, method: LiveId, args: ScriptValue) + ret(NIL) + } + m if m == live_id!(facing) => ret(vm.bx.heap.new_string_from_str(if self.facing == "front" { "front" } else { "back" })), +- m if m == live_id!(capture) => ret(vm.with_cx_mut(|cx| self.capture(cx)).into()), +- m if m == live_id!(record_start) => ret(vm.with_cx_mut(|cx| self.record_start(cx)).into()), ++ m if m == live_id!(capture) || m == live_id!(record_start) => { ++ let recording = m == live_id!(record_start); ++ match capture_intent(vm, args, recording) { ++ Ok(intent) => ret(vm.with_cx_mut(|cx| { ++ if recording { self.record_start(cx, intent) } else { self.capture(cx, intent) } ++ }).into()), ++ Err(error) => { ++ vm.with_cx_mut(|cx| self.fail(cx, error.into())); ++ ret(false.into()) ++ } ++ } ++ } + m if m == live_id!(record_stop) => { + vm.with_cx_mut(|cx| self.record_stop(cx)); + ret(NIL) +@@ -731,16 +819,18 @@ fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + self.drive(cx); + } + Event::PermissionResult(result) if result.permission == Permission::AudioInput +- && self.audio_permission_request.is_some_and(|(id, _, _)| id == result.request_id) => { +- let (_, prompted, may_prompt) = self.audio_permission_request.take().unwrap(); ++ && self.audio_permission_request.is_some_and(|request| request.id == result.request_id) => { ++ let mut request = self.audio_permission_request.take().unwrap(); + if !self.granted("camera.preview") || !self.granted("microphone.record") || self.state != State::Running { + return; + } + if result.status == PermissionStatus::Granted { +- self.begin_recording(cx, true); +- } else if !prompted && may_prompt && crate::splash_policy::device_prompt_allowed(self.heap_key()) ++ self.begin_recording(cx, request.intent); ++ } else if !request.prompted && request.may_prompt && crate::splash_policy::device_prompt_allowed(self.heap_key()) + && matches!(result.status, PermissionStatus::NotDetermined | PermissionStatus::DeniedCanRetry) { +- self.audio_permission_request = Some((cx.request_permission(Permission::AudioInput), true, may_prompt)); ++ request.id = cx.request_permission(Permission::AudioInput); ++ request.prompted = true; ++ self.audio_permission_request = Some(request); + } else { + self.fail(cx, format!("microphone permission {:?}; open the app to authorize recording", result.status)); + } +@@ -803,6 +893,116 @@ fn format(width: usize, height: usize, pixel_format: VideoPixelFormat, id: u64) + VideoFormat { format_id: VideoFormatId(LiveId(id)), width, height, frame_rate: Some(30.0), pixel_format } + } + ++ #[test] ++ fn capture_options_require_explicit_boolean_intent() { ++ let mut cx = Cx::new(Box::new(|_, _| {})); ++ cx.with_vm(|vm| { ++ let args_for = |vm: &mut ScriptVm, value: Option| { ++ let args = vm.bx.heap.new_object(); ++ if let Some(value) = value { ++ let trap = vm.bx.threads.cur().trap.pass(); ++ vm.bx.heap.vec_push(args, NIL, value, trap); ++ } ++ args.into() ++ }; ++ let empty = args_for(vm, None); ++ assert_eq!(capture_intent(vm, empty, false), Ok(CaptureIntent::default())); ++ assert_eq!(capture_intent(vm, empty, true), Ok(CaptureIntent::default())); ++ let options = crate::script_eval!(vm, {{audio: true library: false}}); ++ let args = args_for(vm, Some(options)); ++ assert_eq!(capture_intent(vm, args, true), Ok(CaptureIntent { audio: true, library: false })); ++ assert!(capture_intent(vm, args, false).unwrap_err().contains("capture supports library")); ++ let library = crate::script_eval!(vm, {{library: true}}); ++ let args = args_for(vm, Some(library)); ++ assert_eq!(capture_intent(vm, args, false), Ok(CaptureIntent { audio: false, library: true })); ++ for value in [ ++ crate::script_eval!(vm, {{audio: "true"}}), ++ crate::script_eval!(vm, {{library: 1}}), ++ crate::script_eval!(vm, {{export: true}}), ++ crate::script_eval!(vm, {true}), ++ NIL, ++ ] { ++ let args = args_for(vm, Some(value)); ++ assert!(capture_intent(vm, args, true).unwrap_err().starts_with("bad_request:")); ++ } ++ let args = vm.bx.heap.new_object(); ++ let trap = vm.bx.threads.cur().trap.pass(); ++ vm.bx.heap.vec_push(args, NIL, options, trap); ++ vm.bx.heap.vec_push(args, NIL, library, trap); ++ assert!(capture_intent(vm, args.into(), true).is_err(), "extra arguments cannot hide intent"); ++ }); ++ } ++ ++ #[test] ++ fn available_microphone_and_library_never_opt_a_capture_in() { ++ let mut cx = Cx::new(Box::new(|_, _| {})); ++ let widget = cx.with_vm(|vm| { ++ crate::script_mod(vm); ++ let value = crate::script_eval!(vm, {use mod.widgets.* CameraPreview{}}); ++ WidgetRef::script_from_value(vm, value) ++ }); ++ let mut preview = widget.borrow_mut::().unwrap(); ++ let heap = preview.heap_key(); ++ crate::splash_policy::set_policy_for_heap(heap, vec!["camera".into(), "microphone".into(), "library".into()], vec![], None); ++ preview.state = State::Running; ++ preview.input = Some(VideoInputId(LiveId(1))); ++ // No storage root: stop before native capture. Broad API availability ++ // must neither request microphone permission nor select gallery export. ++ assert!(!preview.record_start(&mut cx, CaptureIntent::default())); ++ assert!(preview.audio_permission_request.is_none()); ++ assert!(!preview.recording_audio); ++ assert!(preview.last_error.contains("storage")); ++ assert!(!preview.last_error.contains("export")); ++ crate::splash_policy::gc_policies(&[heap]); ++ } ++ ++ #[test] ++ fn explicit_intent_preserves_host_consent_and_background_export_rules() { ++ let mut cx = Cx::new(Box::new(|_, _| {})); ++ let widget = cx.with_vm(|vm| { ++ crate::script_mod(vm); ++ let value = crate::script_eval!(vm, {use mod.widgets.* CameraPreview{}}); ++ WidgetRef::script_from_value(vm, value) ++ }); ++ let mut preview = widget.borrow_mut::().unwrap(); ++ let heap = preview.heap_key(); ++ crate::splash_policy::set_policy_for_heap(heap, vec!["camera".into(), "microphone".into(), "library".into()], vec![], None); ++ crate::splash_policy::set_device_consent_for_heap(heap, true); ++ // Missing host identity means no per-app microphone grant, regardless ++ // of a manifest declaration or another app's OS permission. ++ crate::splash_host::set_tag_for_heap(heap, None); ++ assert!(!preview.validate_capture_intent(&mut cx, CaptureIntent { audio: true, library: false })); ++ assert!(preview.last_error.contains("microphone.permission.request")); ++ assert!(preview.audio_permission_request.is_none()); ++ crate::splash_host::set_prompts_for_heap(heap, false); ++ assert!(!preview.validate_capture_intent(&mut cx, CaptureIntent { audio: false, library: true })); ++ assert!(preview.last_error.starts_with("foreground_required:")); ++ assert!(preview.last.is_empty()); ++ assert!(preview.recording.is_none()); ++ crate::splash_policy::gc_policies(&[heap]); ++ crate::splash_host::gc_bridge(&[heap]); ++ } ++ ++ #[cfg(not(target_env = "ohos"))] ++ #[test] ++ fn unsupported_library_export_fails_before_creating_a_local_capture() { ++ let mut cx = Cx::new(Box::new(|_, _| {})); ++ let widget = cx.with_vm(|vm| { ++ crate::script_mod(vm); ++ let value = crate::script_eval!(vm, {use mod.widgets.* CameraPreview{}}); ++ WidgetRef::script_from_value(vm, value) ++ }); ++ let mut preview = widget.borrow_mut::().unwrap(); ++ let heap = preview.heap_key(); ++ crate::splash_policy::set_policy_for_heap(heap, vec!["camera".into(), "library".into()], vec![], None); ++ assert!(!preview.capture(&mut cx, CaptureIntent { audio: false, library: true })); ++ assert!(preview.last_error.starts_with("unsupported_platform:")); ++ assert!(preview.last_error.contains("library: false")); ++ assert!(preview.last.is_empty()); ++ assert!(preview.recording.is_none()); ++ crate::splash_policy::gc_policies(&[heap]); ++ } ++ + #[test] + fn failed_start_stops_retries_until_explicit_start_and_ignores_other_players() { + let mut cx = Cx::new(Box::new(|_, _| {})); +@@ -936,9 +1136,12 @@ fn background_device_consent_checks_os_grants_without_prompting_or_timeout_fallb + assert!(preview.wanted, "a background surface may use an existing OS grant"); + preview.state = State::Running; + preview.input = Some(VideoInputId(LiveId(1))); +- assert!(preview.record_start(&mut cx)); +- let audio_request = preview.audio_permission_request.unwrap().0; +- assert!(!preview.audio_permission_request.unwrap().1, "recording only checks the existing microphone grant"); ++ assert!(preview.record_start(&mut cx, CaptureIntent { audio: true, library: false })); ++ let audio_request = preview.audio_permission_request.unwrap().id; ++ assert_eq!(preview.audio_permission_request.unwrap().intent, CaptureIntent { audio: true, library: false }); ++ assert!(preview.record_start(&mut cx, CaptureIntent::default())); ++ assert_eq!(preview.audio_permission_request.unwrap().intent, CaptureIntent { audio: true, library: false }, "a second call cannot rewrite a pending recording's capture intent"); ++ assert!(!preview.audio_permission_request.unwrap().prompted, "recording only checks the existing microphone grant"); + crate::splash_host::set_prompts_for_heap(heap, true); + preview.handle_event(&mut cx, &result(Permission::AudioInput, audio_request, PermissionStatus::NotDetermined), &mut Scope::empty()); + assert!(preview.audio_permission_request.is_none(), "returning to foreground cannot upgrade a background recording request"); +@@ -946,8 +1149,8 @@ fn background_device_consent_checks_os_grants_without_prompting_or_timeout_fallb + assert!(preview.last_error.contains("microphone permission")); + assert!(preview.permission_request.is_none(), "no native prompt follows the background check"); + crate::splash_host::set_prompts_for_heap(heap, false); +- assert!(preview.record_start(&mut cx)); +- let cancelled = preview.audio_permission_request.unwrap().0; ++ assert!(preview.record_start(&mut cx, CaptureIntent { audio: true, library: false })); ++ let cancelled = preview.audio_permission_request.unwrap().id; + preview.record_stop(&mut cx); + preview.handle_event(&mut cx, &result(Permission::AudioInput, cancelled, PermissionStatus::Granted), &mut Scope::empty()); + assert!(preview.recording.is_none(), "late OS approval must not revive cancelled recording"); +@@ -988,7 +1191,7 @@ fn revocation_stops_an_existing_camera_and_cannot_restart_from_its_watchdog() { + preview.state = State::Idle; + preview.drive(&mut cx); + assert_eq!(preview.state, State::Idle); +- assert!(!preview.capture(&mut cx)); ++ assert!(!preview.capture(&mut cx, CaptureIntent::default())); + crate::splash_policy::gc_policies(&[heap]); + } + diff --git a/tools/test-host-api-android.py b/tools/test-host-api-android.py index 3da969ff9..ba14f3325 100644 --- a/tools/test-host-api-android.py +++ b/tools/test-host-api-android.py @@ -24,6 +24,7 @@ "storage_binary_write_discovery", "contained_binary_roundtrip", "files_status_truthful", + "native_storage_identity_scoped", "location_sample_discovery", "background_import_refused", "background_export_refused", @@ -42,7 +43,7 @@ "audio_playback_discovery", "microphone_recording_discovery", "background_audio_playback_refused", - "undeclared_microphone_recording_refused", + "background_microphone_recording_refused", ) @@ -123,10 +124,10 @@ def require(name, condition): 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('admitted_identity_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('microphone_status_without_declaration_or_consent', data['microphone']['capability'] == 'microphone' and data['microphone']['supported'] is True and data['microphone']['app_policy_granted'] is True and data['microphone']['app_consent'] is False and data['microphone']['os_permission'] in ('granted', 'not_determined', 'denied', 'settings_required')) 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']) diff --git a/tools/test-host-api-native.py b/tools/test-host-api-native.py index 152a3a155..db9dd92c8 100644 --- a/tools/test-host-api-native.py +++ b/tools/test-host-api-native.py @@ -26,6 +26,7 @@ "storage_binary_write_discovery", "contained_binary_roundtrip", "files_status_truthful", + "native_storage_identity_scoped", "location_sample_discovery", "background_import_refused", "background_export_refused", @@ -44,7 +45,7 @@ "audio_playback_discovery", "microphone_recording_discovery", "background_audio_playback_refused", - "undeclared_microphone_recording_refused", + "background_microphone_recording_refused", ) @@ -56,7 +57,7 @@ def require(condition, message): def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--host", type=Path, default=REPO / "target/release/examples/host-api-lab") - parser.add_argument("--hub", type=Path, required=True, help="Current-source hub binary supporting files, device_calendar and audio (contract 1.10 source)") + parser.add_argument("--hub", type=Path, required=True, help="Current-source hub binary supporting declarative capabilities (contract 1.11 source)") parser.add_argument("--output", type=Path, help="New evidence directory; defaults to a private temporary directory") args = parser.parse_args() root = args.output.resolve() if args.output else Path(tempfile.mkdtemp(prefix="octosense-host-api-native-")) @@ -132,11 +133,13 @@ def get(route): data = native["tool_result"]["Ok"] require(data["account"] == "device", "Wrong tool account") require(data["camera"]["capability"] == "camera" and data["camera"]["supported"], "No native camera permission status") - require(data["camera"]["app_policy_granted"] and not data["camera"]["app_consent"], "Capability confused with consent") + require(data["camera"]["app_policy_granted"] and not data["camera"]["app_consent"], "Admitted identity confused with consent") require(data["camera"]["os_permission"] in ["granted", "not_determined", "denied", "settings_required"], "Not a native OS status") require(data["discovery"]["supported"] and data["discovery"]["descriptor"]["name"] == "camera.permission.status", "API discovery mismatch") require(not data["missing"]["implemented"] and not data["missing"]["supported"], "Uncompiled Rust function advertised") - require(not data["microphone_allowed"] and data["microphone_error"], "Undeclared capability was allowed") + require(data["microphone"]["capability"] == "microphone" and data["microphone"]["supported"] is True, "Microphone status blocked by omitted disclosure") + require(data["microphone"]["app_policy_granted"] is True and data["microphone"]["app_consent"] is False, "Microphone status granted device consent") + require(data["microphone"]["os_permission"] in ["granted", "not_determined", "denied", "settings_required"], "Not a native microphone OS status") require(not data["background_prompt_allowed"] and "background" in data["background_error"], "Tool callback gained prompt authority") require(not native["host_sheet_visible"], "Background tool opened a native sheet") require("account_scope" in native["refusals"]["wrong_account"], "Cross-account call accepted") @@ -164,7 +167,7 @@ def get(route): (root / "ui-snapshot.json").write_text(json.dumps(updated, indent=2)) shutil.copyfile(get("g?scale=1")["png"], root / "ui.png") result["bundle_digest"] = native["bundle_digest"] - result["verified"] = ["signed admission and launch", "own Splash tool completion", "native OS permission status", "live app UI update", "native UI button host call", "API discovery and missing-function fallback", "undeclared capability refusal", "background callback prompt refusal", "cross-account and schema refusal", "closed-app refusal", "file and location API discovery", "contained binary storage round trip", "file status metadata", "background import/export refusal", "location refusal without app consent", "public Calendar/Mail discovery", "Calendar status and consent refusal", "background Calendar/Mail mutation refusal", "Mail refusal without connected account"] + result["verified"] = ["signed admission and launch", "own Splash tool completion", "native OS permission status", "live app UI update", "native UI button host call", "API discovery and missing-function fallback", "microphone status without declaration or consent", "background microphone recording refusal", "background callback prompt refusal", "cross-account and schema refusal", "closed-app refusal", "file and location API discovery", "contained binary storage round trip", "file status metadata", "background import/export refusal", "location refusal without app consent", "public Calendar/Mail discovery", "Calendar status and consent refusal", "background Calendar/Mail mutation refusal", "Mail refusal without connected account"] finally: if process.poll() is None: try: diff --git a/tools/test-released-components.py b/tools/test-released-components.py new file mode 100644 index 000000000..62bbe92f7 --- /dev/null +++ b/tools/test-released-components.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python3 +"""Install and run the genuine demo apps in an isolated Mac desktop shell. + +--app-zip tests the packaged release, extracted only into the new evidence +directory after checking the GitHub release's SHA256SUMS digest. --binary is +an explicitly recorded developer rehearsal; it never counts as release proof. +This driver uses App Hub's UI and synthetic local data, not a model or account. +""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import plistlib +import re +import signal +import subprocess +import sys +import time +import zipfile + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'tools/connected-e2e')) +from native import Native + +APPS = [('org.ymote.componentdemo.first', 'Component Demo · First'), + ('org.ymote.componentdemo.second', 'Component Demo · Second')] + + +def process_table(): + # No arguments or environment: provider credentials cannot enter evidence. + result = subprocess.run(['ps', '-axo', 'pid=,ppid=,lstart=,comm='], check=True, + capture_output=True, text=True) + rows = {} + for line in result.stdout.splitlines(): + fields = line.strip().split(None, 2) + if len(fields) == 3: + rows[int(fields[0])] = (int(fields[1]), fields[2]) + return rows + + +class OwnedNative(Native): + def __init__(self, *args, cleanup, **kwargs): + self.descendants = {} + self.next_poll = 0 + self.cleanup = cleanup + self.cleaned = False + super().__init__(*args, **kwargs) + self.remember_children() + + def remember_children(self): + rows = process_table() + owners = {self.child.pid} + while True: + found = {pid for pid, (parent, _) in rows.items() if parent in owners} - owners + if not found: + break + owners.update(found) + for pid in owners - {self.child.pid}: + self.descendants[pid] = rows[pid][1] + self.next_poll = time.monotonic() + 1 + + def call(self, *args, **kwargs): + if time.monotonic() >= self.next_poll: + self.remember_children() + return super().call(*args, **kwargs) + + def wait(self, predicate, timeout=12): + def observed(): + if time.monotonic() >= self.next_poll: + self.remember_children() + return predicate() + return super().wait(observed, timeout) + + def close(self): + if self.cleaned: + return + self.remember_children() + super().close() + + def survivors(): + rows = process_table() + return [pid for pid, identity in self.descendants.items() + if pid in rows and rows[pid][1] == identity] + + # Shell shutdown normally drops the kernel. Only exact descendants + # observed before shutdown can be cleaned if they outlive their owner. + deadline = time.monotonic() + 2 + while survivors() and time.monotonic() < deadline: + time.sleep(.1) + remaining = survivors() + forced = bool(remaining) + for sig, grace in ((signal.SIGTERM, 5), (signal.SIGKILL, 2)): + for pid in survivors(): + try: + os.kill(pid, sig) + except ProcessLookupError: + pass + deadline = time.monotonic() + grace + while survivors() and time.monotonic() < deadline: + time.sleep(.1) + remaining = survivors() + self.cleanup.append({'run': self.name, 'observed_descendants': len(self.descendants), + 'forced_cleanup': forced, 'remaining_descendants': len(remaining)}) + self.cleaned = True + if remaining: + raise RuntimeError('An owned native descendant survived cleanup') + + +def digest(path): + result = hashlib.sha256() + with path.open('rb') as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b''): + result.update(chunk) + return result.hexdigest() + + +def unpack(archive, expected, tag, destination): + if digest(archive) != expected: + raise ValueError('Release archive does not match its SHA256SUMS digest') + # ditto preserves the signed bundle layout. Reject escaping archive names + # first; the input must be the official, checksum-verified app.zip asset. + with zipfile.ZipFile(archive) as source: + for item in source.infolist(): + path = Path(item.filename) + if path.is_absolute() or '..' in path.parts or '\\' in item.filename: + raise ValueError('Unsafe archive entry') + subprocess.run(['ditto', '-x', '-k', str(archive), str(destination)], check=True) + app = destination / 'OctoSense.app' + with (app / 'Contents/Info.plist').open('rb') as stream: + info = plistlib.load(stream) + if info.get('CFBundleShortVersionString') != tag.removeprefix('desktop-v'): + raise ValueError('Packaged version differs from the requested release tag') + binary = app / 'Contents/MacOS/octosense' + if binary.is_symlink() or not binary.is_file(): + raise ValueError('Expected a regular packaged OctoSense executable') + return binary + + +def main(): + os.umask(0o077) + parser = argparse.ArgumentParser(description=__doc__) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument('--app-zip', type=Path) + source.add_argument('--binary', type=Path, help='Developer rehearsal only') + parser.add_argument('--sha256', help='Official app.zip SHA256SUMS entry') + parser.add_argument('--tag') + parser.add_argument('--mirror', type=Path, required=True) + parser.add_argument('--out', type=Path, required=True) + args = parser.parse_args() + if sys.platform != 'darwin': + parser.error('This acceptance driver runs the macOS desktop only') + if args.app_zip and (not re.fullmatch(r'[0-9a-f]{64}', args.sha256 or '') or + not re.fullmatch(r'desktop-v\d+\.\d+\.\d+(?:-[\w.]+)?', args.tag or '')): + parser.error('--app-zip requires --sha256 and --tag') + if args.binary and (args.sha256 or args.tag): + parser.error('A developer binary cannot claim release provenance') + mirror = args.mirror.resolve(strict=True) + if not (mirror / 'catalog-v2.json').is_file(): + parser.error('Use the genuine GitHub-attested catalog mirror') + out = args.out.resolve() + out.mkdir(mode=0o700, parents=True, exist_ok=False) + receipt = {'schema': 1, 'release_archive_tested': bool(args.app_zip), + 'tag': args.tag, 'archive_sha256': args.sha256, + 'catalog_sha256': digest(mirror / 'catalog-v2.json'), + 'checks': {}, 'process_cleanup': [], 'personal_accounts_used': False, + 'live_model_tested': False, 'public_catalog_modified': False} + ui = None + + def check(name, result): + receipt['checks'][name] = bool(result) + assert result, name + + try: + binary = (unpack(args.app_zip.resolve(strict=True), args.sha256, args.tag, out / 'package') + if args.app_zip else args.binary.resolve(strict=True)) + receipt['binary_sha256'] = digest(binary) + profile = out / 'profile' + profile.mkdir(mode=0o700) + # No inherited provider keys, personal vault overrides or test trust + # anchor. HOME is preserved; every application data root is explicit. + env = {key: os.environ[key] for key in ('PATH', 'HOME', 'TMPDIR', 'LANG') if key in os.environ} + env.update(OCTOSENSE_HOME=str(profile), OCTOS_APP_CORE_DIR=str(out / 'kernel'), + OCTOSENSE_MAIL_VAULT='file', OCTOSENSE_LLM_VAULT='file', OCTOSENSE_SECRETS='file', + OCTOSENSE_HUB=str(mirror), OCTOSENSE_HUB_CATALOG='github-v2', MAKEPAD_REMOTE='1') + + def start(name, app): + return OwnedNative(binary, ['--test-action', 'launch-' + app], out, name, + env=env, cwd=out, cleanup=receipt['process_cleanup']) + + ui = start('hub-install', 'apphub') + ui.wait(lambda: ui.find(identifier='search_tab'), timeout=60) + # ShellApprovals is custom-drawn, so its decision buttons are not + # widget selectors. Have the operator inspect the actual frame and + # provide the Don't allow point; never fabricate consent state. + ui.capture('apphub-agent-consent') + request_path = out / 'refuse-apphub-agent.json' + print('Inspect apphub-agent-consent.png, then write the observed Don\u2019t allow ' + 'point as {"x": number, "y": number} to ' + str(request_path), flush=True) + ui.wait(request_path.is_file, timeout=300) + point = json.loads(request_path.read_text()) + size = ui.call('s')['w'][0]['sz'] + if set(point) != {'x', 'y'} or not all(type(point[k]) in (int, float) and + 0 <= point[k] < size[i] for i, k in enumerate(('x', 'y'))): + raise ValueError('The observed refusal point must lie inside this test window') + ui.call('click', **point, wait=1) + consent_path = profile / 'approvals/consent.json' + check('apphub_agent_refused_in_ui', ui.wait(lambda: consent_path.is_file() and + json.loads(consent_path.read_text()).get('apps', {}).get('apphub', {}).get('allowed') is False)) + receipt['agent_refusal'] = {'action': "Don't allow", 'point': point, 'window': size} + ui.capture('apphub-agent-refused') + ui.click_row(ui.reachable(identifier='search_tab')) + for app_id, name in APPS: + ui.field('search', name) + ui.label(name) + ui.click('Get') + ui.label('Install ' + name + '?') + ui.click('Install') + size = ui.call('s')['w'][0]['sz'] + ui.call('m', k='scroll', x=size[0] / 2, y=size[1] / 2, dy=-5000, wait=1) + ui.wait(lambda: any('Installed. Your app is ready to open.' in row.get('t', '') + for row in ui.rows()) and ui.find(text='Open'), timeout=90) + check(app_id + '.installed_through_hub', True) + ui.capture(app_id + '-installed') + ui.click_row(ui.reachable(identifier='back')) + ui.check_logs() + ui.close() + ui = None + # A new shell each time proves the installation survives restart and + # does not depend on the store's in-memory test fixture. Instance + # concurrency and agent tool dispatch have their separate native test. + for app_id, name in APPS: + ui = start(app_id, 'hub:' + app_id) + ui.wait(lambda: any(row.get('t') == name and row.get('ty') == 'Label' + for row in ui.rows()), timeout=60) + for index, expected in enumerate(('1 → 2', '3 → 4'), 1): + ui.click('Run checks') + ui.label('Shared components answered') + check(app_id + '.turn_' + str(index), bool(ui.label("This app's counter: " + expected))) + check(app_id + '.private_note_' + str(index), bool(ui.label('

' + name + '

'))) + ui.call('m', k='move', x=20, y=100, wait=1) + ui.capture(app_id + '-completed') + ui.check_logs() + ui.close() + ui = None + receipt['passed'] = True + except Exception as error: + receipt.update(passed=False, error=str(error)) + if ui: + try: + ui.capture('failure') + except Exception: + pass + raise + finally: + try: + if ui: + ui.close() + finally: + (out / 'receipt.json').write_text(json.dumps(receipt, indent=2) + '\n') + print(f"{sum(receipt['checks'].values())}/{len(receipt['checks'])} checks passed; receipt: {out / 'receipt.json'}") + + +if __name__ == '__main__': + main() diff --git a/tools/test-shared-components.py b/tools/test-shared-components.py new file mode 100644 index 000000000..39be89083 --- /dev/null +++ b/tools/test-shared-components.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Run real GitHub shared-component acceptance in a hidden Mac host or fresh Android test package. + +The mirror must contain an authentic catalog-v2.json and its exact candidate +artifacts. No legacy catalog, signing keys, account data, or model is used. +Run desktop first. Android requires an explicitly selected OnePlus 6. +""" +import argparse +import hashlib +import io +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import tarfile +import time + +ROOT = Path(__file__).resolve().parents[1] + + +def main(): + os.umask(0o077) + parser = argparse.ArgumentParser(description=__doc__) + target = parser.add_mutually_exclusive_group(required=True) + target.add_argument('--host', type=Path) + target.add_argument('--apk', type=Path) + parser.add_argument('--mirror', type=Path, required=True) + parser.add_argument('--out', type=Path, required=True) + parser.add_argument('--adb', type=Path) + parser.add_argument('--aapt2', type=Path) + parser.add_argument('--serial', help='Explicitly assigned OnePlus 6; never written to evidence') + parser.add_argument('--package', default='dev.makepad.octosense.hostapilab.shared1') + args = parser.parse_args() + if not re.fullmatch(r'dev\.makepad\.octosense\.hostapilab\.[a-z0-9_]+', args.package): + parser.error('Use a fresh hostapilab package, never Home') + if args.apk and not all((args.adb, args.aapt2, args.serial)): + parser.error('Android requires --adb, --aapt2 and an explicit --serial') + if os.environ.get('OCTOSENSE_HUB_CATALOG', 'github') != 'github': + parser.error('This test refuses the legacy catalog channel') + source = 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('Use a clean committed source tree for source-bound evidence') + runtime = json.loads((ROOT / 'runtime-patches.lock.json').read_text())['makepad']['tree'] + mirror = args.mirror.resolve(strict=True) + if not (mirror / 'catalog-v2.json').is_file(): + parser.error('Mirror needs a real GitHub-attested catalog-v2.json') + files = [p for p in mirror.rglob('*') if not p.is_dir()] + if any(p.is_symlink() or not (p.is_file() or p.is_dir()) for p in mirror.rglob('*')): + parser.error('Mirror accepts only regular files and directories') + if sum(p.stat().st_size for p in files) > 32 * 1024 * 1024: + parser.error('Synthetic mirror exceeds the 32 MiB acceptance limit') + args.out.mkdir(mode=0o700, parents=True, exist_ok=False) + receipt = {'schema': 1, 'source_revision': source, 'runtime_tree': runtime, + 'mirror_sha256': {str(p.relative_to(mirror)): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted(files)}, + 'checks': {}, 'phone_used': bool(args.apk), 'personal_accounts_used': False, + 'public_catalog_modified': False} + native = None + ui = None + installed = False + adb_base = [str(args.adb), '-s', args.serial] if args.apk else None + + def adb(*values, checked=True, data=None): + return subprocess.run(adb_base + list(values), input=data, capture_output=True, check=checked, timeout=60) + + def check(name, condition): + receipt['checks'][name] = bool(condition) + assert condition, name + + try: + if args.host: + sys.path.insert(0, str(ROOT / 'tools/connected-e2e')) + from native import Native + receipt['binary_sha256'] = hashlib.sha256(args.host.read_bytes()).hexdigest() + # Ephemeral instrument port and hidden window are process-scoped. + os.environ['MAKEPAD_REMOTE'] = '1' + ui = Native(args.host.resolve(), ['--shared-mirror=' + str(mirror), + '--shared-home=' + str(args.out.resolve() / 'home'), + '--receipt=' + str(args.out.resolve() / 'native-result.json')], args.out, 'shared-components') + deadline = time.monotonic() + 120 + while time.monotonic() < deadline: + path = args.out / 'native-result.json' + if path.exists(): + native = json.loads(path.read_text()) + break + if ui.child.poll() is not None: + raise RuntimeError('Native host exited before its receipt') + time.sleep(.15) + if native is None: + raise TimeoutError('Native shared-component host produced no receipt') + if not native.get('passed'): + raise RuntimeError(native.get('error', 'Native component acceptance failed')) + check('final_app_presented', bool(ui.label('Component Demo · Second'))) + check('final_counter_presented', bool(ui.label("This app's counter: 3 → 4"))) + # Force a settled present; the first hidden capture can precede + # the completed glyph frame even after the widget tree exists. + ui.call('m', k='move', x=20, y=100, wait=1) + ui.capture('completed') + ui.check_logs() + else: + check('assigned_oneplus6', adb('shell', 'getprop', 'ro.product.model').stdout.decode().strip() + in {'ONEPLUS A6000', 'ONEPLUS A6003', 'OnePlus 6'}) + present = adb('shell', 'pm', 'path', args.package, checked=False) + check('test_package_absent', present.returncode in (0, 1) and not present.stdout.strip() and not present.stderr.strip()) + badging = subprocess.check_output([str(args.aapt2), 'dump', 'badging', str(args.apk)], text=True) + check('isolated_debuggable_apk', f"name='{args.package}'" in badging and 'application-debuggable' in badging) + receipt['apk_sha256'] = hashlib.sha256(args.apk.read_bytes()).hexdigest() + receipt['package'] = args.package + archive = io.BytesIO() + with tarfile.open(fileobj=archive, mode='w') as tar: + for path in sorted(mirror.rglob('*')): + tar.add(path, arcname='shared-components-lab/mirror/' + str(path.relative_to(mirror)), recursive=False) + 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() + 150 + while time.monotonic() < deadline: + result = adb('exec-out', 'run-as', args.package, 'cat', 'files/shared-components-lab/receipt.json', checked=False) + if result.returncode == 0: + try: + native = json.loads(result.stdout) + break + except ValueError: + pass + time.sleep(.25) + if native is None: + raise TimeoutError('Phone shared-component host produced no receipt') + (args.out / 'native-result.json').write_text(json.dumps(native, indent=2) + '\n') + check('native_platform', native['platform'] == ('android' if args.apk else 'macos')) + check('compiled_source_identity', native['source_revision'] == source and native['source_dirty'] is False) + check('compiled_runtime_identity', native['runtime_tree'] == runtime) + check('real_github_trust', native['catalog_channel'] == 'github' and native['legacy_fallback'] is False) + check('deduplicated_readonly_components', native['deduplicated_readonly_components'] is True) + check('empty_first_declarations', native['apps'][0]['declarations'] in (None, [])) + check('populated_second_declarations', set(native['apps'][1]['declarations']) == {'wasm', 'storage'}) + check('all_four_native_turns', len(native['turns']) == 4) + check('all_28_native_assertions', len(native['checks']) == 28 and all(native['checks'].values())) + check('native_passed', native['passed'] is True) + receipt['passed'] = True + except Exception as error: + receipt['passed'] = False + receipt['error'] = str(error) + raise + finally: + if ui: + ui.close() + if installed: + adb('shell', 'am', 'force-stop', args.package, checked=False) + # This invocation proved the package absent before installing it. + removed = adb('uninstall', args.package, checked=False) + receipt['owned_test_package_removed'] = removed.returncode == 0 and b'Success' in removed.stdout + with (args.out / 'receipt.json').open('x') as output: + json.dump(receipt, output, indent=2) + output.write('\n') + print(f"{sum(receipt['checks'].values())}/{len(receipt['checks'])} checks passed; receipt: {args.out / 'receipt.json'}") + + +if __name__ == '__main__': + main()