diff --git a/AGENTS.md b/AGENTS.md index 6468ee7d..c2e03675 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,7 @@ | A host service (`mail`, `calendar`, `news`, `llm`, `model`) | `apps/mail/host-service/`, `apps/calendar/host-service/`, `apps/news/host-service/`, `apps/ai-providers/` (`llm` and `model.complete`) | `cargo test --locked -p octosense-mail-service -p octosense-calendar-service -p octosense-news-service -p octosense-llm-service` | | Connected accounts: OAuth and the `auth`, `github`, `gmail`, `gcalendar` host services; installed apps' Gmail events and the native review of their saves and sends | `crates/oauth-service/` (its README); `crates/shell/src/connected_events.rs`, `crates/shell/src/connected_review.rs` | `cargo test --locked -p octosense-oauth-service` and `cargo test --locked -p octosense-oauth-service --features host`; the two shell files build and test with the shell (from `phone/`: `cargo test --locked --features mobile-apps -p octosense-shell`); `apps.yml`, `phone.yml` | | AppCard (opt-in) | `apps/appcard/` | [apps/appcard/AGENTS.md](apps/appcard/AGENTS.md), `apps.yml` | -| A native app (App Hub, Rinx, Terminal, Sheets, Reference, AppCard, and the Makepad apps made native: Calculator, Clock, Notes, Reminders, Weather, and Task, which runs only as its own process): its crate, pin, features, hosting per target, sandbox, storage, its tools, which of them the system agent may call (`agent.system_tools`) and which its own agent may (`agent.own_tools`) | `native-apps.json` only; `python3 tools/native_apps.py` writes the marked blocks in the `Cargo.toml`s, `crates/shell/src/native_apps.rs` and `Cargo.lock` | `python3 tools/native_apps.py --check`, `python3 -m unittest discover -s tools -p 'test_*.py'` | +| A native app (App Hub, Rinx, Terminal, Sheets, Reference, AppCard, and the Makepad apps made native: Calculator, Clock, Notes, Reminders, Weather, and Task, which runs only as its own process; and OctosCode, whose `kernel` port the coding scope holds, `crates/shell/src/coding_scope.rs`): its crate, pin, features, hosting per target, sandbox, storage, its tools, which of them the system agent may call (`agent.system_tools`) and which its own agent may (`agent.own_tools`) | `native-apps.json` only; `python3 tools/native_apps.py` writes the marked blocks in the `Cargo.toml`s, `crates/shell/src/native_apps.rs` and `Cargo.lock` | `python3 tools/native_apps.py --check`, `python3 -m unittest discover -s tools -p 'test_*.py'` | | An external pin (Makepad, OctoScript, App Hub, octos, Rinx) | root `Cargo.toml` `[workspace.dependencies]` (a native app's in `native-apps.json`), `native-runtime.lock.json`, `runtime-patches.lock.json` | `python3 tools/setup.py --update`, then `--check --cargo` and `python3 tools/native_apps.py --check` | | A decision | `docs/adr/` (next free number) | — | diff --git a/Cargo.lock b/Cargo.lock index 82c43bea..b9317a87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -261,7 +261,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -272,7 +272,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1400,7 +1400,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" dependencies = [ "lazy_static", - "windows-sys 0.59.0", + "windows-sys 0.48.0", ] [[package]] @@ -2310,7 +2310,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.3", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2607,7 +2607,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4472,7 +4472,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)", + "windows-core 0.56.0", ] [[package]] @@ -4773,7 +4773,7 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82cb6a9f675da968c63b6208c641b9dca58fc0133ae53375736b1767b0cab8bd" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6990,7 +6990,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7763,6 +7763,78 @@ dependencies = [ "tracing", ] +[[package]] +name = "octoscode-client" +version = "0.1.0" +source = "git+https://github.com/octos-org/octoscode-app.git?rev=5d0c2a0962218aa94497201ce1a39b7429f09e36#5d0c2a0962218aa94497201ce1a39b7429f09e36" +dependencies = [ + "log", + "octos-core", + "octoscode-store", + "octoscode-transport", + "reqwest 0.12.28", + "serde", + "serde_json", + "tokio", + "url", +] + +[[package]] +name = "octoscode-module" +version = "0.1.0" +source = "git+https://github.com/octos-org/octoscode-app.git?rev=5d0c2a0962218aa94497201ce1a39b7429f09e36#5d0c2a0962218aa94497201ce1a39b7429f09e36" +dependencies = [ + "chrono", + "log", + "makepad-app-module", + "makepad-widgets", + "octos-core", + "octoscode-client", + "octoscode-store", + "octoscode-transport", + "octoscript-makepad", + "octoscript-render", + "octoscript-ui-l0", + "octoscript-widgets", + "regex", + "reqwest 0.12.28", + "serde", + "serde_json", + "sha2 0.10.9", + "tokio", + "url", +] + +[[package]] +name = "octoscode-store" +version = "0.1.0" +source = "git+https://github.com/octos-org/octoscode-app.git?rev=5d0c2a0962218aa94497201ce1a39b7429f09e36#5d0c2a0962218aa94497201ce1a39b7429f09e36" +dependencies = [ + "log", + "octoscode-transport", + "serde", + "serde_json", +] + +[[package]] +name = "octoscode-transport" +version = "0.1.0" +source = "git+https://github.com/octos-org/octoscode-app.git?rev=5d0c2a0962218aa94497201ce1a39b7429f09e36#5d0c2a0962218aa94497201ce1a39b7429f09e36" +dependencies = [ + "android_logger", + "chrono", + "futures-util", + "log", + "octos-core", + "reqwest 0.12.28", + "serde", + "serde_json", + "tokio", + "tokio-tungstenite 0.24.0", + "url", + "uuid", +] + [[package]] name = "octoscript-capabilities" version = "0.1.0" @@ -8179,6 +8251,7 @@ dependencies = [ "makepad-widgets", "makepad-wm-api", "makepad-wm-theme", + "octoscode-module", "octoscript-makepad", "octoscript-ui-l0", "octosense-ai-host", @@ -8993,7 +9066,7 @@ dependencies = [ "once_cell 1.21.4", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -10033,7 +10106,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -10092,7 +10165,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -10935,7 +11008,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -11246,10 +11319,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell 1.21.4", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -11983,7 +12056,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -12921,7 +12994,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 51d51847..3eeb6eda 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -79,6 +79,7 @@ makepad-clock = { git = "https://github.com/OctoSense-org/makepad.git", rev = "3 makepad-notes = { git = "https://github.com/OctoSense-org/makepad.git", rev = "32d6415fb7476345ad36ee4f98d6f844d1f07fd2", default-features = false } makepad-reminders = { git = "https://github.com/OctoSense-org/makepad.git", rev = "32d6415fb7476345ad36ee4f98d6f844d1f07fd2", default-features = false } makepad-weather = { git = "https://github.com/OctoSense-org/makepad.git", rev = "32d6415fb7476345ad36ee4f98d6f844d1f07fd2", default-features = false } +octoscode-module = { git = "https://github.com/octos-org/octoscode-app.git", rev = "5d0c2a0962218aa94497201ce1a39b7429f09e36", default-features = false, features = ["octosense-module"] } makepad-task = { git = "https://github.com/OctoSense-org/makepad.git", rev = "32d6415fb7476345ad36ee4f98d6f844d1f07fd2" } # END native-apps: pins # --- Makepad (OctoSense-org/makepad), the revision Octoscript-Makepad's @@ -296,6 +297,8 @@ octoscript-schema = { path = ".sources/octoscript/crates/octoscript-schema" } octoscript-makepad = { path = ".sources/octoscript-makepad/crates/octoscript-makepad" } octoscript-widgets = { path = ".sources/octoscript-makepad/crates/octoscript-widgets" } octoscript-node = { path = ".sources/octoscript-makepad/crates/octoscript-node" } +# OctosCode's cards register their globals through it (octos-org/octoscode-app). +octoscript-render = { path = ".sources/octoscript-makepad/crates/octoscript-render" } # Rinx takes the app-peers contract from this repository by git. Redirect # it to the same local workspace crates, so the host and module share one diff --git a/README.md b/README.md index b71b7b32..51d8026f 100644 --- a/README.md +++ b/README.md @@ -376,6 +376,7 @@ Pinned exactly once, in the root `Cargo.toml` and the runtime locks: | [OctoSense-App-Hub](https://github.com/OctoSense-org/OctoSense-App-Hub) | The signed catalog, the store, and the Card runner that contains every script app. | | [octos](https://github.com/octos-org/octos) | The agent kernel. Android bundles it in the APK as `liboctos.so`; the desktop runs the packaged `octos-kernel` beside the shell, checked against this revision (`tools/kernel-artifact.py --host --stage` builds it). | | [Rinx](https://github.com/hagency-org/Rinx) | Matrix chats and mini apps, hosted as a native module. | +| [OctosCode](https://github.com/octos-org/octoscode-app) | The coding client of the person's assistant, hosted as a native module that reaches the kernel through its port in the coding scope (ADR 0003, item 9). | Related, not build inputs: [OctoScript-App-Design-Flow](https://github.com/OctoSense-org/OctoScript-App-Design-Flow) (how apps are built and published), [OctoScript-Android](https://github.com/OctoSense-org/OctoScript-Android) and [OctoScript-OH](https://github.com/OctoSense-org/OctoScript-OH) (other renderers), the [OctoSense website](https://github.com/OctoSense-org/octosense-org.github.io). diff --git a/README.zh-CN.md b/README.zh-CN.md index 9e17cd56..d0bbd02f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -376,6 +376,7 @@ Shell 只有一份,位于 `crates/shell`([ADR 0001(英文)](docs/adr/000 | [OctoSense-App-Hub](https://github.com/OctoSense-org/OctoSense-App-Hub) | 签名目录、商店,以及隔离运行每个脚本应用的 Card runner。 | | [octos](https://github.com/octos-org/octos) | Agent 内核。Android 上 APK 以 `liboctos.so` 形式内置它;桌面端运行 Shell 旁随附的 `octos-kernel`,并核对其版本与此处固定的一致(由 `tools/kernel-artifact.py --host --stage` 构建)。 | | [Rinx](https://github.com/hagency-org/Rinx) | Matrix 聊天与小程序,作为原生模块托管。 | +| [OctosCode](https://github.com/octos-org/octoscode-app) | 用户助手的编码客户端,作为原生模块托管,经由编码范围内的内核端口访问内核(ADR 0003 第 9 条)。 | 相关但不参与构建:[OctoScript-App-Design-Flow](https://github.com/OctoSense-org/OctoScript-App-Design-Flow)(如何构建和发布应用)、[OctoScript-Android](https://github.com/OctoSense-org/OctoScript-Android) 和 [OctoScript-OH](https://github.com/OctoSense-org/OctoScript-OH)(其他渲染后端)、[OctoSense 网站](https://github.com/OctoSense-org/octosense-org.github.io)。 diff --git a/crates/kernel/src/kernel.rs b/crates/kernel/src/kernel.rs index 78482254..f2ace202 100644 --- a/crates/kernel/src/kernel.rs +++ b/crates/kernel/src/kernel.rs @@ -11,7 +11,7 @@ use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader use tokio::sync::{mpsc, watch}; use crate::launch::Launch; -use crate::router::{ConnId, Router}; +use crate::router::{ConnId, Routed, Router, Scope}; use crate::network::Network; use crate::{ClientAccess, CloseReason, LogSink}; use std::path::PathBuf; @@ -25,7 +25,8 @@ pub(crate) enum Inbound { /// Messages to a generation's supervisor. pub(crate) enum Ctl { - Attach(ConnId, mpsc::UnboundedSender), + /// A consumer, held to its scope when it has one (an app's kernel port). + Attach(ConnId, mpsc::UnboundedSender, Option>), Frame(ConnId, String), Detach(ConnId), /// Set the system agent's exact kernel tool list again (a grant changed). @@ -338,7 +339,7 @@ pub(crate) async fn supervise( tokio::select! { biased; msg = ctl.recv() => match msg { - Some(Ctl::Attach(id, tx)) => { router.attach(id); consumers.insert(id, tx); } + Some(Ctl::Attach(id, tx, scope)) => { attach(&mut router, id, scope); consumers.insert(id, tx); } Some(Ctl::Detach(id)) => { router.detach(id); consumers.remove(&id); } Some(Ctl::Stop(reason)) => break Err(reason), Some(frame) => queued.push_back(frame), @@ -366,17 +367,24 @@ pub(crate) async fn supervise( tokio::select! { biased; msg = async { if queued.is_empty() { ctl.recv().await } else { queued.pop_front() } } => match msg { - Some(Ctl::Attach(id, tx)) => { - router.attach(id); + Some(Ctl::Attach(id, tx, scope)) => { + attach(&mut router, id, scope); consumers.insert(id, tx); } - Some(Ctl::Frame(id, text)) => { - if let Some(frame) = router.consumer_frame(id, &text) { + Some(Ctl::Frame(id, text)) => match router.consumer_frame(id, &text) { + Routed::Kernel(frame) => { if let Err(e) = write_line(&mut io, &frame).await { break CloseReason::Exited(format!("writing to the kernel failed: {e}")); } } - } + Routed::Answer(frame) => { + (log)(&format!("octos-core: kernel {generation}: consumer {id}: {}", crate::router::refusal_note(&frame))); + if let Some(tx) = consumers.get(&id) { + let _ = tx.send(Inbound::Frame(frame)); + } + } + Routed::Drop | Routed::Held => {} + }, Some(Ctl::SystemToolList) => { let frame = set_tool_list(&mut tool_lists); if let Err(e) = write_line(&mut io, &frame).await { @@ -399,11 +407,23 @@ pub(crate) async fn supervise( (log)(&format!("octos-core: kernel {generation}: the system agent's tool list {outcome}")); continue; } - for (id, frame) in router.kernel_frame(&text) { + let delivery = router.kernel_frame(&text); + for (id, frame) in delivery.consumers { if let Some(tx) = consumers.get(&id) { let _ = tx.send(Inbound::Frame(frame)); } } + // Frames a session's preparation held, now released. + let mut failed = None; + for frame in delivery.kernel { + if let Err(e) = write_line(&mut io, &frame).await { + failed = Some(CloseReason::Exited(format!("writing to the kernel failed: {e}"))); + break; + } + } + if let Some(reason) = failed { + break reason; + } } Ok(None) => break CloseReason::Exited("the kernel closed its output".into()), Err(e) => break CloseReason::Exited(format!("reading from the kernel failed: {e}")), @@ -436,7 +456,7 @@ pub(crate) async fn supervise( // whose Attach is still queued. ctl.close(); while let Ok(msg) = ctl.try_recv() { - if let Ctl::Attach(id, tx) = msg { + if let Ctl::Attach(id, tx, _) = msg { consumers.insert(id, tx); } } @@ -447,6 +467,13 @@ pub(crate) async fn supervise( let _ = done.send(true); } +fn attach(router: &mut Router, id: ConnId, scope: Option>) { + match scope { + Some(scope) => router.attach_scoped(id, scope), + None => router.attach(id), + } +} + async fn write_line(io: &mut Io, frame: &str) -> std::io::Result<()> { io.writer.write_all(frame.as_bytes()).await?; io.writer.write_all(b"\n").await?; diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index c55f275e..a2a1785c 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -43,7 +43,10 @@ mod kernel; mod network; pub use network::{connection_file, pairing_link, ClientAccess, Pairing, CONNECTION_FILE, SYSTEM_SESSION}; pub mod launch; +mod port; +pub use port::{Deliver, PortEvent, PortHandle}; mod router; +pub use router::{Scope, SCOPE_DENIED}; pub mod system_tools; pub use dirs::{kernel_home, profile_path, resolve_core_dir}; @@ -302,13 +305,24 @@ impl Core { /// kernel can exist here; a kernel that then fails to start closes the /// connection with [`CloseReason::Failed`]. pub fn connect(&self) -> Result { + self.connect_with(None) + } + + /// [`Core::connect`] for a consumer held to `scope` (an app's kernel + /// port): the router checks each of its requests and filters what the + /// kernel sends it. + pub fn connect_scoped(&self, scope: Arc) -> Result { + self.connect_with(Some(scope)) + } + + fn connect_with(&self, scope: Option>) -> Result { let mut st = self.0.state.lock().unwrap(); let conn = st.next_conn; st.next_conn += 1; let (tx, rx) = mpsc::unbounded_channel(); // Join the running generation if it still takes consumers. if let Some(current) = st.current.as_mut() { - if current.ctl.send(Ctl::Attach(conn, tx.clone())).is_ok() { + if current.ctl.send(Ctl::Attach(conn, tx.clone(), scope.clone())).is_ok() { current.connections += 1; return Ok(Connection { core: self.clone(), @@ -330,7 +344,7 @@ impl Core { let (ctl, ctl_rx) = mpsc::unbounded_channel(); let (done_tx, done_rx) = watch::channel(false); let previous = st.last_done.replace(done_rx); - ctl.send(Ctl::Attach(conn, tx)).expect("fresh channel"); + ctl.send(Ctl::Attach(conn, tx, scope)).expect("fresh channel"); let shared = matches!(launch, Launch::WebSocket { .. }); let (ready_tx, ready) = watch::channel(None); st.current = Some(Generation { id, ctl: ctl.clone(), connections: 1, shared, ready: ready.clone() }); @@ -693,6 +707,16 @@ pub fn connect() -> Result { global().connect() } +/// [`connect`] held to `scope` ([`Core::connect_scoped`]). +pub fn connect_scoped(scope: Arc) -> Result { + global().connect_scoped(scope) +} + +/// Serve an app's kernel port on the kernel's runtime ([`Core::serve_scoped`]). +pub fn serve_scoped(label: &str, scope: Arc, up: std::sync::mpsc::Receiver, deliver: Deliver) -> PortHandle { + global().serve_scoped(label, scope, up, deliver) +} + /// The external clients' connection while Talk to Octos is on (host worker /// thread only). pub fn client_access() -> Result { diff --git a/crates/kernel/src/port.rs b/crates/kernel/src/port.rs new file mode 100644 index 00000000..9ce6b87d --- /dev/null +++ b/crates/kernel/src/port.rs @@ -0,0 +1,114 @@ +//! A scoped consumer the kernel's own runtime serves: an app's kernel port +//! (ADR 0003, "An app that is an octos client"), without the shell needing +//! an async runtime of its own. +//! +//! Frames from the app go to the kernel through the router, which holds +//! them to the consumer's [`Scope`]; the kernel's frames for it go to its +//! `deliver` callback. A kernel restart is a [`PortEvent::Reset`]: the +//! connection is made again behind the port and the app opens its sessions +//! again. Any other end of the kernel is [`PortEvent::Closed`], as is the +//! app dropping its end. Dropping the [`PortHandle`] ends it too. + +use std::sync::Arc; + +use tokio::sync::{mpsc, oneshot}; + +use crate::{CloseReason, Core, Scope}; + +/// What a port's consumer is told. +#[derive(Clone, Debug, PartialEq)] +pub enum PortEvent { + /// A kernel frame for it: a reply, a notification or a kernel request. + Frame(String), + /// The kernel restarted; the port goes on with the new one. + Reset(String), + /// The port ended. + Closed(String), +} + +/// Delivers [`PortEvent`]s; `false` when the consumer is gone. +pub type Deliver = Box bool + Send + Sync>; + +/// A served port. Dropping it ends the port's connection. +pub struct PortHandle { + _stop: oneshot::Sender<()>, +} + +impl Core { + /// Serve a scoped consumer: its frames arrive on `up` (one JSON-RPC + /// frame per message; the port ends when the sender is dropped), and + /// what the kernel says to it goes to `deliver`. + pub fn serve_scoped(&self, label: &str, scope: Arc, up: std::sync::mpsc::Receiver, deliver: Deliver) -> PortHandle { + let (stop, stopped) = oneshot::channel(); + let (up_tx, up_rx) = mpsc::unbounded_channel(); + let forwarded = std::thread::Builder::new().name(format!("kernel-port {label}")).spawn(move || { + while let Ok(frame) = up.recv() { + if up_tx.send(frame).is_err() { + break; + } + } + }); + if let Err(e) = forwarded { + deliver(PortEvent::Closed(format!("the kernel port could not start: {e}"))); + return PortHandle { _stop: stop }; + } + let core = self.clone(); + let label = label.to_string(); + self.0.runtime().spawn(serve(core, label, scope, up_rx, deliver, stopped)); + PortHandle { _stop: stop } + } +} + +async fn serve( + core: Core, + label: String, + scope: Arc, + mut up: mpsc::UnboundedReceiver, + deliver: Deliver, + mut stopped: oneshot::Receiver<()>, +) { + loop { + let mut conn = match core.connect_scoped(scope.clone()) { + Ok(conn) => conn, + Err(e) => { + deliver(PortEvent::Closed(format!("no octos kernel here: {e}"))); + return; + } + }; + log::info!("octos-core: kernel port {label} on kernel {}", conn.generation()); + let reason = loop { + tokio::select! { + biased; + // The handle was dropped: the app instance closed. + _ = &mut stopped => return, + frame = up.recv() => match frame { + Some(frame) => { + if let Err(reason) = conn.send(frame) { + break reason; + } + } + // The app dropped its end. + None => return, + }, + frame = conn.recv() => match frame { + Ok(text) => { + if !deliver(PortEvent::Frame(text)) { + return; + } + } + Err(reason) => break reason, + }, + } + }; + if matches!(reason, CloseReason::Restarted) { + log::info!("octos-core: kernel port {label}: the kernel restarted; connecting again"); + if !deliver(PortEvent::Reset(reason.to_string())) { + return; + } + continue; + } + log::info!("octos-core: kernel port {label}: {reason}"); + deliver(PortEvent::Closed(reason.to_string())); + return; + } +} diff --git a/crates/kernel/src/router.rs b/crates/kernel/src/router.rs index 1adb67b4..18a62907 100644 --- a/crates/kernel/src/router.rs +++ b/crates/kernel/src/router.rs @@ -15,13 +15,77 @@ //! //! Only the system workspace is persisted: Web opens it with an explicit cwd, //! so native clients must send the same cwd when resuming after a restart. +//! +//! A **scoped** consumer (an app that is itself an octos client, through its +//! kernel port; ADR 0003) is held to its [`Scope`]: each of its requests is +//! checked first, and one the scope refuses is answered here, never sent; +//! before its first frame about a session the scope may have the router +//! prepare that session (a host request, such as the session's exact tool +//! list), and its frames about the session wait until the kernel confirmed; +//! it hears only about sessions it named in requests its scope allowed (no +//! broadcast of anyone else's), and only the frames its scope lets through, +//! while one its scope keeps from it goes to the unscoped consumers (a +//! host-only frame about its session); its replies pass the scope's filter; +//! and it may answer only the kernel requests it was sent. -use std::collections::{BTreeSet, HashMap}; +use std::collections::{BTreeSet, HashMap, HashSet}; +use std::sync::Arc; -use serde_json::Value; +use serde_json::{json, Value}; pub(crate) type ConnId = u64; +/// What a scoped consumer may do. The kernel sees the shell's authority on +/// every frame, so this is the whole boundary: refuse by default. +pub trait Scope: Send + Sync { + /// A request (or notification) from the consumer: `Ok` forwards it, + /// `params` possibly rewritten; `Err` refuses it with that message. + fn request(&self, method: &str, params: &mut Value) -> Result<(), String>; + /// Whether a notification or kernel request the router would deliver to + /// the consumer reaches it: `session` is the one it is about (a session + /// the consumer named), `None` for one about no session. + fn notification(&self, method: &str, session: Option<&str>) -> bool; + /// The result of the consumer's `method`, filtered in place. + fn result(&self, method: &str, result: &mut Value); + /// The params of a notification or kernel request that + /// [`Scope::notification`] lets through, as the consumer gets them: + /// `Some` when the scope rewrote them, `None` to pass the frame as it is. + fn rewrite(&self, _method: &str, _params: &Value) -> Option { + None + } + /// A host request (`{"method", "params"}`) to make before the consumer's + /// first frame about `session` in this kernel generation; its frames + /// about the session wait for the answer, and a failed one refuses them. + fn prepare(&self, _session: &str) -> Option { + None + } +} + +/// The JSON-RPC error a refused request is answered with. +pub const SCOPE_DENIED: i64 = -32003; + +/// What becomes of a consumer's frame. +#[derive(Debug, PartialEq)] +pub(crate) enum Routed { + /// Write it to the kernel. + Kernel(String), + /// Answer the consumer with it; the kernel never sees the request. + Answer(String), + /// Neither: not JSON, or refused without an id to answer. + Drop, + /// Kept until its session is prepared ([`Scope::prepare`]). + Held, +} + +/// Where a kernel frame's consequences go. +#[derive(Debug, Default, PartialEq)] +pub(crate) struct Delivery { + /// Frames for consumers. + pub(crate) consumers: Vec<(ConnId, String)>, + /// Frames for the kernel: held frames whose session is now prepared. + pub(crate) kernel: Vec, +} + struct Pending { conn: ConnId, id: Value, @@ -34,6 +98,17 @@ pub(crate) struct Router { pending: HashMap, sessions: HashMap>, conns: BTreeSet, + scopes: HashMap>, + /// Kernel requests delivered to scoped consumers, by (consumer, id): + /// the only responses such a consumer may send. + asked: HashSet<(ConnId, String)>, + /// Sessions being prepared for a scoped consumer, by the kernel id of + /// the host request: (consumer, session). + preparing: HashMap, + /// A consumer's frames about a session being prepared, in order. + held: HashMap<(ConnId, String), Vec>, + /// Sessions prepared for a consumer in this kernel generation. + prepared: HashSet<(ConnId, String)>, system_workspace_file: Option, } @@ -45,9 +120,20 @@ impl Router { self.conns.insert(conn); } + /// A consumer held to `scope`. + pub(crate) fn attach_scoped(&mut self, conn: ConnId, scope: Arc) { + self.conns.insert(conn); + self.scopes.insert(conn, scope); + } + /// A consumer left: forget its requests and sessions. pub(crate) fn detach(&mut self, conn: ConnId) { self.conns.remove(&conn); + self.scopes.remove(&conn); + self.asked.retain(|(c, _)| *c != conn); + self.preparing.retain(|_, (c, _)| *c != conn); + self.held.retain(|(c, _), _| *c != conn); + self.prepared.retain(|(c, _)| *c != conn); self.pending.retain(|_, p| p.conn != conn); self.sessions.retain(|_, subscribers| { subscribers.remove(&conn); @@ -55,17 +141,25 @@ impl Router { }); } - /// A consumer's frame, as the kernel should see it. `None`: not JSON, dropped. - pub(crate) fn consumer_frame(&mut self, conn: ConnId, text: &str) -> Option { + /// A consumer's frame: as the kernel should see it, or answered here. + pub(crate) fn consumer_frame(&mut self, conn: ConnId, text: &str) -> Routed { let mut frame: Value = match serde_json::from_str(text) { Ok(v) => v, Err(e) => { log::warn!("octos-core: consumer {conn} sent a frame that is not JSON ({e}); dropped"); - return None; + return Routed::Drop; } }; + if let Some(scope) = self.scopes.get(&conn).cloned() { + if let Some(refused) = self.scoped_frame(conn, scope.as_ref(), &mut frame) { + return refused; + } + if let Some(waiting) = self.prepare(conn, scope.as_ref(), &frame) { + return waiting; + } + } let Some(obj) = frame.as_object_mut() else { - return Some(text.to_owned()); + return Routed::Kernel(text.to_owned()); }; if obj.get("method").and_then(Value::as_str) == Some("session/open") { if let Some(params) = obj.get_mut("params").and_then(Value::as_object_mut) { @@ -85,39 +179,140 @@ impl Router { self.subscribe(session, conn); } let method = obj.get("method").and_then(Value::as_str).map(str::to_owned); + let scoped = self.scopes.contains_key(&conn); match (method, obj.get("id").cloned()) { (Some(method), Some(id)) if !id.is_null() => { self.next += 1; let kernel_id = format!("k{}", self.next); obj.insert("id".into(), Value::String(kernel_id.clone())); self.pending.insert(kernel_id, Pending { conn, id, method }); - Some(frame.to_string()) + Routed::Kernel(frame.to_string()) + } + // A scope may have rewritten a notification's params. + (Some(_), _) if scoped => Routed::Kernel(frame.to_string()), + _ => Routed::Kernel(text.to_owned()), + } + } + + /// A scoped consumer's frame checked: `None` lets it go on (its params + /// as the scope left them), else what becomes of it instead. + fn scoped_frame(&mut self, conn: ConnId, scope: &dyn Scope, frame: &mut Value) -> Option { + let Some(obj) = frame.as_object_mut() else { + return Some(Routed::Drop); + }; + let id = obj.get("id").filter(|id| !id.is_null()).cloned(); + let Some(method) = obj.get("method").and_then(Value::as_str).map(str::to_owned) else { + // A response: only to a kernel request this consumer was sent. + let asked = id.as_ref().map(|id| (conn, id_key(id))); + return match asked { + Some(key) if self.asked.remove(&key) => None, + _ => Some(Routed::Drop), + }; + }; + let params = obj.entry("params").or_insert_with(|| json!({})); + match scope.request(&method, params) { + Ok(()) => None, + Err(message) => { + Some(match id { + Some(id) => Routed::Answer( + json!({"jsonrpc": "2.0", "id": id, "error": {"code": SCOPE_DENIED, "message": message, "data": {"kind": "scope_denied", "method": method}}}) + .to_string(), + ), + None => Routed::Drop, + }) } - _ => Some(text.to_owned()), } } + /// A scoped consumer's frame about a session not yet prepared: the + /// host request to make first (the frame waits), or `Held` while one is + /// out; `None` lets it go on. + fn prepare(&mut self, conn: ConnId, scope: &dyn Scope, frame: &Value) -> Option { + frame.get("method")?; + let session = frame.get("params").and_then(session_of)?; + let key = (conn, session); + if self.prepared.contains(&key) { + return None; + } + if let Some(waiting) = self.held.get_mut(&key) { + waiting.push(frame.to_string()); + return Some(Routed::Held); + } + let Some(mut host) = scope.prepare(&key.1) else { + self.prepared.insert(key); + return None; + }; + self.next += 1; + let kernel_id = format!("p{}", self.next); + if let Some(obj) = host.as_object_mut() { + obj.insert("jsonrpc".into(), "2.0".into()); + obj.insert("id".into(), Value::String(kernel_id.clone())); + } + self.held.insert(key.clone(), vec![frame.to_string()]); + self.preparing.insert(kernel_id, key); + Some(Routed::Kernel(host.to_string())) + } + + /// The kernel answered a session's preparation: release its frames, or + /// refuse them. + fn prepared_or_refused(&mut self, key: (ConnId, String), answer: &Value) -> Delivery { + let mut out = Delivery::default(); + let held = self.held.remove(&key).unwrap_or_default(); + let (conn, session) = key; + if let Some(error) = answer.get("error") { + let message = format!( + "the shell could not prepare session {session}: {}", + error.get("message").and_then(Value::as_str).unwrap_or("refused") + ); + log::warn!("octos-core: consumer {conn}: {message}"); + for text in held { + let Ok(frame) = serde_json::from_str::(&text) else { continue }; + if let Some(id) = frame.get("id").filter(|id| !id.is_null()) { + let method = frame.get("method").cloned().unwrap_or(Value::Null); + out.consumers.push(( + conn, + json!({"jsonrpc": "2.0", "id": id, "error": {"code": SCOPE_DENIED, "message": message, "data": {"kind": "scope_denied", "method": method}}}) + .to_string(), + )); + } + } + return out; + } + self.prepared.insert((conn, session)); + for text in held { + match self.consumer_frame(conn, &text) { + Routed::Kernel(frame) => out.kernel.push(frame), + Routed::Answer(frame) => out.consumers.push((conn, frame)), + Routed::Drop | Routed::Held => {} + } + } + out + } + /// A kernel frame: who gets it, and as what. - pub(crate) fn kernel_frame(&mut self, text: &str) -> Vec<(ConnId, String)> { + pub(crate) fn kernel_frame(&mut self, text: &str) -> Delivery { let mut frame: Value = match serde_json::from_str(text) { Ok(v) => v, Err(_) => { log::warn!("octos-core: kernel wrote a line that is not JSON; dropped"); - return Vec::new(); + return Delivery::default(); } }; let Some(obj) = frame.as_object_mut() else { - return Vec::new(); + return Delivery::default(); }; let is_response = obj.get("method").is_none() && obj.contains_key("id"); if is_response { let Some(kernel_id) = obj.get("id").and_then(Value::as_str).map(str::to_owned) else { log::warn!("octos-core: kernel response without a routable id; dropped"); - return Vec::new(); + return Delivery::default(); }; + if let Some(key) = self.preparing.remove(&kernel_id) { + return self.prepared_or_refused(key, &frame); + } let Some(pending) = self.pending.remove(&kernel_id) else { // The consumer left, or a restart dropped the request. - return Vec::new(); + return Delivery::default(); }; if pending.method == "session/open" { if let Some(session) = obj @@ -129,18 +324,54 @@ impl Router { self.subscribe(session.to_owned(), pending.conn); } } + if let Some(scope) = self.scopes.get(&pending.conn) { + if let Some(result) = obj.get_mut("result") { + scope.result(&pending.method, result); + } + } obj.insert("id".into(), pending.id); - return vec![(pending.conn, frame.to_string())]; - } - // A notification (or a kernel request): by session, else to everyone. - let targets: Vec = match obj.get("params").and_then(session_of) { - Some(session) => match self.subscribers(&session) { - Some(subs) => subs, - None => self.conns.iter().copied().collect(), - }, - None => self.conns.iter().copied().collect(), + return Delivery { consumers: vec![(pending.conn, frame.to_string())], kernel: Vec::new() }; + } + // A notification (or a kernel request): to the consumers that named + // its session, as far as their scopes let it through. When none + // takes it (nobody named the session, or only scoped consumers whose + // scope keeps it from them: a host-only frame about their session), + // to the unscoped consumers; a scoped consumer gets a frame about no + // session only if its scope lets it through. + let method = obj.get("method").and_then(Value::as_str).unwrap_or_default().to_owned(); + let session = obj.get("params").and_then(session_of); + let lets_through = |conn: &ConnId| { + self.scopes.get(conn).is_none_or(|scope| scope.notification(&method, session.as_deref())) }; - targets.into_iter().map(|c| (c, text.to_owned())).collect() + let named = session.as_deref().and_then(|s| self.subscribers(s)).unwrap_or_default(); + let mut targets: Vec = named.into_iter().filter(|c| lets_through(c)).collect(); + if targets.is_empty() { + targets = self + .conns + .iter() + .copied() + .filter(|c| !self.scopes.contains_key(c) || (session.is_none() && lets_through(c))) + .collect(); + } + let request_id = obj.get("id").filter(|id| !id.is_null()).map(id_key); + let mut out = Delivery::default(); + for conn in targets { + let mut line = text.to_owned(); + let Some(scope) = self.scopes.get(&conn) else { + out.consumers.push((conn, line)); + continue; + }; + if let Some(params) = frame.get("params").and_then(|p| scope.rewrite(&method, p)) { + let mut rewritten = frame.clone(); + rewritten["params"] = params; + line = rewritten.to_string(); + } + if let Some(id) = &request_id { + self.asked.insert((conn, id.clone())); + } + out.consumers.push((conn, line)); + } + out } fn subscribe(&mut self, session: String, conn: ConnId) { @@ -162,6 +393,20 @@ fn session_of(params: &Value) -> Option { params.get("session_id").and_then(Value::as_str).map(str::to_owned) } +/// What a refusal the router answered says, for the log: the method and the +/// scope's reason. +pub(crate) fn refusal_note(frame: &str) -> String { + let v: Value = serde_json::from_str(frame).unwrap_or_default(); + let error = &v["error"]; + let method = error["data"]["method"].as_str().unwrap_or("?"); + format!("refused {method}: {}", error["message"].as_str().unwrap_or("?")) +} + +/// A JSON-RPC id as a key (a string id or a number's text). +fn id_key(id: &Value) -> String { + id.as_str().map(str::to_owned).unwrap_or_else(|| id.to_string()) +} + #[cfg(test)] mod tests { use super::*; @@ -171,22 +416,35 @@ mod tests { serde_json::from_str(s).unwrap() } + /// What the consumers get of a kernel frame. + fn to(r: &mut Router, text: &str) -> Vec<(ConnId, String)> { + r.kernel_frame(text).consumers + } + + /// The frame the kernel gets. + fn kernel(routed: Routed) -> String { + match routed { + Routed::Kernel(frame) => frame, + other => panic!("expected a frame for the kernel, got {other:?}"), + } + } + #[test] fn requests_get_kernel_ids_and_replies_go_back_with_the_consumers_id() { let mut r = Router::default(); r.attach(1); r.attach(2); // Both consumers use id "1": the kernel sees two different ids. - let a = parse(&r.consumer_frame(1, r#"{"jsonrpc":"2.0","id":"1","method":"session/list","params":{}}"#).unwrap()); - let b = parse(&r.consumer_frame(2, r#"{"jsonrpc":"2.0","id":"1","method":"session/list","params":{}}"#).unwrap()); + let a = parse(&kernel(r.consumer_frame(1, r#"{"jsonrpc":"2.0","id":"1","method":"session/list","params":{}}"#))); + let b = parse(&kernel(r.consumer_frame(2, r#"{"jsonrpc":"2.0","id":"1","method":"session/list","params":{}}"#))); assert_ne!(a["id"], b["id"]); let reply = json!({"jsonrpc":"2.0","id": b["id"], "result": {"sessions": []}}).to_string(); - let out = r.kernel_frame(&reply); + let out = to(&mut r, &reply); assert_eq!(out.len(), 1); assert_eq!(out[0].0, 2); assert_eq!(parse(&out[0].1)["id"], "1"); // A reply is delivered once. - assert!(r.kernel_frame(&reply).is_empty()); + assert!(to(&mut r, &reply).is_empty()); } #[test] @@ -194,17 +452,17 @@ mod tests { let mut r = Router::default(); r.attach(1); r.attach(2); - let open = parse(&r.consumer_frame(1, r#"{"jsonrpc":"2.0","id":"7","method":"session/open","params":{"session_id":"_main:a"}}"#).unwrap()); - r.kernel_frame(&json!({"jsonrpc":"2.0","id": open["id"], "result": {"opened": {"session_id": "_main:a"}}}).to_string()); + let open = parse(&kernel(r.consumer_frame(1, r#"{"jsonrpc":"2.0","id":"7","method":"session/open","params":{"session_id":"_main:a"}}"#))); + to(&mut r, &json!({"jsonrpc":"2.0","id": open["id"], "result": {"opened": {"session_id": "_main:a"}}}).to_string()); let note = r#"{"jsonrpc":"2.0","method":"message/delta","params":{"session_id":"_main:a","text":"hi"}}"#; - assert_eq!(r.kernel_frame(note), vec![(1, note.to_owned())]); + assert_eq!(to(&mut r, note), vec![(1, note.to_owned())]); // A topic of the session follows it. let topic = r#"{"jsonrpc":"2.0","method":"message/delta","params":{"session_id":"_main:a#t","text":"x"}}"#; - assert_eq!(r.kernel_frame(topic).len(), 1); + assert_eq!(to(&mut r, topic).len(), 1); // A session nobody named reaches everyone; so does one without a session. let other = r#"{"jsonrpc":"2.0","method":"message/delta","params":{"session_id":"_main:b"}}"#; - assert_eq!(r.kernel_frame(other).len(), 2); - assert_eq!(r.kernel_frame(r#"{"jsonrpc":"2.0","method":"server/heartbeat","params":{}}"#).len(), 2); + assert_eq!(to(&mut r, other).len(), 2); + assert_eq!(to(&mut r, r#"{"jsonrpc":"2.0","method":"server/heartbeat","params":{}}"#).len(), 2); } #[test] @@ -217,23 +475,212 @@ mod tests { r.consumer_frame(c, r#"{"jsonrpc":"2.0","id":"1","method":"turn/start","params":{"session_id":"s"}}"#); } let note = r#"{"jsonrpc":"2.0","method":"turn/started","params":{"session_id":"s"}}"#; - assert_eq!(r.kernel_frame(note).iter().map(|x| x.0).collect::>(), vec![1, 2]); + assert_eq!(to(&mut r, note).iter().map(|x| x.0).collect::>(), vec![1, 2]); r.detach(1); - assert_eq!(r.kernel_frame(note).iter().map(|x| x.0).collect::>(), vec![2]); + assert_eq!(to(&mut r, note).iter().map(|x| x.0).collect::>(), vec![2]); r.detach(2); // Nobody names it any more: everyone left gets it. - assert_eq!(r.kernel_frame(note).iter().map(|x| x.0).collect::>(), vec![3]); + assert_eq!(to(&mut r, note).iter().map(|x| x.0).collect::>(), vec![3]); } #[test] fn replies_for_a_departed_consumer_are_dropped_and_passthrough_is_verbatim() { let mut r = Router::default(); r.attach(1); - let req = parse(&r.consumer_frame(1, r#"{"jsonrpc":"2.0","id":"1","method":"session/list","params":{}}"#).unwrap()); + let req = parse(&kernel(r.consumer_frame(1, r#"{"jsonrpc":"2.0","id":"1","method":"session/list","params":{}}"#))); r.detach(1); - assert!(r.kernel_frame(&json!({"jsonrpc":"2.0","id": req["id"], "result": {}}).to_string()).is_empty()); + assert!(to(&mut r, &json!({"jsonrpc":"2.0","id": req["id"], "result": {}}).to_string()).is_empty()); let n = r#"{"jsonrpc":"2.0","method":"client/note","params":{}}"#; - assert_eq!(r.consumer_frame(1, n).as_deref(), Some(n)); - assert_eq!(r.consumer_frame(1, "not json"), None); + assert_eq!(r.consumer_frame(1, n), Routed::Kernel(n.to_owned())); + assert_eq!(r.consumer_frame(1, "not json"), Routed::Drop); + } + + /// A scope for the tests: `session/*` and `turn/*`, on `code-…` sessions + /// only; nothing `peer/…`; about no session, `server/heartbeat` only; + /// `session/list` answers its own sessions (octos names them `id`). + struct CodeOnly; + + impl Scope for CodeOnly { + fn request(&self, method: &str, params: &mut Value) -> Result<(), String> { + if !(method.starts_with("session/") || method.starts_with("turn/")) { + return Err(format!("{method} is not allowed")); + } + match params.get("session_id").and_then(Value::as_str) { + Some(s) if !s.starts_with("code-") => Err(format!("session {s} is not this app's")), + _ => Ok(()), + } + } + fn notification(&self, method: &str, session: Option<&str>) -> bool { + !method.starts_with("peer/") && (session.is_some() || method == "server/heartbeat") + } + fn result(&self, method: &str, result: &mut Value) { + if method == "session/list" { + if let Some(list) = result.get_mut("sessions").and_then(Value::as_array_mut) { + list.retain(|s| s["id"].as_str().is_some_and(|id| id.starts_with("code-"))); + } + } + } + fn rewrite(&self, _method: &str, params: &Value) -> Option { + let methods = params.pointer("/capabilities/supported_methods")?.as_array()?; + let mut params = params.clone(); + params["capabilities"]["supported_methods"] = methods + .iter() + .filter(|m| m.as_str().is_some_and(|m| m.starts_with("session/") || m.starts_with("turn/"))) + .cloned() + .collect(); + Some(params) + } + } + + #[test] + fn a_scoped_consumer_gets_a_notification_as_its_scope_rewrites_it() { + let mut r = Router::default(); + r.attach(1); + r.attach_scoped(2, Arc::new(CodeOnly)); + r.consumer_frame(1, r#"{"jsonrpc":"2.0","id":"1","method":"turn/start","params":{"session_id":"code-a"}}"#); + let open = parse(&kernel(r.consumer_frame(2, r#"{"jsonrpc":"2.0","id":"1","method":"session/open","params":{"session_id":"code-a"}}"#))); + to(&mut r, &json!({"jsonrpc":"2.0","id": open["id"], "result": {"opened": {"session_id": "code-a"}}}).to_string()); + // octos sends the opened session again as a `session/open` + // notification, with the whole capability list. + let opened = r#"{"jsonrpc":"2.0","method":"session/open","params":{"session_id":"code-a","capabilities":{"supported_methods":["turn/start","server/shutdown"]}}}"#; + let out = to(&mut r, opened); + assert_eq!(out.len(), 2); + assert_eq!(out[0], (1, opened.to_owned()), "an unscoped consumer gets the frame verbatim"); + assert_eq!(out[1].0, 2); + assert_eq!(parse(&out[1].1)["params"]["capabilities"]["supported_methods"], json!(["turn/start"])); + // A frame the scope leaves alone passes verbatim. + let delta = r#"{"jsonrpc":"2.0","method":"message/delta","params":{"session_id":"code-a","text":"hi"}}"#; + assert_eq!(to(&mut r, delta), vec![(1, delta.to_owned()), (2, delta.to_owned())]); + } + + /// [`CodeOnly`] that prepares each session with its exact tool list. + struct Prepared; + + impl Scope for Prepared { + fn request(&self, method: &str, params: &mut Value) -> Result<(), String> { + CodeOnly.request(method, params) + } + fn notification(&self, method: &str, session: Option<&str>) -> bool { + CodeOnly.notification(method, session) + } + fn result(&self, method: &str, result: &mut Value) { + CodeOnly.result(method, result) + } + fn rewrite(&self, method: &str, params: &Value) -> Option { + CodeOnly.rewrite(method, params) + } + fn prepare(&self, session: &str) -> Option { + Some(json!({"method": "session/tool_list/set", "params": {"session_id": session, "generic_tools": ["read_file"]}})) + } + } + + #[test] + fn a_scoped_consumers_session_is_prepared_before_its_frames_reach_the_kernel() { + let mut r = Router::default(); + r.attach_scoped(1, Arc::new(Prepared)); + let open = r#"{"jsonrpc":"2.0","id":"1","method":"session/open","params":{"session_id":"code-a"}}"#; + let host = parse(&kernel(r.consumer_frame(1, open))); + assert_eq!(host["method"], "session/tool_list/set", "the preparation goes first"); + assert_eq!(host["params"]["session_id"], "code-a"); + let turn = r#"{"jsonrpc":"2.0","id":"2","method":"turn/start","params":{"session_id":"code-a"}}"#; + assert_eq!(r.consumer_frame(1, turn), Routed::Held, "the session's frames wait"); + assert!(r.pending.is_empty(), "nothing went to the kernel yet"); + let released = r.kernel_frame(&json!({"jsonrpc":"2.0","id": host["id"], "result": {"version": 1}}).to_string()); + assert!(released.consumers.is_empty(), "the host's answer is nobody's"); + let methods: Vec = released.kernel.iter().map(|f| parse(f)["method"].clone()).collect(); + assert_eq!(methods, [json!("session/open"), json!("turn/start")], "released in order"); + assert_eq!(r.pending.len(), 2); + // Prepared: the next frame goes straight on. + let again = r#"{"jsonrpc":"2.0","id":"3","method":"turn/interrupt","params":{"session_id":"code-a"}}"#; + assert_eq!(parse(&kernel(r.consumer_frame(1, again)))["method"], "turn/interrupt"); + } + + #[test] + fn a_session_whose_preparation_failed_refuses_its_held_requests_and_tries_again() { + let mut r = Router::default(); + r.attach_scoped(1, Arc::new(Prepared)); + let open = r#"{"jsonrpc":"2.0","id":"1","method":"session/open","params":{"session_id":"code-a"}}"#; + let host = parse(&kernel(r.consumer_frame(1, open))); + assert_eq!(r.consumer_frame(1, r#"{"jsonrpc":"2.0","method":"turn/steer","params":{"session_id":"code-a"}}"#), Routed::Held); + let refused = r.kernel_frame(&json!({"jsonrpc":"2.0","id": host["id"], "error": {"code": -32602, "message": "no"}}).to_string()); + assert!(refused.kernel.is_empty(), "nothing held reaches the kernel"); + assert_eq!(refused.consumers.len(), 1, "the request is answered; the notification is dropped"); + let answer = parse(&refused.consumers[0].1); + assert_eq!((answer["id"].clone(), answer["error"]["code"].clone()), (json!("1"), json!(SCOPE_DENIED))); + // Not prepared: the next frame prepares it again. + assert_eq!(parse(&kernel(r.consumer_frame(1, open)))["method"], "session/tool_list/set"); + } + + #[test] + fn a_host_only_frame_about_a_scoped_consumers_session_goes_to_the_shell() { + let mut r = Router::default(); + r.attach(1); + r.attach_scoped(2, Arc::new(CodeOnly)); + r.consumer_frame(2, r#"{"jsonrpc":"2.0","id":"1","method":"turn/start","params":{"session_id":"code-a"}}"#); + let call = r#"{"jsonrpc":"2.0","id":"h1","method":"peer/tool/call","params":{"session_id":"code-a"}}"#; + assert_eq!(to(&mut r, call).iter().map(|x| x.0).collect::>(), vec![1], "the shell's, not the app's"); + assert_eq!(r.consumer_frame(2, r#"{"jsonrpc":"2.0","id":"h1","result":{}}"#), Routed::Drop, "nor may the app answer it"); + } + + #[test] + fn a_scoped_consumers_refused_request_is_answered_here_and_never_reaches_the_kernel() { + let mut r = Router::default(); + r.attach_scoped(1, Arc::new(CodeOnly)); + match r.consumer_frame(1, r#"{"jsonrpc":"2.0","id":"9","method":"server/shutdown","params":{}}"#) { + Routed::Answer(frame) => { + let v = parse(&frame); + assert_eq!(v["id"], "9", "answered with the consumer's own id"); + assert_eq!(v["error"]["code"], SCOPE_DENIED); + assert_eq!(v["error"]["data"]["kind"], "scope_denied"); + } + other => panic!("{other:?}"), + } + let other = r#"{"jsonrpc":"2.0","id":"10","method":"turn/start","params":{"session_id":"_main:x"}}"#; + assert!(matches!(r.consumer_frame(1, other), Routed::Answer(_)), "a session that is not its own"); + // A refused notification has no id to answer: dropped. + assert_eq!(r.consumer_frame(1, r#"{"jsonrpc":"2.0","method":"peer/control","params":{}}"#), Routed::Drop); + let ok = parse(&kernel(r.consumer_frame(1, r#"{"jsonrpc":"2.0","id":"11","method":"turn/start","params":{"session_id":"code-a"}}"#))); + assert_ne!(ok["id"], "11", "an allowed request goes on with a kernel id"); + assert_eq!(r.pending.len(), 1, "refusals leave nothing waiting"); + // It named only its own session. + assert_eq!(r.sessions.keys().collect::>(), ["code-a"]); + } + + #[test] + fn a_scoped_consumer_hears_only_its_own_sessions_and_what_its_scope_lets_through() { + let mut r = Router::default(); + r.attach(1); + r.attach_scoped(2, Arc::new(CodeOnly)); + r.consumer_frame(2, r#"{"jsonrpc":"2.0","id":"1","method":"turn/start","params":{"session_id":"code-a"}}"#); + let own = r#"{"jsonrpc":"2.0","method":"message/delta","params":{"session_id":"code-a"}}"#; + assert_eq!(to(&mut r, own).iter().map(|x| x.0).collect::>(), vec![2]); + // A session nobody named reaches the unscoped consumers only. + let other = r#"{"jsonrpc":"2.0","method":"message/delta","params":{"session_id":"_main:b"}}"#; + assert_eq!(to(&mut r, other).iter().map(|x| x.0).collect::>(), vec![1]); + // About no session: as its scope says. + assert_eq!(to(&mut r, r#"{"jsonrpc":"2.0","method":"server/heartbeat","params":{}}"#).len(), 2); + let staged = r#"{"jsonrpc":"2.0","method":"peer/staged","params":{}}"#; + assert_eq!(to(&mut r, staged).iter().map(|x| x.0).collect::>(), vec![1]); + } + + #[test] + fn a_scoped_consumers_replies_pass_its_filter_and_it_answers_only_what_it_was_asked() { + let mut r = Router::default(); + r.attach_scoped(1, Arc::new(CodeOnly)); + let list = parse(&kernel(r.consumer_frame(1, r#"{"jsonrpc":"2.0","id":"1","method":"session/list","params":{}}"#))); + let reply = json!({"jsonrpc":"2.0","id": list["id"], "result": {"sessions": [{"id":"code-a"},{"id":"_main:sys"}]}}); + let out = to(&mut r, &reply.to_string()); + assert_eq!(parse(&out[0].1)["result"]["sessions"], json!([{"id":"code-a"}])); + // A response to a kernel request it was never sent: dropped. + assert_eq!(r.consumer_frame(1, r#"{"jsonrpc":"2.0","id":"q1","result":{}}"#), Routed::Drop); + // One it was sent, about its own session: forwarded, once. + r.consumer_frame(1, r#"{"jsonrpc":"2.0","id":"2","method":"session/open","params":{"session_id":"code-a"}}"#); + let ask = r#"{"jsonrpc":"2.0","id":"q2","method":"user_question/ask","params":{"session_id":"code-a"}}"#; + assert_eq!(to(&mut r, ask).len(), 1); + let answer = r#"{"jsonrpc":"2.0","id":"q2","result":{"answer":"yes"}}"#; + assert_eq!(r.consumer_frame(1, answer), Routed::Kernel(answer.to_owned())); + assert_eq!(r.consumer_frame(1, answer), Routed::Drop, "once"); + r.detach(1); + assert!(r.asked.is_empty() && r.scopes.is_empty()); } } diff --git a/crates/shell/Cargo.toml b/crates/shell/Cargo.toml index 8284f602..4e17faaf 100644 --- a/crates/shell/Cargo.toml +++ b/crates/shell/Cargo.toml @@ -103,6 +103,7 @@ makepad-clock = { workspace = true, optional = true } makepad-notes = { workspace = true, optional = true } makepad-reminders = { workspace = true, optional = true } makepad-weather = { workspace = true, optional = true } +octoscode-module = { workspace = true, features = ["octosense-module"], optional = true } # END native-apps: deps # The host secrets API keeps apps' secrets in the keychain (app_storage/secrets.rs). @@ -141,7 +142,7 @@ octos-core = ["octosense-ai-host/octos-core"] # assistant is the shell's (they imply `octos-core`); App Hub brings the host # services its system apps call. AppCard is opt-in only, on every target. # BEGIN native-apps: features (generated by tools/native_apps.py from native-apps.json; do not edit) -mobile-apps = ["app-rinx", "app-reference", "app-sheets", "app-hub", "app-calculator", "app-clock", "app-notes", "app-reminders", "app-weather", "octos-core"] +mobile-apps = ["app-rinx", "app-reference", "app-sheets", "app-hub", "app-calculator", "app-clock", "app-notes", "app-reminders", "app-weather", "app-octoscode", "octos-core"] app-rinx = ["dep:rinx", "octos-core"] app-reference = ["dep:octosense-reference"] app-sheets = ["dep:makepad-sheets"] @@ -153,6 +154,7 @@ app-clock = ["dep:makepad-clock"] app-notes = ["dep:makepad-notes"] app-reminders = ["dep:makepad-reminders"] app-weather = ["dep:makepad-weather"] +app-octoscode = ["dep:octoscode-module", "octos-core"] # END native-apps: features # The aichat module, seated in the pane in-process. app-aichat = ["dep:makepad-aichat"] diff --git a/crates/shell/src/coding_scope.rs b/crates/shell/src/coding_scope.rs new file mode 100644 index 00000000..a4945ed3 --- /dev/null +++ b/crates/shell/src/coding_scope.rs @@ -0,0 +1,509 @@ +//! The coding scope (ADR 0003, "An app that is an octos client"): what an +//! app whose entry names `kernel: "coding"` (OctosCode) may do through its +//! kernel port. The kernel router holds the app's connection to it on every +//! frame. Over the private pipe octos applies none of its external-client +//! checks and sees the shell's authority, so this is the whole boundary: +//! what is not allowed here is refused. +//! +//! - **Its own sessions only.** Every string under a key naming a session, +//! at any depth, is one of the app's: `_main:api:code-…`, with no topic +//! (a `#peer-…` topic would take an app agent's queued input). The id is +//! the kernel's durable key, so the shell keeps no record and a restart +//! loses nothing. Every profile named is `_main`; no topic, sandbox, +//! origin, tool context or host token is set. +//! - **Its own folders.** A working folder (`cwd`) is the app's own jail, or +//! on a desktop a folder under a root its entry's `storage.external` +//! grants read-write (the person's home for OctosCode). octos fences a +//! session's file tools to its working folder, so the whole tree counts: +//! never OctoSense's own data or the kernel's, a hidden folder at the top +//! of the home, nor a folder that holds any of them (the home itself). It +//! is resolved here and sent resolved. +//! - **A coding agent's tools.** Before the app's first frame about a +//! session, the router sets that session's exact kernel tool list +//! ([`CODING_TOOLS`]): reading and editing files in the session's +//! workspace, search, the web, asking the person and memory. No command +//! runs: octos's own shell asks only about a few patterns, so commands +//! wait for a shell-run tool approved per command. +//! - **What it hears.** Only frames about its own sessions, and none of the +//! host's (`peer/…`: the shell's relay answers those). Lists answer its +//! own sessions, and the methods it is told the kernel supports are the +//! ones it may call. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use octosense_ai_host::kernel::Scope; +use serde_json::{json, Value}; + +/// The prefix of every session the app may name. +pub const OWN_SESSION_PREFIX: &str = "_main:api:code-"; + +/// Each coding session's exact kernel tools: octos's own list for an +/// external client's turn, none of which runs a command. +pub const CODING_TOOLS: &[&str] = octosense_ai_host::kernel::system_tools::EXTERNAL_TURN_TOOLS; + +/// Methods that name no session: their answers are not another session's +/// (filtered where they could be). +const SESSIONLESS: &[&str] = &[ + "session/list", + "config/capabilities/list", + "profile/skills/list", + "memory/overview", + "memory/entity", + "memory/search", + "memory/load", +]; + +/// Methods on one of the app's own sessions (`session_id` required: without +/// one, octos answers for the whole profile). +const ON_OWN_SESSION: &[&str] = &[ + "session/open", + "session/hydrate", + "session/status/read", + "session/btw", + "session/rollback", + "session/compact", + "session/compact/mode/set", + "session/delete", + "session/fork", + "session/goal/get", + "session/goal/set", + "session/goal/clear", + "thread/graph/get", + "turn/start", + "turn/steer", + "turn/interrupt", + "turn/state/get", + "approval/respond", + "approval/scopes/list", + "user_question/respond", + "diff/preview/get", + "snapshot/list", + "snapshot/restore", + "tool/status/list", + "mcp/status/list", + "skill/action/job/list", + "review/start", + "permission/profile/list", + "profile/llm/list", + "task/list", + "task/output/read", + "task/artifact/list", + "task/artifact/read", + "task/cancel", + "agent/list", + "agent/status/read", + "agent/output/read", + "agent/artifact/list", + "agent/artifact/read", + "agent/interrupt", + "agent/close", + "loop/create", + "loop/list", + "loop/delete", + "loop/pause", + "loop/resume", + "loop/fire_now", + "monitor/list", + "monitor/pause", + "monitor/delete", +]; + +/// Methods that may name a working folder. (`launch/resolve` is not one of +/// the app's: it could pick another client's session for a folder, so the +/// app opens a session of its own there instead.) +const WITH_CWD: &[&str] = &["session/open", "session/list"]; + +/// Keys an app may not set at any depth: a topic (folded into the session +/// key), a sandbox override, an origin, a tool context, the host's token. +const FORBIDDEN_KEYS: &[&str] = &["topic", "sandbox", "origin", "tool_context", "host_token"]; + +/// The scope for `app`'s kernel port. +pub fn for_app(app: &str) -> Arc { + Arc::new(CodingScope { app: app.to_string(), roots: Roots::for_app(app) }) +} + +/// Whether the app may call `method` at all. +pub fn allows(method: &str) -> bool { + SESSIONLESS.contains(&method) || ON_OWN_SESSION.contains(&method) +} + +/// Whether `session` is one of the app's: `_main:api:code-`, no topic. +pub fn is_own_session(session: &str) -> bool { + session.strip_prefix(OWN_SESSION_PREFIX).is_some_and(|id| { + !id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) + }) +} + +struct CodingScope { + app: String, + roots: Roots, +} + +impl Scope for CodingScope { + fn request(&self, method: &str, params: &mut Value) -> Result<(), String> { + check(method, params, &self.roots).map_err(|why| format!("{} may not call {method}: {why}", self.app)) + } + + fn notification(&self, method: &str, session: Option<&str>) -> bool { + !method.starts_with("peer/") && session.is_some_and(|s| is_own_session(s.split('#').next().unwrap_or(s))) + } + + fn result(&self, method: &str, result: &mut Value) { + if method == "session/list" { + if let Some(sessions) = result.get_mut("sessions").and_then(Value::as_array_mut) { + // octos names a listed session by its key, or without `_main:api:`. + sessions.retain(|s| { + s.get("id").and_then(Value::as_str).is_some_and(|id| is_own_session(id) || is_own_session(&format!("_main:api:{id}"))) + }); + } + } + trim_supported_methods(result); + } + + // octos sends an opened session again as a `session/open` notification + // (`SessionOpened`) with its whole capability list: trim it as a result's. + fn rewrite(&self, _method: &str, params: &Value) -> Option { + names_methods(params).then(|| { + let mut params = params.clone(); + trim_supported_methods(&mut params); + params + }) + } + + fn prepare(&self, session: &str) -> Option { + is_own_session(session).then(|| { + json!({"method": "session/tool_list/set", "params": {"session_id": session, "generic_tools": CODING_TOOLS}}) + }) + } +} + +/// The checks of one request; `params` gets the resolved working folder. +fn check(method: &str, params: &mut Value, roots: &Roots) -> Result<(), String> { + if !allows(method) { + return Err("not a coding client's method".into()); + } + if !params.is_object() { + return Err("its params must be an object".into()); + } + if let Some(key) = forbidden_key(params) { + return Err(format!("it may not set {key}")); + } + let mut sessions = Vec::new(); + session_ids(params, false, &mut sessions); + if let Some(other) = sessions.iter().find(|s| !is_own_session(s)) { + return Err(format!("session {other} is not this app's")); + } + let mut profiles = Vec::new(); + profile_values(params, &mut profiles); + if let Some(other) = profiles.iter().find(|p| *p != "_main") { + return Err(format!("profile {other} is not this app's")); + } + if ON_OWN_SESSION.contains(&method) && params.get("session_id").and_then(Value::as_str).is_none() { + return Err("it names none of its sessions".into()); + } + if method == "session/fork" { + let child = params.get("new_chat_id").and_then(Value::as_str).unwrap_or_default(); + if !is_own_session(&format!("_main:api:{child}")) { + return Err(format!("a forked session's id must start with code- (got {child:?})")); + } + } + if has_bad_media(params) { + return Err("turn media must be upload handles".into()); + } + match params.get("cwd").filter(|cwd| !cwd.is_null()) { + Some(_) if !WITH_CWD.contains(&method) => Err("it may not set a working folder here".into()), + Some(cwd) => { + let cwd = cwd.as_str().ok_or("cwd must be a path")?; + let resolved = roots.resolve(cwd)?; + params["cwd"] = Value::String(resolved.to_string_lossy().into_owned()); + Ok(()) + } + None => Ok(()), + } +} + +/// The first forbidden key set (non-null) at any depth, or a `cwd` below the +/// top level. +fn forbidden_key(value: &Value) -> Option { + fn walk(value: &Value, top: bool) -> Option { + match value { + Value::Array(items) => items.iter().find_map(|item| walk(item, false)), + Value::Object(map) => map.iter().find_map(|(key, item)| { + let lower = key.to_ascii_lowercase(); + let forbidden = FORBIDDEN_KEYS.iter().any(|k| lower.contains(k)) || (lower == "cwd" && !top); + if forbidden && !item.is_null() { + Some(key.clone()) + } else { + walk(item, false) + } + }), + _ => None, + } + } + walk(value, true) +} + +/// Every string under a key containing `session`, at any depth (octos's own +/// rule for an external client). +fn session_ids(value: &Value, under: bool, out: &mut Vec) { + match value { + Value::String(text) if under => out.push(text.clone()), + Value::Array(items) => items.iter().for_each(|item| session_ids(item, under, out)), + Value::Object(map) => { + for (key, item) in map { + session_ids(item, under || key.to_ascii_lowercase().contains("session"), out); + } + } + _ => {} + } +} + +/// Every string under a key containing `profile`, at any depth. +fn profile_values(value: &Value, out: &mut Vec) { + match value { + Value::Array(items) => items.iter().for_each(|item| profile_values(item, out)), + Value::Object(map) => { + for (key, item) in map { + match item.as_str() { + Some(profile) if key.to_ascii_lowercase().contains("profile") => out.push(profile.to_owned()), + _ => profile_values(item, out), + } + } + } + _ => {} + } +} + +/// Turn media that are not upload handles (`up/…`, no `..`): a raw path +/// would reach the model. +fn has_bad_media(value: &Value) -> bool { + match value { + Value::Array(items) => items.iter().any(has_bad_media), + Value::Object(map) => map.iter().any(|(key, item)| { + if key == "media" { + return item.as_array().is_some_and(|media| { + media.iter().any(|entry| { + let path = entry.get("path").and_then(Value::as_str).or(entry.as_str()).unwrap_or(""); + !path.starts_with("up/") || path.contains("..") + }) + }); + } + has_bad_media(item) + }), + _ => false, + } +} + +/// Whether `value` holds a `supported_methods` list anywhere. +fn names_methods(value: &Value) -> bool { + match value { + Value::Array(items) => items.iter().any(names_methods), + Value::Object(map) => map.iter().any(|(key, item)| key == "supported_methods" || names_methods(item)), + _ => false, + } +} + +/// Keep only the methods the app may call in any `supported_methods` list. +fn trim_supported_methods(value: &mut Value) { + match value { + Value::Array(items) => items.iter_mut().for_each(trim_supported_methods), + Value::Object(map) => { + for (key, item) in map.iter_mut() { + if key == "supported_methods" { + if let Some(methods) = item.as_array_mut() { + methods.retain(|m| m.as_str().is_some_and(allows)); + continue; + } + } + trim_supported_methods(item); + } + } + _ => {} + } +} + +/// The folders the app may work in. +#[derive(Debug, Default)] +struct Roots { + /// The app's own jail. + jail: Option, + /// Read-write roots its entry grants (desktop only). + granted: Vec, + /// Never, even under a granted root: OctoSense's own data, the kernel's + /// home, and the hidden folders at the top of the person's home. + denied: Vec, + /// The person's home, whose hidden top-level folders are denied. + home: Option, +} + +impl Roots { + fn for_app(app: &str) -> Roots { + let canonical = |p: PathBuf| std::fs::canonicalize(&p).unwrap_or(p); + let jail = crate::app_storage::host().and_then(|s| s.layout().app(app).ok()).map(|paths| paths.jail); + let mut roots = Roots { jail: jail.map(canonical), ..Roots::default() }; + roots.denied.push(canonical(crate::octosense::paths::home())); + if let Some(core) = octosense_ai_host::kernel::core_dir() { + roots.denied.push(canonical(core)); + } + if cfg!(any(target_os = "macos", target_os = "linux", target_os = "windows")) && !cfg!(target_env = "ohos") { + if let Some(home) = crate::sandbox::person_home() { + let entry = crate::native_apps::find(app); + for grant in entry.map(|a| a.external).unwrap_or_default() { + if let Some((path, crate::sandbox::Access::ReadWrite)) = crate::sandbox::parse_external(grant, &home) { + roots.granted.push(canonical(path)); + } + } + roots.denied.push(canonical(home.join("Library"))); + roots.home = Some(canonical(home)); + } + } + roots + } + + /// `cwd` resolved (`~` expanded, links followed), if the app may work + /// there. + fn resolve(&self, cwd: &str) -> Result { + let expanded = match (cwd.strip_prefix('~'), &self.home) { + (Some(rest), Some(home)) if rest.is_empty() || rest.starts_with('/') => home.join(rest.trim_start_matches('/')), + _ => PathBuf::from(cwd), + }; + if !expanded.is_absolute() { + return Err(format!("the working folder {cwd:?} is not an absolute path")); + } + let resolved = std::fs::canonicalize(&expanded).map_err(|e| format!("the working folder {cwd:?}: {e}"))?; + if !resolved.is_dir() { + return Err(format!("the working folder {cwd:?} is not a folder")); + } + if self.jail.as_ref().is_some_and(|jail| resolved.starts_with(jail)) { + return Ok(resolved); + } + if !self.granted.iter().any(|root| resolved.starts_with(root)) { + return Err(format!("{cwd:?} is outside the folders this app may work in")); + } + // In, or holding, a denied folder; the home itself or above it (it + // holds the hidden folders); a hidden folder at the top of the home. + let touches = |denied: &PathBuf| resolved.starts_with(denied) || denied.starts_with(&resolved); + if self.denied.iter().any(touches) + || self.home.as_ref().is_some_and(|home| home.starts_with(&resolved)) + || self.hidden_at_top_of_home(&resolved) + { + return Err(format!("{cwd:?} is a folder this app may not work in")); + } + Ok(resolved) + } + + /// Whether `path` is in a hidden folder at the top of the person's home + /// (`~/.ssh`, `~/.config`, …). + fn hidden_at_top_of_home(&self, path: &Path) -> bool { + let Some(home) = &self.home else { return false }; + path.strip_prefix(home) + .ok() + .and_then(|rest| rest.components().next()) + .is_some_and(|first| first.as_os_str().to_string_lossy().starts_with('.')) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("coding-scope-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::canonicalize(&dir).unwrap() + } + + /// A person's home with a project, a hidden folder and OctoSense's own + /// data inside it, granted as OctosCode's entry grants it. + fn roots(home: &Path) -> Roots { + for dir in ["code/app", ".ssh", "octosense/apps/octoscode", "elsewhere"] { + std::fs::create_dir_all(home.join(dir)).unwrap(); + } + Roots { + jail: Some(home.join("octosense/apps/octoscode")), + granted: vec![home.to_path_buf()], + denied: vec![home.join("octosense")], + home: Some(home.to_path_buf()), + } + } + + fn checked(method: &str, params: Value, roots: &Roots) -> Result { + let mut params = params; + check(method, &mut params, roots).map(|()| params) + } + + #[test] + fn only_its_own_sessions_without_a_topic_count_as_its_own() { + assert!(is_own_session("_main:api:code-1f2e")); + for other in ["_main:api:octosense#system", "_main:main", "_main:api:code-1#peer-mail", "work:api:code-1", "_main:api:code-", "code-1"] { + assert!(!is_own_session(other), "{other}"); + } + } + + #[test] + fn a_request_naming_another_session_profile_or_host_parameter_is_refused() { + let roots = Roots::default(); + let own = json!({"session_id": "_main:api:code-a"}); + assert!(checked("turn/start", own.clone(), &roots).is_ok()); + assert!(checked("turn/start", json!({"session_id": "_main:api:octosense#system"}), &roots).is_err(), "the system conversation"); + assert!(checked("turn/start", json!({"session_id": "_main:api:code-a", "target": {"parent_session": "_main:x"}}), &roots).is_err(), "at any depth"); + assert!(checked("turn/start", json!({"session_id": "_main:api:code-a", "topic": "peer-mail"}), &roots).is_err()); + assert!(checked("turn/start", json!({"session_id": "_main:api:code-a", "sandbox": {"read_allow_paths": ["/"]}}), &roots).is_err()); + assert!(checked("turn/start", json!({"session_id": "_main:api:code-a", "profile_id": "work"}), &roots).is_err()); + assert!(checked("turn/start", json!({"session_id": "_main:api:code-a", "media": [{"path": "/etc/passwd"}]}), &roots).is_err()); + assert!(checked("turn/start", json!({"session_id": "_main:api:code-a", "media": [{"path": "up/a.png"}]}), &roots).is_ok()); + assert!(checked("task/list", json!({}), &roots).is_err(), "a profile-wide list"); + for method in ["server/shutdown", "client_hello", "profile/llm/upsert", "peer/dispatch", "monitor/create", "session/tool_list/set", "permission/profile/set", "launch/resolve"] { + assert!(checked(method, own.clone(), &roots).is_err(), "{method}"); + } + assert!(checked("session/fork", json!({"session_id": "_main:api:code-a", "new_chat_id": "code-b"}), &roots).is_ok()); + assert!(checked("session/fork", json!({"session_id": "_main:api:code-a", "new_chat_id": "octosense#system"}), &roots).is_err()); + } + + #[test] + fn a_working_folder_is_its_jail_or_under_a_granted_root_and_is_sent_resolved() { + let home = scratch("roots"); + let roots = roots(&home); + let open = |cwd: &str| checked("session/open", json!({"session_id": "_main:api:code-a", "cwd": cwd}), &roots); + let project = open(&home.join("code/app/../app").to_string_lossy()).unwrap(); + assert_eq!(project["cwd"], home.join("code/app").to_string_lossy().as_ref(), "resolved"); + assert!(open(&home.join("octosense/apps/octoscode").to_string_lossy()).is_ok(), "its own jail"); + assert!(open(&home.join("octosense").to_string_lossy()).is_err(), "OctoSense's own data"); + assert!(open(&home.join(".ssh").to_string_lossy()).is_err(), "a hidden folder at the top of the home"); + assert!(open(&home.to_string_lossy()).is_err(), "the home holds its hidden folders"); + assert!(open(&home.join("elsewhere/..").to_string_lossy()).is_err(), "resolved first: the home again"); + assert!(open("/").is_err(), "outside every granted root"); + assert!(open("code/app").is_err(), "not absolute"); + assert!(open(&home.join("missing").to_string_lossy()).is_err()); + assert!(checked("turn/start", json!({"session_id": "_main:api:code-a", "cwd": home.join("code").to_string_lossy()}), &roots).is_err(), "only where a folder is chosen"); + let jail_only = Roots { granted: Vec::new(), ..roots }; + assert!(checked("session/open", json!({"session_id": "_main:api:code-a", "cwd": home.join("code/app").to_string_lossy()}), &jail_only).is_err(), "a phone: its jail only"); + let _ = std::fs::remove_dir_all(&home); + } + + #[test] + fn it_hears_only_its_own_sessions_lists_only_them_and_is_told_only_what_it_may_call() { + let scope = CodingScope { app: "octoscode".into(), roots: Roots::default() }; + assert!(scope.notification("message/delta", Some("_main:api:code-a"))); + assert!(!scope.notification("message/delta", Some("_main:api:octosense#system"))); + assert!(!scope.notification("peer/tool/call", Some("_main:api:code-a")), "the shell's relay answers those"); + assert!(!scope.notification("background/activity", None)); + let mut list = json!({"sessions": [{"id": "_main:api:code-a"}, {"id": "code-b"}, {"id": "octosense#system"}, {"id": "_main:api:peer-x"}]}); + scope.result("session/list", &mut list); + assert_eq!(list["sessions"], json!([{"id": "_main:api:code-a"}, {"id": "code-b"}])); + let mut caps = json!({"capabilities": {"supported_methods": ["turn/start", "server/shutdown", "peer/dispatch", "session/list"]}}); + scope.result("config/capabilities/list", &mut caps); + assert_eq!(caps["capabilities"]["supported_methods"], json!(["turn/start", "session/list"])); + // The opened session again, as a notification: told the same. + let opened = json!({"session_id": "_main:api:code-a", "capabilities": {"supported_methods": ["turn/start", "profile/llm/select", "session/list"]}}); + assert_eq!(scope.rewrite("session/open", &opened).unwrap()["capabilities"]["supported_methods"], json!(["turn/start", "session/list"])); + assert!(scope.rewrite("message/delta", &json!({"session_id": "_main:api:code-a", "text": "hi"})).is_none(), "anything else passes as it is"); + let prepared = scope.prepare("_main:api:code-a").unwrap(); + assert_eq!(prepared["method"], "session/tool_list/set"); + assert_eq!(prepared["params"]["generic_tools"], json!(CODING_TOOLS)); + assert!(!CODING_TOOLS.iter().any(|t| matches!(*t, "shell" | "bash" | "exec_command" | "monitor_create" | "spawn")), "no command runs"); + assert!(scope.prepare("_main:api:octosense#system").is_none()); + } +} diff --git a/crates/shell/src/kernel_port.rs b/crates/shell/src/kernel_port.rs new file mode 100644 index 00000000..f163961a --- /dev/null +++ b/crates/shell/src/kernel_port.rs @@ -0,0 +1,45 @@ +//! The shell's end of an app's kernel port (Makepad's `OctosUiPort`; ADR +//! 0003, "An app that is an octos client"). +//! +//! An app that is itself an octos client (OctosCode) speaks the kernel's UI +//! protocol. Its module instance opens a port; the module host claims the +//! port for that instance and, when the app's `native-apps.json` entry names +//! a `kernel` port, connects it here: one kernel connection held to the +//! app's scope, which the kernel router enforces on every frame, served on +//! the kernel's own runtime (`octosense_kernel::serve_scoped`). A port the +//! entry does not grant is closed at once ([`refuse`]). +//! +//! The port outlives a kernel restart: the kernel connects again behind it +//! and the app is sent `Reset`, after which it opens its sessions again. Any +//! other end of the kernel closes the port, as does closing the instance +//! (dropping the bridge). + +use makepad_ai_services::ui_port::{UiPortEvent, UiPortLink}; + +/// Close a port its app may not have, saying why. +pub fn refuse(link: UiPortLink, reason: &str) { + link.down.send(UiPortEvent::Closed { reason: reason.to_string() }); +} + +/// A port connected to the kernel. Dropping it ends the connection. +pub struct KernelPortBridge { + #[cfg(kernel)] + _served: octosense_ai_host::kernel::PortHandle, +} + +#[cfg(kernel)] +impl KernelPortBridge { + /// Connect `link`, opened by an instance of `app`, held to `scope`. + pub fn open(app: &str, link: UiPortLink, scope: std::sync::Arc) -> KernelPortBridge { + use octosense_ai_host::kernel::{self, PortEvent}; + let UiPortLink { up, down } = link; + let deliver = Box::new(move |event: PortEvent| { + down.send(match event { + PortEvent::Frame(frame) => UiPortEvent::Frame(frame), + PortEvent::Reset(reason) => UiPortEvent::Reset { reason }, + PortEvent::Closed(reason) => UiPortEvent::Closed { reason }, + }) + }); + KernelPortBridge { _served: kernel::serve_scoped(app, scope, up, deliver) } + } +} diff --git a/crates/shell/src/lib.rs b/crates/shell/src/lib.rs index 3b39c225..14760131 100644 --- a/crates/shell/src/lib.rs +++ b/crates/shell/src/lib.rs @@ -51,6 +51,9 @@ pub mod dock_warp; pub mod host; pub mod host_tools; pub mod hub; +#[cfg(kernel)] +pub mod coding_scope; +pub mod kernel_port; pub mod layout; pub mod octosense; pub mod module_host; @@ -63,6 +66,8 @@ mod module_panic_tests; #[cfg(test)] mod module_peer_tests; #[cfg(test)] +mod module_port_tests; +#[cfg(test)] mod module_resize_tests; #[cfg(test)] mod system_app_theme_tests; diff --git a/crates/shell/src/module_host.rs b/crates/shell/src/module_host.rs index 5daf5297..11cdf2f3 100644 --- a/crates/shell/src/module_host.rs +++ b/crates/shell/src/module_host.rs @@ -126,6 +126,15 @@ pub struct OpenedPeerLinks { links: Vec<(SplashVmId, makepad_ai_services::peer::PeerLink)>, } +/// Kernel ports module code opened (Makepad's `OctosUiPort::open`, which +/// parks the host's end in `PendingUiPorts`), each with the isolate whose +/// code was running, exactly as [`OpenedPeerLinks`]. [`ModuleHost::pump_peer_links`] +/// connects each that its app's entry grants ([`crate::kernel_port`]). +#[derive(Default)] +pub struct OpenedUiPorts { + ports: Vec<(SplashVmId, makepad_ai_services::ui_port::UiPortLink)>, +} + /// The isolates whose module code is running now, innermost last: a /// `contain` inside another (a host call a module's code made) must not /// take the outer module's links as stray or as its own. @@ -143,6 +152,10 @@ fn enter_isolate(cx: &mut Cx, vm_id: SplashVmId) { if !stray.is_empty() { log!("wm: {} peer link(s) opened outside any module instance dropped", stray.len()); } + for port in cx.global::().take() { + crate::kernel_port::refuse(port, "opened outside any app"); + log!("wm: a kernel port opened outside any module instance closed"); + } } } cx.global::().0.push(vm_id); @@ -157,12 +170,17 @@ fn leave_isolate(cx: &mut Cx, vm_id: SplashVmId) { } } -/// After module code of isolate `vm_id` ran: the links it opened are its. +/// After module code of isolate `vm_id` ran: the links and kernel ports it +/// opened are its. fn claim_peer_links(cx: &mut Cx, vm_id: SplashVmId) { let links = cx.global::().take(); if !links.is_empty() { cx.global::().links.extend(links.into_iter().map(|link| (vm_id, link))); } + let ports = cx.global::().take(); + if !ports.is_empty() { + cx.global::().ports.extend(ports.into_iter().map(|port| (vm_id, port))); + } } /// Run `f` inside the module isolate `vm_id` with its panics contained: @@ -301,6 +319,9 @@ pub struct AppInstance { /// A link it opened without a granted agent: not served, but each of /// its requests is answered `no_agent` (not a link: [`ModuleHost::has_peer_link`] is false). refused_peer: Option, + /// Its kernel port, when its code opened Makepad's `OctosUiPort` and + /// its entry grants one (`kernel`): connected in the app's scope. + kernel_port: Option, /// The executor's manifest, read once (contained) at creation: the /// shell asks for it again after a failure, when the executor is gone. manifest: ServiceManifest, @@ -494,6 +515,19 @@ fn apply_module_style(vm: &mut ScriptVm, sheet: &desktop_style::StyleSheet) { }); } +/// Point the preludes' `theme` at `mod.theme` again, right before a module +/// registers: styling an isolate (the sheet's phases, then the WM's palette) +/// leaves them naming an earlier theme object, while a stock isolate's name +/// the one theme. A module that adds a role of its own (OctosCode's +/// `color_text_muted`) assigns it to `mod.theme`, and its widgets read it +/// through the prelude's `theme`, so both must be the same object. +fn sync_prelude_theme(vm: &mut ScriptVm) { + script_eval!(vm, { + mod.prelude.widgets = {..mod.prelude.widgets, theme: mod.theme} + mod.prelude.widgets_internal = {..mod.prelude.widgets_internal, theme: mod.theme} + }); +} + impl ModuleHost { /// Build one instance of `module` for the client id the WM gave it. /// `viewport` is the tile size the layout will give it. @@ -567,6 +601,7 @@ impl ModuleHost { apply_module_style(vm, sheet); } makepad_wm_theme::apply(vm); + sync_prelude_theme(vm); module.register(vm); module.create(vm, open, handles) }); @@ -613,6 +648,7 @@ impl ModuleHost { assistant, peer: None, refused_peer: None, + kernel_port: None, manifest, failed: None, released: false, @@ -632,6 +668,7 @@ impl ModuleHost { apply_module_style(vm, sheet); vm.with_reload(|vm| { makepad_wm_theme::apply(vm); + sync_prelude_theme(vm); instance.module.register(vm); }); let source=instance.root.widget_type_id().and_then(|ty|vm.bx.heap.type_default_for_id(ty)).unwrap_or_else(||instance.root.script_source()); @@ -812,6 +849,32 @@ impl ModuleHost { instance.refused_peer = Some(link); } } + let ports = std::mem::take(&mut cx.global::().ports); + for (vm_id, port) in ports { + let Some(instance) = self.instances.values_mut().find(|i| i.vm_id == vm_id && i.failed.is_none()) else { + crate::kernel_port::refuse(port, "the app instance is gone"); + continue; + }; + if instance.kernel_port.is_some() { + crate::kernel_port::refuse(port, "one kernel port per app instance"); + log!("wm: {} opened a second kernel port; closed", instance.label()); + continue; + } + let app = instance.module.id(); + match crate::native_apps::find(app).map(|a| a.kernel) { + #[cfg(kernel)] + Some(crate::native_apps::KernelPort::Coding) => { + log!("wm: {} opened its kernel port (coding scope)", instance.label()); + instance.kernel_port = Some(crate::kernel_port::KernelPortBridge::open(app, port, crate::coding_scope::for_app(app))); + } + #[cfg(not(kernel))] + Some(crate::native_apps::KernelPort::Coding) => crate::kernel_port::refuse(port, "this build has no octos kernel"), + _ => { + log!("wm: {} opened a kernel port its entry does not grant; closed", instance.label()); + crate::kernel_port::refuse(port, &format!("{app} has no kernel port")); + } + } + } for instance in self.instances.values().filter(|i| i.failed.is_none()) { for link in instance.peer.iter().chain(instance.refused_peer.iter()) { for frame in link.take_up() { @@ -826,6 +889,12 @@ impl ModuleHost { self.instances.get(&client).is_some_and(|i| i.peer.is_some()) } + /// Whether `client` has a connected kernel port (it opened Makepad's + /// `OctosUiPort` and its entry grants one). + pub fn has_kernel_port(&self, client: ClientId) -> bool { + self.instances.get(&client).is_some_and(|i| i.kernel_port.is_some()) + } + /// Whether `client` is an instance whose module panicked. pub fn is_failed(&self, client: ClientId) -> bool { self.instances.get(&client).is_some_and(|i| i.failed.is_some()) @@ -1081,8 +1150,10 @@ impl ModuleHost { } /// The instance's peer link goes as a process's does when it exits: its -/// calls fail, its contexts close, its app's peer stays. +/// calls fail, its contexts close, its app's peer stays. Its kernel port +/// closes with it. fn close_peer_link(instance: &mut AppInstance) { + instance.kernel_port = None; instance.refused_peer = None; if instance.peer.take().is_some() { crate::peer_link::process_gone(instance.client); diff --git a/crates/shell/src/module_port_tests.rs b/crates/shell/src/module_port_tests.rs new file mode 100644 index 00000000..b5f1cb15 --- /dev/null +++ b/crates/shell/src/module_port_tests.rs @@ -0,0 +1,130 @@ +//! Kernel ports (ADR 0003, "An app that is an octos client"): a module that +//! opens Makepad's `OctosUiPort` gets it connected only when its entry in +//! native-apps.json names a `kernel` port; otherwise the host closes it and +//! says why. A port is the instance's whose code opened it, as a peer link +//! is (module_peer_tests.rs). + +use crate::module_host::ModuleHost; +use makepad_ai_services::ui_port::{OctosUiPort, PendingUiPorts, UiPortEvent}; +use makepad_ai_services::wire::{ServiceCall, ServiceManifest}; +use makepad_app_module::*; +use makepad_widgets::*; + +script_mod! { + use mod.prelude.widgets_internal.* + use mod.widgets.* + + mod.widgets.PortProbeBase = #(PortProbe::register_widget(vm)) + mod.widgets.PortProbe = set_type_default() do mod.widgets.PortProbeBase { + width: Fill + height: Fill + } +} + +/// A root that opens its kernel port when told to. +#[derive(Script, ScriptHook, Widget)] +pub struct PortProbe { + #[uid] + uid: WidgetUid, + #[source] + source: ScriptObjectRef, + #[walk] + walk: Walk, + #[layout] + layout: Layout, + #[redraw] + #[live] + draw_bg: DrawColor, + #[rust] + port: Option, +} + +impl Widget for PortProbe { + fn handle_event(&mut self, cx: &mut Cx, event: &Event, _scope: &mut Scope) { + if let Event::Custom(message) = event { + if message.as_str() == "open" { + self.port = Some(OctosUiPort::open(cx)); + } + } + } + + fn draw_walk(&mut self, _cx: &mut Cx2d, _scope: &mut Scope, _walk: Walk) -> DrawStep { + DrawStep::done() + } +} + +/// A probe under an id native-apps.json grants no kernel port. +struct PortProbeModule; + +impl AppModule for PortProbeModule { + fn id(&self) -> &'static str { + "port-probe" + } + fn label(&self) -> &'static str { + "Port probe" + } + fn capabilities(&self) -> &'static [&'static str] { + &[] + } + fn open_schema(&self) -> OpenSchema { + OpenSchema::new(1) + } + fn register(&self, vm: &mut ScriptVm) { + self::script_mod(vm); + } + fn create(&self, vm: &mut ScriptVm, _open: ValidatedOpen, _handles: InstanceHandles) -> InstanceParts { + let value = script_eval!(vm, { use mod.widgets.* PortProbe {} }); + InstanceParts { root: WidgetRef::script_from_value(vm, value), executor: Box::new(NoTools), shutdown: Box::new(|_| {}) } + } +} + +struct NoTools; +impl ServiceExecutor for NoTools { + fn manifest(&self) -> ServiceManifest { + ServiceManifest::new("port-probe", "Port probe", "test") + } + fn execute(&mut self, _cx: &mut Cx, call: &ServiceCall) -> ExecOutcome { + ExecOutcome::Done(makepad_ai_services::wire::ToolResult::unavailable(&call.call_id, "none")) + } +} + +static PROBE: PortProbeModule = PortProbeModule; + +fn setup() -> (Cx, ModuleHost) { + let mut cx = Cx::new(Box::new(|_, _| {})); + cx.with_vm(makepad_widgets::script_mod); + (cx, ModuleHost::default()) +} + +/// What the probe's port has heard. +fn heard(cx: &mut Cx, host: &mut ModuleHost, client: u64) -> Option { + host.dispatch(cx, client, "a test read", |_, root| root.borrow_mut::().unwrap().port.as_mut().and_then(|p| p.try_recv())) + .unwrap() +} + +#[test] +fn should_close_a_port_the_apps_entry_does_not_grant_and_say_why() { + let (mut cx, mut host) = setup(); + host.create(&mut cx, 951, &PROBE, PROBE.open_schema().empty_open().unwrap(), dvec2(400.0, 700.0)).unwrap(); + assert!(host.send_custom(&mut cx, 951, "open".into())); + host.pump_peer_links(&mut cx); + assert!(!host.has_kernel_port(951)); + assert_eq!(heard(&mut cx, &mut host, 951), Some(UiPortEvent::Closed { reason: "port-probe has no kernel port".into() })); + assert!(host.teardown(&mut cx, 951)); +} + +#[test] +fn should_close_a_port_parked_outside_any_module() { + let (mut cx, mut host) = setup(); + host.create(&mut cx, 952, &PROBE, PROBE.open_schema().empty_open().unwrap(), dvec2(400.0, 700.0)).unwrap(); + let (mut stray, link) = OctosUiPort::in_process(); + cx.global::().ports.push(link); + // The next module call finds it parked by nobody's code: closed, and + // not the instance's. + assert!(host.send_custom(&mut cx, 952, "hello".into())); + host.pump_peer_links(&mut cx); + assert!(cx.global::().ports.is_empty()); + assert!(!host.has_kernel_port(952)); + assert_eq!(stray.try_recv(), Some(UiPortEvent::Closed { reason: "opened outside any app".into() })); + assert!(host.teardown(&mut cx, 952)); +} diff --git a/crates/shell/src/native_apps.rs b/crates/shell/src/native_apps.rs index 87815d40..b1f408e9 100644 --- a/crates/shell/src/native_apps.rs +++ b/crates/shell/src/native_apps.rs @@ -49,6 +49,17 @@ pub enum Network { Any, } +/// The port to the shell's octos kernel an app that is itself an octos +/// client gets (`kernel`; ADR 0003). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum KernelPort { + /// None: a port the app opens is closed. + None, + /// The coding scope the kernel router enforces: the app's own + /// sessions, in workspaces the person picked. + Coding, +} + /// One `native-apps.json` entry, as far as the shell reads it. #[derive(Debug)] pub struct NativeApp { @@ -98,6 +109,8 @@ pub struct NativeApp { /// (`None`: the shell's defaults). pub calls_per_turn: Option, pub calls_per_day: Option, + /// `kernel`: its port to the shell's kernel. + pub kernel: KernelPort, } pub const APPS: &[NativeApp] = &[ @@ -126,6 +139,7 @@ pub const APPS: &[NativeApp] = &[ own_tools: &[], calls_per_turn: None, calls_per_day: None, + kernel: KernelPort::None, }, NativeApp { id: "reference", @@ -152,6 +166,7 @@ pub const APPS: &[NativeApp] = &[ own_tools: &[], calls_per_turn: None, calls_per_day: None, + kernel: KernelPort::None, }, NativeApp { id: "sheets", @@ -178,6 +193,7 @@ pub const APPS: &[NativeApp] = &[ own_tools: &[], calls_per_turn: None, calls_per_day: None, + kernel: KernelPort::None, }, NativeApp { id: "terminal", @@ -206,6 +222,7 @@ pub const APPS: &[NativeApp] = &[ own_tools: &["terminal.read_screen", "terminal.read_scrollback"], calls_per_turn: None, calls_per_day: None, + kernel: KernelPort::None, }, NativeApp { id: "appcard", @@ -232,6 +249,7 @@ pub const APPS: &[NativeApp] = &[ own_tools: &[], calls_per_turn: None, calls_per_day: None, + kernel: KernelPort::None, }, NativeApp { id: "apphub", @@ -258,6 +276,7 @@ pub const APPS: &[NativeApp] = &[ own_tools: &["apphub.search", "apphub.installed", "apphub.updates"], calls_per_turn: None, calls_per_day: None, + kernel: KernelPort::None, }, NativeApp { id: "calculator", @@ -284,6 +303,7 @@ pub const APPS: &[NativeApp] = &[ own_tools: &["calculator.eval"], calls_per_turn: None, calls_per_day: None, + kernel: KernelPort::None, }, NativeApp { id: "clock", @@ -310,6 +330,7 @@ pub const APPS: &[NativeApp] = &[ own_tools: &["clock.now"], calls_per_turn: None, calls_per_day: None, + kernel: KernelPort::None, }, NativeApp { id: "notes", @@ -336,6 +357,7 @@ pub const APPS: &[NativeApp] = &[ own_tools: &["notes.search", "notes.read"], calls_per_turn: None, calls_per_day: None, + kernel: KernelPort::None, }, NativeApp { id: "reminders", @@ -362,6 +384,7 @@ pub const APPS: &[NativeApp] = &[ own_tools: &["reminders.due", "reminders.list"], calls_per_turn: None, calls_per_day: None, + kernel: KernelPort::None, }, NativeApp { id: "weather", @@ -388,6 +411,34 @@ pub const APPS: &[NativeApp] = &[ own_tools: &["weather.current"], calls_per_turn: None, calls_per_day: None, + kernel: KernelPort::None, + }, + NativeApp { + id: "octoscode", + feature: "app-octoscode", + bin: None, + macos: Hosting::Module, + windows: Hosting::Module, + linux: Hosting::Module, + android: Hosting::Module, + ios: Hosting::Module, + ohos: Hosting::Module, + wasm: Hosting::Module, + octos: &[], + tools: &[], + network: Network::None, + processes: false, + accounts: false, + external: &["home:rw"], + storage: r#"{"accounts": false, "agent_workspace": "none", "external": ["home:rw"]}"#, + tools_json: r##"[]"##, + generic_tools: &[], + grants: &[], + system_tools: &[], + own_tools: &[], + calls_per_turn: None, + calls_per_day: None, + kernel: KernelPort::Coding, }, NativeApp { id: "task", @@ -414,6 +465,7 @@ pub const APPS: &[NativeApp] = &[ own_tools: &[], calls_per_turn: None, calls_per_day: None, + kernel: KernelPort::None, }, ]; @@ -496,4 +548,6 @@ pub fn link(out: &mut Vec<&'static dyn AppModule>) { out.push(&makepad_reminders::REMINDERS_MODULE); #[cfg(feature = "app-weather")] out.push(&makepad_weather::WEATHER_MODULE); + #[cfg(feature = "app-octoscode")] + out.push(&octoscode_module::OCTOSCODE_MODULE); } diff --git a/crates/shell/src/sandbox/tests.rs b/crates/shell/src/sandbox/tests.rs index 4dc2164d..5699d019 100644 --- a/crates/shell/src/sandbox/tests.rs +++ b/crates/shell/src/sandbox/tests.rs @@ -54,6 +54,7 @@ fn jail_only(root: &Path, hub_port: u16) -> Policy { own_tools: &[], calls_per_turn: None, calls_per_day: None, + kernel: crate::native_apps::KernelPort::None, }; // The scratch root stands in for the person's home: closed but for the // jail. The probe's tools are its program. diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index a470e04e..9b21bd58 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -49,7 +49,7 @@ octosense-shell = { workspace = true } # every standard build. `mobile-apps` is the set phone builds link, also # usable on the desktop; AppCard is not among them (`--features app-appcard`). # BEGIN native-apps: features (generated by tools/native_apps.py from native-apps.json; do not edit) -default = ["octos-core", "app-rinx", "app-terminal", "app-hub", "app-calculator", "app-clock", "app-notes", "app-reminders", "app-weather"] +default = ["octos-core", "app-rinx", "app-terminal", "app-hub", "app-calculator", "app-clock", "app-notes", "app-reminders", "app-weather", "app-octoscode"] mobile-apps = ["octosense-shell/mobile-apps"] app-rinx = ["octosense-shell/app-rinx"] app-reference = ["octosense-shell/app-reference"] @@ -62,6 +62,7 @@ app-clock = ["octosense-shell/app-clock"] app-notes = ["octosense-shell/app-notes"] app-reminders = ["octosense-shell/app-reminders"] app-weather = ["octosense-shell/app-weather"] +app-octoscode = ["octosense-shell/app-octoscode"] # END native-apps: features # The phone shell alone, on the desktop (Android sets it itself). mobile-only = ["octosense-shell/mobile-only"] diff --git a/docs/adr/0003-shared-octos-client-access.md b/docs/adr/0003-shared-octos-client-access.md index 92d4d80d..80a9efdc 100644 --- a/docs/adr/0003-shared-octos-client-access.md +++ b/docs/adr/0003-shared-octos-client-access.md @@ -139,6 +139,56 @@ separate kernel would be a different assistant with different memory. web client's scoped session agree. 8. **Upstream, not an overlay.** `--host-managed` is octos code (octos#2591); OctoSense carries no patch to octos. +9. **An app that is an octos client gets a kernel port in the coding scope** + *(2026-10-05; the person chose this over host authority and over a new + octos token class).* OctosCode (octos-org/octoscode-app) speaks the UI + protocol itself: standalone, it pairs with a server and holds its admin + token. The external token blocks most of what it does (a working folder, + the session list, skills, agents), and the shell's own authority is no + app's. So, inside OctoSense: + + - **A port, not a socket or a token.** The module opens Makepad's + `OctosUiPort`; the module host takes it for the instance that opened it + (as a peer link) and connects it to the kernel as one more consumer of + the private pipe, held to a scope (`octosense_kernel::serve_scoped`). + Nothing listens and no token exists. A kernel restart is a `Reset` on + the same port; the app opens its sessions again. Only an entry whose + `native-apps.json` block names `"kernel": "coding"` gets a port; any + other port is closed with the reason. + - **The router is the whole boundary.** Over the pipe octos applies none + of its external checks, so the router checks each of the app's frames + against the scope (`crates/shell/src/coding_scope.rs`) and answers a + refused one itself (`scope_denied`), default deny: + - its own sessions only: every string under a key naming a session, at + any depth, is `_main:api:code-` with no topic (a `#peer-…` topic + would take an app agent's queued input); the profile is `_main`; no + topic, sandbox, origin, tool context or host token is set. The id is + octos's durable key, so the shell keeps no record; + - its own folders: a working folder is the app's jail, or on a desktop + a folder under a root its entry grants read-write (`storage.external`, + the home for OctosCode), never OctoSense's or the kernel's data, a + hidden folder at the top of the home, nor a folder holding any of + them (octos fences a session's file tools to its folder). It is + resolved by the shell and sent resolved; + - the methods of a coding client: sessions, turns, approvals and + questions on its sessions, diffs, snapshots, tasks, agents, goals and + loops on its sessions, the skill list, memory reads. Never provider, + key or model settings (the AI providers app owns them), permission + profiles, skill installs, peers, monitors that start a command, + `client_hello` (it renegotiates the shared pipe) or `server/shutdown`. + - **A coding agent's tools.** Before the app's first frame about a + session, the router sets that session's exact tool list + (`session/tool_list/set`) and holds the session's frames until the + kernel confirms; a failure refuses them. The list is octos's own for an + external client's turn: files in the session's folder, search, the web, + questions to the person and memory. No command runs: octos's own shell + asks only about a few patterns, so commands wait for a shell-run tool + approved per command, behind Setup's Command execution switch. + - **What it hears.** Frames about its own sessions only; the host's + (`peer/…`, a host tool's call) go to the shell's relay instead; session + lists answer its sessions; the methods it is told the kernel supports, + in an answer or in the `session/open` notification octos sends after an + open, are the ones it may call. ## Consequences @@ -157,6 +207,8 @@ separate kernel would be a different assistant with different memory. (upstream octos issue 2167). - This is not an Android foreground service: when Android kills the shell, the kernel stops with it. +- An app's kernel port adds no listener and no credential. Its sessions are + the person's assistant's (`_main`): the same memory, models and providers. ## Limits @@ -164,6 +216,10 @@ OpenHarmony (embedded core) and iOS (no kernel) have no Talk to Octos. There is no bundled web client. Android packaging and device behaviour, and a terminal UI reading the connection file, are **unverified**. +An app's kernel port (item 9) runs no command yet, and has no uploads (they +go over REST, which the port does not carry). On a phone the app works only +in its own folder. + ## Acceptance Real-kernel tests (a scripted local model, no external calls) check that: @@ -181,6 +237,14 @@ Real-kernel tests (a scripted local model, no external calls) check that: - native and web clients share the system conversation; - a shell killed with SIGKILL takes its kernel with it, in both modes. +For item 9, the router's tests check that a scoped consumer's refused +request never reaches the kernel and is answered with its own id, that it +hears only its own sessions, that a host-only frame about its session goes +to the shell, that it answers only the kernel requests it was sent, and that +a session's frames wait for its tool list (and are refused when setting it +failed); the coding scope's tests check its sessions, folders, methods and +filters. + The six app-peer real-kernel tests pass with Talk to Octos off. CI runs these real-kernel tests (`apps.yml`, job `kernel-security`) against octos built at the pinned revision. A diff --git a/docs/architecture.md b/docs/architecture.md index 589ded5f..5595c061 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -113,6 +113,7 @@ Native apps are reviewed, first-party Rust crates declared only in [`native-apps | Rinx | module | module | module | default / default | injected service | | Terminal | **process** | **process** with Vulkan and Wayland, else module | module | default / off | peer link; `terminal.run` is the system agent's | | Calculator, Clock, Notes, Reminders, Weather | module | module | module | default / default | peer link | +| OctosCode (a coding client of the person's assistant) | module | module | module | default / default | none of its own: a kernel port in the coding scope (ADR 0003, item 9) | | Sheets, Reference | module | module | module | opt-in / `mobile-apps` | – | | Task (no module) | **process** | **process** | – | off / off; the desktop catalog starts it | – | | AppCard | module | module | module | opt-in / opt-in | its own kernel connection | @@ -252,7 +253,7 @@ The broker answers `peer/input/reject`, with a reason, when the agent is not all ### An app and its own agent -An app never speaks OUP and never sees the host token, and the shell stamps the app's identity on every call ([How an app uses its agent](../README.md#how-an-app-uses-its-agent)). There are three paths: +An app never sees the host token, and the shell stamps the app's identity on every call ([How an app uses its agent](../README.md#how-an-app-uses-its-agent)). No app speaks OUP, except an app that is itself an octos client (OctosCode): it gets a kernel port that the shell's router holds to the coding scope, its own sessions in its own folders, with no listener and no token ([ADR 0003](adr/0003-shared-octos-client-access.md), item 9). There are three paths: | Path | Used by | How it works | | --- | --- | --- | @@ -432,7 +433,7 @@ The kernel's core dir, `~/.octosense/octos-home/.octos` on the desktop and `,", " pub calls_per_day: Option,", + " /// `kernel`: its port to the shell's kernel.", + " pub kernel: KernelPort,", "}", "", "pub const APPS: &[NativeApp] = &[", @@ -732,6 +752,7 @@ def render_rust(apps): for key in ("calls_per_turn", "calls_per_day"): value = f"Some({budget[key]})" if key in budget else "None" out.append(f" {key}: {value},") + out.append(f" kernel: KernelPort::{app.get('kernel', 'none').capitalize()},") out.append(" },") out += [ "];", diff --git a/tools/test_native_apps.py b/tools/test_native_apps.py index 6c92931e..bb407a50 100644 --- a/tools/test_native_apps.py +++ b/tools/test_native_apps.py @@ -33,7 +33,7 @@ def test_the_manifest_declares_todays_native_apps(self): apps = native_apps.load(ROOT) self.assertEqual([app["id"] for app in apps], ["rinx", "reference", "sheets", "terminal", "appcard", "apphub", "calculator", "clock", "notes", "reminders", "weather", - "task"]) + "octoscode", "task"]) hosting = {app["id"]: app["hosting"] for app in apps} # Terminal is the only app that runs both linked and as a process # (ADR 0004 §2); Task has no module and runs only as one. @@ -229,6 +229,20 @@ def test_an_apps_own_agent_may_be_narrowed_to_some_of_its_tools(self): terminal["own_tools"] = "terminal.read_screen" self.assertRefused(r"agent\.own_tools must be a list of its own tool names") + def test_only_an_entry_that_names_the_coding_scope_gets_a_kernel_port(self): + """`kernel`: an app that is itself an octos client gets the coding + scope only when its entry says so (OctosCode's does); any other value + is refused.""" + apps = native_apps.validate(self.data) + self.assertEqual([a["id"] for a in apps if "kernel" in a], ["octoscode"], "OctosCode alone asks for a port") + rust = native_apps.render_rust(apps) + self.assertEqual(rust.count("kernel: KernelPort::Coding,"), 1) + self.assertEqual(rust.count("kernel: KernelPort::None,"), len(apps) - 1, "every other entry has none") + self.app("calculator")["kernel"] = "coding" + self.assertEqual(native_apps.render_rust(native_apps.validate(self.data)).count("kernel: KernelPort::Coding,"), 2) + self.app("calculator")["kernel"] = "host" + self.assertRefused(r"calculator: kernel must be one of coding") + def test_the_system_agent_gets_only_an_apps_own_shareable_read_tools(self): """`agent.system_tools`: what the system agent may call of an app's own tools is named per app, and only its shareable read tools