diff --git a/Cargo.lock b/Cargo.lock index 0bf2de31..ea68b71a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -303,7 +303,7 @@ dependencies = [ "objc2-foundation", "parking_lot", "percent-encoding", - "windows-sys 0.52.0", + "windows-sys 0.60.2", "x11rb", ] @@ -356,7 +356,7 @@ dependencies = [ [[package]] name = "article-core" version = "0.1.0" -source = "git+https://github.com/hagency-org/Rinx.git?rev=a72e4b003a05ebcb31bc86c346d7dd06cdffbceb#a72e4b003a05ebcb31bc86c346d7dd06cdffbceb" +source = "git+https://github.com/hagency-org/Rinx.git?rev=197d5170892d64046ba8e291e80aa326c4e4c87a#197d5170892d64046ba8e291e80aa326c4e4c87a" dependencies = [ "blake3", "image", @@ -373,7 +373,7 @@ dependencies = [ [[package]] name = "article-makepad" version = "0.1.0" -source = "git+https://github.com/hagency-org/Rinx.git?rev=a72e4b003a05ebcb31bc86c346d7dd06cdffbceb#a72e4b003a05ebcb31bc86c346d7dd06cdffbceb" +source = "git+https://github.com/hagency-org/Rinx.git?rev=197d5170892d64046ba8e291e80aa326c4e4c87a#197d5170892d64046ba8e291e80aa326c4e4c87a" dependencies = [ "article-core", "libc", @@ -2057,7 +2057,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.3", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8510,7 +8510,7 @@ dependencies = [ [[package]] name = "rinx" version = "1.0.0-beta.1" -source = "git+https://github.com/hagency-org/Rinx.git?rev=a72e4b003a05ebcb31bc86c346d7dd06cdffbceb#a72e4b003a05ebcb31bc86c346d7dd06cdffbceb" +source = "git+https://github.com/hagency-org/Rinx.git?rev=197d5170892d64046ba8e291e80aa326c4e4c87a#197d5170892d64046ba8e291e80aa326c4e4c87a" dependencies = [ "anyhow", "arboard", @@ -8556,6 +8556,7 @@ dependencies = [ "rangemap", "rfd", "rinx-miniapp-core", + "rinx-system-apps", "robius-directories", "robius-file-picker", "robius-location", @@ -8582,8 +8583,18 @@ dependencies = [ [[package]] name = "rinx-miniapp-core" version = "0.1.0" -source = "git+https://github.com/hagency-org/Rinx.git?rev=a72e4b003a05ebcb31bc86c346d7dd06cdffbceb#a72e4b003a05ebcb31bc86c346d7dd06cdffbceb" +source = "git+https://github.com/hagency-org/Rinx.git?rev=197d5170892d64046ba8e291e80aa326c4e4c87a#197d5170892d64046ba8e291e80aa326c4e4c87a" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "rinx-system-apps" +version = "0.1.0" +source = "git+https://github.com/hagency-org/Rinx.git?rev=197d5170892d64046ba8e291e80aa326c4e4c87a#197d5170892d64046ba8e291e80aa326c4e4c87a" dependencies = [ + "octosense-app-policy", "serde", "serde_json", ] @@ -10918,7 +10929,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 56d46b34..4c3f7a1a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -86,7 +86,7 @@ octos-core = { git = "https://github.com/octos-org/octos.git", rev = "a6ea850517 octos-cli = { git = "https://github.com/octos-org/octos.git", rev = "a6ea8505170735f191a12bb47629a6728415d417", 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 = "a72e4b003a05ebcb31bc86c346d7dd06cdffbceb", default-features = false, features = ["octosense-module"] } +rinx = { git = "https://github.com/hagency-org/Rinx.git", rev = "197d5170892d64046ba8e291e80aa326c4e4c87a", default-features = false, features = ["octosense-module"] } # --- AppCard's inline renderers. streaming-markdown-kit = { git = "https://github.com/Project-Robius-China/streaming-markdown-kit.git", branch = "main" } makepad-diagram-kit = { git = "https://github.com/Project-Robius-China/makepad-diagram-kit.git", branch = "main" } @@ -208,12 +208,11 @@ octoscript-makepad = { path = ".sources/octoscript-makepad/crates/octoscript-mak octoscript-widgets = { path = ".sources/octoscript-makepad/crates/octoscript-widgets" } octoscript-node = { path = ".sources/octoscript-makepad/crates/octoscript-node" } -# Rinx still takes the app-peers contract by git from OctoSense-System-Apps -# (archived; it was imported into this repository): that code lives here -# now, so these are the same crates this workspace links — one broker -# contract and one kernel. -[patch."https://github.com/OctoSense-org/OctoSense-System-Apps.git"] +# Hosted Rinx uses this workspace's broker/config/kernel, including when +# its standalone workspace pins an earlier OctoSense revision. +[patch."https://github.com/OctoSense-org/OctoSense.git"] octosense-app-peers = { path = "crates/app-peers" } +octosense-kernel = { path = "crates/kernel" } octosense-llm-config = { path = "apps/ai-providers/config" } # Rinx's Matrix stack: the patched ruma its own workspace uses (Rinx's diff --git a/README.md b/README.md index cd55cb61..017d6ad8 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ 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 it finds the packaged `octos-kernel` beside the shell; `OCTOS_APP_CORE_BIN` remains an override. | | [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). @@ -70,6 +70,8 @@ cargo check --locked -p octosense --features mobile-apps cargo check --locked -p octosense -p octosense-appcard --features mobile-apps,app-appcard ``` +For the desktop AI runtime, `tools/build-desktop.py` builds and stages the locked kernel beside the shell. See [desktop build instructions and validation limits](desktop/README.md#build-and-run). Cargo alone does not install the kernel. + **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 08959d75..eed7d027 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -42,7 +42,7 @@ 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`;`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)。 @@ -70,6 +70,8 @@ cargo check --locked -p octosense --features mobile-apps cargo check --locked -p octosense -p octosense-appcard --features mobile-apps,app-appcard ``` +桌面 AI 运行时由 `tools/build-desktop.py` 按锁定版本构建并放到程序旁;具体步骤和已验证范围见 [桌面构建说明](desktop/README.zh-CN.md#构建与运行)。独立执行 Cargo 不会安装内核。 + **手机端**(在 `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 c7e018bb..f84fc1fb 100644 --- a/crates/ai-host/README.md +++ b/crates/ai-host/README.md @@ -30,7 +30,7 @@ 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 sibling 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 22883501..2d4c1beb 100644 --- a/crates/ai-host/src/lib.rs +++ b/crates/ai-host/src/lib.rs @@ -64,8 +64,8 @@ pub enum KernelSource { Bundled, /// OpenHarmony: the canonical core linked in-process. InProcess, - /// Desktop: the binary `$OCTOS_APP_CORE_BIN` names; none without it (a - /// developer's own `octos serve` is never touched). + /// Desktop: `$OCTOS_APP_CORE_BIN`, or the packaged sibling executable. + /// The name stays compatible; no developer kernel is attached to. Env, /// Desktop or Android: this binary. Program(PathBuf), @@ -76,7 +76,7 @@ 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, override/packaged sibling on a desktop, none on iOS. pub fn platform() -> Self { if cfg!(target_os = "android") { KernelSource::Bundled diff --git a/crates/kernel/README.md b/crates/kernel/README.md index 0f0796bd..91ee8c2b 100644 --- a/crates/kernel/README.md +++ b/crates/kernel/README.md @@ -36,8 +36,9 @@ How it starts, per platform (`src/launch.rs`): - **Desktop**: ` serve --stdio --data-dir ` (plus `--config /config.json` when that file exists) with `OCTOS_HOME=`; the program is the shell's `Options::program` or - `$OCTOS_APP_CORE_BIN`. With neither there is no kernel: a developer's own - `octos serve` is never touched. + `$OCTOS_APP_CORE_BIN`, falling back to `octos-kernel[.exe]` beside the shell + executable. `tools/build-desktop.py` stages the locked runtime there. No + PATH search or attachment to another running kernel occurs. - **iOS**: no kernel. The kernel and the frame pump run on the crate's own Tokio runtime, so a diff --git a/crates/kernel/examples/packaged_probe.rs b/crates/kernel/examples/packaged_probe.rs new file mode 100644 index 00000000..80fc5322 --- /dev/null +++ b/crates/kernel/examples/packaged_probe.rs @@ -0,0 +1,57 @@ +//! Offline verification of a packaged sibling, without a program override. +//! Run beside `octos-kernel`; uses a new temporary profile, no provider calls. +use std::{collections::BTreeMap, path::Path, time::{Duration, SystemTime, UNIX_EPOCH}}; +use octosense_kernel::{CloseReason, Connection, Core, Options}; +use octosense_llm_config::{profile, Provider, ProviderSet}; +use serde_json::{json, Value}; + +async fn call(conn: &mut Connection, id: &str, method: &str, params: Value) -> Value { + conn.send(json!({"jsonrpc":"2.0", "id":id, "method":method, "params":params}).to_string()).unwrap(); + loop { + let text = tokio::time::timeout(Duration::from_secs(30), conn.recv()).await.unwrap().unwrap(); + let reply: Value = serde_json::from_str(&text).unwrap(); + if reply["id"] == id { + assert!(reply.get("error").is_none(), "{reply}"); + return reply["result"].clone(); + } + } +} + +fn provider(root: &Path, family: &str, model: &str) { + let primary = Provider::new(family, Some(model.into())); + let env = BTreeMap::from([(primary.key_env.clone(), "test-fixture-not-a-real-key".into())]); + profile::save_merge(&profile::profile_path(root), &ProviderSet {primary: Some(primary), fallbacks: vec![]}, &env).unwrap(); +} + +#[tokio::main] +async fn main() { + assert!(std::env::var_os("OCTOS_APP_CORE_BIN").is_none(), "probe requires automatic discovery"); + let root = std::env::temp_dir().join(format!("octos-packaged-{}-{}", std::process::id(), SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos())); + std::fs::create_dir(&root).unwrap(); + let core = Core::new(Options::default().core_dir(&root)); + struct Cleanup(Core, std::path::PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + self.0.shutdown_within(Duration::from_secs(5)); + let _ = std::fs::remove_dir_all(&self.1); + } + } + let _cleanup = Cleanup(core.clone(), root.clone()); + provider(&root, "deepseek", "deepseek-v4-flash"); + let mut first = core.connect().expect("packaged runtime must be found without an override"); + let mut second = core.connect().unwrap(); + assert_eq!(first.generation(), second.generation()); + let list = call(&mut first, "providers", "profile/llm/list", json!({"profile_id":"_main"})).await; + assert_eq!(list["primary"]["model"], "deepseek-v4-flash"); + let list = call(&mut second, "providers", "profile/llm/list", json!({"profile_id":"_main"})).await; + assert_eq!(list["primary"]["model"], "deepseek-v4-flash"); + provider(&root, "moonshot", "kimi-k2.5"); + assert!(core.restart()); + assert_eq!(first.recv().await, Err(CloseReason::Restarted)); + assert_eq!(second.recv().await, Err(CloseReason::Restarted)); + let mut next = core.connect().unwrap(); + assert_eq!(next.generation(), 2); + let list = call(&mut next, "providers", "profile/llm/list", json!({"profile_id":"_main"})).await; + assert_eq!(list["primary"]["model"], "kimi-k2.5"); + println!("PASS: sibling runtime, shared kernel, provider profile and restart; no LLM request"); +} diff --git a/crates/kernel/src/launch.rs b/crates/kernel/src/launch.rs index a93b7e1e..6d327048 100644 --- a/crates/kernel/src/launch.rs +++ b/crates/kernel/src/launch.rs @@ -10,9 +10,9 @@ //! (`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 that +//! order. No PATH search or connection to another running kernel occurs. //! - **iOS**: no kernel (an app cannot exec a child). use std::path::{Path, PathBuf}; @@ -96,20 +96,36 @@ 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("OCTOS_APP_CORE_BIN").filter(|v| !v.is_empty()).map(PathBuf::from); + let executable = std::env::current_exe().ok(); + let program = desktop_program(inputs.program, env.as_deref(), executable.as_deref())?; Ok(desktop_stdio(program, core_dir, inputs.env)) } } +/// Overrides are authoritative: a broken override must not silently choose +/// another version. The sibling travels with the shell, independent of cwd. +#[cfg(not(any(target_env = "ohos", target_os = "ios", target_os = "android")))] +fn desktop_program(explicit: Option<&Path>, env: Option<&Path>, executable: Option<&Path>) -> Result { + let packaged = executable.and_then(Path::parent).map(|dir| { + dir.join(if cfg!(windows) { "octos-kernel.exe" } else { "octos-kernel" }) + }); + let program = explicit.or(env).or(packaged.as_deref()).ok_or_else(|| { + Unavailable::NoKernel("no packaged kernel or OCTOS_APP_CORE_BIN override".into()) + })?; + if !program.is_file() { + return Err(Unavailable::NoKernel(format!("{} is not a file; build the desktop runtime with tools/build-desktop.py", 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(program.to_owned()) +} + /// 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 @@ -338,6 +354,48 @@ mod tests { let _ = std::fs::remove_dir_all(home); } + #[cfg(not(any(target_env = "ohos", target_os = "ios", target_os = "android")))] + #[test] + fn desktop_finds_its_sibling_and_keeps_overrides_authoritative() { + let dir = tmp("packaged"); + let shell = dir.join("octosense"); + let bundled = dir.join(if cfg!(windows) { "octos-kernel.exe" } else { "octos-kernel" }); + let explicit = dir.join("explicit"); + let env = dir.join("environment"); + for p in [&bundled, &explicit, &env] { + std::fs::write(p, "fixture").unwrap(); + #[cfg(unix)] { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + } + assert_eq!(desktop_program(None, None, Some(&shell)).unwrap(), bundled); + assert_eq!(desktop_program(None, Some(&env), Some(&shell)).unwrap(), env); + assert_eq!(desktop_program(Some(&explicit), Some(&env), Some(&shell)).unwrap(), explicit); + let missing = dir.join("missing"); + assert!(desktop_program(Some(&missing), Some(&env), Some(&shell)).is_err()); + assert!(desktop_program(None, Some(&missing), Some(&shell)).is_err()); + // Discovery follows the installed executable after a directory move. + let moved = dir.with_extension("moved"); + std::fs::rename(&dir, &moved).unwrap(); + let sibling = moved.join(bundled.file_name().unwrap()); + assert_eq!(desktop_program(None, None, Some(&moved.join("octosense"))).unwrap(), sibling); + std::fs::remove_file(sibling).unwrap(); + assert!(desktop_program(None, None, Some(&moved.join("octosense"))).is_err()); + assert!(desktop_program(None, None, None).is_err()); + std::fs::remove_dir_all(moved).unwrap(); + } + + #[cfg(all(unix, not(any(target_env = "ohos", target_os = "ios", target_os = "android"))))] + #[test] + fn desktop_rejects_a_nonexecutable_runtime() { + let dir = tmp("not-executable"); + let program = dir.join("octos-kernel"); + std::fs::write(&program, "fixture").unwrap(); + assert!(desktop_program(None, None, Some(&dir.join("octosense"))).is_err()); + std::fs::remove_dir_all(dir).unwrap(); + } + #[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 68029f43..024f2d9f 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -86,7 +86,8 @@ 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. 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 cae7b4df..fd825aa6 100644 --- a/crates/kernel/tests/core.rs +++ b/crates/kernel/tests/core.rs @@ -180,11 +180,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 permissions pass resolution; a missing interpreter makes + // the actual spawn fail, exercising the asynchronous failure path. + 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 f5c88609..0c13b68c 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -17,7 +17,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: packaged `octos-kernel`, with `OCTOS_APP_CORE_BIN` override; 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/` @@ -54,12 +54,17 @@ Details, `--update` and `--cache`: [root README](../README.md#set-up). ## Build and run -From the repository root or `desktop/`, after setup: +After setup, run `tools/build-desktop.py` from the repository root. It builds the shell and places `octos-kernel` beside it at the revision in `Cargo.lock`. Launching that shell needs no `OCTOS_APP_CORE_BIN`; an explicit override still wins. + +Full release recipe (a fresh release rebuild was not run in this validation): ```sh -cargo run --release -p octosense +python3 tools/build-desktop.py +./target/release/octosense ``` +Use `--profile dev` for a debug build and `--kernel PATH` to reuse a native runtime. The builder verifies its revision through `--version` and records SHA-256. The macOS validation used that reuse path. Plain `cargo run` does not install a missing runtime. + 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). @@ -112,7 +117,7 @@ App Hub's modules are the exception: they have no process form and always open i | `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 (unset: use the packaged sibling) 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. | | `MAKEPAD_REMOTE`, `MAKEPAD_HIDE_WINDOWS` | Remote-control bridge; hidden windows (see [Demos](#demos)). | @@ -145,7 +150,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` override) with `serve --stdio --data-dir ` on a desktop, the APK's `liboctos.so` on Android; none on iOS or when a desktop has neither a packaged kernel nor an override). 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. 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. @@ -221,7 +226,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 it uses the packaged sibling or `OCTOS_APP_CORE_BIN` override (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 18f3c199..7f2e0151 100644 --- a/desktop/README.zh-CN.md +++ b/desktop/README.zh-CN.md @@ -17,7 +17,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` 中;内核本身是单独的二进制(桌面:随附的 `octos-kernel`,可用 `OCTOS_APP_CORE_BIN` 覆盖;Android:打包的 `liboctos.so`)。 | | [Rinx](https://github.com/hagency-org/Rinx) | Matrix 聊天与小程序,作为模块链接(`app-rinx`,默认开启)。 | ## `desktop/` 的结构 @@ -54,12 +54,17 @@ python3 tools/setup.py --check --cargo # verify: one Makepad, App Hub, octos an ## 构建与运行 -准备完成后,在仓库根目录或 `desktop/` 中运行: +在仓库根目录完成 setup 后,`tools/build-desktop.py` 构建 Shell,并在程序旁放置与 `Cargo.lock` 一致的 `octos-kernel`。启动桌面无需设置 `OCTOS_APP_CORE_BIN`;显式设置仍会覆盖随附内核。 + +完整 release 构建流程(本次未执行全新 release 重编译): ```sh -cargo run --release -p octosense +python3 tools/build-desktop.py +./target/release/octosense ``` +可用 `--profile dev` 构建调试版,并用 `--kernel PATH` 复用已编译的原生内核;脚本会检查其 `--version` 中的提交号与锁定版本一致,并记录 SHA-256。本次 macOS 验证使用了这一复用方式。单独运行 `cargo run` 不会安装缺失的内核。 + 桌面启动时是空的。可从 dock、左上角的 **Apps** 菜单或 **⌘Space**(菜单与搜索)启动 App Hub、系统应用或开发者程序。**System → Quit OctoSense** 会关闭桌面及其托管的一切。 `config/apps.json` 中的开发者程序在首次启动时构建(进度显示在 tile 中)。 @@ -112,7 +117,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` 服务。 | | `MAKEPAD_REMOTE`、`MAKEPAD_HIDE_WINDOWS` | 远程控制桥;隐藏窗口(见[演示](#演示))。 | @@ -145,7 +150,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 退出时内核停止。 需要密码、PIN 或令牌的新功能,应放在宿主服务和宿主自有面板中,绝不放在应用自己的界面里。 @@ -221,7 +226,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_APP_CORE_BIN`(`OCTOS_APP_CORE_DIR` 可选);没有内核时显示登录 / WebSocket 界面。所有 octos crate 都来自 octos-org/octos,且只有根目录 `Cargo.toml` 固定的那一个版本。 ## 演示 diff --git a/tools/build-desktop.py b/tools/build-desktop.py new file mode 100644 index 00000000..ae3476ef --- /dev/null +++ b/tools/build-desktop.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Build the desktop shell with its pinned Octos runtime beside it. + +No environment override is needed when starting the resulting shell. This +produces a source-build layout, not a relocatable installer or signed .app. +Use --kernel to reuse a native executable whose --version matches Cargo.lock. +""" +import argparse +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location("kernel_artifact", ROOT / "tools/kernel-artifact.py") +kernel_tool = importlib.util.module_from_spec(spec) +spec.loader.exec_module(kernel_tool) +SUFFIX = ".exe" if os.name == "nt" else "" + + +def version_matches(version, revision): + 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): + """Check the actual staged bytes before replacing an earlier runtime.""" + kernel, directory = Path(kernel), Path(directory) + directory.mkdir(parents=True, exist_ok=True) + destination = directory / ("octos-kernel" + SUFFIX) + fd, temp = tempfile.mkstemp(prefix=".octos-stage-", suffix=SUFFIX, dir=directory) + os.close(fd) + temp = Path(temp) + try: + shutil.copy2(kernel, temp) + temp.chmod(0o755) + version = subprocess.check_output([str(temp), "--version"], text=True, timeout=30).strip() + if not version_matches(version, revision): + raise RuntimeError(f"Kernel version {version!r} differs from the locked revision {revision}") + receipt = {"source": kernel_tool.OCTOS_URL, "revision": revision, + "version": version, "sha256": hashlib.sha256(temp.read_bytes()).hexdigest()} + temp.replace(destination) + (directory / "octos-kernel.json").write_text(json.dumps(receipt, indent=2) + "\n") + return destination + finally: + temp.unlink(missing_ok=True) + + +def source_at(revision, work, offline): + source = work / "src" + source.mkdir(parents=True, exist_ok=True) + def git(*args, check=True): + return subprocess.run(["git", "-C", str(source), *args], capture_output=True, text=True, check=check) + if not (source / ".git").exists(): + if any(source.iterdir()): + raise RuntimeError(f"Preserving nonempty kernel source directory: {source}") + git("init", "--quiet") + if git("status", "--porcelain").stdout.strip(): + raise RuntimeError(f"Preserving modified kernel source directory: {source}") + if git("cat-file", "-e", revision + "^{commit}", check=False).returncode: + if offline: + raise RuntimeError("Pinned kernel source is not cached; run once without --offline") + git("fetch", "--quiet", "--no-tags", "--depth=1", kernel_tool.OCTOS_URL, revision) + git("checkout", "--quiet", "--detach", revision) + return source + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--profile", choices=("dev", "release"), default="release") + parser.add_argument("--features", help="Additional shell features, e.g. mobile-apps") + parser.add_argument("--target-dir", type=Path, default=Path(os.environ.get("CARGO_TARGET_DIR", ROOT / "target"))) + parser.add_argument("--work", type=Path, default=ROOT / "target/desktop-kernel") + parser.add_argument("--kernel", type=Path, help="Reuse a native executable from the locked Octos revision") + parser.add_argument("--offline", action="store_true") + parser.add_argument("--plan", action="store_true", help="Print the build plan; create nothing") + args = parser.parse_args(argv) + revision = kernel_tool.octos_revision() + target, work = args.target_dir.resolve(), args.work.resolve() + output = target / ("debug" if args.profile == "dev" else "release") + kernel = args.kernel.resolve() if args.kernel else work / "target/release" / ("octos" + SUFFIX) + build_kernel = ["cargo", "build", "--locked", "--release", "--target-dir", str(work / "target"), *kernel_tool.KERNEL_BUILD] + build_shell = ["cargo", "build", "--locked", "-p", "octosense", "--profile", args.profile, "--target-dir", str(target)] + if args.features: + build_shell += ["--features", args.features] + if args.offline: + build_kernel.append("--offline") + build_shell.append("--offline") + if args.plan: + print(json.dumps({"revision": revision, "kernel_source": "prebuilt" if args.kernel else kernel_tool.OCTOS_URL, + "kernel_build": None if args.kernel else build_kernel, + "shell_build": build_shell, "kernel": str(kernel), + "shell": str(output / ("octosense" + SUFFIX)), + "packaged_kernel": str(output / ("octos-kernel" + SUFFIX))}, indent=2)) + return + subprocess.run([sys.executable, str(ROOT / "tools/setup.py"), "--check", "--cargo"], cwd=ROOT, check=True) + if not args.kernel: + source = source_at(revision, work, args.offline) + env = dict(os.environ) + for name in ("RUSTFLAGS", "CARGO_ENCODED_RUSTFLAGS", "MAKEPAD", "MAKEPAD_PACKAGE_DIR", "CARGO_BUILD_TARGET"): + env.pop(name, None) + subprocess.run(build_kernel, cwd=source, env=env, check=True) + # Validate/stage first: a wrong prebuilt must not leave a newly built + # shell next to the wrong runtime. The shell remains a normal Cargo build. + runtime = stage(kernel, output, revision) + env = dict(os.environ) + env.pop("CARGO_BUILD_TARGET", None) + subprocess.run(build_shell, cwd=ROOT, env=env, check=True) + print(json.dumps({"shell": str(output / ("octosense" + SUFFIX)), "kernel": str(runtime), "revision": revision})) + + +if __name__ == "__main__": + main() diff --git a/tools/test_build_desktop.py b/tools/test_build_desktop.py new file mode 100644 index 00000000..0c5b3929 --- /dev/null +++ b/tools/test_build_desktop.py @@ -0,0 +1,80 @@ +"""Desktop packaging contracts; no downloads, real credentials or LLM calls.""" +import contextlib +import hashlib +import importlib.util +import io +import json +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location("build_desktop", Path(__file__).with_name("build-desktop.py")) +desktop = importlib.util.module_from_spec(spec) +spec.loader.exec_module(desktop) +REV = "a6ea8505170735f191a12bb47629a6728415d417" + + +class DesktopRuntime(unittest.TestCase): + def test_plan_uses_locked_kernel_and_stages_next_to_shell(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) / "not-created" + output = io.StringIO() + with contextlib.redirect_stdout(output): + desktop.main(["--plan", "--profile", "dev", "--target-dir", str(root), "--offline"]) + plan = json.loads(output.getvalue()) + self.assertEqual(plan["revision"], desktop.kernel_tool.octos_revision()) + self.assertEqual(Path(plan["shell"]).parent, Path(plan["packaged_kernel"]).parent) + self.assertIn("--locked", plan["kernel_build"]) + self.assertIn("--offline", plan["kernel_build"]) + self.assertFalse(root.exists()) + + def test_version_must_identify_the_locked_revision(self): + self.assertTrue(desktop.version_matches(f"octos 2.0.3-rc.13 ({REV[:7]} 2026-09-27)", REV)) + self.assertFalse(desktop.version_matches("octos 2.0.3-rc.13 (0000000 2026-09-27)", REV)) + self.assertFalse(desktop.version_matches("octos 2.0.3", REV)) + + def test_wrong_runtime_preserves_existing_binary_and_receipt(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source" + source.write_bytes(b"kernel") + out = root / "packaged" + out.mkdir() + dest = out / ("octos-kernel" + desktop.SUFFIX) + dest.write_bytes(b"previous") + receipt = out / "octos-kernel.json" + receipt.write_text("previous receipt") + with patch.object(desktop.subprocess, "check_output", return_value="octos wrong"): + with self.assertRaisesRegex(RuntimeError, "differs"): + desktop.stage(source, out, REV) + self.assertEqual(dest.read_bytes(), b"previous") + self.assertEqual(receipt.read_text(), "previous receipt") + self.assertEqual(sorted(p.name for p in out.iterdir()), sorted([dest.name, receipt.name])) + + @unittest.skipIf(os.name == "nt", "executable script fixture is POSIX") + def test_staged_executable_is_verified_and_receipted(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source" + source.write_text(f"#!/bin/sh\nprintf '%s\\n' 'octos 2.0.3-rc.13 ({REV[:7]} 2026-09-27)'\n") + dest = desktop.stage(source, root / "with spaces", REV) + receipt = json.loads(dest.with_name("octos-kernel.json").read_text()) + self.assertEqual(receipt["revision"], REV) + self.assertEqual(receipt["sha256"], hashlib.sha256(dest.read_bytes()).hexdigest()) + self.assertTrue(os.access(dest, os.X_OK)) + + def test_modified_private_source_is_preserved(self): + with tempfile.TemporaryDirectory() as tmp: + work = Path(tmp) + source = work / "src" + source.mkdir() + (source / "keep").write_text("user work") + with self.assertRaisesRegex(RuntimeError, "Preserving"): + desktop.source_at(REV, work, offline=True) + self.assertEqual((source / "keep").read_text(), "user work") + + +if __name__ == "__main__": + unittest.main()