diff --git a/README.md b/README.md index a531fb6..8d41e0d 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ templates and test fixtures. | Path | What it is | | --- | --- | | `catalog.json` | The signed catalog. Stores verify it against the anchor below before showing anything. | -| `index/-.json` | One admitted entry per app version: its manifest, publisher, source and status. Created by `hub publish`; absent while no app is published. | +| `index/-.json` | One admitted entry per app version: its manifest, publisher, source and status. A maintainer exports the admitted entry from the catalog after `hub publish`; absent while no app is published. | | `artifacts/-.bundle/` | The hub's copy of the bundle, exactly the bytes that were reviewed. Created by `hub publish`. | | `artifacts/-.bundle.pack.json` | The same bundle as one file, which stores download. | | `docs/FIRST-APP.md` | First-app walkthrough for a card app or a script app: author, package, run, capture, validate and submit. | @@ -61,11 +61,40 @@ app id. Only requested names are eligible; owner sharing, caller grants and a real executor remain separate shell checks. An offer neither starts an agent nor gives script UI code raw host-service access. Store defaults are unchanged. +App contract 1.5 admits four additional, independent host-service capabilities: +`auth` for GitHub/Google connection management, `github` for repository operations, +`gcalendar` for Google Calendar, and `gmail` for Gmail. OAuth tokens belong to the +host; apps receive handles bound to their own identity and authorized scopes. +The declarations do not register services: standalone `card-host` has none of +these providers. The OctoSense host implementation and provider registrations +must be installed before a sample can sign in. No OctoSense account is required. + +An ordinary app can bind its own agent tool to a reviewed shared service with +`"implemented_by":"host-service", "host_method":"github.read"` in `tools.json`. +The tool keeps its app namespace. Admission checks the target against +`SHARED_HOST_METHODS`, the declared service grant, private-data disclosure and +minimum risk; the shell must check the resolved grant again when executing it. +Omitting `host_method` preserves the original dispatch behavior. Authentication, +host sheets, direct saves and sends cannot be aliased. An admitted alias neither +installs its service nor grants approval for an external action. + +Catalog tool summaries omit `host_method` so older stores can read the catalog. +They retain permission and risk metadata, but are never used for dispatch. +The signed bundle keeps the complete tools file; installation and launch still +verify its exact digest and publisher signature before loading those bindings. + The `calendar` capability (app contract 1.4) separately admits Calendar UI requests. OctoSense registers the Calendar service and checks its owning app identity. A declared capability alone does not provide a service, a calendar account, or agent tools. +Host-owned account and review sheets are modal: text, keyboard, IME, +clipboard and pointer-release events reach only the visible sheet. Timer and +service callbacks continue to reach the app. The runner captures its own card +and sheet references before evaluating app widgets, so a bundle cannot replace +the host surface by reusing a widget ID. `services::is_sheet_input_event` supplies +the same input boundary to integrated foreground Glance hosts. + The `photos` and `youtube` capabilities (app contract 1.5) admit requests to the app-owned media services supplied by OctoSense. They do not grant Android Gallery access, a YouTube account, or another agent's tools. Cross-app tools diff --git a/README.zh-CN.md b/README.zh-CN.md index 05593a8..fbc318f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -17,7 +17,7 @@ | 路径 | 说明 | | --- | --- | | `catalog.json` | 签名目录。商店在展示任何内容之前,先用下方的信任锚验证它。 | -| `index/-.json` | 每个应用版本一条已准入条目:清单、发布者、源码位置与状态。由 `hub publish` 生成;尚无已发布应用时不存在。 | +| `index/-.json` | 每个应用版本一条已准入条目:清单、发布者、源码位置与状态。维护者在 `hub publish` 后从目录导出已准入条目;尚无已发布应用时不存在。 | | `artifacts/-.bundle/` | App Hub 保存的应用包副本,与审核时的字节完全一致。由 `hub publish` 生成。 | | `artifacts/-.bundle.pack.json` | 打成单个文件的同一应用包,商店下载的就是它。 | | `docs/FIRST-APP.md` | 第一个应用(卡片应用或脚本应用)的完整演练:编写、打包、运行、截图、验证与提交。 | @@ -50,6 +50,11 @@ Splash 脚本和 L0 卡片,并把 crate 索引放在最后。仓库协作规 工具所有者的共享声明、调用者授权与实际执行器仍由 Shell 分别检查。提供工具范围 不会启动代理,也不授予脚本 UI 原始宿主服务权限;商店应用的默认规则不变。 +主机账户和审核面板采用模态输入:文字、键盘、输入法、剪贴板和指针抬起事件 +只交给可见的主机面板,定时器及服务回调仍能到达应用。运行器在执行应用控件前 +保存自身卡片和面板引用,应用不能通过重复控件 ID 替换主机界面。 +`services::is_sheet_input_event` 为集成前台 Glance 主机提供相同输入边界。 + ## 信任锚 商店信任这个锚,并沿着它的证书找到为目录签名的工作密钥。轮换工作密钥不需要发布新版商店。 @@ -82,6 +87,23 @@ appstore 曾用于跑通发布流程的相机卡片已于 2026 年 9 月 20 日移除:相机是随 Shell 出厂的系统应用(与新闻、相册一样),不是商店应用。它的仓库仍保留在 [ymote/camera-card](https://github.com/ymote/camera-card),作为可发布应用包的完整示例。 +应用契约 1.5 新增四项独立的宿主服务能力:`auth` 管理 GitHub/Google +连接,`github` 访问仓库,`gcalendar` 访问 Google Calendar,`gmail` 访问 Gmail。 +OAuth 令牌由宿主保存;应用只取得绑定自身身份与授权范围的连接句柄。 +能力声明不会注册服务;独立的 `card-host` 不提供这些服务。样例登录需要 +安装 OctoSense 的宿主实现并配置提供商注册信息,无需 OctoSense 账号。 + +普通应用可在 `tools.json` 中用 `"implemented_by":"host-service"` 和 +`"host_method":"github.read"` 将自身命名空间的工具绑定到已审核的共享服务。 +准入检查 `SHARED_HOST_METHODS` 白名单、声明的服务权限、私密数据披露和最低风险; +执行时宿主仍需检查实际授予的权限。省略该字段时保持原有分发行为。 +登录、宿主 sheet、直接保存和发送不能通过别名调用。别名通过准入不等于服务 +已注册,也不等于用户批准外部操作。 + +目录中的工具摘要省略 `host_method`,以便旧版商店仍可读取目录;权限和风险信息 +保持不变,该摘要不用于执行分发。签名应用包保留完整工具文件,安装及启动仍须 +验证准确的包摘要和发布者签名,之后才加载工具绑定。 + Calendar 的脚本 UI 另需应用契约 1.4 的 `calendar` 能力。OctoSense 注册日历服务, 每次调用均校验所属应用身份。声明能力本身不会创建服务、连接日历账号或授予 Agent 工具。 diff --git a/crates/app-contract/CHANGELOG.md b/crates/app-contract/CHANGELOG.md index 6c35c57..97dac37 100644 --- a/crates/app-contract/CHANGELOG.md +++ b/crates/app-contract/CHANGELOG.md @@ -5,6 +5,10 @@ within `1.x` it only grows. ## 1.5.0 +- Admit the `auth`, `github`, `gcalendar` and `gmail` host-service capabilities. + Each remains an independent grant. OAuth returns app-bound connection handles; + provider tokens stay in the host. These declarations require corresponding + OctoSense implementations and do not make a provider available by themselves. - Add the `photos` and `youtube` capabilities for the contained Photos and YouTube UIs to call their owning host services. Neither grants Android Gallery access, a YouTube account or another app's agent tools; cross-app diff --git a/crates/app-contract/src/manifest.rs b/crates/app-contract/src/manifest.rs index 5255219..b3af7ed 100644 --- a/crates/app-contract/src/manifest.rs +++ b/crates/app-contract/src/manifest.rs @@ -67,6 +67,14 @@ pub const KNOWN_CAPABILITIES: &[&str] = &[ // person signs in to on the host's own sheet. The app never holds the // password or the connection. "mail", + // Host-owned OAuth connection lifecycle. Returns app-bound handles, never tokens. + "auth", + // GitHub repository reads and host-reviewed Markdown commits. + "github", + // Google Calendar connector; distinct from the local system calendar. + "gcalendar", + // Google Gmail API connector; distinct from the IMAP/SMTP mail service. + "gmail", // Calendar's local event store and UI. The service additionally checks // owning app identity; this is not Google/Android calendar access. "calendar", diff --git a/crates/app-contract/tests/growth.rs b/crates/app-contract/tests/growth.rs index 2a20411..5b0bf89 100644 --- a/crates/app-contract/tests/growth.rs +++ b/crates/app-contract/tests/growth.rs @@ -70,6 +70,24 @@ fn an_unknown_required_feature_needs_a_newer_host() { assert!(err.ends_with("needs a newer host: a, b"), "{err}"); } +#[test] +fn oauth_provider_services_are_independent_grants() { + let limits = HostLimits::default().with_require_signature(false); + let capabilities = ["auth", "github", "gcalendar", "gmail"]; + for granted in capabilities { + let manifest = parse(&manifest_with(&format!(r#""capabilities":["{granted}"]"#))).unwrap(); + let policy = resolve(&manifest, &limits).unwrap(); + for candidate in capabilities { + assert_eq!(policy.allows(candidate), candidate == granted); + } + for unrelated in ["mail", "calendar", "net", "glance", "auth.*"] { + assert!(!policy.allows(unrelated)); + } + let wildcard = parse(&manifest_with(&format!(r#""capabilities":["{granted}.*"]"#))).unwrap(); + assert!(resolve(&wildcard, &limits).is_err()); + } +} + #[test] fn resolve_checks_requires_even_for_a_manifest_not_read_by_parse() { let manifest: AppManifest = serde_json::from_str(&manifest_with(r#""requires":["x"]"#)).unwrap(); diff --git a/crates/app-hub/src/gate.rs b/crates/app-hub/src/gate.rs index 8fe4e77..b52fd08 100644 --- a/crates/app-hub/src/gate.rs +++ b/crates/app-hub/src/gate.rs @@ -486,11 +486,19 @@ pub fn entry_for( None => {} } let listing = std::fs::read_to_string(bundle.join(LISTING_FILE)).ok().and_then(|t| Listing::parse(&t).ok()); + let mut tools = octosense_app_policy::agent::read_tools(bundle)?.map(|t| t.tools).unwrap_or_default(); + // Catalog tools describe permissions; dispatch reads the digest-pinned + // bundle instead. Older stores strictly parse ToolSpec, so including this + // new routing field here would make them reject the entire catalog. + // Keep every permission field, and leave the signed bundle untouched. + for tool in &mut tools { + tool.host_method = None; + } Ok(Entry { artifact: format!("artifacts/{}-{}.bundle", report.app_id, report.version), manifest, listing, - tools: octosense_app_policy::agent::read_tools(bundle)?.map(|t| t.tools).unwrap_or_default(), + tools, publisher: publisher.to_string(), publisher_key: publisher_key.to_string(), source: crate::index::Source { repository: repository.to_string(), commit: commit.to_string() }, diff --git a/crates/app-hub/src/index.rs b/crates/app-hub/src/index.rs index 4413815..4c79496 100644 --- a/crates/app-hub/src/index.rs +++ b/crates/app-hub/src/index.rs @@ -39,9 +39,11 @@ pub struct Entry { pub listing: Option, /// The app's tool manifest (`tools.json`) as reviewed, so a store can /// say which tools wait for approval or may be shared before anything is - /// installed. Omitted when the app ships no tools, so an entry without - /// them serialises, and signs, as it did before the field existed. The - /// bundle's copy is authoritative on the device: the digest pins it. + /// installed. A display projection: `entry_for` omits `host_method` so + /// older stores can still read the catalog. It is never a dispatch table. + /// Omitted when the app ships no tools, so an entry without them serialises, + /// and signs, as it did before the field existed. The bundle's exact copy, + /// including its dispatch bindings, is authoritative: the digest pins it. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub tools: Vec, /// Where the hub's own copy of the bundle lives, relative to the catalog. diff --git a/crates/app-hub/tests/catalog_tool_compat.rs b/crates/app-hub/tests/catalog_tool_compat.rs new file mode 100644 index 0000000..df880e9 --- /dev/null +++ b/crates/app-hub/tests/catalog_tool_compat.rs @@ -0,0 +1,176 @@ +//! Catalog display metadata stays readable by stores before host_method. +//! Dispatch still comes from the exact publisher-signed bundle. +mod common; +use common::Fixture; +use octosense_app_hub::*; +use octosense_app_policy::{AgentBundle, HostLimits}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::fs; + +// Strict wire shape from app-policy at 966e711 (before connected services). +// Defaults/omissions match that reader so roundtrip equality checks the +// canonical bytes it verifies, not merely permissive JSON parsing. +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct LegacyTool { + name: String, + description: String, + input_schema: Value, + output_schema: Value, + risk: String, + #[serde(default)] + background: bool, + #[serde(default)] + shareable: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + private_data: Option, + implemented_by: String, + #[serde(default = "host", skip_serializing_if = "is_host")] + confirm: String, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + outward: bool, + #[serde(default = "yes", skip_serializing_if = "is_true")] + auto_approvable: bool, +} +fn host() -> String { + "host".into() +} +fn is_host(value: &String) -> bool { + value == "host" +} +fn yes() -> bool { + true +} +fn is_true(value: &bool) -> bool { + *value +} + +fn connected() -> Fixture { + let mut f = Fixture::new(); + f.manifest.id = "org.example.calendarpreview".into(); + f.manifest.capabilities = vec!["gcalendar".into()]; + fs::write( + f.bundle.join("tools.json"), + json!({"schema":1,"tools":[{ + "name":"calendarpreview.cached", "description":"Read its cached agenda.", + "input_schema":{"type":"object"}, "output_schema":{"type":"object"}, + "risk":"read", "background":false, "shareable":false, "private_data":true, + "implemented_by":"host-service", "host_method":"gcalendar.cached" + }]}) + .to_string(), + ) + .unwrap(); + f.sign(); + f +} + +#[test] +fn legacy_catalog_tools_roundtrip_without_changing_signed_bundle_dispatch() { + let f = connected(); + let original_bytes = fs::read(f.bundle.join("tools.json")).unwrap(); + let original = AgentBundle::load(&f.bundle, &f.manifest).unwrap().unwrap(); + // Demonstrate the actual pre-fix failure: the strict legacy reader + // refuses this new field, even though the entry is only for display. + let err = + serde_json::from_value::(serde_json::to_value(&original.tools[0]).unwrap()) + .unwrap_err(); + assert!( + err.to_string().contains("unknown field `host_method`"), + "{err}" + ); + + let entry = f.entry(); + let mut expected = original.tools.clone(); + for tool in &mut expected { + tool.host_method = None; + } + assert_eq!( + entry.tools, expected, + "all permission and schema fields must survive" + ); + assert_eq!( + fs::read(f.bundle.join("tools.json")).unwrap(), + original_bytes + ); + + let anchor = HubKey::generate(); + let working = HubKey::generate(); + let mut catalog = Catalog::new(1, "2026-10-06", vec![entry]); + working + .sign_catalog( + &mut catalog, + &anchor.certify(&working.public_hex()).unwrap(), + ) + .unwrap(); + let wire = serde_json::to_value(&catalog).unwrap(); + let legacy_tools: Vec = + serde_json::from_value(wire["entries"][0]["tools"].clone()).unwrap(); + let mut legacy_wire = wire.clone(); + legacy_wire["entries"][0]["tools"] = serde_json::to_value(legacy_tools).unwrap(); + assert_eq!( + legacy_wire, wire, + "legacy defaults must preserve signed JSON" + ); + let legacy_roundtrip: Catalog = serde_json::from_value(legacy_wire).unwrap(); + assert_eq!( + legacy_roundtrip.signing_bytes().unwrap(), + catalog.signing_bytes().unwrap() + ); + verify_catalog(&legacy_roundtrip, &anchor.public_hex()).unwrap(); + + let mut store = Store::new( + &anchor.public_hex(), + &f.root.join("installed"), + HostLimits::default(), + ); + store + .accept_catalog(&serde_json::to_string(&catalog).unwrap()) + .unwrap(); + store + .install_staged(&f.manifest.id, &f.bundle, &f.keys(), "2026-10-06") + .unwrap(); + store.may_run(&f.manifest.id).unwrap(); + let installed = store.install_dir(&f.manifest.id); + let loaded = AgentBundle::load(&installed, &f.manifest).unwrap().unwrap(); + assert_eq!( + loaded.tools, original.tools, + "dispatch must load the original signed bindings" + ); + assert_eq!( + loaded.tools[0].host_method.as_deref(), + Some("gcalendar.cached") + ); + + // Removing or redirecting that binding after install still invalidates + // the signed package. Display projection must never relax integrity. + fs::write(installed.join("tools.json"), original_bytes).unwrap(); + let mut tampered: Value = + serde_json::from_slice(&fs::read(installed.join("tools.json")).unwrap()).unwrap(); + tampered["tools"][0]["host_method"] = json!("gcalendar.get"); + fs::write(installed.join("tools.json"), tampered.to_string()).unwrap(); + assert!(store.may_run(&f.manifest.id).is_err()); + assert!(AgentBundle::load(&installed, &f.manifest).is_err()); +} + +#[test] +fn display_projection_never_masks_a_forbidden_binding_from_admission() { + let mut f = connected(); + let path = f.bundle.join("tools.json"); + let mut tools: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + tools["tools"][0]["host_method"] = json!("gcalendar.review_save"); + fs::write(path, tools.to_string()).unwrap(); + f.sign(); + let report = f.report(None); + assert!(!report.passed()); + assert!(entry_for( + &f.bundle, + &report, + "publisher-one", + &f.publisher.public_hex(), + "", + "", + "2026-10-06" + ) + .is_err()); +} diff --git a/crates/app-policy/src/agent.rs b/crates/app-policy/src/agent.rs index 938ff70..79990e4 100644 --- a/crates/app-policy/src/agent.rs +++ b/crates/app-policy/src/agent.rs @@ -186,6 +186,13 @@ pub struct ToolSpec { #[serde(default, skip_serializing_if = "Option::is_none")] pub private_data: Option, pub implemented_by: ImplementedBy, + /// An explicitly reviewed shared host-service method. The public tool name + /// stays in this app's namespace; the host still dispatches as this app. + /// Omitted preserves the historical same-name route. Only methods in + /// [`SHARED_HOST_METHODS`] are eligible; this field never grants a service + /// or turns a background tool into a foreground approval surface. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub host_method: Option, /// Whose surface confirms a destructive or outward call. Omitted means /// the host. #[serde(default, skip_serializing_if = "is_host_confirm")] @@ -216,6 +223,12 @@ fn is_true(value: &bool) -> bool { } impl ToolSpec { + /// Dispatch target after bundle validation and the executor's grant check. + /// Never infer it from arguments or the tool's untrusted result. + pub fn service_method(&self) -> &str { + self.host_method.as_deref().unwrap_or(&self.name) + } + /// The name without its namespace: `topics.get`. pub fn local_name(&self) -> &str { self.name.split_once('.').map(|(_, rest)| rest).unwrap_or("") @@ -285,8 +298,25 @@ impl ToolManifest { /// Every rule for a contained app's tools, from its manifest. pub fn check(&self, manifest: &AppManifest) -> Vec { - let local_only = manifest.agent.as_ref().and_then(|a| a.model.as_ref()).is_some_and(|m| m.local_only); - self.validate(short_id(&manifest.id), ToolHost::Contained, local_only) + let local_only = manifest + .agent + .as_ref() + .and_then(|a| a.model.as_ref()) + .is_some_and(|m| m.local_only); + let mut issues = self.validate(short_id(&manifest.id), ToolHost::Contained, local_only); + for tool in &self.tools { + if let Some(method) = &tool.host_method { + let family = method.split('.').next().unwrap_or(""); + if !manifest + .capabilities + .iter() + .any(|grant| grant == family || grant == method) + { + issues.push(Issue::refuse("tools", format!("{}: host_method {method:?} requires the declared {family:?} service capability", tool.name))); + } + } + } + issues } /// Every rule for one owner's tools: `namespace` is the app's short id @@ -315,6 +345,9 @@ impl ToolManifest { let mut broker_names = BTreeSet::new(); for tool in &self.tools { let name = &tool.name; + if let Err(error) = check_host_method(tool) { + issues.push(Issue::refuse("tools", format!("{name}: {error}"))); + } if let Err(e) = check_tool_name(name, namespace) { issues.push(Issue::refuse("tools", e)); } else { @@ -388,6 +421,75 @@ impl ToolManifest { } } +/// Initial shared-service tool contract, deliberately narrower than UI calls. +/// New methods need a reviewed host implementation, account/grant checks and a +/// risk floor here. Provider writes, approval/review controls and account +/// management are not agent aliases. The executor must still check its actual +/// resolved capability and registered handler; admission does not provide one. +pub const SHARED_HOST_METHODS: &[(&str, Risk)] = &[ + ("github.repositories", Risk::Read), + ("github.files", Risk::Read), + ("github.read", Risk::Read), + ("gcalendar.calendars", Risk::Read), + ("gcalendar.sync", Risk::Read), + ("gcalendar.refresh", Risk::Read), + ("gcalendar.cached", Risk::Read), + ("gcalendar.get", Risk::Read), + ("gcalendar.prepare", Risk::Read), + ("gmail.labels", Risk::Read), + ("gmail.messages", Risk::Read), + ("gmail.message", Risk::Read), + ("gmail.draft.open", Risk::Act), + ("gmail.draft.get", Risk::Read), + ("gmail.draft.edit", Risk::Act), + ("gmail.event.decide", Risk::Act), + ("gmail.event.status", Risk::Read), + ("glance.publish", Risk::Act), + ("glance.withdraw", Risk::Act), + ("glance.list", Risk::Read), +]; + +fn check_host_method(tool: &ToolSpec) -> Result<(), String> { + let Some(method) = &tool.host_method else { + return Ok(()); + }; + if tool.implemented_by != ImplementedBy::HostService { + return Err("host_method is only valid for implemented_by \"host-service\"".into()); + } + if method.len() > 96 + || !method.contains('.') + || method.split('.').any(|part| { + part.is_empty() + || !part + .bytes() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'_') + }) + { + return Err( + "host_method must be family.method with nonempty [a-z0-9_] segments, at most 96 bytes" + .into(), + ); + } + if method.split('.').any(|part| part == "sheet") { + return Err("host_method cannot target a host sheet or approve an action".into()); + } + let Some((_, minimum)) = SHARED_HOST_METHODS.iter().find(|(name, _)| *name == method) else { + return Err(format!( + "host_method {method:?} is not in the reviewed shared-service tool contract" + )); + }; + if tool.risk < *minimum { + return Err(format!( + "host_method {method:?} requires at least {} risk", + minimum.broker_name().to_lowercase() + )); + } + if tool.private_data != Some(true) { + return Err("shared-service tools must declare private_data: true".into()); + } + Ok(()) +} + /// `.[.…]`, segments `[a-z0-9_]+`. fn check_tool_name(name: &str, namespace: &str) -> Result<(), String> { let Some((head, rest)) = name.split_once('.') else { diff --git a/crates/app-policy/src/listing.rs b/crates/app-policy/src/listing.rs index de3de81..1656b4d 100644 --- a/crates/app-policy/src/listing.rs +++ b/crates/app-policy/src/listing.rs @@ -155,9 +155,21 @@ pub fn privacy_summary(manifest: &AppManifest) -> Vec { lines.push("Stores nothing.".to_string()); } if has("net") && !manifest.network.hosts.is_empty() { - lines.push(format!("Contacts only: {}.", manifest.network.hosts.join(", "))); + lines.push(format!("Direct network requests are limited to: {}.", manifest.network.hosts.join(", "))); } else { - lines.push("Never contacts the network.".to_string()); + lines.push("No direct network access is granted.".to_string()); + } + let provider_services = manifest.capabilities.iter().any(|cap| { + matches!(cap.as_str(), "auth" | "github" | "gmail" | "gcalendar" | "mail" | "images" | "web" + | "news" | "youtube" | "model" | "research" | "crawl" | "octos.turn.start") + || cap.starts_with("matrix.") + || octosense_app_contract::palpo::SERVICES.contains(&cap.as_str()) + }); + let remote_agent_allowed = manifest.agent.as_ref().is_some_and(|agent| { + !agent.model.as_ref().is_some_and(|model| model.local_only) + }); + if provider_services || remote_agent_allowed { + lines.push("Shared host services and assistants may send data to their providers, under the permissions you allow. Direct-network limits do not restrict those service requests.".to_string()); } if has("ledger.read") { lines.push("Reads your shared data.".to_string()); @@ -172,6 +184,10 @@ pub fn privacy_summary(manifest: &AppManifest) -> Vec { ("microphone", "Records sound with videos."), ("library", "Saves photos and videos to your photo library."), ("mail", "Reads and sends mail from accounts you add; it never sees your password."), + ("auth", "Connects its own provider accounts through the host; credentials stay with the host."), + ("github", "Reads authorized GitHub repositories and requests your review before committing Markdown through the host."), + ("gmail", "Reads authorized Gmail messages, keeps reply drafts and requests native host review before sending."), + ("gcalendar", "Reads authorized Google calendars and requests your review before saving event changes through the host."), ("calendar", "Reads and manages local calendar events through the device's Calendar service."), ("llm", "Manages the assistant's AI providers; it never sees your API keys."), ("news", "Reads news the device collects from its feeds and topics."), @@ -342,7 +358,7 @@ mod tests { let quiet = AppManifest::parse(r#"{"schema":1,"id":"a","version":"1","name":"A","integrity":{"bundle_blake3":"00"}}"#).unwrap(); let lines = privacy_summary(&quiet); assert!(lines.contains(&"Stores nothing.".to_string())); - assert!(lines.contains(&"Never contacts the network.".to_string())); + assert!(lines.contains(&"No direct network access is granted.".to_string())); } #[test] @@ -351,7 +367,7 @@ mod tests { "capabilities":["glance"]}"#).unwrap(); let lines = privacy_summary(&m); assert!(lines.contains(&"Shows short cards on your glance screen; each opens only this app.".to_string()), "{lines:?}"); - assert!(lines.contains(&"Never contacts the network.".to_string()), "{lines:?}"); + assert!(lines.contains(&"No direct network access is granted.".to_string()), "{lines:?}"); let quiet = AppManifest::parse(r#"{"schema":1,"id":"a","version":"1","name":"A","integrity":{"bundle_blake3":"00"}}"#).unwrap(); assert!(!privacy_summary(&quiet).iter().any(|l| l.contains("glance"))); } @@ -366,8 +382,8 @@ mod tests { "{lines:?}" ); assert!(!lines.iter().any(|l| l.starts_with("Crawls")), "{lines:?}"); - // The toolbox is the host's, not the app's network. - assert!(lines.contains(&"Never contacts the network.".to_string()), "{lines:?}"); + // Host-mediated searches must not be described as offline. + assert!(lines.contains(&"No direct network access is granted.".to_string()), "{lines:?}"); let m = AppManifest::parse(r#"{"schema":1,"id":"a","version":"1","name":"A","integrity":{"bundle_blake3":"00"}, "capabilities":["crawl"],"research":{"domains_allow":["docs.rs"],"max_depth":2,"max_pages":30}}"#).unwrap(); @@ -388,9 +404,49 @@ mod tests { lines.iter().any(|l| l.contains("to the AI provider you configured") && l.contains("never sees your API keys")), "{lines:?}" ); - // The model service is the host's, not the app's network. - assert!(lines.contains(&"Never contacts the network.".to_string()), "{lines:?}"); + // Model calls may leave the device even without a direct-network grant. + assert!(lines.contains(&"No direct network access is granted.".to_string()), "{lines:?}"); let quiet = AppManifest::parse(r#"{"schema":1,"id":"a","version":"1","name":"A","integrity":{"bundle_blake3":"00"}}"#).unwrap(); assert!(!privacy_summary(&quiet).iter().any(|l| l.contains("AI provider"))); } + + #[test] + fn connected_apps_disclose_service_traffic_without_a_direct_network_grant() { + for capability in ["auth", "github", "gmail", "gcalendar", "model", "research", "crawl", "octos.turn.start"] { + let m = AppManifest::parse(&serde_json::json!({ + "schema":1,"id":"preview","version":"1","name":"Preview", + "integrity":{"bundle_blake3":"00"},"capabilities":[capability] + }).to_string()).unwrap(); + let lines = privacy_summary(&m); + assert!(lines.iter().any(|line| line == "No direct network access is granted."), "{capability}: {lines:?}"); + assert!(lines.iter().any(|line| line.contains("may send data to their providers")), "{capability}: {lines:?}"); + assert!(!lines.iter().any(|line| line.contains("Never contacts") || line.starts_with("Contacts only:"))); + if ["auth", "github", "gmail", "gcalendar"].contains(&capability) { + assert!(lines.iter().any(|line| line.starts_with("Connects its own") || line.starts_with("Reads authorized"))); + } + } + } + + #[test] + fn a_direct_allowlist_does_not_claim_to_limit_shared_provider_requests() { + let m = AppManifest::parse(r#"{"schema":1,"id":"a","version":"1","name":"A", + "integrity":{"bundle_blake3":"00"},"capabilities":["net","github"], + "network":{"hosts":["notes.example"]}}"#).unwrap(); + let lines = privacy_summary(&m); + assert!(lines.contains(&"Direct network requests are limited to: notes.example.".into())); + assert!(lines.iter().any(|line| line.contains("Direct-network limits do not restrict those service requests"))); + assert!(lines.iter().any(|line| line.contains("GitHub repositories"))); + } + + #[test] + fn assistant_provider_access_is_disclosed_but_local_ui_does_not_gain_it() { + let mut m = AppManifest::parse(r#"{"schema":1,"id":"a","version":"1","name":"A", + "integrity":{"bundle_blake3":"00"},"agent":{"profile":"read-only","tools":[]}}"#).unwrap(); + assert!(privacy_summary(&m).iter().any(|line| line.contains("may send data to their providers"))); + m.agent = None; + m.capabilities = vec!["storage".into(), "glance".into()]; + let lines = privacy_summary(&m); + assert!(lines.contains(&"No direct network access is granted.".into())); + assert!(!lines.iter().any(|line| line.contains("may send data to their providers"))); + } } diff --git a/crates/app-policy/src/services.rs b/crates/app-policy/src/services.rs index 5250ffa..297e3ac 100644 --- a/crates/app-policy/src/services.rs +++ b/crates/app-policy/src/services.rs @@ -53,6 +53,10 @@ pub fn is_host_service(capability: &str) -> bool { pub fn service_words(capability: &str) -> Option<&'static str> { if let Some(words) = octosense_app_contract::palpo::words(capability) { return Some(words); } Some(match capability { + "auth" => "Connect and disconnect its own GitHub or Google accounts through the host", + "github" => "Read authorized repositories and ask you to review Markdown commits", + "gcalendar" => "Read authorized Google calendars and ask you to review event changes", + "gmail" => "Read authorized Gmail messages, keep reply drafts and request native send review", "matrix.account_info" => "See which Matrix account you are using", "matrix.device" => "See this device's Matrix session details", "matrix.dm_find" => "Find your direct chats with a person", @@ -148,4 +152,13 @@ mod tests { assert!(!KNOWN_CAPABILITIES.contains(&name), "{name:?} must not be known"); } } + + #[test] + fn connected_services_have_consent_words_without_implied_grants() { + for name in ["auth", "github", "gcalendar", "gmail"] { + assert!(KNOWN_CAPABILITIES.contains(&name)); + assert!(service_words(name).is_some()); + assert!(!is_host_service(&format!("{name}.*"))); + } + } } diff --git a/crates/app-policy/tests/agent_bundle.rs b/crates/app-policy/tests/agent_bundle.rs index c447f1d..47e7713 100644 --- a/crates/app-policy/tests/agent_bundle.rs +++ b/crates/app-policy/tests/agent_bundle.rs @@ -233,6 +233,155 @@ fn every_tool_is_in_the_apps_namespace() { refused_with(&dir, &manifest, "segments of [a-z0-9_]"); } +#[test] +fn shared_host_method_alias_is_pinned_and_needs_its_declared_capability() { + let dir = scratch("shared-host-method"); + edit_tools(&dir, |tools| { + tools["tools"][0]["host_method"] = json!("gcalendar.cached"); + tools["tools"][0]["private_data"] = json!(true); + tools["tools"][0]["shareable"] = json!(false); + }); + let manifest = stamp(&dir, |_| {}); + refused_with( + &dir, + &manifest, + "requires the declared \"gcalendar\" service capability", + ); + assert!(AgentBundle::load(&dir, &manifest).is_err()); + let manifest = stamp(&dir, |m| { + m["capabilities"] + .as_array_mut() + .unwrap() + .push(json!("gcalendar")) + }); + let loaded = AgentBundle::load(&dir, &manifest).unwrap().unwrap(); + let tool = loaded.tool("news.list").unwrap(); + assert_eq!(tool.service_method(), "gcalendar.cached"); + assert_eq!( + tool.name, "news.list", + "alias never changes owner namespace" + ); + edit_tools(&dir, |tools| { + tools["tools"][0]["host_method"] = json!("gmail.messages") + }); + assert!(AgentBundle::load(&dir, &manifest) + .unwrap_err() + .contains("digest")); +} + +#[test] +fn host_aliases_cannot_target_script_implementations_sheets_or_unreviewed_writes() { + let base = || { + json!({"schema":1,"tools":[{ + "name":"inbox.messages","description":"Read the connected inbox.", + "input_schema":{"type":"object"},"output_schema":{"type":"object"}, + "risk":"read","private_data":true,"implemented_by":"host-service","host_method":"gmail.messages" + }]}) + }; + let valid = + ToolManifest::load(&base().to_string(), "inbox", ToolHost::Contained, false).unwrap(); + assert_eq!(valid.0.tools[0].service_method(), "gmail.messages"); + let mut app = base(); + app["tools"][0]["implemented_by"] = json!("app"); + assert!( + ToolManifest::load(&app.to_string(), "inbox", ToolHost::Contained, false) + .unwrap_err() + .contains("only valid") + ); + for method in [ + "gmail.sheet.send", + "gmail.sheet", + "auth.connect", + "auth.disconnect", + "auth.accounts", + "gmail.send", + "gmail.draft.review", + "gcalendar.review_save", + "github.review_save", + "github.save", + "unknown.read", + "gmail..message", + "Gmail.message", + "gmail/message", + "gmail.message?scope=all", + "gmail.message\n", + "gmail.message.extra", + ] { + let mut tool = base(); + tool["tools"][0]["host_method"] = json!(method); + assert!( + ToolManifest::load(&tool.to_string(), "inbox", ToolHost::Contained, false).is_err(), + "{method}" + ); + } +} + +#[test] +fn shared_local_mutations_keep_risk_and_private_data_floors() { + let make = |method: &str, risk: &str, private: bool| { + json!({"schema":1,"tools":[{ + "name":"inbox.action","description":"Run a scoped inbox action.", + "input_schema":{"type":"object"},"output_schema":{"type":"object"}, + "risk":risk,"private_data":private,"implemented_by":"host-service","host_method":method + }]}) + .to_string() + }; + for method in [ + "gmail.draft.open", + "gmail.draft.edit", + "gmail.event.decide", + "glance.publish", + "glance.withdraw", + ] { + assert!(ToolManifest::load( + &make(method, "read", true), + "inbox", + ToolHost::Contained, + false + ) + .unwrap_err() + .contains("at least act")); + assert!(ToolManifest::load( + &make(method, "act", true), + "inbox", + ToolHost::Contained, + false + ) + .is_ok()); + } + assert!(ToolManifest::load( + &make("gmail.event.status", "read", true), + "inbox", + ToolHost::Contained, + false + ) + .is_ok()); + assert!(ToolManifest::load( + &make("gmail.message", "read", false), + "inbox", + ToolHost::Contained, + false + ) + .unwrap_err() + .contains("private_data: true")); +} + +#[test] +fn omitted_host_method_keeps_legacy_serialization_and_dispatch() { + let original = read_json(&fixture().join(TOOLS_FILE)); + let tools = ToolManifest::parse(&original.to_string()).unwrap(); + let serialized = serde_json::to_value(&tools).unwrap(); + for (tool, value) in tools + .tools + .iter() + .zip(serialized["tools"].as_array().unwrap()) + { + assert!(tool.host_method.is_none()); + assert_eq!(tool.service_method(), tool.name); + assert!(!value.as_object().unwrap().contains_key("host_method")); + } +} + #[test] fn an_app_whose_short_id_the_broker_cannot_name_may_not_declare_tools() { let dir = scratch("short-id"); diff --git a/crates/appstore/src/cardapp.rs b/crates/appstore/src/cardapp.rs index afc2f1d..575bb16 100644 --- a/crates/appstore/src/cardapp.rs +++ b/crates/appstore/src/cardapp.rs @@ -43,6 +43,10 @@ pub struct CardAppView { #[rust] app_id: String, #[rust] + card_surface: SplashRef, + #[rust] + host_sheet: SplashRef, + #[rust] started: bool, #[rust] asset_server: Option, @@ -56,6 +60,9 @@ pub struct CardAppView { impl CardAppView { fn start(&mut self, cx: &mut Cx) { + // Capture host identities before untrusted app widgets exist. + self.card_surface = self.view.splash(cx, ids!(body.card)); + self.host_sheet = self.view.splash(cx, ids!(sheet)); let root = crate::data_root(cx); // `.host` can never be an app id, so it is no app's jail. self.host_dir = root.join(".host"); @@ -106,7 +113,7 @@ impl CardAppView { settings.hosts.push(server.allowlist_entry()); let origin = server.origin().to_string(); self.asset_server = Some(server); - let splash = self.view.splash(cx, ids!(card)); + let splash = self.card_surface.clone(); let applied = octosense_app_policy::splash_adapter::apply(&splash, cx, &settings); log!( "card: {} running under {} capability(ies), {} host(s), {} bytes of storage, {} instructions, {} bytes of heap", @@ -131,11 +138,11 @@ impl Widget for CardAppView { self.started = true; self.start(cx); } - let (card, sheet) = (self.view.splash(cx, ids!(card)), self.view.splash(cx, ids!(sheet))); + let (card, sheet) = (self.card_surface.clone(), self.host_sheet.clone()); // A sheet is modal: while it is up, the person's input is for it, and // the app underneath must not take a tap meant for a password field. let sheet_up = sheet.borrow().map(|s| s.view.visible).unwrap_or(false); - if sheet_up && event.requires_visibility() { + if sheet_up && crate::services::is_sheet_input_event(event) { sheet.handle_event(cx, event, scope); } else { self.view.handle_event(cx, event, scope); @@ -218,3 +225,53 @@ impl ServiceExecutor for CardExecutor { #[allow(dead_code)] fn _unused(_: PathBuf) {} + +#[cfg(test)] +mod modal_tests { + use super::*; + use std::cell::RefCell; + thread_local! {static SEEN: RefCell> = const {RefCell::new(Vec::new())};} + script_mod! { + use mod.prelude.widgets.* + mod.widgets.ModalInputProbe = set_type_default() do #(ModalInputProbe::register_widget(vm)) {} + mod.prelude.widgets.ModalInputProbe = mod.widgets.ModalInputProbe + } + #[derive(Script, ScriptHook, Widget)] + struct ModalInputProbe { + #[deref] view: View, + #[live] label: String, + } + impl Widget for ModalInputProbe { + fn handle_event(&mut self, _: &mut Cx, event: &Event, _: &mut Scope) { + if matches!(event, Event::TextInput(_) | Event::KeyDown(_)) { + SEEN.with(|seen| seen.borrow_mut().push(self.label.clone())); + } + } + fn draw_walk(&mut self, _: &mut Cx2d, _: &mut Scope, _: Walk) -> DrawStep {DrawStep::done()} + } + #[test] + fn host_sheet_exclusively_receives_text_and_keys_above_installed_app() { + let mut cx = Cx::new(Box::new(|_, _| {})); + widget_async::register_splash_isolate_mod(|vm| {script_mod(vm);}); + let root = cx.with_vm(|vm| { + makepad_widgets::script_mod(vm); + super::script_mod(vm); + let value = script_eval!(vm, {use mod.widgets.* CardAppView {}}); + WidgetRef::script_from_value(vm, value) + }); + { + let mut runner = root.borrow_mut::().unwrap(); + runner.started = true; + runner.app_id = "org.example.modal-input".into(); + runner.card_surface = runner.view.splash(&mut cx, ids!(body.card)); + runner.host_sheet = runner.view.splash(&mut cx, ids!(sheet)); + runner.card_surface.set_text(&mut cx, "ModalInputProbe {label: \"app\"}"); + runner.host_sheet.set_text(&mut cx, "ModalInputProbe {label: \"host\"}"); + runner.host_sheet.borrow_mut().unwrap().view.visible = true; + } + SEEN.with(|seen| seen.borrow_mut().clear()); + root.handle_event(&mut cx, &Event::TextInput(TextInputEvent {input: "private sheet text".into(), ..Default::default()}), &mut Scope::empty()); + root.handle_event(&mut cx, &Event::KeyDown(KeyEvent {key_code: KeyCode::KeyA, ..Default::default()}), &mut Scope::empty()); + assert_eq!(SEEN.with(|seen| seen.borrow().clone()), ["host", "host"], "underlying app must not receive host-sheet text or keys"); + } +} diff --git a/crates/appstore/src/services.rs b/crates/appstore/src/services.rs index 3c0db2e..b6d9d0d 100644 --- a/crates/appstore/src/services.rs +++ b/crates/appstore/src/services.rs @@ -360,17 +360,16 @@ fn heap_of(cx: &mut Cx, splash: &SplashRef, only_visible: bool) -> Option } fn apply_sheet(cx: &mut Cx, sheet: &SplashRef, change: Option) { - static OPENED: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + // A non-empty Splash::set_text reuses its isolate. A new program string + // alone does not revoke the old sheet's callbacks, timers or queued work. + // Stop it first, including replies that a worker has not delivered yet. + if let Some(heap) = heap_of(cx, sheet, false) { + cancel_heap(heap); + } + sheet.set_text(cx, ""); let up = change.is_some(); - match change { - Some(body) => { - // Every sheet starts empty: the same body again would keep the - // last one's fields, a password typed into a cancelled sign-in - // among them. The counter makes each opening a new program. - let n = OPENED.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - sheet.set_text(cx, &format!("let sheet_opening = {n}\n{body}")); - } - None => sheet.set_text(cx, ""), + if let Some(body) = change { + sheet.set_text(cx, &body); } // After the text: a new program, or tearing the old one down, replaces // the sheet's view, and the replacement is visible. A sheet left @@ -382,6 +381,19 @@ fn apply_sheet(cx: &mut Cx, sheet: &SplashRef, change: Option) { cx.redraw_all(); } +/// Input owned exclusively by a visible host sheet. Makepad's visibility +/// classification omits keyboard, text and pointer-up events; it is not a +/// modal-input boundary. Timers and service replies still reach the app. +pub fn is_sheet_input_event(event: &makepad_widgets::Event) -> bool { + use makepad_widgets::Event; + event.requires_visibility() || matches!(event, + Event::MouseUp(_) | Event::MouseLeave(_) | Event::LongPress(_) + | Event::KeyDown(_) | Event::KeyUp(_) | Event::TextInput(_) + | Event::TextRangeReplace(_) | Event::TextCopy(_) | Event::TextCut(_) + | Event::TextInputStateQuery(_) | Event::ImeAction(_) + | Event::SelectionHandleDrag(_) | Event::Drag(_) | Event::Drop(_) | Event::DragEnd) +} + /// One turn of a host running an app: hand the app's (and its sheet's) host /// requests to the services, apply the sheets they raise, and deliver the /// answers that are ready. Call it on every event the host sees. @@ -405,7 +417,21 @@ pub fn pump(cx: &mut Cx, app_id: &str, host_dir: &std::path::Path, card: &Splash expire_pending(now, |_| true); purge_replies(now, |_| true); } + // Requests drained together may span a replacement: the first request can + // open another service's sheet before a queued close/submit from the old + // sheet is visited. Heap addresses may also be reused after teardown, so + // invalidate this batch's sheet authority explicitly on every change. + // A replacement's startup requests remain queued for the next pump. + let mut original_sheet_valid = true; for request in makepad_widgets::splash_host::take_splash_host_requests_for(&heaps) { + let current_app = heap_of(cx, card, false); + let current_sheet = if original_sheet_valid { heap_of(cx, sheet, true) } else { None }; + let from_sheet = Some(request.heap_key) == current_sheet; + if Some(request.heap_key) != current_app && !from_sheet { + // Its surface went away earlier in this batch. Never downgrade it + // to an app request: ordinary methods can also mutate host state. + continue; + } let args = match request_args(&request.service, &request.args_json) { Ok(args) => args, Err(refusal) => { @@ -417,7 +443,7 @@ pub fn pump(cx: &mut Cx, app_id: &str, host_dir: &std::path::Path, card: &Splash app_id: app_id.to_string(), service: request.service.clone(), args, - from_sheet: Some(request.heap_key) == sheet_heap, + from_sheet, may_prompt: request.may_prompt, host_dir: host_dir.to_path_buf(), }; @@ -430,6 +456,7 @@ pub fn pump(cx: &mut Cx, app_id: &str, host_dir: &std::path::Path, card: &Splash }; dispatch(call, request.heap_key, request.req_id, &mut ops); if let Some(change) = ops.change { + original_sheet_valid = false; apply_sheet(cx, sheet, change); } } @@ -666,4 +693,75 @@ mod tests { purge_replies(later(REPLY_TTL + Duration::from_secs(1)), |h| h == 7109); assert!(take_replies_for(&[7109]).is_empty(), "an isolate that went away without closing leaves nothing behind"); } + + #[test] + fn replacing_a_sheet_revokes_its_queued_requests_and_late_replies() { + use makepad_widgets::*; + + struct LifecycleProbe { + family: &'static str, + seen: Arc>>, + held: Arc>>, + } + impl HostService for LifecycleProbe { + fn family(&self) -> &'static str { self.family } + fn call(&mut self, call: ServiceCall, reply: Replier, host: &mut dyn ServiceHost) { + self.seen.lock().unwrap().push(call.service.clone()); + match call.method() { + "hold" => { *self.held.lock().unwrap() = Some(reply); return; } + "open" => host.open_sheet(r#" + let previous = try {mod.previous_sheet_secret} catch {nil} + host.request("sheet_lifecycle_new.sheet.ready", {previous: previous}, fn(r){}) + Label {text: "replacement"} + "#.into()), + "sheet.close" => host.close_sheet(), + "sheet.ready" => { + assert!(call.from_sheet, "the replacement has its own sheet authority"); + assert!(call.args["previous"].is_null(), "old sheet state crossed into the replacement"); + } + _ => {} + } + reply.send(Ok(Value::Null)); + } + } + + let seen = Arc::new(Mutex::new(Vec::new())); + let held = Arc::new(Mutex::new(None)); + for family in ["sheet_lifecycle_old", "sheet_lifecycle_new"] { + register_host_service(Box::new(LifecycleProbe {family, seen: seen.clone(), held: held.clone()})); + } + let mut cx = Cx::new(Box::new(|_, _| {})); + let root = cx.with_vm(|vm| { + makepad_widgets::script_mod(vm); + let value = script_eval!(vm, { + use mod.widgets.* + View {card := Splash{} sheet := Splash{visible: false}} + }); + WidgetRef::script_from_value(vm, value) + }); + let card = root.splash(&mut cx, ids!(card)); + let sheet = root.splash(&mut cx, ids!(sheet)); + card.set_text(&mut cx, "Label{text: \"app\"}"); + apply_sheet(&mut cx, &sheet, Some(r#" + mod.previous_sheet_secret = "must not survive" + host.request("sheet_lifecycle_old.hold", {}, fn(r){}) + host.request("sheet_lifecycle_new.open", {}, fn(r){}) + host.request("sheet_lifecycle_old.sheet.close", {}, fn(r){}) + host.request("sheet_lifecycle_old.mutate", {}, fn(r){}) + Label {text: "old sheet"} + "#.into())); + let old_heap = heap_of(&mut cx, &sheet, true).expect("old sheet has an isolate"); + pump(&mut cx, "org.example.sheet-lifecycle", &std::env::temp_dir(), &card, &sheet); + assert_eq!(*seen.lock().unwrap(), ["sheet_lifecycle_old.hold", "sheet_lifecycle_new.open"]); + assert!(sheet.borrow().unwrap().view.visible, "the stale close must not hide the replacement"); + held.lock().unwrap().take().unwrap().send(Ok(Value::Null)); + assert!(take_replies_for(&[old_heap]).is_empty(), "the retired sheet's worker cannot reply late"); + + pump(&mut cx, "org.example.sheet-lifecycle", &std::env::temp_dir(), &card, &sheet); + assert_eq!(*seen.lock().unwrap(), [ + "sheet_lifecycle_old.hold", "sheet_lifecycle_new.open", "sheet_lifecycle_new.sheet.ready" + ], "only the replacement's new request may reach a service"); + apply_sheet(&mut cx, &sheet, None); + card.set_text(&mut cx, ""); + } }