diff --git a/.github/workflows/apps.yml b/.github/workflows/apps.yml index 6b7dfb08..55f49f0e 100644 --- a/.github/workflows/apps.yml +++ b/.github/workflows/apps.yml @@ -81,6 +81,44 @@ 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" + # Outside target/: rust-cache restores and prunes target/. + - uses: actions/cache@v4 + with: + 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 "$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: ${{ 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 + 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.lock b/Cargo.lock index 6f786bf7..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", @@ -7379,12 +7379,18 @@ dependencies = [ name = "octosense-kernel" version = "0.1.0" dependencies = [ + "futures-util", + "libc", "log", "octos-cli", + "octos-core", "octosense-llm-config", "serde_json", "tokio", + "tokio-tungstenite 0.24.0", "tracing-subscriber", + "url", + "uuid", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 0bd6ef48..4ee1e37b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -87,14 +87,15 @@ octoscript-node = { git = "https://github.com/OctoSense-org/Octoscript-Makepad.g 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 } +# 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 = "3b5d17a435da8ba9d1fcccbfedce46c357819a3a", 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 6855a990..73f9fff0 100644 --- a/apps/README.md +++ b/apps/README.md @@ -295,6 +295,8 @@ 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 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 The octos agent kernel is a **shell service**, not part of any app. @@ -341,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 @@ -425,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 d8f7cc4b..fd0d6ad7 100644 --- a/apps/README.zh-CN.md +++ b/apps/README.zh-CN.md @@ -263,6 +263,8 @@ MAKEPAD_APP_CONFIG='{"mail_demo":true}' cargo run --release -p octosense-home -- `octosense_kernel::restart()`,让正在运行的内核读取新的服务商。方法列表与注册 方式见其 [README(英文)](ai-providers/host-service/README.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 内核 octos Agent 内核是 **Shell 服务**,不属于任何应用。 @@ -303,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` @@ -376,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/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..92f90fe6 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,6 +151,14 @@ pub trait QrImagePicker: Send + Sync { /// `octos-core`). Runs on whichever thread made the change. pub type OnChanged = Arc; +/// 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 /// [`register`] uses. #[derive(Clone, Default)] @@ -164,6 +184,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 { @@ -217,8 +238,10 @@ 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, })); } @@ -314,6 +337,7 @@ struct Pending { #[derive(Clone, Debug, PartialEq)] enum Kind { Add, + Connect, Edit(String), Export(u64), Import { scanned: Option }, @@ -385,8 +409,11 @@ 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, } fn text<'a>(v: &'a Value, key: &str) -> &'a str { @@ -909,6 +936,121 @@ 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_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()?)); + 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"))] +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. The +/// epoch is taken now, so a newer code minted before the worker runs stays. +fn end_pairing() { + #[cfg(feature = "octos-core")] + { + let epoch = octosense_kernel::pairing_epoch(); + work(move || octosense_kernel::end_pairing_if(epoch)); + } +} + +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" @@ -920,6 +1062,33 @@ 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); + } + 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 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()); work(move || { @@ -1002,10 +1171,14 @@ 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. - 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..e0d6e55d 100644 --- a/apps/ai-providers/host-service/src/sheets.rs +++ b/apps/ai-providers/host-service/src/sheets.rs @@ -59,6 +59,164 @@ fn frame(actions: &str, content: &str) -> String { ) } +/// 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 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 answer(r){ + if !r.is_ok { status(r.error) return } + show(r.data) + status("") +} +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 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) } +// The host's Back (phone) calls cancel(): closing ends any pairing code. +fn cancel(){ close() } +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}"#, + r#"Title{text: "Talk to Octos"} + 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)} + 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} + 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 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 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 +} + +/// 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 cancel(){{ close() }} +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): @@ -774,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 @@ -904,6 +1063,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 50efac0f..79c89884 100644 --- a/apps/ai-providers/host-service/tests/service.rs +++ b/apps/ai-providers/host-service/tests/service.rs @@ -129,6 +129,30 @@ fn two_providers(rig: &mut Rig) { rig.add(json!({"family": "zai", "model": "", "base_url": "", "api_type": "default", "key": ZAI_KEY})); } +#[test] +fn talk_to_octos_controls_require_their_own_trusted_sheet() { + let mut rig = Rig::new("client-sheet", None); + 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); + 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_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"); +} + #[test] fn keys_are_typed_only_on_the_sheet_and_never_come_back() { let mut rig = Rig::new("edit", None); @@ -666,11 +690,17 @@ 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}"); + // 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)")); } #[test] 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/ai-host/src/lib.rs b/crates/ai-host/src/lib.rs index 2de1fa9d..2501c31f 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,18 @@ 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 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::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..82dcf2f3 100644 --- a/crates/kernel/Cargo.toml +++ b/crates/kernel/Cargo.toml @@ -1,16 +1,10 @@ # 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. +# 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" @@ -23,11 +17,18 @@ 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 # 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 @@ -37,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 398dcf2d..03471cb8 100644 --- a/crates/kernel/README.md +++ b/crates/kernel/README.md @@ -1,5 +1,7 @@ # octosense-kernel: the shell's octos kernel +English | [简体中文](README.zh-CN.md) + 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; @@ -19,7 +21,8 @@ the shared runtime piece the shells, AppCard (`apps/appcard/app`) and the | **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. | +| **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`): @@ -39,6 +42,9 @@ How it starts, per platform (`src/launch.rs`): `$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. @@ -84,7 +90,70 @@ 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()`. +`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 (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, 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 + 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 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 + 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 +``` + +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 @@ -93,8 +162,11 @@ From the repository root: ```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: +# 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 ``` @@ -106,13 +178,17 @@ Build the kernel for that test (and for an Android APK, with the NDK and 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 -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. +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 new file mode 100644 index 00000000..863d5b3c --- /dev/null +++ b/crates/kernel/README.zh-CN.md @@ -0,0 +1,163 @@ +# octosense-kernel:Shell 的 octos 内核 + +[English](README.md) | 简体中文 + +[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()), +); +``` + +使用方: + +```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 的传输层(`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 继续收发同样的帧,使用从不离开本进程的宿主令牌(内核从 stdin + 读取两个令牌,从不经过环境变量),并请求 + octos 的 stdio 功能集(`octos_core::ui_protocol::UI_PROTOCOL_STDIO_DEFAULT_FEATURES`); +- 生成一个**外部令牌**。它只能打开 `/api/ui-protocol/ws`,且在其中只能调用白名单内的会话、 + 轮次、回答和只读状态方法:不能修改配置(服务商、密钥、技能、快照),不能调用 + `server/shutdown`,不能操作应用助手(宿主拥有的应用 peer)的会话,只能在自己打开的会话中 + 回答提问。它发起的轮次没有执行代码、管理 octos 或访问 peer 的工具(octos UPCR-2026-036); +- 监听套接字保存在本进程中(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`(Android 只允许 https)。保存的 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` 指向其自身端口的 +请求(**未在设备上验证**): + +```sh +adb -s SERIAL forward tcp:PORT tcp:PORT +``` + +系统会话为 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)。 + +## 测试 + +在仓库根目录: + +```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; 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 +``` + +用根目录 `Cargo.toml` `[workspace.dependencies]` 中的 rev 从 octos-org/octos 构建该测试用的 +内核(Android APK 用的内核再加 NDK 和 `--target aarch64-linux-android`): + +```sh +cargo build --release -p octos-cli --bin octos --no-default-features --features api,git,ast +``` + +`python3 tools/kernel-artifact.py --host --plan` 会打印在本机构建所锁定版本的同一构建步骤。 + +CI:`.github/workflows/apps.yml`(`services` 任务测试本 crate;`apps` 任务构建链接它的 AppCard)。 + +## 只有一个 octos + +本 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 1996a839..39c6ae8c 100644 --- a/crates/kernel/src/kernel.rs +++ b/crates/kernel/src/kernel.rs @@ -12,7 +12,9 @@ use tokio::sync::{mpsc, watch}; use crate::launch::Launch; use crate::router::{ConnId, Router}; -use crate::{CloseReason, LogSink}; +use crate::network::Network; +use crate::{ClientAccess, CloseReason, LogSink}; +use std::path::PathBuf; /// What a consumer's inbound channel carries. #[derive(Debug)] @@ -31,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); @@ -45,6 +50,10 @@ struct Io { writer: Pin>, lines: tokio::io::Lines>>>, running: Running, + /// 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. @@ -64,9 +73,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 +87,20 @@ fn start(launch: &Launch, log: &LogSink, tail: &Tail) -> Result { for (k, v) in env { command.env(k, v); } + let mut secrets = Vec::new(); + if shared { + // 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("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(), network.external_token()]; + pass_listener(&mut command, network)?; + } if let Some(cwd) = cwd { command.current_dir(cwd); } @@ -92,15 +116,18 @@ fn start(launch: &Launch, log: &LogSink, tail: &Tail) -> Result { tokio::spawn(async move { let mut lines = BufReader::new(stderr).lines(); while let Ok(Some(line)) = lines.next_line().await { + 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), + 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")] @@ -119,6 +146,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), + lifeline: None, }) } #[cfg(not(target_env = "ohos"))] @@ -126,6 +154,69 @@ fn start(launch: &Launch, log: &LogSink, tail: &Tail) -> Result { } } + +/// 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> { + 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())? + .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); + // 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 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())? +} + /// 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")] @@ -167,12 +258,16 @@ async fn exited(running: &mut Running) -> String { /// 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; + // 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; } @@ -186,20 +281,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 +312,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 +375,26 @@ 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, + } } }; + let was_ready = matches!(&*ready.borrow(), Some(Ok(_))); + ready.send_replace(Some(Err(reason.clone()))); + if matches!(launch, Launch::WebSocket { .. }) { + 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. ctl.close(); @@ -283,7 +421,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..3eb8e1b4 100644 --- a/crates/kernel/src/launch.rs +++ b/crates/kernel/src/launch.rs @@ -14,6 +14,10 @@ //! 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}; @@ -22,6 +26,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 +44,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"); + // 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 } + } + other => other, + } + } +} + /// Why no kernel can start here. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Unavailable { @@ -178,12 +204,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..d032064e 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. //! @@ -21,6 +20,12 @@ //! 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 @@ -35,6 +40,8 @@ use tokio::sync::{mpsc, watch}; pub mod dirs; mod kernel; +mod network; +pub use network::{connection_file, pairing_link, ClientAccess, Pairing, CONNECTION_FILE, SYSTEM_SESSION}; pub mod launch; mod router; @@ -145,6 +152,8 @@ struct Generation { id: u64, ctl: mpsc::UnboundedSender, connections: usize, + shared: bool, + ready: watch::Receiver>>, } struct State { @@ -159,6 +168,10 @@ struct State { 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 { @@ -213,6 +226,8 @@ impl Core { next_conn: 0, }), runtime: OnceLock::new(), + network: Arc::default(), + pairing_epoch: Mutex::new(0), })) } @@ -240,11 +255,25 @@ 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, - }) + })?; + // 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 { + // 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) } /// Connect to the kernel, starting it if none runs. Fails only when no @@ -265,6 +294,7 @@ impl Core { generation: current.id, ctl: current.ctl.clone(), inbound: rx, + ready: current.ready.clone(), closed: None, }); } @@ -279,10 +309,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 +331,133 @@ 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 - /// 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 { - self.stop_current(CloseReason::Restarted) + let running = self.stop_current(CloseReason::Restarted); + 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 { + 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 { + network::end_pairing(&access, network.host_token()).await; + Ok(()) + }) + }); + } } /// Stop the kernel (if any) and wait up to `timeout` for it to exit. @@ -351,8 +500,13 @@ impl Core { } } - /// A connection left: when it was the last of the running generation, - /// the kernel stops. + /// 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 { + 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. 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 +514,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 +530,7 @@ pub struct Connection { ctl: mpsc::UnboundedSender, inbound: mpsc::UnboundedReceiver, closed: Option, + ready: watch::Receiver>>, } impl std::fmt::Debug for Connection { @@ -385,6 +540,44 @@ impl std::fmt::Debug for Connection { } impl Connection { + /// 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)?; + let result = ready.borrow().as_ref().cloned().unwrap(); + result + } + + /// 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()) + .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)?; + return Ok(serde_json::json!([workspace,"_main",SYSTEM_SESSION]).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 +658,106 @@ pub fn connect() -> Result { global().connect() } +/// 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() +} + +/// 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 { + 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 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_reference().await + } + .await; + let _ = tx.send(result); + }); + 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 exact origin, configured on the host sheet ("" = none; +/// a malformed saved value also reads as none). +pub fn web_client_origin() -> String { + core_dir().map(|dir| network::read_web_origin(&dir)).unwrap_or_default() +} + +/// 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())?; + if network::read_web_origin(&dir) != normalized { + network::save_web_origin(&dir, &normalized)?; + if external_access() { + 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..cbf16ec2 --- /dev/null +++ b/crates/kernel/src/network.rs @@ -0,0 +1,528 @@ +//! 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, 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, 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"; + +/// 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 = (|| { + 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 +} + +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 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" => loopback_http && is_loopback_host(&host), + _ => false, + }; + if !scheme_ok { + 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() + || url.password().is_some() + || url.path() != "/" + || url.query().is_some() + || url.fragment().is_some() + { + return Err("Use an origin without a path, credentials, query or fragment.".into()); + } + Ok(url.origin().ascii_serialization()) +} + +/// 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, + 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://")) + } + + 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 { + /// 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 { + host_token: mint_token(), + external: Mutex::new(None), + port: AtomicU16::new(0), + #[cfg(unix)] + listener: Mutex::new(None), + } + } +} + +impl Network { + 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.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(); + if expected != 0 && expected != port { + return None; + } + self.port.store(port, Ordering::Relaxed); + 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 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())?; + 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 { + 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) +} + +/// A one-time pairing code for the external token (octos's pairing): valid +/// for five minutes and one claim. +#[derive(Clone)] +pub struct Pairing { + pub code: String, + pub server_origin: String, + pub expires_in_secs: u64, +} + +impl std::fmt::Debug for Pairing { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> 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 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", + "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(); + 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()); + } + 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 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/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..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 { @@ -61,6 +61,96 @@ 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())); + 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 { + 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!(!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"); 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 43041887..9d532818 100644 --- a/crates/kernel/tests/real_kernel.rs +++ b/crates/kernel/tests/real_kernel.rs @@ -11,15 +11,98 @@ //! ``` //! //! 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 tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio_tungstenite::tungstenite::{client::IntoClientRequest, Message}; + +type Socket = tokio_tungstenite::WebSocketStream>; + +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 +} + +/// 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() +} + +/// 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 { + 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 { + 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(); @@ -54,10 +137,7 @@ 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"); @@ -94,6 +174,10 @@ async fn a_real_kernel_runs_on_the_profile_the_providers_app_wrote() { 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); let _ = std::fs::remove_dir_all(&dir); @@ -105,3 +189,331 @@ impl Drop for Defer { (self.0)() } } + +#[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 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"] { + 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}"); + } + // 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"], "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"); + 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; + 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 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"); + + // 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; + 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(); + 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"), 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 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); + 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; + // 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 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"); + 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"); + 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"); + // 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 { + 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"); + drop((native, browser)); + core.shutdown_within(Duration::from_secs(15)); + // 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(); + 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]); + 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/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/desktop/README.md b/desktop/README.md index 334553f1..4fd0f2f2 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -147,6 +147,8 @@ AI providers (`os.ai-providers`) edits the octos kernel's LLM providers through **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 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. ### Store apps (App Hub) diff --git a/desktop/README.zh-CN.md b/desktop/README.zh-CN.md index 87d75c72..da74748f 100644 --- a/desktop/README.zh-CN.md +++ b/desktop/README.zh-CN.md @@ -147,6 +147,8 @@ AI 提供商(`os.ai-providers`)通过 `llm` 服务(`octosense-llm-service` **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 退出时内核停止。 +**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 或令牌的新功能,应放在宿主服务和宿主自有面板中,绝不放在应用自己的界面里。 ### 商店应用(App Hub) 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..142fce9b --- /dev/null +++ b/docs/adr/0003-shared-octos-client-access.md @@ -0,0 +1,175 @@ +# ADR 0003: Talk to Octos: one kernel for native and external clients + +Status: Implemented in source (desktop verified; Android **unverified**). +Depends on octos `serve --host-managed` (octos#2591, UPCR-2026-036). + +## Context + +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. + +## 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 + +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, 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 | 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`. 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 + 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 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. + It works only while the sheet is open; the shell turns pairing off when the + 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 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 + 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 + +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 (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), 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; +- 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. 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/docs/adr/README.md b/docs/adr/README.md index 01afc09f..f596036f 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) | 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 78dd1f6e..90f61a57 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)(英文) | Talk to Octos:原生与外部客户端共用一个内核(需手动开启) | 已实现;Android 未验证 | ## Home(手机 Shell)的决策,2026-09-16 至 2026-09-25 diff --git a/phone/README.md b/phone/README.md index 147c0725..f4fd06a0 100644 --- a/phone/README.md +++ b/phone/README.md @@ -205,6 +205,8 @@ 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 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 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..bcf0c155 100644 --- a/phone/README.zh-CN.md +++ b/phone/README.zh-CN.md @@ -120,6 +120,8 @@ adb shell am start -n /.MakepadApp --es makepad.APP_CONFIG '{"mail_demo 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 提供商 AI 提供商(`os.ai-providers`)通过 `llm` 宿主服务([`apps/ai-providers/host-service`](../apps/ai-providers/host-service))编辑 octos 内核的 LLM 提供商,Home 在启动时通过 [`crates/ai-host`](../crates/ai-host/README.md) 注册该服务: diff --git a/tools/kernel-artifact.py b/tools/kernel-artifact.py index f929477c..d04cb2c1 100755 --- a/tools/kernel-artifact.py +++ b/tools/kernel-artifact.py @@ -14,6 +14,7 @@ aarch64-linux-android with the cargo-makepad SDK's NDK clang (API 33, `--no-default-features --features api,git,ast`: the stdio server without the llama.cpp embedder); +- `--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 @@ -98,30 +99,37 @@ 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/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])) + 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}" @@ -151,6 +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 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") @@ -165,12 +174,12 @@ def main(argv=None): 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 +194,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/test_kernel_artifact.py b/tools/test_kernel_artifact.py index d977c900..c8b1df51 100644 --- a/tools/test_kernel_artifact.py +++ b/tools/test_kernel_artifact.py @@ -13,7 +13,7 @@ kernel = importlib.util.module_from_spec(spec) spec.loader.exec_module(kernel) -REV = "3b5d17a435da8ba9d1fcccbfedce46c357819a3a" +REV = "e6223efcaa23daa22b876eb734ae98aa400c6240" def lock_with(*revs): @@ -41,6 +41,24 @@ def test_the_workspace_pins_one_kernel(self): class PlanTests(unittest.TestCase): + 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")) + 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") steps, binary = kernel.plan("a" * 40, work, Path("/sdk with spaces"), required=False)