From 142d1f6b8dd46366558dece90d545de9a26d8141 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:13:48 -0700 Subject: [PATCH 1/9] feat: share Octos system agent with native and web clients --- Cargo.lock | 4 + apps/README.md | 9 +- apps/README.zh-CN.md | 7 +- apps/ai-providers/bundle/main.splash | 3 + apps/ai-providers/host-service/src/lib.rs | 57 ++++- apps/ai-providers/host-service/src/sheets.rs | 63 ++++++ .../host-service/tests/service.rs | 19 ++ .../tests/kernel_restart.rs | 2 +- crates/ai-host/src/lib.rs | 18 +- crates/kernel/Cargo.toml | 18 +- crates/kernel/README.md | 210 +++++++++--------- crates/kernel/README.zh-CN.md | 93 ++++++++ crates/kernel/src/kernel.rs | 112 ++++++++-- crates/kernel/src/launch.rs | 30 ++- crates/kernel/src/lib.rs | 160 +++++++++++-- crates/kernel/src/network.rs | 197 ++++++++++++++++ crates/kernel/src/router.rs | 25 ++- crates/kernel/tests/core.rs | 34 ++- crates/kernel/tests/real_kernel.rs | 188 ++++++++++++++-- desktop/README.md | 5 +- desktop/README.zh-CN.md | 5 +- docs/adr/0003-shared-octos-client-access.md | 73 ++++++ docs/adr/README.md | 1 + docs/adr/README.zh-CN.md | 1 + octos-runtime-patches.lock.json | 6 + phone/README.md | 7 +- phone/README.zh-CN.md | 5 +- tools/kernel-artifact.py | 59 +++-- .../runtime-patches/octos-host-managed.patch | 116 ++++++++++ tools/test_kernel_artifact.py | 46 ++++ 30 files changed, 1371 insertions(+), 202 deletions(-) create mode 100644 crates/kernel/README.zh-CN.md create mode 100644 crates/kernel/src/network.rs create mode 100644 docs/adr/0003-shared-octos-client-access.md create mode 100644 octos-runtime-patches.lock.json create mode 100644 tools/runtime-patches/octos-host-managed.patch diff --git a/Cargo.lock b/Cargo.lock index 6f786bf7..045e9315 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7379,12 +7379,16 @@ dependencies = [ name = "octosense-kernel" version = "0.1.0" dependencies = [ + "futures-util", "log", "octos-cli", "octosense-llm-config", "serde_json", "tokio", + "tokio-tungstenite 0.24.0", "tracing-subscriber", + "url", + "uuid", ] [[package]] diff --git a/apps/README.md b/apps/README.md index 6855a990..f29d9e3e 100644 --- a/apps/README.md +++ b/apps/README.md @@ -295,6 +295,9 @@ after every change, so the running kernel picks up the new providers. The method table and registration are in its [README](ai-providers/host-service/README.md). + +**Talk to the system agent:** open **AI providers → Talk to Octos** for the server address and native **Copy access token** button. Native apps, OctosCode TUI and Web share one WebSocket server; the system session is `_main:api:octosense#system`. The kernel requires this repository's locked `--host-managed` overlay, applied automatically by the Android build tool. The web client is hosted separately. See the [kernel guide](../crates/kernel/README.md) for connections, tunnels and platform limits. + ## The octos kernel The octos agent kernel is a **shell service**, not part of any app. @@ -303,11 +306,11 @@ service; the shells link it by default (cargo feature `octos-core`, also on in `mobile-apps` and native mobile builds): - **One per process, on demand.** The first consumer's `connect()` starts it: - `octos serve --stdio` as a child on desktop and Android (on Android the + `octos serve --host 127.0.0.1 --host-managed` as a child on desktop and Android (on Android the APK's bundled `liboctos.so`), the canonical core in-process on OpenHarmony. Later consumers share it; each gets only the replies to its - own requests and its own sessions' notifications. It stops when the last - consumer leaves. + own requests and its own sessions' notifications. On desktop and Android it stays + available after native apps close, until the shell exits. - **Configured by AI providers.** The `llm` host service writes the kernel's profile, `/profiles/_main.json`, and keys (macOS keychain `octos` service behind `keychain:` markers, `/secrets/` on Linux, the diff --git a/apps/README.zh-CN.md b/apps/README.zh-CN.md index d8f7cc4b..29ae502f 100644 --- a/apps/README.zh-CN.md +++ b/apps/README.zh-CN.md @@ -263,6 +263,9 @@ MAKEPAD_APP_CONFIG='{"mail_demo":true}' cargo run --release -p octosense-home -- `octosense_kernel::restart()`,让正在运行的内核读取新的服务商。方法列表与注册 方式见其 [README(英文)](ai-providers/host-service/README.md)。 + +**与系统 Agent 对话:** 打开 **AI providers → Talk to Octos**,获取服务器地址并通过宿主按钮复制访问令牌。原生应用、OctosCode 终端和 Web 客户端连接同一个 WebSocket 服务;系统会话为 `_main:api:octosense#system`。内核必须包含本仓库锁定的 `--host-managed` 补丁,Android 构建工具会自动应用。Web 客户端仍需单独托管。连接、隧道及平台限制见[内核指南](../crates/kernel/README.zh-CN.md)。 + ## octos 内核 octos Agent 内核是 **Shell 服务**,不属于任何应用。 @@ -271,9 +274,9 @@ Shell 默认链接它(cargo feature `octos-core`,在 `mobile-apps` 和原生 同样开启): - **每进程一个,按需启动。** 第一个使用方调用 `connect()` 时启动:桌面和 - Android 上以子进程运行 `octos serve --stdio`(Android 上是 APK 内置的 + Android 上以子进程运行 `octos serve --host 127.0.0.1 --host-managed`(Android 上是 APK 内置的 `liboctos.so`),OpenHarmony 上在进程内运行标准内核。之后的使用方共享它; - 每个使用方只收到自己请求的回复和自己会话的通知。最后一个使用方离开时内核停止。 + 每个使用方只收到自己请求的回复和自己会话的通知。桌面和 Android 上原生应用关闭后服务继续运行,Shell 退出时停止。 - **由 AI 服务商配置。** `llm` 宿主服务写入内核的 profile `/profiles/_main.json` 以及密钥(macOS 钥匙串 `octos` 服务配合 `keychain:` 标记,Linux 上是 `/secrets/`,其他平台写在 profile 中), diff --git a/apps/ai-providers/bundle/main.splash b/apps/ai-providers/bundle/main.splash index 4531f4d4..b1536e10 100644 --- a/apps/ai-providers/bundle/main.splash +++ b/apps/ai-providers/bundle/main.splash @@ -228,6 +228,9 @@ SolidView{width: Fill height: Fill flow: Down draw_bg.color: page draw_text.color: secondary draw_text.text_style.font_size: 13} } Big{text: "Add model" on_click: || add_model()} + Quiet{text: "Talk to Octos" on_click: || { + host.request("llm.connect_client", {}, fn(r){ if !r.is_ok { status(r.error) } }) + }} View{width: Fill height: Fit flow: Right align: Align{y: 1.0} padding: Inset{top: 4} Section{text: "SAVED MODELS"} View{width: Fill height: 1} diff --git a/apps/ai-providers/host-service/src/lib.rs b/apps/ai-providers/host-service/src/lib.rs index ba3bf9dc..15ebfb21 100644 --- a/apps/ai-providers/host-service/src/lib.rs +++ b/apps/ai-providers/host-service/src/lib.rs @@ -139,6 +139,10 @@ pub trait QrImagePicker: Send + Sync { /// `octos-core`). Runs on whichever thread made the change. pub type OnChanged = Arc; +/// Trusted host UI actions; secrets never return to the requesting script. +pub enum ClientUiAction { CopyToken(String), OpenWeb(String) } +pub type ClientUi = Arc; + /// What a shell hands the service. `Options::default()` is what /// [`register`] uses. #[derive(Clone, Default)] @@ -164,6 +168,7 @@ pub struct Options { /// [`QR_LIFETIME_SECS`], which is the default). `None`: the /// [`QR_LIFETIME_ENV`] variable, else the default. pub qr_lifetime_secs: Option, + pub client_ui: Option, } impl Options { @@ -219,6 +224,7 @@ pub fn register_with(options: Options) { export: Arc::default(), generation: 0, qr_lifetime: qr_lifetime(options.qr_lifetime_secs), + client_ui: options.client_ui, })); } @@ -314,6 +320,7 @@ struct Pending { #[derive(Clone, Debug, PartialEq)] enum Kind { Add, + Connect, Edit(String), Export(u64), Import { scanned: Option }, @@ -387,6 +394,7 @@ pub struct LlmService { export: Arc>>, generation: u64, qr_lifetime: u64, + client_ui: Option, } fn text<'a>(v: &'a Value, key: &str) -> &'a str { @@ -909,6 +917,41 @@ fn apply_import(shared: &Shared, pending: &Mutex>, provisioning: } } + +#[cfg(feature = "octos-core")] +fn client_action(method: &str, args: &Value, ui: Option) -> Result { + use octosense_kernel as kernel; + match method { + "sheet.client_origin" => { + kernel::set_web_client_origin(text(args, "origin"))?; + Ok(json!({})) + } + "sheet.client_copy_token" => { + let ui = ui.ok_or("The host cannot copy to the clipboard.")?; + ui(ClientUiAction::CopyToken(kernel::client_access()?.token)); + Ok(json!({})) + } + "sheet.client_open" => { + let ui = ui.ok_or("The host cannot open a browser.")?; + ui(ClientUiAction::OpenWeb(kernel::web_client_url()?)); + Ok(json!({})) + } + _ => { + let access = kernel::client_access()?; + let origin = kernel::web_client_origin(); + let web_url = if origin.is_empty() { String::new() } else { kernel::web_client_url()? }; + Ok(json!({"origin": access.origin, "endpoint": access.endpoint(), + "profile_id": "_main", "session_id": kernel::SYSTEM_SESSION, + "web_origin": origin, "web_url": web_url, "can_copy": ui.is_some()})) + } + } +} + +#[cfg(not(feature = "octos-core"))] +fn client_action(_method: &str, _args: &Value, _ui: Option) -> Result { + Err("This build does not include the Octos kernel service.".into()) +} + impl HostService for LlmService { fn family(&self) -> &'static str { "llm" @@ -920,6 +963,18 @@ impl HostService for LlmService { } let id = text(&call.args, "id").to_string(); match call.method() { + "connect_client" => { + if call.app_id != "os.ai-providers" { return reply.send(Err("Open AI providers to connect a client.".into())); } + self.raise(&call.app_id, reply, Kind::Connect, sheets::connect_client(), host); + } + "sheet.client_info" | "sheet.client_origin" | "sheet.client_copy_token" | "sheet.client_open" => { + let permitted = call.from_sheet && self.pending.lock().unwrap().as_ref() + .is_some_and(|p| p.kind == Kind::Connect && p.app_id == call.app_id); + if !permitted { return reply.send(Err("Open the client connection sheet first.".into())); } + let method = call.method().to_owned(); + let ui = self.client_ui.clone(); + work(move || reply.send(client_action(&method, &call.args, ui))); + } "providers" => { let (shared, scanner, image_picker) = (self.shared.clone(), self.scanner.is_some(), self.image_picker.is_some()); work(move || { @@ -1005,7 +1060,7 @@ impl HostService for LlmService { IMAGE_WAITER.lock().unwrap().take(); if let Some(waiting) = self.pending.lock().unwrap().take() { // Closing the QR is how an export ends. - let answer = if matches!(waiting.kind, Kind::Export(_)) { Ok(json!({})) } else { Err("Cancelled.".into()) }; + let answer = if matches!(waiting.kind, Kind::Export(_) | Kind::Connect) { Ok(json!({})) } else { Err("Cancelled.".into()) }; waiting.reply.send(answer); } reply.send(Ok(json!({}))); diff --git a/apps/ai-providers/host-service/src/sheets.rs b/apps/ai-providers/host-service/src/sheets.rs index 8dc2d685..1fecc31e 100644 --- a/apps/ai-providers/host-service/src/sheets.rs +++ b/apps/ai-providers/host-service/src/sheets.rs @@ -59,6 +59,69 @@ fn frame(actions: &str, content: &str) -> String { ) } +/// Connection controls live on the trusted sheet. The access token is +/// copied by native code and is never present in this script or its replies. +pub fn connect_client() -> String { + let mut script = String::from(r#" +fn status(text){ ui.status.set_text(text) } +fn refresh(){ + status("Starting Octos…") + host.request("llm.sheet.client_info", {}, fn(r){ + if !r.is_ok { status(r.error) return } + ui.origin.set_text(r.data.origin) + ui.endpoint.set_text(r.data.endpoint) + ui.session.set_text(r.data.session_id) + ui.web_origin.set_text(r.data.web_origin) + ui.web_url.set_text(r.data.web_url) + ui.copy.set_visible(r.data.can_copy) + status("Connected. Native apps and external clients share this agent runtime.") + }) +} +fn save_origin(){ + status("Restarting Octos…") + host.request("llm.sheet.client_origin", {origin: ui.web_origin.text()}, fn(r){ + if !r.is_ok { status(r.error) return } + refresh() + }) +} +fn copy_token(){ + host.request("llm.sheet.client_copy_token", {}, fn(r){ + if r.is_ok { status("Access token copied. Paste it into your client.") } else { status(r.error) } + }) +} +fn open_web(){ + host.request("llm.sheet.client_open", {}, fn(r){ if !r.is_ok { status(r.error) } }) +} +fn close(){ host.request("llm.sheet.cancel", {}, nil) } +start_timeout(0.1, || refresh()) +"#); + script.push_str(STYLES); + script.push_str(&frame( + r#"Plain{text: "Done" on_click: || close()} + View{width: Fill height: 1} + Plain{text: "Refresh" on_click: || refresh()}"#, + r#"Title{text: "Talk to Octos"} + Note{text: "Connect OctosCode Web or OctosCode in a terminal to this device's assistant."} + Caption{text: "Server origin · for the web client"} + origin := Field{is_read_only: true} + Caption{text: "WebSocket endpoint · for OctosCode --endpoint"} + endpoint := Field{is_read_only: true} + Caption{text: "System-agent conversation · profile _main"} + session := Field{is_read_only: true} + copy := Primary{text: "Copy access token" on_click: || copy_token()} + Caption{text: "Web client origin · for example http://localhost:4173"} + web_origin := Field{empty_text: "http://localhost:4173"} + Note{text: "Use the address where your OctosCode Web client is hosted. Saving a changed origin restarts Octos and interrupts active work."} + Plain{text: "Save origin and restart" on_click: || save_origin()} + Caption{text: "System conversation link · open on either device"} + web_url := Field{is_read_only: true} + Primary{text: "Open web client" on_click: || open_web()} + Note{text: "Enter the server origin and paste the access token in the web client. The link selects the system conversation. A computer connects through a tunnel. Closing the browser can interrupt work started there."} + status := Status{}"#, + )); + script +} + /// The add and edit sheet: a five-step wizard in Octoscode's `/model` order, /// one step per page, a step indicator and progress bar at the top, and Back /// / Next pinned at the bottom (Next is greyed out until the step is valid): diff --git a/apps/ai-providers/host-service/tests/service.rs b/apps/ai-providers/host-service/tests/service.rs index 50efac0f..d79c5363 100644 --- a/apps/ai-providers/host-service/tests/service.rs +++ b/apps/ai-providers/host-service/tests/service.rs @@ -129,6 +129,25 @@ fn two_providers(rig: &mut Rig) { rig.add(json!({"family": "zai", "model": "", "base_url": "", "api_type": "default", "key": ZAI_KEY})); } +#[test] +fn client_connection_controls_require_their_own_trusted_sheet() { + let mut rig = Rig::new("client-sheet", None); + for method in ["llm.sheet.client_info", "llm.sheet.client_copy_token", "llm.sheet.client_open", "llm.sheet.client_origin"] { + assert!(rig.ask(method, json!({})).is_err()); + assert!(rig.sheet(method, json!({})).is_err(), "a different sheet cannot access connection controls"); + } + let other = rig.send("os.mail", "llm.connect_client", json!({}), false); + assert!(wait(other).is_err()); + let waiting = rig.send(APP, "llm.connect_client", json!({}), false); + assert!(rig.host.body().contains("Talk to Octos")); + assert!(!rig.host.body().contains("is_password: true")); + assert!(rig.ask("llm.sheet.client_info", json!({})).is_err(), "the calling app cannot impersonate its sheet"); + let other = rig.send("os.mail", "llm.sheet.client_copy_token", json!({}), true); + assert!(wait(other).is_err(), "another app's sheet cannot copy the token"); + rig.sheet("llm.sheet.cancel", json!({})).unwrap(); + assert_eq!(wait(waiting).unwrap(), json!({}), "the app receives no connection details"); +} + #[test] fn keys_are_typed_only_on_the_sheet_and_never_come_back() { let mut rig = Rig::new("edit", None); diff --git a/apps/appcard/app/crates/octos-app-transport/tests/kernel_restart.rs b/apps/appcard/app/crates/octos-app-transport/tests/kernel_restart.rs index d8fe5c7d..1644b722 100644 --- a/apps/appcard/app/crates/octos-app-transport/tests/kernel_restart.rs +++ b/apps/appcard/app/crates/octos-app-transport/tests/kernel_restart.rs @@ -46,7 +46,7 @@ fn listed_pid(event: &TransportEvent) -> Option { async fn a_restarted_kernel_is_rejoined_and_the_session_reopened() { let dir = std::env::temp_dir().join(format!("octos-app-kernel-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); - octosense_kernel::configure(octosense_kernel::Options::default().core_dir(&dir).program(fake_kernel())); + octosense_kernel::configure(octosense_kernel::Options::default().stdio().core_dir(&dir).program(fake_kernel())); let cfg = TransportConfig { base_url: Url::parse("http://127.0.0.1").unwrap(), diff --git a/crates/ai-host/src/lib.rs b/crates/ai-host/src/lib.rs index 2de1fa9d..01b7ee19 100644 --- a/crates/ai-host/src/lib.rs +++ b/crates/ai-host/src/lib.rs @@ -281,6 +281,10 @@ fn register_llm(core_dir: Option, import: QrImport) -> bool { } // With `octos-core` the service itself restarts the kernel after a // change; its consumers reconnect. + options.client_ui = Some(std::sync::Arc::new(|action| { + CLIENT_UI.lock().unwrap().push(action); + makepad_widgets::makepad_platform::thread::SignalToUI::set_ui_signal(); + })); octosense_llm_service::register_with(options.clone()); // `model` (ADR 0002, `model.complete`): contained apps' one-shot model // calls over the same providers, with per-app budgets. Apps granted the @@ -296,9 +300,19 @@ fn register_llm(_core_dir: Option, _import: QrImport) -> bool { false } -/// Every event, on the UI thread, early: opens the scanner or picker the -/// `llm` service asked for and completes it from the platform's answer. +#[cfg(feature = "llm")] +static CLIENT_UI: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + +/// Every event, on the UI thread, early: performs the host sheet's clipboard +/// and browser actions and pumps the platform scanner/picker results. pub fn handle_event(cx: &mut Cx, event: &Event) { + #[cfg(feature = "llm")] + for action in std::mem::take(&mut *CLIENT_UI.lock().unwrap()) { + match action { + octosense_llm_service::ClientUiAction::CopyToken(token) => cx.copy_to_clipboard(&token), + octosense_llm_service::ClientUiAction::OpenWeb(url) => cx.open_url(&url, OpenUrlInPlace::No), + } + } if let Some(state) = STATE.get() { qr::pump(cx, event, state.qr_import); } diff --git a/crates/kernel/Cargo.toml b/crates/kernel/Cargo.toml index e5b63fd5..466042f2 100644 --- a/crates/kernel/Cargo.toml +++ b/crates/kernel/Cargo.toml @@ -1,16 +1,8 @@ # octosense-kernel — the shell's octos kernel (one per process). # -# The shell owns the octos agent kernel as a service: this crate resolves the -# kernel's core dir (where the AI providers app's `llm` service writes -# `profiles/_main.json`), starts ONE kernel lazily on the first `connect()` -# (`octos serve --stdio` as a child on desktop and Android, the canonical core -# in-process on OpenHarmony), multiplexes it between consumers (AppCard, and -# later Rinx), restarts it when the providers change and stops it when the -# last consumer leaves. See README.md. -# -# Explicit package fields (no `workspace = true`): the shells and AppCard's -# workspace take this crate as a plain path dependency. The empty -# `[workspace]` below lets it test on its own from this directory. +# Desktop/Android use one authenticated loopback WebSocket server; the +# OpenHarmony core stays embedded. Native consumers keep a shared frame API. +# External clients attach without creating another kernel. See README.md. [package] name = "octosense-kernel" version = "0.1.0" @@ -23,6 +15,10 @@ publish = false [dependencies] tokio = { version = "1", features = ["rt-multi-thread", "sync", "io-util", "process", "time", "macros"] } +tokio-tungstenite = { workspace = true } +futures-util = "0.3" +uuid = { workspace = true } +url = "2" serde_json = "1" log = "0.4" # `default_core_dir()` — the one definition of where the kernel's profile diff --git a/crates/kernel/README.md b/crates/kernel/README.md index 398dcf2d..329555f4 100644 --- a/crates/kernel/README.md +++ b/crates/kernel/README.md @@ -1,118 +1,122 @@ -# octosense-kernel: the shell's octos kernel - -The [octos](https://github.com/octos-org/octos) agent kernel is a **shell -service**. The shell (Home in `phone/`, the desktop in `desktop/`) owns it; the -**AI providers** system app configures it through the `llm` host service; -**AppCard**, and next Rinx's native mini-app host, connect to it. This crate -is that service: one kernel per process, started on demand, shared, -restarted when the providers change. - -It lives in `crates/` rather than `apps/` because it is not an app: it is -the shared runtime piece the shells, AppCard (`apps/appcard/app`) and the -`llm` service (`apps/ai-providers/host-service`, feature `octos-core`) link. - -## What it does - -| | | -|---|---| -| **Core dir** | octos's data dir: `/profiles/_main.json` is the profile the AI providers app writes. Resolved as: the shell's `Options::core_dir`, else `$OCTOS_APP_CORE_DIR`, else on Android/OpenHarmony `/octos-home/.octos` (the app-private octos home AppCard has always used), else `$HOME/octos-home/.octos` (`octosense_llm_config::profile::default_core_dir()`). | -| **One kernel, lazily** | The first `connect()` starts it; later ones share it. octos holds a single-writer lock on its data dir, so a second kernel on the same dir could not run anyway. | -| **Shared by frames** | A `Connection` carries UI Protocol (JSON-RPC) frames exactly as `octos serve --stdio` speaks them. Each consumer uses its own request ids and receives the replies to its requests and the notifications of the sessions it named (a notification for a session nobody named goes to every consumer). | -| **Restart** | `restart()` stops a running kernel (a no-op when none runs). Connections then end with `CloseReason::Restarted`; a consumer connects again, which starts a fresh kernel that reads the new profile. The next kernel starts only after the old one has exited and released its data dir. | -| **Idle stop** | When the last connection is dropped the kernel stops, as AppCard's own child used to. | -| **Shutdown** | `shutdown()` stops it and waits (5 s at most). | - -How it starts, per platform (`src/launch.rs`): - -- **Android**: `/liboctos.so serve --stdio`, `HOME=` (the octos home), with AppCard's environment - (`OCTOS_SKILLS_PATH`, `OCTOS_OMIT_WORKSPACE_HINT`, `RUST_LOG`, the - `makepad.OCTOS_PROXY` proxy) and the kernel config's memory budget. The APK - must bundle the kernel: `MAKEPAD_ANDROID_EXTRA_LIBS=liboctos.so=` - (the shells' build scripts do it). -- **OpenHarmony**: the canonical core in-process, - `octos_cli::embedded::serve_io(, ..)`, on this crate's runtime - with 8 MiB worker stacks (HAP native libraries may not exec). -- **Desktop**: ` serve --stdio --data-dir ` (plus - `--config /config.json` when that file exists) with - `OCTOS_HOME=`; the program is the shell's `Options::program` or - `$OCTOS_APP_CORE_BIN`. With neither there is no kernel: a developer's own - `octos serve` is never touched. -- **iOS**: no kernel. - -The kernel and the frame pump run on the crate's own Tokio runtime, so a -consumer may use any runtime or none. - -## Using it - -A shell, once at startup, before the first consumer: - -```rust -octosense_kernel::configure( - octosense_kernel::Options::default().app_data_dir(cx.get_data_dir()), -); -// The llm service (feature `octos-core`) writes under the same core dir -// and calls octosense_kernel::restart() after every change. -octosense_llm_service::register_with( - octosense_llm_service::Options::default().core_dir(octosense_kernel::core_dir().unwrap()), -); -``` - -A consumer: - -```rust -let mut conn = octosense_kernel::connect()?; // Err: no kernel here -conn.send(r#"{"jsonrpc":"2.0","id":"1","method":"session/open","params":{"session_id":"_main:api:x","profile_id":"_main"}}"#)?; -loop { - match conn.recv().await { - Ok(frame) => { /* a JSON-RPC frame for this consumer */ } - Err(octosense_kernel::CloseReason::Restarted) => { /* connect again, re-open sessions */ break } - Err(other) => { /* the kernel stopped or could not start: tell the person */ break } - } -} -``` - -AppCard's transport (`apps/appcard/app/crates/octos-app-transport`, -`kernel.rs`) is the reference consumer: on `Restarted` it fails the requests -still waiting, reconnects and opens its sessions again from their replay -cursors, so the app carries on. - -**Rinx and other consumers.** A native mini-app host takes its own -connection (`connect()`), opens sessions with ids of its own (Rinx uses -`:api:rinx-mini-…`) and gets only its sessions' traffic, with no -coupling to AppCard's connection or UI queue. It must handle -`CloseReason::Restarted` by reconnecting. - -Other functions: `core_dir()`, `home()`, `profile()`, `launch()` / -`is_available()` (whether and how a kernel would start), `status()`. - -## Testing - -From the repository root: +# OctoSense's shared Octos server + +English | [简体中文](README.zh-CN.md) + +The shell owns one Octos agent runtime. Native apps use the scoped app-peer +broker; OctosCode TUI and Web attach to the same authenticated WebSocket server. +They do not start another kernel or contend for the same data directory. + +## Talk to the system agent + +1. Configure a model in **AI providers**, then select **Talk to Octos**. +2. The trusted sheet shows **Server origin**, **WebSocket endpoint**, and the + system conversation, `_main:api:octosense#system` in profile `_main`. +3. For OctosCode Web, enter the origin where you host that client, such as + `http://localhost:4173`, and select **Save origin and restart**. A changed + origin interrupts active work. OctoSense does not bundle web-client assets. +4. Select **Copy access token**, then **Open web client**. Enter the sheet's + server origin and paste the token into the web client's **Auth token** field. + Select **Connect**, then **Open conversation** on its saved-link screen. + The generated link selects the existing system conversation using the + workspace confirmed by Octos. It contains no token. The same link can be + opened on your computer. + +The access token grants control of the agent, including its tools. It is copied +by native code at the user's request; neither the app script nor its service +reply receives it. Only use a web client you trust. + +On desktop, launch OctosCode with its `--endpoint`, `--profile-id _main`, and +`--session '_main:api:octosense#system'` options and supply its auth token through +its environment/configuration. Leave stdio mode disabled. A bare `octoscode` +launch normally starts its own kernel and is not this connection. TUI launch +is **unverified** in this change; simultaneous native and browser clients were +verified against the real kernel with a scripted local model. A headless +Chromium run also opened the generated system link, sent a message and +displayed the reply. + +### Computer connected to an Android device + +The server binds only `127.0.0.1`; another APK on the device still needs the +token. A computer needs a tunnel. The ADB forwarding example below is +**unverified on a device in this change**. Replace `SERIAL` and `PORT` with the +authorized device and the port shown in the sheet; using the same port on both +ends keeps the displayed server address usable on the computer: ```sh -cargo test --locked -p octosense-kernel # unit tests + the core against a stand-in kernel (python3) -# The real kernel: a profile written by octosense-llm-config, session/open, -# profile/llm/list, a provider change and a restart. Build octos at the rev -# the root Cargo.toml pins, then: -OCTOS_CORE_TEST_KERNEL=/path/to/octos cargo test -p octosense-kernel --test real_kernel -- --nocapture +adb -s SERIAL forward tcp:PORT tcp:PORT ``` -Build the kernel for that test (and for an Android APK, with the NDK and -`--target aarch64-linux-android`) from octos-org/octos at the rev in the root -`Cargo.toml` `[workspace.dependencies]`: +Serve the web client on the computer, allow that exact web origin in the +phone's sheet, open the system conversation link on the computer, and enter the +forwarded server origin and token there. No root, Termux, PRoot or Ubuntu is +required by this APK-bundled server. This does not add a Linux coding toolchain. +A browser may require permission to reach a local-network address. + +## Runtime and lifetime + +- Desktop and Android run `octos serve --host 127.0.0.1 --host-managed`. + Android executes the APK's `liboctos.so`; desktop uses `OCTOS_APP_CORE_BIN`. +- The first native consumer or connection-sheet request starts the server. + Port zero allocates a free port; only the pinned server's listener announcement + is accepted. Native consumers retain their frame API and negotiate the + capabilities previously enabled by stdio. +- The system workspace is resolved and saved in `system-workspace.txt`. Native + system-session opens reuse it, including after Web scopes the session and + after a full shell restart. App-peer workspaces remain independent. +- Provider/origin changes restart the server after the old process exits, keeping + its port and token for reconnecting clients. Dropping the last native consumer + leaves the server running. Closing the shell stops it. This is not a persistent + Android foreground service; Android process death stops the agent too. +- `/client-connection.json` contains connection details and the secret + token, is written atomically with mode `0600`, and is removed on orderly stop. + Tokens rotate when the shell's kernel service is recreated. Do not publish this + file or put the token in logs/command arguments. +- Core directory: explicit `Options::core_dir`, then `OCTOS_APP_CORE_DIR`, then + `/octos-home/.octos` on phones or `~/octos-home/.octos` on desktop. + AI providers writes `/profiles/_main.json`. +- OpenHarmony retains `octos_cli::embedded::serve_io`; external clients are + unavailable there. iOS has no local kernel. `Options::stdio()` retains private + pipe behavior for fixtures and embedding hosts, including stop when idle. + +A shared server is not a shared conversation unless clients open the same +session. App peers keep their own scoped sessions. Browser-owned active turns +may still be interrupted when their WebSocket closes (upstream Octos issue +2167); this integration does not implement detached-turn ownership. + +## Build and verify + +The executable must contain the overlay in +[`octos-runtime-patches.lock.json`](../../octos-runtime-patches.lock.json). +It adds mandatory host-token authentication, disables password-free solo login +and loopback profile-header impersonation, and runs the profile in the server +process. Plain upstream binaries lacking `--host-managed` fail to start; there +is no unauthenticated fallback. The Octos pin and patch hash are checked before +application. Android packaging applies the overlay automatically. + +The desktop plan and tests below were run from the repository root: ```sh -cargo build --release -p octos-cli --bin octos --no-default-features --features api,git,ast +python3 tools/kernel-artifact.py --host --plan +python3 -m unittest discover -s tools -p 'test_kernel_artifact.py' +cargo test --locked -p octosense-kernel ``` -CI: `.github/workflows/apps.yml` (the `services` job tests this crate; the -`apps` job builds AppCard, which links it). +`python3 tools/kernel-artifact.py --host` builds the release desktop executable +under `target/octos-kernel/target/release/octos` (**release command unverified**; +the pinned source plus overlay was built and exercised in debug mode). Set +`OCTOS_APP_CORE_BIN` to that binary when launching a shell. -## One octos +Real integration tests use `OCTOS_CORE_TEST_KERNEL=` with +`cargo test --locked -p octosense-kernel --test real_kernel -- --nocapture`. +They check authentication, origin rejection, native/browser conversation +sharing, provider restart, token redaction and shutdown without external model +calls. The app-peer real-kernel suite also runs against this transport. On OpenHarmony this crate links `octos-cli` from git octos-org/octos at the one rev the root `Cargo.toml` pins for every octos crate (`3b5d17a4`). A workspace that builds it for OpenHarmony also needs the `nix` patch (octos rev `18fcd3f1`, see the root `Cargo.toml` `[patch.crates-io]`). On every other target it links no octos crate at all: the kernel is a separate binary. + +See [ADR 0003](../../docs/adr/0003-shared-octos-client-access.md). Android APK +packaging and physical-device behavior remain **unverified** in this change. diff --git a/crates/kernel/README.zh-CN.md b/crates/kernel/README.zh-CN.md new file mode 100644 index 00000000..8994656c --- /dev/null +++ b/crates/kernel/README.zh-CN.md @@ -0,0 +1,93 @@ +# OctoSense 共享 Octos 服务 + +[English](README.md) | 简体中文 + +Shell 拥有唯一的 Octos Agent 运行时。原生应用通过受限的 app-peer 接口访问它; +OctosCode 终端和 Web 客户端连接同一个需要认证的 WebSocket 服务,不会另起内核或争用数据目录。 + +## 与系统 Agent 对话 + +1. 在 **AI providers** 配置模型,再打开 **Talk to Octos**。 +2. 宿主面板显示服务器地址、WebSocket 地址,以及 `_main` profile 下的系统会话 + `_main:api:octosense#system`。 +3. 输入你托管 OctosCode Web 的来源地址,例如 `http://localhost:4173`,点击 + **Save origin and restart**。修改来源会重启 Octos,并中断正在进行的任务。 + OctoSense 本次没有内置 Web 客户端资源,仍需单独托管。 +4. 点击 **Copy access token**,再点 **Open web client**。在 Web 客户端填写面板中的 + 服务器地址,把令牌粘贴到 **Auth token**,选择 **Connect**,再在链接确认页点击 + **Open conversation**。生成的链接使用服务器确认的工作目录选择 + 系统会话,不包含令牌;也可以在电脑上打开这个链接。 + +令牌可控制 Agent 及其工具。复制动作由原生代码执行,应用脚本和服务返回值都不会获得令牌。 +仅使用你信任的 Web 客户端。 + +桌面 OctosCode 使用 `--endpoint`、`--profile-id _main` 和 +`--session '_main:api:octosense#system'` 参数,并通过它的环境变量或配置传入令牌,关闭 +stdio 模式。直接启动 `octoscode` 通常会另起自己的内核。本次**未验证 TUI 启动**; +已经使用真实内核和本地模拟模型验证原生客户端与浏览器类型客户端同时连接;也使用无界面 +Chromium 打开生成的系统会话链接,发送消息并看到模型回复。 + +### 电脑连接 Android 手机 + +服务只监听 `127.0.0.1`,手机上的其他 APK 仍需令牌。电脑需要隧道。以下 ADB 示例在本次 +修改中**未经过设备验证**:把 `SERIAL` 换成获授权的设备序列号,`PORT` 换成面板显示的端口。 +两端使用相同端口时,电脑可以直接使用面板中的服务器地址。 + +```sh +adb -s SERIAL forward tcp:PORT tcp:PORT +``` + +在电脑托管 Web 客户端,在手机面板允许该精确来源地址,再在电脑打开系统会话链接并填写 +转发后的服务器地址与令牌。这个 APK 内置服务无需 root、Termux、PRoot 或 Ubuntu,但本次 +修改也不会添加 Linux 编程工具链。浏览器可能要求授予本地网络访问权限。 + +## 运行方式与生命周期 + +- 桌面和 Android 启动 `octos serve --host 127.0.0.1 --host-managed`。 + Android 执行 APK 中的 `liboctos.so`,桌面使用 `OCTOS_APP_CORE_BIN`。 +- 首个原生使用方或连接面板启动服务。首次分配空闲端口,只接受固定版本内核的监听地址公告。 + 原生接口保持不变,显式协商原 stdio 默认支持的协议能力。 +- 系统工作目录解析后保存在 `system-workspace.txt`,原生客户端打开系统会话时复用它。 + Web 为会话绑定目录后,即使 Shell 重启也可继续访问;应用 peer 的目录仍相互独立。 +- 更改模型或 Web 来源时,等待旧进程退出后重启,保留端口和令牌以便客户端重连。 + 关闭全部原生应用后服务继续运行;关闭 Shell 时停止。这不是 Android 常驻前台服务, + Android 杀死应用进程后 Agent 也会停止。 +- `/client-connection.json` 保存连接信息和秘密令牌,以 `0600` 权限原子写入, + 正常停止时删除。重新创建 Shell 内核服务会更换令牌。不要发布此文件,也不要把令牌写进 + 日志或命令行参数。 +- core 目录按顺序选择:`Options::core_dir`、`OCTOS_APP_CORE_DIR`、手机上的 + `<应用数据目录>/octos-home/.octos`,或桌面的 `~/octos-home/.octos`。 + AI providers 写入 `/profiles/_main.json`。 +- OpenHarmony 继续使用进程内 `serve_io`,暂不支持外部客户端;iOS 没有本地内核。 + `Options::stdio()` 为测试替身和嵌入宿主保留私有管道及空闲停止行为。 + +共享服务器不等于共享会话;客户端必须打开相同 session。应用 peer 仍有独立的受限会话。 +浏览器断开 WebSocket 时,它发起的运行中任务仍可能被中断(上游 Octos issue 2167); +本次没有实现脱离客户端的任务所有权。 + +## 构建与验证 + +可执行文件必须包含 [octos-runtime-patches.lock.json](../../octos-runtime-patches.lock.json) +锁定的补丁:强制宿主令牌认证、禁用免密 solo 登录和本地 profile 请求头冒充,并在服务器 +进程内运行 profile。没有 `--host-managed` 的普通上游内核会启动失败,不会回退到免认证模式。 +应用补丁前检查固定版本及哈希。Android 打包工具自动应用它。 + +以下命令已在仓库根目录运行: + +```sh +python3 tools/kernel-artifact.py --host --plan +python3 -m unittest discover -s tools -p 'test_kernel_artifact.py' +cargo test --locked -p octosense-kernel +``` + +`python3 tools/kernel-artifact.py --host` 在 `target/octos-kernel/target/release/octos` +生成桌面 release 内核(**该 release 命令未验证**;已构建并测试同版本加补丁的 debug 内核)。 +启动 Shell 时把 `OCTOS_APP_CORE_BIN` 指向该文件。 + +真实内核测试使用 `OCTOS_CORE_TEST_KERNEL=<带补丁的二进制>` 和 +`cargo test --locked -p octosense-kernel --test real_kernel -- --nocapture`,覆盖认证、 +来源拒绝、原生与浏览器共享对话、模型重启、令牌隐藏和关闭,不调用外部模型。 +app-peer 的真实内核测试也使用新传输。 + +见 [ADR 0003(英文)](../../docs/adr/0003-shared-octos-client-access.md)。 +本次修改的 Android APK 打包及真机行为仍**未验证**。 diff --git a/crates/kernel/src/kernel.rs b/crates/kernel/src/kernel.rs index 1996a839..9d5677eb 100644 --- a/crates/kernel/src/kernel.rs +++ b/crates/kernel/src/kernel.rs @@ -12,7 +12,10 @@ use tokio::sync::{mpsc, watch}; use crate::launch::Launch; use crate::router::{ConnId, Router}; -use crate::{CloseReason, LogSink}; +use crate::{CloseReason, LogSink, ClientAccess}; +use crate::network::Network; +use std::path::PathBuf; +use std::sync::atomic::Ordering; /// What a consumer's inbound channel carries. #[derive(Debug)] @@ -45,6 +48,7 @@ struct Io { writer: Pin>, lines: tokio::io::Lines>>>, running: Running, + network: bool, } /// Keeps the last lines the kernel wrote to stderr, to say why it exited. @@ -64,9 +68,10 @@ impl Tail { } } -fn start(launch: &Launch, log: &LogSink, tail: &Tail) -> Result { +fn start(launch: &Launch, network: &Network, log: &LogSink, tail: &Tail) -> Result { match launch { - Launch::Stdio { program, args, env, cwd } => { + Launch::Stdio { program, args, env, cwd } | Launch::WebSocket { program, args, env, cwd } => { + let shared = matches!(launch, Launch::WebSocket { .. }); let mut command = tokio::process::Command::new(program); command .args(args) @@ -77,6 +82,11 @@ fn start(launch: &Launch, log: &LogSink, tail: &Tail) -> Result { for (k, v) in env { command.env(k, v); } + if shared { + command.args(["--port", &network.port.load(Ordering::Relaxed).to_string()]) + .env("OCTOS_AUTH_TOKEN", &network.token).env("NO_COLOR", "1") + .env_remove("OCTOS_INSTANCE_DATA_DIR"); + } if let Some(cwd) = cwd { command.current_dir(cwd); } @@ -89,9 +99,11 @@ fn start(launch: &Launch, log: &LogSink, tail: &Tail) -> Result { if let Some(stderr) = child.stderr.take() { let log = log.clone(); let tail = tail.clone(); + let token = network.token.clone(); tokio::spawn(async move { let mut lines = BufReader::new(stderr).lines(); while let Ok(Some(line)) = lines.next_line().await { + let line = line.replace(&token, "[redacted]"); (log)(&format!("octos: {line}")); tail.push(line); } @@ -101,6 +113,7 @@ fn start(launch: &Launch, log: &LogSink, tail: &Tail) -> Result { writer: Box::pin(stdin), lines: BufReader::new(Box::pin(stdout) as Pin>).lines(), running: Running::Child(child), + network: shared, }) } #[cfg(target_env = "ohos")] @@ -119,6 +132,7 @@ fn start(launch: &Launch, log: &LogSink, tail: &Tail) -> Result { writer: Box::pin(writer), lines: BufReader::new(Box::pin(reader) as Pin>).lines(), running: Running::Embedded(task), + network: false, }) } #[cfg(not(target_env = "ohos"))] @@ -126,6 +140,33 @@ fn start(launch: &Launch, log: &LogSink, tail: &Tail) -> Result { } } + +async fn prepare_network( + io: &mut Io, network: &Network, core_dir: &std::path::Path, + ready: &watch::Sender>>, +) -> Result<(), String> { + if !io.network { + ready.send_replace(Some(Err(CloseReason::Failed("This platform uses an embedded/private pipe kernel; external clients are unavailable.".into())))); + return Ok(()); + } + tokio::time::timeout(Duration::from_secs(90), async { + let access = loop { + let line = io.lines.next_line().await.map_err(|e| e.to_string())? + .ok_or_else(|| "the kernel exited before opening its WebSocket listener; use an OctoSense kernel built with the host-managed overlay".to_owned())?; + if let Some(access) = network.announced(&line) { break access; } + }; + let stream = crate::network::connect(&access).await?; + let (reader, writer) = tokio::io::split(stream); + let stdout = std::mem::replace(&mut io.lines, BufReader::new(Box::pin(reader) as Pin>).lines()); + io.writer = Box::pin(writer); + // Drain without logging: the server also prints its pairing code here. + tokio::spawn(async move { let mut lines = stdout; while let Ok(Some(_)) = lines.next_line().await {} }); + access.save(core_dir).map_err(|e| format!("cannot save private client connection: {e}"))?; + ready.send_replace(Some(Ok(access))); + Ok(()) + }).await.map_err(|_| "the kernel did not become ready within 90 seconds".to_owned())? +} + /// The embedded core logs through `tracing`; send it to the log sink, once /// per process (as AppCard's embedded transport did). #[cfg(target_env = "ohos")] @@ -166,8 +207,11 @@ async fn exited(running: &mut Running) -> String { /// Stop the kernel: close its input (it drains owned turns on EOF), wait a /// grace period, then kill it and wait, so its data-dir lock is released /// before a next generation starts. -async fn stop(io: Io) { - let Io { writer, lines, mut running } = io; +async fn stop(mut io: Io) { + if io.network { + let _ = tokio::time::timeout(Duration::from_secs(1), io.writer.write_all(b"{\"jsonrpc\":\"2.0\",\"id\":\"host-stop\",\"method\":\"server/shutdown\",\"params\":{}}\n")).await; + } + let Io { writer, lines, mut running, .. } = io; drop(writer); drop(lines); match &mut running { @@ -186,20 +230,29 @@ async fn stop(io: Io) { } } -/// Run generation `generation` until it is stopped or its kernel ends. +/// Settings that remain fixed for one kernel generation. +pub(crate) struct GenerationConfig { + pub generation: u64, + pub launch: Launch, + pub network: Arc, + pub core_dir: PathBuf, + pub log: LogSink, +} + +/// Run a generation until it is stopped or its kernel ends. pub(crate) async fn supervise( - generation: u64, - launch: Launch, + config: GenerationConfig, + ready: watch::Sender>>, mut ctl: mpsc::UnboundedReceiver, done: watch::Sender, - log: LogSink, ended: impl FnOnce() + Send, ) { + let GenerationConfig { generation, launch, network, core_dir, log } = config; let mut consumers: HashMap> = HashMap::new(); - let mut router = Router::default(); + let mut router = Router::new(&core_dir); let tail = Tail::default(); (log)(&format!("octos-core: starting kernel {generation}: {}", describe(&launch))); - let started = start(&launch, &log, &tail); + let started = start(&launch, &network, &log, &tail); let reason = match started { Err(e) => { (log)(&format!("octos-core: kernel {generation} did not start: {e}")); @@ -208,10 +261,30 @@ pub(crate) async fn supervise( CloseReason::Failed(e) } Ok(mut io) => { - let reason = loop { + let mut queued = VecDeque::new(); + // Keep ownership of the child while waiting for startup so Stop + // can always kill AND reap it before the next generation starts. + let startup = { + let setup = prepare_network(&mut io, &network, &core_dir, &ready); + tokio::pin!(setup); + loop { + tokio::select! { + biased; + msg = ctl.recv() => match msg { + Some(Ctl::Attach(id, tx)) => { router.attach(id); 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), + None => break Err(CloseReason::Shutdown), + }, + result = &mut setup => break result.map_err(CloseReason::Failed), + } + } + }; + let reason = if let Err(reason) = startup { reason } else { loop { tokio::select! { biased; - msg = ctl.recv() => match msg { + msg = async { if queued.is_empty() { ctl.recv().await } else { queued.pop_front() } } => match msg { Some(Ctl::Attach(id, tx)) => { router.attach(id); consumers.insert(id, tx); @@ -251,12 +324,19 @@ pub(crate) async fn supervise( }, why = exited(&mut io.running) => break CloseReason::Exited(with_tail(&why, &tail)), } - }; + }}; (log)(&format!("octos-core: stopping kernel {generation}: {reason}")); stop(io).await; - reason + match reason { + CloseReason::Failed(why) => CloseReason::Failed(with_tail(&why, &tail)), + other => other, + } } }; + ready.send_replace(Some(Err(reason.clone()))); + if matches!(launch, Launch::WebSocket { .. }) { + let _ = std::fs::remove_file(core_dir.join(crate::CONNECTION_FILE)); + } // Every consumer of this generation learns why it ended — including one // whose Attach is still queued. ctl.close(); @@ -283,7 +363,7 @@ fn with_tail(why: &str, tail: &Tail) -> String { fn describe(launch: &Launch) -> String { match launch { - Launch::Stdio { program, args, .. } => format!("{} {}", program.display(), args.join(" ")), + Launch::Stdio { program, args, .. } | Launch::WebSocket { program, args, .. } => format!("{} {}", program.display(), args.join(" ")), Launch::Embedded { home } => format!("embedded core, home {}", home.display()), } } diff --git a/crates/kernel/src/launch.rs b/crates/kernel/src/launch.rs index a93b7e1e..e715f7bb 100644 --- a/crates/kernel/src/launch.rs +++ b/crates/kernel/src/launch.rs @@ -1,14 +1,14 @@ //! How a kernel starts on this platform. Resolved afresh for every start, so //! a changed configuration applies on the next start. //! -//! - **Android**: `/liboctos.so serve --stdio`, with +//! - **Android**: `/liboctos.so serve --host 127.0.0.1 --host-managed`, with //! `HOME=` and the kernel home as cwd. An app may exec only //! from its nativeLibraryDir, so the APK bundles the kernel as a "library" //! (`MAKEPAD_ANDROID_EXTRA_LIBS=liboctos.so=`). The environment and //! the kernel config merge are the ones AppCard's `stdio_spawn` used. //! - **OpenHarmony**: the canonical core in-process //! (`octos_cli::embedded::serve_io`); HAP native libraries cannot exec. -//! - **Desktop**: ` serve --stdio --data-dir --config +//! - **Desktop**: ` serve --host 127.0.0.1 --host-managed --data-dir --config //! /config.json` with `OCTOS_HOME=`, where the program //! is the shell's [`crate::Options::program`] or `$OCTOS_APP_CORE_BIN`. //! Without one there is no kernel (a developer's own `octos serve` is never @@ -22,6 +22,13 @@ use crate::dirs; /// A resolved way to start the kernel. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Launch { + /// A loopback HTTP/WebSocket server shared by native and external clients. + WebSocket { + program: PathBuf, + args: Vec, + env: Vec<(String, String)>, + cwd: Option, + }, /// `program args…` speaking NDJSON JSON-RPC on stdin/stdout. Stdio { program: PathBuf, @@ -33,6 +40,21 @@ pub enum Launch { Embedded { home: PathBuf }, } +impl Launch { + pub(crate) fn websocket(self) -> Self { + match self { + Self::Stdio { program, mut args, env, cwd } => { + args.retain(|arg| arg != "--stdio"); + // The reviewed kernel overlay keeps authentication mandatory while + // running the profile in this process, without gateway children. + args.extend(["--host".into(), "127.0.0.1".into(), "--host-managed".into()]); + Self::WebSocket { program, args, env, cwd } + } + other => other, + } + } +} + /// Why no kernel can start here. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Unavailable { @@ -178,12 +200,12 @@ pub(crate) fn prepare(launch: &Launch, core_dir: &Path) { log::warn!("octos-core: could not create {}: {e}", core_dir.display()); } match launch { - Launch::Stdio { cwd: Some(cwd), .. } => { + Launch::Stdio { cwd: Some(cwd), .. } | Launch::WebSocket { cwd: Some(cwd), .. } => { if let Err(e) = std::fs::create_dir_all(cwd) { log::warn!("octos-core: could not create {}: {e}", cwd.display()); } } - Launch::Stdio { .. } => {} + Launch::Stdio { .. } | Launch::WebSocket { .. } => {} Launch::Embedded { home } => { let _ = std::fs::create_dir_all(home); } diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 68029f43..b4b9cdfa 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -2,8 +2,7 @@ //! //! The octos agent kernel is a shell service. A shell (the phone's Home in //! `phone/`, the desktop shell in `desktop/`) [`configure`]s it once at -//! startup; every consumer — the AppCard assistant today, Rinx's native -//! mini-app host next — calls [`connect`] and gets its own [`Connection`] +//! startup; every consumer — AppCard and Rinx's native mini-app host — calls [`connect`] and gets its own [`Connection`] //! to the ONE kernel of the process. The AI providers app's `llm` host service writes the kernel's //! profile under [`core_dir`] and calls [`restart`] after a change. //! @@ -11,16 +10,15 @@ //! next ones share it. octos holds a single-writer lock on its data dir, //! so two kernels on one core dir could not coexist anyway. //! - **Shared by frames.** A connection carries UI Protocol (JSON-RPC) -//! frames, one per `send`/`recv`, exactly as `octos serve --stdio` speaks -//! them. Each consumer uses its own request ids and sees the replies to its +//! frames, one per `send`/`recv`, over the shared WebSocket connection. Each consumer uses its own request ids and sees the replies to its //! own requests and the notifications of the sessions it opened (see //! `router`). //! - **Restart.** [`restart`] stops a running kernel (a no-op when none //! runs); each connection's `recv` then returns -//! [`CloseReason::Restarted`], and the consumer reconnects, which starts a -//! fresh kernel that reads the new profile. -//! - **Idle stop.** When the last connection is dropped the kernel stops, as -//! AppCard's own `kill_on_drop` child did. +//! [`CloseReason::Restarted`], and the consumer reconnects. The service starts a +//! fresh server at the same address with the same token. +//! - **Lifetime.** The shared server survives native app closure so external +//! clients remain usable. Private pipe/embedded mode still stops when idle. //! - **Shutdown.** [`shutdown`] stops it and waits. //! //! Where it runs: see [`launch`]. The kernel and this crate's frame pump run @@ -35,6 +33,8 @@ use tokio::sync::{mpsc, watch}; pub mod dirs; mod kernel; +mod network; +pub use network::{ClientAccess, CONNECTION_FILE, SYSTEM_SESSION}; pub mod launch; mod router; @@ -59,6 +59,7 @@ pub struct Options { program: Option, env: Vec<(String, String)>, log: Option, + stdio: bool, } impl std::fmt::Debug for Options { @@ -73,6 +74,10 @@ impl std::fmt::Debug for Options { } impl Options { + /// Private pipe mode for embedding hosts and protocol fixtures. The + /// desktop and Android defaults are a shared loopback WebSocket server. + pub fn stdio(mut self) -> Self { self.stdio = true; self } + /// The kernel's core dir (octos data dir, `/profiles/_main.json`). /// Default: see [`resolve_core_dir`]. pub fn core_dir(mut self, dir: impl Into) -> Self { @@ -145,6 +150,8 @@ struct Generation { id: u64, ctl: mpsc::UnboundedSender, connections: usize, + shared: bool, + ready: watch::Receiver>>, } struct State { @@ -159,6 +166,7 @@ struct State { struct Inner { state: Mutex, runtime: OnceLock, + network: Arc, } impl Inner { @@ -213,6 +221,7 @@ impl Core { next_conn: 0, }), runtime: OnceLock::new(), + network: Arc::default(), })) } @@ -240,11 +249,21 @@ impl Core { fn launch_of(options: &Options) -> Result { let core_dir = Self::core_dir_of(options); - launch::resolve(&launch::Inputs { + let launch = launch::resolve(&launch::Inputs { core_dir: core_dir.as_deref(), program: options.program.as_deref(), env: &options.env, - }) + })?; + let mut launch = if options.stdio { launch } else { launch.websocket() }; + if let Launch::WebSocket { env, .. } = &mut launch { + if let Some(dir) = core_dir.as_ref() { + if let Ok(origin) = std::fs::read_to_string(dir.join("web-client-origin.txt")) { + let origin = network::validate_origin(origin.trim()).map_err(Unavailable::NoKernel)?; + env.push(("OCTOS_APPUI_ALLOWED_ORIGINS".into(), origin)); + } + } + } + Ok(launch) } /// Connect to the kernel, starting it if none runs. Fails only when no @@ -265,6 +284,7 @@ impl Core { generation: current.id, ctl: current.ctl.clone(), inbound: rx, + ready: current.ready.clone(), closed: None, }); } @@ -279,10 +299,13 @@ impl Core { 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"); - st.current = Some(Generation { id, ctl: ctl.clone(), connections: 1 }); + 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() }); drop(st); let weak = Arc::downgrade(&self.0); + let network = self.0.network.clone(); self.0.runtime().spawn(async move { // Wait until the previous kernel let go of the data dir, then // make the directories (and the phone's kernel config). @@ -298,17 +321,21 @@ impl Core { } } }; - kernel::supervise(id, launch, ctl_rx, done_tx, log, ended).await; + let config = kernel::GenerationConfig { generation: id, launch, network, core_dir, log }; + kernel::supervise(config, ready_tx, ctl_rx, done_tx, ended).await; }); - Ok(Connection { core: self.clone(), id: conn, generation: id, ctl, inbound: rx, closed: None }) + Ok(Connection { core: self.clone(), id: conn, generation: id, ctl, inbound: rx, ready, closed: None }) } /// Restart the kernel if one runs: its connections close with - /// [`CloseReason::Restarted`] and the next `connect()` starts a fresh - /// kernel (after this one let go of its data dir). Returns whether one + /// [`CloseReason::Restarted`]. Shared mode starts a replacement immediately; + /// pipe mode waits for the next consumer. Returns whether one /// was running. Callable from any thread. pub fn restart(&self) -> bool { - self.stop_current(CloseReason::Restarted) + let shared = self.0.state.lock().unwrap().current.as_ref().is_some_and(|g| g.shared); + let running = self.stop_current(CloseReason::Restarted); + if running && shared { let _ = self.connect(); } + running } /// Stop the kernel (if any) and wait up to `timeout` for it to exit. @@ -351,8 +378,19 @@ impl Core { } } - /// A connection left: when it was the last of the running generation, - /// the kernel stops. + /// Start the shared server and wait for its authenticated endpoint. For + /// a host worker thread; never block the UI thread on kernel startup. + pub fn client_access(&self) -> Result { + let connection = self.connect().map_err(|e| e.to_string())?; + let (tx, rx) = std::sync::mpsc::sync_channel(1); + self.0.runtime().spawn(async move { + let result = connection.client_access().await.map_err(|e| e.to_string()); + let _ = tx.send(result); + }); + rx.recv_timeout(Duration::from_secs(95)).map_err(|_| "The Octos server did not become ready.".to_string())? + } + + /// A connection left. Only private pipe/embedded kernels stop when idle. fn detach(&self, generation: u64, conn: u64) { let mut st = self.0.state.lock().unwrap(); let Some(current) = st.current.as_mut().filter(|g| g.id == generation) else { @@ -360,7 +398,7 @@ impl Core { }; let _ = current.ctl.send(Ctl::Detach(conn)); current.connections = current.connections.saturating_sub(1); - if current.connections == 0 { + if current.connections == 0 && !current.shared { let idle = st.current.take().expect("checked above"); let _ = idle.ctl.send(Ctl::Stop(CloseReason::Shutdown)); } @@ -376,6 +414,7 @@ pub struct Connection { ctl: mpsc::UnboundedSender, inbound: mpsc::UnboundedReceiver, closed: Option, + ready: watch::Receiver>>, } impl std::fmt::Debug for Connection { @@ -385,6 +424,48 @@ impl std::fmt::Debug for Connection { } impl Connection { + /// Wait for the actual, authenticated server. The token is for trusted + /// host UI only. Pipe-only platforms return an explicit error. + pub async fn client_access(&self) -> Result { + let mut ready = self.ready.clone(); + ready.wait_for(|v| v.is_some()).await.map_err(|_| CloseReason::Shutdown)?; + let result = ready.borrow().as_ref().cloned().unwrap(); + result + } + + /// Open the system conversation and build OctosCode Web's saved-session + /// link from the workspace the server confirms. No credential is in it. + pub async fn system_web_url(&mut self, origin: &str) -> Result { + let origin = network::validate_origin(origin)?; + if origin.is_empty() { return Err("Save the web client's origin first.".into()); } + let id = uuid::Uuid::new_v4().to_string(); + self.send(serde_json::json!({"jsonrpc":"2.0","id":id,"method":"session/open", + "params":{"session_id":SYSTEM_SESSION,"profile_id":"_main"}}).to_string()) + .map_err(|e| e.to_string())?; + tokio::time::timeout(Duration::from_secs(15), async { + loop { + let frame: serde_json::Value = serde_json::from_str(&self.recv().await.map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + if frame["id"] != id { continue; } + if frame.get("error").is_some() { return Err("Octos could not open its system conversation. Configure a provider first.".into()); } + let opened = &frame["result"]["opened"]; + let workspace = opened["workspace_root"].as_str().filter(|s| !s.is_empty()) + .ok_or("The kernel did not confirm the system workspace.")?; + // Web sends this path back as cwd; Octos canonicalizes it. + // Match that identity on hosts with aliases (/tmp on macOS, + // /data/data on Android) so Web's workspace check succeeds. + let workspace = std::fs::canonicalize(workspace) + .map_err(|_| "The system workspace could not be resolved.")?; + let dir = self.core.core_dir().ok_or("No kernel data directory.")?; + network::save_system_workspace(&dir, &workspace)?; + let mut url = url::Url::parse(&origin).map_err(|e| e.to_string())?; + let reference = serde_json::json!([workspace,"_main",SYSTEM_SESSION]).to_string(); + url.query_pairs_mut().append_pair("s", &reference); + return Ok(url.to_string()); + } + }).await.map_err(|_| "Opening the system conversation timed out.".to_owned())? + } + /// Send one JSON-RPC frame (no trailing newline needed). Never blocks. pub fn send(&self, frame: impl Into) -> Result<(), CloseReason> { if let Some(reason) = &self.closed { @@ -465,6 +546,47 @@ pub fn connect() -> Result { global().connect() } +/// Connection details for a trusted host sheet (run on a worker thread). +pub fn client_access() -> Result { + global().client_access() +} + +/// A credential-free link to the system conversation (host worker only). +/// The user still supplies the server origin and token in the web client. +pub fn web_client_url() -> Result { + let core = global(); + let mut connection = core.connect().map_err(|e| e.to_string())?; + let origin = web_client_origin(); + let (tx, rx) = std::sync::mpsc::sync_channel(1); + core.0.runtime().spawn(async move { + let result = async { + connection.client_access().await.map_err(|e| e.to_string())?; + connection.system_web_url(&origin).await + }.await; + let _ = tx.send(result); + }); + rx.recv_timeout(Duration::from_secs(110)).map_err(|_| "Opening the web client timed out.".to_string())? +} + +/// The web client's explicitly trusted origin, configured on the host sheet. +pub fn web_client_origin() -> String { + core_dir().and_then(|dir| std::fs::read_to_string(dir.join("web-client-origin.txt")).ok()).unwrap_or_default() +} + +/// Change the allowed web origin and restart the server. No wildcards, +/// credentials, paths, queries or fragments are accepted. +pub fn set_web_client_origin(origin: &str) -> Result<(), String> { + let normalized = network::validate_origin(origin.trim())?; + let dir = core_dir().ok_or_else(|| "No kernel data directory.".to_string())?; + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + let path = dir.join("web-client-origin.txt"); + if std::fs::read_to_string(&path).unwrap_or_default() != normalized { + std::fs::write(path, normalized).map_err(|e| e.to_string())?; + restart(); + } + Ok(()) +} + /// Restart the process's kernel if it runs (after a provider change). pub fn restart() -> bool { global().restart() diff --git a/crates/kernel/src/network.rs b/crates/kernel/src/network.rs new file mode 100644 index 00000000..5d20e465 --- /dev/null +++ b/crates/kernel/src/network.rs @@ -0,0 +1,197 @@ +//! The shell-owned server's authenticated loopback connection. +//! A server binds port zero on first start; its actual port and token survive +//! provider restarts. Stdout is private (it also carries Octos's pairing code). + +use std::path::Path; +use std::sync::atomic::{AtomicU16, Ordering}; +use std::time::Duration; + +use futures_util::{SinkExt, StreamExt}; +use serde_json::json; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio_tungstenite::tungstenite::{client::IntoClientRequest, Message}; + +pub const SYSTEM_SESSION: &str = "_main:api:octosense#system"; +pub const CONNECTION_FILE: &str = "client-connection.json"; +pub(crate) const SYSTEM_WORKSPACE_FILE: &str = "system-workspace.txt"; + +pub(crate) fn save_system_workspace(dir: &Path, workspace: &Path) -> Result<(), String> { + let path = dir.join(SYSTEM_WORKSPACE_FILE); + let tmp = dir.join(format!(".system-workspace-{}", uuid::Uuid::new_v4())); + let result = (|| { + std::fs::write(&tmp, workspace.to_string_lossy().as_bytes())?; + std::fs::rename(&tmp, path) + })(); + let _ = std::fs::remove_file(tmp); + result.map_err(|e: std::io::Error| format!("Could not save the system workspace: {e}")) +} + +// Preserve the pinned kernel's stdio_defaults feature contract for native +// consumers. External clients negotiate their own features independently. +const NATIVE_FEATURES: &str = concat!( + "approval.typed.v1,pane.snapshots.v1,session.workspace_cwd.v1,session.sandbox.v1,", + "harness.task_control.v1,harness.task_artifacts.v1,state.session_hydrate.v1,", + "state.thread_graph.v1,state.turn_state_get.v1,event.spawn_complete.v1,", + "event.file_attached.v1,event.voice_audio.v1,voice.asr_admission.v1,plan.todos.v1,", + "event.background_activity.v1,auxiliary.rest_to_ws.v1,coding.autonomy.v1,", + "coding.agent_control.v1,coding.goal_runtime.v1,coding.loop_runtime.v1,", + "coding.monitor_runtime.v1,review.start.v1,context.lifecycle.v1,user_question.v1,", + "skill.actions.v1,skill.action_jobs.v1,event.turn_steer_dropped.v1" +); + +pub(crate) fn validate_origin(origin: &str) -> Result { + if origin.is_empty() { return Ok(String::new()); } + let url = url::Url::parse(origin).map_err(|_| "Enter a web client origin such as http://localhost:4173".to_string())?; + if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none_or(|h| h.contains('*')) + || !url.username().is_empty() || url.password().is_some() || url.path() != "/" + || url.query().is_some() || url.fragment().is_some() + { + return Err("Use an http(s) origin without a path, credentials, query or fragment.".into()); + } + Ok(url.origin().ascii_serialization()) +} + +/// For a trusted host sheet or a user-owned client. Never pass to an app +/// script, log it, or put its token in a command line. +#[derive(Clone)] +pub struct ClientAccess { + pub origin: String, + pub token: String, +} + +impl std::fmt::Debug for ClientAccess { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ClientAccess").field("origin", &self.origin).finish_non_exhaustive() + } +} + +impl ClientAccess { + pub fn endpoint(&self) -> String { + format!("ws://{}/api/ui-protocol/ws", self.origin.trim_start_matches("http://")) + } + + pub(crate) fn save(&self, dir: &Path) -> std::io::Result<()> { + use std::io::Write; + let path = dir.join(CONNECTION_FILE); + let tmp = dir.join(format!(".client-connection-{}", uuid::Uuid::new_v4())); + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let result = (|| { + let mut file = options.open(&tmp)?; + let data = json!({"origin": self.origin, "endpoint": self.endpoint(), "token": self.token, + "profile_id": "_main", "session_id": SYSTEM_SESSION, "pid": std::process::id()}); + file.write_all(data.to_string().as_bytes())?; + std::fs::rename(&tmp, &path) + })(); + let _ = std::fs::remove_file(tmp); + result + } +} + +pub(crate) struct Network { + pub port: AtomicU16, + pub token: String, +} + +impl Default for Network { + fn default() -> Self { + Self { port: AtomicU16::new(0), token: format!("{}{}", uuid::Uuid::new_v4().simple(), uuid::Uuid::new_v4().simple()) } + } +} + +impl Network { + /// Parse only the pinned server's listener announcement, with NO_COLOR. + /// Never infer readiness from a token, a pairing URL or arbitrary logs. + pub fn announced(&self, line: &str) -> Option { + let origin = line.strip_prefix("Listening: ")?; + let url = url::Url::parse(origin).ok()?; + if url.scheme() != "http" || url.host_str() != Some("127.0.0.1") + || !url.username().is_empty() || url.password().is_some() + || url.path() != "/" || url.query().is_some() || url.fragment().is_some() + { + return None; + } + let port = url.port().filter(|p| *p != 0)?; + let expected = self.port.load(Ordering::Relaxed); + if expected != 0 && expected != port { return None; } + self.port.store(port, Ordering::Relaxed); + Some(ClientAccess { origin: format!("http://127.0.0.1:{port}"), token: self.token.clone() }) + } +} + +/// Native consumers keep their existing frame API. This private adapter +/// carries those frames over the same WebSocket endpoint external clients use. +pub(crate) async fn connect(access: &ClientAccess) -> Result { + let mut request = access.endpoint().into_client_request().map_err(|e| e.to_string())?; + request.headers_mut().insert("Authorization", format!("Bearer {}", access.token).parse().unwrap()); + request.headers_mut().insert("X-Profile-Id", "_main".parse().unwrap()); + request.headers_mut().insert("X-Octos-Ui-Features", NATIVE_FEATURES.parse().unwrap()); + // The startup announcement precedes axum's accept loop by a few statements. + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + let socket = loop { + match tokio_tungstenite::connect_async(request.clone()).await { + Ok((socket, _)) => break socket, + Err(tokio_tungstenite::tungstenite::Error::Io(_)) if tokio::time::Instant::now() < deadline => { + tokio::time::sleep(Duration::from_millis(50)).await; + } + Err(_) => return Err("the kernel's authenticated WebSocket connection failed".into()), + } + }; + let (client, bridge) = tokio::io::duplex(1024 * 1024); + tokio::spawn(async move { + let (reader, mut writer) = tokio::io::split(bridge); + let mut lines = BufReader::new(reader).lines(); + let (mut sink, mut stream) = socket.split(); + loop { + tokio::select! { + line = lines.next_line() => match line { + Ok(Some(text)) => if sink.send(Message::Text(text)).await.is_err() { break; }, + _ => break, + }, + frame = stream.next() => match frame { + Some(Ok(Message::Text(text))) => { + if writer.write_all(text.as_bytes()).await.is_err() || writer.write_all(b"\n").await.is_err() { break; } + } + Some(Ok(Message::Ping(bytes))) => if sink.send(Message::Pong(bytes)).await.is_err() { break; }, + Some(Ok(Message::Pong(_))) => {}, + _ => break, + }, + } + } + let _ = sink.close().await; + }); + Ok(client) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_explicit_web_origins_are_accepted() { + for invalid in ["*", "http://*.example.com", "file:///tmp/client", "https://u:p@example.com", + "https://example.com/app", "https://example.com?token=secret", "https://example.com/#x"] { + assert!(validate_origin(invalid).is_err(), "{invalid}"); + } + assert_eq!(validate_origin("http://localhost:4173/").unwrap(), "http://localhost:4173"); + assert_eq!(validate_origin("").unwrap(), ""); + } + + #[test] + fn listener_discovery_rejects_untrusted_and_changed_addresses() { + let network = Network::default(); + for invalid in ["Pair: http://127.0.0.1:1234", "Listening: http://0.0.0.0:1234", + "Listening: http://127.0.0.1:1234/?token=x", "Listening: http://127.0.0.1:0"] { + assert!(network.announced(invalid).is_none()); + } + let first = network.announced("Listening: http://127.0.0.1:1234").unwrap(); + assert!(network.announced("Listening: http://127.0.0.1:1235").is_none()); + let second = network.announced("Listening: http://127.0.0.1:1234").unwrap(); + assert!(first.token == second.token); + } +} diff --git a/crates/kernel/src/router.rs b/crates/kernel/src/router.rs index 141b202f..1adb67b4 100644 --- a/crates/kernel/src/router.rs +++ b/crates/kernel/src/router.rs @@ -1,6 +1,6 @@ -//! Sharing one kernel pipe between consumers. +//! Sharing one kernel frame stream between native consumers. //! -//! `octos serve --stdio` (and `serve_io`) speaks one NDJSON JSON-RPC stream. +//! The WebSocket adapter (or private stdio/embedded mode) presents NDJSON JSON-RPC. //! Each consumer speaks the same protocol as if it owned that stream; the //! router keeps them apart: //! @@ -13,7 +13,8 @@ //! - frames without an id to rewrite (a consumer's notification, its //! response to a kernel request) pass through untouched. //! -//! Pure bookkeeping, no I/O, so it is tested frame by frame. +//! 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. use std::collections::{BTreeSet, HashMap}; @@ -33,9 +34,13 @@ pub(crate) struct Router { pending: HashMap, sessions: HashMap>, conns: BTreeSet, + system_workspace_file: Option, } impl Router { + pub(crate) fn new(core_dir: &std::path::Path) -> Self { + Self { system_workspace_file: Some(core_dir.join(crate::network::SYSTEM_WORKSPACE_FILE)), ..Self::default() } + } pub(crate) fn attach(&mut self, conn: ConnId) { self.conns.insert(conn); } @@ -62,6 +67,20 @@ impl Router { let Some(obj) = frame.as_object_mut() else { return Some(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) { + if params.get("session_id").and_then(Value::as_str) == Some(crate::SYSTEM_SESSION) + && !params.contains_key("cwd") + { + if let Some(workspace) = self.system_workspace_file.as_ref() + .and_then(|path| std::fs::read_to_string(path).ok()) + .filter(|path| std::path::Path::new(path).is_absolute()) + { + params.insert("cwd".into(), workspace.into()); + } + } + } + } if let Some(session) = obj.get("params").and_then(session_of) { self.subscribe(session, conn); } diff --git a/crates/kernel/tests/core.rs b/crates/kernel/tests/core.rs index cae7b4df..aabc54a4 100644 --- a/crates/kernel/tests/core.rs +++ b/crates/kernel/tests/core.rs @@ -24,7 +24,7 @@ fn core(tag: &str) -> (Core, PathBuf) { let log = dir.with_extension("log"); let _ = std::fs::remove_file(&log); let core = Core::new( - Options::default() + Options::default().stdio() .core_dir(&dir) .program(fake_kernel()) .env("FAKE_KERNEL_LOG", log.to_string_lossy()), @@ -61,6 +61,34 @@ async fn gone(pid: u64) -> bool { false } +#[tokio::test(flavor = "multi_thread")] +async fn shutdown_during_websocket_startup_reaps_the_child() { + let dir = core_dir("pending-listener"); + let log = dir.with_extension("log"); + let _ = std::fs::remove_file(&log); + // This fixture never announces a listener. Stop must interrupt readiness, + // not wait for its 90-second timeout or leak a child/data-directory lock. + let core = Core::new(Options::default().program(fake_kernel()).core_dir(&dir) + .env("FAKE_KERNEL_LOG", log.to_string_lossy())); + let mut conn = core.connect().unwrap(); + let started = tokio::time::timeout(Duration::from_secs(5), async { + loop { + if let Ok(text) = std::fs::read_to_string(&log) { + if let Ok(value) = serde_json::from_str::(text.trim()) { break value; } + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }).await.unwrap(); + let pid = started["pid"].as_u64().unwrap(); + assert!(alive(pid)); + core.shutdown_within(Duration::from_secs(5)); + assert!(gone(pid).await); + assert_eq!(next(&mut conn).await.unwrap_err(), CloseReason::Shutdown); + assert!(!dir.join(octosense_kernel::CONNECTION_FILE).exists()); + let _ = std::fs::remove_file(log); + let _ = std::fs::remove_dir_all(dir); +} + #[tokio::test(flavor = "multi_thread")] async fn one_kernel_serves_every_consumer() { let (core, log) = core("single"); @@ -173,7 +201,7 @@ async fn shutdown_stops_and_waits() { #[test] fn no_kernel_binary_means_no_connection() { - let core = Core::new(Options::default().core_dir(core_dir("none")).program("/nonexistent/octos")); + let core = Core::new(Options::default().stdio().core_dir(core_dir("none")).program("/nonexistent/octos")); assert!(matches!(core.connect(), Err(Unavailable::NoKernel(_)))); assert!(!core.status().running); } @@ -185,7 +213,7 @@ async fn a_kernel_that_cannot_start_fails_the_connection() { std::fs::create_dir_all(&dir).unwrap(); let program = dir.join("octos"); std::fs::write(&program, "not a program").unwrap(); - let core = Core::new(Options::default().core_dir(dir.join("core")).program(&program)); + let core = Core::new(Options::default().stdio().core_dir(dir.join("core")).program(&program)); let mut a = core.connect().unwrap(); assert!(matches!(next(&mut a).await, Err(CloseReason::Failed(_)))); } diff --git a/crates/kernel/tests/real_kernel.rs b/crates/kernel/tests/real_kernel.rs index 43041887..e46d59af 100644 --- a/crates/kernel/tests/real_kernel.rs +++ b/crates/kernel/tests/real_kernel.rs @@ -1,16 +1,8 @@ -//! A real `octos` kernel started by the core from a temp core dir whose -//! profile `octosense_llm_config` wrote, as the AI providers app's `llm` -//! service does. No provider is called: keys are fixtures and the kernel is -//! only asked what it runs on (`profile/llm/list`). -//! -//! Runs when `OCTOS_CORE_TEST_KERNEL` names an `octos` binary built from the -//! octos rev AppCard pins: -//! -//! ```sh -//! cargo build --release -p octos-cli --bin octos --no-default-features --features api,git,ast -//! ``` -//! -//! Without it the test says so and passes (CI has no kernel binary). +//! Real host-managed kernel tests: native/browser coexistence, authentication, +//! origin checks, provider restart, and shared system-agent turns using a local +//! scripted model. No external model or real credential is used. +//! Set OCTOS_CORE_TEST_KERNEL to the pinned binary with OctoSense's overlay. +//! Without it these integration tests are skipped (see README.md). use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -20,6 +12,46 @@ use std::time::Duration; use octosense_llm_config::{profile, Provider, ProviderSet}; use octosense_kernel::{CloseReason, Connection, Core, Options}; use serde_json::{json, Value}; +use futures_util::{SinkExt, StreamExt}; +use tokio_tungstenite::tungstenite::{client::IntoClientRequest, Message}; + +type Socket = tokio_tungstenite::WebSocketStream>; + +async fn solo_status(access: &octosense_kernel::ClientAccess, path: &str) -> String { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; + let mut tcp = tokio::net::TcpStream::connect(access.origin.trim_start_matches("http://")).await.unwrap(); + let body = r#"{"name":"Unauthorized app","username":"intruder","email":"intruder@solo.local"}"#; + tcp.write_all(format!("POST {path} HTTP/1.1\r\nHost: {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", access.origin.trim_start_matches("http://"), body.len()).as_bytes()).await.unwrap(); + let mut line = String::new(); + tokio::io::BufReader::new(tcp).read_line(&mut line).await.unwrap(); + line +} + +async fn external(access: &octosense_kernel::ClientAccess) -> Socket { + // Browsers cannot set Authorization; OctosCode Web uses the query token. + let mut req = format!("{}?token={}&ui_feature=state.session_hydrate.v1,session.workspace_cwd.v1", access.endpoint(), access.token).into_client_request().unwrap(); + req.headers_mut().insert("Origin", "http://localhost:4173".parse().unwrap()); + tokio_tungstenite::connect_async(req).await.map(|(s, _)| s).unwrap_or_else(|_| panic!("external client could not connect")) +} + +async fn ws_call(socket: &mut Socket, id: &str, method: &str, params: Value) -> Value { + socket.send(Message::Text(json!({"jsonrpc":"2.0", "id":id, "method":method, "params":params}).to_string())).await.unwrap(); + tokio::time::timeout(Duration::from_secs(30), async { + loop { + match socket.next().await.expect("external connection closed").expect("frame") { + Message::Text(text) => { + let frame: Value = serde_json::from_str(&text).unwrap(); + if frame["id"] == id { + assert!(frame.get("error").is_none(), "{method}: {frame}"); + return frame["result"].clone(); + } + } + Message::Ping(bytes) => socket.send(Message::Pong(bytes)).await.unwrap(), + _ => {}, + } + } + }).await.expect("external request timed out") +} async fn call(conn: &mut Connection, id: &str, method: &str, params: Value) -> Value { conn.send(json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string()).unwrap(); @@ -62,6 +94,7 @@ async fn a_real_kernel_runs_on_the_profile_the_providers_app_wrote() { let _ = std::fs::remove_dir_all(&dir); let core_dir = dir.join("octos-home/.octos"); write_provider(&core_dir, "deepseek", "deepseek-v4-flash"); + std::fs::write(core_dir.join("web-client-origin.txt"), "http://localhost:4173").unwrap(); let lines = Arc::new(Mutex::new(Vec::::new())); let sink = lines.clone(); @@ -77,6 +110,27 @@ async fn a_real_kernel_runs_on_the_profile_the_providers_app_wrote() { assert_eq!(open["opened"]["session_id"], "_main:octos-core-it"); assert_eq!(open["opened"]["active_profile_id"], "_main"); assert_eq!(running_on(&mut conn, "llm1").await, ("deepseek".into(), "deepseek-v4-flash".into())); + let access = conn.client_access().await.unwrap(); + assert!(!format!("{access:?}").contains(&access.token)); + let mut browser = external(&access).await; + let open = ws_call(&mut browser, "open", "session/open", json!({"session_id":"_main:octos-core-it", "profile_id":"_main"})).await; + assert_eq!(open["opened"]["session_id"], "_main:octos-core-it"); + let list = ws_call(&mut browser, "llm1", "profile/llm/list", json!({"profile_id":"_main"})).await; + assert_eq!(list["primary"]["model"], "deepseek-v4-flash"); + // Reject both a missing token and an untrusted browser origin. + assert!(tokio_tungstenite::connect_async(access.endpoint()).await.is_err()); + let mut spoofed = access.endpoint().into_client_request().unwrap(); + spoofed.headers_mut().insert("X-Profile-Id", "_main".parse().unwrap()); + assert!(tokio_tungstenite::connect_async(spoofed).await.is_err(), "another local APK cannot impersonate a trusted proxy"); + assert!(solo_status(&access, "/api/auth/solo").await.contains("403"), "no password-free local login"); + assert!(solo_status(&access, "/api/auth/solo/create").await.contains("403"), "no unauthenticated local owner creation"); + let mut denied = format!("{}?token={}", access.endpoint(), access.token).into_client_request().unwrap(); + denied.headers_mut().insert("Origin", "https://untrusted.example".parse().unwrap()); + assert!(tokio_tungstenite::connect_async(denied).await.is_err()); + #[cfg(unix)] { + use std::os::unix::fs::PermissionsExt; + assert_eq!(std::fs::metadata(core_dir.join(octosense_kernel::CONNECTION_FILE)).unwrap().permissions().mode() & 0o777, 0o600); + } // A second consumer shares that kernel. let mut other = core.connect().unwrap(); assert_eq!(other.generation(), conn.generation()); @@ -85,17 +139,32 @@ async fn a_real_kernel_runs_on_the_profile_the_providers_app_wrote() { // The providers app saves another provider and restarts the kernel. write_provider(&core_dir, "moonshot", "kimi-k2.5"); assert!(core.restart()); - assert_eq!(conn.recv().await, Err(CloseReason::Restarted)); - assert_eq!(other.recv().await, Err(CloseReason::Restarted)); + while conn.recv().await.is_ok() {} + assert_eq!(conn.closed(), Some(&CloseReason::Restarted)); + while other.recv().await.is_ok() {} + assert_eq!(other.closed(), Some(&CloseReason::Restarted)); let mut conn = core.connect().unwrap(); assert_eq!(conn.generation(), 2); call(&mut conn, "open", "session/open", json!({"session_id": "_main:octos-core-it", "profile_id": "_main"})).await; assert_eq!(running_on(&mut conn, "llm2").await, ("moonshot".into(), "kimi-k2.5".into())); + let restarted = conn.client_access().await.unwrap(); + assert_eq!(access.origin, restarted.origin, "external clients reconnect to the same port"); + assert!(access.token == restarted.token, "restart preserves the access token"); + drop(browser); + let mut browser = external(&access).await; + let list = ws_call(&mut browser, "llm2", "profile/llm/list", json!({"profile_id":"_main"})).await; + assert_eq!(list["primary"]["model"], "kimi-k2.5"); let log = lines.lock().unwrap().join("\n"); assert!(log.contains("Model: deepseek-v4-flash") && log.contains("Model: kimi-k2.5"), "{log}"); drop((conn, other)); - assert!(!core.status().running); + assert!(core.status().running, "external clients keep the shared server available"); + let list = ws_call(&mut browser, "after-native-close", "profile/llm/list", json!({"profile_id":"_main"})).await; + assert_eq!(list["primary"]["model"], "kimi-k2.5"); + drop(browser); + assert!(!lines.lock().unwrap().iter().any(|line| line.contains(&access.token)), "no access token in logs"); + assert!(core.shutdown_within(Duration::from_secs(5))); + assert!(!core_dir.join(octosense_kernel::CONNECTION_FILE).exists()); let _ = std::fs::remove_dir_all(&dir); } @@ -105,3 +174,90 @@ impl Drop for Defer { (self.0)() } } + +#[tokio::test(flavor = "multi_thread")] +async fn native_and_browser_talk_to_the_same_system_agent() { + use std::io::BufRead; + use octosense_kernel::SYSTEM_SESSION; + let Some(program) = std::env::var_os("OCTOS_CORE_TEST_KERNEL") else { return }; + let script = Path::new(env!("CARGO_MANIFEST_DIR")).join("../app-peers/tests/fixtures/mock_llm.py"); + let mut model = std::process::Command::new("python3").arg(script) + .stdout(std::process::Stdio::piped()).spawn().unwrap(); + let mut line = String::new(); + std::io::BufReader::new(model.stdout.take().unwrap()).read_line(&mut line).unwrap(); + let port: u16 = line.trim().parse().unwrap(); + let _model = Defer(move || { let _ = model.kill(); let _ = model.wait(); }); + let dir = std::env::temp_dir().join(format!("octos-shared-turn-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("profiles")).unwrap(); + std::fs::write(dir.join("profiles/_main.json"), json!({ + "id":"_main", "name":"Main", "enabled":true, + "created_at":"2026-09-27T00:00:00Z", "updated_at":"2026-09-27T00:00:00Z", + "config":{"llm":{"primary":{"family_id":"local", "model_id":"mock-model", + "route":{"base_url":format!("http://127.0.0.1:{port}/v1"), "api_type":"openai"}}}} + }).to_string()).unwrap(); + std::fs::write(dir.join("web-client-origin.txt"), "http://localhost:4173").unwrap(); + let core = Core::new(Options::default().program(program).core_dir(&dir)); + let mut native = core.connect().unwrap(); + let access = native.client_access().await.unwrap(); + let link = native.system_web_url("http://localhost:4173").await.unwrap(); + let url = url::Url::parse(&link).unwrap(); + assert!(!link.contains(&access.token)); + let reference: Value = serde_json::from_str(&url.query_pairs().find(|(k, _)| k == "s").unwrap().1).unwrap(); + assert!(reference[0].as_str().unwrap().starts_with('/')); + assert_eq!(reference[1], "_main"); + assert_eq!(reference[2], SYSTEM_SESSION); + let mut browser = external(&access).await; + let open = json!({"session_id":SYSTEM_SESSION,"profile_id":"_main"}); + call(&mut native, "open", "session/open", open.clone()).await; + let mut web_open = open; + web_open["cwd"] = reference[0].clone(); + let opened = ws_call(&mut browser, "open", "session/open", web_open).await; + assert_eq!(opened["opened"]["workspace_root"], reference[0], "Web confirms the canonical saved-link workspace"); + let input = |text| json!({"session_id":SYSTEM_SESSION,"turn_id":uuid::Uuid::new_v4(), + "input":[{"kind":"text","text":text}]}); + let first = input("hello from native"); + call(&mut native, "turn", "turn/start", first.clone()).await; + let hydrate = json!({"session_id":SYSTEM_SESSION,"include":["messages"]}); + tokio::time::timeout(Duration::from_secs(30), async { + loop { + let transcript = ws_call(&mut browser, "history", "session/hydrate", hydrate.clone()).await; + if transcript.to_string().contains("ECHO: hello from native") { break; } + tokio::time::sleep(Duration::from_millis(100)).await; + } + }).await.expect("browser sees native turn"); + // Completion can precede the turn/start reply; inspect durable turn + // state rather than depending on the relative delivery of notifications. + tokio::time::timeout(Duration::from_secs(30), async { + loop { + let state = call(&mut native, "state", "turn/state/get", json!({ + "session_id":SYSTEM_SESSION,"turn_id":first["turn_id"]})).await; + if state["state"] == "completed" { break; } + tokio::time::sleep(Duration::from_millis(100)).await; + } + }).await.expect("native turn completed"); + ws_call(&mut browser, "turn", "turn/start", input("hello from browser")).await; + tokio::time::timeout(Duration::from_secs(30), async { + loop { + let transcript = call(&mut native, "history", "session/hydrate", hydrate.clone()).await; + if transcript.to_string().contains("ECHO: hello from browser") { break; } + tokio::time::sleep(Duration::from_millis(100)).await; + } + }).await.expect("native client sees browser turn"); + assert_eq!(core.status().generation, 1); + drop((native, browser)); + core.shutdown_within(Duration::from_secs(5)); + // A Web cwd makes the session persistently scoped. After even a full + // host restart, native app peers must resume it without having to know + // the browser's workspace routing contract. + let restarted = Core::new(Options::default() + .program(std::env::var_os("OCTOS_CORE_TEST_KERNEL").unwrap()).core_dir(&dir)); + let mut native = restarted.connect().unwrap(); + let opened = call(&mut native, "resume", "session/open", json!({ + "session_id":SYSTEM_SESSION,"profile_id":"_main"})).await; + assert_eq!(opened["opened"]["workspace_root"], reference[0]); + assert!(native.system_web_url("http://localhost:4173").await.is_ok()); + drop(native); + restarted.shutdown_within(Duration::from_secs(5)); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/desktop/README.md b/desktop/README.md index 334553f1..a529da89 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -145,7 +145,10 @@ Mail is the worked example (`octosense-mail-service`, from [`../apps/mail/host-s AI providers (`os.ai-providers`) edits the octos kernel's LLM providers through the `llm` service (`octosense-llm-service`, from [`../apps/ai-providers/host-service`](../apps/ai-providers/host-service)). Keys are typed only on host sheets and go to the macOS keychain entry octos reads; the providers are written to the kernel's profile under the shell's octos core dir (`/profiles/_main.json`; core dir `OCTOS_APP_CORE_DIR`, else `~/octos-home/.octos`). A phone's provider QR is imported from a picture of it: **Choose image** opens the open panel, or drop a screenshot on the import sheet. **Start → Settings → AI providers** opens it. After a change the service restarts the kernel if one runs; its consumers (AppCard) reconnect to the new one. -**The octos kernel** is a shell service, not part of any app: `octosense-kernel` ([`../crates/kernel`](../crates/kernel), feature `octos-core`, default). The shell starts it through its AI services at startup ([`../crates/ai-host`](../crates/ai-host/README.md), `octosense_ai_host::start`); nothing runs until a consumer connects, then one kernel per process (` serve --stdio --data-dir ` on a desktop, the APK's `liboctos.so` on Android; none on iOS or on a desktop without `OCTOS_APP_CORE_BIN`). AppCard's agent connects to it; Rinx reaches it through the app-agent broker. It stops when the last consumer leaves and when the shell exits. + +**Talk to the system agent:** open **AI providers → Talk to Octos** for the server address and native **Copy access token** button. Native apps, OctosCode TUI and Web share one WebSocket server; the system session is `_main:api:octosense#system`. The kernel requires this repository's locked `--host-managed` overlay, applied automatically by the Android build tool. The web client is hosted separately. See the [kernel guide](../crates/kernel/README.md) for connections, tunnels and platform limits. + +**The octos kernel** is a shell service, not part of any app: `octosense-kernel` ([`../crates/kernel`](../crates/kernel), feature `octos-core`, default). The shell starts it through its AI services at startup ([`../crates/ai-host`](../crates/ai-host/README.md), `octosense_ai_host::start`); nothing runs until a consumer connects, then one kernel per process (` serve --host 127.0.0.1 --host-managed --data-dir ` on a desktop, the APK's `liboctos.so` on Android; none on iOS or on a desktop without `OCTOS_APP_CORE_BIN`). AppCard's agent connects to it; Rinx reaches it through the app-agent broker. It stays available when native apps close and stops when the shell exits. New app features that need a password, PIN or token belong in a host service and a host sheet, never in the app's own UI. diff --git a/desktop/README.zh-CN.md b/desktop/README.zh-CN.md index 87d75c72..425b59ec 100644 --- a/desktop/README.zh-CN.md +++ b/desktop/README.zh-CN.md @@ -145,7 +145,10 @@ launcher 把四类应用列在一起: AI 提供商(`os.ai-providers`)通过 `llm` 服务(`octosense-llm-service`,来自 [`../apps/ai-providers/host-service`](../apps/ai-providers/host-service))编辑 octos 内核的 LLM 提供商。密钥只在宿主面板上输入,保存到 octos 读取的 macOS 钥匙串条目;提供商写入 Shell 的 octos core 目录下内核的 profile(`/profiles/_main.json`;core 目录为 `OCTOS_APP_CORE_DIR`,否则为 `~/octos-home/.octos`)。手机上的提供商二维码可从图片导入:**Choose image** 打开文件面板,或把截图拖到导入面板上。**开始 → 设置 → AI providers** 可打开它。更改后服务会重启正在运行的内核,使用方(AppCard)会重新连接到新内核。 -**octos 内核**是一项 Shell 服务,不属于任何应用:`octosense-kernel`([`../crates/kernel`](../crates/kernel),feature `octos-core`,默认开启)。Shell 在启动时通过其 AI 服务启动它([`../crates/ai-host`](../crates/ai-host/README.md),`octosense_ai_host::start`);在有使用方连接之前不运行任何东西,之后每个进程只有一个内核(桌面上是 ` serve --stdio --data-dir `,Android 上是 APK 中的 `liboctos.so`;iOS 以及未设置 `OCTOS_APP_CORE_BIN` 的桌面上没有内核)。AppCard 的 Agent 连接它;Rinx 通过应用与 Agent 之间的代理访问它。最后一个使用方离开或 Shell 退出时内核停止。 + +**与系统 Agent 对话:** 打开 **AI providers → Talk to Octos**,获取服务器地址并通过宿主按钮复制访问令牌。原生应用、OctosCode 终端和 Web 客户端连接同一个 WebSocket 服务;系统会话为 `_main:api:octosense#system`。内核必须包含本仓库锁定的 `--host-managed` 补丁,Android 构建工具会自动应用。Web 客户端仍需单独托管。连接、隧道及平台限制见[内核指南](../crates/kernel/README.zh-CN.md)。 + +**octos 内核**是一项 Shell 服务,不属于任何应用:`octosense-kernel`([`../crates/kernel`](../crates/kernel),feature `octos-core`,默认开启)。Shell 在启动时通过其 AI 服务启动它([`../crates/ai-host`](../crates/ai-host/README.md),`octosense_ai_host::start`);在有使用方连接之前不运行任何东西,之后每个进程只有一个内核(桌面上是 ` serve --host 127.0.0.1 --host-managed --data-dir `,Android 上是 APK 中的 `liboctos.so`;iOS 以及未设置 `OCTOS_APP_CORE_BIN` 的桌面上没有内核)。AppCard 的 Agent 连接它;Rinx 通过应用与 Agent 之间的代理访问它。原生应用关闭后服务仍保持可连接;Shell 退出时内核停止。 需要密码、PIN 或令牌的新功能,应放在宿主服务和宿主自有面板中,绝不放在应用自己的界面里。 diff --git a/docs/adr/0003-shared-octos-client-access.md b/docs/adr/0003-shared-octos-client-access.md new file mode 100644 index 00000000..dceca32b --- /dev/null +++ b/docs/adr/0003-shared-octos-client-access.md @@ -0,0 +1,73 @@ +# ADR 0003: One Octos server for native and external clients + +Status: Implemented in source; desktop integration verified; Android acceptance pending. + +## Context + +OctoSense started `octos serve --stdio`. That mode disables the HTTP listener; +a second server cannot open the same single-writer data directory. Starting +OctosCode without an endpoint instead creates an unrelated kernel. Users need +to talk to the system agent from their desktop terminal or a computer's web +browser while native app peers remain connected. + +Loopback alone is not an Android authentication boundary. Upstream solo login +and the trusted-loopback `X-Profile-Id` path are inappropriate for a host-owned +agent that other APKs can reach. + +## Decision + +Desktop and Android start one host-managed HTTP/WebSocket Octos child bound to +127.0.0.1. The native frame router connects to that server with a random host +token and explicitly negotiates the capabilities previously enabled by stdio. +External clients use the same UI Protocol endpoint and independently negotiate +features. We do not add simultaneous stdio and WebSocket listeners upstream. + +A small executable overlay, locked by source revision and SHA-256, introduces +`--host-managed`. It requires local mode, loopback binding and a nonempty token; +uses one in-process profile runtime; disables solo login; and rejects local +profile-header impersonation and alternate credentials. The normal executable +build applies the overlay; incompatible prebuilts fail closed. The workspace's +Octos crate pin is unchanged, including OpenHarmony's embedded implementation. + +The shared server starts on demand and survives native consumer closure. A +provider/origin restart waits for the previous child to exit, preserving port +and token; shell shutdown ends it. The private descriptor is atomically written +with mode 0600 and removed on orderly stop. A new service lifetime rotates the +token. Pipe fixtures and OpenHarmony retain stop-when-idle semantics. + +AI providers exposes **Talk to Octos** through a trusted host sheet. Only that +sheet may request connection controls. Native code copies the token or opens +the browser; scripts never receive the token. An explicit HTTP(S) web origin +is saved as the allowlist. The generated credential-free web link identifies +the system session and server-confirmed workspace. The client still asks for +server origin and token. Web assets remain separately hosted. + +The system conversation is `_main:api:octosense#system` in profile `_main`. +The server-confirmed system workspace is canonicalized and persisted. Native +opens reuse that explicit cwd so Web workspace scoping does not break native +resumption after a full shell restart. +Sharing the runtime does not collapse app-peer sessions or grant raw protocol +access to ordinary apps. A computer reaches the phone's loopback listener over +an authorized tunnel; this change adds no public listener or ROM Binder API. + +## Limits + +This is not an Android foreground service. Shell/process death stops the +kernel. Browser-owned turns can still be interrupted when their socket closes +(upstream issue 2167); detached task ownership is a separate concern. +OpenHarmony has no external listener here; iOS has no local kernel. We do not +bundle a web client, add an embedded chat view, or provide a Linux toolchain. + +## Acceptance + +Real-kernel tests with a local scripted model verify native/browser turns in +one system session, simultaneous app peers, rejection of missing tokens and +spoofed profile headers, disabled solo login, rejection of untrusted origins, +provider restart with stable access, continued service after native closure, +private descriptor permissions and orderly shutdown. Hidden desktop UI checks +exercise the sheet and origin save. A headless Chromium run against the +shell-owned server opened the saved system-conversation link, sent a prompt +and displayed the local model reply. Full host restart preserves the scoped +workspace for native resumption. Artifact tests enforce overlay idempotence +and rejection of changed hashes/revisions. Android device/packaging and actual +TUI acceptance are pending; the OnePlus is untouched. diff --git a/docs/adr/README.md b/docs/adr/README.md index 01afc09f..9d254848 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -8,6 +8,7 @@ Decisions for the OctoSense repository: the shell, its services, the system apps | --- | --- | --- | | [0001](0001-one-octosense-repository.md) | One OctoSense repository for the shell, its services, the system apps and both packagings | Accepted | | [0002](0002-event-driven-app-agents.md) | Event-driven app agents: apps think on their own triggers and publish cards to the glance screen | Proposed | +| [0003](0003-shared-octos-client-access.md) | Shared Octos access for native, terminal and web clients | Implemented; Android acceptance pending | ## Home (phone shell) decisions, 2026-09-16 to 2026-09-25 diff --git a/docs/adr/README.zh-CN.md b/docs/adr/README.zh-CN.md index 78dd1f6e..53e92745 100644 --- a/docs/adr/README.zh-CN.md +++ b/docs/adr/README.zh-CN.md @@ -8,6 +8,7 @@ OctoSense 仓库的决策:Shell、Shell 服务、系统应用,以及桌面 | --- | --- | --- | | [0001](0001-one-octosense-repository.md)(英文) | 用一个 OctoSense 仓库承载 Shell、Shell 服务、系统应用和两种打包形态 | 已接受 | | [0002](0002-event-driven-app-agents.md)(英文) | 事件驱动的应用智能体:应用按自己的触发条件思考,并把卡片发布到一览屏 | 提议中 | +| [0003](0003-shared-octos-client-access.md) | 原生、终端与 Web 客户端共享 Octos 服务 | 已实现;Android 验收待完成 | ## Home(手机 Shell)的决策,2026-09-16 至 2026-09-25 diff --git a/octos-runtime-patches.lock.json b/octos-runtime-patches.lock.json new file mode 100644 index 00000000..042a1870 --- /dev/null +++ b/octos-runtime-patches.lock.json @@ -0,0 +1,6 @@ +{ + "schema_version": 1, + "base_revision": "60cf96e6c8719bd5f1b61ab5b537a88ffa314879", + "patch": "tools/runtime-patches/octos-host-managed.patch", + "sha256": "63720f5fa7d45268c6e20a69d5c1e567b7c630cf54c7a0ac99d2fad0a4ff752d" +} diff --git a/phone/README.md b/phone/README.md index 147c0725..15a05eac 100644 --- a/phone/README.md +++ b/phone/README.md @@ -194,17 +194,20 @@ The octos agent kernel is a Home service, independent of any app: `octos-core` (default, and always on in Android, iOS and OpenHarmony builds). Home starts it at startup with its data dir through the shell's AI services ([`crates/ai-host`](../crates/ai-host/README.md)); nothing runs until a consumer -connects. Then there is one kernel per process: `liboctos.so serve --stdio` +connects. Then there is one kernel per process: `liboctos.so serve --host 127.0.0.1 --host-managed` from the APK's native lib dir on Android (every APK `rom/scripts/build-home.sh` builds carries it), the core in-process on OpenHarmony, the binary named by `OCTOS_APP_CORE_BIN` on a desktop (none otherwise), none on iOS. Its core dir is `/octos-home/.octos` on a phone and `OCTOS_APP_CORE_DIR`, else `~/octos-home/.octos`, on a desktop. The AI providers app configures it (below); AppCard (opt-in) and, next, Rinx connect to it and share it; it -stops when the last one leaves and on Home's shutdown. To build without it +stays available when native apps close and stops on Home's shutdown. To build without it (desktop only): `--no-default-features` plus the features you want, e.g. `--features app-hub`. + +**Talk to the system agent:** open **AI providers → Talk to Octos** for the server address and native **Copy access token** button. Native apps, OctosCode TUI and Web share one WebSocket server; the system session is `_main:api:octosense#system`. The kernel requires this repository's locked `--host-managed` overlay, applied automatically by the Android build tool. The web client is hosted separately. See the [kernel guide](../crates/kernel/README.md) for connections, tunnels and platform limits. + ### AI providers AI providers (`os.ai-providers`) edits the octos kernel's LLM providers diff --git a/phone/README.zh-CN.md b/phone/README.zh-CN.md index dd57e0e2..c872ecdb 100644 --- a/phone/README.zh-CN.md +++ b/phone/README.zh-CN.md @@ -116,9 +116,12 @@ adb shell am start -n /.MakepadApp --es makepad.APP_CONFIG '{"mail_demo `app-news`、`app-photos` 和 `app-maps` 会链接早期的原生模块来替代对应的脚本应用,用于在脚本应用完成真机测量之前进行对比;相关说明见 [docs/photos.md(英文)](docs/photos.md) 和 [docs/maps.md(英文)](docs/maps.md)。Mail 和 Camera 已不再有原生模块。 + +**与系统 Agent 对话:** 打开 **AI providers → Talk to Octos**,获取服务器地址并通过宿主按钮复制访问令牌。原生应用、OctosCode 终端和 Web 客户端连接同一个 WebSocket 服务;系统会话为 `_main:api:octosense#system`。内核必须包含本仓库锁定的 `--host-managed` 补丁,Android 构建工具会自动应用。Web 客户端仍需单独托管。连接、隧道及平台限制见[内核指南](../crates/kernel/README.zh-CN.md)。 + ### octos 内核 -octos Agent 内核是 Home 的一项服务,不依附于任何应用:`octosense-kernel`([`crates/kernel`](../crates/kernel)),feature `octos-core`(默认开启,Android、iOS、OpenHarmony 构建总是开启)。Home 在启动时通过 Shell 的 AI 服务([`crates/ai-host`](../crates/ai-host/README.md))用自己的数据目录启动它,在有使用方连接之前不运行任何东西。之后每个进程只有一个内核:Android 上从 APK 原生库目录运行 `liboctos.so serve --stdio`(`rom/scripts/build-home.sh` 构建的每个 APK 都带有它),OpenHarmony 上在进程内运行,桌面上运行 `OCTOS_APP_CORE_BIN` 指定的二进制(未指定则没有),iOS 上没有。它的 core 目录在手机上是 `<数据目录>/octos-home/.octos`,在桌面上是 `OCTOS_APP_CORE_DIR`,否则 `~/octos-home/.octos`。AI 提供商应用负责配置它(见下文);AppCard(需显式开启)以及之后的 Rinx 连接并共享它;最后一个使用方离开或 Home 关闭时内核停止。不带内核服务构建(仅桌面):`--no-default-features` 加上需要的 feature,例如 `--features app-hub`。 +octos Agent 内核是 Home 的一项服务,不依附于任何应用:`octosense-kernel`([`crates/kernel`](../crates/kernel)),feature `octos-core`(默认开启,Android、iOS、OpenHarmony 构建总是开启)。Home 在启动时通过 Shell 的 AI 服务([`crates/ai-host`](../crates/ai-host/README.md))用自己的数据目录启动它,在有使用方连接之前不运行任何东西。之后每个进程只有一个内核:Android 上从 APK 原生库目录运行 `liboctos.so serve --host 127.0.0.1 --host-managed`(`rom/scripts/build-home.sh` 构建的每个 APK 都带有它),OpenHarmony 上在进程内运行,桌面上运行 `OCTOS_APP_CORE_BIN` 指定的二进制(未指定则没有),iOS 上没有。它的 core 目录在手机上是 `<数据目录>/octos-home/.octos`,在桌面上是 `OCTOS_APP_CORE_DIR`,否则 `~/octos-home/.octos`。AI 提供商应用负责配置它(见下文);AppCard(需显式开启)以及之后的 Rinx 连接并共享它;原生应用关闭后服务仍保持可连接;Home 关闭时内核停止。不带内核服务构建(仅桌面):`--no-default-features` 加上需要的 feature,例如 `--features app-hub`。 ### AI 提供商 diff --git a/tools/kernel-artifact.py b/tools/kernel-artifact.py index f929477c..a0255dd9 100755 --- a/tools/kernel-artifact.py +++ b/tools/kernel-artifact.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 -"""The octos kernel an OctoSense Android APK bundles (Python 3.9+). +"""Build the octos kernel for OctoSense (Python 3.9+). The octos kernel is a shell service (octosense-ai-host, feature `octos-core`; always on Android): at run time the shell execs -`liboctos.so serve --stdio` from the APK's native lib dir, the only place an +`liboctos.so serve --host 127.0.0.1 --host-managed` from the APK's native lib dir, the only place an Android app may exec a binary from. This tool produces that artifact for every shell build (desktop/ and phone/ alike): @@ -12,9 +12,11 @@ - by default it checks that revision out into a private work dir (never a sibling checkout you may be working in) and cross-builds `octos` for aarch64-linux-android with the cargo-makepad SDK's NDK clang (API 33, - `--no-default-features --features api,git,ast`: the stdio server without + `--no-default-features --features api,git,ast`: the HTTP/WebSocket server without the llama.cpp embedder); -- `--kernel ` takes a prebuilt aarch64-linux-android `octos` instead; +- applies the revision- and hash-locked host-managed authentication overlay; +- `--host` builds for this desktop instead of Android (no SDK needed); +- `--kernel ` takes a prebuilt `octos` with the same overlay; - it records what it produced (source, revision, sha256) with `--receipt`; - with a command after `--` it runs that command (the packager) with `MAKEPAD_ANDROID_EXTRA_LIBS=liboctos.so=`. @@ -40,7 +42,7 @@ OCTOS_URL = "https://github.com/octos-org/octos.git" TARGET = "aarch64-linux-android" API = "33" -# The kernel as the shells' phones run it: the stdio server, no llama.cpp +# The shared HTTP/WebSocket kernel, without the llama.cpp # embedder (needs cmake and is not used on a phone). KERNEL_BUILD = ["-p", "octos-cli", "--bin", "octos", "--no-default-features", "--features", "api,git,ast"] LIB_NAME = "liboctos.so" @@ -98,7 +100,7 @@ def build_command(sdk, target_dir, offline=False, required=True): return command -def plan(revision, work, sdk, offline=False, required=True): +def plan(revision, work, sdk, offline=False, required=True, host=False): """(steps, kernel): the (cwd, argv) steps that check the revision out into `work/src` and cross-build it into `work/target`, and the binary's path.""" work = Path(work) @@ -107,21 +109,27 @@ def plan(revision, work, sdk, offline=False, required=True): if not offline: steps.append((src, ["git", "fetch", "--quiet", "--no-tags", "--depth=1", OCTOS_URL, revision])) steps.append((src, ["git", "checkout", "--quiet", "--detach", revision])) + steps.append((src, [sys.executable, str(ROOT / "tools/kernel-artifact.py"), "--apply-host-patch", str(src)])) + if host: + command = ["env", f"CARGO_TARGET_DIR={work / 'target'}", "cargo", "build", "--locked", "--release", *KERNEL_BUILD] + if offline: command.append("--offline") + steps.append((src, command)) + return steps, work / "target/release/octos" steps.append((src, build_command(sdk, work / "target", offline, required))) return steps, work / "target" / TARGET / "release/octos" -def kernel_plan(*, lock=None, work=None, sdk=None, kernel=None, no_kernel=False, offline=False, required=True): +def kernel_plan(*, lock=None, work=None, sdk=None, kernel=None, no_kernel=False, offline=False, required=True, host=False): """(steps, kernel path or None, source) for the kernel an APK bundles: none, a prebuilt one, or one built from the locked revision.""" if no_kernel: return [], None, None if kernel: return [], Path(kernel), "prebuilt" - if not sdk: + if not sdk and not host: raise RuntimeError("An Android SDK dir (--sdk) is required to build the kernel (or pass --kernel / --no-kernel)") revision = octos_revision(lock) - steps, binary = plan(revision, work or ROOT / "target/octos-kernel", sdk, offline, required) + steps, binary = plan(revision, work or ROOT / "target/octos-kernel", sdk, offline, required, host) return steps, binary, f"{OCTOS_URL}@{revision}" @@ -134,7 +142,27 @@ def receipt(kernel, source): """What a build records about the bundled kernel.""" if not kernel: return None - return {"source": source, "sha256": hashlib.sha256(Path(kernel).read_bytes()).hexdigest()} + value = {"source": source, "sha256": hashlib.sha256(Path(kernel).read_bytes()).hexdigest()} + if source != "prebuilt": + value["host_managed_overlay"] = json.loads((ROOT / "octos-runtime-patches.lock.json").read_text()) + return value + + +def apply_host_patch(src): + """Fail closed on a changed pin/patch; repeated builds preserve an applied overlay.""" + src = Path(src) + lock = json.loads((ROOT / "octos-runtime-patches.lock.json").read_text()) + patch = ROOT / lock["patch"] + if hashlib.sha256(patch.read_bytes()).hexdigest() != lock["sha256"]: + raise RuntimeError("The Octos host-managed patch differs from its reviewed hash") + head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=src, text=True).strip() + if head != lock["base_revision"]: + raise RuntimeError("Rebase and review the Octos host-managed patch for the new kernel pin first") + applied = subprocess.run(["git", "apply", "--reverse", "--check", str(patch)], cwd=src, capture_output=True) + if applied.returncode == 0: + return + subprocess.run(["git", "apply", "--check", str(patch)], cwd=src, check=True) + subprocess.run(["git", "apply", str(patch)], cwd=src, check=True) def run_steps(steps): @@ -151,6 +179,8 @@ def main(argv=None): argv, command = argv[:i], argv[i + 1:] p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) p.add_argument("--lock", type=Path, help="The Cargo.lock naming the octos revision (default: the workspace's)") + p.add_argument("--host", action="store_true", help="Build the desktop kernel instead of the Android executable") + p.add_argument("--apply-host-patch", type=Path, help=argparse.SUPPRESS) p.add_argument("--sdk", type=Path, help="cargo-makepad's Android SDK dir (holds ndk/)") source = p.add_mutually_exclusive_group() source.add_argument("--kernel", type=Path, help="A prebuilt aarch64-linux-android octos to bundle") @@ -160,17 +190,20 @@ def main(argv=None): p.add_argument("--receipt", type=Path, help="Write {source, sha256} of the kernel here (JSON)") p.add_argument("--plan", action="store_true", help="Print the plan as JSON; run nothing") args = p.parse_args(argv) + if args.apply_host_patch: + apply_host_patch(args.apply_host_patch) + return if args.kernel: args.kernel = args.kernel.resolve() try: steps, kernel, origin = kernel_plan(lock=args.lock, work=args.work and args.work.resolve(), sdk=args.sdk and args.sdk.resolve(), kernel=args.kernel, no_kernel=args.no_kernel, offline=args.offline, - required=not args.plan) + required=not args.plan, host=args.host) except RuntimeError as e: p.error(str(e)) if args.plan: print(json.dumps({"kernel": str(kernel) if kernel else None, "source": origin, - "android_env": {"MAKEPAD_ANDROID_EXTRA_LIBS": extra_libs(kernel)}, + "android_env": {} if args.host else {"MAKEPAD_ANDROID_EXTRA_LIBS": extra_libs(kernel)}, "steps": [{"cwd": str(cwd), "argv": c} for cwd, c in steps], "then": command or None}, indent=2)) return @@ -185,7 +218,7 @@ def main(argv=None): if command: env = dict(os.environ) env.pop("MAKEPAD_ANDROID_EXTRA_LIBS", None) - if kernel: + if kernel and not args.host: env["MAKEPAD_ANDROID_EXTRA_LIBS"] = extra_libs(kernel) sys.exit(subprocess.run(command, env=env).returncode) diff --git a/tools/runtime-patches/octos-host-managed.patch b/tools/runtime-patches/octos-host-managed.patch new file mode 100644 index 00000000..ee4e868e --- /dev/null +++ b/tools/runtime-patches/octos-host-managed.patch @@ -0,0 +1,116 @@ +diff --git a/crates/octos-cli/src/api/mod.rs b/crates/octos-cli/src/api/mod.rs +index 1d44299e..8b19d147 100644 +--- a/crates/octos-cli/src/api/mod.rs ++++ b/crates/octos-cli/src/api/mod.rs +@@ -372,6 +372,8 @@ pub struct AppState { + /// configs never set) is the primary defence; the handlers additionally + /// reject any request carrying proxy-forwarding headers. + pub solo_login_enabled: bool, ++ /// Only the embedding host's token authenticates a host-managed server. ++ pub host_managed_auth: bool, + /// The HTTP serve's stop switch — the same `watch` channel the SIGINT / + /// SIGTERM watcher flips, so the `server/shutdown` UI Protocol method ends + /// the process through exactly the path Ctrl+C takes: stop accepting, +@@ -536,6 +538,7 @@ impl AppState { + deployment_mode: crate::config::DeploymentMode::Local, + pairing: None, + solo_login_enabled: false, ++ host_managed_auth: false, + serve_shutdown: None, + dangerous_default_permissions: false, + default_network_denied: false, +diff --git a/crates/octos-cli/src/api/router.rs b/crates/octos-cli/src/api/router.rs +index bee4cb52..55f71875 100644 +--- a/crates/octos-cli/src/api/router.rs ++++ b/crates/octos-cli/src/api/router.rs +@@ -1221,6 +1221,11 @@ pub(crate) async fn resolve_identity_public(state: &AppState, token: &str) -> Op + + /// Resolve token to an AuthIdentity. + async fn resolve_identity(state: &AppState, token: &str) -> Option { ++ if state.host_managed_auth { ++ return state.auth_token.as_deref() ++ .filter(|expected| !expected.is_empty() && constant_time_eq(token.as_bytes(), expected.as_bytes())) ++ .map(|_| AuthIdentity::Admin); ++ } + if token.is_empty() { + return None; + } +@@ -1316,6 +1321,10 @@ async fn user_auth_middleware( + return Ok(next.run(req).await); + } + ++ if state.host_managed_auth { ++ return Err(StatusCode::UNAUTHORIZED); ++ } ++ + // 2. Accept X-Profile-Id header for chat API routes (proxy auth). + // The reverse proxy (Caddy) sets this header to identify the profile, + // so requests through the proxy are implicitly authenticated. +diff --git a/crates/octos-cli/src/api/ui_protocol_transport.rs b/crates/octos-cli/src/api/ui_protocol_transport.rs +index 1816bbce..a1769377 100644 +--- a/crates/octos-cli/src/api/ui_protocol_transport.rs ++++ b/crates/octos-cli/src/api/ui_protocol_transport.rs +@@ -9712,7 +9712,7 @@ pub(crate) fn supports_local_solo_profile_create(state: &AppState) -> bool { + /// hosted server, where one client stopping the process would take everyone + /// else down, never advertises or accepts it. + pub(crate) fn supports_server_shutdown(state: &AppState) -> bool { +- local_solo_danger_allowed(state) && state.serve_shutdown.is_some() ++ (local_solo_danger_allowed(state) || state.host_managed_auth) && state.serve_shutdown.is_some() + } + + /// `server/shutdown`: stop this `octos serve` exactly as Ctrl+C would. +diff --git a/crates/octos-cli/src/commands/serve.rs b/crates/octos-cli/src/commands/serve.rs +index 58c90775..0418939b 100644 +--- a/crates/octos-cli/src/commands/serve.rs ++++ b/crates/octos-cli/src/commands/serve.rs +@@ -350,6 +350,12 @@ pub struct ServeCommand { + #[arg(long)] + pub stdio: bool, + ++ /// Host-owned local server: one in-process runtime, mandatory host token, ++ /// no password-free solo login or trusted-proxy authentication. ++ #[arg(long, conflicts_with_all = ["stdio", "solo"])] ++ #[serde(default)] ++ pub host_managed: bool, ++ + /// Working directory (defaults to current directory). + #[arg(short, long)] + pub cwd: Option, +@@ -1068,6 +1074,13 @@ impl ServeCommand { + None + }; + ++ if self.host_managed { ++ eyre::ensure!(!self.stdio && !self.solo, "--host-managed cannot use --stdio or --solo"); ++ eyre::ensure!(self.host == "127.0.0.1", "--host-managed requires loopback binding"); ++ eyre::ensure!(config.mode == crate::config::DeploymentMode::Local, "--host-managed requires local mode"); ++ eyre::ensure!(auth_token.as_deref().is_some_and(|t| !t.is_empty()), "--host-managed requires OCTOS_AUTH_TOKEN"); ++ } ++ + // Initialize profile store and process manager for admin dashboard. + // Registry (`.json`) resolves from the SHARED `state_home`; the + // per-profile `/data` runtime tree roots under the per-instance +@@ -1467,12 +1480,12 @@ impl ServeCommand { + // gateway for the same profile would open its episodes.redb again and + // lock session/open out of the profile after onboarding. Stdio serve + // already has no gateway auto-start; keep HTTP solo consistent. +- let solo_login_enabled_flag = self.solo ++ let solo_login_enabled_flag = !self.host_managed && (self.solo + || std::env::var("OCTOS_SOLO_LOGIN") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) +- .unwrap_or(false); ++ .unwrap_or(false)); + let solo_in_process = +- solo_login_enabled_flag && config.mode == crate::config::DeploymentMode::Local; ++ (solo_login_enabled_flag || self.host_managed) && config.mode == crate::config::DeploymentMode::Local; + + let bridge_js_path = data_dir.join("whatsapp-bridge").join("bridge.js"); + let process_manager = Arc::new( +@@ -1752,6 +1765,7 @@ impl ServeCommand { + host_memory: config.memory.clone(), + pairing: pairing.clone(), + solo_login_enabled: solo_login_enabled_flag, ++ host_managed_auth: self.host_managed, + // Only the HTTP serve has a loop to stop; see AppState::serve_shutdown. + serve_shutdown: (!self.stdio).then(|| serve_shutdown_tx.clone()), + dangerous_default_permissions: dangerous_default_permissions_flag, diff --git a/tools/test_kernel_artifact.py b/tools/test_kernel_artifact.py index d977c900..1567fa0a 100644 --- a/tools/test_kernel_artifact.py +++ b/tools/test_kernel_artifact.py @@ -7,6 +7,8 @@ from pathlib import Path import tempfile import unittest +import subprocess +from unittest.mock import patch ROOT = Path(__file__).resolve().parents[1] spec = importlib.util.spec_from_file_location("kernel_artifact", ROOT / "tools/kernel-artifact.py") @@ -41,6 +43,15 @@ def test_the_workspace_pins_one_kernel(self): class PlanTests(unittest.TestCase): + def test_desktop_plan_also_applies_the_reviewed_overlay(self): + steps, binary, _ = kernel.kernel_plan(host=True, work=Path("/w")) + self.assertIn("--apply-host-patch", steps[-2][1]) + self.assertNotIn("--target", steps[-1][1]) + self.assertEqual(binary, Path("/w/target/release/octos")) + overlay = json.loads((ROOT / "octos-runtime-patches.lock.json").read_text()) + self.assertEqual(overlay["base_revision"], kernel.octos_revision()) + self.assertEqual(overlay["sha256"], hashlib.sha256((ROOT / overlay["patch"]).read_bytes()).hexdigest()) + def test_the_plan_checks_out_and_cross_builds_the_kernel(self): work = Path("/w") steps, binary = kernel.plan("a" * 40, work, Path("/sdk with spaces"), required=False) @@ -114,5 +125,40 @@ def test_the_cli_prints_a_plan_and_runs_nothing(self): kernel.main(["--kernel", "/k/octos", "--no-kernel"]) +class OverlayTests(unittest.TestCase): + def test_apply_is_idempotent_and_refuses_changed_hash_or_revision(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + src = root / "src" + src.mkdir() + def git(*args): + return subprocess.check_output(["git", *args], cwd=src, text=True).strip() + git("init", "--quiet") + (src / "sample").write_text("original\n") + git("add", "sample") + git("-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "commit", "--quiet", "-m", "fixture") + rev = git("rev-parse", "HEAD") + (src / "sample").write_text("patched\n") + overlay = root / "overlay.patch" + overlay.write_text(git("diff") + "\n") + git("restore", "sample") + lock = {"base_revision": rev, "patch": "overlay.patch", "sha256": hashlib.sha256(overlay.read_bytes()).hexdigest()} + lock_file = root / "octos-runtime-patches.lock.json" + lock_file.write_text(json.dumps(lock)) + with patch.object(kernel, "ROOT", root): + kernel.apply_host_patch(src) + kernel.apply_host_patch(src) + self.assertEqual((src / "sample").read_text(), "patched\n") + lock["base_revision"] = "0" * 40 + lock_file.write_text(json.dumps(lock)) + with self.assertRaisesRegex(RuntimeError, "new kernel pin"): + kernel.apply_host_patch(src) + lock["base_revision"] = rev + lock["sha256"] = "0" * 64 + lock_file.write_text(json.dumps(lock)) + with self.assertRaisesRegex(RuntimeError, "reviewed hash"): + kernel.apply_host_patch(src) + + if __name__ == "__main__": unittest.main() From 5f1286b57a2da77198c5a4d7932e93719bd8cdeb Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:53:07 -0700 Subject: [PATCH 2/9] fix(talk-to-octos): opt-in, two tokens, pairing, upstream host-managed serve Rework of the shared Octos server after two security reviews. - Off by default: the kernel stays the private `serve --stdio` child. Talk to Octos (AI providers) restarts it as octos's upstream `serve --host-managed` (octos#2591) and mints an external token; Revoke rotates it; Turn off returns to the pipe, stops the listener and removes the connection file. - Two tokens: native consumers use a host token that never leaves the shell; external clients get a token that opens the UI Protocol socket only (no REST/admin, no server/shutdown, no answers to host-owned app peers). - Web clients pair with octos's one-time code (8 chars, 5 minutes, single use), shown with a QR of the web link, enabled only while the sheet is open. No copy-token action remains. - Web origins: https, http only for loopback; a malformed saved origin reads as none and the kernel stays available; atomic writes. - The shell keeps the listener and passes it to each generation (--listen-fd, Unix); elsewhere a taken port moves to a new port and token. - The child's stdin is its lifeline (a killed shell takes its kernel with it; tested in both modes). Native consumers request octos's own UI_PROTOCOL_STDIO_DEFAULT_FEATURES. - Windows keeps the connection file in per-user local AppData. - Drops the octos overlay patch and its lock; kernel-artifact --host builds the pinned revision (offline stays offline, tested). - ADR 0003: threat model, token grants, opt-in listener, stdin-EOF shutdown. The octos pin points at the octos#2591 head for testing; it must move to the merged octos commit before this merges. Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.toml | 14 +- apps/README.md | 9 +- apps/README.zh-CN.md | 7 +- apps/ai-providers/host-service/src/lib.rs | 164 +++++- apps/ai-providers/host-service/src/sheets.rs | 186 +++++-- .../host-service/tests/service.rs | 20 +- .../tests/kernel_restart.rs | 2 +- crates/ai-host/src/lib.rs | 5 +- crates/kernel/Cargo.toml | 16 +- crates/kernel/README.md | 288 +++++++---- crates/kernel/README.zh-CN.md | 215 +++++--- crates/kernel/src/kernel.rs | 111 ++-- crates/kernel/src/launch.rs | 12 +- crates/kernel/src/lib.rs | 254 ++++++--- crates/kernel/src/network.rs | 482 +++++++++++++++--- crates/kernel/tests/core.rs | 72 ++- crates/kernel/tests/real_kernel.rs | 393 ++++++++++---- desktop/README.md | 5 +- desktop/README.zh-CN.md | 5 +- docs/adr/0003-shared-octos-client-access.md | 167 +++--- docs/adr/README.md | 2 +- docs/adr/README.zh-CN.md | 2 +- octos-runtime-patches.lock.json | 6 - phone/README.md | 7 +- phone/README.zh-CN.md | 7 +- tools/kernel-artifact.py | 48 +- .../runtime-patches/octos-host-managed.patch | 116 ----- tools/test_kernel_artifact.py | 64 +-- 28 files changed, 1860 insertions(+), 819 deletions(-) delete mode 100644 octos-runtime-patches.lock.json delete mode 100644 tools/runtime-patches/octos-host-managed.patch diff --git a/Cargo.toml b/Cargo.toml index 0bd6ef48..1634df8b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -86,15 +86,17 @@ octoscript-node = { git = "https://github.com/OctoSense-org/Octoscript-Makepad.g # service registry the Mail and llm services register into. One revision. octosense-app-hub-app = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "e8601b80ce104db2e48208094714bdcffdce6b5a" } octosense-appstore = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "e8601b80ce104db2e48208094714bdcffdce6b5a" } -# --- octos: ONE source for every octos crate (octos-org/octos main at the -# merge of octos#2594). Move octos-core and octos-cli together; the Android -# kernel artifact (liboctos.so) is built from this same revision. -octos-core = { git = "https://github.com/octos-org/octos.git", rev = "3b5d17a435da8ba9d1fcccbfedce46c357819a3a" } -octos-cli = { git = "https://github.com/octos-org/octos.git", rev = "3b5d17a435da8ba9d1fcccbfedce46c357819a3a", default-features = false } +# --- octos: ONE source for every octos crate. Move octos-core, octos-cli and +# octos-research together; the Android kernel artifact (liboctos.so) is built +# from this same revision. TEMPORARY: the head of octos#2591 +# (`serve --host-managed`), on top of octos main at e200b072 (keeps octos#2583; +# no octos#2567). It must move to the merged octos commit before this merges. +octos-core = { git = "https://github.com/octos-org/octos.git", rev = "2cd29e44ca4bff8c54fe1de10179b5cf29017571" } +octos-cli = { git = "https://github.com/octos-org/octos.git", rev = "2cd29e44ca4bff8c54fe1de10179b5cf29017571", default-features = false } # The research engine (the metasearch, the SSRF-safe polite reader and the # toolbox scope) for the toolbox's `octos-engine` feature. Same source and # revision as above. -octos-research = { git = "https://github.com/octos-org/octos.git", rev = "3b5d17a435da8ba9d1fcccbfedce46c357819a3a", default-features = false } +octos-research = { git = "https://github.com/octos-org/octos.git", rev = "2cd29e44ca4bff8c54fe1de10179b5cf29017571", default-features = false } # --- Rinx (Matrix chats, Moments, articles, mini apps) as a hosted module: # the library only, never its standalone entry or its own kernel (Rinx ADR 0007). rinx = { git = "https://github.com/hagency-org/Rinx.git", rev = "68afcf796d303aaf646eeb832d65c450a56c92b5", default-features = false, features = ["octosense-module"] } diff --git a/apps/README.md b/apps/README.md index f29d9e3e..4fc564b3 100644 --- a/apps/README.md +++ b/apps/README.md @@ -295,8 +295,7 @@ after every change, so the running kernel picks up the new providers. The method table and registration are in its [README](ai-providers/host-service/README.md). - -**Talk to the system agent:** open **AI providers → Talk to Octos** for the server address and native **Copy access token** button. Native apps, OctosCode TUI and Web share one WebSocket server; the system session is `_main:api:octosense#system`. The kernel requires this repository's locked `--host-managed` overlay, applied automatically by the Android build tool. The web client is hosted separately. See the [kernel guide](../crates/kernel/README.md) for connections, tunnels and platform limits. +**Talk to Octos** (off by default): **AI providers → Talk to Octos** turns on a loopback server so a web client or a terminal UI can talk to this device's assistant. While it is on, the kernel runs as `octos serve --host-managed` instead of `--stdio` and native apps keep working over its WebSocket; external clients get a separate token that opens the UI Protocol socket and nothing else. A web client pairs with a one-time code or the QR of its link; a terminal client of this user reads the private connection file. The server stays up when native apps close, until it is turned off or the shell exits. See [ADR 0003](../docs/adr/0003-shared-octos-client-access.md) and the [kernel guide](../crates/kernel/README.md). ## The octos kernel @@ -306,11 +305,11 @@ service; the shells link it by default (cargo feature `octos-core`, also on in `mobile-apps` and native mobile builds): - **One per process, on demand.** The first consumer's `connect()` starts it: - `octos serve --host 127.0.0.1 --host-managed` as a child on desktop and Android (on Android the + `octos serve --stdio` as a child on desktop and Android (on Android the APK's bundled `liboctos.so`), the canonical core in-process on OpenHarmony. Later consumers share it; each gets only the replies to its - own requests and its own sessions' notifications. On desktop and Android it stays - available after native apps close, until the shell exits. + own requests and its own sessions' notifications. It stops when the last + consumer leaves. - **Configured by AI providers.** The `llm` host service writes the kernel's profile, `/profiles/_main.json`, and keys (macOS keychain `octos` service behind `keychain:` markers, `/secrets/` on Linux, the diff --git a/apps/README.zh-CN.md b/apps/README.zh-CN.md index 29ae502f..1e26e65c 100644 --- a/apps/README.zh-CN.md +++ b/apps/README.zh-CN.md @@ -263,8 +263,7 @@ MAKEPAD_APP_CONFIG='{"mail_demo":true}' cargo run --release -p octosense-home -- `octosense_kernel::restart()`,让正在运行的内核读取新的服务商。方法列表与注册 方式见其 [README(英文)](ai-providers/host-service/README.md)。 - -**与系统 Agent 对话:** 打开 **AI providers → Talk to Octos**,获取服务器地址并通过宿主按钮复制访问令牌。原生应用、OctosCode 终端和 Web 客户端连接同一个 WebSocket 服务;系统会话为 `_main:api:octosense#system`。内核必须包含本仓库锁定的 `--host-managed` 补丁,Android 构建工具会自动应用。Web 客户端仍需单独托管。连接、隧道及平台限制见[内核指南](../crates/kernel/README.zh-CN.md)。 +**Talk to Octos**(默认关闭):在 **AI providers → Talk to Octos** 中打开后,本机会启动一个仅监听回环地址的服务,让 Web 客户端或终端界面与本设备的助手对话。开启期间内核以 `octos serve --host-managed` 代替 `--stdio` 运行,原生应用继续通过其 WebSocket 工作;外部客户端使用单独的令牌,只能打开 UI Protocol 套接字。Web 客户端通过一次性配对码或其链接的二维码配对;本用户的终端客户端读取私有连接文件。原生应用关闭后服务仍保持运行,直到关闭该功能或 Shell 退出。见 [ADR 0003(英文)](../docs/adr/0003-shared-octos-client-access.md) 和[内核指南](../crates/kernel/README.zh-CN.md)。 ## octos 内核 @@ -274,9 +273,9 @@ Shell 默认链接它(cargo feature `octos-core`,在 `mobile-apps` 和原生 同样开启): - **每进程一个,按需启动。** 第一个使用方调用 `connect()` 时启动:桌面和 - Android 上以子进程运行 `octos serve --host 127.0.0.1 --host-managed`(Android 上是 APK 内置的 + Android 上以子进程运行 `octos serve --stdio`(Android 上是 APK 内置的 `liboctos.so`),OpenHarmony 上在进程内运行标准内核。之后的使用方共享它; - 每个使用方只收到自己请求的回复和自己会话的通知。桌面和 Android 上原生应用关闭后服务继续运行,Shell 退出时停止。 + 每个使用方只收到自己请求的回复和自己会话的通知。最后一个使用方离开时内核停止。 - **由 AI 服务商配置。** `llm` 宿主服务写入内核的 profile `/profiles/_main.json` 以及密钥(macOS 钥匙串 `octos` 服务配合 `keychain:` 标记,Linux 上是 `/secrets/`,其他平台写在 profile 中), diff --git a/apps/ai-providers/host-service/src/lib.rs b/apps/ai-providers/host-service/src/lib.rs index 15ebfb21..12b4a01a 100644 --- a/apps/ai-providers/host-service/src/lib.rs +++ b/apps/ai-providers/host-service/src/lib.rs @@ -18,6 +18,7 @@ //! | `llm.test` | `{id}` | `{ok, ms, error?}` after one tiny request to the provider (`ok` is a Splash keyword: a script tests `error == nil`) | //! | `llm.export_qr` | `{ids?}` | `{}` when the person closes the sheet that shows the phone QR (all providers, or `ids`) | //! | `llm.import_qr` | – | `{applied, added, updated, message}` once a scanned, picked, dropped or pasted code is imported on the sheet: `applied` labels every provider the code names, `added` those appended (or saved, into an empty list), `updated` the saved ones whose key it changed; `message` says so in a sentence | +//! | `llm.connect_client` | – | `{}` when the person closes the Talk to Octos sheet (`os.ai-providers` only; the app receives no connection detail) | //! //! The app never sees a key, a PIN or a QR. `add_provider`, `edit_provider`, //! `export_qr` and `import_qr` raise the host's sheet, a separate isolate @@ -57,6 +58,17 @@ //! `llm.sheet.fetch_models` answers `{models: [{id, label}]}` from the //! endpoint's model list (`GET {base}/models`, Anthropic `GET /v1/models`). //! +//! Talk to Octos (`llm.connect_client`, feature `octos-core`) is off by +//! default. Its sheet, and only its sheet, calls `llm.sheet.client_info`, +//! `client_enable {on}`, `client_rotate`, `client_origin {origin}`, +//! `client_open`, `client_pair` (then `client_pair_ready` / +//! `client_pair_show`, the export sheet's pattern) and `client_back`. They +//! answer `{enabled, origin, endpoint, descriptor, profile_id, session_id, +//! web_origin}`; no token ever reaches a script, the clipboard or a link. A +//! web client pairs with a one-time code drawn on a host sheet with a QR of +//! its link ([`sheets::pair_client`]); leaving that sheet or closing the Talk +//! to Octos sheet turns pairing off. See `crates/kernel/README.md`. +//! //! Only `os.` apps are served: the provider set is the device's. //! //! State is the kernel's profile, `/profiles/_main.json`: its @@ -139,8 +151,12 @@ pub trait QrImagePicker: Send + Sync { /// `octos-core`). Runs on whichever thread made the change. pub type OnChanged = Arc; -/// Trusted host UI actions; secrets never return to the requesting script. -pub enum ClientUiAction { CopyToken(String), OpenWeb(String) } +/// Trusted host UI actions for Talk to Octos. No token ever reaches a +/// script, the clipboard or a link: a web client pairs with a one-time code. +pub enum ClientUiAction { + /// Open the web client (a credential-free link) in the browser. + OpenWeb(String), +} pub type ClientUi = Arc; /// What a shell hands the service. `Options::default()` is what @@ -222,6 +238,7 @@ pub fn register_with(options: Options) { image_drops: options.image_drops, pending: Arc::default(), export: Arc::default(), + pair: Arc::default(), generation: 0, qr_lifetime: qr_lifetime(options.qr_lifetime_secs), client_ui: options.client_ui, @@ -392,6 +409,8 @@ pub struct LlmService { /// Shared with the workers: they answer the app on success. pending: Arc>>, export: Arc>>, + /// The pairing sheet being prepared, like `export`. + pair: Arc>>, generation: u64, qr_lifetime: u64, client_ui: Option, @@ -918,33 +937,59 @@ fn apply_import(shared: &Shared, pending: &Mutex>, provisioning: } +/// The Talk to Octos sheet's controls (a worker thread: they wait on the +/// kernel). What they answer never carries a token. #[cfg(feature = "octos-core")] fn client_action(method: &str, args: &Value, ui: Option) -> Result { use octosense_kernel as kernel; match method { - "sheet.client_origin" => { - kernel::set_web_client_origin(text(args, "origin"))?; - Ok(json!({})) - } - "sheet.client_copy_token" => { - let ui = ui.ok_or("The host cannot copy to the clipboard.")?; - ui(ClientUiAction::CopyToken(kernel::client_access()?.token)); - Ok(json!({})) - } + "sheet.client_enable" => kernel::set_external_access(args["on"] == true)?, + "sheet.client_rotate" => kernel::rotate_external_access()?, + "sheet.client_origin" => kernel::set_web_client_origin(text(args, "origin"))?, "sheet.client_open" => { let ui = ui.ok_or("The host cannot open a browser.")?; ui(ClientUiAction::OpenWeb(kernel::web_client_url()?)); - Ok(json!({})) - } - _ => { - let access = kernel::client_access()?; - let origin = kernel::web_client_origin(); - let web_url = if origin.is_empty() { String::new() } else { kernel::web_client_url()? }; - Ok(json!({"origin": access.origin, "endpoint": access.endpoint(), - "profile_id": "_main", "session_id": kernel::SYSTEM_SESSION, - "web_origin": origin, "web_url": web_url, "can_copy": ui.is_some()})) + return Ok(json!({})); } + _ => {} } + client_info() +} + +#[cfg(feature = "octos-core")] +fn client_info() -> Result { + use octosense_kernel as kernel; + let enabled = kernel::external_access(); + let web_origin = kernel::web_client_origin(); + let (origin, endpoint, descriptor) = if enabled { + let access = kernel::client_access()?; + let descriptor = kernel::core_dir().map(|d| kernel::connection_file(&d).display().to_string()).unwrap_or_default(); + (access.origin.clone(), access.endpoint(), descriptor) + } else { + Default::default() + }; + Ok(json!({"enabled": enabled, "origin": origin, "endpoint": endpoint, "descriptor": descriptor, + "profile_id": "_main", "session_id": kernel::SYSTEM_SESSION, "web_origin": web_origin})) +} + +/// Mint a pairing code and build its sheet: the code, and a QR of the web +/// client's pairing link. +#[cfg(feature = "octos-core")] +fn pairing_sheet(lifetime: u64) -> Result { + use octosense_kernel as kernel; + let web_origin = kernel::web_client_origin(); + let pairing = kernel::pairing()?; + let link = if web_origin.is_empty() { + None + } else { + let system = kernel::system_reference().ok().map(|(_, reference)| reference); + Some(kernel::pairing_link(&web_origin, &pairing, system.as_deref())?) + }; + let qr = match &link { + Some(link) => Some(qr::render_matrix(link).map_err(|e| e.to_string())?), + None => None, + }; + Ok(sheets::pair_client(&pairing.code, &pairing.server_origin, qr.as_ref().map(|(n, m)| (*n, m.as_slice())), pairing.expires_in_secs.min(lifetime))) } #[cfg(not(feature = "octos-core"))] @@ -952,6 +997,56 @@ fn client_action(_method: &str, _args: &Value, _ui: Option) -> Result< Err("This build does not include the Octos kernel service.".into()) } +#[cfg(not(feature = "octos-core"))] +fn pairing_sheet(_lifetime: u64) -> Result { + Err("This build does not include the Octos kernel service.".into()) +} + +/// The pairing code's sheet is up or being prepared: turn pairing off. +fn end_pairing() { + #[cfg(feature = "octos-core")] + work(octosense_kernel::end_pairing); +} + +impl LlmService { + /// `llm.sheet.client_pair`: prepare the pairing sheet on a worker; the + /// connect sheet polls `sheet.client_pair_ready`, then asks for + /// `sheet.client_pair_show` (the export sheet's pattern). + fn client_pair(&mut self, reply: Replier) { + self.generation += 1; + let generation = self.generation; + *self.pair.lock().unwrap() = Some((generation, None)); + let pair = self.pair.clone(); + let lifetime = self.qr_lifetime.max(60); + work(move || { + let sheet = pairing_sheet(lifetime); + if let Some(job) = pair.lock().unwrap().as_mut().filter(|job| job.0 == generation) { + job.1 = Some(sheet); + } + }); + reply.send(Ok(json!({}))); + } + + fn client_pair_ready(&self, reply: Replier) { + match self.pair.lock().unwrap().as_ref() { + None => reply.send(Err("No pairing code is being prepared.".into())), + Some((_, None)) => reply.send(Ok(json!({"ready": false}))), + Some((_, Some(Ok(_)))) => reply.send(Ok(json!({"ready": true}))), + Some((_, Some(Err(e)))) => reply.send(Err(e.clone())), + } + } + + fn client_pair_show(&mut self, reply: Replier, host: &mut dyn ServiceHost) { + match self.pair.lock().unwrap().take() { + Some((_, Some(Ok(sheet)))) => { + host.open_sheet(sheet); + reply.send(Ok(json!({}))); + } + _ => reply.send(Err("The pairing code is not ready.".into())), + } + } +} + impl HostService for LlmService { fn family(&self) -> &'static str { "llm" @@ -967,13 +1062,28 @@ impl HostService for LlmService { if call.app_id != "os.ai-providers" { return reply.send(Err("Open AI providers to connect a client.".into())); } self.raise(&call.app_id, reply, Kind::Connect, sheets::connect_client(), host); } - "sheet.client_info" | "sheet.client_origin" | "sheet.client_copy_token" | "sheet.client_open" => { + method if method.starts_with("sheet.client_") => { + // Only the Talk to Octos sheet this app raised, never the app. let permitted = call.from_sheet && self.pending.lock().unwrap().as_ref() .is_some_and(|p| p.kind == Kind::Connect && p.app_id == call.app_id); - if !permitted { return reply.send(Err("Open the client connection sheet first.".into())); } - let method = call.method().to_owned(); - let ui = self.client_ui.clone(); - work(move || reply.send(client_action(&method, &call.args, ui))); + if !permitted { return reply.send(Err("Open the Talk to Octos sheet first.".into())); } + match method { + "sheet.client_pair" => self.client_pair(reply), + "sheet.client_pair_ready" => self.client_pair_ready(reply), + "sheet.client_pair_show" => self.client_pair_show(reply, host), + "sheet.client_back" => { + // Leaving the code's sheet ends pairing. + end_pairing(); + host.open_sheet(sheets::connect_client()); + reply.send(Ok(json!({}))); + } + "sheet.client_info" | "sheet.client_enable" | "sheet.client_rotate" | "sheet.client_origin" | "sheet.client_open" => { + let method = method.to_owned(); + let ui = self.client_ui.clone(); + work(move || reply.send(client_action(&method, &call.args, ui))); + } + other => reply.send(Err(format!("llm has no method {other:?}"))), + } } "providers" => { let (shared, scanner, image_picker) = (self.shared.clone(), self.scanner.is_some(), self.image_picker.is_some()); @@ -1057,6 +1167,10 @@ impl HostService for LlmService { "sheet.cancel" => { host.close_sheet(); *self.export.lock().unwrap() = None; + if self.pair.lock().unwrap().take().is_some() || self.pending_kind() == Some(Kind::Connect) { + // The Talk to Octos sheet closed: no pairing code outlives it. + end_pairing(); + } IMAGE_WAITER.lock().unwrap().take(); if let Some(waiting) = self.pending.lock().unwrap().take() { // Closing the QR is how an export ends. diff --git a/apps/ai-providers/host-service/src/sheets.rs b/apps/ai-providers/host-service/src/sheets.rs index 1fecc31e..baf7d93f 100644 --- a/apps/ai-providers/host-service/src/sheets.rs +++ b/apps/ai-providers/host-service/src/sheets.rs @@ -59,69 +59,159 @@ fn frame(actions: &str, content: &str) -> String { ) } -/// Connection controls live on the trusted sheet. The access token is -/// copied by native code and is never present in this script or its replies. +/// Talk to Octos, on the trusted sheet: off by default. Turning it on starts +/// the kernel's loopback server for external clients and mints their token; +/// a web client pairs with a one-time code (`pair_client`). No token is ever +/// shown, copied or sent to this script. pub fn connect_client() -> String { let mut script = String::from(r#" fn status(text){ ui.status.set_text(text) } -fn refresh(){ - status("Starting Octos…") - host.request("llm.sheet.client_info", {}, fn(r){ - if !r.is_ok { status(r.error) return } - ui.origin.set_text(r.data.origin) - ui.endpoint.set_text(r.data.endpoint) - ui.session.set_text(r.data.session_id) - ui.web_origin.set_text(r.data.web_origin) - ui.web_url.set_text(r.data.web_url) - ui.copy.set_visible(r.data.can_copy) - status("Connected. Native apps and external clients share this agent runtime.") - }) +fn show(d){ + if d.enabled { ui.state.set_text("On") } else { ui.state.set_text("Off") } + ui.turn_on.set_visible(d.enabled == false) + ui.on_box.set_visible(d.enabled == true) + ui.origin.set_text(d.origin) + ui.endpoint.set_text(d.endpoint) + ui.descriptor.set_text(d.descriptor) + ui.session.set_text(d.session_id) + ui.web_origin.set_text(d.web_origin) } -fn save_origin(){ - status("Restarting Octos…") - host.request("llm.sheet.client_origin", {origin: ui.web_origin.text()}, fn(r){ - if !r.is_ok { status(r.error) return } - refresh() - }) +fn answer(r){ + if !r.is_ok { status(r.error) return } + show(r.data) + status("") } -fn copy_token(){ - host.request("llm.sheet.client_copy_token", {}, fn(r){ - if r.is_ok { status("Access token copied. Paste it into your client.") } else { status(r.error) } +fn refresh(){ status("Checking…") host.request("llm.sheet.client_info", {}, fn(r){ answer(r) }) } +fn enable(on){ + if on { status("Starting the Talk to Octos server…") } else { status("Stopping external access…") } + host.request("llm.sheet.client_enable", {on: on}, fn(r){ answer(r) }) +} +fn rotate(){ + status("Revoking…") + host.request("llm.sheet.client_rotate", {}, fn(r){ + answer(r) + if r.is_ok { status("Every paired client must pair again.") } }) } -fn open_web(){ - host.request("llm.sheet.client_open", {}, fn(r){ if !r.is_ok { status(r.error) } }) +fn save_origin(){ status("Saving…") host.request("llm.sheet.client_origin", {origin: ui.web_origin.text()}, fn(r){ answer(r) }) } +fn open_web(){ host.request("llm.sheet.client_open", {}, fn(r){ if !r.is_ok { status(r.error) } }) } +fn poll_pair(){ + host.request("llm.sheet.client_pair_ready", {}, fn(r){ + if !r.is_ok { status(r.error) return } + if r.data.ready == true { host.request("llm.sheet.client_pair_show", {}, nil) return } + start_timeout(0.3, || poll_pair()) + }) } +fn pair(){ status("Preparing a pairing code…") host.request("llm.sheet.client_pair", {}, fn(r){ if r.is_ok { poll_pair() } else { status(r.error) } }) } fn close(){ host.request("llm.sheet.cancel", {}, nil) } start_timeout(0.1, || refresh()) "#); script.push_str(STYLES); script.push_str(&frame( r#"Plain{text: "Done" on_click: || close()} - View{width: Fill height: 1} - Plain{text: "Refresh" on_click: || refresh()}"#, + View{width: Fill height: 1}"#, r#"Title{text: "Talk to Octos"} - Note{text: "Connect OctosCode Web or OctosCode in a terminal to this device's assistant."} - Caption{text: "Server origin · for the web client"} - origin := Field{is_read_only: true} - Caption{text: "WebSocket endpoint · for OctosCode --endpoint"} - endpoint := Field{is_read_only: true} - Caption{text: "System-agent conversation · profile _main"} - session := Field{is_read_only: true} - copy := Primary{text: "Copy access token" on_click: || copy_token()} - Caption{text: "Web client origin · for example http://localhost:4173"} - web_origin := Field{empty_text: "http://localhost:4173"} - Note{text: "Use the address where your OctosCode Web client is hosted. Saving a changed origin restarts Octos and interrupts active work."} - Plain{text: "Save origin and restart" on_click: || save_origin()} - Caption{text: "System conversation link · open on either device"} - web_url := Field{is_read_only: true} - Primary{text: "Open web client" on_click: || open_web()} - Note{text: "Enter the server origin and paste the access token in the web client. The link selects the system conversation. A computer connects through a tunnel. Closing the browser can interrupt work started there."} + Note{text: "Let a web client or a terminal on this device talk to your assistant. Off by default; only this device's apps use it then."} + View{width: Fill height: Fit flow: Right spacing: 8 align: Align{y: 0.5} + Caption{text: "Talk to Octos"} + state := Label{text: "" draw_text.color: #x1c1c1e draw_text.text_style: theme.font_bold{font_size: 13}} + } + turn_on := Primary{text: "Turn on" on_click: || enable(true)} + on_box := View{visible: false width: Fill height: Fit flow: Down spacing: 8 + Primary{text: "Pair a web client" on_click: || pair()} + Note{text: "Shows a one-time code and a QR of the web link. A code works once, for five minutes, only while its sheet is open."} + Caption{text: "Web client origin · https, or http only for localhost"} + web_origin := Field{empty_text: "https://web.example"} + Plain{text: "Save origin" on_click: || save_origin()} + Plain{text: "Open web client" on_click: || open_web()} + Caption{text: "Server · the web client pairs with it"} + origin := Field{is_read_only: true} + Caption{text: "WebSocket endpoint · for a terminal client"} + endpoint := Field{is_read_only: true} + Caption{text: "Connection file · a terminal client of this user reads it"} + descriptor := Field{is_read_only: true} + Caption{text: "System-agent conversation · profile _main"} + session := Field{is_read_only: true} + Plain{text: "Revoke all clients" on_click: || rotate()} + Note{text: "Revoking gives clients a new token to pair for. Turning off stops the server; nothing then listens for external clients."} + Plain{text: "Turn off" on_click: || enable(false)} + } + Note{text: "External clients cannot answer approvals for apps' assistants, change settings or stop the assistant. A computer reaches this device through a tunnel that keeps the port number."} status := Status{}"#, )); script } +/// The pairing code's sheet: the code in large type and, when a web origin +/// is saved, a QR of the web client's pairing link. It counts down and ends +/// at zero; leaving it (Back, Done) turns pairing off. +pub fn pair_client(code: &str, server: &str, qr: Option<(usize, &[bool])>, lifetime_secs: u64) -> String { + let mut script = format!( + r##"let left = {lifetime_secs} +let closing = false +fn back(){{ + if closing {{ return }} + closing = true + host.request("llm.sheet.client_back", {{}}, nil) +}} +fn again(){{ + if closing {{ return }} + closing = true + host.request("llm.sheet.client_pair", {{}}, fn(r){{ if r.is_ok {{ poll() }} else {{ ui.status.set_text(r.error) }} }}) +}} +fn poll(){{ + host.request("llm.sheet.client_pair_ready", {{}}, fn(r){{ + if !r.is_ok {{ ui.status.set_text(r.error) return }} + if r.data.ready == true {{ host.request("llm.sheet.client_pair_show", {{}}, nil) return }} + start_timeout(0.3, || poll()) + }}) +}} +fn close(){{ host.request("llm.sheet.cancel", {{}}, nil) }} +fn count_down(){{ + if closing {{ return }} + left = left - 1 + if left <= 0 {{ back() return }} + let m = floor(left / 60) + let s = left - m * 60 + if s < 10 {{ ui.countdown.set_text("Expires in " + m + ":0" + s) }} else {{ ui.countdown.set_text("Expires in " + m + ":" + s) }} + start_timeout(1, || count_down()) +}} +start_timeout(1, || count_down()) +let Dark = SolidView{{height: Fill draw_bg.color: #x000000}} +let Light = View{{height: Fill}} +let QrRow = View{{width: Fit flow: Right}} +{STYLES}"## + ); + let expires = format!("{}:{:02}", lifetime_secs / 60, lifetime_secs % 60); + let qr = match qr { + Some((size, modules)) => format!( + " Note{{text: \"Scan it with the device where the web client runs, or open the web client and type the code.\"}}\n View{{width: Fill height: Fit flow: Down align: Align{{x: 0.5}}\n{} }}\n", + qr_views(size, modules, module_px(size)) + ), + None => " Note{text: \"Save the web client's origin on the previous page to get a QR of its link.\"}\n".to_string(), + }; + let content = format!( + r#" Title{{text: "Pair a web client"}} + Note{{text: "Open the web client, enter this device's server and type the code. The code gives that client access once."}} +{qr} Caption{{text: "Code"}} + code := Label{{width: Fill align: Align{{x: 0.5}} text: "{code}" draw_text.color: ink draw_text.text_style: theme.font_bold{{font_size: 30}}}} + countdown := Caption{{text: "Expires in {expires}"}} + Caption{{text: "Server"}} + Note{{text: "{server}"}} + status := Status{{}}"#, + code = lit(code), + server = lit(server), + ); + script.push_str(&frame( + r#" Plain{text: "Back" on_click: || back()} + View{width: Fill height: 1} + Plain{text: "New code" on_click: || again()} + Primary{text: "Done" on_click: || close()}"#, + &content, + )); + script +} + /// The add and edit sheet: a five-step wizard in Octoscode's `/model` order, /// one step per page, a step indicator and progress bar at the top, and Back /// / Next pinned at the bottom (Next is greyed out until the step is valid): @@ -967,6 +1057,16 @@ if can_drop {{ await_drop() }} mod tests { use super::*; + #[test] + fn the_pairing_sheet_shows_the_code_and_a_qr_of_the_link_only() { + let (size, modules) = octosense_llm_config::qr::render_matrix("https://web.example/?octos=http%3A%2F%2F127.0.0.1%3A4000&pair=ABCD2345").unwrap(); + let sheet = pair_client("ABCD2345", "http://127.0.0.1:4000", Some((size, &modules)), 300); + assert!(sheet.contains("ABCD2345") && sheet.contains("// qr-begin") && sheet.contains("Expires in 5:00")); + assert!(sheet.contains("llm.sheet.client_back"), "leaving the code's sheet turns pairing off"); + let without = pair_client("AB\"CD", "http://127.0.0.1:4000", None, 300); + assert!(!without.contains("// qr-begin") && !without.contains("AB\"CD"), "literals are sanitised"); + } + #[test] fn literals_cannot_be_ended_early() { assert_eq!(lit("a\"b\\c\nd"), "a'b/cd"); diff --git a/apps/ai-providers/host-service/tests/service.rs b/apps/ai-providers/host-service/tests/service.rs index d79c5363..00954321 100644 --- a/apps/ai-providers/host-service/tests/service.rs +++ b/apps/ai-providers/host-service/tests/service.rs @@ -130,20 +130,25 @@ fn two_providers(rig: &mut Rig) { } #[test] -fn client_connection_controls_require_their_own_trusted_sheet() { +fn talk_to_octos_controls_require_their_own_trusted_sheet() { let mut rig = Rig::new("client-sheet", None); - for method in ["llm.sheet.client_info", "llm.sheet.client_copy_token", "llm.sheet.client_open", "llm.sheet.client_origin"] { + let controls = ["llm.sheet.client_info", "llm.sheet.client_enable", "llm.sheet.client_rotate", + "llm.sheet.client_origin", "llm.sheet.client_open", "llm.sheet.client_pair", + "llm.sheet.client_pair_ready", "llm.sheet.client_pair_show", "llm.sheet.client_back"]; + for method in controls { assert!(rig.ask(method, json!({})).is_err()); assert!(rig.sheet(method, json!({})).is_err(), "a different sheet cannot access connection controls"); } let other = rig.send("os.mail", "llm.connect_client", json!({}), false); assert!(wait(other).is_err()); let waiting = rig.send(APP, "llm.connect_client", json!({}), false); - assert!(rig.host.body().contains("Talk to Octos")); - assert!(!rig.host.body().contains("is_password: true")); + let body = rig.host.body().to_string(); + assert!(body.contains("Talk to Octos") && body.contains("Turn on") && body.contains("Pair a web client")); + assert!(!body.contains("is_password: true")); + assert!(!body.to_lowercase().contains("copy"), "no token is ever copied"); assert!(rig.ask("llm.sheet.client_info", json!({})).is_err(), "the calling app cannot impersonate its sheet"); - let other = rig.send("os.mail", "llm.sheet.client_copy_token", json!({}), true); - assert!(wait(other).is_err(), "another app's sheet cannot copy the token"); + let other = rig.send("os.mail", "llm.sheet.client_enable", json!({"on": true}), true); + assert!(wait(other).is_err(), "another app's sheet cannot turn it on"); rig.sheet("llm.sheet.cancel", json!({})).unwrap(); assert_eq!(wait(waiting).unwrap(), json!({}), "the app receives no connection details"); } @@ -685,11 +690,14 @@ fn no_sheet_arms_a_timer_that_outlives_it() { sheets::export_waiting(), sheets::export(size, &modules, "ABCD-EFGH", &["x".into()], 300), sheets::import(true, true, true), + sheets::connect_client(), + sheets::pair_client("ABCD2345", "http://127.0.0.1:4000", Some((size, &modules)), 300), ] { assert!(!body.contains("start_interval"), "{body}"); assert!(!body.contains("fn tick"), "{body}"); } assert!(sheets::export_waiting().contains("host.request(\"llm.sheet.show\", {}, nil)")); + assert!(sheets::connect_client().contains("host.request(\"llm.sheet.client_pair_show\", {}, nil)")); } #[test] diff --git a/apps/appcard/app/crates/octos-app-transport/tests/kernel_restart.rs b/apps/appcard/app/crates/octos-app-transport/tests/kernel_restart.rs index 1644b722..d8fe5c7d 100644 --- a/apps/appcard/app/crates/octos-app-transport/tests/kernel_restart.rs +++ b/apps/appcard/app/crates/octos-app-transport/tests/kernel_restart.rs @@ -46,7 +46,7 @@ fn listed_pid(event: &TransportEvent) -> Option { async fn a_restarted_kernel_is_rejoined_and_the_session_reopened() { let dir = std::env::temp_dir().join(format!("octos-app-kernel-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); - octosense_kernel::configure(octosense_kernel::Options::default().stdio().core_dir(&dir).program(fake_kernel())); + octosense_kernel::configure(octosense_kernel::Options::default().core_dir(&dir).program(fake_kernel())); let cfg = TransportConfig { base_url: Url::parse("http://127.0.0.1").unwrap(), diff --git a/crates/ai-host/src/lib.rs b/crates/ai-host/src/lib.rs index 01b7ee19..2501c31f 100644 --- a/crates/ai-host/src/lib.rs +++ b/crates/ai-host/src/lib.rs @@ -303,13 +303,12 @@ fn register_llm(_core_dir: Option, _import: QrImport) -> bool { #[cfg(feature = "llm")] static CLIENT_UI: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); -/// Every event, on the UI thread, early: performs the host sheet's clipboard -/// and browser actions and pumps the platform scanner/picker results. +/// Every event, on the UI thread, early: performs the Talk to Octos sheet's +/// browser action and pumps the platform scanner/picker results. pub fn handle_event(cx: &mut Cx, event: &Event) { #[cfg(feature = "llm")] for action in std::mem::take(&mut *CLIENT_UI.lock().unwrap()) { match action { - octosense_llm_service::ClientUiAction::CopyToken(token) => cx.copy_to_clipboard(&token), octosense_llm_service::ClientUiAction::OpenWeb(url) => cx.open_url(&url, OpenUrlInPlace::No), } } diff --git a/crates/kernel/Cargo.toml b/crates/kernel/Cargo.toml index 466042f2..82dcf2f3 100644 --- a/crates/kernel/Cargo.toml +++ b/crates/kernel/Cargo.toml @@ -1,8 +1,10 @@ # octosense-kernel — the shell's octos kernel (one per process). # -# Desktop/Android use one authenticated loopback WebSocket server; the -# OpenHarmony core stays embedded. Native consumers keep a shared frame API. -# External clients attach without creating another kernel. See README.md. +# The shell starts ONE kernel lazily (`octos serve --stdio` as a child on +# desktop and Android, the canonical core in-process on OpenHarmony) and +# shares it between consumers over UI Protocol frames. When the person turns +# on Talk to Octos, the child runs as `octos serve --host-managed` instead and +# external clients attach to the same kernel. See README.md. [package] name = "octosense-kernel" version = "0.1.0" @@ -24,6 +26,9 @@ log = "0.4" # `default_core_dir()` — the one definition of where the kernel's profile # lives, shared with the `llm` host service that writes it. octosense-llm-config = { workspace = true } +# `UI_PROTOCOL_STDIO_DEFAULT_FEATURES`: the feature set native consumers keep +# when Talk to Octos moves them from the stdio pipe to the WebSocket. +octos-core = { workspace = true } # OpenHarmony denies exec from HAP native libraries: the canonical core is # linked into the app and served over an in-memory pipe. Same single octos @@ -33,6 +38,11 @@ octosense-llm-config = { workspace = true } octos-cli = { workspace = true, features = ["api", "git", "ast"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] } +# Passing the kept Talk to Octos listener to the kernel (`dup2` between fork +# and exec). +[target.'cfg(unix)'.dependencies] +libc = "0.2" + [dev-dependencies] tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } diff --git a/crates/kernel/README.md b/crates/kernel/README.md index 329555f4..d0590fe2 100644 --- a/crates/kernel/README.md +++ b/crates/kernel/README.md @@ -1,122 +1,190 @@ -# OctoSense's shared Octos server +# octosense-kernel: the shell's octos kernel English | [简体中文](README.zh-CN.md) -The shell owns one Octos agent runtime. Native apps use the scoped app-peer -broker; OctosCode TUI and Web attach to the same authenticated WebSocket server. -They do not start another kernel or contend for the same data directory. - -## Talk to the system agent - -1. Configure a model in **AI providers**, then select **Talk to Octos**. -2. The trusted sheet shows **Server origin**, **WebSocket endpoint**, and the - system conversation, `_main:api:octosense#system` in profile `_main`. -3. For OctosCode Web, enter the origin where you host that client, such as - `http://localhost:4173`, and select **Save origin and restart**. A changed - origin interrupts active work. OctoSense does not bundle web-client assets. -4. Select **Copy access token**, then **Open web client**. Enter the sheet's - server origin and paste the token into the web client's **Auth token** field. - Select **Connect**, then **Open conversation** on its saved-link screen. - The generated link selects the existing system conversation using the - workspace confirmed by Octos. It contains no token. The same link can be - opened on your computer. - -The access token grants control of the agent, including its tools. It is copied -by native code at the user's request; neither the app script nor its service -reply receives it. Only use a web client you trust. - -On desktop, launch OctosCode with its `--endpoint`, `--profile-id _main`, and -`--session '_main:api:octosense#system'` options and supply its auth token through -its environment/configuration. Leave stdio mode disabled. A bare `octoscode` -launch normally starts its own kernel and is not this connection. TUI launch -is **unverified** in this change; simultaneous native and browser clients were -verified against the real kernel with a scripted local model. A headless -Chromium run also opened the generated system link, sent a message and -displayed the reply. - -### Computer connected to an Android device - -The server binds only `127.0.0.1`; another APK on the device still needs the -token. A computer needs a tunnel. The ADB forwarding example below is -**unverified on a device in this change**. Replace `SERIAL` and `PORT` with the -authorized device and the port shown in the sheet; using the same port on both -ends keeps the displayed server address usable on the computer: +The [octos](https://github.com/octos-org/octos) agent kernel is a **shell +service**. The shell (Home in `phone/`, the desktop in `desktop/`) owns it; the +**AI providers** system app configures it through the `llm` host service; +**AppCard**, and next Rinx's native mini-app host, connect to it. This crate +is that service: one kernel per process, started on demand, shared, +restarted when the providers change. + +It lives in `crates/` rather than `apps/` because it is not an app: it is +the shared runtime piece the shells, AppCard (`apps/appcard/app`) and the +`llm` service (`apps/ai-providers/host-service`, feature `octos-core`) link. + +## What it does + +| | | +|---|---| +| **Core dir** | octos's data dir: `/profiles/_main.json` is the profile the AI providers app writes. Resolved as: the shell's `Options::core_dir`, else `$OCTOS_APP_CORE_DIR`, else on Android/OpenHarmony `/octos-home/.octos` (the app-private octos home AppCard has always used), else `$HOME/octos-home/.octos` (`octosense_llm_config::profile::default_core_dir()`). | +| **One kernel, lazily** | The first `connect()` starts it; later ones share it. octos holds a single-writer lock on its data dir, so a second kernel on the same dir could not run anyway. | +| **Shared by frames** | A `Connection` carries UI Protocol (JSON-RPC) frames exactly as `octos serve --stdio` speaks them. Each consumer uses its own request ids and receives the replies to its requests and the notifications of the sessions it named (a notification for a session nobody named goes to every consumer). | +| **Restart** | `restart()` stops a running kernel (a no-op when none runs). Connections then end with `CloseReason::Restarted`; a consumer connects again, which starts a fresh kernel that reads the new profile. The next kernel starts only after the old one has exited and released its data dir. | +| **Idle stop** | When the last connection is dropped the kernel stops, as AppCard's own child used to (not while Talk to Octos is on, below). | +| **Talk to Octos** | Off by default. While the person has it on, the kernel is octos's host-owned loopback server and external clients can attach to the same kernel (below). | +| **Shutdown** | `shutdown()` stops it and waits (5 s at most). | + +How it starts, per platform (`src/launch.rs`): + +- **Android**: `/liboctos.so serve --stdio`, `HOME=` (the octos home), with AppCard's environment + (`OCTOS_SKILLS_PATH`, `OCTOS_OMIT_WORKSPACE_HINT`, `RUST_LOG`, the + `makepad.OCTOS_PROXY` proxy) and the kernel config's memory budget. The APK + must bundle the kernel: `MAKEPAD_ANDROID_EXTRA_LIBS=liboctos.so=` + (the shells' build scripts do it). +- **OpenHarmony**: the canonical core in-process, + `octos_cli::embedded::serve_io(, ..)`, on this crate's runtime + with 8 MiB worker stacks (HAP native libraries may not exec). +- **Desktop**: ` serve --stdio --data-dir ` (plus + `--config /config.json` when that file exists) with + `OCTOS_HOME=`; the program is the shell's `Options::program` or + `$OCTOS_APP_CORE_BIN`. With neither there is no kernel: a developer's own + `octos serve` is never touched. +- **iOS**: no kernel. +- **Talk to Octos on** (desktop and Android): the same command with + `--host 127.0.0.1 --host-managed` instead of `--stdio`, and the listener + this process keeps passed as descriptor 3 (`--listen-fd 3`, Unix). + +The kernel and the frame pump run on the crate's own Tokio runtime, so a +consumer may use any runtime or none. + +## Using it + +A shell, once at startup, before the first consumer: + +```rust +octosense_kernel::configure( + octosense_kernel::Options::default().app_data_dir(cx.get_data_dir()), +); +// The llm service (feature `octos-core`) writes under the same core dir +// and calls octosense_kernel::restart() after every change. +octosense_llm_service::register_with( + octosense_llm_service::Options::default().core_dir(octosense_kernel::core_dir().unwrap()), +); +``` + +A consumer: + +```rust +let mut conn = octosense_kernel::connect()?; // Err: no kernel here +conn.send(r#"{"jsonrpc":"2.0","id":"1","method":"session/open","params":{"session_id":"_main:api:x","profile_id":"_main"}}"#)?; +loop { + match conn.recv().await { + Ok(frame) => { /* a JSON-RPC frame for this consumer */ } + Err(octosense_kernel::CloseReason::Restarted) => { /* connect again, re-open sessions */ break } + Err(other) => { /* the kernel stopped or could not start: tell the person */ break } + } +} +``` + +AppCard's transport (`apps/appcard/app/crates/octos-app-transport`, +`kernel.rs`) is the reference consumer: on `Restarted` it fails the requests +still waiting, reconnects and opens its sessions again from their replay +cursors, so the app carries on. + +**Rinx and other consumers.** A native mini-app host takes its own +connection (`connect()`), opens sessions with ids of its own (Rinx uses +`:api:rinx-mini-…`) and gets only its sessions' traffic, with no +coupling to AppCard's connection or UI queue. It must handle +`CloseReason::Restarted` by reconnecting. + +Other functions: `core_dir()`, `home()`, `profile()`, `launch()` / +`is_available()` (whether and how a kernel would start), `status()`, and the +Talk to Octos controls below. + +## Talk to Octos + +Talk to Octos lets a web client or a terminal UI talk to this device's +assistant. It is **off by default**; the kernel is then the private stdio +child above and nothing listens. **AI providers → Talk to Octos** turns it on +(`set_external_access(true)`), which: + +- restarts the kernel as `octos serve --host-managed` (octos + [`docs/HOST_MANAGED_SERVE.md`](https://github.com/octos-org/octos/blob/main/docs/HOST_MANAGED_SERVE.md)); + native consumers keep the same frames over its WebSocket with a host token + that never leaves this process, and request octos's stdio feature set + (`octos_core::ui_protocol::UI_PROTOCOL_STDIO_DEFAULT_FEATURES`); +- mints an **external token**. It opens `/api/ui-protocol/ws` and nothing + else: no REST or admin route, no `server/shutdown`, and no answers to the + approvals or questions of apps' assistants (host-owned app peers); +- keeps the listener in this process (Unix) and hands it to every kernel + generation, so a restart keeps the port and no other app can take it in + between. Elsewhere a restart reuses the port when it is free, and otherwise + moves to a new port with a new external token; +- keeps the server up when native consumers leave, until it is turned off or + the shell exits. The kernel's stdin is its lifeline: when the shell exits or + crashes, the kernel sees EOF and stops. + +How clients get in: + +- **Web.** The sheet's **Pair a web client** enables octos's pairing + (`pairing()`): an 8-character code, valid for five minutes and one claim, + shown with a QR of the web client's link + (`/?octos=&pair=`). The code only works while + that sheet is open (`end_pairing()` when it closes). The web origin saved on + the sheet is the only browser origin the server trusts: `https`, or `http` + only for localhost, 127.0.0.1 or [::1]. A malformed saved origin counts as + none; the kernel still starts. +- **Terminal.** The connection file `connection_file(core_dir)` + (`/client-connection.json`, mode 0600; on Windows + `%LOCALAPPDATA%\OctoSense\client-connection.json`, whose default ACL admits + this user, SYSTEM and administrators) holds the endpoint and the external + token for a client of this user. It is rewritten when the port or token + changes and removed when the server stops. Terminal UI launch is + **unverified**. +- **Revoke all clients** (`rotate_external_access()`) mints a new external + token and restarts the server; **Turn off** (`set_external_access(false)`) + stops it, forgets the token and port, and removes the connection file. + +A computer reaches a phone's server through a tunnel that keeps the port +number, since the server only answers requests whose `Host` names its own +port (**unverified on a device**): ```sh adb -s SERIAL forward tcp:PORT tcp:PORT ``` -Serve the web client on the computer, allow that exact web origin in the -phone's sheet, open the system conversation link on the computer, and enter the -forwarded server origin and token there. No root, Termux, PRoot or Ubuntu is -required by this APK-bundled server. This does not add a Linux coding toolchain. -A browser may require permission to reach a local-network address. - -## Runtime and lifetime - -- Desktop and Android run `octos serve --host 127.0.0.1 --host-managed`. - Android executes the APK's `liboctos.so`; desktop uses `OCTOS_APP_CORE_BIN`. -- The first native consumer or connection-sheet request starts the server. - Port zero allocates a free port; only the pinned server's listener announcement - is accepted. Native consumers retain their frame API and negotiate the - capabilities previously enabled by stdio. -- The system workspace is resolved and saved in `system-workspace.txt`. Native - system-session opens reuse it, including after Web scopes the session and - after a full shell restart. App-peer workspaces remain independent. -- Provider/origin changes restart the server after the old process exits, keeping - its port and token for reconnecting clients. Dropping the last native consumer - leaves the server running. Closing the shell stops it. This is not a persistent - Android foreground service; Android process death stops the agent too. -- `/client-connection.json` contains connection details and the secret - token, is written atomically with mode `0600`, and is removed on orderly stop. - Tokens rotate when the shell's kernel service is recreated. Do not publish this - file or put the token in logs/command arguments. -- Core directory: explicit `Options::core_dir`, then `OCTOS_APP_CORE_DIR`, then - `/octos-home/.octos` on phones or `~/octos-home/.octos` on desktop. - AI providers writes `/profiles/_main.json`. -- OpenHarmony retains `octos_cli::embedded::serve_io`; external clients are - unavailable there. iOS has no local kernel. `Options::stdio()` retains private - pipe behavior for fixtures and embedding hosts, including stop when idle. - -A shared server is not a shared conversation unless clients open the same -session. App peers keep their own scoped sessions. Browser-owned active turns -may still be interrupted when their WebSocket closes (upstream Octos issue -2167); this integration does not implement detached-turn ownership. - -## Build and verify - -The executable must contain the overlay in -[`octos-runtime-patches.lock.json`](../../octos-runtime-patches.lock.json). -It adds mandatory host-token authentication, disables password-free solo login -and loopback profile-header impersonation, and runs the profile in the server -process. Plain upstream binaries lacking `--host-managed` fail to start; there -is no unauthenticated fallback. The Octos pin and patch hash are checked before -application. Android packaging applies the overlay automatically. - -The desktop plan and tests below were run from the repository root: +The system conversation is `_main:api:octosense#system` in profile `_main`; +its workspace is saved in `system-workspace.txt` so native opens and Web's +scoped session agree. OpenHarmony (embedded core) and iOS (no kernel) have no +Talk to Octos. See [ADR 0003](../../docs/adr/0003-shared-octos-client-access.md) +for the threat model. + +## Testing + +From the repository root: ```sh -python3 tools/kernel-artifact.py --host --plan -python3 -m unittest discover -s tools -p 'test_kernel_artifact.py' -cargo test --locked -p octosense-kernel +cargo test --locked -p octosense-kernel # unit tests + the core against a stand-in kernel (python3) +# The real kernel: a profile written by octosense-llm-config, session/open, +# profile/llm/list, a provider change and a restart; Talk to Octos on and off +# (what the external token must not reach, pairing, restart and rotation); +# native and web clients on one system conversation; and a host killed with +# SIGKILL taking its kernel with it. Build octos at the rev the root +# Cargo.toml pins, then: +OCTOS_CORE_TEST_KERNEL=/path/to/octos cargo test -p octosense-kernel --test real_kernel -- --nocapture ``` -`python3 tools/kernel-artifact.py --host` builds the release desktop executable -under `target/octos-kernel/target/release/octos` (**release command unverified**; -the pinned source plus overlay was built and exercised in debug mode). Set -`OCTOS_APP_CORE_BIN` to that binary when launching a shell. - -Real integration tests use `OCTOS_CORE_TEST_KERNEL=` with -`cargo test --locked -p octosense-kernel --test real_kernel -- --nocapture`. -They check authentication, origin rejection, native/browser conversation -sharing, provider restart, token redaction and shutdown without external model -calls. The app-peer real-kernel suite also runs against this transport. - -On OpenHarmony this crate links `octos-cli` from git octos-org/octos at the -one rev the root `Cargo.toml` pins for every octos crate (`3b5d17a4`). A -workspace that builds it for OpenHarmony also needs the `nix` patch (octos -rev `18fcd3f1`, see the root `Cargo.toml` `[patch.crates-io]`). On every other target it -links no octos crate at all: the kernel is a separate binary. - -See [ADR 0003](../../docs/adr/0003-shared-octos-client-access.md). Android APK -packaging and physical-device behavior remain **unverified** in this change. +Build the kernel for that test (and for an Android APK, with the NDK and +`--target aarch64-linux-android`) from octos-org/octos at the rev in the root +`Cargo.toml` `[workspace.dependencies]`: + +```sh +cargo build --release -p octos-cli --bin octos --no-default-features --features api,git,ast +``` + +`python3 tools/kernel-artifact.py --host --plan` prints the same build of the +pinned revision for this desktop. + +CI: `.github/workflows/apps.yml` (the `services` job tests this crate; the +`apps` job builds AppCard, which links it). + +## One octos + +This crate links `octos-core` (for the stdio feature set) and, on +OpenHarmony, `octos-cli`, from git octos-org/octos at the one rev the root +`Cargo.toml` pins for every octos crate. A workspace that builds it for +OpenHarmony also needs the `nix` patch (octos rev `18fcd3f1`, see the root +`Cargo.toml` `[patch.crates-io]`). Elsewhere the kernel is a separate binary +built from that same rev. diff --git a/crates/kernel/README.zh-CN.md b/crates/kernel/README.zh-CN.md index 8994656c..1b4405d6 100644 --- a/crates/kernel/README.zh-CN.md +++ b/crates/kernel/README.zh-CN.md @@ -1,93 +1,160 @@ -# OctoSense 共享 Octos 服务 +# octosense-kernel:Shell 的 octos 内核 [English](README.md) | 简体中文 -Shell 拥有唯一的 Octos Agent 运行时。原生应用通过受限的 app-peer 接口访问它; -OctosCode 终端和 Web 客户端连接同一个需要认证的 WebSocket 服务,不会另起内核或争用数据目录。 +[octos](https://github.com/octos-org/octos) Agent 内核是一项 **Shell 服务**。 +Shell(`phone/` 中的 Home、`desktop/` 中的桌面)拥有它;**AI providers** 系统应用 +通过 `llm` 宿主服务配置它;**AppCard** 以及之后 Rinx 的原生小程序宿主连接它。 +本 crate 就是这项服务:每个进程一个内核,按需启动、共享,服务商变化时重启。 + +它放在 `crates/` 而不是 `apps/`,因为它不是应用,而是 Shell、AppCard +(`apps/appcard/app`)和 `llm` 服务(`apps/ai-providers/host-service`,feature +`octos-core`)共同链接的运行时组件。 + +## 它做什么 + +| | | +|---|---| +| **Core 目录** | octos 的数据目录:`/profiles/_main.json` 是 AI providers 应用写入的 profile。解析顺序:Shell 的 `Options::core_dir`,否则 `$OCTOS_APP_CORE_DIR`,否则在 Android/OpenHarmony 上为 `<应用数据目录>/octos-home/.octos`(AppCard 一直使用的应用私有 octos home),否则 `$HOME/octos-home/.octos`(`octosense_llm_config::profile::default_core_dir()`)。 | +| **一个内核,按需启动** | 第一次 `connect()` 启动它,之后的连接共享它。octos 对数据目录持有单写者锁,同一目录上本来也无法运行第二个内核。 | +| **按帧共享** | 一个 `Connection` 传递 UI Protocol(JSON-RPC)帧,与 `octos serve --stdio` 的格式完全相同。每个使用方使用自己的请求 id,只收到自己请求的回复和自己指定会话的通知(没有任何使用方指定的会话通知会发给所有使用方)。 | +| **重启** | `restart()` 停止正在运行的内核(没有运行时什么也不做)。连接随后以 `CloseReason::Restarted` 结束;使用方重新连接,会启动读取新 profile 的新内核。新内核要等旧内核退出并释放数据目录后才启动。 | +| **空闲停止** | 最后一个连接被丢弃时内核停止,与 AppCard 自己的子进程过去的行为一致(Talk to Octos 开启时除外,见下文)。 | +| **Talk to Octos** | 默认关闭。用户开启后,内核作为 octos 的宿主托管回环服务运行,外部客户端可以连接同一个内核(见下文)。 | +| **关闭** | `shutdown()` 停止内核并等待(最多 5 秒)。 | + +各平台的启动方式(`src/launch.rs`): + +- **Android**:`/liboctos.so serve --stdio`,`HOME=` + (octos home),使用 AppCard 的环境变量(`OCTOS_SKILLS_PATH`、 + `OCTOS_OMIT_WORKSPACE_HINT`、`RUST_LOG`、`makepad.OCTOS_PROXY` 代理)以及内核配置的 + 内存预算。APK 必须打包内核:`MAKEPAD_ANDROID_EXTRA_LIBS=liboctos.so=` + (Shell 的构建脚本会这样做)。 +- **OpenHarmony**:在进程内运行标准内核 `octos_cli::embedded::serve_io(, ..)`, + 运行在本 crate 的运行时上,工作线程栈 8 MiB(HAP 原生库不能 exec)。 +- **桌面**:` serve --stdio --data-dir `(`/config.json` + 存在时再加 `--config /config.json`),`OCTOS_HOME=`;程序为 Shell 的 + `Options::program` 或 `$OCTOS_APP_CORE_BIN`。两者都没有时就没有内核:绝不会动开发者 + 自己的 `octos serve`。 +- **iOS**:没有内核。 +- **开启 Talk to Octos 时**(桌面和 Android):同一命令,以 `--host 127.0.0.1 --host-managed` + 代替 `--stdio`,并把本进程保留的监听套接字作为描述符 3 传入(`--listen-fd 3`,Unix)。 + +内核和帧转发运行在本 crate 自己的 Tokio 运行时上,因此使用方可以使用任何运行时,或不用运行时。 + +## 使用方式 + +Shell 在启动时、第一个使用方之前调用一次: + +```rust +octosense_kernel::configure( + octosense_kernel::Options::default().app_data_dir(cx.get_data_dir()), +); +// The llm service (feature `octos-core`) writes under the same core dir +// and calls octosense_kernel::restart() after every change. +octosense_llm_service::register_with( + octosense_llm_service::Options::default().core_dir(octosense_kernel::core_dir().unwrap()), +); +``` -## 与系统 Agent 对话 +使用方: + +```rust +let mut conn = octosense_kernel::connect()?; // Err: no kernel here +conn.send(r#"{"jsonrpc":"2.0","id":"1","method":"session/open","params":{"session_id":"_main:api:x","profile_id":"_main"}}"#)?; +loop { + match conn.recv().await { + Ok(frame) => { /* a JSON-RPC frame for this consumer */ } + Err(octosense_kernel::CloseReason::Restarted) => { /* connect again, re-open sessions */ break } + Err(other) => { /* the kernel stopped or could not start: tell the person */ break } + } +} +``` -1. 在 **AI providers** 配置模型,再打开 **Talk to Octos**。 -2. 宿主面板显示服务器地址、WebSocket 地址,以及 `_main` profile 下的系统会话 - `_main:api:octosense#system`。 -3. 输入你托管 OctosCode Web 的来源地址,例如 `http://localhost:4173`,点击 - **Save origin and restart**。修改来源会重启 Octos,并中断正在进行的任务。 - OctoSense 本次没有内置 Web 客户端资源,仍需单独托管。 -4. 点击 **Copy access token**,再点 **Open web client**。在 Web 客户端填写面板中的 - 服务器地址,把令牌粘贴到 **Auth token**,选择 **Connect**,再在链接确认页点击 - **Open conversation**。生成的链接使用服务器确认的工作目录选择 - 系统会话,不包含令牌;也可以在电脑上打开这个链接。 +AppCard 的传输层(`apps/appcard/app/crates/octos-app-transport`,`kernel.rs`)是参考 +使用方:收到 `Restarted` 时,它让仍在等待的请求失败,重新连接,并从各会话的回放游标重新 +打开会话,应用得以继续。 + +**Rinx 与其他使用方。** 原生小程序宿主使用自己的连接(`connect()`),用自己的 id 打开 +会话(Rinx 使用 `:api:rinx-mini-…`),只收到自己会话的流量,不与 AppCard 的 +连接或 UI 队列耦合。它必须通过重新连接来处理 `CloseReason::Restarted`。 + +其他函数:`core_dir()`、`home()`、`profile()`、`launch()` / `is_available()`(是否以及如何 +启动内核)、`status()`,以及下面的 Talk to Octos 控制函数。 + +## Talk to Octos + +Talk to Octos 让 Web 客户端或终端界面与本设备的助手对话。它**默认关闭**;此时内核是上文 +的私有 stdio 子进程,不监听任何端口。**AI providers → Talk to Octos** 可将其开启 +(`set_external_access(true)`),开启后: + +- 内核以 `octos serve --host-managed` 重启(见 octos + [`docs/HOST_MANAGED_SERVE.md`](https://github.com/octos-org/octos/blob/main/docs/HOST_MANAGED_SERVE.md),英文); + 原生使用方通过其 WebSocket 继续收发同样的帧,使用从不离开本进程的宿主令牌,并请求 + octos 的 stdio 功能集(`octos_core::ui_protocol::UI_PROTOCOL_STDIO_DEFAULT_FEATURES`); +- 生成一个**外部令牌**。它只能打开 `/api/ui-protocol/ws`:不能访问 REST 或管理接口, + 不能调用 `server/shutdown`,也不能回答应用助手(宿主拥有的应用 peer)的审批或提问; +- 监听套接字保存在本进程中(Unix)并交给每一代内核,因此重启保持端口不变,中间也没有 + 其他应用能占用它。其他平台上,端口空闲时重启沿用原端口,否则换到新端口并生成新的外部令牌; +- 原生使用方离开后服务仍保持运行,直到关闭该功能或 Shell 退出。内核的 stdin 是它的生命线: + Shell 退出或崩溃时,内核读到 EOF 后停止。 + +客户端如何接入: + +- **Web。** 面板上的 **Pair a web client** 启用 octos 的配对(`pairing()`):一个 8 位 + 配对码,五分钟内有效,只能使用一次,同时显示 Web 客户端链接 + (`/?octos=&pair=`)的二维码。配对码只在该面板打开期间有效 + (面板关闭时调用 `end_pairing()`)。面板上保存的 Web origin 是服务唯一信任的浏览器 + 来源:`https`,或仅限 localhost、127.0.0.1、[::1] 的 `http`。保存的 origin 格式错误时视为 + 没有 origin,内核照常启动。 +- **终端。** 连接文件 `connection_file(core_dir)`(`/client-connection.json`, + 权限 0600;Windows 上为 `%LOCALAPPDATA%\OctoSense\client-connection.json`,其默认 ACL + 只允许当前用户、SYSTEM 和管理员)保存端点和外部令牌,供当前用户的客户端使用。端口或 + 令牌变化时重写,服务停止时删除。启动终端界面**未经验证**。 +- **Revoke all clients**(`rotate_external_access()`)生成新的外部令牌并重启服务; + **Turn off**(`set_external_access(false)`)停止服务,丢弃令牌和端口,并删除连接文件。 + +电脑需要通过保持端口号不变的隧道访问手机上的服务,因为服务只回应 `Host` 指向其自身端口的 +请求(**未在设备上验证**): -令牌可控制 Agent 及其工具。复制动作由原生代码执行,应用脚本和服务返回值都不会获得令牌。 -仅使用你信任的 Web 客户端。 +```sh +adb -s SERIAL forward tcp:PORT tcp:PORT +``` -桌面 OctosCode 使用 `--endpoint`、`--profile-id _main` 和 -`--session '_main:api:octosense#system'` 参数,并通过它的环境变量或配置传入令牌,关闭 -stdio 模式。直接启动 `octoscode` 通常会另起自己的内核。本次**未验证 TUI 启动**; -已经使用真实内核和本地模拟模型验证原生客户端与浏览器类型客户端同时连接;也使用无界面 -Chromium 打开生成的系统会话链接,发送消息并看到模型回复。 +系统会话为 profile `_main` 中的 `_main:api:octosense#system`;其工作区保存在 +`system-workspace.txt` 中,使原生打开与 Web 的限定会话保持一致。OpenHarmony(嵌入式内核) +和 iOS(没有内核)没有 Talk to Octos。威胁模型见 +[ADR 0003(英文)](../../docs/adr/0003-shared-octos-client-access.md)。 -### 电脑连接 Android 手机 +## 测试 -服务只监听 `127.0.0.1`,手机上的其他 APK 仍需令牌。电脑需要隧道。以下 ADB 示例在本次 -修改中**未经过设备验证**:把 `SERIAL` 换成获授权的设备序列号,`PORT` 换成面板显示的端口。 -两端使用相同端口时,电脑可以直接使用面板中的服务器地址。 +在仓库根目录: ```sh -adb -s SERIAL forward tcp:PORT tcp:PORT +cargo test --locked -p octosense-kernel # unit tests + the core against a stand-in kernel (python3) +# The real kernel: a profile written by octosense-llm-config, session/open, +# profile/llm/list, a provider change and a restart; Talk to Octos on and off +# (what the external token must not reach, pairing, restart and rotation); +# native and web clients on one system conversation; and a host killed with +# SIGKILL taking its kernel with it. Build octos at the rev the root +# Cargo.toml pins, then: +OCTOS_CORE_TEST_KERNEL=/path/to/octos cargo test -p octosense-kernel --test real_kernel -- --nocapture ``` -在电脑托管 Web 客户端,在手机面板允许该精确来源地址,再在电脑打开系统会话链接并填写 -转发后的服务器地址与令牌。这个 APK 内置服务无需 root、Termux、PRoot 或 Ubuntu,但本次 -修改也不会添加 Linux 编程工具链。浏览器可能要求授予本地网络访问权限。 - -## 运行方式与生命周期 - -- 桌面和 Android 启动 `octos serve --host 127.0.0.1 --host-managed`。 - Android 执行 APK 中的 `liboctos.so`,桌面使用 `OCTOS_APP_CORE_BIN`。 -- 首个原生使用方或连接面板启动服务。首次分配空闲端口,只接受固定版本内核的监听地址公告。 - 原生接口保持不变,显式协商原 stdio 默认支持的协议能力。 -- 系统工作目录解析后保存在 `system-workspace.txt`,原生客户端打开系统会话时复用它。 - Web 为会话绑定目录后,即使 Shell 重启也可继续访问;应用 peer 的目录仍相互独立。 -- 更改模型或 Web 来源时,等待旧进程退出后重启,保留端口和令牌以便客户端重连。 - 关闭全部原生应用后服务继续运行;关闭 Shell 时停止。这不是 Android 常驻前台服务, - Android 杀死应用进程后 Agent 也会停止。 -- `/client-connection.json` 保存连接信息和秘密令牌,以 `0600` 权限原子写入, - 正常停止时删除。重新创建 Shell 内核服务会更换令牌。不要发布此文件,也不要把令牌写进 - 日志或命令行参数。 -- core 目录按顺序选择:`Options::core_dir`、`OCTOS_APP_CORE_DIR`、手机上的 - `<应用数据目录>/octos-home/.octos`,或桌面的 `~/octos-home/.octos`。 - AI providers 写入 `/profiles/_main.json`。 -- OpenHarmony 继续使用进程内 `serve_io`,暂不支持外部客户端;iOS 没有本地内核。 - `Options::stdio()` 为测试替身和嵌入宿主保留私有管道及空闲停止行为。 - -共享服务器不等于共享会话;客户端必须打开相同 session。应用 peer 仍有独立的受限会话。 -浏览器断开 WebSocket 时,它发起的运行中任务仍可能被中断(上游 Octos issue 2167); -本次没有实现脱离客户端的任务所有权。 - -## 构建与验证 - -可执行文件必须包含 [octos-runtime-patches.lock.json](../../octos-runtime-patches.lock.json) -锁定的补丁:强制宿主令牌认证、禁用免密 solo 登录和本地 profile 请求头冒充,并在服务器 -进程内运行 profile。没有 `--host-managed` 的普通上游内核会启动失败,不会回退到免认证模式。 -应用补丁前检查固定版本及哈希。Android 打包工具自动应用它。 - -以下命令已在仓库根目录运行: +用根目录 `Cargo.toml` `[workspace.dependencies]` 中的 rev 从 octos-org/octos 构建该测试用的 +内核(Android APK 用的内核再加 NDK 和 `--target aarch64-linux-android`): ```sh -python3 tools/kernel-artifact.py --host --plan -python3 -m unittest discover -s tools -p 'test_kernel_artifact.py' -cargo test --locked -p octosense-kernel +cargo build --release -p octos-cli --bin octos --no-default-features --features api,git,ast ``` -`python3 tools/kernel-artifact.py --host` 在 `target/octos-kernel/target/release/octos` -生成桌面 release 内核(**该 release 命令未验证**;已构建并测试同版本加补丁的 debug 内核)。 -启动 Shell 时把 `OCTOS_APP_CORE_BIN` 指向该文件。 +`python3 tools/kernel-artifact.py --host --plan` 会打印在本机构建所锁定版本的同一构建步骤。 + +CI:`.github/workflows/apps.yml`(`services` 任务测试本 crate;`apps` 任务构建链接它的 AppCard)。 -真实内核测试使用 `OCTOS_CORE_TEST_KERNEL=<带补丁的二进制>` 和 -`cargo test --locked -p octosense-kernel --test real_kernel -- --nocapture`,覆盖认证、 -来源拒绝、原生与浏览器共享对话、模型重启、令牌隐藏和关闭,不调用外部模型。 -app-peer 的真实内核测试也使用新传输。 +## 只有一个 octos -见 [ADR 0003(英文)](../../docs/adr/0003-shared-octos-client-access.md)。 -本次修改的 Android APK 打包及真机行为仍**未验证**。 +本 crate 从 git octos-org/octos 链接 `octos-core`(用于 stdio 功能集),在 OpenHarmony 上还 +链接 `octos-cli`,二者都使用根目录 `Cargo.toml` 为所有 octos crate 锁定的同一个 rev。为 +OpenHarmony 构建它的工作区还需要 `nix` 补丁(octos rev `18fcd3f1`,见根目录 `Cargo.toml` +的 `[patch.crates-io]`)。其他平台上,内核是用同一 rev 构建的独立二进制文件。 diff --git a/crates/kernel/src/kernel.rs b/crates/kernel/src/kernel.rs index 9d5677eb..02b3f0b4 100644 --- a/crates/kernel/src/kernel.rs +++ b/crates/kernel/src/kernel.rs @@ -12,10 +12,9 @@ use tokio::sync::{mpsc, watch}; use crate::launch::Launch; use crate::router::{ConnId, Router}; -use crate::{CloseReason, LogSink, ClientAccess}; use crate::network::Network; +use crate::{ClientAccess, CloseReason, LogSink}; use std::path::PathBuf; -use std::sync::atomic::Ordering; /// What a consumer's inbound channel carries. #[derive(Debug)] @@ -34,6 +33,9 @@ pub(crate) enum Ctl { /// How long a stopping kernel may drain before it is killed. const STDIO_GRACE: Duration = Duration::from_secs(3); +/// A host-managed server drains open WebSocket connections for up to 10 s +/// after its stdin closes (octos `SERVE_SHUTDOWN_GRACE`). +const SHARED_GRACE: Duration = Duration::from_secs(12); /// The embedded core drains owned turns on EOF (as AppCard allowed it). #[cfg(target_env = "ohos")] const EMBEDDED_GRACE: Duration = Duration::from_secs(12); @@ -48,7 +50,10 @@ struct Io { writer: Pin>, lines: tokio::io::Lines>>>, running: Running, - network: bool, + /// Talk to Octos: the child's stdin, held open for its whole life. The + /// host-managed server stops when it reaches EOF, which also happens + /// when this process dies, so a crashed shell leaves no kernel behind. + lifeline: Option, } /// Keeps the last lines the kernel wrote to stderr, to say why it exited. @@ -82,10 +87,17 @@ fn start(launch: &Launch, network: &Network, log: &LogSink, tail: &Tail) -> Resu for (k, v) in env { command.env(k, v); } + let mut secrets = Vec::new(); if shared { - command.args(["--port", &network.port.load(Ordering::Relaxed).to_string()]) - .env("OCTOS_AUTH_TOKEN", &network.token).env("NO_COLOR", "1") - .env_remove("OCTOS_INSTANCE_DATA_DIR"); + let external = network.external_token(); + command + .env("OCTOS_AUTH_TOKEN", network.host_token()) + .env("OCTOS_HOST_EXTERNAL_TOKEN", &external) + .env("NO_COLOR", "1") + .env_remove("OCTOS_INSTANCE_DATA_DIR") + .env_remove("OCTOS_SOLO_LOGIN"); + secrets = vec![network.host_token().to_owned(), external]; + pass_listener(&mut command, network)?; } if let Some(cwd) = cwd { command.current_dir(cwd); @@ -99,21 +111,21 @@ fn start(launch: &Launch, network: &Network, log: &LogSink, tail: &Tail) -> Resu if let Some(stderr) = child.stderr.take() { let log = log.clone(); let tail = tail.clone(); - let token = network.token.clone(); tokio::spawn(async move { let mut lines = BufReader::new(stderr).lines(); while let Ok(Some(line)) = lines.next_line().await { - let line = line.replace(&token, "[redacted]"); + let line = secrets.iter().fold(line, |line, secret| line.replace(secret.as_str(), "[redacted]")); (log)(&format!("octos: {line}")); tail.push(line); } }); } - Ok(Io { - writer: Box::pin(stdin), - lines: BufReader::new(Box::pin(stdout) as Pin>).lines(), - running: Running::Child(child), - network: shared, + let lines = BufReader::new(Box::pin(stdout) as Pin>).lines(); + Ok(if shared { + // Frames go over the WebSocket once it is up (`prepare_network`). + Io { writer: Box::pin(tokio::io::sink()), lines, running: Running::Child(child), lifeline: Some(stdin) } + } else { + Io { writer: Box::pin(stdin), lines, running: Running::Child(child), lifeline: None } }) } #[cfg(target_env = "ohos")] @@ -132,7 +144,7 @@ fn start(launch: &Launch, network: &Network, log: &LogSink, tail: &Tail) -> Resu writer: Box::pin(writer), lines: BufReader::new(Box::pin(reader) as Pin>).lines(), running: Running::Embedded(task), - network: false, + lifeline: None, }) } #[cfg(not(target_env = "ohos"))] @@ -141,27 +153,58 @@ fn start(launch: &Launch, network: &Network, log: &LogSink, tail: &Tail) -> Resu } +/// Unix: hand the listener this process keeps to the child as descriptor 3 +/// (`--listen-fd 3`). Elsewhere: `--port`, the previous one when still free. +#[cfg(unix)] +fn pass_listener(command: &mut tokio::process::Command, network: &Network) -> Result<(), String> { + let fd = network.listener_fd().map_err(|e| format!("cannot open the Talk to Octos listener: {e}"))?; + command.args(["--listen-fd", "3"]); + // SAFETY: between fork and exec only async-signal-safe dup2/fcntl run. + // They place the kept listener at descriptor 3 and clear its + // close-on-exec flag there (dup2 onto itself would keep the flag). + unsafe { + command.pre_exec(move || { + if fd != 3 && libc::dup2(fd, 3) == -1 { + return Err(std::io::Error::last_os_error()); + } + if libc::fcntl(3, libc::F_SETFD, 0) == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + Ok(()) +} + +#[cfg(not(unix))] +fn pass_listener(command: &mut tokio::process::Command, network: &Network) -> Result<(), String> { + command.args(["--port", &network.port_for_restart().to_string()]); + Ok(()) +} + async fn prepare_network( io: &mut Io, network: &Network, core_dir: &std::path::Path, ready: &watch::Sender>>, ) -> Result<(), String> { - if !io.network { - ready.send_replace(Some(Err(CloseReason::Failed("This platform uses an embedded/private pipe kernel; external clients are unavailable.".into())))); + if io.lifeline.is_none() { + // The private pipe (Talk to Octos off, or the embedded core). + ready.send_replace(Some(Err(CloseReason::Failed("Talk to Octos is off.".into())))); return Ok(()); } tokio::time::timeout(Duration::from_secs(90), async { - let access = loop { + loop { let line = io.lines.next_line().await.map_err(|e| e.to_string())? - .ok_or_else(|| "the kernel exited before opening its WebSocket listener; use an OctoSense kernel built with the host-managed overlay".to_owned())?; - if let Some(access) = network.announced(&line) { break access; } - }; - let stream = crate::network::connect(&access).await?; + .ok_or_else(|| "the kernel exited before opening its WebSocket listener (it needs an octos with `serve --host-managed`)".to_owned())?; + if network.announced(&line).is_some() { break; } + } + let access = network.access(); + let stream = crate::network::connect(&access, network.host_token()).await?; let (reader, writer) = tokio::io::split(stream); let stdout = std::mem::replace(&mut io.lines, BufReader::new(Box::pin(reader) as Pin>).lines()); io.writer = Box::pin(writer); - // Drain without logging: the server also prints its pairing code here. + // Keep draining the child's stdout; nothing more is parsed from it. tokio::spawn(async move { let mut lines = stdout; while let Ok(Some(_)) = lines.next_line().await {} }); - access.save(core_dir).map_err(|e| format!("cannot save private client connection: {e}"))?; + access.save(core_dir).map_err(|e| format!("cannot save the private client connection: {e}"))?; ready.send_replace(Some(Ok(access))); Ok(()) }).await.map_err(|_| "the kernel did not become ready within 90 seconds".to_owned())? @@ -207,16 +250,17 @@ async fn exited(running: &mut Running) -> String { /// Stop the kernel: close its input (it drains owned turns on EOF), wait a /// grace period, then kill it and wait, so its data-dir lock is released /// before a next generation starts. -async fn stop(mut io: Io) { - if io.network { - let _ = tokio::time::timeout(Duration::from_secs(1), io.writer.write_all(b"{\"jsonrpc\":\"2.0\",\"id\":\"host-stop\",\"method\":\"server/shutdown\",\"params\":{}}\n")).await; - } - let Io { writer, lines, mut running, .. } = io; +async fn stop(io: Io) { + // Closing stdin stops either kind of child: the stdio pipe drains owned + // turns on EOF; a host-managed server drains its connections and exits. + let Io { writer, lines, mut running, lifeline } = io; + let grace = if lifeline.is_some() { SHARED_GRACE } else { STDIO_GRACE }; drop(writer); drop(lines); + drop(lifeline); match &mut running { Running::Child(child) => { - if tokio::time::timeout(STDIO_GRACE, child.wait()).await.is_err() { + if tokio::time::timeout(grace, child.wait()).await.is_err() { let _ = child.start_kill(); let _ = child.wait().await; } @@ -333,9 +377,16 @@ pub(crate) async fn supervise( } } }; + let was_ready = matches!(&*ready.borrow(), Some(Ok(_))); ready.send_replace(Some(Err(reason.clone()))); if matches!(launch, Launch::WebSocket { .. }) { - let _ = std::fs::remove_file(core_dir.join(crate::CONNECTION_FILE)); + crate::network::remove_descriptor(&core_dir); + // Without an inherited listener, a server that never came up may + // have lost its port to another process: the next start takes a new + // port and a new external token. + if !was_ready && !matches!(reason, CloseReason::Restarted | CloseReason::Shutdown) && cfg!(not(unix)) { + network.fall_back(); + } } // Every consumer of this generation learns why it ended — including one // whose Attach is still queued. diff --git a/crates/kernel/src/launch.rs b/crates/kernel/src/launch.rs index e715f7bb..3eb8e1b4 100644 --- a/crates/kernel/src/launch.rs +++ b/crates/kernel/src/launch.rs @@ -1,19 +1,23 @@ //! How a kernel starts on this platform. Resolved afresh for every start, so //! a changed configuration applies on the next start. //! -//! - **Android**: `/liboctos.so serve --host 127.0.0.1 --host-managed`, with +//! - **Android**: `/liboctos.so serve --stdio`, with //! `HOME=` and the kernel home as cwd. An app may exec only //! from its nativeLibraryDir, so the APK bundles the kernel as a "library" //! (`MAKEPAD_ANDROID_EXTRA_LIBS=liboctos.so=`). The environment and //! the kernel config merge are the ones AppCard's `stdio_spawn` used. //! - **OpenHarmony**: the canonical core in-process //! (`octos_cli::embedded::serve_io`); HAP native libraries cannot exec. -//! - **Desktop**: ` serve --host 127.0.0.1 --host-managed --data-dir --config +//! - **Desktop**: ` serve --stdio --data-dir --config //! /config.json` with `OCTOS_HOME=`, where the program //! is the shell's [`crate::Options::program`] or `$OCTOS_APP_CORE_BIN`. //! Without one there is no kernel (a developer's own `octos serve` is never //! touched). //! - **iOS**: no kernel (an app cannot exec a child). +//! +//! With Talk to Octos on (desktop and Android), `--stdio` becomes +//! `--host 127.0.0.1 --host-managed`: octos's host-owned loopback server +//! (see [`crate::network`]). use std::path::{Path, PathBuf}; @@ -45,8 +49,8 @@ impl Launch { match self { Self::Stdio { program, mut args, env, cwd } => { args.retain(|arg| arg != "--stdio"); - // The reviewed kernel overlay keeps authentication mandatory while - // running the profile in this process, without gateway children. + // octos's host-owned server: mandatory tokens, profiles in + // this process, no solo login, stops on stdin EOF. args.extend(["--host".into(), "127.0.0.1".into(), "--host-managed".into()]); Self::WebSocket { program, args, env, cwd } } diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index b4b9cdfa..3d2aab6e 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -10,15 +10,22 @@ //! next ones share it. octos holds a single-writer lock on its data dir, //! so two kernels on one core dir could not coexist anyway. //! - **Shared by frames.** A connection carries UI Protocol (JSON-RPC) -//! frames, one per `send`/`recv`, over the shared WebSocket connection. Each consumer uses its own request ids and sees the replies to its +//! frames, one per `send`/`recv`, exactly as `octos serve --stdio` speaks +//! them. Each consumer uses its own request ids and sees the replies to its //! own requests and the notifications of the sessions it opened (see //! `router`). //! - **Restart.** [`restart`] stops a running kernel (a no-op when none //! runs); each connection's `recv` then returns -//! [`CloseReason::Restarted`], and the consumer reconnects. The service starts a -//! fresh server at the same address with the same token. -//! - **Lifetime.** The shared server survives native app closure so external -//! clients remain usable. Private pipe/embedded mode still stops when idle. +//! [`CloseReason::Restarted`], and the consumer reconnects, which starts a +//! fresh kernel that reads the new profile. +//! - **Idle stop.** When the last connection is dropped the kernel stops, as +//! AppCard's own `kill_on_drop` child did. +//! - **Talk to Octos** (off by default; desktop and Android). While the +//! person has it on ([`set_external_access`]), the kernel is octos's +//! host-managed loopback server: native consumers keep the same frames over +//! its WebSocket, external clients attach with a separate token (see +//! `network`), a restart brings the server straight back, and it does not +//! stop when native consumers leave. //! - **Shutdown.** [`shutdown`] stops it and waits. //! //! Where it runs: see [`launch`]. The kernel and this crate's frame pump run @@ -34,7 +41,7 @@ use tokio::sync::{mpsc, watch}; pub mod dirs; mod kernel; mod network; -pub use network::{ClientAccess, CONNECTION_FILE, SYSTEM_SESSION}; +pub use network::{connection_file, pairing_link, ClientAccess, Pairing, CONNECTION_FILE, SYSTEM_SESSION}; pub mod launch; mod router; @@ -59,7 +66,6 @@ pub struct Options { program: Option, env: Vec<(String, String)>, log: Option, - stdio: bool, } impl std::fmt::Debug for Options { @@ -74,10 +80,6 @@ impl std::fmt::Debug for Options { } impl Options { - /// Private pipe mode for embedding hosts and protocol fixtures. The - /// desktop and Android defaults are a shared loopback WebSocket server. - pub fn stdio(mut self) -> Self { self.stdio = true; self } - /// The kernel's core dir (octos data dir, `/profiles/_main.json`). /// Default: see [`resolve_core_dir`]. pub fn core_dir(mut self, dir: impl Into) -> Self { @@ -254,13 +256,17 @@ impl Core { program: options.program.as_deref(), env: &options.env, })?; - let mut launch = if options.stdio { launch } else { launch.websocket() }; + // Talk to Octos is the person's opt-in; the private pipe otherwise. + let Some(dir) = core_dir.as_ref().filter(|dir| network::external_access_enabled(dir)) else { + return Ok(launch); + }; + let mut launch = launch.websocket(); if let Launch::WebSocket { env, .. } = &mut launch { - if let Some(dir) = core_dir.as_ref() { - if let Ok(origin) = std::fs::read_to_string(dir.join("web-client-origin.txt")) { - let origin = network::validate_origin(origin.trim()).map_err(Unavailable::NoKernel)?; - env.push(("OCTOS_APPUI_ALLOWED_ORIGINS".into(), origin)); - } + // A malformed saved origin means none: the kernel stays up and + // trusts no browser origin (never the built-in development ones). + let origin = network::read_web_origin(dir); + if !origin.is_empty() { + env.push(("OCTOS_APPUI_ALLOWED_ORIGINS".into(), origin)); } } Ok(launch) @@ -328,16 +334,111 @@ impl Core { } /// Restart the kernel if one runs: its connections close with - /// [`CloseReason::Restarted`]. Shared mode starts a replacement immediately; - /// pipe mode waits for the next consumer. Returns whether one - /// was running. Callable from any thread. + /// [`CloseReason::Restarted`]. With Talk to Octos on, a replacement + /// starts at once (external clients reconnect to it); otherwise the next + /// consumer starts it. Returns whether one was running. Callable from any + /// thread. pub fn restart(&self) -> bool { - let shared = self.0.state.lock().unwrap().current.as_ref().is_some_and(|g| g.shared); let running = self.stop_current(CloseReason::Restarted); - if running && shared { let _ = self.connect(); } + if running && self.shared_launch() { + let _ = self.connect(); + } running } + fn shared_launch(&self) -> bool { + matches!(self.launch(), Ok(Launch::WebSocket { .. })) + } + + /// Whether Talk to Octos is on. + pub fn external_access(&self) -> bool { + self.core_dir().is_some_and(|dir| network::external_access_enabled(&dir)) + } + + /// Turn Talk to Octos on or off. On: mint a fresh external token and + /// (re)start the kernel as the host-managed server. Off: forget the + /// token and port, remove the descriptor and go back to the private + /// pipe; every external connection ends with the server. A running + /// kernel restarts either way. + pub fn set_external_access(&self, on: bool) -> Result<(), String> { + let dir = self.core_dir().ok_or("No kernel data directory.")?; + // OpenHarmony's core is embedded (no process to serve from); iOS + // has no kernel. + if on && !matches!(self.launch().map_err(|e| e.to_string())?, Launch::Stdio { .. } | Launch::WebSocket { .. }) { + return Err("Talk to Octos is not available on this platform.".into()); + } + network::save_external_access(&dir, on).map_err(|e| format!("Could not save the setting: {e}"))?; + if on { + self.0.network.rotate(); + } else { + self.0.network.clear(); + network::remove_descriptor(&dir); + } + if !self.restart() && on { + // Nothing ran: start the server now so clients can attach. + let _ = self.connect(); + } + Ok(()) + } + + /// Retire the external token: every paired or connected external client + /// must pair again. The server restarts to drop open connections. + pub fn rotate_external_access(&self) -> Result<(), String> { + if !self.external_access() { + return Err("Talk to Octos is off.".into()); + } + self.0.network.rotate(); + if !self.restart() { + let _ = self.connect(); + } + Ok(()) + } + + /// Run `f` against the ready server on the core's runtime and wait (for a + /// host worker thread; never the UI thread). + fn with_ready_server( + &self, + timeout: Duration, + f: impl FnOnce(ClientAccess, Arc) -> std::pin::Pin> + Send>> + Send + 'static, + ) -> Result { + if !self.external_access() { + return Err("Turn on Talk to Octos first.".into()); + } + let connection = self.connect().map_err(|e| e.to_string())?; + let network = self.0.network.clone(); + let (tx, rx) = std::sync::mpsc::sync_channel(1); + self.0.runtime().spawn(async move { + let result = match connection.client_access().await { + Ok(access) => f(access, network).await, + Err(e) => Err(e.to_string()), + }; + drop(connection); + let _ = tx.send(result); + }); + rx.recv_timeout(timeout).map_err(|_| "The Octos server did not become ready.".to_string())? + } + + /// Enable a one-time pairing code for the external token (five minutes, + /// one claim). Call while the pairing UI is open; [`Self::end_pairing`] + /// when it closes. + pub fn pairing(&self) -> Result { + self.with_ready_server(Duration::from_secs(100), |access, network| { + Box::pin(async move { network::start_pairing(&access, network.host_token()).await }) + }) + } + + /// Turn pairing off again (best effort; codes also expire on their own). + pub fn end_pairing(&self) { + if self.external_access() && self.status().running { + let _ = self.with_ready_server(Duration::from_secs(15), |access, network| { + Box::pin(async move { + network::end_pairing(&access, network.host_token()).await; + Ok(()) + }) + }); + } + } + /// Stop the kernel (if any) and wait up to `timeout` for it to exit. pub fn shutdown_within(&self, timeout: Duration) -> bool { let running = self.stop_current(CloseReason::Shutdown); @@ -378,16 +479,10 @@ impl Core { } } - /// Start the shared server and wait for its authenticated endpoint. For - /// a host worker thread; never block the UI thread on kernel startup. + /// The external clients' connection (Talk to Octos on), once the server + /// is ready. For a host worker thread; never block the UI thread. pub fn client_access(&self) -> Result { - let connection = self.connect().map_err(|e| e.to_string())?; - let (tx, rx) = std::sync::mpsc::sync_channel(1); - self.0.runtime().spawn(async move { - let result = connection.client_access().await.map_err(|e| e.to_string()); - let _ = tx.send(result); - }); - rx.recv_timeout(Duration::from_secs(95)).map_err(|_| "The Octos server did not become ready.".to_string())? + self.with_ready_server(Duration::from_secs(95), |access, _| Box::pin(async move { Ok(access) })) } /// A connection left. Only private pipe/embedded kernels stop when idle. @@ -424,8 +519,8 @@ impl std::fmt::Debug for Connection { } impl Connection { - /// Wait for the actual, authenticated server. The token is for trusted - /// host UI only. Pipe-only platforms return an explicit error. + /// Wait for the Talk to Octos server; its token is the EXTERNAL one, for + /// trusted host UI only. With Talk to Octos off this is an error. pub async fn client_access(&self) -> Result { let mut ready = self.ready.clone(); ready.wait_for(|v| v.is_some()).await.map_err(|_| CloseReason::Shutdown)?; @@ -433,11 +528,10 @@ impl Connection { result } - /// Open the system conversation and build OctosCode Web's saved-session - /// link from the workspace the server confirms. No credential is in it. - pub async fn system_web_url(&mut self, origin: &str) -> Result { - let origin = network::validate_origin(origin)?; - if origin.is_empty() { return Err("Save the web client's origin first.".into()); } + /// Open the system conversation and return OctosCode Web's saved-session + /// reference (`[workspace, profile, session]`) for the workspace the + /// server confirms. No credential is in it. + pub async fn system_reference(&mut self) -> Result { let id = uuid::Uuid::new_v4().to_string(); self.send(serde_json::json!({"jsonrpc":"2.0","id":id,"method":"session/open", "params":{"session_id":SYSTEM_SESSION,"profile_id":"_main"}}).to_string()) @@ -458,10 +552,7 @@ impl Connection { .map_err(|_| "The system workspace could not be resolved.")?; let dir = self.core.core_dir().ok_or("No kernel data directory.")?; network::save_system_workspace(&dir, &workspace)?; - let mut url = url::Url::parse(&origin).map_err(|e| e.to_string())?; - let reference = serde_json::json!([workspace,"_main",SYSTEM_SESSION]).to_string(); - url.query_pairs_mut().append_pair("s", &reference); - return Ok(url.to_string()); + return Ok(serde_json::json!([workspace,"_main",SYSTEM_SESSION]).to_string()); } }).await.map_err(|_| "Opening the system conversation timed out.".to_owned())? } @@ -546,43 +637,92 @@ pub fn connect() -> Result { global().connect() } -/// Connection details for a trusted host sheet (run on a worker thread). +/// The external clients' connection while Talk to Octos is on (host worker +/// thread only). pub fn client_access() -> Result { global().client_access() } +/// Whether Talk to Octos is on. +pub fn external_access() -> bool { + global().external_access() +} + +/// Turn Talk to Octos on (mints the external token) or off (ends external +/// access). Host worker thread only. +pub fn set_external_access(on: bool) -> Result<(), String> { + global().set_external_access(on) +} + +/// Retire the external token; paired clients must pair again. +pub fn rotate_external_access() -> Result<(), String> { + global().rotate_external_access() +} + +/// A one-time pairing code for the external token (host worker only). +pub fn pairing() -> Result { + global().pairing() +} + +/// Turn the pairing code off (host worker only). +pub fn end_pairing() { + global().end_pairing() +} + /// A credential-free link to the system conversation (host worker only). -/// The user still supplies the server origin and token in the web client. +/// The web client pairs (code or QR) before it can connect. pub fn web_client_url() -> Result { + system_reference().map(|(origin, reference)| { + let mut url = url::Url::parse(&origin).expect("a validated origin"); + if let Some(server) = client_access().ok().map(|a| a.origin) { + url.query_pairs_mut().append_pair("octos", &server); + } + url.query_pairs_mut().append_pair("s", &reference); + url.to_string() + }) +} + +/// The saved web origin and the system conversation reference Web opens. +pub fn system_reference() -> Result<(String, String), String> { let core = global(); - let mut connection = core.connect().map_err(|e| e.to_string())?; let origin = web_client_origin(); + if origin.is_empty() { + return Err("Save the web client's origin first.".into()); + } + if !core.external_access() { + return Err("Turn on Talk to Octos first.".into()); + } + let mut connection = core.connect().map_err(|e| e.to_string())?; let (tx, rx) = std::sync::mpsc::sync_channel(1); core.0.runtime().spawn(async move { let result = async { connection.client_access().await.map_err(|e| e.to_string())?; - connection.system_web_url(&origin).await - }.await; + connection.system_reference().await + } + .await; let _ = tx.send(result); }); - rx.recv_timeout(Duration::from_secs(110)).map_err(|_| "Opening the web client timed out.".to_string())? + let reference = rx.recv_timeout(Duration::from_secs(110)).map_err(|_| "Opening the web client timed out.".to_string())??; + Ok((origin, reference)) } -/// The web client's explicitly trusted origin, configured on the host sheet. +/// The web client's exact origin, configured on the host sheet ("" = none; +/// a malformed saved value also reads as none). pub fn web_client_origin() -> String { - core_dir().and_then(|dir| std::fs::read_to_string(dir.join("web-client-origin.txt")).ok()).unwrap_or_default() + core_dir().map(|dir| network::read_web_origin(&dir)).unwrap_or_default() } -/// Change the allowed web origin and restart the server. No wildcards, -/// credentials, paths, queries or fragments are accepted. +/// Change the allowed web origin (atomically) and restart a running Talk to +/// Octos server. `https`, or `http` only for loopback; no wildcards, +/// credentials, paths, queries or fragments. pub fn set_web_client_origin(origin: &str) -> Result<(), String> { let normalized = network::validate_origin(origin.trim())?; let dir = core_dir().ok_or_else(|| "No kernel data directory.".to_string())?; - std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; - let path = dir.join("web-client-origin.txt"); - if std::fs::read_to_string(&path).unwrap_or_default() != normalized { - std::fs::write(path, normalized).map_err(|e| e.to_string())?; - restart(); + if network::read_web_origin(&dir) != normalized { + network::save_web_origin(&dir, &normalized)?; + if external_access() { + restart(); + } } Ok(()) } diff --git a/crates/kernel/src/network.rs b/crates/kernel/src/network.rs index 5d20e465..b864d652 100644 --- a/crates/kernel/src/network.rs +++ b/crates/kernel/src/network.rs @@ -1,58 +1,173 @@ -//! The shell-owned server's authenticated loopback connection. -//! A server binds port zero on first start; its actual port and token survive -//! provider restarts. Stdout is private (it also carries Octos's pairing code). +//! Talk to Octos: the opt-in loopback server external clients attach to. +//! +//! Off by default: the kernel is a private `octos serve --stdio` child. When +//! the person turns Talk to Octos on, the kernel runs as +//! `octos serve --host-managed` (octos `docs/HOST_MANAGED_SERVE.md`) and +//! native consumers reach it over its WebSocket with the HOST token, which +//! never leaves this process. External clients get the EXTERNAL token, which +//! opens the UI Protocol socket and nothing else: through a one-time pairing +//! code (web) or the private descriptor file (a terminal UI of this user). +//! +//! - The host token lives for the service's lifetime. The external token is +//! minted when Talk to Octos turns on and whenever the person rotates it. +//! - On Unix this process binds the listener once and passes it to every +//! kernel generation (`--listen-fd`), so no other local app can take the +//! port between restarts. Elsewhere a restart reuses the port when it is +//! free and otherwise falls back to a fresh port and a fresh external token. +//! - The child's stdin is its lifeline: when this process exits or crashes, +//! the kernel sees EOF and stops. -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU16, Ordering}; +use std::sync::Mutex; use std::time::Duration; use futures_util::{SinkExt, StreamExt}; use serde_json::json; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; use tokio_tungstenite::tungstenite::{client::IntoClientRequest, Message}; pub const SYSTEM_SESSION: &str = "_main:api:octosense#system"; pub const CONNECTION_FILE: &str = "client-connection.json"; pub(crate) const SYSTEM_WORKSPACE_FILE: &str = "system-workspace.txt"; +/// `{"enabled": true}` while Talk to Octos is on (the person's choice). +pub(crate) const EXTERNAL_ACCESS_FILE: &str = "external-access.json"; +/// The web client's exact origin, the only browser origin the server trusts. +pub(crate) const WEB_ORIGIN_FILE: &str = "web-client-origin.txt"; -pub(crate) fn save_system_workspace(dir: &Path, workspace: &Path) -> Result<(), String> { - let path = dir.join(SYSTEM_WORKSPACE_FILE); - let tmp = dir.join(format!(".system-workspace-{}", uuid::Uuid::new_v4())); +/// Write `bytes` to `dir/name` atomically (temp file, then rename), readable +/// by this user only on Unix. +pub(crate) fn write_private(dir: &Path, name: &str, bytes: &[u8]) -> std::io::Result<()> { + use std::io::Write; + std::fs::create_dir_all(dir)?; + let tmp = dir.join(format!(".{name}-{}", uuid::Uuid::new_v4().simple())); + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } let result = (|| { - std::fs::write(&tmp, workspace.to_string_lossy().as_bytes())?; - std::fs::rename(&tmp, path) + let mut file = options.open(&tmp)?; + file.write_all(bytes)?; + file.sync_all()?; + std::fs::rename(&tmp, dir.join(name)) })(); - let _ = std::fs::remove_file(tmp); - result.map_err(|e: std::io::Error| format!("Could not save the system workspace: {e}")) -} - -// Preserve the pinned kernel's stdio_defaults feature contract for native -// consumers. External clients negotiate their own features independently. -const NATIVE_FEATURES: &str = concat!( - "approval.typed.v1,pane.snapshots.v1,session.workspace_cwd.v1,session.sandbox.v1,", - "harness.task_control.v1,harness.task_artifacts.v1,state.session_hydrate.v1,", - "state.thread_graph.v1,state.turn_state_get.v1,event.spawn_complete.v1,", - "event.file_attached.v1,event.voice_audio.v1,voice.asr_admission.v1,plan.todos.v1,", - "event.background_activity.v1,auxiliary.rest_to_ws.v1,coding.autonomy.v1,", - "coding.agent_control.v1,coding.goal_runtime.v1,coding.loop_runtime.v1,", - "coding.monitor_runtime.v1,review.start.v1,context.lifecycle.v1,user_question.v1,", - "skill.actions.v1,skill.action_jobs.v1,event.turn_steer_dropped.v1" -); + let _ = std::fs::remove_file(&tmp); + result +} + +pub(crate) fn save_system_workspace(dir: &Path, workspace: &Path) -> Result<(), String> { + write_private(dir, SYSTEM_WORKSPACE_FILE, workspace.to_string_lossy().as_bytes()) + .map_err(|e| format!("Could not save the system workspace: {e}")) +} + +/// Whether the person turned Talk to Octos on. A missing or unreadable file +/// is off. +pub(crate) fn external_access_enabled(dir: &Path) -> bool { + std::fs::read_to_string(dir.join(EXTERNAL_ACCESS_FILE)) + .ok() + .and_then(|text| serde_json::from_str::(&text).ok()) + .is_some_and(|value| value["enabled"] == true) +} + +pub(crate) fn save_external_access(dir: &Path, enabled: bool) -> std::io::Result<()> { + if enabled { + write_private(dir, EXTERNAL_ACCESS_FILE, json!({"enabled": true}).to_string().as_bytes()) + } else { + match std::fs::remove_file(dir.join(EXTERNAL_ACCESS_FILE)) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e), + _ => Ok(()), + } + } +} +/// The feature set native consumers had on the stdio pipe, from octos. +fn native_features() -> String { + octos_core::ui_protocol::UI_PROTOCOL_STDIO_DEFAULT_FEATURES.join(",") +} + +fn is_loopback_host(host: &url::Host<&str>) -> bool { + match host { + url::Host::Domain(domain) => domain.eq_ignore_ascii_case("localhost"), + url::Host::Ipv4(ip) => *ip == std::net::Ipv4Addr::LOCALHOST, + url::Host::Ipv6(ip) => *ip == std::net::Ipv6Addr::LOCALHOST, + } +} + +/// An exact web-client origin: `https://…`, or `http://` only for +/// localhost, 127.0.0.1 or [::1]. No wildcard, credentials, path, query or +/// fragment. `""` means none. pub(crate) fn validate_origin(origin: &str) -> Result { - if origin.is_empty() { return Ok(String::new()); } - let url = url::Url::parse(origin).map_err(|_| "Enter a web client origin such as http://localhost:4173".to_string())?; - if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none_or(|h| h.contains('*')) - || !url.username().is_empty() || url.password().is_some() || url.path() != "/" - || url.query().is_some() || url.fragment().is_some() + if origin.is_empty() { + return Ok(String::new()); + } + let usage = "Enter a web client origin such as https://web.example or http://localhost:4173"; + let url = url::Url::parse(origin).map_err(|_| usage.to_string())?; + let host = url.host().ok_or_else(|| usage.to_string())?; + let scheme_ok = match url.scheme() { + "https" => true, + "http" => is_loopback_host(&host), + _ => false, + }; + if !scheme_ok { + return Err("Use https, or http only for localhost, 127.0.0.1 or [::1].".into()); + } + if url.host_str().is_none_or(|h| h.contains('*')) + || !url.username().is_empty() + || url.password().is_some() + || url.path() != "/" + || url.query().is_some() + || url.fragment().is_some() { - return Err("Use an http(s) origin without a path, credentials, query or fragment.".into()); + return Err("Use an origin without a path, credentials, query or fragment.".into()); } Ok(url.origin().ascii_serialization()) } -/// For a trusted host sheet or a user-owned client. Never pass to an app -/// script, log it, or put its token in a command line. +/// The saved web-client origin. A malformed file means no origin (logged): +/// the kernel stays available and simply trusts no browser origin. +pub(crate) fn read_web_origin(dir: &Path) -> String { + let Ok(text) = std::fs::read_to_string(dir.join(WEB_ORIGIN_FILE)) else { + return String::new(); + }; + match validate_origin(text.trim()) { + Ok(origin) => origin, + Err(e) => { + log::warn!("octos-core: ignoring the saved web client origin: {e}"); + String::new() + } + } +} + +pub(crate) fn save_web_origin(dir: &Path, origin: &str) -> Result<(), String> { + write_private(dir, WEB_ORIGIN_FILE, origin.as_bytes()).map_err(|e| format!("Could not save the origin: {e}")) +} + +/// Where the private descriptor lives. On Windows it is kept in the per-user +/// local AppData (`%LOCALAPPDATA%\OctoSense`), whose default ACL admits only +/// this user, SYSTEM and administrators; elsewhere beside the kernel data, +/// mode 0600. +pub fn connection_file(core_dir: &Path) -> PathBuf { + connection_file_for(core_dir, cfg!(windows).then(|| std::env::var_os("LOCALAPPDATA")).flatten()) +} + +pub(crate) fn connection_file_for(core_dir: &Path, local_app_data: Option) -> PathBuf { + match local_app_data.filter(|dir| !dir.is_empty()) { + Some(dir) => PathBuf::from(dir).join("OctoSense").join(CONNECTION_FILE), + None => core_dir.join(CONNECTION_FILE), + } +} + +/// 244 random bits from the OS CSPRNG (two UUID v4, via `getrandom`), hex. +fn mint_token() -> String { + format!("{}{}", uuid::Uuid::new_v4().simple(), uuid::Uuid::new_v4().simple()) +} + +/// An external client's connection: the server origin and the EXTERNAL +/// token. For the trusted host sheet and this user's terminal UI only; never +/// pass it to an app script, log it, or put it in a command line. #[derive(Clone)] pub struct ClientAccess { pub origin: String, @@ -70,68 +185,148 @@ impl ClientAccess { format!("ws://{}/api/ui-protocol/ws", self.origin.trim_start_matches("http://")) } - pub(crate) fn save(&self, dir: &Path) -> std::io::Result<()> { - use std::io::Write; - let path = dir.join(CONNECTION_FILE); - let tmp = dir.join(format!(".client-connection-{}", uuid::Uuid::new_v4())); - let mut options = std::fs::OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600); - } - let result = (|| { - let mut file = options.open(&tmp)?; - let data = json!({"origin": self.origin, "endpoint": self.endpoint(), "token": self.token, - "profile_id": "_main", "session_id": SYSTEM_SESSION, "pid": std::process::id()}); - file.write_all(data.to_string().as_bytes())?; - std::fs::rename(&tmp, &path) - })(); - let _ = std::fs::remove_file(tmp); - result + fn authority(&self) -> &str { + self.origin.trim_start_matches("http://") } + + /// The descriptor a terminal UI of this user reads. + pub(crate) fn save(&self, core_dir: &Path) -> std::io::Result<()> { + let path = connection_file(core_dir); + let dir = path.parent().unwrap_or(core_dir); + let data = json!({"origin": self.origin, "endpoint": self.endpoint(), "token": self.token, + "profile_id": "_main", "session_id": SYSTEM_SESSION, "pid": std::process::id()}); + write_private(dir, CONNECTION_FILE, data.to_string().as_bytes()) + } +} + +pub(crate) fn remove_descriptor(core_dir: &Path) { + let _ = std::fs::remove_file(connection_file(core_dir)); } +/// The shared server's credentials and address for this service lifetime. pub(crate) struct Network { - pub port: AtomicU16, - pub token: String, + /// The native consumers' (admin) token. Never leaves this process. + host_token: String, + /// The external clients' token while Talk to Octos is on. + external: Mutex>, + port: AtomicU16, + /// Unix: the listener this process keeps and every generation inherits. + #[cfg(unix)] + listener: Mutex>, } impl Default for Network { fn default() -> Self { - Self { port: AtomicU16::new(0), token: format!("{}{}", uuid::Uuid::new_v4().simple(), uuid::Uuid::new_v4().simple()) } + Self { + host_token: mint_token(), + external: Mutex::new(None), + port: AtomicU16::new(0), + #[cfg(unix)] + listener: Mutex::new(None), + } } } impl Network { - /// Parse only the pinned server's listener announcement, with NO_COLOR. - /// Never infer readiness from a token, a pairing URL or arbitrary logs. - pub fn announced(&self, line: &str) -> Option { + pub fn host_token(&self) -> &str { + &self.host_token + } + + /// The external token, minted on first use. + pub fn external_token(&self) -> String { + self.external.lock().unwrap().get_or_insert_with(mint_token).clone() + } + + /// A fresh external token: every paired client must pair again. + pub fn rotate(&self) { + *self.external.lock().unwrap() = Some(mint_token()); + } + + /// Talk to Octos turned off: forget the external token and the port. + pub fn clear(&self) { + *self.external.lock().unwrap() = None; + #[cfg(unix)] + { + *self.listener.lock().unwrap() = None; + } + self.port.store(0, Ordering::Relaxed); + } + + pub fn port(&self) -> u16 { + self.port.load(Ordering::Relaxed) + } + + /// Unix: the kept listener's descriptor, binding 127.0.0.1:0 the first + /// time. The descriptor stays open (and close-on-exec) in this process. + #[cfg(unix)] + pub fn listener_fd(&self) -> std::io::Result { + use std::os::fd::AsRawFd; + let mut listener = self.listener.lock().unwrap(); + if listener.is_none() { + let bound = std::net::TcpListener::bind(("127.0.0.1", 0))?; + self.port.store(bound.local_addr()?.port(), Ordering::Relaxed); + *listener = Some(bound); + } + Ok(listener.as_ref().expect("bound above").as_raw_fd()) + } + + /// Without an inherited listener: the port for the next start. The + /// previous port when it is still free; otherwise a fresh port chosen by + /// the kernel and a fresh external token, since whoever holds the old + /// port could otherwise receive clients' tokens. + #[cfg_attr(unix, allow(dead_code))] + pub fn port_for_restart(&self) -> u16 { + let port = self.port(); + if port != 0 && std::net::TcpListener::bind(("127.0.0.1", port)).is_err() { + log::warn!("octos-core: port {port} is taken; moving Talk to Octos to a new port and rotating its token"); + self.fall_back(); + } + self.port() + } + + /// A start on the previous port failed: new port, new external token. + pub fn fall_back(&self) { + self.port.store(0, Ordering::Relaxed); + self.rotate(); + } + + /// Parse only the listener announcement (`NO_COLOR`). Never infer + /// readiness from anything else the kernel prints. + pub fn announced(&self, line: &str) -> Option { let origin = line.strip_prefix("Listening: ")?; - let url = url::Url::parse(origin).ok()?; - if url.scheme() != "http" || url.host_str() != Some("127.0.0.1") - || !url.username().is_empty() || url.password().is_some() - || url.path() != "/" || url.query().is_some() || url.fragment().is_some() + let url = url::Url::parse(origin.trim()).ok()?; + if url.scheme() != "http" + || url.host_str() != Some("127.0.0.1") + || !url.username().is_empty() + || url.password().is_some() + || url.path() != "/" + || url.query().is_some() + || url.fragment().is_some() { return None; } let port = url.port().filter(|p| *p != 0)?; - let expected = self.port.load(Ordering::Relaxed); - if expected != 0 && expected != port { return None; } + let expected = self.port(); + if expected != 0 && expected != port { + return None; + } self.port.store(port, Ordering::Relaxed); - Some(ClientAccess { origin: format!("http://127.0.0.1:{port}"), token: self.token.clone() }) + Some(port) + } + + pub fn access(&self) -> ClientAccess { + ClientAccess { origin: format!("http://127.0.0.1:{}", self.port()), token: self.external_token() } } } -/// Native consumers keep their existing frame API. This private adapter -/// carries those frames over the same WebSocket endpoint external clients use. -pub(crate) async fn connect(access: &ClientAccess) -> Result { +/// Native consumers keep their frame API. This private adapter carries those +/// frames over the server's WebSocket with the host token. +pub(crate) async fn connect(access: &ClientAccess, host_token: &str) -> Result { let mut request = access.endpoint().into_client_request().map_err(|e| e.to_string())?; - request.headers_mut().insert("Authorization", format!("Bearer {}", access.token).parse().unwrap()); - request.headers_mut().insert("X-Profile-Id", "_main".parse().unwrap()); - request.headers_mut().insert("X-Octos-Ui-Features", NATIVE_FEATURES.parse().unwrap()); - // The startup announcement precedes axum's accept loop by a few statements. + let headers = request.headers_mut(); + headers.insert("Authorization", format!("Bearer {host_token}").parse().map_err(|_| "bad token")?); + headers.insert("X-Octos-Ui-Features", native_features().parse().map_err(|_| "bad features")?); + // The announcement precedes axum's accept loop by a few statements. let deadline = tokio::time::Instant::now() + Duration::from_secs(10); let socket = loop { match tokio_tungstenite::connect_async(request.clone()).await { @@ -168,20 +363,106 @@ pub(crate) async fn connect(access: &ClientAccess) -> Result) -> std::fmt::Result { + f.debug_struct("Pairing").field("server_origin", &self.server_origin).finish_non_exhaustive() + } +} + +/// The web client's pairing link (octos's `?octos=&pair=`), +/// with the system conversation reference when known. +pub fn pairing_link(web_origin: &str, pairing: &Pairing, system: Option<&str>) -> Result { + let mut url = url::Url::parse(web_origin).map_err(|e| e.to_string())?; + url.query_pairs_mut().append_pair("octos", &pairing.server_origin).append_pair("pair", &pairing.code); + if let Some(reference) = system { + url.query_pairs_mut().append_pair("s", reference); + } + Ok(url.to_string()) +} + +/// One host-only request to the server (`/api/admin/host/pairing`). +async fn host_request(access: &ClientAccess, host_token: &str, method: &str) -> Result<(u16, String), String> { + let authority = access.authority().to_owned(); + let mut tcp = tokio::net::TcpStream::connect(&authority).await.map_err(|e| e.to_string())?; + let request = format!( + "{method} /api/admin/host/pairing HTTP/1.1\r\nHost: {authority}\r\nAuthorization: Bearer {host_token}\r\n\ + Content-Length: 0\r\nConnection: close\r\n\r\n" + ); + tcp.write_all(request.as_bytes()).await.map_err(|e| e.to_string())?; + let mut response = Vec::new(); + tokio::time::timeout(Duration::from_secs(10), tcp.read_to_end(&mut response)) + .await + .map_err(|_| "the kernel did not answer".to_string())? + .map_err(|e| e.to_string())?; + let response = String::from_utf8_lossy(&response); + let status = response + .split_whitespace() + .nth(1) + .and_then(|s| s.parse::().ok()) + .ok_or("the kernel sent no status")?; + let body = response.split_once("\r\n\r\n").map(|(_, b)| b.to_owned()).unwrap_or_default(); + Ok((status, body)) +} + +pub(crate) async fn start_pairing(access: &ClientAccess, host_token: &str) -> Result { + let (status, body) = host_request(access, host_token, "POST").await?; + if status != 200 { + return Err(format!("The kernel refused to pair ({status}). Turn Talk to Octos on first.")); + } + // Content-Length framing only (no chunking for this small JSON body). + let value: serde_json::Value = serde_json::from_str(body.trim()).map_err(|_| "unexpected pairing reply")?; + Ok(Pairing { + code: value["code"].as_str().ok_or("no pairing code")?.to_owned(), + server_origin: value["server_origin"].as_str().ok_or("no server origin")?.to_owned(), + expires_in_secs: value["expires_in_secs"].as_u64().unwrap_or(300), + }) +} + +pub(crate) async fn end_pairing(access: &ClientAccess, host_token: &str) { + let _ = host_request(access, host_token, "DELETE").await; +} + #[cfg(test)] mod tests { use super::*; #[test] - fn only_explicit_web_origins_are_accepted() { + fn web_origins_are_exact_and_https_unless_loopback() { for invalid in ["*", "http://*.example.com", "file:///tmp/client", "https://u:p@example.com", - "https://example.com/app", "https://example.com?token=secret", "https://example.com/#x"] { + "https://example.com/app", "https://example.com?token=secret", "https://example.com/#x", + "http://example.com", "http://192.168.1.2:4173", "http://10.0.0.1", "ws://localhost:4173"] { assert!(validate_origin(invalid).is_err(), "{invalid}"); } assert_eq!(validate_origin("http://localhost:4173/").unwrap(), "http://localhost:4173"); + assert_eq!(validate_origin("http://127.0.0.1:4173").unwrap(), "http://127.0.0.1:4173"); + assert_eq!(validate_origin("http://[::1]:4173").unwrap(), "http://[::1]:4173"); + assert_eq!(validate_origin("https://web.example").unwrap(), "https://web.example"); assert_eq!(validate_origin("").unwrap(), ""); } + #[test] + fn a_malformed_saved_origin_means_none() { + let dir = std::env::temp_dir().join(format!("octos-origin-{}", uuid::Uuid::new_v4().simple())); + assert_eq!(read_web_origin(&dir), ""); + save_web_origin(&dir, "http://evil.example").unwrap(); + assert_eq!(read_web_origin(&dir), "", "http for a non-loopback host is refused"); + save_web_origin(&dir, "https://web.example").unwrap(); + assert_eq!(read_web_origin(&dir), "https://web.example"); + let leftovers: Vec<_> = std::fs::read_dir(&dir).unwrap().filter_map(Result::ok) + .filter(|e| e.file_name().to_string_lossy().starts_with('.')).collect(); + assert!(leftovers.is_empty(), "atomic writes leave no temp files"); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn listener_discovery_rejects_untrusted_and_changed_addresses() { let network = Network::default(); @@ -189,9 +470,52 @@ mod tests { "Listening: http://127.0.0.1:1234/?token=x", "Listening: http://127.0.0.1:0"] { assert!(network.announced(invalid).is_none()); } - let first = network.announced("Listening: http://127.0.0.1:1234").unwrap(); + assert_eq!(network.announced("Listening: http://127.0.0.1:1234"), Some(1234)); assert!(network.announced("Listening: http://127.0.0.1:1235").is_none()); - let second = network.announced("Listening: http://127.0.0.1:1234").unwrap(); - assert!(first.token == second.token); + let first = network.access(); + assert_eq!(first.origin, "http://127.0.0.1:1234"); + assert_eq!(first.token, network.access().token, "stable until rotated"); + assert_ne!(first.token, network.host_token(), "external and host tokens differ"); + network.rotate(); + assert_ne!(first.token, network.access().token); + assert!(!format!("{first:?}").contains(&first.token)); + } + + #[test] + fn a_taken_port_moves_the_server_and_rotates_its_token() { + let network = Network::default(); + let holder = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = holder.local_addr().unwrap().port(); + network.announced(&format!("Listening: http://127.0.0.1:{port}")).unwrap(); + let before = network.external_token(); + assert_eq!(network.port_for_restart(), 0, "a squatted port is abandoned"); + assert_ne!(network.external_token(), before, "and the token it was given is retired"); + drop(holder); + } + + #[test] + fn the_descriptor_lives_in_local_app_data_when_given() { + let core = Path::new("/core"); + assert_eq!(connection_file_for(core, None), core.join(CONNECTION_FILE)); + assert_eq!(connection_file_for(core, Some("".into())), core.join(CONNECTION_FILE)); + assert_eq!(connection_file_for(core, Some("/appdata".into())), + Path::new("/appdata").join("OctoSense").join(CONNECTION_FILE)); + } + + #[test] + fn the_pairing_link_carries_the_code_and_server() { + let pairing = Pairing { code: "ABCD2345".into(), server_origin: "http://127.0.0.1:4000".into(), expires_in_secs: 300 }; + let link = pairing_link("https://web.example", &pairing, Some("[\"/w\",\"_main\",\"s\"]")).unwrap(); + let url = url::Url::parse(&link).unwrap(); + let pairs: std::collections::HashMap<_, _> = url.query_pairs().into_owned().collect(); + assert_eq!(pairs["octos"], "http://127.0.0.1:4000"); + assert_eq!(pairs["pair"], "ABCD2345"); + assert!(!format!("{pairing:?}").contains("ABCD2345")); + } + + #[test] + fn native_consumers_request_octos_stdio_feature_set() { + let features = native_features(); + assert!(features.contains("approval.typed.v1") && features.contains("state.session_hydrate.v1")); } } diff --git a/crates/kernel/tests/core.rs b/crates/kernel/tests/core.rs index aabc54a4..f27a8c7e 100644 --- a/crates/kernel/tests/core.rs +++ b/crates/kernel/tests/core.rs @@ -6,7 +6,7 @@ use std::path::PathBuf; use std::time::Duration; -use octosense_kernel::{CloseReason, Connection, Core, Options, Unavailable}; +use octosense_kernel::{CloseReason, Connection, Core, Launch, Options, Unavailable}; use serde_json::{json, Value}; fn fake_kernel() -> PathBuf { @@ -24,7 +24,7 @@ fn core(tag: &str) -> (Core, PathBuf) { let log = dir.with_extension("log"); let _ = std::fs::remove_file(&log); let core = Core::new( - Options::default().stdio() + Options::default() .core_dir(&dir) .program(fake_kernel()) .env("FAKE_KERNEL_LOG", log.to_string_lossy()), @@ -70,6 +70,8 @@ async fn shutdown_during_websocket_startup_reaps_the_child() { // not wait for its 90-second timeout or leak a child/data-directory lock. let core = Core::new(Options::default().program(fake_kernel()).core_dir(&dir) .env("FAKE_KERNEL_LOG", log.to_string_lossy())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("external-access.json"), r#"{"enabled":true}"#).unwrap(); let mut conn = core.connect().unwrap(); let started = tokio::time::timeout(Duration::from_secs(5), async { loop { @@ -84,11 +86,71 @@ async fn shutdown_during_websocket_startup_reaps_the_child() { core.shutdown_within(Duration::from_secs(5)); assert!(gone(pid).await); assert_eq!(next(&mut conn).await.unwrap_err(), CloseReason::Shutdown); - assert!(!dir.join(octosense_kernel::CONNECTION_FILE).exists()); + assert!(!octosense_kernel::connection_file(&dir).exists()); let _ = std::fs::remove_file(log); let _ = std::fs::remove_dir_all(dir); } +fn args(launch: &Launch) -> Vec { + match launch { + Launch::Stdio { args, .. } | Launch::WebSocket { args, .. } => args.clone(), + Launch::Embedded { .. } => Vec::new(), + } +} + +fn env(launch: &Launch) -> Vec<(String, String)> { + match launch { + Launch::Stdio { env, .. } | Launch::WebSocket { env, .. } => env.clone(), + Launch::Embedded { .. } => Vec::new(), + } +} + +#[test] +fn talk_to_octos_is_off_by_default_and_the_toggle_switches_the_launch() { + let (core, _) = core("toggle"); + // Off: the private stdio pipe, no listener, no external access. + let launch = core.launch().unwrap(); + assert!(matches!(launch, Launch::Stdio { .. })); + assert!(args(&launch).contains(&"--stdio".to_string())); + assert!(!core.external_access()); + assert!(core.client_access().unwrap_err().contains("Talk to Octos")); + assert!(core.pairing().is_err()); + // On: octos's host-managed loopback server. + core.set_external_access(true).unwrap(); + assert!(core.external_access()); + let launch = core.launch().unwrap(); + let on = args(&launch); + assert!(matches!(launch, Launch::WebSocket { .. })); + assert!(!on.contains(&"--stdio".to_string())); + for flag in ["--host-managed", "--host", "127.0.0.1"] { + assert!(on.contains(&flag.to_string()), "{on:?}"); + } + // Off again: back to the pipe; the setting and descriptor are gone. + core.set_external_access(false).unwrap(); + assert!(!core.external_access()); + assert!(matches!(core.launch().unwrap(), Launch::Stdio { .. })); + let dir = core.core_dir().unwrap(); + assert!(!dir.join("external-access.json").exists()); + assert!(!octosense_kernel::connection_file(&dir).exists()); + core.shutdown_within(Duration::from_secs(5)); +} + +#[test] +fn a_malformed_web_origin_leaves_the_kernel_available() { + let (core, _) = core("bad-origin"); + let dir = core.core_dir().unwrap(); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("external-access.json"), r#"{"enabled":true}"#).unwrap(); + for bad in ["http://evil.example", "not a url", "https://web.example/path"] { + std::fs::write(dir.join("web-client-origin.txt"), bad).unwrap(); + let launch = core.launch().expect("a bad origin never makes the kernel unavailable"); + assert!(!env(&launch).iter().any(|(k, _)| k == "OCTOS_APPUI_ALLOWED_ORIGINS"), "{bad}"); + } + std::fs::write(dir.join("web-client-origin.txt"), "https://web.example").unwrap(); + let launch = core.launch().unwrap(); + assert!(env(&launch).contains(&("OCTOS_APPUI_ALLOWED_ORIGINS".into(), "https://web.example".into()))); +} + #[tokio::test(flavor = "multi_thread")] async fn one_kernel_serves_every_consumer() { let (core, log) = core("single"); @@ -201,7 +263,7 @@ async fn shutdown_stops_and_waits() { #[test] fn no_kernel_binary_means_no_connection() { - let core = Core::new(Options::default().stdio().core_dir(core_dir("none")).program("/nonexistent/octos")); + let core = Core::new(Options::default().core_dir(core_dir("none")).program("/nonexistent/octos")); assert!(matches!(core.connect(), Err(Unavailable::NoKernel(_)))); assert!(!core.status().running); } @@ -213,7 +275,7 @@ async fn a_kernel_that_cannot_start_fails_the_connection() { std::fs::create_dir_all(&dir).unwrap(); let program = dir.join("octos"); std::fs::write(&program, "not a program").unwrap(); - let core = Core::new(Options::default().stdio().core_dir(dir.join("core")).program(&program)); + let core = Core::new(Options::default().core_dir(dir.join("core")).program(&program)); let mut a = core.connect().unwrap(); assert!(matches!(next(&mut a).await, Err(CloseReason::Failed(_)))); } diff --git a/crates/kernel/tests/real_kernel.rs b/crates/kernel/tests/real_kernel.rs index e46d59af..0c54d219 100644 --- a/crates/kernel/tests/real_kernel.rs +++ b/crates/kernel/tests/real_kernel.rs @@ -1,40 +1,78 @@ -//! Real host-managed kernel tests: native/browser coexistence, authentication, -//! origin checks, provider restart, and shared system-agent turns using a local -//! scripted model. No external model or real credential is used. -//! Set OCTOS_CORE_TEST_KERNEL to the pinned binary with OctoSense's overlay. -//! Without it these integration tests are skipped (see README.md). +//! A real `octos` kernel started by the core from a temp core dir whose +//! profile `octosense_llm_config` wrote, as the AI providers app's `llm` +//! service does. No provider is called: keys are fixtures and the kernel is +//! only asked what it runs on (`profile/llm/list`). +//! +//! Runs when `OCTOS_CORE_TEST_KERNEL` names an `octos` binary built from the +//! octos rev AppCard pins: +//! +//! ```sh +//! cargo build --release -p octos-cli --bin octos --no-default-features --features api,git,ast +//! ``` +//! +//! Without it the test says so and passes (CI has no kernel binary). +//! +//! The Talk to Octos tests need an octos with `serve --host-managed` +//! (octos#2591); they drive the server as an external client would, and +//! probe what that client must NOT be able to do. use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::Duration; +use futures_util::{SinkExt, StreamExt}; +use octosense_kernel::{ClientAccess, CloseReason, Connection, Core, Options, SYSTEM_SESSION}; use octosense_llm_config::{profile, Provider, ProviderSet}; -use octosense_kernel::{CloseReason, Connection, Core, Options}; use serde_json::{json, Value}; -use futures_util::{SinkExt, StreamExt}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio_tungstenite::tungstenite::{client::IntoClientRequest, Message}; type Socket = tokio_tungstenite::WebSocketStream>; -async fn solo_status(access: &octosense_kernel::ClientAccess, path: &str) -> String { - use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; - let mut tcp = tokio::net::TcpStream::connect(access.origin.trim_start_matches("http://")).await.unwrap(); - let body = r#"{"name":"Unauthorized app","username":"intruder","email":"intruder@solo.local"}"#; - tcp.write_all(format!("POST {path} HTTP/1.1\r\nHost: {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", access.origin.trim_start_matches("http://"), body.len()).as_bytes()).await.unwrap(); - let mut line = String::new(); - tokio::io::BufReader::new(tcp).read_line(&mut line).await.unwrap(); - line +const WEB: &str = "http://localhost:4173"; + +fn kernel() -> Option { + let program = std::env::var_os("OCTOS_CORE_TEST_KERNEL").map(PathBuf::from); + if program.is_none() { + eprintln!("OCTOS_CORE_TEST_KERNEL is not set: skipping the real-kernel test"); + } + program } -async fn external(access: &octosense_kernel::ClientAccess) -> Socket { - // Browsers cannot set Authorization; OctosCode Web uses the query token. - let mut req = format!("{}?token={}&ui_feature=state.session_hydrate.v1,session.workspace_cwd.v1", access.endpoint(), access.token).into_client_request().unwrap(); - req.headers_mut().insert("Origin", "http://localhost:4173".parse().unwrap()); - tokio_tungstenite::connect_async(req).await.map(|(s, _)| s).unwrap_or_else(|_| panic!("external client could not connect")) +/// A blocking core call (they wait on the kernel) off the test's runtime. +async fn blocking(f: impl FnOnce() -> T + Send + 'static) -> T { + tokio::task::spawn_blocking(f).await.unwrap() } -async fn ws_call(socket: &mut Socket, id: &str, method: &str, params: Value) -> Value { +/// One raw HTTP/1.1 request; returns the status code and body. +async fn http(port: u16, method: &str, path: &str, host: &str, token: Option<&str>, body: &str) -> (u16, String) { + let mut tcp = tokio::net::TcpStream::connect(("127.0.0.1", port)).await.unwrap(); + let auth = token.map(|t| format!("Authorization: Bearer {t}\r\n")).unwrap_or_default(); + tcp.write_all(format!("{method} {path} HTTP/1.1\r\nHost: {host}\r\n{auth}Content-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap(); + let mut response = String::new(); + let _ = tokio::time::timeout(Duration::from_secs(10), tcp.read_to_string(&mut response)).await; + let status = response.split_whitespace().nth(1).and_then(|s| s.parse().ok()).unwrap_or(0); + (status, response.split_once("\r\n\r\n").map(|(_, b)| b.to_owned()).unwrap_or_default()) +} + +fn port_of(access: &ClientAccess) -> u16 { + access.origin.rsplit(':').next().unwrap().parse().unwrap() +} + +/// An external client the way a browser connects: the bearer subprotocol, +/// an allowed Origin, and its own feature request. +async fn external(access: &ClientAccess) -> Socket { + let mut req = format!("{}?ui_feature=state.session_hydrate.v1,session.workspace_cwd.v1", access.endpoint()) + .into_client_request().unwrap(); + req.headers_mut().insert("Sec-WebSocket-Protocol", format!("octos-ui, octos.bearer.{}", access.token).parse().unwrap()); + req.headers_mut().insert("Origin", WEB.parse().unwrap()); + let (socket, response) = tokio_tungstenite::connect_async(req).await.expect("external client could not connect"); + assert_eq!(response.headers()["sec-websocket-protocol"], "octos-ui"); + socket +} + +async fn ws_frame(socket: &mut Socket, id: &str, method: &str, params: Value) -> Value { socket.send(Message::Text(json!({"jsonrpc":"2.0", "id":id, "method":method, "params":params}).to_string())).await.unwrap(); tokio::time::timeout(Duration::from_secs(30), async { loop { @@ -42,17 +80,30 @@ async fn ws_call(socket: &mut Socket, id: &str, method: &str, params: Value) -> Message::Text(text) => { let frame: Value = serde_json::from_str(&text).unwrap(); if frame["id"] == id { - assert!(frame.get("error").is_none(), "{method}: {frame}"); - return frame["result"].clone(); + return frame; } } Message::Ping(bytes) => socket.send(Message::Pong(bytes)).await.unwrap(), - _ => {}, + _ => {} } } }).await.expect("external request timed out") } +async fn ws_call(socket: &mut Socket, id: &str, method: &str, params: Value) -> Value { + let frame = ws_frame(socket, id, method, params).await; + assert!(frame.get("error").is_none(), "{method}: {frame}"); + frame["result"].clone() +} + +async fn ws_refused(req: impl IntoClientRequest + Unpin) -> u16 { + match tokio_tungstenite::connect_async(req).await { + Err(tokio_tungstenite::tungstenite::Error::Http(response)) => response.status().as_u16(), + Err(e) => panic!("unexpected error: {e}"), + Ok(_) => 101, + } +} + async fn call(conn: &mut Connection, id: &str, method: &str, params: Value) -> Value { conn.send(json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string()).unwrap(); loop { @@ -86,15 +137,11 @@ async fn running_on(conn: &mut Connection, id: &str) -> (String, String) { #[tokio::test(flavor = "multi_thread")] async fn a_real_kernel_runs_on_the_profile_the_providers_app_wrote() { - let Some(program) = std::env::var_os("OCTOS_CORE_TEST_KERNEL").map(PathBuf::from) else { - eprintln!("OCTOS_CORE_TEST_KERNEL is not set: skipping the real-kernel test"); - return; - }; + let Some(program) = kernel() else { return }; let dir = std::env::temp_dir().join(format!("octos-core-real-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); let core_dir = dir.join("octos-home/.octos"); write_provider(&core_dir, "deepseek", "deepseek-v4-flash"); - std::fs::write(core_dir.join("web-client-origin.txt"), "http://localhost:4173").unwrap(); let lines = Arc::new(Mutex::new(Vec::::new())); let sink = lines.clone(); @@ -110,27 +157,6 @@ async fn a_real_kernel_runs_on_the_profile_the_providers_app_wrote() { assert_eq!(open["opened"]["session_id"], "_main:octos-core-it"); assert_eq!(open["opened"]["active_profile_id"], "_main"); assert_eq!(running_on(&mut conn, "llm1").await, ("deepseek".into(), "deepseek-v4-flash".into())); - let access = conn.client_access().await.unwrap(); - assert!(!format!("{access:?}").contains(&access.token)); - let mut browser = external(&access).await; - let open = ws_call(&mut browser, "open", "session/open", json!({"session_id":"_main:octos-core-it", "profile_id":"_main"})).await; - assert_eq!(open["opened"]["session_id"], "_main:octos-core-it"); - let list = ws_call(&mut browser, "llm1", "profile/llm/list", json!({"profile_id":"_main"})).await; - assert_eq!(list["primary"]["model"], "deepseek-v4-flash"); - // Reject both a missing token and an untrusted browser origin. - assert!(tokio_tungstenite::connect_async(access.endpoint()).await.is_err()); - let mut spoofed = access.endpoint().into_client_request().unwrap(); - spoofed.headers_mut().insert("X-Profile-Id", "_main".parse().unwrap()); - assert!(tokio_tungstenite::connect_async(spoofed).await.is_err(), "another local APK cannot impersonate a trusted proxy"); - assert!(solo_status(&access, "/api/auth/solo").await.contains("403"), "no password-free local login"); - assert!(solo_status(&access, "/api/auth/solo/create").await.contains("403"), "no unauthenticated local owner creation"); - let mut denied = format!("{}?token={}", access.endpoint(), access.token).into_client_request().unwrap(); - denied.headers_mut().insert("Origin", "https://untrusted.example".parse().unwrap()); - assert!(tokio_tungstenite::connect_async(denied).await.is_err()); - #[cfg(unix)] { - use std::os::unix::fs::PermissionsExt; - assert_eq!(std::fs::metadata(core_dir.join(octosense_kernel::CONNECTION_FILE)).unwrap().permissions().mode() & 0o777, 0o600); - } // A second consumer shares that kernel. let mut other = core.connect().unwrap(); assert_eq!(other.generation(), conn.generation()); @@ -139,32 +165,21 @@ async fn a_real_kernel_runs_on_the_profile_the_providers_app_wrote() { // The providers app saves another provider and restarts the kernel. write_provider(&core_dir, "moonshot", "kimi-k2.5"); assert!(core.restart()); - while conn.recv().await.is_ok() {} - assert_eq!(conn.closed(), Some(&CloseReason::Restarted)); - while other.recv().await.is_ok() {} - assert_eq!(other.closed(), Some(&CloseReason::Restarted)); + assert_eq!(conn.recv().await, Err(CloseReason::Restarted)); + assert_eq!(other.recv().await, Err(CloseReason::Restarted)); let mut conn = core.connect().unwrap(); assert_eq!(conn.generation(), 2); call(&mut conn, "open", "session/open", json!({"session_id": "_main:octos-core-it", "profile_id": "_main"})).await; assert_eq!(running_on(&mut conn, "llm2").await, ("moonshot".into(), "kimi-k2.5".into())); - let restarted = conn.client_access().await.unwrap(); - assert_eq!(access.origin, restarted.origin, "external clients reconnect to the same port"); - assert!(access.token == restarted.token, "restart preserves the access token"); - drop(browser); - let mut browser = external(&access).await; - let list = ws_call(&mut browser, "llm2", "profile/llm/list", json!({"profile_id":"_main"})).await; - assert_eq!(list["primary"]["model"], "kimi-k2.5"); let log = lines.lock().unwrap().join("\n"); assert!(log.contains("Model: deepseek-v4-flash") && log.contains("Model: kimi-k2.5"), "{log}"); + // Talk to Octos is off: the kernel is the private pipe, nothing listens + // and there is no descriptor. + assert!(!core.external_access()); + assert!(!octosense_kernel::connection_file(&core_dir).exists()); drop((conn, other)); - assert!(core.status().running, "external clients keep the shared server available"); - let list = ws_call(&mut browser, "after-native-close", "profile/llm/list", json!({"profile_id":"_main"})).await; - assert_eq!(list["primary"]["model"], "kimi-k2.5"); - drop(browser); - assert!(!lines.lock().unwrap().iter().any(|line| line.contains(&access.token)), "no access token in logs"); - assert!(core.shutdown_within(Duration::from_secs(5))); - assert!(!core_dir.join(octosense_kernel::CONNECTION_FILE).exists()); + assert!(!core.status().running); let _ = std::fs::remove_dir_all(&dir); } @@ -175,11 +190,168 @@ impl Drop for Defer { } } +#[tokio::test(flavor = "multi_thread")] +async fn talk_to_octos_admits_an_external_client_to_the_ui_protocol_only() { + let Some(program) = kernel() else { return }; + let dir = std::env::temp_dir().join(format!("octos-core-talk-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let core_dir = dir.join("octos-home/.octos"); + write_provider(&core_dir, "deepseek", "deepseek-v4-flash"); + std::fs::write(core_dir.join("web-client-origin.txt"), WEB).unwrap(); + let lines = Arc::new(Mutex::new(Vec::::new())); + let sink = lines.clone(); + let core = Core::new(Options::default().core_dir(&core_dir).program(&program).log(move |l| { + sink.lock().unwrap().push(l.to_owned()); + })); + let dump = lines.clone(); + let _print_log = Defer(move || eprintln!("kernel log:\n{}", dump.lock().unwrap().join("\n"))); + + // Turning it on starts the host-managed server and mints the token. + let c = core.clone(); + blocking(move || c.set_external_access(true)).await.unwrap(); + let c = core.clone(); + let access = blocking(move || c.client_access()).await.unwrap(); + let port = port_of(&access); + let authority = format!("127.0.0.1:{port}"); + assert!(!format!("{access:?}").contains(&access.token)); + + // Native consumers share the same kernel over the host token. + let mut native = core.connect().unwrap(); + assert_eq!(running_on(&mut native, "llm1").await.1, "deepseek-v4-flash"); + let mut browser = external(&access).await; + let list = ws_call(&mut browser, "llm1", "profile/llm/list", json!({"profile_id":"_main"})).await; + assert_eq!(list["primary"]["model"], "deepseek-v4-flash"); + + // What the external token must not reach. + for path in ["/api/admin/overview", "/api/admin/stop-all", "/api/admin/token/rotate", "/api/admin/host/pairing"] { + let (status, _) = http(port, "POST", path, &authority, Some(&access.token), "").await; + assert!(status == 401 || status == 403, "{path}: {status}"); + } + let (status, _) = http(port, "GET", "/api/my/profile", &authority, Some(&access.token), "").await; + assert_eq!(status, 403, "REST is closed to the external token"); + let shutdown = ws_frame(&mut browser, "stop", "server/shutdown", json!({})).await; + assert!(shutdown.get("error").is_some(), "external clients cannot stop the server: {shutdown}"); + let caps = ws_call(&mut browser, "caps", "config/capabilities/list", json!({})).await; + assert!(!caps.to_string().contains("server/shutdown"), "not advertised: {caps}"); + for topic in ["peer-rinx", "peerctx-rinx.app-a"] { + let peer = format!("_main:api:octosense#{topic}"); + let answer = ws_frame(&mut browser, "approve", "approval/respond", json!({ + "session_id": peer, "approval_id": uuid::Uuid::new_v4().to_string(), "decision": "approve"})).await; + assert_eq!(answer["error"]["data"]["kind"], "host_owned_peer_answer_denied", "{answer}"); + } + // DNS rebinding and other local apps. + let (status, _) = http(port, "GET", "/health", &format!("rebind.example:{port}"), None, "").await; + assert_eq!(status, 421, "a foreign Host header is refused"); + let (status, _) = http(port, "GET", "/health", &authority, None, "").await; + assert_eq!(status, 200); + assert_eq!(ws_refused(access.endpoint()).await, 401, "no token"); + let mut spoofed = access.endpoint().into_client_request().unwrap(); + spoofed.headers_mut().insert("X-Profile-Id", "_main".parse().unwrap()); + assert_eq!(ws_refused(spoofed).await, 401, "no trusted-proxy impersonation"); + for solo in ["/api/auth/solo", "/api/auth/solo/create"] { + let body = r#"{"name":"Unauthorized app","username":"intruder","email":"intruder@solo.local"}"#; + let (status, _) = http(port, "POST", solo, &authority, None, body).await; + assert!(status == 403 || status == 404, "{solo}: {status}"); + } + for origin in ["https://untrusted.example", "http://localhost:5173"] { + let mut denied = format!("{}?token={}", access.endpoint(), access.token).into_client_request().unwrap(); + denied.headers_mut().insert("Origin", origin.parse().unwrap()); + assert_eq!(ws_refused(denied).await, 403, "{origin}"); + } + // The descriptor carries the external token only, privately. + let descriptor = octosense_kernel::connection_file(&core_dir); + let saved: Value = serde_json::from_str(&std::fs::read_to_string(&descriptor).unwrap()).unwrap(); + assert_eq!(saved["token"], access.token.as_str()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!(std::fs::metadata(&descriptor).unwrap().permissions().mode() & 0o777, 0o600); + } + + // A provider restart keeps the port and token (the host keeps the + // listener), and the server comes straight back. + write_provider(&core_dir, "moonshot", "kimi-k2.5"); + drop(browser); + assert!(core.restart()); + while native.recv().await.is_ok() {} + let c = core.clone(); + let restarted = blocking(move || c.client_access()).await.unwrap(); + assert_eq!(restarted.origin, access.origin, "the same port"); + assert_eq!(restarted.token, access.token, "the same external token"); + let mut browser = external(&restarted).await; + let list = ws_call(&mut browser, "llm2", "profile/llm/list", json!({"profile_id":"_main"})).await; + assert_eq!(list["primary"]["model"], "kimi-k2.5"); + drop(native); + assert!(core.status().running, "external clients keep the server up"); + + // Rotating retires the old token. + let c = core.clone(); + blocking(move || c.rotate_external_access()).await.unwrap(); + let c = core.clone(); + let rotated = blocking(move || c.client_access()).await.unwrap(); + assert_ne!(rotated.token, access.token); + let mut old = format!("{}?token={}", access.endpoint(), access.token).into_client_request().unwrap(); + old.headers_mut().insert("Origin", WEB.parse().unwrap()); + assert_eq!(ws_refused(old).await, 401, "the old token is dead"); + drop(browser); + + // Turning it off stops external access: nothing listens any more. + let c = core.clone(); + blocking(move || c.set_external_access(false)).await.unwrap(); + for _ in 0..100 { + if tokio::net::TcpStream::connect(("127.0.0.1", port)).await.is_err() { break; } + tokio::time::sleep(Duration::from_millis(100)).await; + } + assert!(tokio::net::TcpStream::connect(("127.0.0.1", port)).await.is_err(), "nothing listens with Talk to Octos off"); + assert!(!descriptor.exists()); + assert!(!lines.lock().unwrap().iter().any(|l| l.contains(&access.token) || l.contains(&rotated.token)), "no token in logs"); + core.shutdown_within(Duration::from_secs(15)); + let _ = std::fs::remove_dir_all(&dir); +} + +#[tokio::test(flavor = "multi_thread")] +async fn pairing_hands_a_web_client_the_external_token_once() { + let Some(program) = kernel() else { return }; + let dir = std::env::temp_dir().join(format!("octos-core-pair-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let core_dir = dir.join("octos-home/.octos"); + write_provider(&core_dir, "deepseek", "deepseek-v4-flash"); + let core = Core::new(Options::default().core_dir(&core_dir).program(&program)); + let c = core.clone(); + blocking(move || c.set_external_access(true)).await.unwrap(); + let c = core.clone(); + let access = blocking(move || c.client_access()).await.unwrap(); + let port = port_of(&access); + let authority = format!("127.0.0.1:{port}"); + let (status, _) = http(port, "GET", "/pair/info", &authority, None, "").await; + assert_eq!(status, 404, "pairing is off until the host enables it"); + let c = core.clone(); + let pairing = blocking(move || c.pairing()).await.unwrap(); + assert_eq!(pairing.code.len(), 8); + assert_eq!(pairing.expires_in_secs, 300); + let link = octosense_kernel::pairing_link(WEB, &pairing, None).unwrap(); + assert!(link.contains(&pairing.code) && !link.contains(&access.token)); + let claim = json!({"code": pairing.code}).to_string(); + let (status, body) = http(port, "POST", "/pair/claim", &authority, None, &claim).await; + assert_eq!(status, 200); + let claimed: Value = serde_json::from_str(body.trim()).unwrap(); + assert_eq!(claimed["token"], access.token.as_str(), "the external token, never the host's"); + let (status, _) = http(port, "POST", "/pair/claim", &authority, None, &claim).await; + assert_eq!(status, 400, "single use"); + let c = core.clone(); + blocking(move || c.end_pairing()).await; + let (status, _) = http(port, "GET", "/pair/info", &authority, None, "").await; + assert_eq!(status, 404, "off again when the sheet closes"); + let c = core.clone(); + blocking(move || c.set_external_access(false)).await.unwrap(); + core.shutdown_within(Duration::from_secs(15)); + let _ = std::fs::remove_dir_all(&dir); +} + #[tokio::test(flavor = "multi_thread")] async fn native_and_browser_talk_to_the_same_system_agent() { use std::io::BufRead; - use octosense_kernel::SYSTEM_SESSION; - let Some(program) = std::env::var_os("OCTOS_CORE_TEST_KERNEL") else { return }; + let Some(program) = kernel() else { return }; let script = Path::new(env!("CARGO_MANIFEST_DIR")).join("../app-peers/tests/fixtures/mock_llm.py"); let mut model = std::process::Command::new("python3").arg(script) .stdout(std::process::Stdio::piped()).spawn().unwrap(); @@ -196,14 +368,13 @@ async fn native_and_browser_talk_to_the_same_system_agent() { "config":{"llm":{"primary":{"family_id":"local", "model_id":"mock-model", "route":{"base_url":format!("http://127.0.0.1:{port}/v1"), "api_type":"openai"}}}} }).to_string()).unwrap(); - std::fs::write(dir.join("web-client-origin.txt"), "http://localhost:4173").unwrap(); - let core = Core::new(Options::default().program(program).core_dir(&dir)); + std::fs::write(dir.join("web-client-origin.txt"), WEB).unwrap(); + let core = Core::new(Options::default().program(&program).core_dir(&dir)); + let c = core.clone(); + blocking(move || c.set_external_access(true)).await.unwrap(); let mut native = core.connect().unwrap(); let access = native.client_access().await.unwrap(); - let link = native.system_web_url("http://localhost:4173").await.unwrap(); - let url = url::Url::parse(&link).unwrap(); - assert!(!link.contains(&access.token)); - let reference: Value = serde_json::from_str(&url.query_pairs().find(|(k, _)| k == "s").unwrap().1).unwrap(); + let reference: Value = serde_json::from_str(&native.system_reference().await.unwrap()).unwrap(); assert!(reference[0].as_str().unwrap().starts_with('/')); assert_eq!(reference[1], "_main"); assert_eq!(reference[2], SYSTEM_SESSION); @@ -226,8 +397,6 @@ async fn native_and_browser_talk_to_the_same_system_agent() { tokio::time::sleep(Duration::from_millis(100)).await; } }).await.expect("browser sees native turn"); - // Completion can precede the turn/start reply; inspect durable turn - // state rather than depending on the relative delivery of notifications. tokio::time::timeout(Duration::from_secs(30), async { loop { let state = call(&mut native, "state", "turn/state/get", json!({ @@ -244,20 +413,72 @@ async fn native_and_browser_talk_to_the_same_system_agent() { tokio::time::sleep(Duration::from_millis(100)).await; } }).await.expect("native client sees browser turn"); - assert_eq!(core.status().generation, 1); drop((native, browser)); - core.shutdown_within(Duration::from_secs(5)); - // A Web cwd makes the session persistently scoped. After even a full - // host restart, native app peers must resume it without having to know - // the browser's workspace routing contract. - let restarted = Core::new(Options::default() - .program(std::env::var_os("OCTOS_CORE_TEST_KERNEL").unwrap()).core_dir(&dir)); + core.shutdown_within(Duration::from_secs(15)); + // A Web cwd makes the session persistently scoped. After a full host + // restart with Talk to Octos off, native app peers still resume it. + std::fs::remove_file(dir.join("external-access.json")).unwrap(); + let restarted = Core::new(Options::default().program(&program).core_dir(&dir)); let mut native = restarted.connect().unwrap(); let opened = call(&mut native, "resume", "session/open", json!({ "session_id":SYSTEM_SESSION,"profile_id":"_main"})).await; assert_eq!(opened["opened"]["workspace_root"], reference[0]); - assert!(native.system_web_url("http://localhost:4173").await.is_ok()); drop(native); restarted.shutdown_within(Duration::from_secs(5)); let _ = std::fs::remove_dir_all(dir); } + +/// The kernel must not outlive a host that dies without stopping it +/// (a crash or SIGKILL): its stdin closes and it stops. This test re-runs +/// itself as the host in a child process and kills that process. +#[test] +fn a_host_that_dies_takes_its_kernel_with_it() { + const HOST: &str = "OCTOSENSE_KERNEL_TEST_HOST"; + let Some(program) = kernel() else { return }; + if let Some(dir) = std::env::var_os(HOST).map(PathBuf::from) { + // The host: start the kernel, report ready, then wait to be killed. + let shared = dir.join("shared").exists(); + write_provider(&dir, "deepseek", "deepseek-v4-flash"); + let core = Core::new(Options::default().program(&program).core_dir(&dir)); + if shared { + core.set_external_access(true).unwrap(); + core.client_access().unwrap(); + } + let _conn = core.connect().unwrap(); + std::thread::sleep(Duration::from_secs(3)); + std::fs::write(dir.join("ready"), "").unwrap(); + std::thread::sleep(Duration::from_secs(600)); + return; + } + for shared in [false, true] { + let dir = std::env::temp_dir().join(format!("octos-core-orphan-{}-{shared}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + if shared { + std::fs::write(dir.join("shared"), "").unwrap(); + } + let mut host = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "a_host_that_dies_takes_its_kernel_with_it", "--nocapture", "--test-threads=1"]) + .env(HOST, &dir) + .spawn() + .unwrap(); + let deadline = std::time::Instant::now() + Duration::from_secs(120); + while !dir.join("ready").exists() { + assert!(std::time::Instant::now() < deadline, "the host did not start its kernel"); + std::thread::sleep(Duration::from_millis(100)); + } + let kernels = || { + let out = std::process::Command::new("pgrep").args(["-f", &dir.to_string_lossy()]).output().unwrap(); + String::from_utf8_lossy(&out.stdout).lines().map(str::to_owned).collect::>() + }; + assert!(!kernels().is_empty(), "the kernel runs (shared: {shared})"); + host.kill().unwrap(); // SIGKILL: no orderly stop + host.wait().unwrap(); + let deadline = std::time::Instant::now() + Duration::from_secs(30); + while !kernels().is_empty() { + assert!(std::time::Instant::now() < deadline, "the kernel outlived its host (shared: {shared}): {:?}", kernels()); + std::thread::sleep(Duration::from_millis(200)); + } + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/desktop/README.md b/desktop/README.md index a529da89..4fd0f2f2 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -145,10 +145,9 @@ Mail is the worked example (`octosense-mail-service`, from [`../apps/mail/host-s AI providers (`os.ai-providers`) edits the octos kernel's LLM providers through the `llm` service (`octosense-llm-service`, from [`../apps/ai-providers/host-service`](../apps/ai-providers/host-service)). Keys are typed only on host sheets and go to the macOS keychain entry octos reads; the providers are written to the kernel's profile under the shell's octos core dir (`/profiles/_main.json`; core dir `OCTOS_APP_CORE_DIR`, else `~/octos-home/.octos`). A phone's provider QR is imported from a picture of it: **Choose image** opens the open panel, or drop a screenshot on the import sheet. **Start → Settings → AI providers** opens it. After a change the service restarts the kernel if one runs; its consumers (AppCard) reconnect to the new one. +**The octos kernel** is a shell service, not part of any app: `octosense-kernel` ([`../crates/kernel`](../crates/kernel), feature `octos-core`, default). The shell starts it through its AI services at startup ([`../crates/ai-host`](../crates/ai-host/README.md), `octosense_ai_host::start`); nothing runs until a consumer connects, then one kernel per process (` serve --stdio --data-dir ` on a desktop, the APK's `liboctos.so` on Android; none on iOS or on a desktop without `OCTOS_APP_CORE_BIN`). AppCard's agent connects to it; Rinx reaches it through the app-agent broker. It stops when the last consumer leaves and when the shell exits. -**Talk to the system agent:** open **AI providers → Talk to Octos** for the server address and native **Copy access token** button. Native apps, OctosCode TUI and Web share one WebSocket server; the system session is `_main:api:octosense#system`. The kernel requires this repository's locked `--host-managed` overlay, applied automatically by the Android build tool. The web client is hosted separately. See the [kernel guide](../crates/kernel/README.md) for connections, tunnels and platform limits. - -**The octos kernel** is a shell service, not part of any app: `octosense-kernel` ([`../crates/kernel`](../crates/kernel), feature `octos-core`, default). The shell starts it through its AI services at startup ([`../crates/ai-host`](../crates/ai-host/README.md), `octosense_ai_host::start`); nothing runs until a consumer connects, then one kernel per process (` serve --host 127.0.0.1 --host-managed --data-dir ` on a desktop, the APK's `liboctos.so` on Android; none on iOS or on a desktop without `OCTOS_APP_CORE_BIN`). AppCard's agent connects to it; Rinx reaches it through the app-agent broker. It stays available when native apps close and stops when the shell exits. +**Talk to Octos** (off by default): **AI providers → Talk to Octos** turns on a loopback server so a web client or a terminal UI can talk to this device's assistant. While it is on, the kernel runs as `octos serve --host-managed` instead of `--stdio` and native apps keep working over its WebSocket; external clients get a separate token that opens the UI Protocol socket and nothing else. A web client pairs with a one-time code or the QR of its link; a terminal client of this user reads the private connection file. The server stays up when native apps close, until it is turned off or the shell exits. See [ADR 0003](../docs/adr/0003-shared-octos-client-access.md) and the [kernel guide](../crates/kernel/README.md). New app features that need a password, PIN or token belong in a host service and a host sheet, never in the app's own UI. diff --git a/desktop/README.zh-CN.md b/desktop/README.zh-CN.md index 425b59ec..da74748f 100644 --- a/desktop/README.zh-CN.md +++ b/desktop/README.zh-CN.md @@ -145,10 +145,9 @@ launcher 把四类应用列在一起: AI 提供商(`os.ai-providers`)通过 `llm` 服务(`octosense-llm-service`,来自 [`../apps/ai-providers/host-service`](../apps/ai-providers/host-service))编辑 octos 内核的 LLM 提供商。密钥只在宿主面板上输入,保存到 octos 读取的 macOS 钥匙串条目;提供商写入 Shell 的 octos core 目录下内核的 profile(`/profiles/_main.json`;core 目录为 `OCTOS_APP_CORE_DIR`,否则为 `~/octos-home/.octos`)。手机上的提供商二维码可从图片导入:**Choose image** 打开文件面板,或把截图拖到导入面板上。**开始 → 设置 → AI providers** 可打开它。更改后服务会重启正在运行的内核,使用方(AppCard)会重新连接到新内核。 +**octos 内核**是一项 Shell 服务,不属于任何应用:`octosense-kernel`([`../crates/kernel`](../crates/kernel),feature `octos-core`,默认开启)。Shell 在启动时通过其 AI 服务启动它([`../crates/ai-host`](../crates/ai-host/README.md),`octosense_ai_host::start`);在有使用方连接之前不运行任何东西,之后每个进程只有一个内核(桌面上是 ` serve --stdio --data-dir `,Android 上是 APK 中的 `liboctos.so`;iOS 以及未设置 `OCTOS_APP_CORE_BIN` 的桌面上没有内核)。AppCard 的 Agent 连接它;Rinx 通过应用与 Agent 之间的代理访问它。最后一个使用方离开或 Shell 退出时内核停止。 -**与系统 Agent 对话:** 打开 **AI providers → Talk to Octos**,获取服务器地址并通过宿主按钮复制访问令牌。原生应用、OctosCode 终端和 Web 客户端连接同一个 WebSocket 服务;系统会话为 `_main:api:octosense#system`。内核必须包含本仓库锁定的 `--host-managed` 补丁,Android 构建工具会自动应用。Web 客户端仍需单独托管。连接、隧道及平台限制见[内核指南](../crates/kernel/README.zh-CN.md)。 - -**octos 内核**是一项 Shell 服务,不属于任何应用:`octosense-kernel`([`../crates/kernel`](../crates/kernel),feature `octos-core`,默认开启)。Shell 在启动时通过其 AI 服务启动它([`../crates/ai-host`](../crates/ai-host/README.md),`octosense_ai_host::start`);在有使用方连接之前不运行任何东西,之后每个进程只有一个内核(桌面上是 ` serve --host 127.0.0.1 --host-managed --data-dir `,Android 上是 APK 中的 `liboctos.so`;iOS 以及未设置 `OCTOS_APP_CORE_BIN` 的桌面上没有内核)。AppCard 的 Agent 连接它;Rinx 通过应用与 Agent 之间的代理访问它。原生应用关闭后服务仍保持可连接;Shell 退出时内核停止。 +**Talk to Octos**(默认关闭):在 **AI providers → Talk to Octos** 中打开后,本机会启动一个仅监听回环地址的服务,让 Web 客户端或终端界面与本设备的助手对话。开启期间内核以 `octos serve --host-managed` 代替 `--stdio` 运行,原生应用继续通过其 WebSocket 工作;外部客户端使用单独的令牌,只能打开 UI Protocol 套接字。Web 客户端通过一次性配对码或其链接的二维码配对;本用户的终端客户端读取私有连接文件。原生应用关闭后服务仍保持运行,直到关闭该功能或 Shell 退出。见 [ADR 0003(英文)](../docs/adr/0003-shared-octos-client-access.md) 和[内核指南](../crates/kernel/README.zh-CN.md)。 需要密码、PIN 或令牌的新功能,应放在宿主服务和宿主自有面板中,绝不放在应用自己的界面里。 diff --git a/docs/adr/0003-shared-octos-client-access.md b/docs/adr/0003-shared-octos-client-access.md index dceca32b..d5aed4b0 100644 --- a/docs/adr/0003-shared-octos-client-access.md +++ b/docs/adr/0003-shared-octos-client-access.md @@ -1,73 +1,122 @@ -# ADR 0003: One Octos server for native and external clients +# ADR 0003: Talk to Octos: one kernel for native and external clients -Status: Implemented in source; desktop integration verified; Android acceptance pending. +Status: Implemented in source (desktop verified; Android **unverified**). +Depends on octos `serve --host-managed` (octos#2591, UPCR-2026-036). ## Context -OctoSense started `octos serve --stdio`. That mode disables the HTTP listener; -a second server cannot open the same single-writer data directory. Starting -OctosCode without an endpoint instead creates an unrelated kernel. Users need -to talk to the system agent from their desktop terminal or a computer's web -browser while native app peers remain connected. +OctoSense runs one octos kernel per shell process as a private +`octos serve --stdio` child (the embedded core on OpenHarmony). Native apps +share it through `crates/kernel`, and apps reach the assistant only through +the app-peer broker (`crates/app-peers`). People also want to talk to the +same assistant from a web client or a terminal UI. Starting a second kernel +does not work: octos holds a single-writer lock on its data directory, and a +separate kernel would be a different assistant with different memory. -Loopback alone is not an Android authentication boundary. Upstream solo login -and the trusted-loopback `X-Profile-Id` path are inappropriate for a host-owned -agent that other APKs can reach. +## Threat model + +- **Loopback is not a boundary.** On Android every installed app can connect + to `127.0.0.1`. On a shared computer, every other local user can. Web pages + reach loopback through the person's browser (cross-site WebSocket, DNS + rebinding). +- **The kernel's authority is large.** A client that controls the system agent + controls its tools, and through the app peers, work done for apps. +- **The shell can die at any time**: a crash, a kill, an OOM. A kernel that + outlives it keeps its data-directory lock and its port. +- **Ports are first come, first served.** Between two kernel generations, + another local process can bind a port that clients still trust. ## Decision -Desktop and Android start one host-managed HTTP/WebSocket Octos child bound to -127.0.0.1. The native frame router connects to that server with a random host -token and explicitly negotiates the capabilities previously enabled by stdio. -External clients use the same UI Protocol endpoint and independently negotiate -features. We do not add simultaneous stdio and WebSocket listeners upstream. - -A small executable overlay, locked by source revision and SHA-256, introduces -`--host-managed`. It requires local mode, loopback binding and a nonempty token; -uses one in-process profile runtime; disables solo login; and rejects local -profile-header impersonation and alternate credentials. The normal executable -build applies the overlay; incompatible prebuilts fail closed. The workspace's -Octos crate pin is unchanged, including OpenHarmony's embedded implementation. - -The shared server starts on demand and survives native consumer closure. A -provider/origin restart waits for the previous child to exit, preserving port -and token; shell shutdown ends it. The private descriptor is atomically written -with mode 0600 and removed on orderly stop. A new service lifetime rotates the -token. Pipe fixtures and OpenHarmony retain stop-when-idle semantics. - -AI providers exposes **Talk to Octos** through a trusted host sheet. Only that -sheet may request connection controls. Native code copies the token or opens -the browser; scripts never receive the token. An explicit HTTP(S) web origin -is saved as the allowlist. The generated credential-free web link identifies -the system session and server-confirmed workspace. The client still asks for -server origin and token. Web assets remain separately hosted. - -The system conversation is `_main:api:octosense#system` in profile `_main`. -The server-confirmed system workspace is canonicalized and persisted. Native -opens reuse that explicit cwd so Web workspace scoping does not break native -resumption after a full shell restart. -Sharing the runtime does not collapse app-peer sessions or grant raw protocol -access to ordinary apps. A computer reaches the phone's loopback listener over -an authorized tunnel; this change adds no public listener or ROM Binder API. +1. **Opt-in listener.** The kernel stays the private stdio child unless the + person turns on **Talk to Octos** in AI providers (Settings → AI + providers). Only then does the shell restart it as + `octos serve --host-managed --host 127.0.0.1`. Turning it off restarts the + kernel on the pipe: nothing listens, and the connection file is removed. +2. **Two tokens.** + + | Token | Holder | Grants | + | --- | --- | --- | + | Host token | the shell process only, passed in the child's environment | everything a native consumer had on stdio (octos admin) | + | External token | a paired web client, or a terminal client of this user through the 0600 connection file | `/api/ui-protocol/ws` only, as user `_main` | + + The external token gets no REST route (403), no admin route (401), no + `server/shutdown` (never offered on a host-managed server), and no answers + to approvals or questions of host-owned app peers (`peer-…`/`peerctx-…` + sessions). Those belong to the person in the app, as UPCR-2026-034 already + requires of the system agent. The external token is minted when Talk to + Octos turns on, and again on **Revoke all clients** (which restarts the + server, ending open connections). A new shell lifetime mints a new one. + Tokens are never printed, logged, copied to the clipboard or put in a + command line. +3. **Pairing, not copying.** A web client gets the external token only + through octos's pairing: an 8-character code shown on the trusted sheet + (with a QR of the web client's link), valid for five minutes and one claim. + It works only while the sheet is open; the shell turns pairing off when the + sheet closes. There is no "copy token" action. +4. **Browser guards.** The server answers only requests whose `Host` names + its own loopback listener (DNS rebinding). It trusts only the web origin + the person saved: `https`, or `http` only for localhost, 127.0.0.1 or + [::1]. A malformed saved origin counts as none, and the kernel still + starts. Origin protects a browser that holds a token from other pages. It + is not authentication; the token is. +5. **The host owns the lifecycle.** The kernel's stdin is its lifeline: when + the shell exits, crashes or is killed, the kernel reads EOF and stops. On + Linux and Android it also asks for SIGTERM when the parent dies. Clients + cannot stop it. +6. **The port stays the host's.** On Unix the shell binds the listener once + and passes it to every kernel generation (`--listen-fd`). A provider + restart therefore keeps the port, and no other process can take it in + between; clients that connect meanwhile wait in the backlog. Without + descriptor passing (Windows), a restart reuses the port when it is free, + and otherwise moves to a fresh port with a fresh external token and + rewrites the connection file. +7. **Native consumers are unchanged.** They keep the same frame API over the + WebSocket and request octos's own stdio feature set + (`UI_PROTOCOL_STDIO_DEFAULT_FEATURES`). The system conversation is + `_main:api:octosense#system`. Its workspace is saved so native opens and a + web client's scoped session agree. +8. **Upstream, not an overlay.** `--host-managed` is octos code (octos#2591); + OctoSense carries no patch to octos. + +## Consequences + +- With Talk to Octos off (the default), behaviour is exactly the private-pipe + kernel: the app-peer real-kernel tests run on it. +- With it on, the kernel no longer stops when the last native consumer leaves. + It stops when the person turns it off or the shell exits. +- The connection file (`/client-connection.json`, mode 0600) holds + the external token. On Windows it is kept in `%LOCALAPPDATA%\OctoSense\`, + whose default ACL admits this user, SYSTEM and administrators, and not + other users. An administrator or a process of the same user can read it: + that is the same trust as the user's own files. +- A computer reaches a phone's server through a tunnel that keeps the port + number (for example `adb forward tcp:P tcp:P`), because of the `Host` check. +- Browser-owned turns can still be interrupted when their socket closes + (upstream octos issue 2167). +- This is not an Android foreground service: when Android kills the shell, + the kernel stops with it. ## Limits -This is not an Android foreground service. Shell/process death stops the -kernel. Browser-owned turns can still be interrupted when their socket closes -(upstream issue 2167); detached task ownership is a separate concern. -OpenHarmony has no external listener here; iOS has no local kernel. We do not -bundle a web client, add an embedded chat view, or provide a Linux toolchain. +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**. ## Acceptance -Real-kernel tests with a local scripted model verify native/browser turns in -one system session, simultaneous app peers, rejection of missing tokens and -spoofed profile headers, disabled solo login, rejection of untrusted origins, -provider restart with stable access, continued service after native closure, -private descriptor permissions and orderly shutdown. Hidden desktop UI checks -exercise the sheet and origin save. A headless Chromium run against the -shell-owned server opened the saved system-conversation link, sent a prompt -and displayed the local model reply. Full host restart preserves the scoped -workspace for native resumption. Artifact tests enforce overlay idempotence -and rejection of changed hashes/revisions. Android device/packaging and actual -TUI acceptance are pending; the OnePlus is untouched. +Real-kernel tests (a scripted local model, no external calls) check that: + +- nothing listens while Talk to Octos is off; +- when it is on, the external token gets 401/403 on `/api/admin/*` and REST, + cannot call `server/shutdown` (not advertised either), and cannot answer a + peer approval; +- a foreign `Host`, a missing token, a spoofed profile header, solo login and + untrusted origins are refused; +- pairing is single use and hands out the external token; +- a restart keeps the port and token, and rotation retires the old token; +- native and web clients share the system conversation; +- a shell killed with SIGKILL takes its kernel with it, in both modes. + +The six app-peer real-kernel tests pass with Talk to Octos off. A +hidden-window desktop run turned Talk to Octos on and off from the sheet. diff --git a/docs/adr/README.md b/docs/adr/README.md index 9d254848..f596036f 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -8,7 +8,7 @@ Decisions for the OctoSense repository: the shell, its services, the system apps | --- | --- | --- | | [0001](0001-one-octosense-repository.md) | One OctoSense repository for the shell, its services, the system apps and both packagings | Accepted | | [0002](0002-event-driven-app-agents.md) | Event-driven app agents: apps think on their own triggers and publish cards to the glance screen | Proposed | -| [0003](0003-shared-octos-client-access.md) | Shared Octos access for native, terminal and web clients | Implemented; Android acceptance pending | +| [0003](0003-shared-octos-client-access.md) | Talk to Octos: one kernel for native and external clients (opt-in) | Implemented; Android unverified | ## Home (phone shell) decisions, 2026-09-16 to 2026-09-25 diff --git a/docs/adr/README.zh-CN.md b/docs/adr/README.zh-CN.md index 53e92745..90f61a57 100644 --- a/docs/adr/README.zh-CN.md +++ b/docs/adr/README.zh-CN.md @@ -8,7 +8,7 @@ OctoSense 仓库的决策:Shell、Shell 服务、系统应用,以及桌面 | --- | --- | --- | | [0001](0001-one-octosense-repository.md)(英文) | 用一个 OctoSense 仓库承载 Shell、Shell 服务、系统应用和两种打包形态 | 已接受 | | [0002](0002-event-driven-app-agents.md)(英文) | 事件驱动的应用智能体:应用按自己的触发条件思考,并把卡片发布到一览屏 | 提议中 | -| [0003](0003-shared-octos-client-access.md) | 原生、终端与 Web 客户端共享 Octos 服务 | 已实现;Android 验收待完成 | +| [0003](0003-shared-octos-client-access.md)(英文) | Talk to Octos:原生与外部客户端共用一个内核(需手动开启) | 已实现;Android 未验证 | ## Home(手机 Shell)的决策,2026-09-16 至 2026-09-25 diff --git a/octos-runtime-patches.lock.json b/octos-runtime-patches.lock.json deleted file mode 100644 index 042a1870..00000000 --- a/octos-runtime-patches.lock.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "schema_version": 1, - "base_revision": "60cf96e6c8719bd5f1b61ab5b537a88ffa314879", - "patch": "tools/runtime-patches/octos-host-managed.patch", - "sha256": "63720f5fa7d45268c6e20a69d5c1e567b7c630cf54c7a0ac99d2fad0a4ff752d" -} diff --git a/phone/README.md b/phone/README.md index 15a05eac..f4fd06a0 100644 --- a/phone/README.md +++ b/phone/README.md @@ -194,19 +194,18 @@ The octos agent kernel is a Home service, independent of any app: `octos-core` (default, and always on in Android, iOS and OpenHarmony builds). Home starts it at startup with its data dir through the shell's AI services ([`crates/ai-host`](../crates/ai-host/README.md)); nothing runs until a consumer -connects. Then there is one kernel per process: `liboctos.so serve --host 127.0.0.1 --host-managed` +connects. Then there is one kernel per process: `liboctos.so serve --stdio` from the APK's native lib dir on Android (every APK `rom/scripts/build-home.sh` builds carries it), the core in-process on OpenHarmony, the binary named by `OCTOS_APP_CORE_BIN` on a desktop (none otherwise), none on iOS. Its core dir is `/octos-home/.octos` on a phone and `OCTOS_APP_CORE_DIR`, else `~/octos-home/.octos`, on a desktop. The AI providers app configures it (below); AppCard (opt-in) and, next, Rinx connect to it and share it; it -stays available when native apps close and stops on Home's shutdown. To build without it +stops when the last one leaves and on Home's shutdown. To build without it (desktop only): `--no-default-features` plus the features you want, e.g. `--features app-hub`. - -**Talk to the system agent:** open **AI providers → Talk to Octos** for the server address and native **Copy access token** button. Native apps, OctosCode TUI and Web share one WebSocket server; the system session is `_main:api:octosense#system`. The kernel requires this repository's locked `--host-managed` overlay, applied automatically by the Android build tool. The web client is hosted separately. See the [kernel guide](../crates/kernel/README.md) for connections, tunnels and platform limits. +**Talk to Octos** (off by default): **AI providers → Talk to Octos** turns on a loopback server so a web client or a terminal UI can talk to this device's assistant. While it is on, the kernel runs as `octos serve --host-managed` instead of `--stdio` and native apps keep working over its WebSocket; external clients get a separate token that opens the UI Protocol socket and nothing else. A web client pairs with a one-time code or the QR of its link; a terminal client of this user reads the private connection file. The server stays up when native apps close, until it is turned off or the shell exits. See [ADR 0003](../docs/adr/0003-shared-octos-client-access.md) and the [kernel guide](../crates/kernel/README.md). ### AI providers diff --git a/phone/README.zh-CN.md b/phone/README.zh-CN.md index c872ecdb..bcf0c155 100644 --- a/phone/README.zh-CN.md +++ b/phone/README.zh-CN.md @@ -116,12 +116,11 @@ adb shell am start -n /.MakepadApp --es makepad.APP_CONFIG '{"mail_demo `app-news`、`app-photos` 和 `app-maps` 会链接早期的原生模块来替代对应的脚本应用,用于在脚本应用完成真机测量之前进行对比;相关说明见 [docs/photos.md(英文)](docs/photos.md) 和 [docs/maps.md(英文)](docs/maps.md)。Mail 和 Camera 已不再有原生模块。 - -**与系统 Agent 对话:** 打开 **AI providers → Talk to Octos**,获取服务器地址并通过宿主按钮复制访问令牌。原生应用、OctosCode 终端和 Web 客户端连接同一个 WebSocket 服务;系统会话为 `_main:api:octosense#system`。内核必须包含本仓库锁定的 `--host-managed` 补丁,Android 构建工具会自动应用。Web 客户端仍需单独托管。连接、隧道及平台限制见[内核指南](../crates/kernel/README.zh-CN.md)。 - ### octos 内核 -octos Agent 内核是 Home 的一项服务,不依附于任何应用:`octosense-kernel`([`crates/kernel`](../crates/kernel)),feature `octos-core`(默认开启,Android、iOS、OpenHarmony 构建总是开启)。Home 在启动时通过 Shell 的 AI 服务([`crates/ai-host`](../crates/ai-host/README.md))用自己的数据目录启动它,在有使用方连接之前不运行任何东西。之后每个进程只有一个内核:Android 上从 APK 原生库目录运行 `liboctos.so serve --host 127.0.0.1 --host-managed`(`rom/scripts/build-home.sh` 构建的每个 APK 都带有它),OpenHarmony 上在进程内运行,桌面上运行 `OCTOS_APP_CORE_BIN` 指定的二进制(未指定则没有),iOS 上没有。它的 core 目录在手机上是 `<数据目录>/octos-home/.octos`,在桌面上是 `OCTOS_APP_CORE_DIR`,否则 `~/octos-home/.octos`。AI 提供商应用负责配置它(见下文);AppCard(需显式开启)以及之后的 Rinx 连接并共享它;原生应用关闭后服务仍保持可连接;Home 关闭时内核停止。不带内核服务构建(仅桌面):`--no-default-features` 加上需要的 feature,例如 `--features app-hub`。 +octos Agent 内核是 Home 的一项服务,不依附于任何应用:`octosense-kernel`([`crates/kernel`](../crates/kernel)),feature `octos-core`(默认开启,Android、iOS、OpenHarmony 构建总是开启)。Home 在启动时通过 Shell 的 AI 服务([`crates/ai-host`](../crates/ai-host/README.md))用自己的数据目录启动它,在有使用方连接之前不运行任何东西。之后每个进程只有一个内核:Android 上从 APK 原生库目录运行 `liboctos.so serve --stdio`(`rom/scripts/build-home.sh` 构建的每个 APK 都带有它),OpenHarmony 上在进程内运行,桌面上运行 `OCTOS_APP_CORE_BIN` 指定的二进制(未指定则没有),iOS 上没有。它的 core 目录在手机上是 `<数据目录>/octos-home/.octos`,在桌面上是 `OCTOS_APP_CORE_DIR`,否则 `~/octos-home/.octos`。AI 提供商应用负责配置它(见下文);AppCard(需显式开启)以及之后的 Rinx 连接并共享它;最后一个使用方离开或 Home 关闭时内核停止。不带内核服务构建(仅桌面):`--no-default-features` 加上需要的 feature,例如 `--features app-hub`。 + +**Talk to Octos**(默认关闭):在 **AI providers → Talk to Octos** 中打开后,本机会启动一个仅监听回环地址的服务,让 Web 客户端或终端界面与本设备的助手对话。开启期间内核以 `octos serve --host-managed` 代替 `--stdio` 运行,原生应用继续通过其 WebSocket 工作;外部客户端使用单独的令牌,只能打开 UI Protocol 套接字。Web 客户端通过一次性配对码或其链接的二维码配对;本用户的终端客户端读取私有连接文件。原生应用关闭后服务仍保持运行,直到关闭该功能或 Shell 退出。见 [ADR 0003(英文)](../docs/adr/0003-shared-octos-client-access.md) 和[内核指南](../crates/kernel/README.zh-CN.md)。 ### AI 提供商 diff --git a/tools/kernel-artifact.py b/tools/kernel-artifact.py index a0255dd9..d04cb2c1 100755 --- a/tools/kernel-artifact.py +++ b/tools/kernel-artifact.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 -"""Build the octos kernel for OctoSense (Python 3.9+). +"""The octos kernel an OctoSense Android APK bundles (Python 3.9+). The octos kernel is a shell service (octosense-ai-host, feature `octos-core`; always on Android): at run time the shell execs -`liboctos.so serve --host 127.0.0.1 --host-managed` from the APK's native lib dir, the only place an +`liboctos.so serve --stdio` from the APK's native lib dir, the only place an Android app may exec a binary from. This tool produces that artifact for every shell build (desktop/ and phone/ alike): @@ -12,11 +12,10 @@ - by default it checks that revision out into a private work dir (never a sibling checkout you may be working in) and cross-builds `octos` for aarch64-linux-android with the cargo-makepad SDK's NDK clang (API 33, - `--no-default-features --features api,git,ast`: the HTTP/WebSocket server without + `--no-default-features --features api,git,ast`: the stdio server without the llama.cpp embedder); -- applies the revision- and hash-locked host-managed authentication overlay; -- `--host` builds for this desktop instead of Android (no SDK needed); -- `--kernel ` takes a prebuilt `octos` with the same overlay; +- `--host` builds it for this desktop instead (no SDK needed); +- `--kernel ` takes a prebuilt aarch64-linux-android `octos` instead; - it records what it produced (source, revision, sha256) with `--receipt`; - with a command after `--` it runs that command (the packager) with `MAKEPAD_ANDROID_EXTRA_LIBS=liboctos.so=`. @@ -42,7 +41,7 @@ OCTOS_URL = "https://github.com/octos-org/octos.git" TARGET = "aarch64-linux-android" API = "33" -# The shared HTTP/WebSocket kernel, without the llama.cpp +# The kernel as the shells' phones run it: the stdio server, no llama.cpp # embedder (needs cmake and is not used on a phone). KERNEL_BUILD = ["-p", "octos-cli", "--bin", "octos", "--no-default-features", "--features", "api,git,ast"] LIB_NAME = "liboctos.so" @@ -102,17 +101,18 @@ def build_command(sdk, target_dir, offline=False, required=True): def plan(revision, work, sdk, offline=False, required=True, host=False): """(steps, kernel): the (cwd, argv) steps that check the revision out into - `work/src` and cross-build it into `work/target`, and the binary's path.""" + `work/src` and cross-build it into `work/target` (or, `host`, build it for + this machine), and the binary's path.""" work = Path(work) src = work / "src" steps = [(work, ["git", "init", "--quiet", str(src)])] if not offline: steps.append((src, ["git", "fetch", "--quiet", "--no-tags", "--depth=1", OCTOS_URL, revision])) steps.append((src, ["git", "checkout", "--quiet", "--detach", revision])) - steps.append((src, [sys.executable, str(ROOT / "tools/kernel-artifact.py"), "--apply-host-patch", str(src)])) if host: command = ["env", f"CARGO_TARGET_DIR={work / 'target'}", "cargo", "build", "--locked", "--release", *KERNEL_BUILD] - if offline: command.append("--offline") + if offline: + command.append("--offline") steps.append((src, command)) return steps, work / "target/release/octos" steps.append((src, build_command(sdk, work / "target", offline, required))) @@ -142,27 +142,7 @@ def receipt(kernel, source): """What a build records about the bundled kernel.""" if not kernel: return None - value = {"source": source, "sha256": hashlib.sha256(Path(kernel).read_bytes()).hexdigest()} - if source != "prebuilt": - value["host_managed_overlay"] = json.loads((ROOT / "octos-runtime-patches.lock.json").read_text()) - return value - - -def apply_host_patch(src): - """Fail closed on a changed pin/patch; repeated builds preserve an applied overlay.""" - src = Path(src) - lock = json.loads((ROOT / "octos-runtime-patches.lock.json").read_text()) - patch = ROOT / lock["patch"] - if hashlib.sha256(patch.read_bytes()).hexdigest() != lock["sha256"]: - raise RuntimeError("The Octos host-managed patch differs from its reviewed hash") - head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=src, text=True).strip() - if head != lock["base_revision"]: - raise RuntimeError("Rebase and review the Octos host-managed patch for the new kernel pin first") - applied = subprocess.run(["git", "apply", "--reverse", "--check", str(patch)], cwd=src, capture_output=True) - if applied.returncode == 0: - return - subprocess.run(["git", "apply", "--check", str(patch)], cwd=src, check=True) - subprocess.run(["git", "apply", str(patch)], cwd=src, check=True) + return {"source": source, "sha256": hashlib.sha256(Path(kernel).read_bytes()).hexdigest()} def run_steps(steps): @@ -179,8 +159,7 @@ def main(argv=None): argv, command = argv[:i], argv[i + 1:] p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) p.add_argument("--lock", type=Path, help="The Cargo.lock naming the octos revision (default: the workspace's)") - p.add_argument("--host", action="store_true", help="Build the desktop kernel instead of the Android executable") - p.add_argument("--apply-host-patch", type=Path, help=argparse.SUPPRESS) + p.add_argument("--host", action="store_true", help="Build the kernel for this desktop instead of Android") p.add_argument("--sdk", type=Path, help="cargo-makepad's Android SDK dir (holds ndk/)") source = p.add_mutually_exclusive_group() source.add_argument("--kernel", type=Path, help="A prebuilt aarch64-linux-android octos to bundle") @@ -190,9 +169,6 @@ def main(argv=None): p.add_argument("--receipt", type=Path, help="Write {source, sha256} of the kernel here (JSON)") p.add_argument("--plan", action="store_true", help="Print the plan as JSON; run nothing") args = p.parse_args(argv) - if args.apply_host_patch: - apply_host_patch(args.apply_host_patch) - return if args.kernel: args.kernel = args.kernel.resolve() try: diff --git a/tools/runtime-patches/octos-host-managed.patch b/tools/runtime-patches/octos-host-managed.patch deleted file mode 100644 index ee4e868e..00000000 --- a/tools/runtime-patches/octos-host-managed.patch +++ /dev/null @@ -1,116 +0,0 @@ -diff --git a/crates/octos-cli/src/api/mod.rs b/crates/octos-cli/src/api/mod.rs -index 1d44299e..8b19d147 100644 ---- a/crates/octos-cli/src/api/mod.rs -+++ b/crates/octos-cli/src/api/mod.rs -@@ -372,6 +372,8 @@ pub struct AppState { - /// configs never set) is the primary defence; the handlers additionally - /// reject any request carrying proxy-forwarding headers. - pub solo_login_enabled: bool, -+ /// Only the embedding host's token authenticates a host-managed server. -+ pub host_managed_auth: bool, - /// The HTTP serve's stop switch — the same `watch` channel the SIGINT / - /// SIGTERM watcher flips, so the `server/shutdown` UI Protocol method ends - /// the process through exactly the path Ctrl+C takes: stop accepting, -@@ -536,6 +538,7 @@ impl AppState { - deployment_mode: crate::config::DeploymentMode::Local, - pairing: None, - solo_login_enabled: false, -+ host_managed_auth: false, - serve_shutdown: None, - dangerous_default_permissions: false, - default_network_denied: false, -diff --git a/crates/octos-cli/src/api/router.rs b/crates/octos-cli/src/api/router.rs -index bee4cb52..55f71875 100644 ---- a/crates/octos-cli/src/api/router.rs -+++ b/crates/octos-cli/src/api/router.rs -@@ -1221,6 +1221,11 @@ pub(crate) async fn resolve_identity_public(state: &AppState, token: &str) -> Op - - /// Resolve token to an AuthIdentity. - async fn resolve_identity(state: &AppState, token: &str) -> Option { -+ if state.host_managed_auth { -+ return state.auth_token.as_deref() -+ .filter(|expected| !expected.is_empty() && constant_time_eq(token.as_bytes(), expected.as_bytes())) -+ .map(|_| AuthIdentity::Admin); -+ } - if token.is_empty() { - return None; - } -@@ -1316,6 +1321,10 @@ async fn user_auth_middleware( - return Ok(next.run(req).await); - } - -+ if state.host_managed_auth { -+ return Err(StatusCode::UNAUTHORIZED); -+ } -+ - // 2. Accept X-Profile-Id header for chat API routes (proxy auth). - // The reverse proxy (Caddy) sets this header to identify the profile, - // so requests through the proxy are implicitly authenticated. -diff --git a/crates/octos-cli/src/api/ui_protocol_transport.rs b/crates/octos-cli/src/api/ui_protocol_transport.rs -index 1816bbce..a1769377 100644 ---- a/crates/octos-cli/src/api/ui_protocol_transport.rs -+++ b/crates/octos-cli/src/api/ui_protocol_transport.rs -@@ -9712,7 +9712,7 @@ pub(crate) fn supports_local_solo_profile_create(state: &AppState) -> bool { - /// hosted server, where one client stopping the process would take everyone - /// else down, never advertises or accepts it. - pub(crate) fn supports_server_shutdown(state: &AppState) -> bool { -- local_solo_danger_allowed(state) && state.serve_shutdown.is_some() -+ (local_solo_danger_allowed(state) || state.host_managed_auth) && state.serve_shutdown.is_some() - } - - /// `server/shutdown`: stop this `octos serve` exactly as Ctrl+C would. -diff --git a/crates/octos-cli/src/commands/serve.rs b/crates/octos-cli/src/commands/serve.rs -index 58c90775..0418939b 100644 ---- a/crates/octos-cli/src/commands/serve.rs -+++ b/crates/octos-cli/src/commands/serve.rs -@@ -350,6 +350,12 @@ pub struct ServeCommand { - #[arg(long)] - pub stdio: bool, - -+ /// Host-owned local server: one in-process runtime, mandatory host token, -+ /// no password-free solo login or trusted-proxy authentication. -+ #[arg(long, conflicts_with_all = ["stdio", "solo"])] -+ #[serde(default)] -+ pub host_managed: bool, -+ - /// Working directory (defaults to current directory). - #[arg(short, long)] - pub cwd: Option, -@@ -1068,6 +1074,13 @@ impl ServeCommand { - None - }; - -+ if self.host_managed { -+ eyre::ensure!(!self.stdio && !self.solo, "--host-managed cannot use --stdio or --solo"); -+ eyre::ensure!(self.host == "127.0.0.1", "--host-managed requires loopback binding"); -+ eyre::ensure!(config.mode == crate::config::DeploymentMode::Local, "--host-managed requires local mode"); -+ eyre::ensure!(auth_token.as_deref().is_some_and(|t| !t.is_empty()), "--host-managed requires OCTOS_AUTH_TOKEN"); -+ } -+ - // Initialize profile store and process manager for admin dashboard. - // Registry (`.json`) resolves from the SHARED `state_home`; the - // per-profile `/data` runtime tree roots under the per-instance -@@ -1467,12 +1480,12 @@ impl ServeCommand { - // gateway for the same profile would open its episodes.redb again and - // lock session/open out of the profile after onboarding. Stdio serve - // already has no gateway auto-start; keep HTTP solo consistent. -- let solo_login_enabled_flag = self.solo -+ let solo_login_enabled_flag = !self.host_managed && (self.solo - || std::env::var("OCTOS_SOLO_LOGIN") - .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) -- .unwrap_or(false); -+ .unwrap_or(false)); - let solo_in_process = -- solo_login_enabled_flag && config.mode == crate::config::DeploymentMode::Local; -+ (solo_login_enabled_flag || self.host_managed) && config.mode == crate::config::DeploymentMode::Local; - - let bridge_js_path = data_dir.join("whatsapp-bridge").join("bridge.js"); - let process_manager = Arc::new( -@@ -1752,6 +1765,7 @@ impl ServeCommand { - host_memory: config.memory.clone(), - pairing: pairing.clone(), - solo_login_enabled: solo_login_enabled_flag, -+ host_managed_auth: self.host_managed, - // Only the HTTP serve has a loop to stop; see AppState::serve_shutdown. - serve_shutdown: (!self.stdio).then(|| serve_shutdown_tx.clone()), - dangerous_default_permissions: dangerous_default_permissions_flag, diff --git a/tools/test_kernel_artifact.py b/tools/test_kernel_artifact.py index 1567fa0a..6ecc9865 100644 --- a/tools/test_kernel_artifact.py +++ b/tools/test_kernel_artifact.py @@ -7,15 +7,15 @@ from pathlib import Path import tempfile import unittest -import subprocess -from unittest.mock import patch ROOT = Path(__file__).resolve().parents[1] spec = importlib.util.spec_from_file_location("kernel_artifact", ROOT / "tools/kernel-artifact.py") kernel = importlib.util.module_from_spec(spec) spec.loader.exec_module(kernel) -REV = "3b5d17a435da8ba9d1fcccbfedce46c357819a3a" +# The head of octos#2591 (`serve --host-managed`) until it merges; then the +# merged octos commit (the root Cargo.toml says the same). +REV = "2cd29e44ca4bff8c54fe1de10179b5cf29017571" def lock_with(*revs): @@ -43,14 +43,23 @@ def test_the_workspace_pins_one_kernel(self): class PlanTests(unittest.TestCase): - def test_desktop_plan_also_applies_the_reviewed_overlay(self): - steps, binary, _ = kernel.kernel_plan(host=True, work=Path("/w")) - self.assertIn("--apply-host-patch", steps[-2][1]) - self.assertNotIn("--target", steps[-1][1]) + def test_the_desktop_plan_builds_the_locked_revision_unpatched(self): + steps, binary, source = kernel.kernel_plan(host=True, work=Path("/w")) + self.assertEqual(source, f"{kernel.OCTOS_URL}@{kernel.octos_revision()}") self.assertEqual(binary, Path("/w/target/release/octos")) - overlay = json.loads((ROOT / "octos-runtime-patches.lock.json").read_text()) - self.assertEqual(overlay["base_revision"], kernel.octos_revision()) - self.assertEqual(overlay["sha256"], hashlib.sha256((ROOT / overlay["patch"]).read_bytes()).hexdigest()) + build = steps[-1][1] + self.assertIn("--locked", build) + self.assertNotIn("--target", build) + self.assertNotIn("--offline", build) + # octos ships `serve --host-managed` itself: no overlay step, no lock. + self.assertFalse(any("--apply-host-patch" in argv for _, argv in steps)) + self.assertFalse((ROOT / "octos-runtime-patches.lock.json").exists()) + self.assertFalse((ROOT / "tools/runtime-patches/octos-host-managed.patch").exists()) + + def test_an_offline_desktop_plan_stays_offline(self): + steps, _, _ = kernel.kernel_plan(host=True, work=Path("/w"), offline=True) + self.assertFalse(any(argv[:2] == ["git", "fetch"] for _, argv in steps)) + self.assertIn("--offline", steps[-1][1]) def test_the_plan_checks_out_and_cross_builds_the_kernel(self): work = Path("/w") @@ -125,40 +134,5 @@ def test_the_cli_prints_a_plan_and_runs_nothing(self): kernel.main(["--kernel", "/k/octos", "--no-kernel"]) -class OverlayTests(unittest.TestCase): - def test_apply_is_idempotent_and_refuses_changed_hash_or_revision(self): - with tempfile.TemporaryDirectory() as temp: - root = Path(temp) - src = root / "src" - src.mkdir() - def git(*args): - return subprocess.check_output(["git", *args], cwd=src, text=True).strip() - git("init", "--quiet") - (src / "sample").write_text("original\n") - git("add", "sample") - git("-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "commit", "--quiet", "-m", "fixture") - rev = git("rev-parse", "HEAD") - (src / "sample").write_text("patched\n") - overlay = root / "overlay.patch" - overlay.write_text(git("diff") + "\n") - git("restore", "sample") - lock = {"base_revision": rev, "patch": "overlay.patch", "sha256": hashlib.sha256(overlay.read_bytes()).hexdigest()} - lock_file = root / "octos-runtime-patches.lock.json" - lock_file.write_text(json.dumps(lock)) - with patch.object(kernel, "ROOT", root): - kernel.apply_host_patch(src) - kernel.apply_host_patch(src) - self.assertEqual((src / "sample").read_text(), "patched\n") - lock["base_revision"] = "0" * 40 - lock_file.write_text(json.dumps(lock)) - with self.assertRaisesRegex(RuntimeError, "new kernel pin"): - kernel.apply_host_patch(src) - lock["base_revision"] = rev - lock["sha256"] = "0" * 64 - lock_file.write_text(json.dumps(lock)) - with self.assertRaisesRegex(RuntimeError, "reviewed hash"): - kernel.apply_host_patch(src) - - if __name__ == "__main__": unittest.main() From 45d8052ea6d637fe70975e1fe3ed0bb5a112fcf3 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Mon, 28 Sep 2026 03:31:25 -0700 Subject: [PATCH 3/9] chore(octos): pin the octos#2591 head with its review fixes The external token also cannot manage host-owned app peers (octos host_owned_peer_control_denied); the real-kernel test and docs say so. Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.toml | 8 ++++---- crates/kernel/README.md | 5 +++-- crates/kernel/README.zh-CN.md | 2 +- crates/kernel/tests/real_kernel.rs | 4 ++++ docs/adr/0003-shared-octos-client-access.md | 3 ++- tools/test_kernel_artifact.py | 2 +- 6 files changed, 15 insertions(+), 9 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1634df8b..727a647a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -89,14 +89,14 @@ octosense-appstore = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub # --- octos: ONE source for every octos crate. Move octos-core, octos-cli and # octos-research together; the Android kernel artifact (liboctos.so) is built # from this same revision. TEMPORARY: the head of octos#2591 -# (`serve --host-managed`), on top of octos main at e200b072 (keeps octos#2583; +# (`serve --host-managed` plus its review fixes), on top of octos main at e200b072 (keeps octos#2583; # no octos#2567). It must move to the merged octos commit before this merges. -octos-core = { git = "https://github.com/octos-org/octos.git", rev = "2cd29e44ca4bff8c54fe1de10179b5cf29017571" } -octos-cli = { git = "https://github.com/octos-org/octos.git", rev = "2cd29e44ca4bff8c54fe1de10179b5cf29017571", default-features = false } +octos-core = { git = "https://github.com/octos-org/octos.git", rev = "27ccc8b497c520c9766ff353e78fb8ff7249e917" } +octos-cli = { git = "https://github.com/octos-org/octos.git", rev = "27ccc8b497c520c9766ff353e78fb8ff7249e917", default-features = false } # The research engine (the metasearch, the SSRF-safe polite reader and the # toolbox scope) for the toolbox's `octos-engine` feature. Same source and # revision as above. -octos-research = { git = "https://github.com/octos-org/octos.git", rev = "2cd29e44ca4bff8c54fe1de10179b5cf29017571", default-features = false } +octos-research = { git = "https://github.com/octos-org/octos.git", rev = "27ccc8b497c520c9766ff353e78fb8ff7249e917", default-features = false } # --- Rinx (Matrix chats, Moments, articles, mini apps) as a hosted module: # the library only, never its standalone entry or its own kernel (Rinx ADR 0007). rinx = { git = "https://github.com/hagency-org/Rinx.git", rev = "68afcf796d303aaf646eeb832d65c450a56c92b5", default-features = false, features = ["octosense-module"] } diff --git a/crates/kernel/README.md b/crates/kernel/README.md index d0590fe2..52267298 100644 --- a/crates/kernel/README.md +++ b/crates/kernel/README.md @@ -106,8 +106,9 @@ child above and nothing listens. **AI providers → Talk to Octos** turns it on that never leaves this process, and request octos's stdio feature set (`octos_core::ui_protocol::UI_PROTOCOL_STDIO_DEFAULT_FEATURES`); - mints an **external token**. It opens `/api/ui-protocol/ws` and nothing - else: no REST or admin route, no `server/shutdown`, and no answers to the - approvals or questions of apps' assistants (host-owned app peers); + else: no REST or admin route, no `server/shutdown`, no answers to the + approvals or questions of apps' assistants (host-owned app peers), and no + control of those peers; - keeps the listener in this process (Unix) and hands it to every kernel generation, so a restart keeps the port and no other app can take it in between. Elsewhere a restart reuses the port when it is free, and otherwise diff --git a/crates/kernel/README.zh-CN.md b/crates/kernel/README.zh-CN.md index 1b4405d6..c75e8367 100644 --- a/crates/kernel/README.zh-CN.md +++ b/crates/kernel/README.zh-CN.md @@ -93,7 +93,7 @@ Talk to Octos 让 Web 客户端或终端界面与本设备的助手对话。它* 原生使用方通过其 WebSocket 继续收发同样的帧,使用从不离开本进程的宿主令牌,并请求 octos 的 stdio 功能集(`octos_core::ui_protocol::UI_PROTOCOL_STDIO_DEFAULT_FEATURES`); - 生成一个**外部令牌**。它只能打开 `/api/ui-protocol/ws`:不能访问 REST 或管理接口, - 不能调用 `server/shutdown`,也不能回答应用助手(宿主拥有的应用 peer)的审批或提问; + 不能调用 `server/shutdown`,不能回答应用助手(宿主拥有的应用 peer)的审批或提问,也不能管理这些 peer; - 监听套接字保存在本进程中(Unix)并交给每一代内核,因此重启保持端口不变,中间也没有 其他应用能占用它。其他平台上,端口空闲时重启沿用原端口,否则换到新端口并生成新的外部令牌; - 原生使用方离开后服务仍保持运行,直到关闭该功能或 Shell 退出。内核的 stdin 是它的生命线: diff --git a/crates/kernel/tests/real_kernel.rs b/crates/kernel/tests/real_kernel.rs index 0c54d219..ce448d37 100644 --- a/crates/kernel/tests/real_kernel.rs +++ b/crates/kernel/tests/real_kernel.rs @@ -239,6 +239,10 @@ async fn talk_to_octos_admits_an_external_client_to_the_ui_protocol_only() { "session_id": peer, "approval_id": uuid::Uuid::new_v4().to_string(), "decision": "approve"})).await; assert_eq!(answer["error"]["data"]["kind"], "host_owned_peer_answer_denied", "{answer}"); } + // Nor manage host-owned app peers (octos UPCR-2026-036). + let control = ws_frame(&mut browser, "ctx", "peer/context/open", json!({ + "session_id": SYSTEM_SESSION, "peer": "rinx", "context_id": "a"})).await; + assert_eq!(control["error"]["data"]["kind"], "host_owned_peer_control_denied", "{control}"); // DNS rebinding and other local apps. let (status, _) = http(port, "GET", "/health", &format!("rebind.example:{port}"), None, "").await; assert_eq!(status, 421, "a foreign Host header is refused"); diff --git a/docs/adr/0003-shared-octos-client-access.md b/docs/adr/0003-shared-octos-client-access.md index d5aed4b0..19f2e6de 100644 --- a/docs/adr/0003-shared-octos-client-access.md +++ b/docs/adr/0003-shared-octos-client-access.md @@ -43,7 +43,8 @@ separate kernel would be a different assistant with different memory. The external token gets no REST route (403), no admin route (401), no `server/shutdown` (never offered on a host-managed server), and no answers to approvals or questions of host-owned app peers (`peer-…`/`peerctx-…` - sessions). Those belong to the person in the app, as UPCR-2026-034 already + sessions), nor any call to their control plane (creating, resuming or + binding one, changing its model, opening or closing its request contexts). Those belong to the person in the app, as UPCR-2026-034 already requires of the system agent. The external token is minted when Talk to Octos turns on, and again on **Revoke all clients** (which restarts the server, ending open connections). A new shell lifetime mints a new one. diff --git a/tools/test_kernel_artifact.py b/tools/test_kernel_artifact.py index 6ecc9865..b7487600 100644 --- a/tools/test_kernel_artifact.py +++ b/tools/test_kernel_artifact.py @@ -15,7 +15,7 @@ # The head of octos#2591 (`serve --host-managed`) until it merges; then the # merged octos commit (the root Cargo.toml says the same). -REV = "2cd29e44ca4bff8c54fe1de10179b5cf29017571" +REV = "27ccc8b497c520c9766ff353e78fb8ff7249e917" def lock_with(*revs): From efcb217960457984c32b30c271463203b3189a98 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Mon, 28 Sep 2026 04:12:55 -0700 Subject: [PATCH 4/9] fix(talk-to-octos): re-review: tokens on stdin, allowlist, Back ends pairing - M3: the kernel gets its host and external tokens as its first two stdin lines, never in its environment (/proc//environ is readable by this app's own processes, the kernel's tools included). Pins octos#2591 at 45046be0, which refuses env tokens, fences /proc/*/environ|cmdline from file tools and the shell policy, and gives external turns no code, admin or peer tools. ADR 0003 states the tool set and sandbox the profile runs. - M1/H1 (octos): external clients call an allowlist and nothing on app peers' sessions; the connect sheet, ADR 0003 section 2 and the kernel README say exactly that. Real-kernel tests check profile/llm/list, profile/llm/upsert, sub-providers, skills and snapshot/restore are refused and an app peer's session cannot be opened. - M2: the connect, pairing and export sheets define cancel(), so the phone's Back closes them through the service and pairing ends; a test requires cancel() on every service sheet. - L2: pairing on/off is serialized with an epoch: an "off" from a closed sheet never turns off a newer code. - L3: Android trusts https web origins only. - L4: apps.yml job kernel-security builds octos at the pinned revision (cached by it) and runs the kernel and app-peer real-kernel tests; a new check that Revoke all ends a live external session. - L5: the sheet warns that turning on or off, revoking and saving an origin restart the assistant and stop work in progress. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/apps.yml | 37 ++++++++++ Cargo.toml | 6 +- apps/ai-providers/host-service/src/lib.rs | 8 +- apps/ai-providers/host-service/src/sheets.rs | 10 ++- .../host-service/tests/service.rs | 3 + crates/kernel/README.md | 17 +++-- crates/kernel/README.zh-CN.md | 11 ++- crates/kernel/src/kernel.rs | 19 +++-- crates/kernel/src/lib.rs | 31 ++++++++ crates/kernel/src/network.rs | 11 ++- crates/kernel/tests/fixtures/fake_kernel.py | 6 +- crates/kernel/tests/real_kernel.rs | 37 ++++++++-- docs/adr/0003-shared-octos-client-access.md | 73 ++++++++++++++----- tools/test_kernel_artifact.py | 2 +- 14 files changed, 216 insertions(+), 55 deletions(-) diff --git a/.github/workflows/apps.yml b/.github/workflows/apps.yml index 6b7dfb08..62c3477a 100644 --- a/.github/workflows/apps.yml +++ b/.github/workflows/apps.yml @@ -81,6 +81,43 @@ jobs: cargo clippy --locked -p octosense-toolbox --all-targets --no-deps -- -D warnings cargo clippy --locked -p octosense-toolbox --all-targets --features live,octos-engine --no-deps -- -D warnings + kernel-security: + # The Talk to Octos security checks against a REAL octos: the kernel's + # real-kernel tests (what the external token must not reach, pairing, + # revoke, a SIGKILLed host) and the app-peer real-kernel tests. The octos + # binary is built at the revision Cargo.lock pins and cached by it. + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - uses: dtolnay/rust-toolchain@stable + - run: python3 tools/setup.py + - name: The pinned octos revision + id: octos + run: echo "rev=$(python3 -c 'import importlib.util as u; s=u.spec_from_file_location("k","tools/kernel-artifact.py"); m=u.module_from_spec(s); s.loader.exec_module(m); print(m.octos_revision())')" >> "$GITHUB_OUTPUT" + - uses: actions/cache@v4 + with: + path: target/octos-kernel/target/release/octos + key: octos-kernel-${{ runner.os }}-${{ steps.octos.outputs.rev }} + - name: Build octos (unless cached) + run: | + if [ ! -x target/octos-kernel/target/release/octos ]; then + python3 tools/kernel-artifact.py --host + fi + - uses: Swatinem/rust-cache@v2 + with: + shared-key: kernel-security + - name: Real-kernel tests + env: + OCTOS_CORE_TEST_KERNEL: ${{ github.workspace }}/target/octos-kernel/target/release/octos + OCTOS_APP_PEERS_TEST_KERNEL: ${{ github.workspace }}/target/octos-kernel/target/release/octos + run: | + cargo test --locked -p octosense-kernel --test real_kernel -- --test-threads=1 --nocapture + cargo test --locked -p octosense-app-peers --features octos-core --test real_kernel + apps: # macOS: the host services and AppCard link App Hub and Makepad, whose # host system-dependency set is verified on macOS (what the dev machines run). diff --git a/Cargo.toml b/Cargo.toml index 727a647a..ad43e667 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,12 +91,12 @@ octosense-appstore = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub # from this same revision. TEMPORARY: the head of octos#2591 # (`serve --host-managed` plus its review fixes), on top of octos main at e200b072 (keeps octos#2583; # no octos#2567). It must move to the merged octos commit before this merges. -octos-core = { git = "https://github.com/octos-org/octos.git", rev = "27ccc8b497c520c9766ff353e78fb8ff7249e917" } -octos-cli = { git = "https://github.com/octos-org/octos.git", rev = "27ccc8b497c520c9766ff353e78fb8ff7249e917", default-features = false } +octos-core = { git = "https://github.com/octos-org/octos.git", rev = "45046be091be988cadcb09a2bf0727e1b72f5264" } +octos-cli = { git = "https://github.com/octos-org/octos.git", rev = "45046be091be988cadcb09a2bf0727e1b72f5264", default-features = false } # The research engine (the metasearch, the SSRF-safe polite reader and the # toolbox scope) for the toolbox's `octos-engine` feature. Same source and # revision as above. -octos-research = { git = "https://github.com/octos-org/octos.git", rev = "27ccc8b497c520c9766ff353e78fb8ff7249e917", default-features = false } +octos-research = { git = "https://github.com/octos-org/octos.git", rev = "45046be091be988cadcb09a2bf0727e1b72f5264", default-features = false } # --- Rinx (Matrix chats, Moments, articles, mini apps) as a hosted module: # the library only, never its standalone entry or its own kernel (Rinx ADR 0007). rinx = { git = "https://github.com/hagency-org/Rinx.git", rev = "68afcf796d303aaf646eeb832d65c450a56c92b5", default-features = false, features = ["octosense-module"] } diff --git a/apps/ai-providers/host-service/src/lib.rs b/apps/ai-providers/host-service/src/lib.rs index 12b4a01a..92f90fe6 100644 --- a/apps/ai-providers/host-service/src/lib.rs +++ b/apps/ai-providers/host-service/src/lib.rs @@ -1002,10 +1002,14 @@ fn pairing_sheet(_lifetime: u64) -> Result { Err("This build does not include the Octos kernel service.".into()) } -/// The pairing code's sheet is up or being prepared: turn pairing off. +/// The pairing code's sheet is up or being prepared: turn pairing off. The +/// epoch is taken now, so a newer code minted before the worker runs stays. fn end_pairing() { #[cfg(feature = "octos-core")] - work(octosense_kernel::end_pairing); + { + let epoch = octosense_kernel::pairing_epoch(); + work(move || octosense_kernel::end_pairing_if(epoch)); + } } impl LlmService { diff --git a/apps/ai-providers/host-service/src/sheets.rs b/apps/ai-providers/host-service/src/sheets.rs index baf7d93f..be0b4440 100644 --- a/apps/ai-providers/host-service/src/sheets.rs +++ b/apps/ai-providers/host-service/src/sheets.rs @@ -104,6 +104,8 @@ fn poll_pair(){ } fn pair(){ status("Preparing a pairing code…") host.request("llm.sheet.client_pair", {}, fn(r){ if r.is_ok { poll_pair() } else { status(r.error) } }) } fn close(){ host.request("llm.sheet.cancel", {}, nil) } +// The host's Back (phone) calls cancel(): closing ends any pairing code. +fn cancel(){ close() } start_timeout(0.1, || refresh()) "#); script.push_str(STYLES); @@ -117,12 +119,14 @@ start_timeout(0.1, || refresh()) state := Label{text: "" draw_text.color: #x1c1c1e draw_text.text_style: theme.font_bold{font_size: 13}} } turn_on := Primary{text: "Turn on" on_click: || enable(true)} + Note{text: "Turning on restarts the assistant: work in progress, in apps too, stops."} on_box := View{visible: false width: Fill height: Fit flow: Down spacing: 8 Primary{text: "Pair a web client" on_click: || pair()} Note{text: "Shows a one-time code and a QR of the web link. A code works once, for five minutes, only while its sheet is open."} Caption{text: "Web client origin · https, or http only for localhost"} web_origin := Field{empty_text: "https://web.example"} Plain{text: "Save origin" on_click: || save_origin()} + Note{text: "Saving a changed origin restarts the assistant: work in progress, in apps too, stops."} Plain{text: "Open web client" on_click: || open_web()} Caption{text: "Server · the web client pairs with it"} origin := Field{is_read_only: true} @@ -133,10 +137,10 @@ start_timeout(0.1, || refresh()) Caption{text: "System-agent conversation · profile _main"} session := Field{is_read_only: true} Plain{text: "Revoke all clients" on_click: || rotate()} - Note{text: "Revoking gives clients a new token to pair for. Turning off stops the server; nothing then listens for external clients."} + Note{text: "Revoking disconnects every client and makes each pair again. It restarts the assistant, and Turn off does too: work in progress, in apps too, stops."} Plain{text: "Turn off" on_click: || enable(false)} } - Note{text: "External clients cannot answer approvals for apps' assistants, change settings or stop the assistant. A computer reaches this device through a tunnel that keeps the port number."} + Note{text: "A paired client can hold conversations with the assistant and answer its questions in them. It cannot see or drive the apps' assistants, change models, keys or skills, run commands, or stop the assistant. A computer reaches this device through a tunnel that keeps the port number."} status := Status{}"#, )); script @@ -167,6 +171,7 @@ fn poll(){{ }}) }} fn close(){{ host.request("llm.sheet.cancel", {{}}, nil) }} +fn cancel(){{ close() }} fn count_down(){{ if closing {{ return }} left = left - 1 @@ -927,6 +932,7 @@ fn close(){{ closing = true host.request("llm.sheet.cancel", {{}}, nil) }} +fn cancel(){{ close() }} fn count_down(){{ if closing {{ return }} left = left - 1 diff --git a/apps/ai-providers/host-service/tests/service.rs b/apps/ai-providers/host-service/tests/service.rs index 00954321..79c89884 100644 --- a/apps/ai-providers/host-service/tests/service.rs +++ b/apps/ai-providers/host-service/tests/service.rs @@ -695,6 +695,9 @@ fn no_sheet_arms_a_timer_that_outlives_it() { ] { assert!(!body.contains("start_interval"), "{body}"); assert!(!body.contains("fn tick"), "{body}"); + // The phone's Back calls cancel(): without it the sheet is only + // hidden and its service never hears that it closed. + assert!(body.contains("fn cancel("), "every sheet defines cancel(): {body}"); } assert!(sheets::export_waiting().contains("host.request(\"llm.sheet.show\", {}, nil)")); assert!(sheets::connect_client().contains("host.request(\"llm.sheet.client_pair_show\", {}, nil)")); diff --git a/crates/kernel/README.md b/crates/kernel/README.md index 52267298..03471cb8 100644 --- a/crates/kernel/README.md +++ b/crates/kernel/README.md @@ -103,12 +103,15 @@ child above and nothing listens. **AI providers → Talk to Octos** turns it on - restarts the kernel as `octos serve --host-managed` (octos [`docs/HOST_MANAGED_SERVE.md`](https://github.com/octos-org/octos/blob/main/docs/HOST_MANAGED_SERVE.md)); native consumers keep the same frames over its WebSocket with a host token - that never leaves this process, and request octos's stdio feature set + that never leaves this process (the kernel gets both tokens on its stdin, + never in its environment), and request octos's stdio feature set (`octos_core::ui_protocol::UI_PROTOCOL_STDIO_DEFAULT_FEATURES`); - mints an **external token**. It opens `/api/ui-protocol/ws` and nothing - else: no REST or admin route, no `server/shutdown`, no answers to the - approvals or questions of apps' assistants (host-owned app peers), and no - control of those peers; + else, and there only an allowlist of session, turn, answer and read-only + status methods: no configuration (providers, keys, skills, snapshots), no + `server/shutdown`, nothing on an app's assistant's sessions (host-owned app + peers), and answers only in sessions it opened. Its turns get no tool that + runs code, administers octos or reaches peers (octos UPCR-2026-036); - keeps the listener in this process (Unix) and hands it to every kernel generation, so a restart keeps the port and no other app can take it in between. Elsewhere a restart reuses the port when it is free, and otherwise @@ -124,9 +127,9 @@ How clients get in: shown with a QR of the web client's link (`/?octos=&pair=`). The code only works while that sheet is open (`end_pairing()` when it closes). The web origin saved on - the sheet is the only browser origin the server trusts: `https`, or `http` - only for localhost, 127.0.0.1 or [::1]. A malformed saved origin counts as - none; the kernel still starts. + the sheet is the only browser origin the server trusts: `https`, or on a + desktop also `http` for localhost, 127.0.0.1 or [::1] (Android: https + only). A malformed saved origin counts as none; the kernel still starts. - **Terminal.** The connection file `connection_file(core_dir)` (`/client-connection.json`, mode 0600; on Windows `%LOCALAPPDATA%\OctoSense\client-connection.json`, whose default ACL admits diff --git a/crates/kernel/README.zh-CN.md b/crates/kernel/README.zh-CN.md index c75e8367..863d5b3c 100644 --- a/crates/kernel/README.zh-CN.md +++ b/crates/kernel/README.zh-CN.md @@ -90,10 +90,13 @@ Talk to Octos 让 Web 客户端或终端界面与本设备的助手对话。它* - 内核以 `octos serve --host-managed` 重启(见 octos [`docs/HOST_MANAGED_SERVE.md`](https://github.com/octos-org/octos/blob/main/docs/HOST_MANAGED_SERVE.md),英文); - 原生使用方通过其 WebSocket 继续收发同样的帧,使用从不离开本进程的宿主令牌,并请求 + 原生使用方通过其 WebSocket 继续收发同样的帧,使用从不离开本进程的宿主令牌(内核从 stdin + 读取两个令牌,从不经过环境变量),并请求 octos 的 stdio 功能集(`octos_core::ui_protocol::UI_PROTOCOL_STDIO_DEFAULT_FEATURES`); -- 生成一个**外部令牌**。它只能打开 `/api/ui-protocol/ws`:不能访问 REST 或管理接口, - 不能调用 `server/shutdown`,不能回答应用助手(宿主拥有的应用 peer)的审批或提问,也不能管理这些 peer; +- 生成一个**外部令牌**。它只能打开 `/api/ui-protocol/ws`,且在其中只能调用白名单内的会话、 + 轮次、回答和只读状态方法:不能修改配置(服务商、密钥、技能、快照),不能调用 + `server/shutdown`,不能操作应用助手(宿主拥有的应用 peer)的会话,只能在自己打开的会话中 + 回答提问。它发起的轮次没有执行代码、管理 octos 或访问 peer 的工具(octos UPCR-2026-036); - 监听套接字保存在本进程中(Unix)并交给每一代内核,因此重启保持端口不变,中间也没有 其他应用能占用它。其他平台上,端口空闲时重启沿用原端口,否则换到新端口并生成新的外部令牌; - 原生使用方离开后服务仍保持运行,直到关闭该功能或 Shell 退出。内核的 stdin 是它的生命线: @@ -105,7 +108,7 @@ Talk to Octos 让 Web 客户端或终端界面与本设备的助手对话。它* 配对码,五分钟内有效,只能使用一次,同时显示 Web 客户端链接 (`/?octos=&pair=`)的二维码。配对码只在该面板打开期间有效 (面板关闭时调用 `end_pairing()`)。面板上保存的 Web origin 是服务唯一信任的浏览器 - 来源:`https`,或仅限 localhost、127.0.0.1、[::1] 的 `http`。保存的 origin 格式错误时视为 + 来源:`https`,桌面上还可使用 localhost、127.0.0.1、[::1] 的 `http`(Android 只允许 https)。保存的 origin 格式错误时视为 没有 origin,内核照常启动。 - **终端。** 连接文件 `connection_file(core_dir)`(`/client-connection.json`, 权限 0600;Windows 上为 `%LOCALAPPDATA%\OctoSense\client-connection.json`,其默认 ACL diff --git a/crates/kernel/src/kernel.rs b/crates/kernel/src/kernel.rs index 02b3f0b4..39c6ae8c 100644 --- a/crates/kernel/src/kernel.rs +++ b/crates/kernel/src/kernel.rs @@ -89,14 +89,16 @@ fn start(launch: &Launch, network: &Network, log: &LogSink, tail: &Tail) -> Resu } let mut secrets = Vec::new(); if shared { - let external = network.external_token(); + // The tokens go on stdin (`prepare_network`), never in the + // environment: /proc//environ is readable by every + // process of this app's user, octos's own tools included. command - .env("OCTOS_AUTH_TOKEN", network.host_token()) - .env("OCTOS_HOST_EXTERNAL_TOKEN", &external) .env("NO_COLOR", "1") + .env_remove("OCTOS_AUTH_TOKEN") + .env_remove("OCTOS_HOST_EXTERNAL_TOKEN") .env_remove("OCTOS_INSTANCE_DATA_DIR") .env_remove("OCTOS_SOLO_LOGIN"); - secrets = vec![network.host_token().to_owned(), external]; + secrets = vec![network.host_token().to_owned(), network.external_token()]; pass_listener(&mut command, network)?; } if let Some(cwd) = cwd { @@ -186,11 +188,16 @@ async fn prepare_network( io: &mut Io, network: &Network, core_dir: &std::path::Path, ready: &watch::Sender>>, ) -> Result<(), String> { - if io.lifeline.is_none() { + let Some(lifeline) = io.lifeline.as_mut() else { // The private pipe (Talk to Octos off, or the embedded core). ready.send_replace(Some(Err(CloseReason::Failed("Talk to Octos is off.".into())))); return Ok(()); - } + }; + // octos `serve --host-managed` reads its two tokens as the first two + // stdin lines; the pipe then stays open as the lifeline. + let tokens = format!("{}\n{}\n", network.host_token(), network.external_token()); + lifeline.write_all(tokens.as_bytes()).await.map_err(|e| format!("cannot hand the kernel its tokens: {e}"))?; + lifeline.flush().await.map_err(|e| format!("cannot hand the kernel its tokens: {e}"))?; tokio::time::timeout(Duration::from_secs(90), async { loop { let line = io.lines.next_line().await.map_err(|e| e.to_string())? diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 3d2aab6e..d032064e 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -169,6 +169,9 @@ struct Inner { state: Mutex, runtime: OnceLock, network: Arc, + /// Serializes pairing on and off; counts every `pairing()`, so a late + /// "off" from a closed sheet cannot turn off a newer code. + pairing_epoch: Mutex, } impl Inner { @@ -224,6 +227,7 @@ impl Core { }), runtime: OnceLock::new(), network: Arc::default(), + pairing_epoch: Mutex::new(0), })) } @@ -422,13 +426,30 @@ impl Core { /// one claim). Call while the pairing UI is open; [`Self::end_pairing`] /// when it closes. pub fn pairing(&self) -> Result { + let mut epoch = self.0.pairing_epoch.lock().unwrap(); + *epoch += 1; self.with_ready_server(Duration::from_secs(100), |access, network| { Box::pin(async move { network::start_pairing(&access, network.host_token()).await }) }) } + /// The current pairing epoch: capture it when a sheet closes and pass it + /// to [`Self::end_pairing_if`]. + pub fn pairing_epoch(&self) -> u64 { + *self.0.pairing_epoch.lock().unwrap() + } + /// Turn pairing off again (best effort; codes also expire on their own). pub fn end_pairing(&self) { + self.end_pairing_if(self.pairing_epoch()); + } + + /// Turn pairing off unless a newer code was minted since `epoch`. + pub fn end_pairing_if(&self, epoch: u64) { + let current = self.0.pairing_epoch.lock().unwrap(); + if *current != epoch { + return; + } if self.external_access() && self.status().running { let _ = self.with_ready_server(Duration::from_secs(15), |access, network| { Box::pin(async move { @@ -669,6 +690,16 @@ pub fn end_pairing() { global().end_pairing() } +/// See [`Core::pairing_epoch`]. +pub fn pairing_epoch() -> u64 { + global().pairing_epoch() +} + +/// See [`Core::end_pairing_if`] (host worker only). +pub fn end_pairing_if(epoch: u64) { + global().end_pairing_if(epoch) +} + /// A credential-free link to the system conversation (host worker only). /// The web client pairs (code or QR) before it can connect. pub fn web_client_url() -> Result { diff --git a/crates/kernel/src/network.rs b/crates/kernel/src/network.rs index b864d652..cbf16ec2 100644 --- a/crates/kernel/src/network.rs +++ b/crates/kernel/src/network.rs @@ -106,13 +106,20 @@ pub(crate) fn validate_origin(origin: &str) -> Result { let usage = "Enter a web client origin such as https://web.example or http://localhost:4173"; let url = url::Url::parse(origin).map_err(|_| usage.to_string())?; let host = url.host().ok_or_else(|| usage.to_string())?; + // On Android any app can serve http://localhost, so a phone trusts https + // origins only; a desktop also takes a local http development server. + let loopback_http = !cfg!(target_os = "android"); let scheme_ok = match url.scheme() { "https" => true, - "http" => is_loopback_host(&host), + "http" => loopback_http && is_loopback_host(&host), _ => false, }; if !scheme_ok { - return Err("Use https, or http only for localhost, 127.0.0.1 or [::1].".into()); + return Err(if loopback_http { + "Use https, or http only for localhost, 127.0.0.1 or [::1].".into() + } else { + "Use an https origin.".into() + }); } if url.host_str().is_none_or(|h| h.contains('*')) || !url.username().is_empty() diff --git a/crates/kernel/tests/fixtures/fake_kernel.py b/crates/kernel/tests/fixtures/fake_kernel.py index 97a4330f..11da95bd 100755 --- a/crates/kernel/tests/fixtures/fake_kernel.py +++ b/crates/kernel/tests/fixtures/fake_kernel.py @@ -31,7 +31,11 @@ def send(obj): line = line.strip() if not line: continue - msg = json.loads(line) + try: + msg = json.loads(line) + except ValueError: + # `serve --host-managed` gets its two token lines first; skip them. + continue method, rid, params = msg.get("method"), msg.get("id"), msg.get("params") or {} if method == "test/exit": print("fake kernel: asked to exit", file=sys.stderr, flush=True) diff --git a/crates/kernel/tests/real_kernel.rs b/crates/kernel/tests/real_kernel.rs index ce448d37..773e400b 100644 --- a/crates/kernel/tests/real_kernel.rs +++ b/crates/kernel/tests/real_kernel.rs @@ -219,8 +219,11 @@ async fn talk_to_octos_admits_an_external_client_to_the_ui_protocol_only() { let mut native = core.connect().unwrap(); assert_eq!(running_on(&mut native, "llm1").await.1, "deepseek-v4-flash"); let mut browser = external(&access).await; - let list = ws_call(&mut browser, "llm1", "profile/llm/list", json!({"profile_id":"_main"})).await; - assert_eq!(list["primary"]["model"], "deepseek-v4-flash"); + let open = ws_call(&mut browser, "open", "session/open", json!({"session_id": SYSTEM_SESSION, "profile_id": "_main"})).await; + assert_eq!(open["opened"]["session_id"], SYSTEM_SESSION); + // Profile configuration is not the external client's, not even to read. + let list = ws_frame(&mut browser, "llm1", "profile/llm/list", json!({"profile_id":"_main"})).await; + assert_eq!(list["error"]["data"]["kind"], "external_method_denied", "{list}"); // What the external token must not reach. for path in ["/api/admin/overview", "/api/admin/stop-all", "/api/admin/token/rotate", "/api/admin/host/pairing"] { @@ -242,7 +245,16 @@ async fn talk_to_octos_admits_an_external_client_to_the_ui_protocol_only() { // Nor manage host-owned app peers (octos UPCR-2026-036). let control = ws_frame(&mut browser, "ctx", "peer/context/open", json!({ "session_id": SYSTEM_SESSION, "peer": "rinx", "context_id": "a"})).await; - assert_eq!(control["error"]["data"]["kind"], "host_owned_peer_control_denied", "{control}"); + assert_eq!(control["error"]["data"]["kind"], "external_method_denied", "{control}"); + // Nor configure providers (a redirected base_url would receive the key), + // skills or snapshots, nor open an app peer's session. + for method in ["profile/llm/upsert", "profile/sub_providers/upsert", "profile/skills/install", "snapshot/restore"] { + let denied = ws_frame(&mut browser, method, method, json!({"profile_id": "_main"})).await; + assert_eq!(denied["error"]["data"]["kind"], "external_method_denied", "{method}: {denied}"); + } + let peer_open = ws_frame(&mut browser, "peer-open", "session/open", json!({ + "session_id": "_main:api:octosense#peer-rinx", "profile_id": "_main"})).await; + assert_eq!(peer_open["error"]["data"]["kind"], "host_owned_peer_session_denied", "{peer_open}"); // DNS rebinding and other local apps. let (status, _) = http(port, "GET", "/health", &format!("rebind.example:{port}"), None, "").await; assert_eq!(status, 421, "a foreign Host header is refused"); @@ -283,21 +295,32 @@ async fn talk_to_octos_admits_an_external_client_to_the_ui_protocol_only() { assert_eq!(restarted.origin, access.origin, "the same port"); assert_eq!(restarted.token, access.token, "the same external token"); let mut browser = external(&restarted).await; - let list = ws_call(&mut browser, "llm2", "profile/llm/list", json!({"profile_id":"_main"})).await; - assert_eq!(list["primary"]["model"], "kimi-k2.5"); + ws_call(&mut browser, "open2", "session/open", json!({"session_id": SYSTEM_SESSION, "profile_id": "_main"})).await; + let mut native_check = core.connect().unwrap(); + assert_eq!(running_on(&mut native_check, "llm2").await.1, "kimi-k2.5"); + drop(native_check); drop(native); assert!(core.status().running, "external clients keep the server up"); - // Rotating retires the old token. + // Rotating retires the old token and ends the live connection. let c = core.clone(); blocking(move || c.rotate_external_access()).await.unwrap(); + let ended = tokio::time::timeout(Duration::from_secs(30), async { + loop { + match browser.next().await { + None | Some(Err(_)) | Some(Ok(Message::Close(_))) => break, + Some(Ok(_)) => {} + } + } + }) + .await; + assert!(ended.is_ok(), "Revoke all ends a live external session"); let c = core.clone(); let rotated = blocking(move || c.client_access()).await.unwrap(); assert_ne!(rotated.token, access.token); let mut old = format!("{}?token={}", access.endpoint(), access.token).into_client_request().unwrap(); old.headers_mut().insert("Origin", WEB.parse().unwrap()); assert_eq!(ws_refused(old).await, 401, "the old token is dead"); - drop(browser); // Turning it off stops external access: nothing listens any more. let c = core.clone(); diff --git a/docs/adr/0003-shared-octos-client-access.md b/docs/adr/0003-shared-octos-client-access.md index 19f2e6de..f8dbca1a 100644 --- a/docs/adr/0003-shared-octos-client-access.md +++ b/docs/adr/0003-shared-octos-client-access.md @@ -33,32 +33,61 @@ separate kernel would be a different assistant with different memory. providers). Only then does the shell restart it as `octos serve --host-managed --host 127.0.0.1`. Turning it off restarts the kernel on the pipe: nothing listens, and the connection file is removed. -2. **Two tokens.** +2. **Two tokens, delivered on stdin.** The shell writes both as the first + two lines of the kernel's stdin; they never enter an environment or a + command line. `/proc//environ` is readable by every process of the + same user, and on Android that includes the kernel's own tools. | Token | Holder | Grants | | --- | --- | --- | - | Host token | the shell process only, passed in the child's environment | everything a native consumer had on stdio (octos admin) | - | External token | a paired web client, or a terminal client of this user through the 0600 connection file | `/api/ui-protocol/ws` only, as user `_main` | - - The external token gets no REST route (403), no admin route (401), no - `server/shutdown` (never offered on a host-managed server), and no answers - to approvals or questions of host-owned app peers (`peer-…`/`peerctx-…` - sessions), nor any call to their control plane (creating, resuming or - binding one, changing its model, opening or closing its request contexts). Those belong to the person in the app, as UPCR-2026-034 already - requires of the system agent. The external token is minted when Talk to - Octos turns on, and again on **Revoke all clients** (which restarts the - server, ending open connections). A new shell lifetime mints a new one. - Tokens are never printed, logged, copied to the clipboard or put in a - command line. + | Host token | the shell process only | everything a native consumer had on stdio (octos admin) | + | External token | a paired web client, or a terminal client of this user through the 0600 connection file | `/api/ui-protocol/ws` only, as user `_main`, and there only an allowlist of methods | + + The external token gets no REST route (403) and no admin route (401). On + the socket it may call only these methods: `config/capabilities/list`, + `session/status/read`, `system/status.get`, `session/open`, + `session/hydrate`, `session/messages_page`, `session/status.get`, + `turn/start`, `turn/interrupt`, `turn/steer`, `turn/state/get`, + `approval/respond`, `approval/scopes/list`, `user_question/respond` and + `diff/preview/get`. Everything else is refused. That covers provider, key + and model configuration (a redirected `base_url` would otherwise receive + the stored key), skills, snapshots, session fork and delete, every peer + method and `server/shutdown`. It also cannot name an app peer's session + (`peer-…`/`peerctx-…`) in any call. Those sessions carry the apps' memory + and workspaces, and their approvals and questions belong to the person in + the app, as UPCR-2026-034 already requires of the system agent. It answers + prompts only on sessions it opened itself. A turn it starts gets no tool + that runs code or commands (`shell`, `exec_command`, `spawn*`, `browser`, + `git`, …), administers the server, profiles or skills, delegates, or + reaches peers (`peer_*`). The model therefore cannot drive the apps' + assistants or read the host's processes on its behalf. + + The external token is minted when Talk to Octos turns on, and again on + **Revoke all clients** (which restarts the server, ending open + connections). A new shell lifetime mints a new one. Tokens are never + printed, logged or copied to the clipboard. + + **What the profile runs.** OctoSense configures neither the tool set nor + the sandbox: the kernel runs octos's defaults. That means all built-in + tools, file tools fenced to the session workspace (plus the a2app memory + read-zone on Android), and sandbox `Auto`: Seatbelt on macOS, bubblewrap or + Landlock on Linux when present, and on Android usually none, so the shell + tool runs unconfined as the app's user. Hence the tool restriction above + for external turns. For every session, no file tool opens + `/proc//environ` or `/proc//cmdline`, and the shell policy + refuses commands naming them. 3. **Pairing, not copying.** A web client gets the external token only through octos's pairing: an 8-character code shown on the trusted sheet (with a QR of the web client's link), valid for five minutes and one claim. It works only while the sheet is open; the shell turns pairing off when the - sheet closes. There is no "copy token" action. + sheet closes (including the phone's Back, which calls the sheet's + `cancel()`), in order: a late "off" cannot cancel a newer code. Failed + claims are rate-limited, not burned, and every claim is audited without the + code. There is no "copy token" action. 4. **Browser guards.** The server answers only requests whose `Host` names its own loopback listener (DNS rebinding). It trusts only the web origin - the person saved: `https`, or `http` only for localhost, 127.0.0.1 or - [::1]. A malformed saved origin counts as none, and the kernel still + the person saved: `https`, or on a desktop also `http` for localhost, + 127.0.0.1 or [::1] (never on Android, where any app can serve localhost). A malformed saved origin counts as none, and the kernel still starts. Origin protects a browser that holds a token from other pages. It is not authentication; the token is. 5. **The host owns the lifecycle.** The kernel's stdin is its lifeline: when @@ -110,8 +139,10 @@ Real-kernel tests (a scripted local model, no external calls) check that: - nothing listens while Talk to Octos is off; - when it is on, the external token gets 401/403 on `/api/admin/*` and REST, - cannot call `server/shutdown` (not advertised either), and cannot answer a - peer approval; + cannot call `server/shutdown` (not advertised either), provider, skill or + snapshot methods, or profile reads, cannot open an app peer's session, and + cannot answer a peer approval; +- **Revoke all clients** ends a live external session; - a foreign `Host`, a missing token, a spoofed profile header, solo login and untrusted origins are refused; - pairing is single use and hands out the external token; @@ -119,5 +150,7 @@ 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. -The six app-peer real-kernel tests pass with Talk to Octos off. A +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 hidden-window desktop run turned Talk to Octos on and off from the sheet. diff --git a/tools/test_kernel_artifact.py b/tools/test_kernel_artifact.py index b7487600..1baefa21 100644 --- a/tools/test_kernel_artifact.py +++ b/tools/test_kernel_artifact.py @@ -15,7 +15,7 @@ # The head of octos#2591 (`serve --host-managed`) until it merges; then the # merged octos commit (the root Cargo.toml says the same). -REV = "27ccc8b497c520c9766ff353e78fb8ff7249e917" +REV = "45046be091be988cadcb09a2bf0727e1b72f5264" def lock_with(*revs): From 0da6edfb940739d5fffa3b4c401ad48f558ddc16 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Mon, 28 Sep 2026 05:18:08 -0700 Subject: [PATCH 5/9] chore(octos): pin octos#2591 at 8925ac33 (default-deny external tools, _main only) ADR 0003 section 2 follows: external turns keep a fixed allowlist of built-in tools (no MCP or plugin tools either), and external calls name no profile but _main. Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.toml | 6 +++--- docs/adr/0003-shared-octos-client-access.md | 11 ++++++----- tools/test_kernel_artifact.py | 2 +- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index ad43e667..7da9a312 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,12 +91,12 @@ octosense-appstore = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub # from this same revision. TEMPORARY: the head of octos#2591 # (`serve --host-managed` plus its review fixes), on top of octos main at e200b072 (keeps octos#2583; # no octos#2567). It must move to the merged octos commit before this merges. -octos-core = { git = "https://github.com/octos-org/octos.git", rev = "45046be091be988cadcb09a2bf0727e1b72f5264" } -octos-cli = { git = "https://github.com/octos-org/octos.git", rev = "45046be091be988cadcb09a2bf0727e1b72f5264", default-features = false } +octos-core = { git = "https://github.com/octos-org/octos.git", rev = "8925ac336cacea7e71e419f850a80060e11501e7" } +octos-cli = { git = "https://github.com/octos-org/octos.git", rev = "8925ac336cacea7e71e419f850a80060e11501e7", default-features = false } # The research engine (the metasearch, the SSRF-safe polite reader and the # toolbox scope) for the toolbox's `octos-engine` feature. Same source and # revision as above. -octos-research = { git = "https://github.com/octos-org/octos.git", rev = "45046be091be988cadcb09a2bf0727e1b72f5264", default-features = false } +octos-research = { git = "https://github.com/octos-org/octos.git", rev = "8925ac336cacea7e71e419f850a80060e11501e7", default-features = false } # --- Rinx (Matrix chats, Moments, articles, mini apps) as a hosted module: # the library only, never its standalone entry or its own kernel (Rinx ADR 0007). rinx = { git = "https://github.com/hagency-org/Rinx.git", rev = "68afcf796d303aaf646eeb832d65c450a56c92b5", default-features = false, features = ["octosense-module"] } diff --git a/docs/adr/0003-shared-octos-client-access.md b/docs/adr/0003-shared-octos-client-access.md index f8dbca1a..a41600b6 100644 --- a/docs/adr/0003-shared-octos-client-access.md +++ b/docs/adr/0003-shared-octos-client-access.md @@ -56,11 +56,12 @@ separate kernel would be a different assistant with different memory. (`peer-…`/`peerctx-…`) in any call. Those sessions carry the apps' memory and workspaces, and their approvals and questions belong to the person in the app, as UPCR-2026-034 already requires of the system agent. It answers - prompts only on sessions it opened itself. A turn it starts gets no tool - that runs code or commands (`shell`, `exec_command`, `spawn*`, `browser`, - `git`, …), administers the server, profiles or skills, delegates, or - reaches peers (`peer_*`). The model therefore cannot drive the apps' - assistants or read the host's processes on its behalf. + prompts only on sessions it opened itself, and names no profile but + `_main`. A turn it starts keeps only a fixed allowlist of built-in + workspace, web, question and memory tools. Everything else is absent: no + command or code execution, delegation, administration, peers (`peer_*`), + MCP or plugin tools. The model therefore cannot drive the apps' assistants + or read the host's processes on its behalf. The external token is minted when Talk to Octos turns on, and again on **Revoke all clients** (which restarts the server, ending open diff --git a/tools/test_kernel_artifact.py b/tools/test_kernel_artifact.py index 1baefa21..1c4a8b0b 100644 --- a/tools/test_kernel_artifact.py +++ b/tools/test_kernel_artifact.py @@ -15,7 +15,7 @@ # The head of octos#2591 (`serve --host-managed`) until it merges; then the # merged octos commit (the root Cargo.toml says the same). -REV = "45046be091be988cadcb09a2bf0727e1b72f5264" +REV = "8925ac336cacea7e71e419f850a80060e11501e7" def lock_with(*revs): From 296d9bf02fda869d286189dc88bd4447cd8446ac Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Mon, 28 Sep 2026 05:39:17 -0700 Subject: [PATCH 6/9] ci(kernel-security): build octos outside target/, which rust-cache prunes Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/apps.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/apps.yml b/.github/workflows/apps.yml index 62c3477a..55f49f0e 100644 --- a/.github/workflows/apps.yml +++ b/.github/workflows/apps.yml @@ -98,22 +98,23 @@ jobs: - name: The pinned octos revision id: octos run: echo "rev=$(python3 -c 'import importlib.util as u; s=u.spec_from_file_location("k","tools/kernel-artifact.py"); m=u.module_from_spec(s); s.loader.exec_module(m); print(m.octos_revision())')" >> "$GITHUB_OUTPUT" + # Outside target/: rust-cache restores and prunes target/. - uses: actions/cache@v4 with: - path: target/octos-kernel/target/release/octos + path: ${{ runner.temp }}/octos-kernel/target/release/octos key: octos-kernel-${{ runner.os }}-${{ steps.octos.outputs.rev }} - name: Build octos (unless cached) run: | - if [ ! -x target/octos-kernel/target/release/octos ]; then - python3 tools/kernel-artifact.py --host + if [ ! -x "$RUNNER_TEMP/octos-kernel/target/release/octos" ]; then + python3 tools/kernel-artifact.py --host --work "$RUNNER_TEMP/octos-kernel" fi - uses: Swatinem/rust-cache@v2 with: shared-key: kernel-security - name: Real-kernel tests env: - OCTOS_CORE_TEST_KERNEL: ${{ github.workspace }}/target/octos-kernel/target/release/octos - OCTOS_APP_PEERS_TEST_KERNEL: ${{ github.workspace }}/target/octos-kernel/target/release/octos + OCTOS_CORE_TEST_KERNEL: ${{ runner.temp }}/octos-kernel/target/release/octos + OCTOS_APP_PEERS_TEST_KERNEL: ${{ runner.temp }}/octos-kernel/target/release/octos run: | cargo test --locked -p octosense-kernel --test real_kernel -- --test-threads=1 --nocapture cargo test --locked -p octosense-app-peers --features octos-core --test real_kernel From 802f0fbed0759de787503a8005b7361b928cd63a Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Mon, 28 Sep 2026 07:20:26 -0700 Subject: [PATCH 7/9] fix(talk-to-octos): third review: pin octos#2591 at 81d2180b, exact ADR - Pins octos#2591 at 81d2180b (on octos main 5c5b6a53): the external tool allowlist applies to the finished per-turn registry; external clients cannot pass topic, cwd or sandbox, steer, interrupt or answer only their own turns (approvals once-only), never negotiate workspace_cwd, and run no continuations; process views (/proc/self, /proc//..., /dev/fd) are fenced from file tools on the canonical path. - Real-kernel test: the browser's cwd is refused and it lands in the saved system workspace; it cannot interrupt the host's turn. - ADR 0003 section 2 now states exactly what is enforced; the sheet's text matches. Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.toml | 8 +-- apps/ai-providers/host-service/src/sheets.rs | 2 +- crates/kernel/tests/real_kernel.rs | 18 +++++-- docs/adr/0003-shared-octos-client-access.md | 52 +++++++++++++------- tools/test_kernel_artifact.py | 2 +- 5 files changed, 54 insertions(+), 28 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 7da9a312..70fc31c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -89,14 +89,14 @@ octosense-appstore = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub # --- octos: ONE source for every octos crate. Move octos-core, octos-cli and # octos-research together; the Android kernel artifact (liboctos.so) is built # from this same revision. TEMPORARY: the head of octos#2591 -# (`serve --host-managed` plus its review fixes), on top of octos main at e200b072 (keeps octos#2583; +# (`serve --host-managed` plus its review fixes), on top of octos main at 5c5b6a53 (keeps octos#2583; # no octos#2567). It must move to the merged octos commit before this merges. -octos-core = { git = "https://github.com/octos-org/octos.git", rev = "8925ac336cacea7e71e419f850a80060e11501e7" } -octos-cli = { git = "https://github.com/octos-org/octos.git", rev = "8925ac336cacea7e71e419f850a80060e11501e7", default-features = false } +octos-core = { git = "https://github.com/octos-org/octos.git", rev = "81d2180bbeec4eaabc598aeded67a5f5a8bd55f5" } +octos-cli = { git = "https://github.com/octos-org/octos.git", rev = "81d2180bbeec4eaabc598aeded67a5f5a8bd55f5", default-features = false } # The research engine (the metasearch, the SSRF-safe polite reader and the # toolbox scope) for the toolbox's `octos-engine` feature. Same source and # revision as above. -octos-research = { git = "https://github.com/octos-org/octos.git", rev = "8925ac336cacea7e71e419f850a80060e11501e7", default-features = false } +octos-research = { git = "https://github.com/octos-org/octos.git", rev = "81d2180bbeec4eaabc598aeded67a5f5a8bd55f5", default-features = false } # --- Rinx (Matrix chats, Moments, articles, mini apps) as a hosted module: # the library only, never its standalone entry or its own kernel (Rinx ADR 0007). rinx = { git = "https://github.com/hagency-org/Rinx.git", rev = "68afcf796d303aaf646eeb832d65c450a56c92b5", default-features = false, features = ["octosense-module"] } diff --git a/apps/ai-providers/host-service/src/sheets.rs b/apps/ai-providers/host-service/src/sheets.rs index be0b4440..e0d6e55d 100644 --- a/apps/ai-providers/host-service/src/sheets.rs +++ b/apps/ai-providers/host-service/src/sheets.rs @@ -140,7 +140,7 @@ start_timeout(0.1, || refresh()) Note{text: "Revoking disconnects every client and makes each pair again. It restarts the assistant, and Turn off does too: work in progress, in apps too, stops."} Plain{text: "Turn off" on_click: || enable(false)} } - Note{text: "A paired client can hold conversations with the assistant and answer its questions in them. It cannot see or drive the apps' assistants, change models, keys or skills, run commands, or stop the assistant. A computer reaches this device through a tunnel that keeps the port number."} + Note{text: "A paired client talks with the assistant in the system conversation and answers only its own requests' questions. It cannot see or drive the apps' assistants, touch this device's turns, change models, keys or skills, run commands or git, or stop the assistant. A computer reaches this device through a tunnel that keeps the port number."} status := Status{}"#, )); script diff --git a/crates/kernel/tests/real_kernel.rs b/crates/kernel/tests/real_kernel.rs index 773e400b..9d532818 100644 --- a/crates/kernel/tests/real_kernel.rs +++ b/crates/kernel/tests/real_kernel.rs @@ -408,10 +408,14 @@ async fn native_and_browser_talk_to_the_same_system_agent() { let mut browser = external(&access).await; let open = json!({"session_id":SYSTEM_SESSION,"profile_id":"_main"}); call(&mut native, "open", "session/open", open.clone()).await; - let mut web_open = open; + // An external client never chooses the workspace: a cwd is refused, and + // without one it lands in the workspace the system session is bound to. + let mut web_open = open.clone(); web_open["cwd"] = reference[0].clone(); - let opened = ws_call(&mut browser, "open", "session/open", web_open).await; - assert_eq!(opened["opened"]["workspace_root"], reference[0], "Web confirms the canonical saved-link workspace"); + let refused = ws_frame(&mut browser, "open-cwd", "session/open", web_open).await; + assert_eq!(refused["error"]["data"]["kind"], "external_parameter_denied", "{refused}"); + let opened = ws_call(&mut browser, "open", "session/open", open).await; + assert_eq!(opened["opened"]["workspace_root"], reference[0], "Web lands in the system workspace"); let input = |text| json!({"session_id":SYSTEM_SESSION,"turn_id":uuid::Uuid::new_v4(), "input":[{"kind":"text","text":text}]}); let first = input("hello from native"); @@ -432,6 +436,10 @@ async fn native_and_browser_talk_to_the_same_system_agent() { tokio::time::sleep(Duration::from_millis(100)).await; } }).await.expect("native turn completed"); + // The browser cannot interrupt the host's turn on the shared session. + let interrupt = ws_frame(&mut browser, "int", "turn/interrupt", json!({ + "session_id":SYSTEM_SESSION,"turn_id":first["turn_id"]})).await; + assert_eq!(interrupt["error"]["data"]["kind"], "external_turn_denied", "{interrupt}"); ws_call(&mut browser, "turn", "turn/start", input("hello from browser")).await; tokio::time::timeout(Duration::from_secs(30), async { loop { @@ -442,8 +450,8 @@ async fn native_and_browser_talk_to_the_same_system_agent() { }).await.expect("native client sees browser turn"); drop((native, browser)); core.shutdown_within(Duration::from_secs(15)); - // A Web cwd makes the session persistently scoped. After a full host - // restart with Talk to Octos off, native app peers still resume it. + // After a full host restart with Talk to Octos off, native app peers + // still resume the system session in its saved workspace. std::fs::remove_file(dir.join("external-access.json")).unwrap(); let restarted = Core::new(Options::default().program(&program).core_dir(&dir)); let mut native = restarted.connect().unwrap(); diff --git a/docs/adr/0003-shared-octos-client-access.md b/docs/adr/0003-shared-octos-client-access.md index a41600b6..142fce9b 100644 --- a/docs/adr/0003-shared-octos-client-access.md +++ b/docs/adr/0003-shared-octos-client-access.md @@ -49,19 +49,36 @@ separate kernel would be a different assistant with different memory. `session/hydrate`, `session/messages_page`, `session/status.get`, `turn/start`, `turn/interrupt`, `turn/steer`, `turn/state/get`, `approval/respond`, `approval/scopes/list`, `user_question/respond` and - `diff/preview/get`. Everything else is refused. That covers provider, key - and model configuration (a redirected `base_url` would otherwise receive - the stored key), skills, snapshots, session fork and delete, every peer - method and `server/shutdown`. It also cannot name an app peer's session - (`peer-…`/`peerctx-…`) in any call. Those sessions carry the apps' memory - and workspaces, and their approvals and questions belong to the person in - the app, as UPCR-2026-034 already requires of the system agent. It answers - prompts only on sessions it opened itself, and names no profile but - `_main`. A turn it starts keeps only a fixed allowlist of built-in - workspace, web, question and memory tools. Everything else is absent: no - command or code execution, delegation, administration, peers (`peer_*`), - MCP or plugin tools. The model therefore cannot drive the apps' assistants - or read the host's processes on its behalf. + `diff/preview/get`. Every other method fails with `external_method_denied`. + That includes provider, key and model configuration (a redirected + `base_url` would otherwise receive the stored key), skills, snapshots, + session fork and delete, every peer method and `server/shutdown`. + Within those methods: + + - no session of an app's assistant (`peer-…`/`peerctx-…`, under any + `*session*` key at any depth) and no profile but `_main`; + - no `topic`, `cwd` or `sandbox` parameter and no local media. The web + client's sessions stay in the workspace octos bound them to; for the + system conversation that is the saved system workspace; + - `turn/interrupt`, `turn/steer`, `approval/respond` and + `user_question/respond` only for turns this connection started. On the + shared system conversation the host's own turns are untouchable, and + an external approval is once-only (no approval scope is recorded); + - a turn it starts gets exactly this tool set, filtered on the finished + per-turn registry: `read_file`, `write_file`, `edit_file`, `diff_edit`, + `apply_patch`, `glob`, `grep`, `list_dir`, `code_structure`, + `check_workspace_contract`, `web_search`, `web_fetch`, + `ask_user_question`, `recall`, `recall_memory`, `memory_search`, + `memory_load`, `view_image`, `view_video` and `tool_search` (those the + kernel has registered). That means no shell or other code execution, + no git, no `spawn` or delegation, no `peer_*`, `send_file`, task, MCP or + plugin tool. The memory tools read the system agent's memory, not the + apps'. `web_fetch` refuses loopback (this server's port included), + private, link-local and metadata addresses; + - it runs no background continuations; the host's connection does. + + The model therefore cannot drive the apps' assistants or read the host's + processes on the external client's behalf. The external token is minted when Talk to Octos turns on, and again on **Revoke all clients** (which restarts the server, ending open @@ -73,10 +90,11 @@ separate kernel would be a different assistant with different memory. tools, file tools fenced to the session workspace (plus the a2app memory read-zone on Android), and sandbox `Auto`: Seatbelt on macOS, bubblewrap or Landlock on Linux when present, and on Android usually none, so the shell - tool runs unconfined as the app's user. Hence the tool restriction above - for external turns. For every session, no file tool opens - `/proc//environ` or `/proc//cmdline`, and the shell policy - refuses commands naming them. + tool runs unconfined as the app's user. Hence the external turn has no + shell at all. For every session, no file tool opens a process's private + view (`/proc/self`, `/proc//…`, `/dev/fd`), judged on the raw, + normalized and canonical path. The shell policy's text check on + `/proc//environ` is only a backstop for the host's own turns. 3. **Pairing, not copying.** A web client gets the external token only through octos's pairing: an 8-character code shown on the trusted sheet (with a QR of the web client's link), valid for five minutes and one claim. diff --git a/tools/test_kernel_artifact.py b/tools/test_kernel_artifact.py index 1c4a8b0b..fba96289 100644 --- a/tools/test_kernel_artifact.py +++ b/tools/test_kernel_artifact.py @@ -15,7 +15,7 @@ # The head of octos#2591 (`serve --host-managed`) until it merges; then the # merged octos commit (the root Cargo.toml says the same). -REV = "8925ac336cacea7e71e419f850a80060e11501e7" +REV = "81d2180bbeec4eaabc598aeded67a5f5a8bd55f5" def lock_with(*revs): From a11aa92c4ee76b077848ac9482d41e5d0548ec9b Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Mon, 28 Sep 2026 08:07:16 -0700 Subject: [PATCH 8/9] chore(octos): pin octos#2591 head 64a233c7 (test-only change over 81d2180b) Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.toml | 6 +++--- tools/test_kernel_artifact.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 70fc31c6..1182cb73 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,12 +91,12 @@ octosense-appstore = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub # from this same revision. TEMPORARY: the head of octos#2591 # (`serve --host-managed` plus its review fixes), on top of octos main at 5c5b6a53 (keeps octos#2583; # no octos#2567). It must move to the merged octos commit before this merges. -octos-core = { git = "https://github.com/octos-org/octos.git", rev = "81d2180bbeec4eaabc598aeded67a5f5a8bd55f5" } -octos-cli = { git = "https://github.com/octos-org/octos.git", rev = "81d2180bbeec4eaabc598aeded67a5f5a8bd55f5", default-features = false } +octos-core = { git = "https://github.com/octos-org/octos.git", rev = "64a233c7483601907819a87e0a5c49ff905145c7" } +octos-cli = { git = "https://github.com/octos-org/octos.git", rev = "64a233c7483601907819a87e0a5c49ff905145c7", default-features = false } # The research engine (the metasearch, the SSRF-safe polite reader and the # toolbox scope) for the toolbox's `octos-engine` feature. Same source and # revision as above. -octos-research = { git = "https://github.com/octos-org/octos.git", rev = "81d2180bbeec4eaabc598aeded67a5f5a8bd55f5", default-features = false } +octos-research = { git = "https://github.com/octos-org/octos.git", rev = "64a233c7483601907819a87e0a5c49ff905145c7", default-features = false } # --- Rinx (Matrix chats, Moments, articles, mini apps) as a hosted module: # the library only, never its standalone entry or its own kernel (Rinx ADR 0007). rinx = { git = "https://github.com/hagency-org/Rinx.git", rev = "68afcf796d303aaf646eeb832d65c450a56c92b5", default-features = false, features = ["octosense-module"] } diff --git a/tools/test_kernel_artifact.py b/tools/test_kernel_artifact.py index fba96289..f592def6 100644 --- a/tools/test_kernel_artifact.py +++ b/tools/test_kernel_artifact.py @@ -15,7 +15,7 @@ # The head of octos#2591 (`serve --host-managed`) until it merges; then the # merged octos commit (the root Cargo.toml says the same). -REV = "81d2180bbeec4eaabc598aeded67a5f5a8bd55f5" +REV = "64a233c7483601907819a87e0a5c49ff905145c7" def lock_with(*revs): From 6eee96d078ed529b6aeb86aed91236dcd642092e Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:04:26 -0700 Subject: [PATCH 9/9] chore(octos): pin octos main at the merge of #2591 (e6223efc) Every octos crate (octos-core, octos-cli, octos-research) moves from the temporary #2591 head to its merge commit on octos main, e6223efc (same tree as 64a233c7; contains 3b5d17a4 from #107). The READMEs and the kernel-artifact test name the new rev. Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 36 ++++++++++++++++++----------------- Cargo.toml | 15 +++++++-------- apps/README.md | 4 ++-- apps/README.zh-CN.md | 4 ++-- apps/appcard/README.md | 2 +- apps/appcard/README.zh-CN.md | 2 +- crates/toolbox/README.md | 2 +- tools/test_kernel_artifact.py | 4 +--- 8 files changed, 34 insertions(+), 35 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 045e9315..0f52e019 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6691,7 +6691,7 @@ dependencies = [ [[package]] name = "octos-agent" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=3b5d17a435da8ba9d1fcccbfedce46c357819a3a#3b5d17a435da8ba9d1fcccbfedce46c357819a3a" +source = "git+https://github.com/octos-org/octos.git?rev=e6223efcaa23daa22b876eb734ae98aa400c6240#e6223efcaa23daa22b876eb734ae98aa400c6240" dependencies = [ "async-trait", "base64 0.22.1", @@ -6820,7 +6820,7 @@ dependencies = [ [[package]] name = "octos-bus" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=3b5d17a435da8ba9d1fcccbfedce46c357819a3a#3b5d17a435da8ba9d1fcccbfedce46c357819a3a" +source = "git+https://github.com/octos-org/octos.git?rev=e6223efcaa23daa22b876eb734ae98aa400c6240#e6223efcaa23daa22b876eb734ae98aa400c6240" dependencies = [ "aes 0.8.4", "async-trait", @@ -6849,7 +6849,7 @@ dependencies = [ [[package]] name = "octos-cli" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=3b5d17a435da8ba9d1fcccbfedce46c357819a3a#3b5d17a435da8ba9d1fcccbfedce46c357819a3a" +source = "git+https://github.com/octos-org/octos.git?rev=e6223efcaa23daa22b876eb734ae98aa400c6240#e6223efcaa23daa22b876eb734ae98aa400c6240" dependencies = [ "agent-client-protocol", "argon2", @@ -6930,7 +6930,7 @@ dependencies = [ [[package]] name = "octos-core" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=3b5d17a435da8ba9d1fcccbfedce46c357819a3a#3b5d17a435da8ba9d1fcccbfedce46c357819a3a" +source = "git+https://github.com/octos-org/octos.git?rev=e6223efcaa23daa22b876eb734ae98aa400c6240#e6223efcaa23daa22b876eb734ae98aa400c6240" dependencies = [ "chrono", "eyre", @@ -6944,7 +6944,7 @@ dependencies = [ [[package]] name = "octos-diagnostics" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=3b5d17a435da8ba9d1fcccbfedce46c357819a3a#3b5d17a435da8ba9d1fcccbfedce46c357819a3a" +source = "git+https://github.com/octos-org/octos.git?rev=e6223efcaa23daa22b876eb734ae98aa400c6240#e6223efcaa23daa22b876eb734ae98aa400c6240" dependencies = [ "eyre", "octos-core", @@ -6956,7 +6956,7 @@ dependencies = [ [[package]] name = "octos-fleet" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=3b5d17a435da8ba9d1fcccbfedce46c357819a3a#3b5d17a435da8ba9d1fcccbfedce46c357819a3a" +source = "git+https://github.com/octos-org/octos.git?rev=e6223efcaa23daa22b876eb734ae98aa400c6240#e6223efcaa23daa22b876eb734ae98aa400c6240" dependencies = [ "eyre", "octos-core", @@ -6972,7 +6972,7 @@ dependencies = [ [[package]] name = "octos-fleet-worker" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=3b5d17a435da8ba9d1fcccbfedce46c357819a3a#3b5d17a435da8ba9d1fcccbfedce46c357819a3a" +source = "git+https://github.com/octos-org/octos.git?rev=e6223efcaa23daa22b876eb734ae98aa400c6240#e6223efcaa23daa22b876eb734ae98aa400c6240" dependencies = [ "async-trait", "eyre", @@ -6989,7 +6989,7 @@ dependencies = [ [[package]] name = "octos-llm" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=3b5d17a435da8ba9d1fcccbfedce46c357819a3a#3b5d17a435da8ba9d1fcccbfedce46c357819a3a" +source = "git+https://github.com/octos-org/octos.git?rev=e6223efcaa23daa22b876eb734ae98aa400c6240#e6223efcaa23daa22b876eb734ae98aa400c6240" dependencies = [ "async-trait", "base64 0.22.1", @@ -7013,7 +7013,7 @@ dependencies = [ [[package]] name = "octos-memory" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=3b5d17a435da8ba9d1fcccbfedce46c357819a3a#3b5d17a435da8ba9d1fcccbfedce46c357819a3a" +source = "git+https://github.com/octos-org/octos.git?rev=e6223efcaa23daa22b876eb734ae98aa400c6240#e6223efcaa23daa22b876eb734ae98aa400c6240" dependencies = [ "bincode", "chrono", @@ -7032,7 +7032,7 @@ dependencies = [ [[package]] name = "octos-pipeline" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=3b5d17a435da8ba9d1fcccbfedce46c357819a3a#3b5d17a435da8ba9d1fcccbfedce46c357819a3a" +source = "git+https://github.com/octos-org/octos.git?rev=e6223efcaa23daa22b876eb734ae98aa400c6240#e6223efcaa23daa22b876eb734ae98aa400c6240" dependencies = [ "async-trait", "chrono", @@ -7054,7 +7054,7 @@ dependencies = [ [[package]] name = "octos-plugin" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=3b5d17a435da8ba9d1fcccbfedce46c357819a3a#3b5d17a435da8ba9d1fcccbfedce46c357819a3a" +source = "git+https://github.com/octos-org/octos.git?rev=e6223efcaa23daa22b876eb734ae98aa400c6240#e6223efcaa23daa22b876eb734ae98aa400c6240" dependencies = [ "eyre", "metrics", @@ -7068,7 +7068,7 @@ dependencies = [ [[package]] name = "octos-research" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=3b5d17a435da8ba9d1fcccbfedce46c357819a3a#3b5d17a435da8ba9d1fcccbfedce46c357819a3a" +source = "git+https://github.com/octos-org/octos.git?rev=e6223efcaa23daa22b876eb734ae98aa400c6240#e6223efcaa23daa22b876eb734ae98aa400c6240" dependencies = [ "chrono", "dom_smoothie", @@ -7089,7 +7089,7 @@ dependencies = [ [[package]] name = "octos-sandbox" version = "0.1.0" -source = "git+https://github.com/octos-org/octos.git?rev=3b5d17a435da8ba9d1fcccbfedce46c357819a3a#3b5d17a435da8ba9d1fcccbfedce46c357819a3a" +source = "git+https://github.com/octos-org/octos.git?rev=e6223efcaa23daa22b876eb734ae98aa400c6240#e6223efcaa23daa22b876eb734ae98aa400c6240" dependencies = [ "clap", "eyre", @@ -7104,7 +7104,7 @@ dependencies = [ [[package]] name = "octos-services" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=3b5d17a435da8ba9d1fcccbfedce46c357819a3a#3b5d17a435da8ba9d1fcccbfedce46c357819a3a" +source = "git+https://github.com/octos-org/octos.git?rev=e6223efcaa23daa22b876eb734ae98aa400c6240#e6223efcaa23daa22b876eb734ae98aa400c6240" dependencies = [ "chrono", "dirs", @@ -7126,7 +7126,7 @@ dependencies = [ [[package]] name = "octos-store" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=3b5d17a435da8ba9d1fcccbfedce46c357819a3a#3b5d17a435da8ba9d1fcccbfedce46c357819a3a" +source = "git+https://github.com/octos-org/octos.git?rev=e6223efcaa23daa22b876eb734ae98aa400c6240#e6223efcaa23daa22b876eb734ae98aa400c6240" dependencies = [ "base64 0.22.1", "chrono", @@ -7146,7 +7146,7 @@ dependencies = [ [[package]] name = "octos-swarm" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=3b5d17a435da8ba9d1fcccbfedce46c357819a3a#3b5d17a435da8ba9d1fcccbfedce46c357819a3a" +source = "git+https://github.com/octos-org/octos.git?rev=e6223efcaa23daa22b876eb734ae98aa400c6240#e6223efcaa23daa22b876eb734ae98aa400c6240" dependencies = [ "async-trait", "chrono", @@ -7165,7 +7165,7 @@ dependencies = [ [[package]] name = "octos-workflows" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=3b5d17a435da8ba9d1fcccbfedce46c357819a3a#3b5d17a435da8ba9d1fcccbfedce46c357819a3a" +source = "git+https://github.com/octos-org/octos.git?rev=e6223efcaa23daa22b876eb734ae98aa400c6240#e6223efcaa23daa22b876eb734ae98aa400c6240" dependencies = [ "chrono", "eyre", @@ -7380,8 +7380,10 @@ name = "octosense-kernel" version = "0.1.0" dependencies = [ "futures-util", + "libc", "log", "octos-cli", + "octos-core", "octosense-llm-config", "serde_json", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 1182cb73..4ee1e37b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -86,17 +86,16 @@ octoscript-node = { git = "https://github.com/OctoSense-org/Octoscript-Makepad.g # service registry the Mail and llm services register into. One revision. octosense-app-hub-app = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "e8601b80ce104db2e48208094714bdcffdce6b5a" } octosense-appstore = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "e8601b80ce104db2e48208094714bdcffdce6b5a" } -# --- octos: ONE source for every octos crate. Move octos-core, octos-cli and -# octos-research together; the Android kernel artifact (liboctos.so) is built -# from this same revision. TEMPORARY: the head of octos#2591 -# (`serve --host-managed` plus its review fixes), on top of octos main at 5c5b6a53 (keeps octos#2583; -# no octos#2567). It must move to the merged octos commit before this merges. -octos-core = { git = "https://github.com/octos-org/octos.git", rev = "64a233c7483601907819a87e0a5c49ff905145c7" } -octos-cli = { git = "https://github.com/octos-org/octos.git", rev = "64a233c7483601907819a87e0a5c49ff905145c7", default-features = false } +# --- octos: ONE source for every octos crate (octos-org/octos main at the +# merge of octos#2591, `serve --host-managed`). Move octos-core, octos-cli +# and octos-research together; the Android kernel artifact (liboctos.so) is +# built from this same revision. +octos-core = { git = "https://github.com/octos-org/octos.git", rev = "e6223efcaa23daa22b876eb734ae98aa400c6240" } +octos-cli = { git = "https://github.com/octos-org/octos.git", rev = "e6223efcaa23daa22b876eb734ae98aa400c6240", default-features = false } # The research engine (the metasearch, the SSRF-safe polite reader and the # toolbox scope) for the toolbox's `octos-engine` feature. Same source and # revision as above. -octos-research = { git = "https://github.com/octos-org/octos.git", rev = "64a233c7483601907819a87e0a5c49ff905145c7", default-features = false } +octos-research = { git = "https://github.com/octos-org/octos.git", rev = "e6223efcaa23daa22b876eb734ae98aa400c6240", default-features = false } # --- Rinx (Matrix chats, Moments, articles, mini apps) as a hosted module: # the library only, never its standalone entry or its own kernel (Rinx ADR 0007). rinx = { git = "https://github.com/hagency-org/Rinx.git", rev = "68afcf796d303aaf646eeb832d65c450a56c92b5", default-features = false, features = ["octosense-module"] } diff --git a/apps/README.md b/apps/README.md index 4fc564b3..73f9fff0 100644 --- a/apps/README.md +++ b/apps/README.md @@ -343,7 +343,7 @@ octos UI Protocol v1. kernel, a WebSocket or REST) and `octos-app-render` (streaming-markdown renderer). - **octos**: every octos crate comes from git `octos-org/octos` at the one - rev in the root `Cargo.toml` `[workspace.dependencies]` (today `3b5d17a4`, + rev in the root `Cargo.toml` `[workspace.dependencies]` (today `e6223efc`, octos `main`), shared with `crates/kernel` and the shells. AppCard starts no kernel of its own: it connects to the shell's ([The octos kernel](#the-octos-kernel)). - **Makepad**: not vendored. Makepad, Octoscript and Octoscript-Makepad are @@ -427,7 +427,7 @@ plus an entry in each shell's `system-apps.json`. | [OctoScript-App-Design-Flow](https://github.com/OctoSense-org/OctoScript-App-Design-Flow) | how to design, build, check and publish an app | | [OctoScript](https://github.com/OctoSense-org/OctoScript), [OctoScript-Makepad](https://github.com/OctoSense-org/OctoScript-Makepad), [makepad](https://github.com/OctoSense-org/makepad) | the language and runtime | | [Rinx](https://github.com/hagency-org/Rinx) | Matrix chats and mini apps, a native module; reaches the assistant through `crates/app-peers` | -| [octos](https://github.com/octos-org/octos) | the agent kernel: run as a shell service by `crates/kernel`, configured by AI providers, used by AppCard and other consumers (one rev, `3b5d17a4`) | +| [octos](https://github.com/octos-org/octos) | the agent kernel: run as a shell service by `crates/kernel`, configured by AI providers, used by AppCard and other consumers (one rev, `e6223efc`) | ## Contributing diff --git a/apps/README.zh-CN.md b/apps/README.zh-CN.md index 1e26e65c..fd0d6ad7 100644 --- a/apps/README.zh-CN.md +++ b/apps/README.zh-CN.md @@ -305,7 +305,7 @@ octos UI Protocol v1 与 octos 通信。 `octos-app-render`(流式 markdown 渲染)。 - **octos**:所有 octos crate 都来自 git `octos-org/octos`,版本为根目录 `Cargo.toml` 的 `[workspace.dependencies]` 中唯一的 rev(目前是 octos `main` 上的 - `3b5d17a4`),与 `crates/kernel` 和 Shell 共用。AppCard 不再自己启动内核,而是连接 Shell 的内核 + `e6223efc`),与 `crates/kernel` 和 Shell 共用。AppCard 不再自己启动内核,而是连接 Shell 的内核 (见 [octos 内核](#octos-内核))。 - **Makepad**:不内置。Makepad、Octoscript 和 Octoscript-Makepad 是仓库根目录下 `.sources/` 中由 `tools/setup.py` 准备的检出,版本由 `native-runtime.lock.json` @@ -378,7 +378,7 @@ Shell 的 `system-apps.json` 中加入它。 | [OctoScript-App-Design-Flow](https://github.com/OctoSense-org/OctoScript-App-Design-Flow) | 如何设计、构建、检查和发布应用 | | [OctoScript](https://github.com/OctoSense-org/OctoScript)、[OctoScript-Makepad](https://github.com/OctoSense-org/OctoScript-Makepad)、[makepad](https://github.com/OctoSense-org/makepad) | 语言与运行时 | | [Rinx](https://github.com/hagency-org/Rinx) | Matrix 聊天与小程序,原生模块;通过 `crates/app-peers` 访问助手 | -| [octos](https://github.com/octos-org/octos) | Agent 内核:由 `crates/kernel` 作为 Shell 服务运行,由 AI providers 配置,供 AppCard 等使用方使用(只用一个版本 `3b5d17a4`) | +| [octos](https://github.com/octos-org/octos) | Agent 内核:由 `crates/kernel` 作为 Shell 服务运行,由 AI providers 配置,供 AppCard 等使用方使用(只用一个版本 `e6223efc`) | ## 参与贡献 diff --git a/apps/appcard/README.md b/apps/appcard/README.md index 528cbb20..4aecf929 100644 --- a/apps/appcard/README.md +++ b/apps/appcard/README.md @@ -42,7 +42,7 @@ docs/ Architecture, protocol, build and review notes. Every octos crate (`octos-core`, and on OpenHarmony `octos-cli` with the ~20 crates it pulls in) comes from **one** source: git `https://github.com/octos-org/octos.git` at the single rev in the repository's -root `Cargo.toml` `[workspace.dependencies]` (today `3b5d17a4`, octos main), +root `Cargo.toml` `[workspace.dependencies]` (today `e6223efc`, octos main), shared with `crates/kernel` and both shells. octos's OpenHarmony-safe `nix` is patched in the root `[patch.crates-io]` (from octos `18fcd3f1`). There is no octos submodule; check the graph keeps one octos with diff --git a/apps/appcard/README.zh-CN.md b/apps/appcard/README.zh-CN.md index dab2d79d..d4c239b7 100644 --- a/apps/appcard/README.zh-CN.md +++ b/apps/appcard/README.zh-CN.md @@ -39,7 +39,7 @@ docs/ 架构、协议、构建与评审笔记。 所有 octos crate(`octos-core`,以及 OpenHarmony 上的 `octos-cli` 和它引入的约 20 个 crate)都来自**同一个**来源:git `https://github.com/octos-org/octos.git`, 版本为仓库根目录 `Cargo.toml` 的 `[workspace.dependencies]` 中唯一的 rev(目前是 -octos main 上的 `3b5d17a4`),与 `crates/kernel` 和两个 Shell 共用。octos 中适配 +octos main 上的 `e6223efc`),与 `crates/kernel` 和两个 Shell 共用。octos 中适配 OpenHarmony 的 `nix` 在根目录的 `[patch.crates-io]` 中 patch(来自 octos `18fcd3f1`)。 没有 octos submodule;可用 `cargo tree --locked -p octos-app -i octos-core --target all --depth 0` 检查依赖图中只有一份 octos。 diff --git a/crates/toolbox/README.md b/crates/toolbox/README.md index 22ac7d3b..395d4b04 100644 --- a/crates/toolbox/README.md +++ b/crates/toolbox/README.md @@ -164,7 +164,7 @@ Backends: - no cookies, credentials or proxies, and 401/402/403 are failures, so there is no paywall or login bypass; - **SSRF blocking** on every fetch and every redirect hop. Loopback, private, link-local (including cloud-metadata 169.254.169.254 and `fd00:ec2::254`), unique-local, shared (CGNAT), multicast, reserved and IPv4-mapped forms are refused, both as URL literals and in DNS answers. A resolver filters the addresses the connection actually uses, so DNS rebinding cannot get past the check. `localhost` names are refused. - **Licenses**: `dom_smoothie`, `dom_query`, `gjson`, `html-escape` (MIT), `flagset` (Apache-2.0), `quick-xml` (MIT), and Mozilla's `cssparser` and `selectors` (MPL-2.0, unmodified; the workspace already links them through `scraper`). All of these are behind the `live` feature. -- **The octos research engine** (`research::octos::OctosResearch`, feature `octos-engine`). It depends on `octos-research` through the workspace's one octos pin, and needs octos#2568 (reader), #2582 (metasearch), #2585 (`octos_research::toolbox`), #2590 (read-failure reasons, posts marked as posts) and #2594 (stub and consent pages, whole-topic feed matching, a soft deadline for slow engines), all in the pin (octos 3b5d17a4). It only finds and reads; everything under "The policy" above stays in the host unchanged. +- **The octos research engine** (`research::octos::OctosResearch`, feature `octos-engine`). It depends on `octos-research` through the workspace's one octos pin, and needs octos#2568 (reader), #2582 (metasearch), #2585 (`octos_research::toolbox`), #2590 (read-failure reasons, posts marked as posts) and #2594 (stub and consent pages, whole-topic feed matching, a soft deadline for slow engines), all in the pin (octos e6223efc, which contains 3b5d17a4). It only finds and reads; everything under "The policy" above stays in the host unchanged. - **One definition of an app's reach.** The app's grant **is** `octos_research::toolbox::Scope`; nothing is mapped. Each search is narrowed by octos's `Scope::search_args`, in the host (above) and again here, so a language, region, category or domain outside the grant is refused there and the recency is clamped. The search asks the engines for a candidate pool of three times the app's `limit` (at most 30, `candidate_pool`) so the host's filters still leave `limit`; `max_results` bounds what the app gets, not the pool. Each read is checked with `Scope::check_domain` twice: the link, then the page the browser ended on, the publisher for a Google News link. - **Search** is the octos metasearch (`octos_research::metasearch`), category `news`: Google News in the language's own edition, GDELT, publisher feeds, Hacker News and Mastodon, plus keyed engines when their keys are set. - Octos fans the query out under a 15 s deadline, then merges, deduplicates and ranks the results. It spaces requests per host, honours `Retry-After`, caches with ETags, and suspends a failing engine with doubling backoff. That backoff is how GDELT's 429s are handled. diff --git a/tools/test_kernel_artifact.py b/tools/test_kernel_artifact.py index f592def6..c8b1df51 100644 --- a/tools/test_kernel_artifact.py +++ b/tools/test_kernel_artifact.py @@ -13,9 +13,7 @@ kernel = importlib.util.module_from_spec(spec) spec.loader.exec_module(kernel) -# The head of octos#2591 (`serve --host-managed`) until it merges; then the -# merged octos commit (the root Cargo.toml says the same). -REV = "64a233c7483601907819a87e0a5c49ff905145c7" +REV = "e6223efcaa23daa22b876eb734ae98aa400c6240" def lock_with(*revs):