diff --git a/crates/shell/src/host_tools/real_kernel_tests.rs b/crates/shell/src/host_tools/real_kernel_tests.rs index e79ebdb7..b2e3eb43 100644 --- a/crates/shell/src/host_tools/real_kernel_tests.rs +++ b/crates/shell/src/host_tools/real_kernel_tests.rs @@ -311,6 +311,190 @@ fn real_kernel_an_allowed_apps_agent_is_prepared_listed_and_shares_its_conversat drop(model); } +/// The shell's side of a process app's peer link, for the test: News's +/// agent granted and allowed, its service the test's broker. +struct ProcessHost(Broker); + +impl crate::peer_link::PeerHost for ProcessHost { + fn granted(&self, _app: &str) -> std::collections::BTreeSet { + OCTOS_SERVICES.iter().map(|s| s.to_string()).collect() + } + fn keeps_accounts(&self, _app: &str) -> bool { + false + } + fn consent(&mut self, _app: &str) -> bool { + true + } + fn service(&mut self, _app: &str, _services: &std::collections::BTreeSet) -> Option> { + Some(Arc::new(self.0.clone())) + } + fn tool_rule(&self, _app: &str, _tool: &str) -> Option<(crate::native_apps::Confirm, bool)> { + None + } + fn request_approval(&mut self, _app: &str, _tool: crate::approvals::ToolSpec, _args: Value, _caller: crate::approvals::Caller, _context: crate::approvals::RequestContext) -> crate::approvals::Route { + crate::approvals::Route::Refused("no approvals in this test".into()) + } + fn take_decisions(&mut self) -> Vec<(crate::approvals::RequestId, crate::approvals::Decision, String)> { + Vec::new() + } + fn app_confirm_answered(&mut self, _id: &crate::approvals::RequestId, _approved: bool, _reason: &str) {} + fn link_opened(&mut self, _app: &str) {} + fn link_closed(&mut self, _app: &str) {} +} + +/// ADR 0004 §6 on a REAL kernel, for a PROCESS app: the app opens its +/// conversation over the peer link (`octos.session.open` without a +/// `client`), and when the system agent sends the app's agent a message +/// (`peer_send_input`), that turn (the system agent's lane) reaches the +/// app's process live as `conversation` frames, each with its lane and +/// speaker: `turn/started` with the request, streamed text, and the end, +/// without the app asking for history. The frames are printed +/// (`[peer-link frame]`) as the fixtures of Makepad's client. +/// +/// Like the other tests here it needs `OCTOS_SHELL_TEST_KERNEL` (else it +/// says so and passes). CI runs it: `phone.yml` builds the pinned octos +/// with `tools/kernel-artifact.py --host` (cached per revision) and sets +/// `OCTOS_SHELL_TEST_KERNEL` for the shell's tests (the `home` job). Locally: +/// `OCTOS_SHELL_TEST_KERNEL= cargo test --locked --features +/// mobile-apps -p octosense-shell real_kernel_a_process -- --nocapture` +/// from `phone/`. Its kernel, model, broker, link and temporary files are +/// released by a drop guard, whether it passes or fails. +#[test] +fn real_kernel_a_process_app_hears_the_system_agents_lane_live() { + let Some(program) = kernel() else { return }; + let _factory = crate::agents::FACTORY_TESTS.lock().unwrap_or_else(|e| e.into_inner()); + let dir = std::env::temp_dir().join(format!("octosense-shell-peer-link-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let offered_log = dir.join("offered.jsonl"); + let model = start_model(&offered_log); + let core_dir = dir.join("octos-home/.octos"); + write_profile(&core_dir, model.1); + let core = Core::new(Options::default().core_dir(&core_dir).program(&program)); + // Everything below is released on the way out, pass or fail. + let mut guard = Cleanup { core: core.clone(), model: Some(model), dirs: vec![dir.clone()], broker: None, links: None }; + let home = dir.join("home"); + std::fs::create_dir_all(&home).unwrap(); + if crate::approvals::with(|_| ()).is_none() { + crate::approvals::init(&home); + } + // The person allowed News's agent (else its peer refuses `peer/input`). + crate::approvals::with(|a| a.consent.set(&crate::approvals::rules::ApprovalGesture::sheet_tap(), "os.news", true, crate::approvals::now())); + // News's tools (so its peer has a host route for `peer/input`). + let host_dir = dir.join("apps/.host"); + std::fs::create_dir_all(&host_dir).unwrap(); + octosense_news_service::register_with(octosense_news_service::Options::default().host_dir(&host_dir).timer(false)); + let bundle = super::script_apps::tests::stamped_bundle("news", "peer-link", |_, _| {}); + guard.dirs.push(bundle.clone()); + super::script_apps::install("os.news", super::script_apps::from_bundle(&bundle).unwrap(), host_dir.clone()); + let services = OCTOS_SERVICES.iter().map(|s| s.to_string()).collect(); + let mut cfg = BrokerConfig::new(Deployment::Hosted, "_main", "_main:api:octosense#system", "card.os.news", "News", services); + cfg.state_dir = Some(dir.join("host-state")); + cfg.tool_host = Some(ToolHostHandle(Arc::new(super::ShellToolHost) as Arc)); + let broker = Broker::new(cfg, Arc::new(CoreConnector::shared(core.clone()))); + guard.broker = Some(broker.clone()); + + // The app's process connects its hub socket; its frames are recorded. + let frames: Arc>> = Arc::default(); + let f = frames.clone(); + let out: crate::peer_link::FrameOut = Arc::new(move |json: String| f.lock().unwrap().push(json)); + let links = guard.links.insert(crate::peer_link::PeerLinks::new(Box::new(ProcessHost(broker.clone())), Box::new(crate::peer_link::RecordingToolRelay::default()))); + assert!(links.connected(7, "os.news", out)); + let downs = |frames: &Arc>>| frames.lock().unwrap().iter().filter_map(|f| crate::peer_link::wire::Down::parse(f)).collect::>(); + // Its conversation, as Makepad's client opens it (no `client`). + assert!(links.on_frame(7, "os.news", r#"{"octos_peer":{"up":"request","req_id":1,"method":"octos.session.open","args":{"client":null}}}"#, None)); + let deadline = Instant::now() + Duration::from_secs(90); + let context = loop { + super::pump(); + if let Some(reply) = downs(&frames).into_iter().find_map(|d| match d { + crate::peer_link::wire::Down::Reply { req_id: 1, result } => Some(result), + _ => None, + }) { + break reply.expect("the conversation opened")["context"].as_str().unwrap().to_string(); + } + assert!(Instant::now() < deadline, "the conversation did not open: {:?}", frames.lock().unwrap()); + std::thread::sleep(Duration::from_millis(50)); + }; + let (slug, _) = broker.peer().expect("the peer is bound"); + + // The system agent sends News's agent a message. + let mut chat = crate::system_chat::session::Driver::new(Box::new(SystemLink(core.clone()))); + chat.command(crate::system_chat::session::Command::Open); + let deadline = Instant::now() + Duration::from_secs(60); + while chat.model.phase() != &crate::system_chat::model::Phase::Ready { + chat.step(Duration::from_millis(50)); + assert!(Instant::now() < deadline, "the system session did not open: {:?}", chat.model.phase()); + } + chat.command(crate::system_chat::session::Command::Send(format!("TELL_PEER_SHOW:{slug}"))); + let deadline = Instant::now() + Duration::from_secs(120); + let lane = loop { + chat.step(Duration::from_millis(50)); + super::pump(); + let lane: Vec = downs(&frames) + .into_iter() + .filter_map(|d| match d { + crate::peer_link::wire::Down::Conversation { context: c, event } if c == context && event["lane"] == "system_agent" => Some(event), + _ => None, + }) + .collect(); + // The kernel's v2 envelopes: the turn's end (`turn_terminal`) and + // its answer (`assistant_delta`), in either order. + let envelope = |kind: &str| lane.iter().any(|e| e["params"]["payload"]["type"] == kind); + if envelope("turn_terminal") && envelope("assistant_delta") { + break lane; + } + assert!(Instant::now() < deadline, "the process never heard the system agent's lane: {:?}", frames.lock().unwrap()); + }; + for frame in frames.lock().unwrap().iter() { + eprintln!("[peer-link frame] {frame}"); + } + drop(chat); + drop(guard); + + // Live: the turn's start (with its request and speaker) came before its + // end, the system agent's words and the answer streamed, each event with + // its speaker; no history was asked for. + let started = lane.iter().position(|e| e["method"] == "turn/started").expect("turn/started reached the app"); + let ended = lane.iter().position(|e| e["params"]["payload"]["type"] == "turn_terminal").unwrap(); + assert!(started < ended, "{lane:?}"); + assert_eq!(lane[started]["speaker"]["kind"], "system_agent", "{}", lane[started]); + assert_eq!(lane[started]["request"]["text"], "SHOW_SHARED", "the system agent's words: {}", lane[started]); + assert!(lane.iter().any(|e| e["params"]["payload"]["type"] == "user_message" && e["display_text"] == "SHOW_SHARED"), "{lane:?}"); + assert!( + lane.iter().any(|e| e["params"]["payload"]["type"] == "assistant_delta" && e["params"]["payload"]["data"]["text"].as_str().is_some_and(|t| t.starts_with("SHARED"))), + "the answer streamed to the app: {lane:?}" + ); + assert!(lane.iter().filter(|e| e["method"] != "session/orchestration" && e["method"] != "context/normalization_reported").all(|e| e["speaker"]["kind"] == "system_agent"), "{lane:?}"); + assert!(!frames.lock().unwrap().iter().any(|f| f.contains("octos.session.history")), "no history request"); +} + +/// Releases a real-kernel test's resources when it ends, pass or fail: the +/// process's link (its contexts close), the broker, the kernel, the +/// scripted model and the temporary directories. +struct Cleanup { + core: Core, + model: Option, + dirs: Vec, + broker: Option, + links: Option, +} + +impl Drop for Cleanup { + fn drop(&mut self) { + if let Some(links) = self.links.as_mut() { + links.process_gone(7); + } + if let Some(broker) = self.broker.take() { + broker.release(); + } + self.core.shutdown_within(Duration::from_secs(5)); + self.model.take(); + for dir in &self.dirs { + let _ = std::fs::remove_dir_all(dir); + } + } +} + struct SystemLink(Core); impl crate::system_chat::session::Connector for SystemLink { fn connect(&mut self) -> Result, crate::system_chat::session::Unavailable> { diff --git a/crates/shell/src/peer_link/tests.rs b/crates/shell/src/peer_link/tests.rs index cc03bc29..040221f7 100644 --- a/crates/shell/src/peer_link/tests.rs +++ b/crates/shell/src/peer_link/tests.rs @@ -245,6 +245,11 @@ fn the_shell_reads_the_makepad_clients_frames() { assert_eq!(Up::parse(r#"{"octos_peer":{"up":"request","req_id":1,"method":"octos.turn.start"},"from":"rinx"}"#), None); let big = request(1, "octos.turn.start", json!({"text": "a".repeat(wire::MAX_FRAME_BYTES)})); assert_eq!(Up::parse(&big), None); + // A conversation frame as Makepad's client writes it (its own key + // order) reads back here; the client reads the shell's (its fixtures + // are frames recorded from this link on a real kernel). + let makepad_conversation = r#"{"octos_peer":{"down":"conversation","context":"pl7-1","event":{"method":"turn/started"}}}"#; + assert_eq!(Down::parse(makepad_conversation), Some(Down::Conversation { context: "pl7-1".into(), event: json!({"method": "turn/started"}) })); let call = Down::ToolCall(wire::ToolCallDown { call_id: "k2".into(), name: "send".into(), diff --git a/crates/shell/src/peer_link/wire.rs b/crates/shell/src/peer_link/wire.rs index f2dc0250..6f545f78 100644 --- a/crates/shell/src/peer_link/wire.rs +++ b/crates/shell/src/peer_link/wire.rs @@ -83,7 +83,9 @@ pub enum Down { /// `person` or `system_agent`), for a context opened without a /// `client` (the app's conversation): every turn, whoever speaks /// (`event.speaker`), after the request that opened it answered. - /// A client that does not know the frame ignores it. + /// Makepad's client (`makepad_ai_services::peer`) surfaces it as + /// `PeerEvent::Conversation`; a client that does not know the frame + /// ignores it. Conversation { context: String, event: Value }, } diff --git a/docs/adr/0004-native-apps-hosting-and-peers.md b/docs/adr/0004-native-apps-hosting-and-peers.md index 27b390f8..7ec6e8ea 100644 --- a/docs/adr/0004-native-apps-hosting-and-peers.md +++ b/docs/adr/0004-native-apps-hosting-and-peers.md @@ -366,7 +366,7 @@ How it is turned on and kept from leaking into normal use: - [x] **Process launch review (2026-09-30)**: done in this change. Process apps are built from the workspace with `--locked` and started directly (no `cargo run` in the app's directory; section 2); the build's paths are read-only in every app's sandbox, macOS profiles and client logs moved into the OctoSense home, login items unwritable (section 3); the desktop catalog rows are checked against `native-apps.json`; a slot whose child was reaped never signals that process group again; the third close within 5 s ends an app that keeps refusing (section 2); `terminal.run` needs a launch that reported its sandbox (sections 10, 12). Still open from it: the Linux Landlock rules do not apply `read_only` yet; AppCard opened with `MAKEPAD_WM_TEST_APP=appcard` in a hidden shell leaves the shell with no window, on `main` too (not caused by this change; not investigated). -*Still open after the implementation (2026-09-29):* the Windows AppContainer sandbox ([#137](https://github.com/OctoSense-org/OctoSense/issues/137)); the Linux sandbox has run only in CI; process apps see the system agent's lane only through history until Makepad's peer client reads `conversation` frames; a second message on one conversation handle is refused rather than queued; Rinx still has to send turn triggers and hand over its send sheet; the OpenHarmony build with octos#2619's BoringSSL (`btls-sys`) is untested. +*Still open after the implementation (2026-09-29):* the Windows AppContainer sandbox ([#137](https://github.com/OctoSense-org/OctoSense/issues/137)); the Linux sandbox has run only in CI; ~~process apps see the system agent's lane only through history until Makepad's peer client reads `conversation` frames; a second message on one conversation handle is refused rather than queued~~ (done in Makepad's client, [makepad#77](https://github.com/OctoSense-org/makepad/pull/77), in the runtime since the makepad e29a0eaa pin; see the gaps below); Rinx still has to send turn triggers and hand over its send sheet; the OpenHarmony build with octos#2619's BoringSSL (`btls-sys`) is untested. Gaps between this decision and `main` found after it was accepted (2026-09-28; see [the architecture overview](../architecture.md#where-the-code-and-the-adrs-disagree)): @@ -381,7 +381,7 @@ Gaps between this decision and `main` found after it was accepted (2026-09-28; s - **Contained script apps' cards** (`crates/ai-host/src/contained.rs`): streamed text is dropped (the app gets only the final answer, and reads the rest of the conversation, both lanes merged, with `octos.session.history`: App Hub's service calls push no events), an approval the router does not take is declined (listed in `denied_approvals`), and every peer uses the fixed account `device`. *(Until #166 each app had one request context shared by all its cards and UI; #166 moved them to the peer's shared conversation; now they talk in the person's lane, a sharing context per handle generation, section 6.)* - [x] **`user_question/requested` for cards**: by design a card never answers its agent's question; the broker hands every question on the peer's session or a context to the shell's request model (`crates/shell/src/questions/`), routed by the turn's origin, and the person answers on a shell surface (the app's conversation, or the system chat). - [x] **A failed host `turn/start` for a `peer/input` is only logged** (`start_input` in `broker.rs`); the system agent was not told. Since #166 a start refused `turn_in_progress` is retried with the same turn id. Done: every other failure (the kernel's refusal, a timeout, the turn withdrawn by a Stop or the app closing) is now refused to the kernel with `peer/input/reject` (`other`, with the reason in one line), and a start still `turn_in_progress` at the turn timeout with `busy`, on the connection the input came on; the peer's queue moves on (`a_refused_turn_start_for_an_input_is_rejected_with_its_reason`, scripted kernel). ~~**Still open in octos:** octos (fe08d8e6) marks the input answered as soon as a `turn/start` names its turn id, before admission, so a start the kernel then refuses leaves the input "started" and the kernel refuses the reject (`peer_input_already_started`).~~ Closed in octos by [#2652](https://github.com/octos-org/octos/pull/2652) (in the 4a3ec9f9 pin): only an admitted `turn/start` answers the input, so the broker's reject after a refused start now reaches the system agent; the broker needed no change. A real-kernel run of this path is **unverified**. - - **The two lanes, left open**: the Makepad peer client (`makepad_ai_services::peer`) does not read the `conversation` frames yet (it drops events of a request that has answered), so a process app shows the system agent's turns only through `octos.session.history` until it does; a conversation handle runs one turn of its own at a time (a second message on the same handle is refused, not queued); the shared history block is the kernel's defaults (the shell sends `share_history: {}`); and the accepted hazards of section 6 (unlocked file edits across lanes, shared unknown-outcome marks, a slight budget overshoot). *(octos [#2627](https://github.com/octos-org/octos/issues/2627), `peer_gather` refusing the kernel's own `result.md`, is fixed by octos #2630.)* + - **The two lanes, left open**: ~~the Makepad peer client (`makepad_ai_services::peer`) does not read the `conversation` frames yet~~ *(done 2026-09-30, [makepad#77](https://github.com/OctoSense-org/makepad/pull/77): the client surfaces every `conversation` frame of a context it opened as `PeerEvent::Conversation`, with its lane, speaker, envelope type and answer delta, so a process app follows the system agent's turns live; `real_kernel_a_process_app_hears_the_system_agents_lane_live` in `crates/shell/src/host_tools/real_kernel_tests.rs` proves on a real kernel that the shell streams that lane to a process app's link, and those recorded frames are the client's test fixtures)*; ~~a conversation handle runs one turn of its own at a time (a second message on the same handle is refused, not queued)~~ *(a handle still runs one turn at a time, but Makepad's client now queues a second message behind the running turn and sends it when that turn answers: at most 8 wait per handle, beyond that `queue_full`; closing the handle, by the app or the shell, answers the running and waiting ones `cancelled`, and Stop cancels what waits. An in-process module calling `OctosContext` directly is still refused a second turn by the broker)*; both are in the runtime since OctoSense's makepad e29a0eaa pin, which contains #77; the shared history block is the kernel's defaults (the shell sends `share_history: {}`); and the accepted hazards of section 6 (unlocked file edits across lanes, shared unknown-outcome marks, a slight budget overshoot). *(octos [#2627](https://github.com/octos-org/octos/issues/2627), `peer_gather` refusing the kernel's own `result.md`, is fixed by octos #2630.)* - [x] **The `peer/input` route belongs to whichever connection registered the peer's tools last**: octos `set_host_route` (`crates/octos-cli/src/peers/host_tools.rs`) replaces any earlier route. Untested with more than one instance of an app. Done: one instance drives an app's peer (section 5, "One driver per app peer"). In the shell every broker shares the kernel's one connection, and its router hands a peer session's frames to every consumer that named the session. So before this, two instances of one app (two windows of one module) each started the system agent's `peer/input` and each ran the peer's tool calls, and each drew its approvals. Tested in `crates/app-peers/tests/broker.rs` (`two_instances_of_one_app_drive_the_peer_once_and_hand_over_on_close`, scripted kernel, two connections); a real-kernel run with two instances is **unverified**. Not changed: the relay keeps one executor per app (`set_executor`), so an in-process module with two instances that each set one keeps the last. Only Rinx sets one, and it runs one instance per process. - [x] **Script apps never take a native app's name; App Hub's manifest carries ADR 0004's fields** (2026-09-30, from the ADR 0004 code review; [App-Hub#44](https://github.com/OctoSense-org/OctoSense-App-Hub/pull/44) and the App Hub pin after it). A store app could use a native app's id (`terminal`, `rinx`, `apphub`; App Hub reserved only `os.`), and the shell keys a script app's jail and storage folders, spec, tool declarations, executor and consent by that id, and its tools by the id's last segment: it stood in for the Terminal or Rinx. Now App Hub refuses an id, or an id's last segment, that is a native app's or a name the shell acts under (`RESERVED_NAMES`: `system`, `toolbox`, `workflow`, `dev`, `agents`, `octos`, `shell`, `card`, `os`) at its gate, install and admission (`policy::resolve`, `AgentBundle::load`), and the shell checks again against `native-apps.json` (`apps::check_script_app_id`) before it opens a card, loads or installs a script app's tools, sets its storage spec or deletes its folders on uninstall; a shell test fails if `RESERVED_NAMES` lacks a native app. App Hub's `storage` block takes section 11's fields, and its `tools.json` takes `outward` (waits for the person like a destructive tool) and `auto_approvable` (`false`: no standing rule), which the shell passes on (`outward` to the kernel, `auto_approvable` to the approval router). Still open: a store app may still share a system app's namespace (`dev.example.mail` declares `mail.*`), and the relay resolves a dotted grant by name across every owner (`Catalog::owner_of`); Mail's bundle declares no `storage.accounts` yet, and contained peers still use the account `device`. - [x] **A shell-native chat with the system agent**: done. `crates/shell/src/system_chat/` (Setup → Assistant → Assistant chat, F8; full screen on a phone) talks to `_main:api:octosense#system` over the shell's own kernel connection, and its approvals go through the approval router, batched per request.