diff --git a/Cargo.lock b/Cargo.lock index d496f1ad..a5519168 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7581,6 +7581,7 @@ dependencies = [ "octos-core", "octosense-llm-config", "serde_json", + "sha2 0.10.9", "tokio", "tokio-tungstenite 0.24.0", "tracing-subscriber", diff --git a/README.md b/README.md index 8bfbf1fb..5858307f 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ flowchart LR - **The shell** (`crates/shell`, one process) hosts the window manager, the native modules (App Hub, Rinx), App Hub's Card runner (every script app in its own isolate), the system chat, the approval router, the host-tool relay and [`crates/ai-host`](crates/ai-host/README.md), whose [app-peers broker](crates/app-peers/README.md) is the kernel's host connection. -- **The octos kernel** ([`crates/kernel`](crates/kernel/README.md)) starts on first use: a child process speaking OUP over stdio on the desktop (`OCTOS_APP_CORE_BIN`) and Android (`liboctos.so`), an in-process task on OpenHarmony, none on iOS. It exits with the shell. +- **The octos kernel** ([`crates/kernel`](crates/kernel/README.md)) starts on first use: a child process speaking OUP over stdio on the desktop (the packaged `octos-kernel` beside the shell, or `OCTOS_APP_CORE_BIN`) and Android (`liboctos.so`), an in-process task on OpenHarmony, none on iOS. It exits with the shell. - **Process apps**: on the desktop the Terminal runs as its own process, attached over the shell's hub (frames and the AI bus), in an OS sandbox built from its `native-apps.json` entry (Seatbelt on macOS, Landlock and seccomp on Linux, not yet on Windows). A process app reaches its own agent over the **peer link**; the shell side is on `main`, but the Terminal is not granted an agent, so no process app uses it yet. - **External clients**: Talk to Octos (opt-in) lets a web or terminal client use the system conversation with a limited external token: an allowlist of methods, no `peer/*` method, no app agent's session, no host-routed tools. @@ -314,14 +314,14 @@ Pinned exactly once, in the root `Cargo.toml` and the runtime locks: | [makepad (OctoSense fork)](https://github.com/OctoSense-org/makepad) | The UI framework and the `cargo-makepad` packager. Checked out in `.sources/makepad`, plus the reviewed runtime patch. | | [OctoScript-Makepad](https://github.com/OctoSense-org/OctoScript-Makepad), [OctoScript](https://github.com/OctoSense-org/OctoScript) | The runtime release that names the Makepad and OctoScript revisions (`native-runtime.lock.json`). | | [OctoSense-App-Hub](https://github.com/OctoSense-org/OctoSense-App-Hub) | The signed catalog, the store, the Card runner that contains every app (`octosense-app-hub-app`). | -| [octos](https://github.com/octos-org/octos) | The agent kernel. On Android the APK bundles it as `liboctos.so`; on a desktop the kernel service runs the binary named by `OCTOS_APP_CORE_BIN`. | +| [octos](https://github.com/octos-org/octos) | The agent kernel. On Android the APK bundles it as `liboctos.so`; on a desktop the kernel service runs the packaged `octos-kernel` beside the shell, checked against this revision (`tools/kernel-artifact.py --host --stage` builds it); `OCTOS_APP_CORE_BIN` overrides it. | | [Rinx](https://github.com/hagency-org/Rinx) | Matrix chats and mini apps, hosted as a native module. | Related, not build inputs: [OctoScript-App-Design-Flow](https://github.com/OctoSense-org/OctoScript-App-Design-Flow) (how apps are built and published), [OctoScript-Android](https://github.com/OctoSense-org/OctoScript-Android) and [OctoScript-OH](https://github.com/OctoSense-org/OctoScript-OH) (other renderers), the [OctoSense website](https://github.com/OctoSense-org/octosense-org.github.io). ## AI services (octos) -Each shell runs one [octos](https://github.com/octos-org/octos) agent kernel, started on first use: the APK's `liboctos.so` on Android, in process on OpenHarmony, the binary `OCTOS_APP_CORE_BIN` names on a desktop, none on iOS. The person chooses its models and types keys in the **AI providers** system app, on host sheets; keys stay in the platform's secret store and never reach an app. [`crates/ai-host`](crates/ai-host/README.md) is the shells' one entry point, and [`crates/app-peers`](crates/app-peers/README.md) gives each granted native app its own octos peer (private contexts, workspace and memory `app//acct-`), owned by the shell's system agent. Peer tool approvals use the shell router: developer mode, eligible standing rules or a person on the owning app/Shell confirmation sheet. The system agent cannot answer them. +Each shell runs one [octos](https://github.com/octos-org/octos) agent kernel, started on first use: the APK's `liboctos.so` on Android, in process on OpenHarmony, on a desktop the packaged `octos-kernel` beside the shell (or the binary `OCTOS_APP_CORE_BIN` names), none on iOS. The person chooses its models and types keys in the **AI providers** system app, on host sheets; keys stay in the platform's secret store and never reach an app. [`crates/ai-host`](crates/ai-host/README.md) is the shells' one entry point, and [`crates/app-peers`](crates/app-peers/README.md) gives each granted native app its own octos peer (private contexts, workspace and memory `app//acct-`), owned by the shell's system agent. Peer tool approvals use the shell router: developer mode, eligible standing rules or a person on the owning app/Shell confirmation sheet. The system agent cannot answer them. What works today: native modules (Rinx) use their peer; AppCard (opt-in) uses the kernel directly. Contained script apps, system or store, reach it through the `octos` host service in a shell that hosts a kernel: each app gets its own host-owned peer (`card.`), and its tool approvals go to the shell's approval sheets like every other app agent's ([#155](https://github.com/OctoSense-org/OctoSense/pull/155)). The `llm` service manages providers for `os.*` apps only. An app's own agent (`tools.json`, `AGENT.md`, skills, triggers, glance cards) is [ADR 0002](docs/adr/0002-event-driven-app-agents.md); host-service-backed `tools.json` tools reach their agents through the shell relay since [#160](https://github.com/OctoSense-org/OctoSense/pull/160). Tools marked `implemented_by: "app"` still lack a Card-runner executor; declaring a tool does not implement it. @@ -367,6 +367,8 @@ cargo check --locked -p octosense --features mobile-apps cargo check --locked -p octosense -p octosense-appcard --features mobile-apps,app-appcard ``` +The assistant needs the octos kernel beside the shell: `python3 tools/kernel-artifact.py --host --stage target/release` builds the pinned revision and stages it, once per octos pin; the desktop refuses a staged kernel of another revision and says so ([Build and run](desktop/README.md#build-and-run)). Without one the desktop runs without an assistant. + **Phone** (from `phone/`, which selects the phone's system apps; details in [phone/README.md](phone/README.md)): ```sh diff --git a/README.zh-CN.md b/README.zh-CN.md index 5721b230..1c743b65 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -66,7 +66,7 @@ flowchart LR - **Shell**(`crates/shell`,一个进程)承载窗口管理器、原生模块(App Hub、Rinx)、App Hub 的 Card runner(每个脚本应用都在自己的隔离环境中)、系统对话、审批路由、宿主工具中转,以及 [`crates/ai-host`](crates/ai-host/README.md);其中的 [app-peers 代理](crates/app-peers/README.md)就是内核的宿主连接。 -- **octos 内核**([`crates/kernel`](crates/kernel/README.zh-CN.md))首次使用时启动:桌面端(`OCTOS_APP_CORE_BIN`)和 Android(`liboctos.so`)上是通过 stdio 讲 OUP 的子进程,OpenHarmony 上是进程内的任务,iOS 上没有。它随 Shell 一起退出。 +- **octos 内核**([`crates/kernel`](crates/kernel/README.zh-CN.md))首次使用时启动:桌面端(Shell 旁随附的 `octos-kernel`,或 `OCTOS_APP_CORE_BIN`)和 Android(`liboctos.so`)上是通过 stdio 讲 OUP 的子进程,OpenHarmony 上是进程内的任务,iOS 上没有。它随 Shell 一起退出。 - **进程应用**:桌面端的 Terminal 作为独立进程运行,通过 Shell 的 hub 连接(画面和 AI bus),运行在按其 `native-apps.json` 条目构建的系统沙箱中(macOS 上是 Seatbelt,Linux 上是 Landlock 和 seccomp,Windows 上尚未实现)。进程应用通过 **peer link** 使用自己的 Agent;Shell 一侧已在 `main` 上,但 Terminal 没有被授予 Agent,所以目前还没有进程应用使用它。 - **外部客户端**:Talk to Octos(需手动开启)让网页或终端客户端以受限的外部 token 使用系统对话:只有一份方法白名单,不能调用任何 `peer/*` 方法,不能进入任何应用 Agent 的会话,也拿不到宿主路由的工具。 @@ -314,14 +314,14 @@ Shell 只有一份,位于 `crates/shell`([ADR 0001(英文)](docs/adr/000 | [makepad(OctoSense fork)](https://github.com/OctoSense-org/makepad) | UI 框架和 `cargo-makepad` 打包工具。检出到 `.sources/makepad`,并应用经审查的运行时补丁。 | | [OctoScript-Makepad](https://github.com/OctoSense-org/OctoScript-Makepad)、[OctoScript](https://github.com/OctoSense-org/OctoScript) | 指定 Makepad 和 OctoScript 版本的运行时发行版(`native-runtime.lock.json`)。 | | [OctoSense-App-Hub](https://github.com/OctoSense-org/OctoSense-App-Hub) | 签名目录、商店,以及隔离运行每个应用的 Card runner(`octosense-app-hub-app`)。 | -| [octos](https://github.com/octos-org/octos) | Agent 内核。在 Android 上 APK 以 `liboctos.so` 形式内置它;在桌面上内核服务运行 `OCTOS_APP_CORE_BIN` 指定的程序。 | +| [octos](https://github.com/octos-org/octos) | Agent 内核。在 Android 上 APK 以 `liboctos.so` 形式内置它;在桌面上内核服务运行 Shell 旁随附的 `octos-kernel`,并核对其版本与此处固定的一致(由 `tools/kernel-artifact.py --host --stage` 构建);`OCTOS_APP_CORE_BIN` 可覆盖它。 | | [Rinx](https://github.com/hagency-org/Rinx) | Matrix 聊天与小程序,作为原生模块托管。 | 相关但不参与构建:[OctoScript-App-Design-Flow](https://github.com/OctoSense-org/OctoScript-App-Design-Flow)(如何构建和发布应用)、[OctoScript-Android](https://github.com/OctoSense-org/OctoScript-Android) 和 [OctoScript-OH](https://github.com/OctoSense-org/OctoScript-OH)(其他渲染后端)、[OctoSense 网站](https://github.com/OctoSense-org/octosense-org.github.io)。 ## AI 服务(octos) -每个 Shell 运行一个 [octos](https://github.com/octos-org/octos) Agent 内核,首次使用时启动:Android 上是 APK 中的 `liboctos.so`,OpenHarmony 上在进程内运行,桌面端运行 `OCTOS_APP_CORE_BIN` 指定的二进制,iOS 上没有。用户在系统应用 **AI providers** 中、在宿主面板上选择模型并输入密钥;密钥保存在平台的密钥存储中,永远不会到达应用。[`crates/ai-host`](crates/ai-host/README.md) 是两个 Shell 的统一入口,[`crates/app-peers`](crates/app-peers/README.md) 为每个获授权的原生应用分配自己的 octos peer(私有的上下文、工作区和记忆 `app//acct-`),归 Shell 的系统 Agent 所有。Peer 工具审批经 Shell 路由,由开发者模式、符合条件的常设规则,或用户在所属应用/Shell 确认面板上决定。系统 Agent 无法代答。 +每个 Shell 运行一个 [octos](https://github.com/octos-org/octos) Agent 内核,首次使用时启动:Android 上是 APK 中的 `liboctos.so`,OpenHarmony 上在进程内运行,桌面端运行 Shell 旁随附的 `octos-kernel`(或 `OCTOS_APP_CORE_BIN` 指定的二进制),iOS 上没有。用户在系统应用 **AI providers** 中、在宿主面板上选择模型并输入密钥;密钥保存在平台的密钥存储中,永远不会到达应用。[`crates/ai-host`](crates/ai-host/README.md) 是两个 Shell 的统一入口,[`crates/app-peers`](crates/app-peers/README.md) 为每个获授权的原生应用分配自己的 octos peer(私有的上下文、工作区和记忆 `app//acct-`),归 Shell 的系统 Agent 所有。Peer 工具审批经 Shell 路由,由开发者模式、符合条件的常设规则,或用户在所属应用/Shell 确认面板上决定。系统 Agent 无法代答。 目前可用的:原生模块(Rinx)使用自己的 peer;AppCard(需主动开启)直接使用内核。隔离运行的脚本应用,无论系统应用还是商店应用,在托管了内核的 Shell 中通过 `octos` 宿主服务使用助手:每个应用有自己的、由宿主拥有的 peer(`card.<应用 id>`),它的工具审批和其他应用 Agent 一样交给 Shell 的审批面板([#155](https://github.com/OctoSense-org/OctoSense/pull/155))。`llm` 服务只为 `os.*` 应用管理提供方。应用自己的 Agent(`tools.json`、`AGENT.md`、skills、触发器、glance 卡片)见 [ADR 0002](docs/adr/0002-event-driven-app-agents.md);自 [#160](https://github.com/OctoSense-org/OctoSense/pull/160) 起,应用 `tools.json` 中由宿主服务实现的工具已通过 Shell relay 提供给其 Agent。标为 `implemented_by: "app"` 的工具仍缺少 Card runner 执行器;声明不等于实现。 @@ -367,6 +367,8 @@ cargo check --locked -p octosense --features mobile-apps cargo check --locked -p octosense -p octosense-appcard --features mobile-apps,app-appcard ``` +助手需要 Shell 旁的 octos 内核:`python3 tools/kernel-artifact.py --host --stage target/release` 构建固定版本并放到该处,每个 octos 固定版本做一次;桌面会拒绝版本不符的内核并说明原因([构建与运行](desktop/README.zh-CN.md#构建与运行))。没有内核时桌面在没有助手的情况下运行。 + **手机端**(在 `phone/` 中运行,它会选择手机端的系统应用;详见 [phone/README.zh-CN.md](phone/README.zh-CN.md)): ```sh diff --git a/crates/ai-host/README.md b/crates/ai-host/README.md index 40570a6f..2b04a4cb 100644 --- a/crates/ai-host/README.md +++ b/crates/ai-host/README.md @@ -45,7 +45,8 @@ ai_host::shutdown(); ``` `Host` fields: `data_dir`; `kernel: KernelSource` (`Bundled` on Android, -`InProcess` on OpenHarmony, `Env` = `$OCTOS_APP_CORE_BIN` on a desktop, +`InProcess` on OpenHarmony, `Env` = `$OCTOS_APP_CORE_BIN` or the packaged +`octos-kernel` on a desktop, `Program(path)`, `None`; `KernelSource::platform()` picks); `qr_import: QrImport` (`platform()` or `paste_only()`); `policy: Policy` (`Policy::shipped()` grants Rinx the `octos.*` services). diff --git a/crates/ai-host/src/lib.rs b/crates/ai-host/src/lib.rs index d4599603..46c342f5 100644 --- a/crates/ai-host/src/lib.rs +++ b/crates/ai-host/src/lib.rs @@ -93,7 +93,9 @@ pub enum KernelSource { Bundled, /// OpenHarmony: the canonical core linked in-process. InProcess, - /// Desktop: the binary `$OCTOS_APP_CORE_BIN` names; none without it (a + /// Desktop: the binary `$OCTOS_APP_CORE_BIN` names, else the packaged + /// `octos-kernel` beside the shell when its receipt names the pinned + /// octos revision (`octosense_kernel::launch`); none without either (a /// developer's own `octos serve` is never touched). Env, /// Desktop or Android: this binary. @@ -105,7 +107,8 @@ pub enum KernelSource { impl KernelSource { /// This platform's kernel: bundled on Android, in-process on - /// OpenHarmony, `$OCTOS_APP_CORE_BIN` on a desktop, none on iOS. + /// OpenHarmony, `$OCTOS_APP_CORE_BIN` or the packaged kernel on a + /// desktop, none on iOS. pub fn platform() -> Self { if cfg!(target_os = "android") { KernelSource::Bundled diff --git a/crates/kernel/Cargo.toml b/crates/kernel/Cargo.toml index 82dcf2f3..990493a5 100644 --- a/crates/kernel/Cargo.toml +++ b/crates/kernel/Cargo.toml @@ -38,6 +38,11 @@ octos-core = { workspace = true } octos-cli = { workspace = true, features = ["api", "git", "ast"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] } +# A desktop checks its packaged `octos-kernel` against the receipt's SHA-256 +# before running it (`launch::verify_packaged`). +[target.'cfg(not(any(target_env = "ohos", target_os = "ios", target_os = "android")))'.dependencies] +sha2 = "0.10" + # Passing the kept Talk to Octos listener to the kernel (`dup2` between fork # and exec). [target.'cfg(unix)'.dependencies] diff --git a/crates/kernel/README.md b/crates/kernel/README.md index 00c18a75..c5fb2981 100644 --- a/crates/kernel/README.md +++ b/crates/kernel/README.md @@ -42,8 +42,17 @@ How it starts, per platform (`src/launch.rs`): with 8 MiB worker stacks (HAP native libraries may not exec). - **Desktop**: ` serve --stdio --data-dir ` (plus `--config /config.json` when that file exists) with - `OCTOS_HOME=`; the program is the shell's `Options::program` or - `$OCTOS_APP_CORE_BIN`. With neither there is no kernel: a developer's own + `OCTOS_HOME=`; the program is the shell's `Options::program`, + `$OCTOS_APP_CORE_BIN`, or the packaged `octos-kernel[.exe]` beside the + shell executable (in a macOS `.app`, `Contents/MacOS` or + `Contents/Resources`), in that order. The packaged kernel runs only when + its receipt `octos-kernel.json` (beside it, in the `.app`'s + `Contents/Resources`, or a Linux package's `usr/lib/octosense`) names the + octos revision this build pins (`build.rs` reads it from `Cargo.lock`) and + its SHA-256 matches; otherwise the connection fails with the reason. + `tools/kernel-artifact.py --host --stage ` builds and stages it; + `OCTOSENSE_KERNEL_ANY_REVISION=1` accepts another revision while + developing. No `PATH` search, no working directory, and a developer's own `octos serve` is never touched. - **iOS**: no kernel. - **Talk to Octos on** (desktop and Android): the same command with diff --git a/crates/kernel/README.zh-CN.md b/crates/kernel/README.zh-CN.md index 710405e0..8843052b 100644 --- a/crates/kernel/README.zh-CN.md +++ b/crates/kernel/README.zh-CN.md @@ -38,8 +38,14 @@ Shell(`phone/` 中的 Home、`desktop/` 中的桌面)拥有它;**AI provid 运行在本 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`。 + `Options::program`、`$OCTOS_APP_CORE_BIN`,或 Shell 可执行文件旁随附的 + `octos-kernel[.exe]`(macOS `.app` 中为 `Contents/MacOS` 或 `Contents/Resources`), + 依次选用。随附内核只有在其收据 `octos-kernel.json`(在它旁边、`.app` 的 + `Contents/Resources` 或 Linux 包的 `usr/lib/octosense` 中)记录的版本与本次构建固定的 + octos 版本一致(`build.rs` 从 `Cargo.lock` 读取),且 SHA-256 相符时才运行;否则连接 + 失败并给出原因。`tools/kernel-artifact.py --host --stage <目录>` 负责构建和放置;开发时 + `OCTOSENSE_KERNEL_ANY_REVISION=1` 可接受其他版本。不在 `PATH` 或工作目录中查找,绝不会 + 动开发者自己的 `octos serve`。 - **iOS**:没有内核。 - **开启 Talk to Octos 时**(桌面和 Android):同一命令,以 `--host 127.0.0.1 --host-managed` 代替 `--stdio`,并把本进程保留的监听套接字作为描述符 3 传入(`--listen-fd 3`,Unix)。 diff --git a/crates/kernel/build.rs b/crates/kernel/build.rs new file mode 100644 index 00000000..2d3c79ff --- /dev/null +++ b/crates/kernel/build.rs @@ -0,0 +1,58 @@ +//! The octos revision this build pins, for the desktop launch check +//! (`launch::verify_packaged`): a packaged `octos-kernel` runs only when its +//! receipt names this revision. +//! +//! Read from the workspace's Cargo.lock (the `octos-cli` the Android and +//! desktop kernel artifacts are built from, as tools/kernel-artifact.py reads +//! it; else `octos-core`, which this crate links). `OCTOSENSE_OCTOS_REVISION` +//! at build time overrides it. Empty when neither is found: the desktop then +//! refuses a packaged kernel it cannot check (an explicit program still runs). + +use std::path::Path; + +fn main() { + println!("cargo:rerun-if-env-changed=OCTOSENSE_OCTOS_REVISION"); + let lock = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../Cargo.lock"); + println!("cargo:rerun-if-changed={}", lock.display()); + let revision = std::env::var("OCTOSENSE_OCTOS_REVISION") + .ok() + .filter(|r| is_revision(r)) + .or_else(|| std::fs::read_to_string(&lock).ok().and_then(|text| pinned(&text))) + .unwrap_or_default(); + println!("cargo:rustc-env=OCTOSENSE_PINNED_OCTOS_REVISION={revision}"); +} + +fn is_revision(text: &str) -> bool { + text.len() == 40 && text.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) +} + +/// The one octos-org/octos revision of `octos-cli` (else `octos-core`) in a +/// Cargo.lock; none when absent or ambiguous. +fn pinned(lock: &str) -> Option { + const SOURCE: &str = "git+https://github.com/octos-org/octos.git?rev="; + for crate_name in ["octos-cli", "octos-core"] { + let mut found = Vec::new(); + for block in lock.split("[[package]]") { + let field = |key: &str| { + block.lines().find_map(|line| { + let rest = line.strip_prefix(key)?.trim_start().strip_prefix('=')?.trim(); + Some(rest.trim_matches('"').to_owned()) + }) + }; + if field("name").as_deref() != Some(crate_name) { + continue; + } + if let Some(rev) = field("source").and_then(|s| s.strip_prefix(SOURCE).map(|r| r.split('#').next().unwrap_or("").to_owned())) { + if is_revision(&rev) && !found.contains(&rev) { + found.push(rev); + } + } + } + match found.len() { + 0 => continue, + 1 => return found.pop(), + _ => return None, + } + } + None +} diff --git a/crates/kernel/src/launch.rs b/crates/kernel/src/launch.rs index bd3717f3..ebe15a96 100644 --- a/crates/kernel/src/launch.rs +++ b/crates/kernel/src/launch.rs @@ -10,9 +10,15 @@ //! (`octos_cli::embedded::serve_io`); HAP native libraries cannot exec. //! - **Desktop**: ` serve --stdio --data-dir --config //! /config.json` with `OCTOS_HOME=`, where the program -//! is the shell's [`crate::Options::program`] or `$OCTOS_APP_CORE_BIN`. -//! Without one there is no kernel (a developer's own `octos serve` is never -//! touched). +//! is the shell's [`crate::Options::program`], `$OCTOS_APP_CORE_BIN`, or +//! the packaged `octos-kernel[.exe]` beside the shell executable (in a +//! macOS `.app`, `Contents/MacOS` or `Contents/Resources`), in that order. +//! The packaged kernel runs only when its receipt (`octos-kernel.json`, +//! written by `tools/kernel-artifact.py --stage`) names the octos revision +//! this build pins (checked when resolved, `verify_packaged`) and the +//! kernel's SHA-256 (checked by [`prepare`] before each start, off the +//! caller's thread). No PATH search and no attaching to another running +//! 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 @@ -122,20 +128,250 @@ pub(crate) fn resolve(inputs: &Inputs) -> Result { } #[cfg(not(any(target_env = "ohos", target_os = "ios", target_os = "android")))] { - let program = match inputs.program { - Some(p) => p.to_path_buf(), - None => std::env::var_os("OCTOS_APP_CORE_BIN") - .filter(|v| !v.is_empty()) - .map(PathBuf::from) - .ok_or_else(|| Unavailable::NoKernel("no kernel binary configured (OCTOS_APP_CORE_BIN)".into()))?, - }; - if !program.is_file() { - return Err(Unavailable::NoKernel(format!("{} is not a file", program.display()))); - } + let env = std::env::var_os(PROGRAM_ENV).filter(|v| !v.is_empty()).map(PathBuf::from); + // Through symlinks (a launcher linked into a bin dir): the kernel + // travels with the real executable. + let executable = std::env::current_exe().ok().map(|p| p.canonicalize().unwrap_or(p)); + let any_revision = std::env::var_os(ANY_REVISION_ENV).is_some_and(|v| v == "1"); + let program = desktop_program(inputs.program, env.as_deref(), executable.as_deref(), PINNED_REVISION, any_revision)?; Ok(desktop_stdio(program, core_dir, inputs.env)) } } +/// The desktop's explicit kernel: wins over the packaged one, unchecked (the +/// person chose it). +#[cfg(not(any(target_env = "ohos", target_os = "ios", target_os = "android")))] +pub const PROGRAM_ENV: &str = "OCTOS_APP_CORE_BIN"; +/// Development only: `=1` runs a packaged kernel whose receipt names another +/// octos revision than this build pins (a missing receipt or a SHA-256 +/// mismatch is still refused). +#[cfg(not(any(target_env = "ohos", target_os = "ios", target_os = "android")))] +pub const ANY_REVISION_ENV: &str = "OCTOSENSE_KERNEL_ANY_REVISION"; +/// The packaged kernel's file name, beside the shell executable. +#[cfg(not(any(target_env = "ohos", target_os = "ios", target_os = "android")))] +pub const PACKAGED_KERNEL: &str = if cfg!(windows) { "octos-kernel.exe" } else { "octos-kernel" }; +/// Its receipt: `{"source", "revision", "version", "sha256"}`. +#[cfg(not(any(target_env = "ohos", target_os = "ios", target_os = "android")))] +pub const PACKAGED_RECEIPT: &str = "octos-kernel.json"; +/// The octos revision this build pins (the workspace's Cargo.lock, read by +/// build.rs); empty when the build could not tell. +#[cfg(not(any(target_env = "ohos", target_os = "ios", target_os = "android")))] +pub const PINNED_REVISION: &str = env!("OCTOSENSE_PINNED_OCTOS_REVISION"); + +/// The kernel program on a desktop: `explicit` (`Options::program`), else +/// `env` (`$OCTOS_APP_CORE_BIN`), else the packaged kernel that travels with +/// `executable`, wherever it was installed or moved (never the working +/// directory). An override is authoritative: a broken one is an error, never +/// a fallback to another kernel. The packaged one must pass +/// [`verify_packaged`]. +#[cfg(not(any(target_env = "ohos", target_os = "ios", target_os = "android")))] +fn desktop_program( + explicit: Option<&Path>, + env: Option<&Path>, + executable: Option<&Path>, + pinned: &str, + any_revision: bool, +) -> Result { + if let Some(program) = explicit.or(env) { + check_executable(program)?; + return Ok(program.to_owned()); + } + let Some(dir) = executable.and_then(Path::parent) else { + return Err(Unavailable::NoKernel(format!("no packaged kernel and no {PROGRAM_ENV} override"))); + }; + let Some(program) = packaged_candidates(dir).into_iter().find(|p| p.is_file()) else { + return Err(Unavailable::NoKernel(format!( + "no packaged {PACKAGED_KERNEL} beside {} and no {PROGRAM_ENV} override; \ + stage one with `python3 tools/kernel-artifact.py --host --stage `", + dir.display() + ))); + }; + check_executable(&program)?; + let receipt = receipt_candidates(dir, &program).into_iter().find(|p| p.is_file()); + let sha256 = verify_packaged(&program, receipt.as_deref(), pinned, any_revision)?; + // Hashing a 100 MB kernel takes most of a second: not here (resolving + // also answers status queries on the UI thread) but in `prepare`, right + // before the start. + expect_sha256(&program, sha256); + Ok(program) +} + +/// Where a packaged kernel may sit, relative to the shell executable's dir: +/// beside it (a source build's `target/`, the Windows install dir, +/// Linux `usr/bin`, a `.app`'s `Contents/MacOS`), or a `.app`'s +/// `Contents/Resources`. +#[cfg(not(any(target_env = "ohos", target_os = "ios", target_os = "android")))] +fn packaged_candidates(dir: &Path) -> Vec { + let mut found = vec![dir.join(PACKAGED_KERNEL)]; + if let Some(resources) = bundle_resources(dir) { + found.push(resources.join(PACKAGED_KERNEL)); + } + found +} + +/// Where its receipt may sit: beside the kernel, a `.app`'s +/// `Contents/Resources` (nothing but code belongs in `Contents/MacOS`), or a +/// Linux package's `usr/lib/octosense` (not `usr/bin`). +#[cfg(not(any(target_env = "ohos", target_os = "ios", target_os = "android")))] +fn receipt_candidates(dir: &Path, program: &Path) -> Vec { + let mut found = vec![program.with_file_name(PACKAGED_RECEIPT)]; + if let Some(resources) = bundle_resources(dir) { + found.push(resources.join(PACKAGED_RECEIPT)); + } + if dir.file_name().is_some_and(|n| n == "bin") { + if let Some(prefix) = dir.parent() { + found.push(prefix.join("lib/octosense").join(PACKAGED_RECEIPT)); + } + } + found +} + +/// `.app/Contents/Resources` when `dir` is `.app/Contents/MacOS`. +#[cfg(not(any(target_env = "ohos", target_os = "ios", target_os = "android")))] +fn bundle_resources(dir: &Path) -> Option { + let contents = dir.parent()?; + let is_bundle = dir.file_name()? == "MacOS" + && contents.file_name()? == "Contents" + && contents.parent()?.extension().is_some_and(|e| e == "app"); + is_bundle.then(|| contents.join("Resources")) +} + +#[cfg(not(any(target_env = "ohos", target_os = "ios", target_os = "android")))] +fn check_executable(program: &Path) -> Result<(), Unavailable> { + if !program.is_file() { + return Err(Unavailable::NoKernel(format!("{} is not a file", program.display()))); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if std::fs::metadata(program).map(|m| m.permissions().mode() & 0o111 == 0).unwrap_or(true) { + return Err(Unavailable::NoKernel(format!("{} is not executable", program.display()))); + } + } + Ok(()) +} + +/// A packaged kernel runs only when it is the one this build pins: its +/// receipt names `pinned` (unless `any_revision`, a development override) +/// and records a SHA-256, which [`prepare`] checks against the file before +/// every start ([`check_sha256`]). So an old `target/release/octos-kernel` +/// left from an earlier pin, a kernel without a receipt or a binary swapped +/// in later is refused with a reason the person sees, never run silently. +/// Returns the recorded SHA-256. Reads only the small receipt. +#[cfg(not(any(target_env = "ohos", target_os = "ios", target_os = "android")))] +pub(crate) fn verify_packaged(program: &Path, receipt: Option<&Path>, pinned: &str, any_revision: bool) -> Result { + let refuse = |why: String| Err(Unavailable::NoKernel(refusal(program, &why))); + let Some(receipt) = receipt else { + return refuse(format!("it has no receipt ({PACKAGED_RECEIPT}) saying which octos revision it is")); + }; + let text = match std::fs::read_to_string(receipt) { + Ok(text) => text, + Err(e) => return refuse(format!("its receipt {} cannot be read ({e})", receipt.display())), + }; + let Ok(record) = serde_json::from_str::(&text) else { + return refuse(format!("its receipt {} is not JSON", receipt.display())); + }; + let revision = record["revision"].as_str().unwrap_or(""); + let sha256 = record["sha256"].as_str().unwrap_or("").to_ascii_lowercase(); + if revision.is_empty() || sha256.len() != 64 || !sha256.bytes().all(|b| b.is_ascii_hexdigit()) { + return refuse(format!("its receipt {} names no revision and SHA-256", receipt.display())); + } + if revision != pinned { + if pinned.is_empty() { + if !any_revision { + return refuse("this build does not know which octos revision it pins".into()); + } + } else if any_revision { + log::warn!( + "octos-core: {ANY_REVISION_ENV}=1: running the packaged kernel at octos {} although this build pins {}", + short(revision), + short(pinned) + ); + } else { + return refuse(format!( + "it is octos {} but this build pins {} (set {ANY_REVISION_ENV}=1 to run it anyway while developing)", + short(revision), + short(pinned) + )); + } + } + Ok(sha256) +} + +#[cfg(not(any(target_env = "ohos", target_os = "ios", target_os = "android")))] +fn refusal(program: &Path, why: &str) -> String { + format!( + "refusing the packaged kernel {}: {why}. Rebuild it with `python3 tools/kernel-artifact.py --host --stage {}` \ + or name a kernel with {PROGRAM_ENV}", + program.display(), + program.parent().map(|p| p.display().to_string()).unwrap_or_default() + ) +} + +/// Packaged kernels resolved, and the SHA-256 their receipts record. +#[cfg(not(any(target_env = "ohos", target_os = "ios", target_os = "android")))] +type Expected = std::collections::HashMap; +#[cfg(not(any(target_env = "ohos", target_os = "ios", target_os = "android")))] +static EXPECTED: std::sync::Mutex> = std::sync::Mutex::new(None); + +#[cfg(not(any(target_env = "ohos", target_os = "ios", target_os = "android")))] +fn expect_sha256(program: &Path, sha256: String) { + EXPECTED.lock().unwrap().get_or_insert_with(Expected::new).insert(program.to_path_buf(), sha256); +} + +/// Before a start (from [`prepare`], on the kernel's runtime): a packaged +/// kernel's bytes must be the ones its receipt records. A file already +/// checked (same path, size and modification time) is not hashed again. +/// Any other program (an override) has nothing to check. +#[cfg(not(any(target_env = "ohos", target_os = "ios", target_os = "android")))] +pub(crate) fn check_sha256(program: &Path) -> Result<(), String> { + let Some(expected) = EXPECTED.lock().unwrap().as_ref().and_then(|e| e.get(program).cloned()) else { + return Ok(()); + }; + // (path, recorded SHA-256) -> (size, modification time) when it matched. + type Checked = Vec<((PathBuf, String), (u64, Option))>; + static CHECKED: std::sync::Mutex = std::sync::Mutex::new(Vec::new()); + let stamp = std::fs::metadata(program).ok().map(|m| (m.len(), m.modified().ok())); + let key = (program.to_path_buf(), expected); + if let Some(stamp) = stamp { + if CHECKED.lock().unwrap().iter().any(|(k, s)| *k == key && *s == stamp) { + return Ok(()); + } + } + let actual = sha256_file(program).map_err(|e| refusal(program, &format!("it cannot be read ({e})")))?; + if actual != key.1 { + return Err(refusal(program, "its SHA-256 is not the one its receipt records")); + } + if let Some(stamp) = stamp { + let mut checked = CHECKED.lock().unwrap(); + checked.retain(|(k, _)| k.0 != key.0); + checked.push((key, stamp)); + } + Ok(()) +} + +#[cfg(not(any(target_env = "ohos", target_os = "ios", target_os = "android")))] +fn short(revision: &str) -> &str { + revision.get(..12).unwrap_or(revision) +} + +#[cfg(not(any(target_env = "ohos", target_os = "ios", target_os = "android")))] +fn sha256_file(path: &Path) -> std::io::Result { + use sha2::Digest; + use std::io::Read; + let mut file = std::fs::File::open(path)?; + let mut hasher = sha2::Sha256::new(); + let mut buf = vec![0u8; 1 << 20]; + loop { + let n = file.read(&mut buf)?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + } + Ok(hasher.finalize().iter().map(|b| format!("{b:02x}")).collect()) +} + /// The desktop launch: an explicit data dir, as AppCard's local core mode has /// always run it, and `/config.json` when there is one (octos /// refuses to start on a named config file that does not exist; without one @@ -205,8 +441,16 @@ pub(crate) fn phone_stdio(program: PathBuf, core_dir: &Path, extra: &[(String, S /// written and read back (a foreign policy in the profile, the person's own /// octos home, an unwritable profile), `Err` says why and the kernel is not /// started: every consumer's connection closes with that reason. A kernel -/// without the policy would give the system agent octos's shell. +/// without the policy would give the system agent octos's shell. Likewise +/// on a desktop when a packaged kernel's bytes are not the ones its receipt +/// records ([`check_sha256`]). pub(crate) fn prepare(launch: &Launch, core_dir: &Path) -> Result<(), String> { + // A packaged kernel's bytes, against its receipt (never on the thread + // that resolved it). + #[cfg(not(any(target_env = "ohos", target_os = "ios", target_os = "android")))] + if let Launch::Stdio { program, .. } | Launch::WebSocket { program, .. } = launch { + check_sha256(program)?; + } if let Err(e) = std::fs::create_dir_all(core_dir) { log::warn!("octos-core: could not create {}: {e}", core_dir.display()); } @@ -382,6 +626,160 @@ mod tests { let _ = std::fs::remove_dir_all(home); } + #[cfg(not(any(target_env = "ohos", target_os = "ios", target_os = "android")))] + const PIN: &str = "ae230ce04d57f3c29cf6c2518e5956a86c07d788"; + + /// An executable fixture at `path` and, with `revision`, its receipt at + /// `receipt` (default: beside it) recording that revision and its SHA-256. + #[cfg(not(any(target_env = "ohos", target_os = "ios", target_os = "android")))] + fn fixture(path: &Path, receipt: Option<&Path>, revision: Option<&str>) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, format!("fixture kernel {}", path.display())).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + if let Some(revision) = revision { + let receipt = receipt.map(Path::to_path_buf).unwrap_or_else(|| path.with_file_name(PACKAGED_RECEIPT)); + std::fs::create_dir_all(receipt.parent().unwrap()).unwrap(); + let sha = sha256_file(path).unwrap(); + std::fs::write(receipt, serde_json::json!({"revision": revision, "sha256": sha, "version": "octos test"}).to_string()).unwrap(); + } + } + + #[cfg(not(any(target_env = "ohos", target_os = "ios", target_os = "android")))] + fn why(result: Result) -> String { + match result { + Err(Unavailable::NoKernel(why)) => why, + other => panic!("expected a refusal, got {other:?}"), + } + } + + #[cfg(not(any(target_env = "ohos", target_os = "ios", target_os = "android")))] + #[test] + fn desktop_finds_its_packaged_kernel_and_keeps_overrides_authoritative() { + let dir = tmp("packaged"); + let shell = dir.join("octosense"); + let packaged = dir.join(PACKAGED_KERNEL); + let explicit = dir.join("explicit"); + let env = dir.join("environment"); + fixture(&packaged, None, Some(PIN)); + fixture(&explicit, None, None); + fixture(&env, None, None); + assert_eq!(desktop_program(None, None, Some(&shell), PIN, false).unwrap(), packaged); + assert_eq!(desktop_program(None, Some(&env), Some(&shell), PIN, false).unwrap(), env); + assert_eq!(desktop_program(Some(&explicit), Some(&env), Some(&shell), PIN, false).unwrap(), explicit); + // A broken override is an error, never a fallback to the packaged one. + let missing = dir.join("missing"); + assert!(desktop_program(Some(&missing), Some(&env), Some(&shell), PIN, false).is_err()); + assert!(desktop_program(None, Some(&missing), Some(&shell), PIN, false).is_err()); + // Overrides are the person's choice: no receipt needed, any revision. + assert_eq!(desktop_program(None, Some(&env), Some(&shell), "", false).unwrap(), env); + // Found from the executable after the whole directory moves. + let moved = dir.with_extension("moved"); + let _ = std::fs::remove_dir_all(&moved); + std::fs::rename(&dir, &moved).unwrap(); + assert_eq!(desktop_program(None, None, Some(&moved.join("octosense")), PIN, false).unwrap(), moved.join(PACKAGED_KERNEL)); + std::fs::remove_file(moved.join(PACKAGED_KERNEL)).unwrap(); + assert!(why(desktop_program(None, None, Some(&moved.join("octosense")), PIN, false)).contains("kernel-artifact.py --host --stage")); + assert!(desktop_program(None, None, None, PIN, false).is_err()); + std::fs::remove_dir_all(moved).unwrap(); + } + + #[cfg(not(any(target_env = "ohos", target_os = "ios", target_os = "android")))] + #[test] + fn desktop_refuses_a_packaged_kernel_of_another_revision() { + let dir = tmp("stale"); + let shell = dir.join("octosense"); + let packaged = dir.join(PACKAGED_KERNEL); + // An old target/release/octos-kernel from an earlier pin. + fixture(&packaged, None, Some(&"b".repeat(40))); + let refused = why(desktop_program(None, None, Some(&shell), PIN, false)); + assert!(refused.contains("bbbbbbbbbbbb") && refused.contains(&PIN[..12]), "{refused}"); + assert!(refused.contains(ANY_REVISION_ENV), "{refused}"); + // The development override runs it anyway. + assert_eq!(desktop_program(None, None, Some(&shell), PIN, true).unwrap(), packaged); + // A build that does not know its pin refuses it too. + assert!(why(desktop_program(None, None, Some(&shell), "", false)).contains("does not know")); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[cfg(not(any(target_env = "ohos", target_os = "ios", target_os = "android")))] + #[test] + fn desktop_refuses_a_packaged_kernel_without_a_matching_receipt() { + let dir = tmp("receipt"); + let shell = dir.join("octosense"); + let packaged = dir.join(PACKAGED_KERNEL); + fixture(&packaged, None, None); + assert!(why(desktop_program(None, None, Some(&shell), PIN, true)).contains("no receipt")); + std::fs::write(dir.join(PACKAGED_RECEIPT), "not json").unwrap(); + assert!(why(desktop_program(None, None, Some(&shell), PIN, false)).contains("not JSON")); + std::fs::write(dir.join(PACKAGED_RECEIPT), r#"{"revision": "x", "sha256": "not a digest"}"#).unwrap(); + assert!(why(desktop_program(None, None, Some(&shell), PIN, true)).contains("no revision and SHA-256")); + fixture(&packaged, None, Some(PIN)); + assert!(desktop_program(None, None, Some(&shell), PIN, false).is_ok()); + // The bytes are checked before the start (prepare), not when resolved. + assert_eq!(check_sha256(&packaged), Ok(())); + // The file changes after it was checked (another size): hashed again. + std::fs::write(&packaged, "a different kernel binary").unwrap(); + assert!(desktop_program(None, None, Some(&shell), PIN, true).is_ok(), "resolving reads only the receipt"); + let refused = check_sha256(&packaged).unwrap_err(); + assert!(refused.contains("SHA-256"), "the development override never skips the hash: {refused}"); + let launch = desktop_stdio(packaged.clone(), &dir.join("core"), &[]); + assert!(prepare(&launch, &dir.join("core")).unwrap_err().contains("SHA-256"), "no start"); + // An override is never hashed. + assert_eq!(check_sha256(&dir.join("explicit")), Ok(())); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[cfg(not(any(target_env = "ohos", target_os = "ios", target_os = "android")))] + #[test] + fn desktop_finds_the_kernel_in_installed_layouts() { + let dir = tmp("layouts"); + // macOS .app: the kernel in Contents/MacOS, its receipt in Resources. + let macos = dir.join("OctoSense.app/Contents/MacOS"); + let resources = dir.join("OctoSense.app/Contents/Resources"); + fixture(&macos.join(PACKAGED_KERNEL), Some(&resources.join(PACKAGED_RECEIPT)), Some(PIN)); + assert_eq!(desktop_program(None, None, Some(&macos.join("octosense")), PIN, false).unwrap(), macos.join(PACKAGED_KERNEL)); + // ... or both in Resources. + std::fs::remove_file(macos.join(PACKAGED_KERNEL)).unwrap(); + fixture(&resources.join(PACKAGED_KERNEL), None, Some(PIN)); + assert_eq!(desktop_program(None, None, Some(&macos.join("octosense")), PIN, false).unwrap(), resources.join(PACKAGED_KERNEL)); + // Not a bundle: a directory merely named MacOS has no Resources lookup. + let plain = dir.join("plain/Contents/MacOS"); + std::fs::create_dir_all(&plain).unwrap(); + fixture(&dir.join("plain/Contents/Resources").join(PACKAGED_KERNEL), None, Some(PIN)); + assert!(desktop_program(None, None, Some(&plain.join("octosense")), PIN, false).is_err()); + // Linux package: usr/bin/octos-kernel, receipt in usr/lib/octosense. + let bin = dir.join("usr/bin"); + fixture(&bin.join(PACKAGED_KERNEL), Some(&dir.join("usr/lib/octosense").join(PACKAGED_RECEIPT)), Some(PIN)); + assert_eq!(desktop_program(None, None, Some(&bin.join("octosense")), PIN, false).unwrap(), bin.join(PACKAGED_KERNEL)); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[cfg(all(unix, not(any(target_env = "ohos", target_os = "ios", target_os = "android"))))] + #[test] + fn desktop_rejects_a_nonexecutable_kernel() { + use std::os::unix::fs::PermissionsExt; + let dir = tmp("not-executable"); + let program = dir.join(PACKAGED_KERNEL); + fixture(&program, None, Some(PIN)); + std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o644)).unwrap(); + assert!(why(desktop_program(None, None, Some(&dir.join("octosense")), PIN, false)).contains("not executable")); + assert!(desktop_program(Some(&program), None, None, PIN, false).is_err()); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[cfg(not(any(target_env = "ohos", target_os = "ios", target_os = "android")))] + #[test] + fn the_pinned_revision_is_the_workspace_lock() { + let lock = std::fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../Cargo.lock")).unwrap(); + assert_eq!(PINNED_REVISION.len(), 40, "build.rs found the octos pin"); + assert!(lock.contains("name = \"octos-cli\"\nversion = "), "octos-cli is in the lock"); + assert!(lock.contains(&format!("octos.git?rev={PINNED_REVISION}#")), "{PINNED_REVISION} is the locked octos"); + } + #[cfg(not(any(target_env = "ohos", target_os = "ios", target_os = "android")))] #[test] fn desktop_without_a_program_has_no_kernel() { diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 1d24dce2..c55f275e 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -94,7 +94,10 @@ impl Options { self } /// The kernel binary (desktop; on Android it overrides the bundled - /// `liboctos.so`). Default: `$OCTOS_APP_CORE_BIN`. + /// `liboctos.so`). Desktop default: `$OCTOS_APP_CORE_BIN`, then the + /// packaged `octos-kernel[.exe]` beside this process's executable, run + /// only when its receipt names the pinned octos revision (see + /// [`launch`]). pub fn program(mut self, program: impl Into) -> Self { self.program = Some(program.into()); self diff --git a/crates/kernel/tests/core.rs b/crates/kernel/tests/core.rs index 8790cd7e..2a74ba25 100644 --- a/crates/kernel/tests/core.rs +++ b/crates/kernel/tests/core.rs @@ -270,11 +270,14 @@ fn no_kernel_binary_means_no_connection() { #[tokio::test(flavor = "multi_thread")] async fn a_kernel_that_cannot_start_fails_the_connection() { - // A file that is not executable: resolvable, but the spawn fails. - let dir = core_dir("noexec"); + use std::os::unix::fs::PermissionsExt; + // Executable, so it resolves (a non-executable file is refused before + // any start); a missing interpreter makes the spawn itself fail. + let dir = core_dir("bad-interpreter"); std::fs::create_dir_all(&dir).unwrap(); let program = dir.join("octos"); - std::fs::write(&program, "not a program").unwrap(); + std::fs::write(&program, "#!/nonexistent/octos-interpreter\n").unwrap(); + std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)).unwrap(); let core = Core::new(Options::default().core_dir(dir.join("core")).program(&program)); let mut a = core.connect().unwrap(); assert!(matches!(next(&mut a).await, Err(CloseReason::Failed(_)))); diff --git a/desktop/README.md b/desktop/README.md index ee4d530e..49885c10 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -4,7 +4,7 @@ English | [简体中文](README.zh-CN.md) **New to the code?** Read the [desktop, Home, ROM and system-app walkthrough](docs/code-walkthrough.md), then the [agent and Tokio walkthrough](../docs/architecture-walkthrough.md). The first follows launch, native hosting, script bundles, app data and Android platform boundaries. -> **Where this fits.** The desktop is one shell process with the octos kernel as its child (`OCTOS_APP_CORE_BIN`). App Hub, the Card runner with the script apps, and Rinx run in process; the Terminal runs as its own process, in an OS sandbox on macOS and Linux (not yet on Windows), attached over the shell's hub. On macOS that sandbox keeps `~/.cargo`, `~/.rustup` and the OctoSense checkout read-only, so run `cargo install`, `rustup update` and builds of OctoSense itself in another terminal. Diagrams of the processes, an app agent's two lanes and a tool call with its approval: [How it fits together](../README.md#how-it-fits-together); the details: [docs/architecture.md](../docs/architecture.md) and [ADR 0004](../docs/adr/0004-native-apps-hosting-and-peers.md). +> **Where this fits.** The desktop is one shell process with the octos kernel as its child (the packaged `octos-kernel`, or `OCTOS_APP_CORE_BIN`). App Hub, the Card runner with the script apps, and Rinx run in process; the Terminal runs as its own process, in an OS sandbox on macOS and Linux (not yet on Windows), attached over the shell's hub. On macOS that sandbox keeps `~/.cargo`, `~/.rustup` and the OctoSense checkout read-only, so run `cargo install`, `rustup update` and builds of OctoSense itself in another terminal. Diagrams of the processes, an app agent's two lanes and a tool call with its approval: [How it fits together](../README.md#how-it-fits-together); the details: [docs/architecture.md](../docs/architecture.md) and [ADR 0004](../docs/adr/0004-native-apps-hosting-and-peers.md). The desktop shell of [OctoSense](https://github.com/OctoSense-org), the agent shell on top of your operating system, and the desktop packaging of the OctoSense repository (formerly the OctoSense-Desktop repository). It is one Makepad window that is the desktop: a launcher, a dock and tiles, hosting system apps and App Hub store apps as contained script programs, trusted native modules in-process, and Makepad developer programs as child processes. It gets its apps the same way the phone shell, [Home](../phone/README.md), does. Setup, the repository layout and CI are in the [root README](../README.md). @@ -21,7 +21,7 @@ The desktop shell of [OctoSense](https://github.com/OctoSense-org), the agent sh | [OctoScript-App-Design-Flow](https://github.com/OctoSense-org/OctoScript-App-Design-Flow) | Where apps are designed, built and published to the App Hub. | | [OctoScript-Makepad](https://github.com/OctoSense-org/OctoScript-Makepad) | The runtime release that pins Makepad and OctoScript. Checked out in `.sources/`. | | [makepad (OctoSense fork)](https://github.com/OctoSense-org/makepad) | The framework. Checked out in `.sources/makepad`. | -| [octos](https://github.com/octos-org/octos) | The agent kernel, a shell service (`octos-core`, on by default): AI providers configures it, AppCard, Rinx and other consumers connect to it. One revision, pinned in the root `Cargo.toml`; the kernel itself is a separate binary (desktop: `OCTOS_APP_CORE_BIN`; Android: bundled `liboctos.so`). | +| [octos](https://github.com/octos-org/octos) | The agent kernel, a shell service (`octos-core`, on by default): AI providers configures it, AppCard, Rinx and other consumers connect to it. One revision, pinned in the root `Cargo.toml`; the kernel itself is a separate binary (desktop: the packaged `octos-kernel` beside the shell, see [Build and run](#build-and-run), or `OCTOS_APP_CORE_BIN`; Android: bundled `liboctos.so`). | | [Rinx](https://github.com/hagency-org/Rinx) | Matrix chats and mini apps, linked as a module (`app-rinx`, on by default). | ## Layout of `desktop/` @@ -58,12 +58,17 @@ Details, `--update` and `--cache`: [root README](../README.md#set-up). ## Build and run -From the repository root or `desktop/`, after setup: +From the repository root, after setup: stage the octos kernel beside the shell, then build and run it. ```sh +python3 tools/kernel-artifact.py --host --stage target/release # once per octos pin cargo run --release -p octosense ``` +`kernel-artifact.py --host` checks out the octos revision `Cargo.lock` pins into `target/octos-kernel/` (never a checkout of yours) and builds it for this machine; `--stage target/release` puts it there as `octos-kernel` with its receipt `octos-kernel.json` (revision, version, SHA-256). `--kernel ` stages a binary you already built instead; it is refused unless its `--version` names the pinned revision. For a debug build stage into `target/debug`. Plain `cargo run` never builds or installs the kernel. + +At startup the kernel service looks for `octos-kernel` beside the `octosense` executable (in a macOS `.app` also in `Contents/Resources`), never in the working directory or on `PATH`, and runs it only when its receipt names the octos revision this build pins and the binary's SHA-256 matches. Otherwise the desktop runs without an assistant and says why: for example, after the octos pin moves, `refusing the packaged kernel …: it is octos but this build pins `; stage it again. `OCTOS_APP_CORE_BIN=` still wins and is not checked. While developing against another octos, `OCTOSENSE_KERNEL_ANY_REVISION=1` runs a packaged kernel of another revision (the receipt and its SHA-256 are still required). + The desktop starts empty. Start App Hub, a system app or a developer program from the dock, the top-left **Apps** menu, or **⌘Space** (menu and search). **System → Quit OctoSense** closes the desktop and everything it hosts. Developer programs from `config/apps.json` build on first launch (progress shows in the tile). @@ -116,7 +121,7 @@ App Hub's modules have no process form and always open in-process. | `MAKEPAD_APP_CONFIG='{"mail_demo":true}'` | Serve Mail's demo mailbox (see [Demos](#demos)). | | `OCTOSENSE_MAIL_VAULT=file` | Keep Mail passwords in a 0600 file instead of the macOS keychain. | | `OCTOSENSE_LLM_VAULT=file` | Keep AI providers' keys in the owner-only octos profile instead of the macOS keychain. | -| `OCTOS_APP_CORE_BIN`, `OCTOS_APP_CORE_DIR` | The octos kernel binary the shell's kernel service runs (none: no kernel on this desktop) and its core dir (default `~/octos-home/.octos`; the AI providers profile is `/profiles/_main.json`). | +| `OCTOS_APP_CORE_BIN`, `OCTOS_APP_CORE_DIR` | The octos kernel binary the shell's kernel service runs, unchecked (unset: the packaged `octos-kernel`, see [Build and run](#build-and-run)) and its core dir (default `~/octos-home/.octos`; the AI providers profile is `/profiles/_main.json`). | | `OCTOSENSE_GLANCE_DEMO=1` | Publish a sample L0 News digest card (as `os.news`) to the glance screen at startup: F9 on a desktop style, the glance page on a phone style. A test path for the `glance` service. | | `OCTOSENSE_GLANCE_DEMO=mail` | Publish two fake Mail action cards (L0, as `os.mail`, with a toast each) at startup: clicking a toast opens that card in the card window, where Reply, Send (demo), Ask and Track work on fake data. They work the same in the glance panel, which opens with them. No mail is read and no model is called. `scripts/mail_card_remote.sh` drives it hidden. | | `MAKEPAD_REMOTE`, `MAKEPAD_HIDE_WINDOWS` | Remote-control bridge; hidden windows (see [Demos](#demos)). | @@ -150,7 +155,7 @@ Mail is the worked example (`octosense-mail-service`, from [`../apps/mail/host-s AI providers (`os.ai-providers`) edits the octos kernel's LLM providers through the `llm` service (`octosense-llm-service`, from [`../apps/ai-providers/host-service`](../apps/ai-providers/host-service)). Keys are typed only on host sheets and go to the macOS keychain entry octos reads; the providers are written to the kernel's profile under the shell's octos core dir (`/profiles/_main.json`; core dir `OCTOS_APP_CORE_DIR`, else `~/octos-home/.octos`). A phone's provider QR is imported from a picture of it: **Choose image** opens the open panel, or drop a screenshot on the import sheet. **Start → Settings → AI providers** opens it. After a change the service restarts the kernel if one runs; its consumers (AppCard) reconnect to the new one. -**The octos kernel** is a shell service, not part of any app: `octosense-kernel` ([`../crates/kernel`](../crates/kernel), feature `octos-core`, default). The shell starts it through its AI services at startup ([`../crates/ai-host`](../crates/ai-host/README.md), `octosense_ai_host::start`); nothing runs until a consumer connects, then one kernel per process (` serve --stdio --data-dir ` on a desktop, the APK's `liboctos.so` on Android; none on iOS or on a desktop without `OCTOS_APP_CORE_BIN`). AppCard's agent connects to it; Rinx reaches it through the app-agent broker. It stops when the last consumer leaves and when the shell exits. +**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 (the packaged `octos-kernel` or `OCTOS_APP_CORE_BIN`, with `serve --stdio --data-dir `, on a desktop; the APK's `liboctos.so` on Android; none on iOS or on a desktop with neither). 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). @@ -228,7 +233,7 @@ AppCard is **not shipped for now**: it interfered with the other apps, so no bui cargo run --release -p octosense --features app-appcard -- --module appcard ``` -It starts no kernel of its own: it connects to the shell's. On desktop that needs `OCTOS_APP_CORE_BIN` (and optionally `OCTOS_APP_CORE_DIR`); without a kernel it shows its login / WebSocket screen. Every octos crate comes from octos-org/octos at the one revision the root `Cargo.toml` pins. +It starts no kernel of its own: it connects to the shell's. On desktop that is the packaged `octos-kernel` or `OCTOS_APP_CORE_BIN` (and optionally `OCTOS_APP_CORE_DIR`); without a kernel it shows its login / WebSocket screen. Every octos crate comes from octos-org/octos at the one revision the root `Cargo.toml` pins. ## Demos diff --git a/desktop/README.zh-CN.md b/desktop/README.zh-CN.md index 21590403..8da3537c 100644 --- a/desktop/README.zh-CN.md +++ b/desktop/README.zh-CN.md @@ -6,7 +6,7 @@ [OctoSense](https://github.com/OctoSense-org)(运行在操作系统之上的 Agent 交互 Shell)的桌面端 Shell,也是 OctoSense 仓库中的桌面端打包(原为 OctoSense-Desktop 仓库)。它是一个 Makepad 窗口,这个窗口本身就是桌面:launcher、dock 和平铺窗格(tile)。系统应用和 App Hub 商店应用以隔离的脚本程序运行,受信任的原生模块在进程内运行,Makepad 开发者程序作为子进程运行。它获取应用的方式与手机 Shell [Home](../phone/README.zh-CN.md) 完全相同。环境准备、仓库结构和 CI 见[根目录 README](../README.zh-CN.md)。 -> **在整个系统中的位置。**桌面端是一个 Shell 进程,octos 内核是它的子进程(`OCTOS_APP_CORE_BIN`)。App Hub、运行脚本应用的 Card runner 和 Rinx 在进程内运行;Terminal 作为独立进程运行,在 macOS 和 Linux 上运行在系统沙箱中(Windows 上尚未实现),通过 Shell 的 hub 连接。在 macOS 上,该沙箱中 `~/.cargo`、`~/.rustup` 和 OctoSense 源码目录是只读的,因此 `cargo install`、`rustup update` 以及构建 OctoSense 本身都要在其他终端里运行。进程、应用 Agent 的两条通道以及一次带审批的工具调用的图示:[整体如何运作](../README.zh-CN.md#整体如何运作);详细说明:[docs/architecture.zh-CN.md](../docs/architecture.zh-CN.md) 和 [ADR 0004(英文)](../docs/adr/0004-native-apps-hosting-and-peers.md)。 +> **在整个系统中的位置。**桌面端是一个 Shell 进程,octos 内核是它的子进程(随附的 `octos-kernel`,或 `OCTOS_APP_CORE_BIN`)。App Hub、运行脚本应用的 Card runner 和 Rinx 在进程内运行;Terminal 作为独立进程运行,在 macOS 和 Linux 上运行在系统沙箱中(Windows 上尚未实现),通过 Shell 的 hub 连接。在 macOS 上,该沙箱中 `~/.cargo`、`~/.rustup` 和 OctoSense 源码目录是只读的,因此 `cargo install`、`rustup update` 以及构建 OctoSense 本身都要在其他终端里运行。进程、应用 Agent 的两条通道以及一次带审批的工具调用的图示:[整体如何运作](../README.zh-CN.md#整体如何运作);详细说明:[docs/architecture.zh-CN.md](../docs/architecture.zh-CN.md) 和 [ADR 0004(英文)](../docs/adr/0004-native-apps-hosting-and-peers.md)。 **要开发 OctoSense 应用?** 构建、检查和发布应用都不需要本仓库:请从 [OctoSense-org 主页](https://github.com/OctoSense-org)的阅读列表开始(先读 OctoScript-App-Design-Flow 的 `AGENTS.md`,再读 `docs/QUICKSTART.md`)。只有想在发布前在桌面 Shell 中看到自己的应用时,才需要构建本 Shell(见[发布前试用自己的应用](#发布前试用自己的应用))。 @@ -21,7 +21,7 @@ | [OctoScript-App-Design-Flow](https://github.com/OctoSense-org/OctoScript-App-Design-Flow) | 设计、构建应用并发布到 App Hub 的地方。 | | [OctoScript-Makepad](https://github.com/OctoSense-org/OctoScript-Makepad) | 固定 Makepad 与 OctoScript 版本的运行时发布。检出在 `.sources/` 中。 | | [makepad(OctoSense 分支)](https://github.com/OctoSense-org/makepad) | 框架。检出在 `.sources/makepad` 中。 | -| [octos](https://github.com/octos-org/octos) | Agent 内核,一项 Shell 服务(`octos-core`,默认开启):AI 提供商配置它,AppCard、Rinx 等使用方连接它。只有一个版本,固定在根目录 `Cargo.toml` 中;内核本身是单独的二进制(桌面:`OCTOS_APP_CORE_BIN`;Android:打包的 `liboctos.so`)。 | +| [octos](https://github.com/octos-org/octos) | Agent 内核,一项 Shell 服务(`octos-core`,默认开启):AI 提供商配置它,AppCard、Rinx 等使用方连接它。只有一个版本,固定在根目录 `Cargo.toml` 中;内核本身是单独的二进制(桌面:Shell 旁随附的 `octos-kernel`,见[构建与运行](#构建与运行),或 `OCTOS_APP_CORE_BIN`;Android:打包的 `liboctos.so`)。 | | [Rinx](https://github.com/hagency-org/Rinx) | Matrix 聊天与小程序,作为模块链接(`app-rinx`,默认开启)。 | ## `desktop/` 的结构 @@ -58,12 +58,17 @@ python3 tools/setup.py --check --cargo # verify: one Makepad, App Hub, octos an ## 构建与运行 -准备完成后,在仓库根目录或 `desktop/` 中运行: +准备完成后,在仓库根目录先把 octos 内核放到 Shell 旁边,再构建并运行: ```sh +python3 tools/kernel-artifact.py --host --stage target/release # 每个 octos 固定版本做一次 cargo run --release -p octosense ``` +`kernel-artifact.py --host` 把 `Cargo.lock` 固定的 octos 版本检出到 `target/octos-kernel/`(不会使用你自己的检出),并为本机构建;`--stage target/release` 把它以 `octos-kernel` 放到该目录,并写入收据 `octos-kernel.json`(版本号、`--version` 输出和 SHA-256)。`--kernel <路径>` 可改用你已构建好的二进制;其 `--version` 不是固定版本时会被拒绝。调试构建请放到 `target/debug`。单独执行 `cargo run` 不会构建或安装内核。 + +启动时,内核服务在 `octosense` 可执行文件旁查找 `octos-kernel`(在 macOS `.app` 中还会查找 `Contents/Resources`),从不在工作目录或 `PATH` 中查找;只有当收据中的版本与本次构建固定的 octos 版本一致、且二进制的 SHA-256 相符时才运行它。否则桌面在没有助手的情况下运行并说明原因,例如 octos 固定版本更新后会显示 `refusing the packaged kernel …: it is octos <旧版本> but this build pins <新版本>`,重新执行上面的 stage 即可。`OCTOS_APP_CORE_BIN=<路径>` 仍然优先,且不做检查。针对其他 octos 版本开发时,`OCTOSENSE_KERNEL_ANY_REVISION=1` 可以运行其他版本的随附内核(仍要求有收据且 SHA-256 相符)。 + 桌面启动时是空的。可从 dock、左上角的 **Apps** 菜单或 **⌘Space**(菜单与搜索)启动 App Hub、系统应用或开发者程序。**System → Quit OctoSense** 会关闭桌面及其托管的一切。 `config/apps.json` 中的开发者程序在首次启动时构建(进度显示在 tile 中)。 @@ -116,7 +121,7 @@ App Hub 的模块没有进程形态,总是在进程内打开。 | `MAKEPAD_APP_CONFIG='{"mail_demo":true}'` | 提供邮件的演示邮箱(见[演示](#演示))。 | | `OCTOSENSE_MAIL_VAULT=file` | 把邮件密码保存在权限为 0600 的文件中,而不是 macOS 钥匙串。 | | `OCTOSENSE_LLM_VAULT=file` | 把 AI 提供商的密钥保存在仅所有者可读的 octos profile 中,而不是 macOS 钥匙串。 | -| `OCTOS_APP_CORE_BIN`、`OCTOS_APP_CORE_DIR` | Shell 内核服务运行的 octos 内核二进制(未设置:此桌面上没有内核)及其 core 目录(默认 `~/octos-home/.octos`;AI 提供商的 profile 为 `/profiles/_main.json`)。 | +| `OCTOS_APP_CORE_BIN`、`OCTOS_APP_CORE_DIR` | Shell 内核服务运行的 octos 内核二进制,不做检查(未设置:使用随附的 `octos-kernel`,见[构建与运行](#构建与运行))及其 core 目录(默认 `~/octos-home/.octos`;AI 提供商的 profile 为 `/profiles/_main.json`)。 | | `OCTOSENSE_GLANCE_DEMO=1` | 启动时以 `os.news` 身份向一览屏发布一张示例 L0 新闻摘要卡片:桌面风格下按 F9 查看,手机风格下在一览页查看。用于测试 `glance` 服务。 | | `OCTOSENSE_GLANCE_DEMO=mail` | 启动时以 `os.mail` 身份发布两张假的邮件操作卡片(L0,各带一条通知):点击通知会在卡片窗口中打开对应卡片,可用假数据试用回复、发送(演示)、提问和跟踪。在随卡片打开的一览面板里,它们同样可用。不读取邮件,也不调用模型。`scripts/mail_card_remote.sh` 以隐藏窗口驱动它。 | | `MAKEPAD_REMOTE`、`MAKEPAD_HIDE_WINDOWS` | 远程控制桥;隐藏窗口(见[演示](#演示))。 | @@ -150,7 +155,7 @@ launcher 把四类应用列在一起: AI 提供商(`os.ai-providers`)通过 `llm` 服务(`octosense-llm-service`,来自 [`../apps/ai-providers/host-service`](../apps/ai-providers/host-service))编辑 octos 内核的 LLM 提供商。密钥只在宿主面板上输入,保存到 octos 读取的 macOS 钥匙串条目;提供商写入 Shell 的 octos core 目录下内核的 profile(`/profiles/_main.json`;core 目录为 `OCTOS_APP_CORE_DIR`,否则为 `~/octos-home/.octos`)。手机上的提供商二维码可从图片导入:**Choose image** 打开文件面板,或把截图拖到导入面板上。**开始 → 设置 → AI providers** 可打开它。更改后服务会重启正在运行的内核,使用方(AppCard)会重新连接到新内核。 -**octos 内核**是一项 Shell 服务,不属于任何应用:`octosense-kernel`([`../crates/kernel`](../crates/kernel),feature `octos-core`,默认开启)。Shell 在启动时通过其 AI 服务启动它([`../crates/ai-host`](../crates/ai-host/README.md),`octosense_ai_host::start`);在有使用方连接之前不运行任何东西,之后每个进程只有一个内核(桌面上是 ` serve --stdio --data-dir `,Android 上是 APK 中的 `liboctos.so`;iOS 以及未设置 `OCTOS_APP_CORE_BIN` 的桌面上没有内核)。AppCard 的 Agent 连接它;Rinx 通过应用与 Agent 之间的代理访问它。最后一个使用方离开或 Shell 退出时内核停止。 +**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`);在有使用方连接之前不运行任何东西,之后每个进程只有一个内核(桌面上是随附的 `octos-kernel` 或 `OCTOS_APP_CORE_BIN`,参数为 `serve --stdio --data-dir `;Android 上是 APK 中的 `liboctos.so`;iOS 以及两者都没有的桌面上没有内核)。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)。 @@ -228,7 +233,7 @@ AppCard **目前不随产品发布**:它会干扰其他应用,因此除非 cargo run --release -p octosense --features app-appcard -- --module appcard ``` -它不会自己启动内核,而是连接 Shell 的内核。在桌面上这需要 `OCTOS_APP_CORE_BIN`(`OCTOS_APP_CORE_DIR` 可选);没有内核时显示登录 / WebSocket 界面。所有 octos crate 都来自 octos-org/octos,且只有根目录 `Cargo.toml` 固定的那一个版本。 +它不会自己启动内核,而是连接 Shell 的内核。在桌面上即随附的 `octos-kernel` 或 `OCTOS_APP_CORE_BIN`(`OCTOS_APP_CORE_DIR` 可选);没有内核时显示登录 / WebSocket 界面。所有 octos crate 都来自 octos-org/octos,且只有根目录 `Cargo.toml` 固定的那一个版本。 ## 演示 diff --git a/docs/ai-services.md b/docs/ai-services.md index 5184168c..02d527b6 100644 --- a/docs/ai-services.md +++ b/docs/ai-services.md @@ -24,7 +24,7 @@ For the whole system around it (processes per platform, agents, protocols, tools | Piece | Status | Where | | --- | --- | --- | -| One octos kernel per shell process, started on first use, restarted after a provider change | Works today (desktop with `OCTOS_APP_CORE_BIN`, Android, OpenHarmony; none on iOS) | [`crates/kernel`](../crates/kernel/README.md) | +| One octos kernel per shell process, started on first use, restarted after a provider change | Works today (desktop with its packaged `octos-kernel` or `OCTOS_APP_CORE_BIN`, Android, OpenHarmony; none on iOS) | [`crates/kernel`](../crates/kernel/README.md) | | AI providers: the person's model providers and keys, keys only on host sheets | Works today | [`apps/ai-providers`](../apps/ai-providers/host-service/README.md) | | The shell's AI entry point (`start`, policy, per-instance offer, QR import) | Works today | [`crates/ai-host`](../crates/ai-host/README.md) | | Host-owned app peers: one octos peer per granted app/account, owned by the system agent | Implemented for native modules (Rinx) and contained script apps after per-app consent | [`crates/app-peers`](../crates/app-peers/README.md), [Rinx ADR 0007](https://github.com/hagency-org/Rinx/blob/main/docs/adr/0007-host-owned-octos-app-peers.md) | @@ -89,7 +89,7 @@ How it runs, by platform (`crates/kernel/src/launch.rs`, `KernelSource::platform | Platform | Kernel | Core dir (the octos home) | | --- | --- | --- | -| Desktop (macOS; Windows and Linux untested) | `$OCTOS_APP_CORE_BIN serve --stdio --data-dir ` (plus `--config /config.json` if present). **Without an explicit kernel program or `OCTOS_APP_CORE_BIN` there is no desktop kernel**, and a developer's own `octos serve` is never touched. Kernel packaging was tracked in [#85](https://github.com/OctoSense-org/OctoSense/pull/85); this recipe uses an explicit binary. | `$OCTOS_APP_CORE_DIR`, else `/octos-home/.octos` (`~/.octosense/octos-home/.octos`): OctoSense's own, no longer the person's `~/octos-home/.octos`, whose provider settings it copies once | +| Desktop (macOS; Windows and Linux untested) | ` serve --stdio --data-dir ` (plus `--config /config.json` if present), where the kernel is an explicit kernel program (`Options::program`), else `$OCTOS_APP_CORE_BIN`, else the packaged `octos-kernel` beside the shell, run only when its receipt names the pinned octos revision and its SHA-256 matches ([desktop/README.md, Build and run](../desktop/README.md#build-and-run)). **With none of these there is no desktop kernel**, and a developer's own `octos serve` is never touched. | `$OCTOS_APP_CORE_DIR`, else `/octos-home/.octos` (`~/.octosense/octos-home/.octos`): OctoSense's own, no longer the person's `~/octos-home/.octos`, whose provider settings it copies once | | Android (Home) | The APK's `liboctos.so serve --stdio`, built by [`tools/kernel-artifact.py`](../tools/kernel-artifact.py) from the octos revision the root `Cargo.toml` pins | `/octos-home/.octos` | | OpenHarmony | In process (`octos_cli::embedded::serve_io`), because a HAP may not exec | `/octos-home/.octos` | | iOS | **None.** Providers are still saved; no app gets an assistant | – | @@ -219,7 +219,7 @@ A script's own assistant calls use declared `octos.*` services. Separately, the | `octos.*` with arguments beyond the rules | `r.error`: `Unsupported Octos arguments`, or `Provide text (at most 32 KiB)` | | `octos.*` with the assistant turned off for apps (`OCTOSENSE_CONTAINED_APPS=0`) | `r.error`: `The assistant is turned off for apps on this device` | | `octos.*` before the person allowed the app's agent | `r.error`: `Waiting for the person to allow this app's agent (OctoSense asks the first time)`, and the shell shows its first-use sheet (read in `contained.rs` and `approvals/mod.rs`; **unverified** in a running shell) | -| `octos.*` on a desktop without `OCTOS_APP_CORE_BIN` | `r.error`: `no octos kernel: no kernel binary configured (OCTOS_APP_CORE_BIN)` | +| `octos.*` on a desktop without a packaged kernel or `OCTOS_APP_CORE_BIN` | `r.error`: `no octos kernel: no packaged octos-kernel beside and no OCTOS_APP_CORE_BIN override; …` (a stale packaged kernel: `no octos kernel: refusing the packaged kernel …`) | | `octos.*` in a build that links no kernel (iOS) | `r.error`: `no service answers "octos" on this device` (**unverified**) | | `llm.*` from a store app granted `llm` | `llm is for OctoSense's own apps.` | | anything in App Hub's `card-host` | `no service answers "" on this device` (`card-host` registers no services) | @@ -273,7 +273,7 @@ The build/launch recipes below are **unverified**; read dependency versions from cargo run --release -p octosense ``` - The log says `octos: kernel service ready (starts on first use), core dir …`. Without `OCTOS_APP_CORE_BIN` it says there is no kernel, and AI providers still saves providers. + The log says `octos: kernel service ready (starts on first use), core dir …`. Without `OCTOS_APP_CORE_BIN` it uses the packaged `octos-kernel` beside the shell if there is one (`python3 tools/kernel-artifact.py --host --stage target/release`), else says there is no kernel, and AI providers still saves providers. 3. Open **Start → Settings → AI providers**, add a model (family, model, route, key, **Test connection**, save). The profile is `$T/octos-home/.octos/profiles/_main.json`; with the file vault the key is in it, so delete `$T` afterwards. 4. Use the assistant through a consumer: Rinx (linked by default and in-process; open it from the launcher; sign in to Matrix, allow its agent on the first-use sheet, then use its assistant), or AppCard (`--features app-appcard`). A contained app reaches it through the `octos` host service ([above](#contained-script-apps-system-and-store)); leave `OCTOSENSE_CONTAINED_APPS` unset and allow the app's agent when the shell asks. F8 also opens the system chat; use `agents.ask` for consent/preparation, then `peer_send_input` for delegation. diff --git a/docs/ai-services.zh-CN.md b/docs/ai-services.zh-CN.md index f30065f6..aaab5235 100644 --- a/docs/ai-services.zh-CN.md +++ b/docs/ai-services.zh-CN.md @@ -24,7 +24,7 @@ | 组成部分 | 状态 | 位置 | | --- | --- | --- | -| 每个 Shell 进程一个 octos 内核,首次使用时启动,提供方变更后重启 | 目前可用(设置了 `OCTOS_APP_CORE_BIN` 的桌面端、Android、OpenHarmony;iOS 没有) | [`crates/kernel`](../crates/kernel/README.md) | +| 每个 Shell 进程一个 octos 内核,首次使用时启动,提供方变更后重启 | 目前可用(有随附 `octos-kernel` 或设置了 `OCTOS_APP_CORE_BIN` 的桌面端、Android、OpenHarmony;iOS 没有) | [`crates/kernel`](../crates/kernel/README.md) | | AI providers:用户的模型提供方和密钥,密钥只在宿主面板上输入 | 目前可用 | [`apps/ai-providers`](../apps/ai-providers/host-service/README.md) | | Shell 的 AI 入口(`start`、策略、按实例提供服务、QR 导入) | 目前可用 | [`crates/ai-host`](../crates/ai-host/README.md) | | 宿主拥有的应用 peer:每个获授权的应用/账号一个 octos peer,归系统 Agent 所有 | 已实现:原生模块(Rinx)及经过按应用同意的隔离脚本应用 | [`crates/app-peers`](../crates/app-peers/README.md)、[Rinx ADR 0007](https://github.com/hagency-org/Rinx/blob/main/docs/adr/0007-host-owned-octos-app-peers.md) | @@ -89,7 +89,7 @@ flowchart TB | 平台 | 内核 | Core 目录(octos home) | | --- | --- | --- | -| 桌面端(macOS;Windows 和 Linux 未测试) | `$OCTOS_APP_CORE_BIN serve --stdio --data-dir `(存在 `/config.json` 时再加 `--config`)。**未显式指定内核程序或 `OCTOS_APP_CORE_BIN` 时没有桌面内核**,开发者自己运行的 `octos serve` 永远不会被动到。[#85](https://github.com/OctoSense-org/OctoSense/pull/85) 是内核打包的历史跟踪;此配方显式指定二进制。 | `$OCTOS_APP_CORE_DIR`,否则为 `/octos-home/.octos`(`~/.octosense/octos-home/.octos`):OctoSense 自己的目录,不再是用户的 `~/octos-home/.octos`(只从中复制一次提供商设置) | +| 桌面端(macOS;Windows 和 Linux 未测试) | `<内核> serve --stdio --data-dir `(存在 `/config.json` 时再加 `--config`),内核为显式指定的内核程序(`Options::program`),否则为 `$OCTOS_APP_CORE_BIN`,再否则为 Shell 旁随附的 `octos-kernel`;后者只有在收据中的版本与固定的 octos 版本一致且 SHA-256 相符时才运行([desktop/README.zh-CN.md,构建与运行](../desktop/README.zh-CN.md#构建与运行))。**三者都没有时没有桌面内核**,开发者自己运行的 `octos serve` 永远不会被动到。 | `$OCTOS_APP_CORE_DIR`,否则为 `/octos-home/.octos`(`~/.octosense/octos-home/.octos`):OctoSense 自己的目录,不再是用户的 `~/octos-home/.octos`(只从中复制一次提供商设置) | | Android(Home) | APK 中的 `liboctos.so serve --stdio`,由 [`tools/kernel-artifact.py`](../tools/kernel-artifact.py) 按根 `Cargo.toml` 锁定的 octos 版本构建 | `<应用数据目录>/octos-home/.octos` | | OpenHarmony | 进程内运行(`octos_cli::embedded::serve_io`),因为 HAP 不能 exec | `<应用数据目录>/octos-home/.octos` | | iOS | **没有。** 提供方仍会保存;没有应用能获得助手 | – | @@ -217,7 +217,7 @@ flowchart TB | 参数超出规则的 `octos.*` | `r.error`:`Unsupported Octos arguments` 或 `Provide text (at most 32 KiB)` | | 设备为应用关闭了助手时(`OCTOSENSE_CONTAINED_APPS=0`)的 `octos.*` | `r.error`:`The assistant is turned off for apps on this device` | | 用户尚未允许该应用的 Agent 时的 `octos.*` | `r.error`:`Waiting for the person to allow this app's agent (OctoSense asks the first time)`,同时 Shell 显示首次使用面板(在 `contained.rs` 和 `approvals/mod.rs` 中读到;未在运行的 Shell 中验证,**unverified**) | -| 在未设置 `OCTOS_APP_CORE_BIN` 的桌面端调用 `octos.*` | `r.error`:`no octos kernel: no kernel binary configured (OCTOS_APP_CORE_BIN)` | +| 在既无随附内核又未设置 `OCTOS_APP_CORE_BIN` 的桌面端调用 `octos.*` | `r.error`:`no octos kernel: no packaged octos-kernel beside <目录> and no OCTOS_APP_CORE_BIN override; …`(随附内核版本过旧时:`no octos kernel: refusing the packaged kernel …`) | | 在不链接内核的构建(iOS)中调用 `octos.*` | `r.error`:`no service answers "octos" on this device`(**未验证**) | | 获得 `llm` 授权的商店应用调用 `llm.*` | `llm is for OctoSense's own apps.` | | 在 App Hub 的 `card-host` 中调用任何服务 | `no service answers "" on this device`(`card-host` 不注册任何服务) | @@ -271,7 +271,7 @@ manifest 的 `agent` 和已准入 `tools.json` 已参与 Shell 的 Agent 发现 cargo run --release -p octosense ``` - 日志中会出现 `octos: kernel service ready (starts on first use), core dir …`。没有 `OCTOS_APP_CORE_BIN` 时日志会说明没有内核,AI providers 仍会保存提供方。 + 日志中会出现 `octos: kernel service ready (starts on first use), core dir …`。没有 `OCTOS_APP_CORE_BIN` 时使用 Shell 旁随附的 `octos-kernel`(`python3 tools/kernel-artifact.py --host --stage target/release`),两者都没有时日志会说明没有内核,AI providers 仍会保存提供方。 3. 打开 **Start → Settings → AI providers**,添加一个模型(family、模型、路由、密钥、**Test connection**、保存)。配置文件是 `$T/octos-home/.octos/profiles/_main.json`;使用文件密钥库时密钥就在其中,用完后请删除 `$T`。 4. 通过某个使用者来使用助手:Rinx(默认链接并在进程内运行;从启动器打开它;登录 Matrix,在首次使用面板上允许它的 Agent,然后使用它的助手),或 AppCard(`--features app-appcard`)。隔离运行的应用通过 `octos` 宿主服务访问它([见上文](#隔离运行的脚本应用系统应用和商店应用));保持 `OCTOSENSE_CONTAINED_APPS` 未设置,并在 Shell 询问时同意。F8 也可打开系统聊天;`agents.ask` 用于同意/准备,`peer_send_input` 才委派任务。 diff --git a/docs/architecture.md b/docs/architecture.md index 08e5267c..4fcd0b0f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -94,7 +94,7 @@ Each shell runs at most one [octos](https://github.com/octos-org/octos) kernel, | Platform | Kernel | How it is started | | --- | --- | --- | -| Desktop (macOS; Windows and Linux untested) | a child process: the `octos` binary named by `OCTOS_APP_CORE_BIN` (or the embedder's `Options::program`) | `serve --stdio --data-dir `; without a binary there is no kernel (no `PATH` lookup). Shipping the binary with the desktop is in progress ([#85](https://github.com/OctoSense-org/OctoSense/pull/85)). | +| Desktop (macOS; Windows and Linux untested) | a child process: the embedder's `Options::program`, else the `octos` binary named by `OCTOS_APP_CORE_BIN`, else the packaged `octos-kernel` beside the shell (run only when its receipt names the pinned octos revision) | `serve --stdio --data-dir `; without a binary there is no kernel (no `PATH` or working-directory lookup). | | Android (Home) | a child process: the APK's bundled `liboctos.so`, built by [`tools/kernel-artifact.py`](../tools/kernel-artifact.py) at the pinned octos revision | `serve --stdio`, found next to `libmakepad.so` in the app's native library directory | | OpenHarmony | in process: `octos_cli::embedded::serve_io` as a task over an in-memory duplex (a HAP may not exec) | same protocol, no child | | iOS | none | the providers are saved; no app gets an assistant | @@ -140,7 +140,7 @@ Script apps (the system apps News, Photos, Maps, Camera on phones, Mail, AI prov flowchart TB subgraph desktop["Desktop (macOS)"] ds["OctoSense process
shell + App Hub + Card runner + Rinx"] - dk["octos child
OCTOS_APP_CORE_BIN serve --stdio"] + dk["octos child
octos-kernel serve --stdio"] dt["Terminal child
--stdin-loop"] ds -- "stdin/stdout" --> dk dt -- "hub WebSocket, IOSurface frames" --> ds diff --git a/docs/architecture.zh-CN.md b/docs/architecture.zh-CN.md index 1d9b021b..870d3557 100644 --- a/docs/architecture.zh-CN.md +++ b/docs/architecture.zh-CN.md @@ -94,7 +94,7 @@ flowchart LR | 平台 | 内核 | 如何启动 | | --- | --- | --- | -| 桌面端(macOS;Windows 和 Linux 未经测试) | 子进程:`OCTOS_APP_CORE_BIN`(或嵌入方的 `Options::program`)指定的 `octos` 二进制 | `serve --stdio --data-dir `;没有二进制就没有内核(不在 `PATH` 中查找)。随桌面端一起发布该二进制正在进行中([#85](https://github.com/OctoSense-org/OctoSense/pull/85))。 | +| 桌面端(macOS;Windows 和 Linux 未经测试) | 子进程:嵌入方的 `Options::program`,否则为 `OCTOS_APP_CORE_BIN` 指定的 `octos` 二进制,否则为 Shell 旁随附的 `octos-kernel`(只有收据中的版本与固定的 octos 版本一致时才运行) | `serve --stdio --data-dir `;没有二进制就没有内核(不在 `PATH` 或工作目录中查找)。 | | Android(Home) | 子进程:APK 中打包的 `liboctos.so`,由 [`tools/kernel-artifact.py`](../tools/kernel-artifact.py) 按锁定的 octos 版本构建 | `serve --stdio`,在应用的原生库目录中、`libmakepad.so` 旁边找到 | | OpenHarmony | 进程内:`octos_cli::embedded::serve_io` 作为一个任务运行在内存中的双工流上(HAP 不允许 exec) | 同样的协议,没有子进程 | | iOS | 无 | 提供方配置会保存;没有应用能用上助手 | @@ -140,7 +140,7 @@ Shell 在运行时如何决定(`crates/shell/src/apps.rs`,`AppRegistry::host flowchart TB subgraph desktop["桌面端(macOS)"] ds["OctoSense 进程
Shell + App Hub + Card runner + Rinx"] - dk["octos 子进程
OCTOS_APP_CORE_BIN serve --stdio"] + dk["octos 子进程
octos-kernel serve --stdio"] dt["Terminal 子进程
--stdin-loop"] ds -- "stdin/stdout" --> dk dt -- "hub WebSocket、IOSurface 画面" --> ds diff --git a/tools/kernel-artifact.py b/tools/kernel-artifact.py index d04cb2c1..06e7dd76 100755 --- a/tools/kernel-artifact.py +++ b/tools/kernel-artifact.py @@ -14,7 +14,12 @@ 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); +- `--host` builds it for this desktop instead (no SDK needed), and + `--stage ` puts it in `` as the desktop's packaged kernel: + `octos-kernel[.exe]` (refused unless its `--version` names the locked + revision) and the receipt `octos-kernel.json` ({source, revision, version, + sha256}) the desktop checks before it runs that kernel + (crates/kernel/src/launch.rs); - `--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 @@ -22,6 +27,7 @@ python3 tools/kernel-artifact.py --sdk \\ -- cargo makepad android run -p octosense --release + python3 tools/kernel-artifact.py --host --stage target/release # desktop `--plan` prints the steps as JSON without running anything. Build scripts import this file (`kernel_plan`, `extra_libs`, `receipt`) rather than copying @@ -34,8 +40,10 @@ import os from pathlib import Path import re +import shutil import subprocess import sys +import tempfile ROOT = Path(__file__).resolve().parents[1] OCTOS_URL = "https://github.com/octos-org/octos.git" @@ -45,6 +53,10 @@ # embedder (needs cmake and is not used on a phone). KERNEL_BUILD = ["-p", "octos-cli", "--bin", "octos", "--no-default-features", "--features", "api,git,ast"] LIB_NAME = "liboctos.so" +# The desktop's packaged kernel and its receipt (crates/kernel/src/launch.rs: +# PACKAGED_KERNEL, PACKAGED_RECEIPT). +STAGED_NAME = "octos-kernel" + (".exe" if os.name == "nt" else "") +RECEIPT_NAME = "octos-kernel.json" def octos_revision(lock=None): @@ -110,11 +122,12 @@ def plan(revision, work, sdk, offline=False, required=True, host=False): 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] + # No `env` wrapper: this plan also runs on Windows. + command = ["cargo", "build", "--locked", "--release", "--target-dir", str(work / "target"), *KERNEL_BUILD] if offline: command.append("--offline") steps.append((src, command)) - return steps, work / "target/release/octos" + return steps, work / "target/release" / ("octos" + (".exe" if os.name == "nt" else "")) steps.append((src, build_command(sdk, work / "target", offline, required))) return steps, work / "target" / TARGET / "release/octos" @@ -145,6 +158,47 @@ def receipt(kernel, source): return {"source": source, "sha256": hashlib.sha256(Path(kernel).read_bytes()).hexdigest()} +def version_matches(version, revision): + """`octos 2.0.3-rc.13 (ae230ce 2026-10-01)`: octos's `--version` names + the short commit it was built from.""" + match = re.fullmatch(r"octos \S+ \(([0-9a-f]{7,40})(?: \d{4}-\d{2}-\d{2})?\)", version.strip()) + return bool(match and revision.startswith(match[1])) + + +def stage(kernel, directory, revision, source=None): + """Put `kernel` in `directory` as the desktop's packaged kernel, with its + receipt. The copy is checked before anything is replaced: a binary whose + `--version` does not name `revision` leaves an earlier staged kernel and + receipt as they were. Returns the receipt.""" + kernel, directory = Path(kernel), Path(directory) + directory.mkdir(parents=True, exist_ok=True) + fd, temp = tempfile.mkstemp(prefix=".octos-kernel-", suffix=".tmp", dir=directory) + os.close(fd) + temp = Path(temp) + try: + shutil.copy2(kernel, temp) + temp.chmod(0o755) + try: + version = subprocess.run([str(temp), "--version"], capture_output=True, text=True, timeout=60, + check=True).stdout.strip() + except (OSError, subprocess.SubprocessError) as e: + raise RuntimeError(f"{kernel} does not run here ({e}): not a kernel for this machine") from None + if not version_matches(version, revision): + raise RuntimeError(f"{kernel} reports {version!r}, not the locked octos revision {revision}") + record = {"source": source or f"{OCTOS_URL}@{revision}", "revision": revision, "version": version, + "sha256": hashlib.sha256(temp.read_bytes()).hexdigest()} + receipt_path = directory / RECEIPT_NAME + receipt_temp = receipt_path.with_name(receipt_path.name + ".tmp") + receipt_temp.write_text(json.dumps(record, indent=2) + "\n") + # The kernel first: a receipt never describes a binary not yet there + # (the desktop then refuses the pair on its hash, never runs it). + os.replace(temp, directory / STAGED_NAME) + os.replace(receipt_temp, receipt_path) + return record + finally: + temp.unlink(missing_ok=True) + + def run_steps(steps): for cwd, command in steps: Path(cwd).mkdir(parents=True, exist_ok=True) @@ -167,10 +221,15 @@ def main(argv=None): p.add_argument("--work", type=Path, help="Where octos is checked out and built (default: target/octos-kernel)") p.add_argument("--offline", action="store_true") p.add_argument("--receipt", type=Path, help="Write {source, sha256} of the kernel here (JSON)") + p.add_argument("--stage", type=Path, metavar="DIR", + help=f"Desktop: put the kernel in DIR as {STAGED_NAME} with its receipt {RECEIPT_NAME} " + "(with --host, or --kernel for a prebuilt one for this machine)") p.add_argument("--plan", action="store_true", help="Print the plan as JSON; run nothing") args = p.parse_args(argv) if args.kernel: args.kernel = args.kernel.resolve() + if args.stage and (args.no_kernel or not (args.host or args.kernel)): + p.error("--stage is for the desktop: pass --host (or --kernel with a kernel for this machine)") 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, @@ -181,6 +240,7 @@ def main(argv=None): print(json.dumps({"kernel": str(kernel) if kernel else None, "source": origin, "android_env": {} if args.host else {"MAKEPAD_ANDROID_EXTRA_LIBS": extra_libs(kernel)}, "steps": [{"cwd": str(cwd), "argv": c} for cwd, c in steps], + "stage": str(args.stage.resolve() / STAGED_NAME) if args.stage else None, "then": command or None}, indent=2)) return if args.kernel and not args.kernel.is_file(): @@ -191,6 +251,12 @@ def main(argv=None): print(f"octos kernel: {kernel}", flush=True) if args.receipt: args.receipt.write_text(json.dumps(receipt(kernel, origin), indent=2) + "\n") + if args.stage: + try: + staged = stage(kernel, args.stage, octos_revision(args.lock), origin) + except RuntimeError as e: + sys.exit(f"kernel-artifact: {e}") + print(f"staged: {args.stage / STAGED_NAME} ({staged['version']}, sha256 {staged['sha256'][:16]})", flush=True) if command: env = dict(os.environ) env.pop("MAKEPAD_ANDROID_EXTRA_LIBS", None) diff --git a/tools/test_kernel_artifact.py b/tools/test_kernel_artifact.py index e8138e87..2dd7716b 100644 --- a/tools/test_kernel_artifact.py +++ b/tools/test_kernel_artifact.py @@ -4,6 +4,7 @@ import importlib.util import io import json +import os from pathlib import Path import tempfile import unittest @@ -132,5 +133,81 @@ def test_the_cli_prints_a_plan_and_runs_nothing(self): kernel.main(["--kernel", "/k/octos", "--no-kernel"]) +@unittest.skipIf(os.name == "nt", "the stand-in kernel is a POSIX shell script") +class StageTests(unittest.TestCase): + """--stage: the desktop's packaged kernel and the receipt + crates/kernel/src/launch.rs checks before running it.""" + + def stand_in(self, directory, version): + program = Path(directory) / "octos" + program.write_text(f"#!/bin/sh\nprintf '%s\\n' '{version}'\n") + program.chmod(0o755) + return program + + def test_the_version_must_name_the_locked_revision(self): + self.assertTrue(kernel.version_matches(f"octos 2.0.3-rc.13 ({REV[:7]} 2026-10-01)", REV)) + self.assertTrue(kernel.version_matches(f"octos 2.0.3 ({REV[:9]})", REV)) + self.assertFalse(kernel.version_matches("octos 2.0.3-rc.13 (0000000 2026-10-01)", REV)) + self.assertFalse(kernel.version_matches("octos 2.0.3", REV)) + self.assertFalse(kernel.version_matches(f"something else ({REV[:7]})", REV)) + + def test_a_staged_kernel_carries_its_revision_and_hash(self): + with tempfile.TemporaryDirectory() as temp: + source = self.stand_in(temp, f"octos 2.0.3-rc.13 ({REV[:7]} 2026-10-01)") + out = Path(temp) / "target dir/release" + record = kernel.stage(source, out, REV) + staged = out / kernel.STAGED_NAME + self.assertTrue(os.access(staged, os.X_OK)) + on_disk = json.loads((out / kernel.RECEIPT_NAME).read_text()) + self.assertEqual(on_disk, record) + self.assertEqual(on_disk["revision"], REV) + self.assertEqual(on_disk["source"], f"{kernel.OCTOS_URL}@{REV}") + self.assertEqual(on_disk["sha256"], hashlib.sha256(staged.read_bytes()).hexdigest()) + self.assertEqual(sorted(p.name for p in out.iterdir()), sorted([kernel.STAGED_NAME, kernel.RECEIPT_NAME])) + + def test_a_kernel_of_another_revision_replaces_nothing(self): + with tempfile.TemporaryDirectory() as temp: + out = Path(temp) / "release" + out.mkdir() + (out / kernel.STAGED_NAME).write_bytes(b"previous") + (out / kernel.RECEIPT_NAME).write_text("previous receipt") + wrong = self.stand_in(temp, "octos 2.0.3-rc.13 (0000000 2026-09-01)") + with self.assertRaisesRegex(RuntimeError, "not the locked octos revision"): + kernel.stage(wrong, out, REV) + self.assertEqual((out / kernel.STAGED_NAME).read_bytes(), b"previous") + self.assertEqual((out / kernel.RECEIPT_NAME).read_text(), "previous receipt") + self.assertEqual(len(list(out.iterdir())), 2, "no temporary file is left behind") + garbage = Path(temp) / "garbage" + garbage.write_bytes(b"\x00\x01 not a program") + with self.assertRaisesRegex(RuntimeError, "does not run here"): + kernel.stage(garbage, out, REV) + + def test_the_cli_stages_only_a_desktop_kernel(self): + with tempfile.TemporaryDirectory() as temp: + lock = Path(temp) / "Cargo.lock" + lock.write_text(lock_with(REV)) + source = self.stand_in(temp, f"octos 2.0.3 ({REV[:7]} 2026-10-01)") + out = io.StringIO() + with contextlib.redirect_stdout(out): + kernel.main(["--lock", str(lock), "--kernel", str(source), "--stage", str(Path(temp) / "rel")]) + self.assertIn("staged:", out.getvalue()) + self.assertEqual(json.loads((Path(temp) / "rel" / kernel.RECEIPT_NAME).read_text())["source"], "prebuilt") + plan = io.StringIO() + with contextlib.redirect_stdout(plan): + kernel.main(["--lock", str(lock), "--host", "--work", str(Path(temp) / "w"), "--stage", str(Path(temp) / "x"), "--plan"]) + printed = json.loads(plan.getvalue()) + self.assertEqual(Path(printed["stage"]).name, kernel.STAGED_NAME) + self.assertIn("--target-dir", printed["steps"][-1]["argv"], "no `env` wrapper: Windows runs it too") + self.assertFalse((Path(temp) / "x").exists(), "a plan stages nothing") + for argv in (["--sdk", "/sdk", "--stage", "x"], ["--no-kernel", "--stage", "x"]): + with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): + kernel.main(["--lock", str(lock), *argv]) + + def test_the_names_are_the_ones_the_desktop_looks_for(self): + launch = (ROOT / "crates/kernel/src/launch.rs").read_text() + self.assertIn('"octos-kernel"', launch) + self.assertIn(f'"{kernel.RECEIPT_NAME}"', launch) + + if __name__ == "__main__": unittest.main()