From 9586193f7b89799e4acb93716d2d30d4f488293e Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:51:35 -0700 Subject: [PATCH 1/6] kernel: a dead kernel's close reason waits for its last stderr lines The supervisor saw the kernel end (its stdout closing, or its exit status) and read the stderr tail at once, but stderr is copied into the tail by a task of its own. The line the kernel wrote just before it exited could still be in the pipe, so the reason lost it: CI's `a_kernel_that_dies_closes_its_connections_with_its_last_words` failed with "the kernel closed its output: fake kernel up", without the "asked to exit" the fake kernel wrote last. The tail is now read after the kernel is stopped and the stderr task has reached the end of the pipe (bounded at 2 s, for a process the kernel started that still holds the pipe). Failed reasons get the same complete tail. The test asks for the last words themselves, not just "exit". Co-Authored-By: Claude Opus 5.5 --- crates/kernel/src/kernel.rs | 42 +++++++++++++++++++++++++++++-------- crates/kernel/tests/core.rs | 4 +++- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/crates/kernel/src/kernel.rs b/crates/kernel/src/kernel.rs index 360847b4..78482254 100644 --- a/crates/kernel/src/kernel.rs +++ b/crates/kernel/src/kernel.rs @@ -60,6 +60,9 @@ struct Io { /// host-managed server stops when it reaches EOF, which also happens /// when this process dies, so a crashed shell leaves no kernel behind. lifeline: Option, + /// The task copying the child's stderr into the [`Tail`]; it ends at + /// the end of the pipe, after the kernel's last line. + stderr: Option>, } /// Keeps the last lines the kernel wrote to stderr, to say why it exited. @@ -116,7 +119,7 @@ fn start(launch: &Launch, network: &Network, log: &LogSink, tail: &Tail) -> Resu return Err("the kernel's stdin/stdout are not piped".into()); }; // The kernel logs to stderr; it never carries protocol frames. - if let Some(stderr) = child.stderr.take() { + let stderr = child.stderr.take().map(|stderr| { let log = log.clone(); let tail = tail.clone(); tokio::spawn(async move { @@ -126,14 +129,14 @@ fn start(launch: &Launch, network: &Network, log: &LogSink, tail: &Tail) -> Resu (log)(&format!("octos: {line}")); tail.push(line); } - }); - } + }) + }); let lines = BufReader::new(Box::pin(stdout) as Pin>).lines(); Ok(if shared { // Frames go over the WebSocket once it is up (`prepare_network`). - Io { writer: Box::pin(tokio::io::sink()), lines, running: Running::Child(child), lifeline: Some(stdin) } + Io { writer: Box::pin(tokio::io::sink()), lines, running: Running::Child(child), lifeline: Some(stdin), stderr } } else { - Io { writer: Box::pin(stdin), lines, running: Running::Child(child), lifeline: None } + Io { writer: Box::pin(stdin), lines, running: Running::Child(child), lifeline: None, stderr } }) } #[cfg(target_env = "ohos")] @@ -153,6 +156,7 @@ fn start(launch: &Launch, network: &Network, log: &LogSink, tail: &Tail) -> Resu lines: BufReader::new(Box::pin(reader) as Pin>).lines(), running: Running::Embedded(task), lifeline: None, + stderr: None, }) } #[cfg(not(target_env = "ohos"))] @@ -266,7 +270,7 @@ async fn exited(running: &mut Running) -> String { async fn stop(io: Io) { // Closing stdin stops either kind of child: the stdio pipe drains owned // turns on EOF; a host-managed server drains its connections and exits. - let Io { writer, lines, mut running, lifeline } = io; + let Io { writer, lines, mut running, lifeline, .. } = io; let grace = if lifeline.is_some() { SHARED_GRACE } else { STDIO_GRACE }; drop(writer); drop(lines); @@ -401,16 +405,18 @@ pub(crate) async fn supervise( } } } - Ok(None) => break CloseReason::Exited(with_tail("the kernel closed its output", &tail)), + Ok(None) => break CloseReason::Exited("the kernel closed its output".into()), Err(e) => break CloseReason::Exited(format!("reading from the kernel failed: {e}")), }, - why = exited(&mut io.running) => break CloseReason::Exited(with_tail(&why, &tail)), + why = exited(&mut io.running) => break CloseReason::Exited(why), } }}; (log)(&format!("octos-core: stopping kernel {generation}: {reason}")); + let stderr = io.stderr.take(); stop(io).await; match reason { - CloseReason::Failed(why) => CloseReason::Failed(with_tail(&why, &tail)), + CloseReason::Failed(why) => CloseReason::Failed(last_words(&why, stderr, &tail).await), + CloseReason::Exited(why) => CloseReason::Exited(last_words(&why, stderr, &tail).await), other => other, } } @@ -464,6 +470,24 @@ fn tool_list_reply(text: &str) -> Option { }) } +/// How long a stopped kernel's stderr may take to reach its end. The pipe +/// closes with the kernel; only a process it started that inherited the +/// pipe can hold it open longer. +const LAST_WORDS_GRACE: Duration = Duration::from_secs(2); + +/// `why` with the kernel's last words (the [`Tail`]), once `stderr`, the +/// task reading them, has reached the end of the pipe. That task runs +/// beside the supervisor, so lines the kernel wrote just before its stdout +/// closed or it exited can still be on their way to the tail: read at once, +/// the reason said "the kernel closed its output: fake kernel up" without +/// the "asked to exit" the kernel wrote last. +async fn last_words(why: &str, stderr: Option>, tail: &Tail) -> String { + if let Some(reader) = stderr { + let _ = tokio::time::timeout(LAST_WORDS_GRACE, reader).await; + } + with_tail(why, tail) +} + fn with_tail(why: &str, tail: &Tail) -> String { let tail = tail.text(); if tail.is_empty() { diff --git a/crates/kernel/tests/core.rs b/crates/kernel/tests/core.rs index 2a74ba25..43d9d545 100644 --- a/crates/kernel/tests/core.rs +++ b/crates/kernel/tests/core.rs @@ -238,7 +238,9 @@ async fn a_kernel_that_dies_closes_its_connections_with_its_last_words() { call(&mut a, "1", "session/list", json!({})).await; a.send(json!({"jsonrpc": "2.0", "method": "test/exit", "params": {}}).to_string()).unwrap(); match next(&mut a).await { - Err(CloseReason::Exited(why)) => assert!(why.contains("asked to exit") || why.contains("exit"), "{why}"), + // The line it wrote to stderr last, whichever end the core saw + // first: its output closing or its exit status. + Err(CloseReason::Exited(why)) => assert!(why.contains("asked to exit"), "{why}"), other => panic!("expected Exited, got {other:?}"), } for _ in 0..100 { From 57d36f3f8d6c1e4b6f59f066f617d62387bd72dd Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:53:12 -0700 Subject: [PATCH 2/6] fix(shell): the catalog tests read the build's apps, not other tests' probes The glance tile tests register probe system apps (os.glancetile, os.glanceinput, os.glancequiet, os.glancesweep) in App Hub's system-app registry. That registry is process-wide, has no removal, and is the one `system_card_apps` lists, so a catalog test running after a glance test in the same process saw the probes as launcher rows: `bundled_apps_open_without_catalog_files_or_child_processes` failed its catalog comparison 7 of 10 times beside `glance_card::` on main, and `the_system_apps_ship_as_card_apps` compares the same list. Tests now register their system apps through `apps::test_system_apps::register`, which notes the id before it registers it, and the tests of the build's own catalog leave noted ids out. Production code is unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/shell/src/apps.rs | 39 ++++++++++++++++++++++++++++++--- crates/shell/src/glance_card.rs | 5 +++-- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/crates/shell/src/apps.rs b/crates/shell/src/apps.rs index 5cba914d..64b30f32 100644 --- a/crates/shell/src/apps.rs +++ b/crates/shell/src/apps.rs @@ -141,6 +141,32 @@ pub fn system_card_apps() -> Vec { Vec::new() } +/// System apps a test registers for itself (the glance tile tests' +/// probes). They join App Hub's registry, the one [`system_card_apps`] +/// lists, which is process-wide and never forgets an app, so a test of the +/// build's own catalog saw them or not depending on which tests had run +/// before it in the same process. A test registers its app here, and the +/// catalog tests leave these out. +#[cfg(test)] +pub(crate) mod test_system_apps { + use std::sync::Mutex; + + static IDS: Mutex> = Mutex::new(Vec::new()); + + /// Register `app` for a test. It is noted first, so a catalog that + /// lists it was read after it was noted. + #[cfg(feature = "app-hub")] + pub(crate) fn register(app: octosense_appstore::system::SystemApp) { + IDS.lock().unwrap_or_else(|e| e.into_inner()).push(app.id); + octosense_appstore::system::register_system_app(app); + } + + /// Whether a test registered the system app `id` (an `os.` id). + pub(crate) fn is_test_app(id: &str) -> bool { + IDS.lock().unwrap_or_else(|e| e.into_inner()).contains(&id) + } +} + /// The services contained apps call through `host.request` (ADR 0004) /// that are not the assistant's, registered once, before the first system /// app can open: `mail` keeps accounts and passwords for the Mail app; @@ -658,12 +684,19 @@ mod tests { .collect() } + /// The build's own rows among `rows`: without the system apps other + /// tests registered for themselves ([`super::test_system_apps`]). + /// `rows` must be read before this looks, as an argument is. + fn build_rows(rows: Vec) -> Vec { + rows.into_iter().filter(|row| !card_manifest_id(row).is_some_and(super::test_system_apps::is_test_app)).collect() + } + #[cfg(feature = "mobile-apps")] #[test] fn bundled_apps_open_without_catalog_files_or_child_processes() { use makepad_widgets::*; let _one_rinx = crate::module_host::RINX_INSTANCE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - let catalog = bundled_catalog(); + let catalog = build_rows(bundled_catalog()); // The linked modules in link order (AppCard is opt-in, `app-appcard`, // not part of `mobile-apps`; `settings` is the phone product's), then // the system apps no native module of the same id replaces. @@ -718,7 +751,7 @@ mod tests { let mut cx = Cx::new(Box::new(|_, _| {})); cx.with_vm(makepad_widgets::script_mod); let mut host = crate::module_host::ModuleHost::default(); - for (index, app) in bundled_catalog().iter().enumerate() { + for (index, app) in build_rows(bundled_catalog()).iter().enumerate() { let module = registry.module(&app.id).unwrap(); let client = index as u64 + 1; host.create(&mut cx, client, module, module_open(module, app).unwrap(), dvec2(400.0, 700.0)).unwrap(); @@ -768,7 +801,7 @@ mod tests { fn the_system_apps_ship_as_card_apps() { let registry = AppRegistry::default(); let native = registry.linked_ids(); - let ids: Vec = system_card_apps().into_iter().map(|app| app.id).collect(); + let ids: Vec = build_rows(system_card_apps()).into_iter().map(|app| app.id).collect(); let expected: Vec<&str> = system_app_ids().iter().copied().filter(|id| !native.contains(id)).collect(); assert_eq!(ids, expected); for id in &ids { diff --git a/crates/shell/src/glance_card.rs b/crates/shell/src/glance_card.rs index 7716a342..6906a252 100644 --- a/crates/shell/src/glance_card.rs +++ b/crates/shell/src/glance_card.rs @@ -839,7 +839,8 @@ mod tests { } /// A system app, registered from a pack made on the fly, whose manifest - /// grants `glance` and `storage` and nothing else. + /// grants `glance` and `storage` and nothing else. 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) { let dir = std::env::temp_dir().join(format!("glance-tile-app-{id}-{}", std::process::id())); @@ -852,7 +853,7 @@ mod tests { ); std::fs::write(dir.join("manifest.json"), manifest).unwrap(); let pack = serde_json::to_string(&octosense_app_hub::pack::pack_dir(&dir).unwrap()).unwrap(); - octosense_appstore::system::register_system_app(octosense_appstore::system::SystemApp { id, name: "Glance test", pack: Box::leak(pack.into_boxed_str()), assets: &[] }); + crate::apps::test_system_apps::register(octosense_appstore::system::SystemApp { id, name: "Glance test", pack: Box::leak(pack.into_boxed_str()), assets: &[] }); } /// A Cx with the widgets and the tile frame registered, for driving From 967418ccba7cf3228bead53767c17f6ebc822f48 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:02:48 -0700 Subject: [PATCH 3/6] fix(shell): the relay seam's test keeps to approvals of its own `approvals::tests::the_relay_gets_decisions_made_before_it_was_installed` began with `init_memory()`, which replaces the shell's process-wide approvals with empty ones. Every consent a test running beside it had given was gone. `app_chat::tests::the_panel_opens_a_sharing_context_and_sends_person_turns_there` allows its app, then reads `agents::line()`, which reports a peer slug only while the app is allowed. Beside `approvals::` it failed 50 of 50 runs with `peer_slug: null`. The same test also installed its recording relay into the shared approvals, where it would take other tests' decisions. The relay seam (`approval_requested`, `set_relay`, `bus_requested`) is now methods on `Approvals`. The free functions call them on the shell's approvals, as before. The test builds `Approvals::memory()` and never touches the shared one. Co-Authored-By: Claude Opus 5.5 --- crates/shell/src/approvals/mod.rs | 52 ++++++++++++++++++----------- crates/shell/src/approvals/tests.rs | 44 ++++++++++++------------ 2 files changed, 54 insertions(+), 42 deletions(-) diff --git a/crates/shell/src/approvals/mod.rs b/crates/shell/src/approvals/mod.rs index 33168e40..0d1a0fd2 100644 --- a/crates/shell/src/approvals/mod.rs +++ b/crates/shell/src/approvals/mod.rs @@ -130,6 +130,31 @@ impl Approvals { pub fn consent_granted(&self, app: &str) -> bool { self.consent.granted(app, self.router.hooks().grants_all(app)) } + /// The relay's entry point (octos#2567) on these approvals: a call + /// needs an approval. + pub fn approval_requested(&mut self, app: &str, tool: ToolSpec, args: serde_json::Value, caller: Caller, context: RequestContext) -> Route { + self.router.approval_requested(app, tool, args, caller, context) + } + /// The relay installs itself; decisions made before are handed over + /// first. + pub fn set_relay(&mut self, mut relay: Box) { + for (id, decision, reason) in self.queue.take() { + relay.approval_decided(&id, decision, &reason); + } + *self.external.lock().unwrap_or_else(|e| e.into_inner()) = Some(relay); + } + /// The AI bus's held `confirm: host` call as a request like any other. + pub fn bus_requested(&mut self, held: &crate::ai_bus::HeldCall) -> Route { + let mut tool = ToolSpec::host(&held.tool); + tool.auto_approvable = held.auto_approvable; + if held.command { + tool = tool.command(); + } + let args = serde_json::from_str(&held.args).unwrap_or_else(|_| serde_json::Value::String(held.args.clone())); + // The pane is the person's own conversation with the system agent. + let context = RequestContext { call_id: held.key.clone(), trigger: Trigger::Person, ..RequestContext::default() }; + self.approval_requested(&held.app, tool, args, Caller::SystemAgent, context) + } } static STATE: Mutex> = Mutex::new(None); @@ -160,34 +185,23 @@ pub fn now() -> u64 { // ------------------------------------------------------------ the relay -/// The relay's entry point (octos#2567): a call needs an approval. +/// The relay's entry point (octos#2567): a call needs an approval +/// ([`Approvals::approval_requested`] on the shell's approvals). pub fn approval_requested(app: &str, tool: ToolSpec, args: serde_json::Value, caller: Caller, context: RequestContext) -> Route { - with(|a| a.router.approval_requested(app, tool, args, caller, context)).unwrap_or_else(|| Route::Refused("approvals are not set up".into())) + with(|a| a.approval_requested(app, tool, args, caller, context)).unwrap_or_else(|| Route::Refused("approvals are not set up".into())) } -/// The relay installs itself; decisions made before are handed over first. -pub fn set_relay(mut relay: Box) { - with(|a| { - for (id, decision, reason) in a.queue.take() { - relay.approval_decided(&id, decision, &reason); - } - *a.external.lock().unwrap_or_else(|e| e.into_inner()) = Some(relay); - }); +/// The relay installs itself; decisions made before are handed over first +/// ([`Approvals::set_relay`] on the shell's approvals). +pub fn set_relay(relay: Box) { + with(|a| a.set_relay(relay)); } /// The AI bus's `confirm: host` calls (`ai_bus::Route::Approval`): each is /// a request like any other; the shell drains the answers here and /// releases the held call (`AiBus::release`). pub fn bus_requested(held: &crate::ai_bus::HeldCall) -> Route { - let mut tool = ToolSpec::host(&held.tool); - tool.auto_approvable = held.auto_approvable; - if held.command { - tool = tool.command(); - } - let args = serde_json::from_str(&held.args).unwrap_or_else(|_| serde_json::Value::String(held.args.clone())); - // The pane is the person's own conversation with the system agent. - let context = RequestContext { call_id: held.key.clone(), trigger: Trigger::Person, ..RequestContext::default() }; - approval_requested(&held.app, tool, args, Caller::SystemAgent, context) + with(|a| a.bus_requested(held)).unwrap_or_else(|| Route::Refused("approvals are not set up".into())) } pub fn take_bus_decisions() -> Vec<(RequestId, Decision, String)> { diff --git a/crates/shell/src/approvals/tests.rs b/crates/shell/src/approvals/tests.rs index 6b41ace1..329ae884 100644 --- a/crates/shell/src/approvals/tests.rs +++ b/crates/shell/src/approvals/tests.rs @@ -993,32 +993,31 @@ fn only_settings_and_the_sheet_make_a_person_gesture() { // ---------------------------------------------------------------- the relay seam +/// On approvals of its own, never the shell's (`super::with`): those are +/// the process's, and the tests that run app agents keep their consent +/// and relay there while this runs. #[test] fn the_relay_gets_decisions_made_before_it_was_installed() { - super::init_memory(); - let route = super::approval_requested("os.mail", ToolSpec::host("mail.archive").not_auto_approvable(), json!({"id": 1}), Caller::SystemAgent, ctx("g1", Trigger::Person)); - assert!(matches!(route, Route::Sheet(_))); - let (sheet, id) = super::with(|a| { + let mut a = super::Approvals::memory(); + let front = |a: &super::Approvals| { let s = a.router.front_sheet().unwrap(); (s.id, s.lines[0].request.clone()) - }) - .unwrap(); - super::with(|a| a.router.answer(sheet, &id, Answer::Deny, &ApprovalGesture::sheet_tap(), T0)).unwrap().unwrap(); + }; + let route = a.approval_requested("os.mail", ToolSpec::host("mail.archive").not_auto_approvable(), json!({"id": 1}), Caller::SystemAgent, ctx("g1", Trigger::Person)); + assert!(matches!(route, Route::Sheet(_))); + let (sheet, id) = front(&a); + a.router.answer(sheet, &id, Answer::Deny, &ApprovalGesture::sheet_tap(), T0).unwrap(); let relay = RecordingRelay::default(); - super::set_relay(Box::new(relay.clone())); + a.set_relay(Box::new(relay.clone())); assert_eq!(relay.take(), vec![(RequestId("g1".into()), Decision::Deny, "denied on the sheet".into())]); // From now on, straight to it. - super::approval_requested("os.mail", ToolSpec::host("mail.archive").not_auto_approvable(), json!({"id": 2}), Caller::SystemAgent, ctx("g2", Trigger::Person)); - let (sheet, id) = super::with(|a| { - let s = a.router.front_sheet().unwrap(); - (s.id, s.lines[0].request.clone()) - }) - .unwrap(); - super::with(|a| a.router.answer(sheet, &id, Answer::Once, &ApprovalGesture::sheet_tap(), T0)).unwrap().unwrap(); + a.approval_requested("os.mail", ToolSpec::host("mail.archive").not_auto_approvable(), json!({"id": 2}), Caller::SystemAgent, ctx("g2", Trigger::Person)); + let (sheet, id) = front(&a); + a.router.answer(sheet, &id, Answer::Once, &ApprovalGesture::sheet_tap(), T0).unwrap(); assert_eq!(relay.take().len(), 1); // The AI bus's held `confirm: host` calls come back to the shell, not // to the relay: the Terminal's `run` asks the person, never a rule. - super::with(|a| a.router.create_rule(&ApprovalGesture::settings_tap(), RuleDraft::everything("terminal", 30), T0)).unwrap().unwrap(); + a.router.create_rule(&ApprovalGesture::settings_tap(), RuleDraft::everything("terminal", 30), T0).unwrap(); let held = crate::ai_bus::HeldCall { key: "bus:w4:c1".into(), app: "terminal".into(), @@ -1027,19 +1026,18 @@ fn the_relay_gets_decisions_made_before_it_was_installed() { auto_approvable: false, command: true, }; - assert!(matches!(super::bus_requested(&held), Route::Sheet(_))); - let (sheet, id, caller, args) = super::with(|a| { + assert!(matches!(a.bus_requested(&held), Route::Sheet(_))); + let (sheet, id, caller, args) = { let s = a.router.front_sheet().unwrap(); (s.id, s.lines[0].request.clone(), s.lines[0].caller.clone(), s.lines[0].args.join(" ")) - }) - .unwrap(); + }; assert_eq!(id, RequestId("bus:w4:c1".into())); assert_eq!(caller, "The system agent"); assert_eq!(args, "command (1 line): 1 \u{2502} ls", "a command, one numbered row per line"); - super::with(|a| a.router.answer(sheet, &id, Answer::Once, &ApprovalGesture::sheet_tap(), T0)).unwrap().unwrap(); + a.router.answer(sheet, &id, Answer::Once, &ApprovalGesture::sheet_tap(), T0).unwrap(); assert!(relay.take().is_empty()); - assert_eq!(super::take_bus_decisions(), vec![(id, Decision::ApproveOnce, "approved on the sheet".into())]); - assert!(!super::consent_granted("os.news"), "developer mode is off in tests: nothing is granted"); + assert_eq!(a.bus.take(), vec![(id, Decision::ApproveOnce, "approved on the sheet".into())]); + assert!(!a.consent_granted("os.news"), "developer mode is off in tests: nothing is granted"); } /// The module host's gate: the first ask shows the first-use sheet and From 102b90b33b0ab69c8f22a0ff0de1362642ac7c30 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:55:35 -0700 Subject: [PATCH 4/6] fix(shell): the jail test asks the sandbox's verdict on the hub, not the whole connect's `a_jail_only_app_reads_its_jail_and_is_refused_everything_else` failed on GitHub's macOS 14 runners with "the hub is reachable: " and nothing after it: the sandboxed `nc -z` exited 1 without a word, because nc prints why a connect failed only with `-v`. Its exit status says only that the connect did not complete. A sandbox refusal and a refusal past the sandbox (no listener on the port then, the network stack) look the same, though only the first is what this test is about. Closing the hub's listener before the probe reproduces the CI message exactly. The probe now runs `nc -v -n` and the test reads what nc says. The hub fails only on the sandbox's own refusal (EPERM from Seatbelt and seccomp, EACCES from Landlock), or when nc never reported a connect at all. A refusal past the sandbox is printed and passes. Any other port must now be refused by the sandbox itself, not just fail, which the old check could not tell apart. Co-Authored-By: Claude Opus 5.5 --- crates/shell/src/sandbox/tests.rs | 48 +++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/crates/shell/src/sandbox/tests.rs b/crates/shell/src/sandbox/tests.rs index 1abc3103..d8269de5 100644 --- a/crates/shell/src/sandbox/tests.rs +++ b/crates/shell/src/sandbox/tests.rs @@ -66,6 +66,33 @@ fn run(program: &str, args: &[&str], policy: &Policy) -> (bool, String) { (out.status.success(), format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr))) } +/// What `nc -v -z` said became of its connect: `Ok` when it connected, the +/// reason it printed when the connect failed, `None` when it said neither +/// (it never ran its connect: `sandbox-exec` refused to start it, say). +/// Apple's nc writes `nc: connectx to … failed: `, OpenBSD's +/// `nc: connect to … failed: `. +fn connect_outcome(out: &str) -> Option> { + for line in out.lines() { + if line.starts_with("Connection to ") && line.ends_with("succeeded!") { + return Some(Ok(())); + } + if line.starts_with("nc: connect") { + if let Some((_, why)) = line.split_once(" failed: ") { + return Some(Err(why.trim())); + } + } + } + None +} + +/// A connect the sandbox itself refused: EPERM from Seatbelt and seccomp, +/// EACCES from Landlock. Every other error is decided past the sandbox's +/// check (an absent listener, the network stack), so it means the sandbox +/// let the connect out. +fn refused_by_sandbox(why: &str) -> bool { + why == "Operation not permitted" || why == "Permission denied" +} + fn sandbox_works_here() -> bool { if cfg!(target_os = "macos") { return Path::new(macos::SANDBOX_EXEC).is_file(); @@ -110,12 +137,23 @@ fn a_jail_only_app_reads_its_jail_and_is_refused_everything_else() { let (ok, out) = run("/bin/sh", &["-c", &format!("echo x > {}/written", root.display())], &Policy { processes: true, ..policy.clone() }); assert!(!ok, "it writes nothing outside: {out}"); - // Network: the hub on loopback, nothing else. - let nc = |port: u16| run("/usr/bin/nc", &["-z", "-w", "2", "127.0.0.1", &port.to_string()], &policy); - let (ok, out) = nc(hub_port); - assert!(ok, "the hub is reachable: {out}"); + // Network: the hub on loopback, nothing else. What is asked is the + // sandbox's verdict on each connect, so nc says what became of it + // (`-v`) instead of only exiting 1, and only the sandbox's own refusal + // counts as one: a connect the sandbox lets out may still be refused by + // the network stack, which is not this test's business. + let nc = |port: u16| run("/usr/bin/nc", &["-v", "-n", "-z", "-w", "2", "127.0.0.1", &port.to_string()], &policy); + let (_, out) = nc(hub_port); + match connect_outcome(&out) { + Some(Ok(())) => {} + Some(Err(why)) => { + assert!(!refused_by_sandbox(why), "the sandbox lets the hub through: {out}"); + eprintln!("the hub's connect left the sandbox and failed past it: {why}"); + } + None => panic!("nc said nothing about the hub's connect: {out}"), + } let (ok, out) = nc(other_port); - assert!(!ok, "any other connection is refused: {out}"); + assert!(!ok && matches!(connect_outcome(&out), Some(Err(why)) if refused_by_sandbox(why)), "any other connection is refused by the sandbox: {out}"); // Processes: none. let (ok, out) = run("/bin/sh", &["-c", "/bin/echo first; /bin/echo spawned"], &policy); From db0e79205c126390aa1e952fd689657aa1081a1a Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:08:03 -0700 Subject: [PATCH 5/6] octos: pin 056173e8 (the merge of octos#2675) octos#2675 makes session/hydrate rows name their tool calls: a tool row carries tool_call_id and tool_name, an assistant row its tool_calls. The shell's system chat and "Ask " panel already read tool_call_id and name/tool_name, and the broker's merged history passes rows through, so reloaded tool rows now show their tool's name instead of "tool". The two-lane scenario checks it against the real kernel: News's agent's news_share call in the panel's reloaded history, and the system chat's peer_send_input. The pin also brings #2261, #2664, #2665, #2669, #2671 and #2672. octos-core, octos-cli, octos-research and the kernel artifact move together; Cargo.lock changes only the 17 octos source lines. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 34 +++++++++---------- Cargo.toml | 13 ++++--- crates/shell/src/host_tools/scenario_tests.rs | 17 ++++++++++ crates/toolbox/README.md | 4 +-- .../adr/0004-native-apps-hosting-and-peers.md | 4 +-- tools/test_kernel_artifact.py | 2 +- 6 files changed, 48 insertions(+), 26 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a5519168..b06c0a74 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6852,7 +6852,7 @@ dependencies = [ [[package]] name = "octos-agent" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "async-trait", "base64 0.22.1", @@ -6982,7 +6982,7 @@ dependencies = [ [[package]] name = "octos-bus" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "aes 0.8.4", "async-trait", @@ -7011,7 +7011,7 @@ dependencies = [ [[package]] name = "octos-cli" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "agent-client-protocol", "argon2", @@ -7092,7 +7092,7 @@ dependencies = [ [[package]] name = "octos-core" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "chrono", "eyre", @@ -7106,7 +7106,7 @@ dependencies = [ [[package]] name = "octos-diagnostics" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "eyre", "octos-core", @@ -7118,7 +7118,7 @@ dependencies = [ [[package]] name = "octos-fleet" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "eyre", "octos-core", @@ -7134,7 +7134,7 @@ dependencies = [ [[package]] name = "octos-fleet-worker" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "async-trait", "eyre", @@ -7151,7 +7151,7 @@ dependencies = [ [[package]] name = "octos-llm" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "async-trait", "base64 0.22.1", @@ -7175,7 +7175,7 @@ dependencies = [ [[package]] name = "octos-memory" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "bincode", "chrono", @@ -7194,7 +7194,7 @@ dependencies = [ [[package]] name = "octos-pipeline" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "async-trait", "chrono", @@ -7216,7 +7216,7 @@ dependencies = [ [[package]] name = "octos-plugin" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "eyre", "metrics", @@ -7230,7 +7230,7 @@ dependencies = [ [[package]] name = "octos-research" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "base64 0.22.1", "chromiumoxide", @@ -7254,7 +7254,7 @@ dependencies = [ [[package]] name = "octos-sandbox" version = "0.1.0" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "clap", "eyre", @@ -7269,7 +7269,7 @@ dependencies = [ [[package]] name = "octos-services" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "chrono", "dirs", @@ -7291,7 +7291,7 @@ dependencies = [ [[package]] name = "octos-store" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "base64 0.22.1", "chrono", @@ -7311,7 +7311,7 @@ dependencies = [ [[package]] name = "octos-swarm" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "async-trait", "chrono", @@ -7330,7 +7330,7 @@ dependencies = [ [[package]] name = "octos-workflows" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "chrono", "eyre", diff --git a/Cargo.toml b/Cargo.toml index 8653183d..5108d045 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -110,7 +110,12 @@ octosense-app-hub = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub" # and App Hub and Rinx no longer need the same App Hub commit. octosense-app-contract = "1" # --- octos: ONE source for every octos crate (octos-org/octos main at the -# merge of octos#2656, low fixes for host-owned app peers; after #2655, the +# merge of octos#2675, hydrate rows that name a tool row's call and tool; +# after #2672, the accepted client_commands echoed on session/open, #2671 +# and #2669, docs, #2665, undeclared secrets kept from strict skills, #2664, +# octos's own dev profile, and #2261, a credential store that keeps +# credentials on a failed save; before that #2656, low fixes for host-owned +# app peers; #2655, the # unknown-outcome interlock kept across a kernel restart, #2653, a bound # workspace swapped for a symlink no longer re-roots the session, #2648, a # host session's exact tool list; #2649, @@ -120,12 +125,12 @@ octosense-app-contract = "1" # keeps its peer/input, and #2645, Bing News's Chinese results). # Move octos-core, octos-cli and octos-research together; the Android kernel # artifact (liboctos.so) is built from this same revision. -octos-core = { git = "https://github.com/octos-org/octos.git", rev = "ae230ce04d57f3c29cf6c2518e5956a86c07d788" } -octos-cli = { git = "https://github.com/octos-org/octos.git", rev = "ae230ce04d57f3c29cf6c2518e5956a86c07d788", default-features = false } +octos-core = { git = "https://github.com/octos-org/octos.git", rev = "056173e85b150e387805fc307fe231064ac1ed35" } +octos-cli = { git = "https://github.com/octos-org/octos.git", rev = "056173e85b150e387805fc307fe231064ac1ed35", default-features = false } # The research engine (the metasearch, the SSRF-safe polite reader and the # toolbox scope) for the toolbox's `octos-engine` feature. Same source and # revision as above. -octos-research = { git = "https://github.com/octos-org/octos.git", rev = "ae230ce04d57f3c29cf6c2518e5956a86c07d788", default-features = false } +octos-research = { git = "https://github.com/octos-org/octos.git", rev = "056173e85b150e387805fc307fe231064ac1ed35", default-features = false } # --- AppCard's inline renderers. streaming-markdown-kit = { git = "https://github.com/Project-Robius-China/streaming-markdown-kit.git", branch = "main" } makepad-diagram-kit = { git = "https://github.com/Project-Robius-China/makepad-diagram-kit.git", branch = "main" } diff --git a/crates/shell/src/host_tools/scenario_tests.rs b/crates/shell/src/host_tools/scenario_tests.rs index 9e4e1125..083d6216 100644 --- a/crates/shell/src/host_tools/scenario_tests.rs +++ b/crates/shell/src/host_tools/scenario_tests.rs @@ -688,6 +688,23 @@ fn the_system_agent_and_the_person_work_with_one_app_agent_at_once() { assert_eq!((rows[audience]["lane"].clone(), rows[publish]["lane"].clone()), (json!("person"), json!("system_agent"))); assert!(task < ask && ask < audience && audience < publish, "merged by time: task {task}, ask {ask}, audience {audience}, publish {publish}"); assert!(rows.iter().all(|m| !m["content"].as_str().unwrap_or("").contains("" panel); **Implemented** (2026-09-29): every plan step and every gap from the 2026-09-28 review is on `main`, and the full CI (Desktop, Phone, Apps and services, ROM) passed on `74e1d713`. The follow-ups below remain open work, not part of this decision's implementation. - **Scope:** How OctoSense declares, links, hosts, isolates and trusts its bundled native Rust apps on each target; where every app keeps its data and what its agent may read; how every bundled app, native or script, owns an octos app agent; how the system agent and app agents work across apps; and how the person approves what they do, live or in advance. -- **Relates to:** [ADR 0001](0001-one-octosense-repository.md); [ADR 0002](0002-event-driven-app-agents.md) (app agents; amended by this ADR); [Home ADR 0002](home/0002-agentic-app-security-model.md) and [Home ADR 0004](home/0004-system-apps-are-contained-script-apps.md) (amended); Rinx [ADR 0007](https://github.com/hagency-org/Rinx/blob/main/docs/adr/0007-host-owned-octos-app-peers.md); octos UPCR-2026-034 (host-owned app peers, merged), UPCR-2026-035 ([octos#2567](https://github.com/octos-org/octos/pull/2567), host tools per app peer, merged; OctoSense pins its follow-ups, now octos ae230ce0 (4a3ec9f9 plus the security fixes #2653, #2655 and #2656) with octos#2621, #2626, #2636, #2644, #2652, #2654, #2657, #2658 and the UPCR additions #2647 (`read_parent`), #2649 (`peer/purge`) and #2648 (`session/tool_list/set`)) and UPCR-2026-036 (`serve --host-managed`, [octos#2591](https://github.com/octos-org/octos/pull/2591), merged); [octos#2601](https://github.com/octos-org/octos/pull/2601) (tool origin, per-connection ownership); [ADR 0003](0003-shared-octos-client-access.md) (Talk to Octos, OctoSense [#98](https://github.com/OctoSense-org/OctoSense/pull/98): external clients never reach app agents). +- **Relates to:** [ADR 0001](0001-one-octosense-repository.md); [ADR 0002](0002-event-driven-app-agents.md) (app agents; amended by this ADR); [Home ADR 0002](home/0002-agentic-app-security-model.md) and [Home ADR 0004](home/0004-system-apps-are-contained-script-apps.md) (amended); Rinx [ADR 0007](https://github.com/hagency-org/Rinx/blob/main/docs/adr/0007-host-owned-octos-app-peers.md); octos UPCR-2026-034 (host-owned app peers, merged), UPCR-2026-035 ([octos#2567](https://github.com/octos-org/octos/pull/2567), host tools per app peer, merged; OctoSense pins its follow-ups, now octos 056173e8 (4a3ec9f9 plus the security fixes #2653, #2655 and #2656, and #2675, hydrate rows that name their tool calls) with octos#2621, #2626, #2636, #2644, #2652, #2654, #2657, #2658 and the UPCR additions #2647 (`read_parent`), #2649 (`peer/purge`) and #2648 (`session/tool_list/set`)) and UPCR-2026-036 (`serve --host-managed`, [octos#2591](https://github.com/octos-org/octos/pull/2591), merged); [octos#2601](https://github.com/octos-org/octos/pull/2601) (tool origin, per-connection ownership); [ADR 0003](0003-shared-octos-client-access.md) (Talk to Octos, OctoSense [#98](https://github.com/OctoSense-org/OctoSense/pull/98): external clients never reach app agents). ## Context @@ -43,7 +43,7 @@ ### What octos provides (octos `main` e6223ef, verified) -Verified at e6223ef when this ADR was written. OctoSense now pins octos ae230ce0 (the merge of octos#2656, low fixes for host-owned app peers; after #2655, the unknown-outcome interlock kept across a kernel restart, and #2653, a bound workspace that no longer resolves to its recorded path is refused rather than re-rooted; before that 4a3ec9f9, the merge of octos#2648, a host session's durable exact tool list, `session/tool_list/set`; after #2649, `peer/purge`, #2647, `peer/context/open` `read_parent`, #2658, `peer/tools/unregister`, #2657, a host session's tools registered by the host's own connection without an app peer's token, #2654, host tool claims bounded per peer, #2652, a refused host `turn/start` no longer consumes its `peer/input`, #2651, the model cannot `peer_close` a host-owned app peer, and #2650; before that fe08d8e6, the merge of octos#2645, which holds #2644, a running turn shown in the other lane's shared history, and #2636, the parallel person context with shared history; after #2626, the shared peer conversation, #2630, `peer_gather` accepting the kernel's own `result.md`, #2621, `peer/input/reject`, and #2616, the #2567 follow-ups); the corrections dated 2026-09-29 below were checked against c608384d, the pin before it. +Verified at e6223ef when this ADR was written. OctoSense now pins octos 056173e8 (the merge of octos#2675, `session/hydrate` rows that carry a tool row's `tool_call_id` and `tool_name` and an assistant row's `tool_calls`, so a reloaded transcript names its tool rows; after #2672, #2671, #2669, #2665, #2664 and #2261; before that ae230ce0, the merge of octos#2656, low fixes for host-owned app peers; after #2655, the unknown-outcome interlock kept across a kernel restart, and #2653, a bound workspace that no longer resolves to its recorded path is refused rather than re-rooted; before that 4a3ec9f9, the merge of octos#2648, a host session's durable exact tool list, `session/tool_list/set`; after #2649, `peer/purge`, #2647, `peer/context/open` `read_parent`, #2658, `peer/tools/unregister`, #2657, a host session's tools registered by the host's own connection without an app peer's token, #2654, host tool claims bounded per peer, #2652, a refused host `turn/start` no longer consumes its `peer/input`, #2651, the model cannot `peer_close` a host-owned app peer, and #2650; before that fe08d8e6, the merge of octos#2645, which holds #2644, a running turn shown in the other lane's shared history, and #2636, the parallel person context with shared history; after #2626, the shared peer conversation, #2630, `peer_gather` accepting the kernel's own `result.md`, #2621, `peer/input/reject`, and #2616, the #2567 follow-ups); the corrections dated 2026-09-29 below were checked against c608384d, the pin before it. - **Peers are sessions, not processes.** One kernel process per shell; every peer is a session in it (tokio tasks) with its own workspace, memory namespace, transcript and model lane. Tools such as the shell run as short child processes in the peer's workspace. - **No 8-peer ceiling.** `peer/prepare` stages 1–8 peers **per call** (`n`); the only total is a soft cap of 8192 live peer registrations. The model's `peer_handoff` tool is limited to 4 per turn; host-owned app peers are not created through it. OctoSense's broker prepares one peer per call. diff --git a/tools/test_kernel_artifact.py b/tools/test_kernel_artifact.py index 2dd7716b..84da0492 100644 --- a/tools/test_kernel_artifact.py +++ b/tools/test_kernel_artifact.py @@ -14,7 +14,7 @@ kernel = importlib.util.module_from_spec(spec) spec.loader.exec_module(kernel) -REV = "ae230ce04d57f3c29cf6c2518e5956a86c07d788" +REV = "056173e85b150e387805fc307fe231064ac1ed35" def lock_with(*revs): From eb807ecb21f37b83415f0461e37ddb5cb52a93db Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:50:52 -0700 Subject: [PATCH 6/6] fix(shell): a hover on the window in front never reaches a card window behind it A module tile claimed presses but never a hover. A mouse move over a part of a card window that takes no hover (Mail's empty page) went on to the tile behind unclaimed, and the app there took it: a News button under Mail lit up and showed its hand cursor. A tile behind also handed its app every move, claimed or not, as it handed presses before #277. The live tile now treats a hover (a mouse move with no button held) the way #277 treats a press. A hover inside it that none of its app's widgets took is the tile's, and one a window in front already took is let by, so its app never sees it. The one exception is the move that leaves a widget of the app still holding the hover: it reaches the root, so that widget sees its hover end, and being claimed it lets no other hover start. The closed face of a stopped app claims a hover inside it too. Moves with a button held (drags) pass as before, so captures keep working. Tests, in module_input_tests.rs: a hover on the window in front never reaches the app behind; makepad's Button behind it never lights up or shows its hand; a hover on a stopped face never reaches the app behind it. Co-Authored-By: Claude Opus 5.5 --- crates/shell/src/module_input_tests.rs | 123 +++++++++++++++++++++++-- crates/shell/src/module_view.rs | 72 ++++++++++++++- 2 files changed, 186 insertions(+), 9 deletions(-) diff --git a/crates/shell/src/module_input_tests.rs b/crates/shell/src/module_input_tests.rs index f7b5b752..e3fec77a 100644 --- a/crates/shell/src/module_input_tests.rs +++ b/crates/shell/src/module_input_tests.rs @@ -6,6 +6,12 @@ //! which a GestureView ignores. News's story rows are GestureViews, so a //! click on Mail's inbox footer, over News's third story, opened that story //! (the instrument's B3). +//! +//! A hover goes the same way. The window in front takes a hover inside it +//! even where none of its widgets does, and a tile behind lets a hover a +//! window in front took by. Before, no tile claimed a move, so a hover over +//! Mail's empty part reached News's widgets unclaimed: they lit up and set +//! their cursors under Mail. use crate::module_host::ModuleHost; use crate::module_view::MpModuleView; @@ -27,12 +33,13 @@ script_mod! { } } -/// An app's root that counts the presses reaching it. A tappable one takes -/// them as makepad's GestureView does: a button press through +/// An app's root that counts the presses and hovers reaching it. A tappable +/// one takes them as makepad's GestureView does: a button press through /// `hits_with_capture_overload`, which co-captures a press another area /// already claimed, and a touch straight from the raw stream. A claim alone -/// never keeps a press from it. An inert one takes nothing, like the empty -/// part of a window (Mail's inbox footer). +/// never keeps a press from it. It takes a hover through the same hit test, +/// as a Button or a View does, which claims the move. An inert one takes +/// nothing, like the empty part of a window (Mail's inbox footer). #[derive(Script, ScriptHook, Widget)] pub struct TapProbe { #[uid] @@ -54,6 +61,12 @@ pub struct TapProbe { /// Presses it took and saw released over it. #[rust] taps: usize, + /// Hovers (mouse moves with no button held) that reached the app. + #[rust] + moves: usize, + /// Hovers it took. + #[rust] + hovers: usize, /// The touch it follows. #[rust] touch: Option, @@ -63,6 +76,7 @@ impl Widget for TapProbe { fn handle_event(&mut self, cx: &mut Cx, event: &Event, _scope: &mut Scope) { match event { Event::MouseDown(_) => self.seen += 1, + Event::MouseMove(_) => self.moves += 1, Event::TouchUpdate(update) => { self.seen += update.touches.iter().filter(|t| t.state == TouchState::Start).count(); } @@ -88,10 +102,10 @@ impl Widget for TapProbe { } return; } - if let Hit::FingerUp(e) = event.hits_with_capture_overload(cx, area, true) { - if e.is_over { - self.taps += 1; - } + match event.hits_with_capture_overload(cx, area, true) { + Hit::FingerUp(e) if e.is_over => self.taps += 1, + Hit::FingerHoverIn(_) | Hit::FingerHoverOver(_) => self.hovers += 1, + _ => {} } } @@ -112,6 +126,8 @@ enum Root { Inert, /// Makepad's own GestureView. Gesture, + /// Makepad's own Button. + Button, } struct InputProbe { @@ -142,6 +158,10 @@ impl AppModule for InputProbe { let value = script_eval!(vm, { use mod.widgets.* GestureView {} }); WidgetRef::script_from_value(vm, value) } + Root::Button => { + let value = script_eval!(vm, { use mod.widgets.* Button { text: "Read" } }); + WidgetRef::script_from_value(vm, value) + } Root::Tappable | Root::Inert => { let value = script_eval!(vm, { use mod.widgets.* TapProbe {} }); let root = WidgetRef::script_from_value(vm, value); @@ -169,6 +189,8 @@ static NEWS: InputProbe = InputProbe { id: "news-probe", root: Root::Tappable }; static MAIL: InputProbe = InputProbe { id: "mail-probe", root: Root::Inert }; /// News with makepad's GestureView as its row, as its bundle has it. static GESTURE_NEWS: InputProbe = InputProbe { id: "gesture-probe", root: Root::Gesture }; +/// News with makepad's Button under the pointer, as its tab bar has them. +static BUTTON_NEWS: InputProbe = InputProbe { id: "button-probe", root: Root::Button }; fn setup() -> (Cx, ModuleHost) { let mut cx = Cx::new(Box::new(|_, _| {})); @@ -242,6 +264,18 @@ fn mouse_up(abs: Vec2d) -> Event { Event::MouseUp(MouseUpEvent { abs, button: MouseButton::PRIMARY, window_id: WindowId(0, 0), modifiers: Default::default(), time: 1.1 }) } +/// A mouse move with no button held. +fn hover(abs: Vec2d) -> Event { + Event::MouseMove(MouseMoveEvent { + abs, + lock_delta: Vec2d::default(), + window_id: WindowId(0, 0), + modifiers: Default::default(), + time: 1.2, + handled: Cell::new(Area::Empty), + }) +} + fn touch(abs: Vec2d, state: TouchState) -> Event { Event::TouchUpdate(TouchUpdateEvent { time: 1.0, @@ -296,6 +330,15 @@ fn counts(cx: &mut Cx, host: &mut ModuleHost, client: u64) -> (usize, usize) { .unwrap() } +/// (hovers seen, hovers taken) of a probe app. +fn hover_counts(cx: &mut Cx, host: &mut ModuleHost, client: u64) -> (usize, usize) { + host.dispatch(cx, client, "a test read", |_, root| { + let probe = root.borrow::().unwrap(); + (probe.moves, probe.hovers) + }) + .unwrap() +} + /// News is opened, then Mail, which cascades over it. A press inside both /// lands on Mail where Mail has nothing to take it, over a News row. const NEWS_AT: (f64, f64) = (20.0, 40.0); @@ -368,6 +411,70 @@ fn a_gesture_view_behind_the_window_in_front_never_captures_its_press() { close(cx, host, vec![news, mail], frame, &[1, 2]); } +/// A hover on the front window where it takes none: Mail's tile takes it, +/// and News's app behind never sees it. +#[test] +fn a_hover_on_the_window_in_front_never_reaches_the_app_behind() { + let (mut cx, mut host, news, mail, frame) = news_and_mail(&NEWS); + let over_both = dvec2(OVER_BOTH.0, OVER_BOTH.1); + let moved = [hover(over_both)]; + assert_eq!(offer(&mut cx, &[&mail, &news], &moved), [], "a hover raises nothing"); + assert_eq!(hover_counts(&mut cx, &mut host, 2), (1, 0), "Mail's app saw it; nothing there took it"); + assert_eq!(hover_counts(&mut cx, &mut host, 1), (0, 0), "News's app never saw it, so its row did not light up"); + // So Mail's tile took it: any window further back sees it claimed. + let Event::MouseMove(e) = &moved[0] else { unreachable!() }; + assert!(!e.handled.get().is_empty(), "the hover left Mail unclaimed"); + // Where News is in front of nothing, its row takes the hover... + offer(&mut cx, &[&mail, &news], &[hover(dvec2(NEWS_ONLY.0, NEWS_ONLY.1))]); + assert_eq!(hover_counts(&mut cx, &mut host, 1), (1, 1)); + // ...and the move that takes the pointer on over Mail still reaches News, + // so the row sees its hover end. Mail took that move, so nothing lights + // up, and no move after it reaches News. + offer(&mut cx, &[&mail, &news], &[hover(over_both), hover(over_both + dvec2(10.0, 0.0))]); + assert_eq!(hover_counts(&mut cx, &mut host, 1), (2, 1), "one move to end the hover, none after it"); + // Mail's app sees every move: those over it, and the one outside it. + assert_eq!(hover_counts(&mut cx, &mut host, 2), (4, 0)); + close(cx, host, vec![news, mail], frame, &[1, 2]); +} + +/// With makepad's own Button as the app behind: a hover it takes lights it +/// up (`hover.on`) and asks for the hand cursor. A hover on Mail where Mail +/// takes none does neither. +#[test] +fn a_button_behind_the_window_in_front_never_lights_up_under_it() { + let (mut cx, mut host, news, mail, frame) = news_and_mail(&BUTTON_NEWS); + let lit = |cx: &mut Cx, host: &mut ModuleHost| { + host.dispatch(cx, 1, "a test read", |cx, root| root.borrow::