From fa25e8f6eea3955406476cf9547668847da285b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tyrese=20Luo=20=28=E7=BE=85=E5=81=A5=E5=B3=AF=29?= Date: Wed, 14 Oct 2026 10:09:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(native-apps):=20=E5=BC=95=E5=85=A5?= =?UTF-8?q?=E5=8E=9F=E7=94=9F=E7=BC=96=E7=A0=81=E5=BA=94=E7=94=A8=20OctoBu?= =?UTF-8?q?ddy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 以 git pin(f714871,v0.3.0)从 github.com/tyreseluo/OctoBuddy 引入原生编码应用:外环 把请求拆成切片,inner 并行实现,宿主跑检查、提交并验收;支持 Claude Code、 Codex、pi 和 octos 四种 agent。桌面 shell 默认编入,手机 shell 不编。 - native-apps.json 注册(模块形态、network any、可起进程、每 account 一份 agent 工作区),给系统的 agent 工具四个:octobuddy.status / send / report / request,并授权 terminal.run - docs/native-apps/octobuddy(.zh-CN).md:逐项说明这些授权的用途、约束和拿掉的 后果;README 的 agent 表链接过去 - tools/native_apps.py:原生 app 可声明显示名(name,可选,不超过 40 字符), NativeApp 随之生成;approvals::sheet::app_label 优先用它(确认单上是 「OctoBuddy」而不是「Octobuddy」) - 新测试:生成的 Rust 能解析(结构体字段和每个 NativeApp 条目的字段都以逗号 结尾,不靠 rustfmt 也查;有 rustfmt 时整份解析)、name 的校验与生成、 app_label 用声明的 name - OctoBuddy 与本仓共用一个 App Hub(3863591)、一个 makepad(d653bc4)和本仓 的 app-peers / llm-config,Cargo.lock 只多出 OctoBuddy 一项 --- Cargo.lock | 74 +++++++++++++++++++++++++++-- Cargo.toml | 1 + README.md | 2 + README.zh-CN.md | 2 + crates/ai-host/src/native_agents.rs | 1 + crates/shell/Cargo.toml | 2 + crates/shell/src/approvals/sheet.rs | 6 ++- crates/shell/src/approvals/tests.rs | 8 ++++ crates/shell/src/native_apps.rs | 46 ++++++++++++++++++ crates/shell/src/sandbox/tests.rs | 1 + desktop/Cargo.toml | 3 +- docs/native-apps/octobuddy.md | 32 +++++++++++++ docs/native-apps/octobuddy.zh-CN.md | 32 +++++++++++++ native-apps.json | 26 ++++++++++ tools/native_apps.py | 14 +++++- tools/test_native_apps.py | 36 +++++++++++++- 16 files changed, 275 insertions(+), 11 deletions(-) create mode 100644 docs/native-apps/octobuddy.md create mode 100644 docs/native-apps/octobuddy.zh-CN.md diff --git a/Cargo.lock b/Cargo.lock index 2c5153194..7e8a3ca9b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3361,6 +3361,21 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "disktree-core" +version = "0.11.0" +source = "git+https://github.com/tobi/disktree?rev=6c8d4ce6211bf135ff4e42890faebff1040e1846#6c8d4ce6211bf135ff4e42890faebff1040e1846" +dependencies = [ + "chrono", + "rayon", + "rustc-hash 2.1.3", + "rustix", + "sysinfo 0.31.4", + "trash", + "winapi-util", + "windows-sys 0.61.2", +] + [[package]] name = "dispatch2" version = "0.3.1" @@ -5381,8 +5396,8 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", - "windows-result 0.4.1 (registry+https://github.com/rust-lang/crates.io-index)", + "windows-link 0.1.3", + "windows-result 0.3.4", ] [[package]] @@ -6297,7 +6312,7 @@ dependencies = [ "log", "presser", "thiserror 2.0.20", - "windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)", + "windows 0.61.3", ] [[package]] @@ -7043,7 +7058,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)", + "windows-core 0.61.2", ] [[package]] @@ -10976,7 +10991,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "subtle", - "sysinfo", + "sysinfo 0.34.2", "tar", "tempfile", "tokio", @@ -11828,6 +11843,24 @@ dependencies = [ "uuid", ] +[[package]] +name = "octosense-octobuddy" +version = "0.3.2" +source = "git+https://github.com/tyreseluo/OctoBuddy.git?rev=668765cc3e1e6c2ccd9a35baaeabebd76d3da852#668765cc3e1e6c2ccd9a35baaeabebd76d3da852" +dependencies = [ + "base64 0.22.1", + "disktree-core", + "makepad-app-module", + "makepad-terminal", + "makepad-widgets", + "octosense-app-peers", + "octosense-app-policy", + "octosense-llm-config", + "serde", + "serde_json", + "sha2 0.10.9", +] + [[package]] name = "octosense-pdf-service" version = "0.1.0" @@ -11955,6 +11988,7 @@ dependencies = [ "octosense-markdown-editor", "octosense-news-service", "octosense-oauth-service", + "octosense-octobuddy", "octosense-pdf-service", "octosense-photo-service", "octosense-reference", @@ -17053,6 +17087,19 @@ dependencies = [ "walkdir", ] +[[package]] +name = "sysinfo" +version = "0.31.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "355dbe4f8799b304b05e1b0f05fc59b2a18d36645cf169607da45bde2f69a1be" +dependencies = [ + "core-foundation-sys", + "libc", + "memchr 2.8.3", + "ntapi", + "windows 0.56.0", +] + [[package]] name = "sysinfo" version = "0.34.2" @@ -17715,6 +17762,23 @@ dependencies = [ "strength_reduce", ] +[[package]] +name = "trash" +version = "5.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be89b3fe156965d29ac4f8522f3a640c655affdd9f21cb4f36857f0c92c00317" +dependencies = [ + "libc", + "log", + "objc2", + "objc2-foundation", + "once_cell 1.21.4", + "percent-encoding", + "scopeguard", + "urlencoding", + "windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "tree-sitter" version = "0.24.7" diff --git a/Cargo.toml b/Cargo.toml index 57ec9df34..c02b00e72 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,6 +91,7 @@ authors = ["octos-app contributors"] octosense-updater-ui = { path = "crates/updater-ui" } rinx = { git = "https://github.com/hagency-org/Rinx.git", tag = "v1.1.0", default-features = false, features = ["octosense-module"] } octosense-reference = { path = "apps/reference" } +octosense-octobuddy = { git = "https://github.com/tyreseluo/OctoBuddy.git", rev = "668765cc3e1e6c2ccd9a35baaeabebd76d3da852", default-features = false, features = ["octosense-module"] } makepad-sheets = { git = "https://github.com/OctoSense-org/makepad.git", rev = "d653bc4493619ab979ccff165cf48c0c63b77d19", default-features = false } makepad-terminal = { git = "https://github.com/OctoSense-org/makepad.git", rev = "d653bc4493619ab979ccff165cf48c0c63b77d19" } octosense-appcard = { path = "apps/appcard/module" } diff --git a/README.md b/README.md index bf5bdfe2d..9b468dc14 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,7 @@ These apps have an agent: | App | Kind | Its agent's own tools | What the system agent may call | | --- | --- | --- | --- | | Rinx (Matrix chat) | native, in the shell | octos's file, memory and web tools | – | +| OctoBuddy (desktop; [its grants](docs/native-apps/octobuddy.md)) | native, in the shell | its loops' own agents (Claude Code, Codex, pi, octos), checked by the host, and `terminal.run` | `octobuddy.status` (read); `octobuddy.send`, `octobuddy.report` and `octobuddy.request`, each confirmed by the person | | Terminal (desktop) | native; its own process in a checkout build, inside the shell in a release package | `terminal.read_screen`, `terminal.read_scrollback` | `terminal.run`, only while it runs as its own sandboxed process, behind Setup's switch, approved per command | | Calculator, Clock, Notes, Reminders, Weather | native, in the shell | each app's read tools | the same read tools | | App Hub | native, in the shell | `apphub.search`, `apphub.installed`, `apphub.updates`, read only: installs and updates stay on App Hub's own screens | the same read tools | @@ -407,6 +408,7 @@ Pinned exactly once, in the root `Cargo.toml` and the runtime locks: | [OctoSense-App-Hub](https://github.com/OctoSense-org/OctoSense-App-Hub) | The signed catalog, the store, and the Card runner that contains every script app. | | [octos](https://github.com/octos-org/octos) | The agent kernel. Android bundles it in the APK as `liboctos.so`; the desktop runs the packaged `octos-kernel` beside the shell, checked against this revision (`tools/kernel-artifact.py --host --stage` builds it). | | [Rinx](https://github.com/hagency-org/Rinx) | Matrix chats and mini apps, hosted as a native module. | +| [OctoBuddy](https://github.com/tyreseluo/OctoBuddy) | The coding app: an outer loop plans the work into slices, inner loops build them in parallel, and the host checks and reviews what lands. Hosted as a native module on the desktop. | | [OctosCode](https://github.com/octos-org/octoscode-app) | The coding client of the person's assistant, hosted as a native module that reaches the kernel through its port in the coding scope (ADR 0003, item 9). | Related, not build inputs: [OctoSense-App-Flow](https://github.com/OctoSense-org/OctoSense-App-Flow) (how apps are built and published), [OctoScript-Android](https://github.com/OctoSense-org/OctoScript-Android) and [OctoScript-OH](https://github.com/OctoSense-org/OctoScript-OH) (other renderers), the [OctoSense website](https://github.com/OctoSense-org/octosense-org.github.io). diff --git a/README.zh-CN.md b/README.zh-CN.md index f84fa73ca..3680bd9fa 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -135,6 +135,7 @@ flowchart LR | 应用 | 类型 | 它的 Agent 自己的工具 | 系统 Agent 可以调用 | | --- | --- | --- | --- | | Rinx(Matrix 聊天) | 原生,在 Shell 内 | octos 的文件、记忆和网页工具 | – | +| OctoBuddy(桌面;[授权说明](docs/native-apps/octobuddy.zh-CN.md)) | 原生,在 Shell 内 | 它双环自己驱动的 agent(Claude Code、Codex、pi、octos),由宿主检查验收,另有 `terminal.run` | `octobuddy.status`(只读);`octobuddy.send`、`octobuddy.report` 与 `octobuddy.request`,每次都经人确认 | | Terminal(桌面端) | 原生;源码检出构建中为独立进程,发布包中在 Shell 内 | `terminal.read_screen`、`terminal.read_scrollback` | `terminal.run`,只在它作为独立的沙箱进程运行时提供,受 Setup 开关控制,每条命令都要批准 | | Calculator、Clock、Notes、Reminders、Weather | 原生,在 Shell 内 | 各自的只读工具 | 同样的只读工具 | | App Hub | 原生,在 Shell 内 | `apphub.search`、`apphub.installed`、`apphub.updates`,只读:安装和更新仍在 App Hub 自己的界面上进行 | 同样的只读工具 | @@ -395,6 +396,7 @@ Shell 只有一份,位于 `crates/shell`([ADR 0001(英文)](docs/adr/000 | [OctoSense-App-Hub](https://github.com/OctoSense-org/OctoSense-App-Hub) | 签名目录、商店,以及隔离运行每个脚本应用的 Card runner。 | | [octos](https://github.com/octos-org/octos) | Agent 内核。Android 上 APK 以 `liboctos.so` 形式内置它;桌面端运行 Shell 旁随附的 `octos-kernel`,并核对其版本与此处固定的一致(由 `tools/kernel-artifact.py --host --stage` 构建)。 | | [Rinx](https://github.com/hagency-org/Rinx) | Matrix 聊天与小程序,作为原生模块托管。 | +| [OctoBuddy](https://github.com/tyreseluo/OctoBuddy) | 编码应用:外环把工作拆成切片,inner 并行实现,宿主检查验收。桌面端作为原生模块托管。 | | [OctosCode](https://github.com/octos-org/octoscode-app) | 用户助手的编码客户端,作为原生模块托管,经由编码范围内的内核端口访问内核(ADR 0003 第 9 条)。 | 相关但不参与构建:[OctoSense-App-Flow](https://github.com/OctoSense-org/OctoSense-App-Flow)(如何构建和发布应用)、[OctoScript-Android](https://github.com/OctoSense-org/OctoScript-Android) 和 [OctoScript-OH](https://github.com/OctoSense-org/OctoScript-OH)(其他渲染后端)、[OctoSense 网站](https://github.com/OctoSense-org/octosense-org.github.io)。 diff --git a/crates/ai-host/src/native_agents.rs b/crates/ai-host/src/native_agents.rs index 7d487a628..aa6020143 100644 --- a/crates/ai-host/src/native_agents.rs +++ b/crates/ai-host/src/native_agents.rs @@ -7,6 +7,7 @@ /// (app id, the `octos.*` services its agent is granted). pub const NATIVE_AGENTS: &[(&str, &[&str])] = &[ ("rinx", &["octos.session.open", "octos.session.history", "octos.turn.start", "octos.turn.interrupt"]), + ("octobuddy", &["octos.session.open", "octos.session.history", "octos.turn.start", "octos.turn.interrupt"]), ("sheets", &["octos.session.open", "octos.session.history", "octos.turn.start", "octos.turn.interrupt"]), ("terminal", &["octos.session.open", "octos.session.history", "octos.turn.start", "octos.turn.interrupt"]), ("apphub", &["octos.session.open", "octos.session.history", "octos.turn.start", "octos.turn.interrupt"]), diff --git a/crates/shell/Cargo.toml b/crates/shell/Cargo.toml index 2950de5c8..2e88cd3ff 100644 --- a/crates/shell/Cargo.toml +++ b/crates/shell/Cargo.toml @@ -117,6 +117,7 @@ makepad-aichat = { workspace = true, optional = true } octosense-updater-ui = { workspace = true, optional = true } rinx = { workspace = true, features = ["octosense-module"], optional = true } octosense-reference = { workspace = true, optional = true } +octosense-octobuddy = { workspace = true, features = ["octosense-module"], optional = true } makepad-sheets = { workspace = true, optional = true } makepad-terminal = { workspace = true, optional = true } octosense-appcard = { workspace = true, optional = true } @@ -179,6 +180,7 @@ mobile-apps = ["app-updater", "app-rinx", "app-reference", "app-sheets", "app-hu app-updater = ["dep:octosense-updater-ui"] app-rinx = ["dep:rinx", "octos-core"] app-reference = ["dep:octosense-reference"] +app-octobuddy = ["dep:octosense-octobuddy"] app-sheets = ["dep:makepad-sheets"] app-terminal = ["dep:makepad-terminal"] app-appcard = ["dep:octosense-appcard", "octos-core"] diff --git a/crates/shell/src/approvals/sheet.rs b/crates/shell/src/approvals/sheet.rs index 4cbddc96b..5b8c609f7 100644 --- a/crates/shell/src/approvals/sheet.rs +++ b/crates/shell/src/approvals/sheet.rs @@ -95,9 +95,13 @@ pub struct Sheet { pub opened: u64, } -/// `os.mail` → `Mail`. +/// `os.mail` → `Mail`; a native app's `name` where it declares one +/// (`octobuddy` → `OctoBuddy`). pub fn app_label(app: &str) -> String { let id = app.strip_prefix("os.").unwrap_or(app); + if let Some(name) = crate::native_apps::find(id).and_then(|native| native.name) { + return name.to_string(); + } let mut chars = id.chars(); match chars.next() { Some(c) => c.to_uppercase().collect::() + chars.as_str(), diff --git a/crates/shell/src/approvals/tests.rs b/crates/shell/src/approvals/tests.rs index 8a61663c6..124554a30 100644 --- a/crates/shell/src/approvals/tests.rs +++ b/crates/shell/src/approvals/tests.rs @@ -1176,3 +1176,11 @@ fn settings_lists_every_app_that_declares_an_agent_before_it_asks() { assert!(listed.contains(&("org.example.trip".to_string(), State::Undecided)), "listed before it ever asked: {listed:?}"); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn a_native_apps_declared_name_is_what_its_sheets_say() { + use super::sheet::app_label; + assert_eq!(app_label("octobuddy"), "OctoBuddy", "native-apps.json's name"); + assert_eq!(app_label("rinx"), "Rinx", "no name: the id with a capital"); + assert_eq!(app_label("os.mail"), "Mail"); +} diff --git a/crates/shell/src/native_apps.rs b/crates/shell/src/native_apps.rs index 453351cd0..f36fef67f 100644 --- a/crates/shell/src/native_apps.rs +++ b/crates/shell/src/native_apps.rs @@ -64,6 +64,8 @@ pub enum KernelPort { #[derive(Debug)] pub struct NativeApp { pub id: &'static str, + /// What the person sees (`name`); none: the id with a capital. + pub name: Option<&'static str>, /// The shell's Cargo feature that links it. pub feature: &'static str, /// The binary a process-hosted instance runs. @@ -116,6 +118,7 @@ pub struct NativeApp { pub const APPS: &[NativeApp] = &[ NativeApp { id: "updater", + name: None, feature: "app-updater", bin: None, macos: Hosting::Module, @@ -143,6 +146,7 @@ pub const APPS: &[NativeApp] = &[ }, NativeApp { id: "rinx", + name: None, feature: "app-rinx", bin: None, macos: Hosting::Module, @@ -170,6 +174,7 @@ pub const APPS: &[NativeApp] = &[ }, NativeApp { id: "reference", + name: None, feature: "app-reference", bin: Some("octosense-reference"), macos: Hosting::Module, @@ -195,8 +200,37 @@ pub const APPS: &[NativeApp] = &[ calls_per_day: None, kernel: KernelPort::None, }, + NativeApp { + id: "octobuddy", + name: Some("OctoBuddy"), + feature: "app-octobuddy", + bin: None, + macos: Hosting::Module, + windows: Hosting::Module, + linux: Hosting::Module, + android: Hosting::Module, + ios: Hosting::Module, + ohos: Hosting::Module, + wasm: Hosting::Module, + octos: &["octos.session.open", "octos.session.history", "octos.turn.start", "octos.turn.interrupt"], + tools: &[], + network: Network::Any, + processes: true, + accounts: false, + external: &[], + storage: r#"{"accounts": false, "agent_workspace": "account", "external": []}"#, + tools_json: r##"[{"name":"octobuddy.status","description":"What OctoBuddy is doing now: its projects and sessions, each session's outer loop (what it does, its cost, what waits for it) and its inner loops (role, status, estimate and steps used, cost, the outer loop's verdict). Its published apps as its Live page has them (live.apps: each app's published version, whether it is watched and its health, an open incident with what is wrong and what was done, the last report). Optionally only one project, by name.","input_schema":{"type":"object","properties":{"project":{"type":"string","maxLength":200}},"additionalProperties":false},"risk":"read","shareable":true},{"name":"octobuddy.send","description":"Send a message to the outer loop of one OctoBuddy session, as the person would from its composer; it waits in the session's queue while the outer loop is busy. The session is named by its id or by words of its title.","input_schema":{"type":"object","properties":{"session":{"type":"string","maxLength":200},"message":{"type":"string","minLength":1,"maxLength":8000}},"required":["session","message"],"additionalProperties":false},"risk":"act","confirm":"host","shareable":true},{"name":"octobuddy.report","description":"Report a problem with an app OctoBuddy built and published (the person says it is broken, or another app saw it fail). Call it first, before checking anything yourself: OctoBuddy runs the published version on this device at once (no need to fetch its API or run anything) and keeps watching it; the reply says whether that run found the problem and what was on screen, and the app's OctoBuddy session hears it. Repairing it stays the person's choice in OctoBuddy (its Live page). The app is named by its name or id.","input_schema":{"type":"object","properties":{"app":{"type":"string","minLength":1,"maxLength":200},"problem":{"type":"string","minLength":1,"maxLength":2000},"from":{"type":"string","maxLength":200}},"required":["app","problem"],"additionalProperties":false},"risk":"act","confirm":"host","shareable":true},{"name":"octobuddy.request","description":"Ask OctoBuddy for a new OctoSense app (the person wants one, or another app needs one). It waits for the person: OctoBuddy shows it on its Apps page and builds it only if they press Build (an outer loop plans it, inner loops make it, its check runs); they publish it to the local App Hub from there. The reply is at once, with the request's id; octobuddy.status shows its status under requests. Say what it is for, what it shows and does, and when it is done; name its data (an https JSON API) if it has one.","input_schema":{"type":"object","properties":{"what":{"type":"string","minLength":1,"maxLength":4000},"for":{"type":"string","maxLength":200},"acceptance":{"type":"string","maxLength":2000},"data":{"type":"string","maxLength":500},"name":{"type":"string","maxLength":60}},"required":["what"],"additionalProperties":false},"risk":"act","confirm":"host","shareable":true}]"##, + generic_tools: &["read_file", "write_file", "edit_file", "glob", "grep", "list_dir", "web_search", "web_fetch", "view_image", "ask_user_question", "memory_search", "save_memory"], + grants: &[("terminal", "terminal.run")], + system_tools: &[], + own_tools: &["octobuddy.status", "octobuddy.send", "octobuddy.report", "octobuddy.request"], + calls_per_turn: None, + calls_per_day: None, + kernel: KernelPort::None, + }, NativeApp { id: "sheets", + name: None, feature: "app-sheets", bin: Some("sheets"), macos: Hosting::Module, @@ -224,6 +258,7 @@ pub const APPS: &[NativeApp] = &[ }, NativeApp { id: "terminal", + name: None, feature: "app-terminal", bin: Some("terminal"), macos: Hosting::Process, @@ -253,6 +288,7 @@ pub const APPS: &[NativeApp] = &[ }, NativeApp { id: "appcard", + name: None, feature: "app-appcard", bin: None, macos: Hosting::Module, @@ -280,6 +316,7 @@ pub const APPS: &[NativeApp] = &[ }, NativeApp { id: "apphub", + name: None, feature: "app-hub", bin: None, macos: Hosting::Module, @@ -307,6 +344,7 @@ pub const APPS: &[NativeApp] = &[ }, NativeApp { id: "calculator", + name: None, feature: "app-calculator", bin: Some("calculator"), macos: Hosting::Module, @@ -334,6 +372,7 @@ pub const APPS: &[NativeApp] = &[ }, NativeApp { id: "clock", + name: None, feature: "app-clock", bin: Some("clock"), macos: Hosting::Module, @@ -361,6 +400,7 @@ pub const APPS: &[NativeApp] = &[ }, NativeApp { id: "notes", + name: None, feature: "app-notes", bin: Some("notes"), macos: Hosting::Module, @@ -388,6 +428,7 @@ pub const APPS: &[NativeApp] = &[ }, NativeApp { id: "reminders", + name: None, feature: "app-reminders", bin: Some("reminders"), macos: Hosting::Module, @@ -415,6 +456,7 @@ pub const APPS: &[NativeApp] = &[ }, NativeApp { id: "weather", + name: None, feature: "app-weather", bin: Some("weather"), macos: Hosting::Module, @@ -442,6 +484,7 @@ pub const APPS: &[NativeApp] = &[ }, NativeApp { id: "octoscode", + name: None, feature: "app-octoscode", bin: None, macos: Hosting::Module, @@ -469,6 +512,7 @@ pub const APPS: &[NativeApp] = &[ }, NativeApp { id: "task", + name: None, feature: "app-task", bin: Some("task"), macos: Hosting::Process, @@ -556,6 +600,8 @@ pub fn link(out: &mut Vec<&'static dyn AppModule>) { out.push(&rinx::module::RINX_MODULE); #[cfg(any(feature = "app-reference", native_mobile))] out.push(&octosense_reference::REFERENCE_MODULE); + #[cfg(feature = "app-octobuddy")] + out.push(&octosense_octobuddy::OCTOBUDDY_MODULE); #[cfg(any(feature = "app-sheets", native_mobile))] out.push(&makepad_sheets::SHEETS_MODULE); #[cfg(feature = "app-terminal")] diff --git a/crates/shell/src/sandbox/tests.rs b/crates/shell/src/sandbox/tests.rs index 5699d0196..9b1aba138 100644 --- a/crates/shell/src/sandbox/tests.rs +++ b/crates/shell/src/sandbox/tests.rs @@ -31,6 +31,7 @@ impl Drop for Scratch { fn jail_only(root: &Path, hub_port: u16) -> Policy { let app = crate::native_apps::NativeApp { id: "probe", + name: None, feature: "app-probe", bin: Some("probe"), macos: crate::native_apps::Hosting::Process, diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index e52a917fd..4f9b55683 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -49,11 +49,12 @@ octosense-shell = { workspace = true } # every standard build. `mobile-apps` is the set phone builds link, also # usable on the desktop; AppCard is not among them (`--features app-appcard`). # BEGIN native-apps: features (generated by tools/native_apps.py from native-apps.json; do not edit) -default = ["octos-core", "wasm-functions", "craft-engines", "app-updater", "app-rinx", "app-terminal", "app-hub", "app-calculator", "app-clock", "app-notes", "app-reminders", "app-weather", "app-octoscode"] +default = ["octos-core", "wasm-functions", "craft-engines", "app-updater", "app-rinx", "app-octobuddy", "app-terminal", "app-hub", "app-calculator", "app-clock", "app-notes", "app-reminders", "app-weather", "app-octoscode"] mobile-apps = ["octosense-shell/mobile-apps"] app-updater = ["octosense-shell/app-updater"] app-rinx = ["octosense-shell/app-rinx"] app-reference = ["octosense-shell/app-reference"] +app-octobuddy = ["octosense-shell/app-octobuddy"] app-sheets = ["octosense-shell/app-sheets"] app-terminal = ["octosense-shell/app-terminal"] app-appcard = ["octosense-shell/app-appcard"] diff --git a/docs/native-apps/octobuddy.md b/docs/native-apps/octobuddy.md new file mode 100644 index 000000000..00018c150 --- /dev/null +++ b/docs/native-apps/octobuddy.md @@ -0,0 +1,32 @@ +# OctoBuddy's entry in `native-apps.json` + +English | [简体中文](octobuddy.zh-CN.md) + +[OctoBuddy](https://github.com/tyreseluo/OctoBuddy) is the coding app: an outer loop plans the work into slices, inner loops build them in parallel, the host checks and reviews what lands. Its entry asks for more than any other module, so this page says, for each grant, what it is for, what bounds it and what goes if it is taken away. What OctoBuddy keeps and sends is in its own [PRIVACY.md](https://github.com/tyreseluo/OctoBuddy/blob/main/PRIVACY.md). + +## Where it runs + +- **A module on every target** (`hosting`), linked into the desktop shell by default and left out of the phone shell (`shells`). +- **In the shell's process.** The `sandbox` block is applied only to a process launch (`sandbox::Policy::for_app`, ADR 0004 §3), so today no OS sandbox narrows OctoBuddy: it can do what the shell can, as Rinx, App Hub and AppCard can. The block says what it does, and is the policy it gets if its `hosting` ever moves to `process`. + +## The grants + +| Grant | What it is for | What bounds it | Without it | +| --- | --- | --- | --- | +| `sandbox.network: "any"` | The model providers the person enabled (through OctoBuddy's loopback proxy, which adds the key), the agents' downloads (npm's registry, octos's GitHub release, each checked against a pinned digest), the app templates repository. | Only the hosts the person's providers and OctoBuddy's pins name; no telemetry (PRIVACY.md). | No model answers; no agent installs. | +| `sandbox.processes: true` | The loops are agent programs OctoBuddy starts: `claude -p`, `codex app-server`, `pi --mode rpc`, its own `octos serve`; and `git` and the project's own checks, which the host runs before it accepts a slice. | Each child gets a placeholder key, never the provider's; the agents run in the project's folder (or its git worktree), which the person picked. | No outer or inner loop: OctoBuddy would be a chat only. | +| `storage.agent_workspace: "account"` | Its app agent (`octos.*` services, the system agent's peer) works in OctoBuddy's account folder, as every app agent with files does (ADR 0004 §11). | That folder only; a request context sees `contexts//` only. `storage.accounts: false`: one folder for the device. | The app agent reads no files. | +| `agent.octos`: `session.open`, `session.history`, `turn.start`, `turn.interrupt` | A chat on OctoSense's own agent, and the peer the system agent talks to. | The same four services as Rinx. | No chat on OctoSense's agent. | +| `agent.generic_tools` (12) | The octos kernel tools its agent gets in a chat: files (`read_file`, `write_file`, `edit_file`, `glob`, `grep`, `list_dir`), the web (`web_search`, `web_fetch`), `view_image`, `ask_user_question`, `memory_search`, `save_memory`. | Inside its account folder; never octos's shell (`tools/native_apps.py` refuses it for any app). | The chat agent can only talk. | +| `agent.grants`: `terminal.run` | The one way its chat agent can run a command: in the Terminal, where the person sees it. | `terminal.run` is `destructive` and host-confirmed; no standing rule answers it (`auto_approvable: false`): the person confirms every command, live. It is offered only where the Terminal runs as a sandboxed process (ADR 0004 §10, §12). The loops do not use it. | The chat agent cannot run a command; the loops are unchanged. | +| `agent.tools`: `octobuddy.status` (read), `octobuddy.send`, `octobuddy.report`, `octobuddy.request` (act) | What the system agent may ask of OctoBuddy: what runs now, a message to a session's outer loop, a problem with an app it published, a new app to build. | `status` is shareable and read-only. The three `act` tools are host-confirmed: the person confirms each call. `request` only queues: OctoBuddy builds the app when the person presses Build. | The system agent cannot see or reach OctoBuddy. | + +## Why not narrower + +- **`network: "any"` rather than a list.** The providers are the person's: OctoSense's AI providers app lets them add any endpoint, so the hosts are not known when the entry is reviewed. +- **Inner loops on OctoBuddy's own `octos serve`, not the shell's kernel.** An app's peer cannot yet work in a folder the person picks, share it between loops, run commands or be steered. OctoBuddy's [`docs/upcr-draft-coding-peers.md`](https://github.com/tyreseluo/OctoBuddy/blob/main/docs/upcr-draft-coding-peers.md) drafts what the kernel would need; with it, `processes` could be narrowed to the checks. +- **`terminal.run`** could be dropped without changing the loops; it is kept so a chat on OctoSense's agent can run a command the person sees and confirms. + +## Changing it + +The entry pins a revision of OctoBuddy (`source.rev`). Moving it is a change to `native-apps.json` and `python3 tools/native_apps.py`; a change to a grant is reviewed here, with this page. diff --git a/docs/native-apps/octobuddy.zh-CN.md b/docs/native-apps/octobuddy.zh-CN.md new file mode 100644 index 000000000..e6032aeea --- /dev/null +++ b/docs/native-apps/octobuddy.zh-CN.md @@ -0,0 +1,32 @@ +# OctoBuddy 在 `native-apps.json` 里的条目 + +[English](octobuddy.md) | 简体中文 + +[OctoBuddy](https://github.com/tyreseluo/OctoBuddy) 是编码应用:外环把工作拆成切片,inner 并行实现,宿主检查验收。它的条目申请的权限比其他任何模块都多,所以本页逐条说明:每项授权用来做什么、受什么约束、拿掉会怎样。OctoBuddy 保存和发送哪些数据,见它自己的 [PRIVACY.zh-CN.md](https://github.com/tyreseluo/OctoBuddy/blob/main/PRIVACY.zh-CN.md)。 + +## 运行在哪里 + +- **各平台都是模块**(`hosting`),桌面 shell 默认编入,手机 shell 不编(`shells`)。 +- **在 shell 的进程里。** `sandbox` 块只作用于进程启动(`sandbox::Policy::for_app`,ADR 0004 §3),所以目前没有 OS 沙箱约束 OctoBuddy:shell 能做的它都能做,Rinx、App Hub、AppCard 也一样。这个块写的是它实际做的事,也是它的 `hosting` 将来改成 `process` 时会拿到的策略。 + +## 各项授权 + +| 授权 | 用途 | 约束 | 拿掉会怎样 | +| --- | --- | --- | --- | +| `sandbox.network: "any"` | 用户启用的模型 provider(经 OctoBuddy 的 loopback 代理,由代理加 key)、agent 的下载(npm registry、octos 的 GitHub release,都按钉死的摘要校验)、应用模板仓库。 | 只连用户的 provider 和 OctoBuddy 钉死的来源;没有遥测(见 PRIVACY)。 | 模型无法回答,agent 无法安装。 | +| `sandbox.processes: true` | 双环就是 OctoBuddy 启动的 agent 程序:`claude -p`、`codex app-server`、`pi --mode rpc`、它自己的 `octos serve`;还有 `git` 和项目自己的检查,宿主在验收切片前运行。 | 子进程只拿到占位 key,拿不到 provider 的真 key;agent 在用户选定的项目目录(或其 git worktree)里工作。 | 没有外环和 inner,OctoBuddy 只剩聊天。 | +| `storage.agent_workspace: "account"` | 它的 app agent(`octos.*` 服务,系统 agent 的 peer)在 OctoBuddy 的账户目录里工作,与其他带文件的 app agent 相同(ADR 0004 §11)。 | 只限这个目录;request context 只看到 `contexts//`。`storage.accounts: false`:整台设备一个目录。 | app agent 不能读文件。 | +| `agent.octos`:`session.open`、`session.history`、`turn.start`、`turn.interrupt` | 在 OctoSense 自己的 agent 上聊天,以及系统 agent 与之对话的 peer。 | 与 Rinx 相同的四个服务。 | 不能用 OctoSense 的 agent 聊天。 | +| `agent.generic_tools`(12 个) | 聊天时它的 agent 拿到的 octos 内核工具:文件(`read_file`、`write_file`、`edit_file`、`glob`、`grep`、`list_dir`)、网页(`web_search`、`web_fetch`)、`view_image`、`ask_user_question`、`memory_search`、`save_memory`。 | 限于它的账户目录;永远拿不到 octos 的 shell(`tools/native_apps.py` 对任何 app 都拒绝)。 | 聊天 agent 只能对话。 | +| `agent.grants`:`terminal.run` | 聊天 agent 运行命令的唯一途径:在终端里跑,用户看得见。 | `terminal.run` 属于 `destructive`,由宿主确认;没有常设规则能代答(`auto_approvable: false`):每条命令都要用户当场确认。只在终端作为沙箱进程运行的地方提供(ADR 0004 §10、§12)。双环不使用它。 | 聊天 agent 不能运行命令;双环不受影响。 | +| `agent.tools`:`octobuddy.status`(读)、`octobuddy.send`、`octobuddy.report`、`octobuddy.request`(写) | 系统 agent 能向 OctoBuddy 要的东西:当前在跑什么、给某个会话的外环发消息、报告它发布的应用出了问题、请求做一个新应用。 | `status` 可共享且只读。三个写工具由宿主确认:每次调用都要用户确认。`request` 只是排队:用户按下 Build 才会开始构建。 | 系统 agent 看不到、也调不动 OctoBuddy。 | + +## 为什么不能更窄 + +- **`network: "any"` 而不是白名单。** provider 是用户自己的:OctoSense 的 AI providers 应用允许添加任意 endpoint,审核条目时无法预知主机。 +- **inner 跑在 OctoBuddy 自己的 `octos serve` 上,而不是 shell 的内核。** app 的 peer 目前还不能在用户选的目录里工作、不能让多个 loop 共享目录、不能运行命令、不能被 steer。OctoBuddy 的 [`docs/upcr-draft-coding-peers.md`](https://github.com/tyreseluo/OctoBuddy/blob/main/docs/upcr-draft-coding-peers.md) 起草了内核需要补的能力;有了这些,`processes` 可以收窄到只跑检查。 +- **`terminal.run`** 拿掉也不影响双环;保留它,是为了让 OctoSense agent 上的聊天能运行用户看得见、并逐条确认的命令。 + +## 怎么改 + +条目钉在 OctoBuddy 的某个 revision(`source.rev`)。升级就是改 `native-apps.json` 再跑 `python3 tools/native_apps.py`;改任何一项授权都在这里审核,并同步修改本页。 diff --git a/native-apps.json b/native-apps.json index 244bac8d0..2eb05f282 100644 --- a/native-apps.json +++ b/native-apps.json @@ -76,6 +76,32 @@ "storage": { "accounts": false, "agent_workspace": "none", "external": [] }, "agent": { "octos": [], "tools": null } }, + { + "id": "octobuddy", + "name": "OctoBuddy", + "crate": "octosense-octobuddy", + "source": { "git": "https://github.com/tyreseluo/OctoBuddy.git", "rev": "668765cc3e1e6c2ccd9a35baaeabebd76d3da852", "local": null }, + "module": "octosense_octobuddy::OCTOBUDDY_MODULE", + "bin": null, + "default_features": false, + "crate_features": ["octosense-module"], + "implies": [], + "hosting": { "macos": "module", "windows": "module", "linux": "module", "android": "module", "ios": "module", "ohos": "module" }, + "shells": { "desktop": "default", "phone": "off" }, + "native_mobile": "feature", + "sandbox": { "network": "any", "processes": true }, + "storage": { "accounts": false, "agent_workspace": "account", "external": [] }, + "agent": { + "octos": ["octos.session.open", "octos.session.history", "octos.turn.start", "octos.turn.interrupt"], + "tools": [ + {"name": "octobuddy.status", "description": "What OctoBuddy is doing now: its projects and sessions, each session's outer loop (what it does, its cost, what waits for it) and its inner loops (role, status, estimate and steps used, cost, the outer loop's verdict). Its published apps as its Live page has them (live.apps: each app's published version, whether it is watched and its health, an open incident with what is wrong and what was done, the last report). Optionally only one project, by name.", "input_schema": {"type": "object", "properties": {"project": {"type": "string", "maxLength": 200}}, "additionalProperties": false}, "risk": "read", "shareable": true}, + {"name": "octobuddy.send", "description": "Send a message to the outer loop of one OctoBuddy session, as the person would from its composer; it waits in the session's queue while the outer loop is busy. The session is named by its id or by words of its title.", "input_schema": {"type": "object", "properties": {"session": {"type": "string", "maxLength": 200}, "message": {"type": "string", "minLength": 1, "maxLength": 8000}}, "required": ["session", "message"], "additionalProperties": false}, "risk": "act", "confirm": "host", "shareable": true}, + {"name": "octobuddy.report", "description": "Report a problem with an app OctoBuddy built and published (the person says it is broken, or another app saw it fail). Call it first, before checking anything yourself: OctoBuddy runs the published version on this device at once (no need to fetch its API or run anything) and keeps watching it; the reply says whether that run found the problem and what was on screen, and the app's OctoBuddy session hears it. Repairing it stays the person's choice in OctoBuddy (its Live page). The app is named by its name or id.", "input_schema": {"type": "object", "properties": {"app": {"type": "string", "minLength": 1, "maxLength": 200}, "problem": {"type": "string", "minLength": 1, "maxLength": 2000}, "from": {"type": "string", "maxLength": 200}}, "required": ["app", "problem"], "additionalProperties": false}, "risk": "act", "confirm": "host", "shareable": true}, + {"name": "octobuddy.request", "description": "Ask OctoBuddy for a new OctoSense app (the person wants one, or another app needs one). It waits for the person: OctoBuddy shows it on its Apps page and builds it only if they press Build (an outer loop plans it, inner loops make it, its check runs); they publish it to the local App Hub from there. The reply is at once, with the request's id; octobuddy.status shows its status under requests. Say what it is for, what it shows and does, and when it is done; name its data (an https JSON API) if it has one.", "input_schema": {"type": "object", "properties": {"what": {"type": "string", "minLength": 1, "maxLength": 4000}, "for": {"type": "string", "maxLength": 200}, "acceptance": {"type": "string", "maxLength": 2000}, "data": {"type": "string", "maxLength": 500}, "name": {"type": "string", "maxLength": 60}}, "required": ["what"], "additionalProperties": false}, "risk": "act", "confirm": "host", "shareable": true} + ], + "generic_tools": ["read_file", "write_file", "edit_file", "glob", "grep", "list_dir", "web_search", "web_fetch", "view_image", "ask_user_question", "memory_search", "save_memory"], "grants": [{"app": "terminal", "tool": "terminal.run"}] + } + }, { "id": "sheets", "crate": "makepad-sheets", diff --git a/tools/native_apps.py b/tools/native_apps.py index 8a0ad60c1..7857d69ac 100644 --- a/tools/native_apps.py +++ b/tools/native_apps.py @@ -74,9 +74,12 @@ # Features on by default in one shell only: the phone offers app agents the # system toolbox (ADR 0002 §6), reading pages in its own WebView. SHELL_BASE_DEFAULT = {"phone": ["toolbox-peers"], "desktop": ["craft-engines"]} -APP_KEYS = {"id", "feature", "crate", "source", "module", "bin", "bin_features", "default_features", "crate_features", +APP_KEYS = {"id", "name", "feature", "crate", "source", "module", "bin", "bin_features", "default_features", "crate_features", "implies", "hosting", "shells", "native_mobile", "sandbox", "storage", "agent", "kernel"} -REQUIRED_KEYS = APP_KEYS - {"feature", "bin_features", "kernel"} +# `name`: what the person sees (the confirmation sheets' "OctoBuddy", +# `approvals::sheet::app_label`); without it the id with a capital ("rinx" -> +# "Rinx"). +REQUIRED_KEYS = APP_KEYS - {"name", "feature", "bin_features", "kernel"} # `kernel`: the port to the shell's octos kernel an app that is itself an # octos client gets (ADR 0003, "An app that is an octos client"): `coding`, # the coding scope the shell's kernel router enforces. Without it, a port the @@ -129,6 +132,9 @@ def validate(data): if missing: continue ident = app["id"] + name = app.get("name") + if name is not None and (not isinstance(name, str) or not name.strip() or len(name) > 40 or name != name.strip()): + problems.append(f"{where}: name must be a non-empty string of at most 40 characters, without spaces around it") if not isinstance(ident, str) or not re.fullmatch(r"[a-z][a-z0-9-]*", ident): problems.append(f"{where}: id must be lowercase letters, digits and '-'") if ident in seen: @@ -657,6 +663,8 @@ def render_rust(apps): "#[derive(Debug)]", "pub struct NativeApp {", " pub id: &'static str,", + " /// What the person sees (`name`); none: the id with a capital.", + " pub name: Option<&'static str>,", " /// The shell's Cargo feature that links it.", " pub feature: &'static str,", " /// The binary a process-hosted instance runs.", @@ -715,6 +723,8 @@ def render_rust(apps): octos = ", ".join(s(x) for x in app["agent"]["octos"]) out.append(" NativeApp {") out.append(f" id: {s(app['id'])},") + name_value = f"Some({s(app['name'])})" if app.get("name") else "None" + out.append(f" name: {name_value},") out.append(f" feature: {s(feature_of(app))},") out.append(f" bin: {bin_value},") for target in TARGETS + ("wasm",): diff --git a/tools/test_native_apps.py b/tools/test_native_apps.py index b7943ade0..6de9d13dc 100644 --- a/tools/test_native_apps.py +++ b/tools/test_native_apps.py @@ -6,6 +6,7 @@ import re from pathlib import Path import shutil +import subprocess import tempfile import unittest from unittest.mock import patch @@ -31,7 +32,7 @@ def test_every_generated_place_matches_the_manifest(self): def test_the_manifest_declares_todays_native_apps(self): apps = native_apps.load(ROOT) - self.assertEqual([app["id"] for app in apps], ["updater", "rinx", "reference", "sheets", "terminal", "appcard", "apphub", + self.assertEqual([app["id"] for app in apps], ["updater", "rinx", "reference", "octobuddy", "sheets", "terminal", "appcard", "apphub", "calculator", "clock", "notes", "reminders", "weather", "octoscode", "task"]) hosting = {app["id"]: app["hosting"] for app in apps} @@ -42,7 +43,7 @@ def test_the_manifest_declares_todays_native_apps(self): self.assertEqual(hosting["terminal"]["macos"], "process") self.assertEqual(hosting["terminal"]["windows"], "process") self.assertEqual(hosting["terminal"]["linux"], "process-if-vulkan") - for ident in ("apphub", "rinx", "sheets", "reference", "appcard"): + for ident in ("apphub", "rinx", "sheets", "reference", "octobuddy", "appcard"): self.assertEqual(set(hosting[ident].values()), {"module"}, ident) # Non-Vulkan Linux is in-process for every app with a module (a # process-only app is not there). @@ -290,8 +291,39 @@ def test_refuses_unknown_keys(self): self.app("reference")["hosted"] = "yes" self.assertRefused(r"reference: unknown hosted") + def test_the_display_name_is_checked_and_generated(self): + rust = native_apps.render_rust(native_apps.validate(self.data)) + self.assertIn('id: "octobuddy",\n name: Some("OctoBuddy"),', rust) + self.assertIn('id: "rinx",\n name: None,', rust) + for bad in ("", " OctoBuddy", "x" * 41, 7): + self.app("octobuddy")["name"] = bad + self.assertRefused(r"octobuddy: name must be a non-empty string") + class Generation(Fixture): + def test_the_generated_rust_parses(self): + """Every generated Rust file is valid syntax: a field without its + comma once reached a pull request (`name: Option<&'static str>`). + The comma check runs without rustfmt too (CI's minimal toolchain): + the struct's fields and each `NativeApp { … }` entry's.""" + apps = native_apps.validate(self.data) + for label, rust in (("native_apps.rs", native_apps.render_rust(apps)), + ("native_agents.rs", native_apps.render_agents(apps))): + fields = [line for line in rust.splitlines() + if re.fullmatch(r" pub \w+: .*", line) or re.fullmatch(r" \w+: .*[^\[]", line)] + for line in fields: + self.assertTrue(line.endswith(","), f"{label}: {line.strip()}") + if label == "native_apps.rs": + self.assertIn(" pub name: Option<&'static str>,", fields) + self.assertIn(' name: Some("OctoBuddy"),', fields) + rustfmt = shutil.which("rustfmt") + if rustfmt is None: + continue + run = subprocess.run([rustfmt, "--edition", "2021", "--emit", "stdout"], input=rust, + capture_output=True, text=True) + self.assertNotIn("error", run.stderr, f"{label}: {run.stderr}") + self.assertEqual(run.returncode, 0, f"{label}: {run.stderr}") + def test_a_manifest_change_is_drift_until_regenerated(self): self.app("terminal")["hosting"]["macos"] = "module" self.save()