diff --git a/crates/ai-host/src/lib.rs b/crates/ai-host/src/lib.rs index 716129d7..fb933dde 100644 --- a/crates/ai-host/src/lib.rs +++ b/crates/ai-host/src/lib.rs @@ -167,8 +167,9 @@ impl Policy { /// What a shell tells [`start`]. #[derive(Clone, Debug)] pub struct Host { - /// The app's data dir (`cx.get_data_dir()`): a phone's kernel home lives - /// under it. + /// The app's data dir (`cx.get_data_dir()` on a phone, OctoSense's state + /// dir on a desktop): the kernel's octos home lives under it, never the + /// person's own `~/octos-home`. pub data_dir: Option, pub kernel: KernelSource, pub qr_import: QrImport, @@ -262,8 +263,8 @@ fn configure_kernel(_host: &Host) -> (bool, Result<(), String>) { } /// The kernel's octos home, where the `llm` service writes: the kernel's core -/// dir (`$OCTOS_APP_CORE_DIR`, else on a phone `/octos-home/.octos`, -/// else `$HOME/octos-home/.octos`). +/// dir (`$OCTOS_APP_CORE_DIR`, else `/octos-home/.octos`, OctoSense's +/// own, else `$HOME/octos-home/.octos`). pub fn core_dir(data_dir: Option) -> Option { #[cfg(kernel)] { @@ -277,10 +278,8 @@ pub fn core_dir(data_dir: Option) -> Option { if let Some(dir) = std::env::var_os("OCTOS_APP_CORE_DIR").filter(|v| !v.is_empty()) { return Some(PathBuf::from(dir)); } - if cfg!(any(target_os = "android", target_env = "ohos")) { - if let Some(dir) = data_dir.filter(|d| !d.is_empty()) { - return Some(PathBuf::from(dir).join("octos-home").join(".octos")); - } + if let Some(dir) = data_dir.filter(|d| !d.is_empty()) { + return Some(PathBuf::from(dir).join("octos-home").join(".octos")); } octosense_llm_config::profile::default_core_dir() } diff --git a/crates/ai-host/src/tests.rs b/crates/ai-host/src/tests.rs index 14dcc1dd..cace49e3 100644 --- a/crates/ai-host/src/tests.rs +++ b/crates/ai-host/src/tests.rs @@ -116,16 +116,18 @@ fn the_shipped_policy_grants_rinx_the_assistant() { } #[test] -fn a_phone_core_dir_is_the_kernels_octos_home() { +fn the_core_dir_is_octosenses_own_octos_home_under_its_data_dir() { if std::env::var_os("OCTOS_APP_CORE_DIR").is_some() { return; } let dir = core_dir(Some("/data/user/0/app/files".into())); - if cfg!(any(target_os = "android", target_env = "ohos")) { - assert_eq!(dir, Some(PathBuf::from("/data/user/0/app/files/octos-home/.octos"))); - } else { - assert_eq!(dir, octosense_llm_config::profile::default_core_dir()); - } + // With the kernel service, the kernel's (configured) core dir. + #[cfg(kernel)] + assert_eq!(dir, octosense_kernel::core_dir()); + // Without it, every platform: `/octos-home/.octos`, never + // `~/octos-home`. + #[cfg(not(kernel))] + assert_eq!(dir, Some(PathBuf::from("/data/user/0/app/files/octos-home/.octos"))); } #[test] diff --git a/crates/app-peers/tests/fixtures/mock_agent_llm.py b/crates/app-peers/tests/fixtures/mock_agent_llm.py index 62b6a879..e786a68b 100644 --- a/crates/app-peers/tests/fixtures/mock_agent_llm.py +++ b/crates/app-peers/tests/fixtures/mock_agent_llm.py @@ -9,13 +9,15 @@ "QUESTION_ME" ("TELL_PEER_HOLD:": "QUESTION_HOLD", never answered); - "TELL_PEER_AGAIN:": send the peer "SECOND_INPUT"; - "TELL_PEER_SUDO:": send the peer "RUN_SUDO", on which the peer runs - a shell command that needs the person's approval; - - "APPROVE_PEER:": try to approve the peer's pending tool approval - with peer_respond (the kernel must refuse it for a host-owned peer); + a shell command when it is offered octos's `shell` (the kernel profile's + policy denies it: ADR 0004 §12), else says "NO SHELL OFFERED"; + - "APPROVE_PEER:": try to approve a peer's tool with peer_respond; - a user text "QUESTION_ME" with ask_user_question offered: ask one question; - a message naming a waiting peer with peer_respond offered: answer "42"; - otherwise echo. -Prints its port, then serves until killed. Logs each decision to stderr. +Prints its port, then serves until killed. Logs each decision to stderr and, +with MOCK_LLM_TOOLS_LOG set, appends {"user": , "tools": +[]} per request to that file. """ import itertools import json @@ -61,6 +63,8 @@ def decide(body): return {"tool": "peer_send_input", "args": {"slug": sudo.group(1), "message": "RUN_SUDO"}} if "RUN_SUDO" in last_user and "shell" in tools: return {"tool": "shell", "args": {"command": "rm -rf ./approval-probe && echo APPROVED_RAN"}} + if "RUN_SUDO" in last_user: + return {"text": "NO SHELL OFFERED"} approve = re.search(r"APPROVE_PEER:([a-z0-9-]+)", last_user) if approve and "peer_respond" in tools: return {"tool": "peer_respond", "args": {"slug": approve.group(1), "decision": "approve"}} @@ -86,6 +90,12 @@ def log_message(self, *args): def do_POST(self): body = json.loads(self.rfile.read(int(self.headers.get("content-length", "0"))) or b"{}") decision = decide(body) + log = os.environ.get("MOCK_LLM_TOOLS_LOG") + if log: + user = next((text_of(m) for m in reversed(body.get("messages", [])) if m.get("role") == "user"), "") + tools = [t.get("function", {}).get("name") for t in body.get("tools", []) or []] + with open(log, "a") as f: + f.write(json.dumps({"user": user, "tools": tools}) + "\n") last = (body.get("messages") or [{}])[-1] print("decision:", json.dumps(decision), "after:", text_of(last)[:300].replace("\n", " "), file=sys.stderr, flush=True) if "tool" in decision: diff --git a/crates/app-peers/tests/fixtures/mock_llm.py b/crates/app-peers/tests/fixtures/mock_llm.py index 2fec7fd4..a56059f3 100644 --- a/crates/app-peers/tests/fixtures/mock_llm.py +++ b/crates/app-peers/tests/fixtures/mock_llm.py @@ -2,10 +2,14 @@ """A scripted OpenAI-compatible model for kernel tests. No network, no keys. POST /v1/chat/completions answers "ECHO: ". A user text that -contains SLOW waits 20 s first (for revocation tests). Prints its port on the -first line of stdout, then serves until killed. Standard library only. +contains SLOW waits 20 s first (for revocation tests). With +MOCK_LLM_TOOLS_LOG set, each request appends one JSON line {"user": , "tools": []} to that file (what a turn was offered). +Prints its port on the first line of stdout, then serves until killed. +Standard library only. """ import json +import os import sys import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -35,6 +39,11 @@ def do_POST(self): length = int(self.headers.get("content-length", "0")) body = json.loads(self.rfile.read(length) or b"{}") text = last_user_text(body) + log = os.environ.get("MOCK_LLM_TOOLS_LOG") + if log: + tools = [t.get("function", {}).get("name") for t in body.get("tools", []) or []] + with open(log, "a") as f: + f.write(json.dumps({"user": text, "tools": tools}) + "\n") if "SLOW" in text: time.sleep(20) reply = "ECHO: " + text.strip().splitlines()[-1] if text.strip() else "ECHO:" diff --git a/crates/app-peers/tests/real_kernel.rs b/crates/app-peers/tests/real_kernel.rs index 588792c5..e9520254 100644 --- a/crates/app-peers/tests/real_kernel.rs +++ b/crates/app-peers/tests/real_kernel.rs @@ -304,13 +304,20 @@ fn an_account_change_drops_a_late_reply_and_resume_keeps_the_peer_across_restart /// Criterion 3 of ADR 0007 with a scripted model: the system agent sends the /// app peer input, the peer asks a question, the kernel wakes the system -/// agent, which answers, and the peer continues with the answer. +/// agent, which answers, and the peer continues with the answer. Every model +/// request on the way (the host's turn, the kernel's wake continuation, the +/// peer's turns) is offered none of octos's shell (ADR 0004 §12: command +/// execution is a granted host tool, never octos's `shell`). #[test] fn the_system_agent_and_the_app_peer_exchange_a_question_and_answer() { let Some(program) = kernel() else { return }; let script = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/mock_agent_llm.py"); + let dir = temp("qa"); + std::fs::create_dir_all(&dir).unwrap(); + let offered_log = dir.join("offered.jsonl"); let mut child = std::process::Command::new("python3") .arg(script) + .env("MOCK_LLM_TOOLS_LOG", &offered_log) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::inherit()) .spawn() @@ -320,7 +327,6 @@ fn the_system_agent_and_the_app_peer_exchange_a_question_and_answer() { .read_line(&mut line) .unwrap(); let model = Model(child, line.trim().parse().unwrap()); - let dir = temp("qa"); let core_dir = dir.join("octos-home/.octos"); write_profile(&core_dir, model.1); let core = Core::new(Options::default().core_dir(&core_dir).program(&program)); @@ -358,6 +364,27 @@ fn the_system_agent_and_the_app_peer_exchange_a_question_and_answer() { text.contains("PEER GOT 42"), "the peer continued with the system agent's answer: {text}" ); + let requests: Vec = std::fs::read_to_string(&offered_log) + .unwrap() + .lines() + .map(|l| serde_json::from_str(l).unwrap()) + .collect(); + for request in &requests { + for tool in request["tools"].as_array().unwrap() { + let tool = tool.as_str().unwrap(); + assert!( + !["shell", "bash", "exec_command", "write_stdin"].contains(&tool), + "{tool} offered: {request}" + ); + } + } + assert!( + requests.iter().any(|r| { + !r["user"].as_str().unwrap().contains("TELL_PEER") + && r["tools"].as_array().unwrap().iter().any(|t| t == "peer_respond") + }), + "the system agent's wake continuation was seen and checked" + ); rinx.release(); drop(rinx); @@ -553,13 +580,19 @@ fn a_second_input_to_an_answered_peer_runs() { let _ = std::fs::remove_dir_all(&dir); } -/// ADR 0007: a host-owned app peer's tool approval is the person's, answered -/// in the app. The system agent hands the peer work that needs an approval; -/// the kernel must not wake the system agent for it, and when the system -/// agent tries to approve it anyway with `peer_respond` the kernel refuses. -/// The approval stays pending until the person answers it through the host. +/// ADR 0004 §12 (was ADR 0007's approval test): octos's own `shell` is +/// offered to no `_main` session (the kernel profile's policy, written by +/// `octosense-kernel` at every start, denies it and nothing else), so the system agent cannot +/// get a command run through an app peer by asking or by "approving". The +/// system agent handing the peer a command gets no tool approval parked and +/// nothing run; its `peer_respond` "approval" finds nothing to approve. +/// Command execution an app is granted arrives as a host tool with a live +/// approval (for example `terminal.run`), registered by the shell (plan +/// steps 6 and 7); approvals of app tools return here then. That octos +/// refuses the system agent an app peer's approval is octos's own test +/// (`ui_protocol_tests.rs`, ADR 0007). #[test] -fn the_system_agent_cannot_approve_an_app_peers_tool() { +fn the_system_agent_cannot_get_a_command_run_through_an_app_peer() { let Some(program) = kernel() else { return }; let script = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/mock_agent_llm.py"); let mut child = std::process::Command::new("python3") @@ -612,74 +645,44 @@ fn the_system_agent_cannot_approve_an_app_peers_tool() { }; start_system_turn(format!("TELL_PEER_SUDO:{slug}")); - let mut approvals = Vec::new(); + let mut peer_text = String::new(); for _ in 0..120 { - approvals = pending_approvals(); - if !approvals.is_empty() { + peer_text = hydrate(&peer_session, &["messages"]).to_string(); + if peer_text.contains("NO SHELL OFFERED") { break; } std::thread::sleep(Duration::from_millis(500)); } - assert_eq!(approvals.len(), 1, "the peer parked on one tool approval"); - // Room for a wake to fire, were the kernel to send one. - std::thread::sleep(Duration::from_secs(3)); - // A woken system agent acts on the wake (the scripted one reaches for - // peer_respond), which leaves a tool result beyond the peer_send_input one. - let system_text = hydrate(system, &["messages"]).to_string(); - assert_eq!( - system_text.matches("\"role\":\"tool\"").count(), - 1, - "the system agent was not woken for the app peer's approval: {system_text}" + assert!( + peer_text.contains("NO SHELL OFFERED"), + "the peer ran the system agent's input without a shell tool: {peer_text}" ); + assert!(pending_approvals().is_empty(), "no tool approval parked"); start_system_turn(format!("APPROVE_PEER:{slug}")); let mut system_text = String::new(); for _ in 0..60 { system_text = hydrate(system, &["messages"]).to_string(); - if system_text.contains("host-owned app peer") { + if system_text.contains("not awaiting input") { break; } std::thread::sleep(Duration::from_millis(500)); } assert!( - system_text.contains("host-owned app peer"), - "peer_respond refused the system agent's approval: {system_text}" - ); - assert_eq!( - pending_approvals().len(), - 1, - "the approval is still the person's to answer" + system_text.contains("not awaiting input"), + "peer_respond had nothing to approve: {system_text}" ); + std::thread::sleep(Duration::from_secs(2)); + assert!(pending_approvals().is_empty()); assert!( !hydrate(&peer_session, &["messages"]) .to_string() .contains("APPROVED_RAN"), - "the command did not run on the system agent's say-so" + "no command ran" ); - // The person approves in the app; the peer's command then runs. - let approval_id = approvals[0]["approval_id"].as_str().unwrap().to_owned(); - rinx.host_request( - "approval/respond", - json!({"session_id": peer_session, "approval_id": approval_id, "decision": "approve"}), - ) - .expect("the person's approval"); - let mut ran = false; - for _ in 0..60 { - if pending_approvals().is_empty() - && hydrate(&peer_session, &["messages"]) - .to_string() - .contains("APPROVED_RAN") - { - ran = true; - break; - } - std::thread::sleep(Duration::from_millis(500)); - } - rinx.release(); drop(rinx); core.shutdown_within(Duration::from_secs(5)); - assert!(ran, "the peer ran the command once the person approved it"); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/kernel/README.md b/crates/kernel/README.md index 03471cb8..a0398614 100644 --- a/crates/kernel/README.md +++ b/crates/kernel/README.md @@ -17,7 +17,7 @@ the shared runtime piece the shells, AppCard (`apps/appcard/app`) and the | | | |---|---| -| **Core dir** | octos's data dir: `/profiles/_main.json` is the profile the AI providers app writes. Resolved as: the shell's `Options::core_dir`, else `$OCTOS_APP_CORE_DIR`, else on Android/OpenHarmony `/octos-home/.octos` (the app-private octos home AppCard has always used), else `$HOME/octos-home/.octos` (`octosense_llm_config::profile::default_core_dir()`). | +| **Core dir** | octos's data dir: `/profiles/_main.json` is the profile the AI providers app writes. Resolved as: the shell's `Options::core_dir`, else `$OCTOS_APP_CORE_DIR`, else `/octos-home/.octos`: OctoSense's own octos home (the platform's app data dir on a phone, the app-private home AppCard has always used; OctoSense's state dir `~/.octosense` on a desktop), else `$HOME/octos-home/.octos` (`octosense_llm_config::profile::default_core_dir()`, for a consumer that names nothing). A desktop OctoSense used to share `$HOME/octos-home/.octos` with the person's standalone octos; on first use it now copies only the provider and model settings (`llm`, `env_vars`) from there into its own profile, and never writes, moves or deletes anything there. | | **One kernel, lazily** | The first `connect()` starts it; later ones share it. octos holds a single-writer lock on its data dir, so a second kernel on the same dir could not run anyway. | | **Shared by frames** | A `Connection` carries UI Protocol (JSON-RPC) frames exactly as `octos serve --stdio` speaks them. Each consumer uses its own request ids and receives the replies to its requests and the notifications of the sessions it named (a notification for a session nobody named goes to every consumer). | | **Restart** | `restart()` stops a running kernel (a no-op when none runs). Connections then end with `CloseReason::Restarted`; a consumer connects again, which starts a fresh kernel that reads the new profile. The next kernel starts only after the old one has exited and released its data dir. | @@ -155,6 +155,40 @@ scoped session agree. OpenHarmony (embedded core) and iOS (no kernel) have no Talk to Octos. See [ADR 0003](../../docs/adr/0003-shared-octos-client-access.md) for the threat model. +## The system agent's tools + +[ADR 0004](../../docs/adr/0004-native-apps-hosting-and-peers.md) §12: the +system agent's tool set is its grants. Its default octos tools are +`system_tools::SYSTEM_AGENT_TOOLS`: supervision (`peer_send_input`, +`peer_gather`, `peer_list`, `peer_respond`, `peer_close`), its workspace's +file tools (octos fences them to the session's working directory), memory, +`ask_user_question`, media viewing, octos's `web_search` / `web_fetch` (until +toolbox grants replace them, #108) and `tool_search`. Granted toolbox and +cross-app tools, and command execution when the person turns it on in +Settings (off by default), join it as host tools through `SystemAgentTools`; +command execution is a host tool with a live approval (`terminal.run`). + +**What the kernel enforces today.** octos has no tool list the host can set +for one session, so every start writes the `_main` profile's `tool_policy` +(`system_tools::tool_policy`): everything a grant can give, minus octos's own +shell (`group:runtime`: `shell`, `bash`, `exec_command`, `write_stdin`), the +one tool OctoSense never offers, since §12 grants command execution only as +a host tool. octos applies it to every turn of the profile, wake +continuations included. So: + +- **§12's "exactly its grants" is not yet enforced for the system agent**: + it gets no octos shell, but otherwise is bounded by the grantable ceiling, + not its list, until octos#2567 adds session-targeted registration and tool + lists (reviewer item M1; our item 5). The exact-list real-kernel test is + ignored until then; +- app peers are narrowed to their grants by their turns' `generic_tools` + (plan step 6); +- Talk to Octos external turns keep octos's own allowlist. + +The policy is written only into OctoSense's own core dir, and only over a +policy OctoSense wrote (`"owner": "octosense"`): a foreign policy, or the +person's own `$HOME/octos-home/.octos`, is refused with a warning. + ## Testing From the repository root: diff --git a/crates/kernel/README.zh-CN.md b/crates/kernel/README.zh-CN.md index 863d5b3c..4433daef 100644 --- a/crates/kernel/README.zh-CN.md +++ b/crates/kernel/README.zh-CN.md @@ -15,7 +15,7 @@ Shell(`phone/` 中的 Home、`desktop/` 中的桌面)拥有它;**AI provid | | | |---|---| -| **Core 目录** | octos 的数据目录:`/profiles/_main.json` 是 AI providers 应用写入的 profile。解析顺序:Shell 的 `Options::core_dir`,否则 `$OCTOS_APP_CORE_DIR`,否则在 Android/OpenHarmony 上为 `<应用数据目录>/octos-home/.octos`(AppCard 一直使用的应用私有 octos home),否则 `$HOME/octos-home/.octos`(`octosense_llm_config::profile::default_core_dir()`)。 | +| **Core 目录** | octos 的数据目录:`/profiles/_main.json` 是 AI providers 应用写入的 profile。解析顺序:Shell 的 `Options::core_dir`,否则 `$OCTOS_APP_CORE_DIR`,否则 `<应用数据目录>/octos-home/.octos`:OctoSense 自己的 octos home(手机上为平台的应用数据目录,即 AppCard 一直使用的应用私有 home;桌面上为 OctoSense 的状态目录 `~/.octosense`),否则 `$HOME/octos-home/.octos`(`octosense_llm_config::profile::default_core_dir()`,供未指定任何目录的使用者)。桌面版 OctoSense 以前与用户独立的 octos 共用 `$HOME/octos-home/.octos`;现在首次使用时只从那里复制提供商和模型设置(`llm`、`env_vars`)到自己的 profile,绝不在那里写入、移动或删除任何内容。 | | **一个内核,按需启动** | 第一次 `connect()` 启动它,之后的连接共享它。octos 对数据目录持有单写者锁,同一目录上本来也无法运行第二个内核。 | | **按帧共享** | 一个 `Connection` 传递 UI Protocol(JSON-RPC)帧,与 `octos serve --stdio` 的格式完全相同。每个使用方使用自己的请求 id,只收到自己请求的回复和自己指定会话的通知(没有任何使用方指定的会话通知会发给所有使用方)。 | | **重启** | `restart()` 停止正在运行的内核(没有运行时什么也不做)。连接随后以 `CloseReason::Restarted` 结束;使用方重新连接,会启动读取新 profile 的新内核。新内核要等旧内核退出并释放数据目录后才启动。 | @@ -129,6 +129,27 @@ adb -s SERIAL forward tcp:PORT tcp:PORT 和 iOS(没有内核)没有 Talk to Octos。威胁模型见 [ADR 0003(英文)](../../docs/adr/0003-shared-octos-client-access.md)。 +## 系统智能体的工具 + +[ADR 0004](../../docs/adr/0004-native-apps-hosting-and-peers.md) §12:系统智能体的工具集就是它获得的授权。它默认的 octos +工具是 `system_tools::SYSTEM_AGENT_TOOLS`:监督(`peer_send_input`、`peer_gather`、`peer_list`、`peer_respond`、 +`peer_close`)、其工作区内的文件工具(octos 将其限制在会话工作目录内)、记忆、`ask_user_question`、查看媒体、octos 的 +`web_search` / `web_fetch`(在工具箱授权取代它们之前,#108)以及 `tool_search`。授予的工具箱工具和跨应用工具,以及用户在设置中开启 +(默认关闭)的命令执行,通过 `SystemAgentTools` 作为宿主工具加入;命令执行是需要实时批准的宿主工具(`terminal.run`)。 + +**内核今天执行的内容。** octos 没有宿主可为单个会话设置的工具名单,因此每次启动都写入 `_main` profile 的 `tool_policy` +(`system_tools::tool_policy`):任何授权可给予的一切,唯独去掉 octos 自己的 shell(`group:runtime`:`shell`、`bash`、 +`exec_command`、`write_stdin`)——这是 OctoSense 唯一从不提供的工具,因为 §12 只以宿主工具的形式授予命令执行。octos +对该 profile 的每个回合(包括唤醒续接回合)都应用它。因此: + +- **§12 的“恰好是它的授权”对系统智能体尚未执行**:它拿不到 octos shell,但除此之外受可授权上限约束,而不是它的清单, + 直到 octos#2567 增加面向会话的注册和工具名单(评审第 M1 项;我们的第 5 项)。精确清单的真实内核测试在此之前被忽略; +- 应用 peer 由其回合的 `generic_tools` 收窄到其授权(计划第 6 步); +- Talk to Octos 外部回合保留 octos 自己的允许列表。 + +该策略只写入 OctoSense 自己的 core 目录,且只覆盖 OctoSense 写入的策略(`"owner": "octosense"`):遇到外来策略或用户自己的 +`$HOME/octos-home/.octos` 时拒绝并发出警告。 + ## 测试 在仓库根目录: diff --git a/crates/kernel/src/dirs.rs b/crates/kernel/src/dirs.rs index 7996556b..680e4a02 100644 --- a/crates/kernel/src/dirs.rs +++ b/crates/kernel/src/dirs.rs @@ -2,10 +2,17 @@ //! //! The **core dir** is octos's data dir: `/profiles/_main.json` is //! the profile the AI providers app's `llm` service writes and the kernel -//! reads. On a phone it is `/.octos` under the kernel's own HOME, -//! `/octos-home` (the app-private dir AppCard has always used); -//! the kernel is spawned with that HOME (Android) or served from it -//! (OpenHarmony, `octos_cli::embedded::serve_io(home, ..)`). +//! reads. It is OctoSense's own: `/octos-home/.octos`, where +//! the app data dir is the platform's (a phone's files dir) or OctoSense's +//! state dir on a desktop (`~/.octosense`). On a phone the kernel is spawned +//! with that HOME (Android) or served from it (OpenHarmony, +//! `octos_cli::embedded::serve_io(home, ..)`). +//! +//! A desktop OctoSense used to share the person's own octos home +//! (`$HOME/octos-home/.octos`) with their standalone octos. It no longer +//! does, since OctoSense writes its own tool policy into its profile; on +//! first use [`migrate_shared_profile`] COPIES the provider and model +//! settings from there, never modifying or deleting the person's files. use std::path::{Path, PathBuf}; @@ -13,30 +20,77 @@ use std::path::{Path, PathBuf}; /// /// 1. `explicit` (the shell's [`crate::Options::core_dir`]); /// 2. `$OCTOS_APP_CORE_DIR` (non-empty); -/// 3. on Android and OpenHarmony, `/octos-home/.octos` when the -/// shell named its data dir (it registers services before the hosted app -/// has pointed `$HOME` there); +/// 3. `/octos-home/.octos` when the shell named its data dir +/// (a phone's files dir; OctoSense's state dir on a desktop): OctoSense's +/// own octos home; /// 4. `octosense_llm_config::profile::default_core_dir()`: -/// `$HOME/octos-home/.octos`. +/// `$HOME/octos-home/.octos`, for a consumer that names nothing (the +/// kernel then never writes a tool policy there, see `system_tools`). pub fn resolve_core_dir(explicit: Option<&Path>, app_data_dir: Option<&Path>) -> Option { - resolve_core_dir_for(explicit, app_data_dir, cfg!(any(target_os = "android", target_env = "ohos"))) -} - -pub(crate) fn resolve_core_dir_for(explicit: Option<&Path>, app_data_dir: Option<&Path>, phone: bool) -> Option { if let Some(dir) = explicit.filter(|d| !d.as_os_str().is_empty()) { return Some(dir.to_path_buf()); } if let Some(dir) = std::env::var_os("OCTOS_APP_CORE_DIR").filter(|v| !v.is_empty()) { return Some(PathBuf::from(dir)); } - if phone { - if let Some(data) = app_data_dir.filter(|d| !d.as_os_str().is_empty()) { - return Some(data.join("octos-home").join(".octos")); - } + if let Some(data) = app_data_dir.filter(|d| !d.as_os_str().is_empty()) { + return Some(data.join("octos-home").join(".octos")); } octosense_llm_config::profile::default_core_dir() } +/// The shared octos home an OctoSense core dir may inherit settings from: +/// `$HOME/octos-home/.octos`, only on a desktop whose core dir comes from +/// its app data dir (an explicit or `$OCTOS_APP_CORE_DIR` core dir is the +/// caller's choice; phones never shared it). +pub(crate) fn shared_profile_source(explicit: Option<&Path>, app_data_dir: Option<&Path>) -> Option { + let desktop = !cfg!(any(target_os = "android", target_env = "ohos", target_os = "ios")); + let derived = explicit.is_none_or(|d| d.as_os_str().is_empty()) + && std::env::var_os("OCTOS_APP_CORE_DIR").is_none_or(|v| v.is_empty()) + && app_data_dir.is_some_and(|d| !d.as_os_str().is_empty()); + (desktop && derived).then(octosense_llm_config::profile::default_core_dir).flatten() +} + +/// The profile keys the migration carries: the provider and model settings +/// (`llm`) and the key references beside them (`env_vars`: keychain markers +/// or keys the person saved there). +const MIGRATED_CONFIG_KEYS: &[&str] = &["llm", "env_vars"]; + +/// Copy the provider and model settings of `from`'s `_main` profile into a +/// new `_main` profile under `to`, once: only when `to` has no profile and +/// `from` has one. Never writes, moves or deletes anything under `from`; no +/// tool policy or other setting of the person's octos is carried over. +/// Returns whether it copied. +pub fn migrate_shared_profile(from: &Path, to: &Path) -> Result { + let target = profile_path(to); + if target.exists() || from == to { + return Ok(false); + } + let Ok(bytes) = std::fs::read(profile_path(from)) else { return Ok(false) }; + let source: serde_json::Value = serde_json::from_slice(&bytes) + .map_err(|e| format!("{} is not JSON: {e}", profile_path(from).display()))?; + let mut config = serde_json::Map::new(); + for key in MIGRATED_CONFIG_KEYS { + if let Some(value) = source["config"].get(*key) { + config.insert((*key).to_owned(), value.clone()); + } + } + if config.is_empty() { + return Ok(false); + } + let now = source["updated_at"].clone(); + let profile = serde_json::json!({ + "id": "_main", "name": source["name"].as_str().unwrap_or("Main"), "enabled": true, + "created_at": source["created_at"].clone(), "updated_at": now, + "config": config, + }); + let body = serde_json::to_vec_pretty(&profile).map_err(|e| e.to_string())?; + let dir = target.parent().unwrap_or(to); + crate::network::write_private(dir, "_main.json", &body) + .map_err(|e| format!("could not write {}: {e}", target.display()))?; + Ok(true) +} + /// The kernel's HOME for a core dir: its parent when it is the conventional /// `/.octos`, else the core dir itself (then the kernel is also told /// `--data-dir `, see `launch`). @@ -69,33 +123,38 @@ mod tests { #[test] fn explicit_wins_over_everything() { let _g = ENV.lock().unwrap(); - let got = resolve_core_dir_for(Some(Path::new("/x/core")), Some(Path::new("/data")), true); + let got = resolve_core_dir(Some(Path::new("/x/core")), Some(Path::new("/data"))); assert_eq!(got, Some(PathBuf::from("/x/core"))); + assert_eq!(shared_profile_source(Some(Path::new("/x/core")), Some(Path::new("/data"))), None); } #[test] - fn env_then_phone_data_dir_then_home() { + fn env_then_app_data_dir_then_home() { let _g = ENV.lock().unwrap(); let saved_dir = std::env::var_os("OCTOS_APP_CORE_DIR"); let saved_home = std::env::var_os("HOME"); std::env::set_var("OCTOS_APP_CORE_DIR", "/env/core"); - assert_eq!(resolve_core_dir_for(None, Some(Path::new("/data")), true), Some(PathBuf::from("/env/core"))); + assert_eq!(resolve_core_dir(None, Some(Path::new("/data"))), Some(PathBuf::from("/env/core"))); + assert_eq!(shared_profile_source(None, Some(Path::new("/data"))), None, "the caller's choice"); std::env::set_var("OCTOS_APP_CORE_DIR", ""); std::env::set_var("HOME", "/home/me"); - // A phone with the shell's data dir: the app-private octos home. - assert_eq!( - resolve_core_dir_for(None, Some(Path::new("/data/user/0/app/files")), true), - Some(PathBuf::from("/data/user/0/app/files/octos-home/.octos")) - ); - // A desktop ignores the data dir: $HOME/octos-home/.octos. + // Every platform with a data dir: OctoSense's own octos home. assert_eq!( - resolve_core_dir_for(None, Some(Path::new("/data")), false), - Some(PathBuf::from("/home/me/octos-home/.octos")) + resolve_core_dir(None, Some(Path::new("/home/me/.octosense"))), + Some(PathBuf::from("/home/me/.octosense/octos-home/.octos")) ); - // Same as the llm service's default when nothing is named. - assert_eq!(resolve_core_dir_for(None, None, true), octosense_llm_config::profile::default_core_dir()); + if !cfg!(any(target_os = "android", target_env = "ohos", target_os = "ios")) { + assert_eq!( + shared_profile_source(None, Some(Path::new("/home/me/.octosense"))), + Some(PathBuf::from("/home/me/octos-home/.octos")), + "a desktop inherits from the shared home it used to use" + ); + } + // Nothing named: the llm service's default. + assert_eq!(resolve_core_dir(None, None), octosense_llm_config::profile::default_core_dir()); + assert_eq!(shared_profile_source(None, None), None); match saved_dir { Some(v) => std::env::set_var("OCTOS_APP_CORE_DIR", v), @@ -107,6 +166,65 @@ mod tests { } } + fn tmp(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("octos-dirs-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + dir + } + + fn snapshot(dir: &Path) -> Vec<(PathBuf, Vec)> { + let mut out = Vec::new(); + let mut stack = vec![dir.to_path_buf()]; + while let Some(d) = stack.pop() { + for entry in std::fs::read_dir(&d).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { stack.push(path) } else { out.push((path.clone(), std::fs::read(&path).unwrap())) } + } + } + out.sort(); + out + } + + #[test] + fn migration_copies_provider_settings_once_and_never_touches_the_source() { + let root = tmp("migrate"); + let shared = root.join("octos-home/.octos"); + let own = root.join("octosense/octos-home/.octos"); + std::fs::create_dir_all(shared.join("profiles")).unwrap(); + std::fs::write(profile_path(&shared), serde_json::json!({ + "id": "_main", "name": "Mine", "enabled": true, + "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-02T00:00:00Z", + "config": {"llm": {"primary": {"family_id": "deepseek", "model_id": "m"}}, + "env_vars": {"DEEPSEEK_API_KEY": "keychain:DEEPSEEK_API_KEY::_main"}, + "tool_policy": {"allow": ["shell"]}, "channels": {"x": 1}} + }).to_string()).unwrap(); + std::fs::write(shared.join("config.json"), "{}").unwrap(); + let before = snapshot(&shared); + + assert_eq!(migrate_shared_profile(&shared, &own), Ok(true)); + let copied: serde_json::Value = serde_json::from_slice(&std::fs::read(profile_path(&own)).unwrap()).unwrap(); + assert_eq!(copied["id"], "_main"); + assert_eq!(copied["config"]["llm"]["primary"]["family_id"], "deepseek"); + assert_eq!(copied["config"]["env_vars"]["DEEPSEEK_API_KEY"], "keychain:DEEPSEEK_API_KEY::_main"); + assert!(copied["config"].get("tool_policy").is_none(), "the person's policy is not ours"); + assert!(copied["config"].get("channels").is_none(), "only provider settings"); + assert!(!own.join("config.json").exists()); + + // Once: an existing OctoSense profile is never overwritten. + std::fs::write(profile_path(&own), r#"{"config":{"llm":{"primary":{"family_id":"zai"}}}}"#).unwrap(); + assert_eq!(migrate_shared_profile(&shared, &own), Ok(false)); + assert!(std::fs::read_to_string(profile_path(&own)).unwrap().contains("zai")); + assert_eq!(snapshot(&shared), before, "the person's octos home is unchanged"); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn migration_without_a_shared_profile_does_nothing() { + let root = tmp("migrate-none"); + assert_eq!(migrate_shared_profile(&root.join("a"), &root.join("b")), Ok(false)); + assert!(!root.join("b").exists()); + } + #[test] fn kernel_home_is_the_parent_of_a_dot_octos_core_dir() { assert_eq!(kernel_home(Path::new("/d/octos-home/.octos")), PathBuf::from("/d/octos-home")); diff --git a/crates/kernel/src/launch.rs b/crates/kernel/src/launch.rs index 3eb8e1b4..af7ff47d 100644 --- a/crates/kernel/src/launch.rs +++ b/crates/kernel/src/launch.rs @@ -198,7 +198,8 @@ pub(crate) fn phone_stdio(program: PathBuf, core_dir: &Path, extra: &[(String, S /// Make what a start needs: the core dir, the cwd (a missing cwd fails the /// spawn's chdir with ENOENT, permanently, since the kernel would create it), -/// and on Android the kernel config's memory budget (as AppCard did). +/// on Android the kernel config's memory budget (as AppCard did), and the +/// system agent's tool policy in the profile ([`crate::system_tools`]). pub(crate) fn prepare(launch: &Launch, core_dir: &Path) { if let Err(e) = std::fs::create_dir_all(core_dir) { log::warn!("octos-core: could not create {}: {e}", core_dir.display()); @@ -217,6 +218,9 @@ pub(crate) fn prepare(launch: &Launch, core_dir: &Path) { if cfg!(target_os = "android") { ensure_kernel_config(&dirs::kernel_home(core_dir)); } + // Every start: the system agent's tool set (ADR 0004 §12) as the + // profile's tool policy, which octos reads at start. + crate::system_tools::enforce(core_dir); } /// Floor for `memory.max_inject_tokens` in the phone kernel's config. diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index d032064e..cfa48e45 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -44,6 +44,7 @@ mod network; pub use network::{connection_file, pairing_link, ClientAccess, Pairing, CONNECTION_FILE, SYSTEM_SESSION}; pub mod launch; mod router; +pub mod system_tools; pub use dirs::{kernel_home, profile_path, resolve_core_dir}; pub use launch::{Launch, Unavailable}; @@ -86,8 +87,8 @@ impl Options { self.core_dir = Some(dir.into()); self } - /// The app's data dir (`cx.get_data_dir()`): on a phone the core dir is - /// `/octos-home/.octos`. + /// The app's data dir (`cx.get_data_dir()` on a phone, OctoSense's state + /// dir on a desktop): the core dir is `/octos-home/.octos`. pub fn app_data_dir(mut self, dir: impl Into) -> Self { self.app_data_dir = Some(dir.into()); self @@ -217,6 +218,7 @@ impl Default for Core { impl Core { pub fn new(options: Options) -> Self { + Self::adopt_shared_profile(&options); Core(Arc::new(Inner { state: Mutex::new(State { options, @@ -234,9 +236,26 @@ impl Core { /// Replace the options. A running kernel keeps its launch until it /// restarts; the core dir answers the new value at once. pub fn configure(&self, options: Options) { + Self::adopt_shared_profile(&options); self.0.state.lock().unwrap().options = options; } + /// A desktop OctoSense's own core dir inherits, once, the provider and + /// model settings of the octos home it used to share with the person's + /// standalone octos (copy only; see [`dirs::migrate_shared_profile`]). + fn adopt_shared_profile(options: &Options) { + let Some(from) = dirs::shared_profile_source(options.core_dir.as_deref(), options.app_data_dir.as_deref()) else { + return; + }; + let Some(to) = Self::core_dir_of(options) else { return }; + match dirs::migrate_shared_profile(&from, &to) { + Ok(true) => Inner::log(options)(&format!( + "octos-core: copied the provider settings from {} to OctoSense's own {}", from.display(), to.display())), + Ok(false) => {} + Err(e) => Inner::log(options)(&format!("octos-core: provider settings not copied: {e}")), + } + } + /// The kernel's core dir (see [`resolve_core_dir`]). pub fn core_dir(&self) -> Option { let st = self.0.state.lock().unwrap(); diff --git a/crates/kernel/src/system_tools.rs b/crates/kernel/src/system_tools.rs new file mode 100644 index 00000000..b5d07a5d --- /dev/null +++ b/crates/kernel/src/system_tools.rs @@ -0,0 +1,368 @@ +//! The system agent's tool set and the kernel profile's tool policy +//! (ADR 0004 §12, plan step 4). +//! +//! - [`SYSTEM_AGENT_TOOLS`]: the octos tools the system agent +//! (`_main:api:octosense#system`) gets by default. With what the person +//! grants it ([`SystemAgentTools`]: toolbox tools, other apps' shareable +//! tools, and command execution when turned on in Settings) that is its +//! whole set: ADR 0004 §12's "exactly its grants". +//! - [`tool_policy`]: the `_main` profile's policy, the ceiling for every +//! `_main` session. It is every tool any grant can give: OctoSense +//! hard-codes no exclusions (§12) except ONE, octos's own shell +//! ([`OCTOS_SHELL`]). §12 delivers command execution only as a host tool +//! with a live approval (for example `terminal.run`), run by the shell +//! where it can be approved, audited and shown; octos's `shell` runs +//! commands inside the kernel with none of that, so no grant gives it. +//! (If a later reading of §12 makes octos's `shell` grantable too, this is +//! the one line to change: TODO(ADR 0004 §12).) +//! +//! **What is enforced today, and what is not.** octos (the pinned rev) has no +//! tool list the host can set for one session; the only roster control a +//! host has is the profile's `tool_policy` (allow/deny, deny wins), which +//! octos re-applies to every turn's finished registry (after the per-turn +//! `peer_*`, `spawn` and `send_file` tools) and to kernel wake continuations +//! alike. So before every kernel start ([`enforce`], from `launch::prepare`) +//! the host writes [`tool_policy`] into its OWN profile. Consequently: +//! +//! - **§12's "exactly its grants" is NOT yet enforced for the system +//! agent.** It is bounded by the grantable ceiling: no octos shell, but +//! every other tool octos registers for it. Its exact list needs +//! session-targeted registration and tool lists in octos (octos#2567, +//! reviewer item M1; our item 5). The real-kernel exact-list test is kept, +//! ignored until then. +//! - **App peers are not capped below what they can be granted**: each is +//! narrowed to its grants by its turns' `generic_tools` (octos#2567, plan +//! step 6). Host-routed tools (app, toolbox, cross-app tools, command +//! execution) are registered after the policy, so it never strips them. +//! - **Talk to Octos external turns are unaffected**: octos confines them to +//! its external allowlist ([`EXTERNAL_TURN_TOOLS`]), none of which is the +//! shell (UPCR-2026-036). +//! +//! **Whose profile.** The kernel's core dir is OctoSense's own +//! (`/octos-home/.octos`, see `dirs`), never the +//! person's standalone octos home, and [`enforce`] replaces only a policy +//! OctoSense wrote ([`POLICY_OWNER`]): it refuses, and warns, on a foreign +//! policy and on the person's own octos home. + +use std::collections::BTreeSet; +use std::path::Path; + +use serde_json::{json, Value}; + +/// The octos tools the system agent gets by default (not yet enforced as +/// exact: see the module docs). +/// +/// - **Supervision** of app peers (ADR 0004 §6): `peer_send_input` briefs +/// and asks, `peer_gather` / `peer_list` read the blackboard, +/// `peer_respond` answers a peer's question (never its approvals, which +/// octos refuses), `peer_close` retires one. Not `peer_handoff`: app peers +/// are prepared by the host (`peer/prepare`). +/// - **Its workspace**, fenced by octos to the session's working directory: +/// read, search and edit files there. +/// - **The person**: `ask_user_question`, media viewing. +/// - **Memory**: recall and search, and saving to its own namespace. +/// - **The web**: octos's builtin `web_search` / `web_fetch`, which Talk to +/// Octos clients also keep, until the toolbox (#108) grants the system +/// agent `toolbox.search` / `toolbox.web_read`. +/// +/// Anything else it may have is by grant ([`SystemAgentTools`]); command +/// execution only as a host tool the person turns on in Settings. +pub const SYSTEM_AGENT_TOOLS: &[&str] = &[ + // Supervision. + "peer_send_input", + "peer_gather", + "peer_list", + "peer_respond", + "peer_close", + // Its workspace (octos fences these to the session's working directory). + "read_file", + "write_file", + "edit_file", + "diff_edit", + "apply_patch", + "glob", + "grep", + "list_dir", + "code_structure", + "check_workspace_contract", + // The person. + "ask_user_question", + "view_image", + "view_video", + // Memory. + "recall", + "recall_memory", + "memory_search", + "memory_load", + "save_memory", + "memory_note", + // The web (until the toolbox grants replace them, #108). + "web_search", + "web_fetch", + // Tool discovery over this same set. + "tool_search", +]; + +/// octos's own shell: `group:runtime`, the `shell` tool and its aliases +/// (`bash`, `exec_command` and its PTY input `write_stdin`). The one tool +/// OctoSense never offers: command execution is granted as a host tool +/// ([`COMMAND_EXECUTION_TOOL`]), each command approved live. +pub const OCTOS_SHELL: &str = "group:runtime"; + +/// Marks a `tool_policy` OctoSense wrote (`"owner"`, a field octos ignores). +pub const POLICY_OWNER: &str = "octosense"; + +/// The host tool granted command execution arrives as (the Terminal app's +/// shareable tool, ADR 0004 §10 and §12): each command approved live. +pub const COMMAND_EXECUTION_TOOL: &str = "terminal.run"; + +/// octos's allowlist for a Talk to Octos external turn (octos +/// `crates/octos-cli/src/api/host_managed.rs`, `EXTERNAL_TURN_TOOLS`, at the +/// pinned rev). Kept here to prove the system agent's set never narrows it. +pub const EXTERNAL_TURN_TOOLS: &[&str] = &[ + "read_file", + "write_file", + "edit_file", + "diff_edit", + "apply_patch", + "glob", + "grep", + "list_dir", + "code_structure", + "check_workspace_contract", + "web_search", + "web_fetch", + "ask_user_question", + "recall", + "recall_memory", + "memory_search", + "memory_load", + "view_image", + "view_video", + "tool_search", +]; + +/// The system agent's tool set: [`SYSTEM_AGENT_TOOLS`] plus what it is +/// granted. Granted tools are host-routed; registering them on the system +/// session, like narrowing it to this set, needs octos's session-targeted +/// registration (octos#2567 items 5 and 6), so today nothing grants any and +/// [`SystemAgentTools::host_tools`] is what the shell will register then. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct SystemAgentTools { + toolbox: BTreeSet, + cross_app: BTreeSet, + command_execution: bool, +} + +impl SystemAgentTools { + /// The set as shipped: no grants, command execution off. + pub fn new() -> Self { + Self::default() + } + + /// Grant a toolbox tool (`toolbox.search`, …; ADR 0002 §6, #108). + pub fn grant_toolbox(&mut self, tool: impl Into) -> &mut Self { + self.toolbox.insert(tool.into()); + self + } + + /// Grant another app's shareable tool (`mail.send`, …; ADR 0004 §7). + pub fn grant_cross_app(&mut self, tool: impl Into) -> &mut Self { + self.cross_app.insert(tool.into()); + self + } + + /// The person's Settings switch for the system agent's command + /// execution (ADR 0004 §12; off by default). On, the system agent gets + /// the host tool [`COMMAND_EXECUTION_TOOL`], each command approved live + /// (section 8); never octos's shell. + /// + /// TODO(ADR 0004 plan steps 4/6): persist the switch in Settings → + /// Assistant (no Settings plumbing for it exists yet) and register the + /// host tool on the system session once octos can (octos#2567 item 6). + pub fn grant_command_execution(&mut self, on: bool) -> &mut Self { + self.command_execution = on; + self + } + + /// Whether the person granted command execution. + pub fn command_execution(&self) -> bool { + self.command_execution + } + + /// The host-routed tools the system agent is granted. + pub fn host_tools(&self) -> BTreeSet { + let mut tools: BTreeSet = self.toolbox.union(&self.cross_app).cloned().collect(); + if self.command_execution { + tools.insert(COMMAND_EXECUTION_TOOL.to_owned()); + } + tools + } + + /// Every tool name a system-agent turn is meant to be offered: its octos + /// tools and its host tools. + pub fn names(&self) -> BTreeSet { + SYSTEM_AGENT_TOOLS.iter().map(|t| t.to_string()).chain(self.host_tools()).collect() + } +} + +/// The octos `ToolPolicy` the kernel runs `_main` with: everything a grant +/// can give (an empty allowlist is octos's "allow all") except octos's +/// shell, marked as OctoSense's. +pub fn tool_policy() -> Value { + json!({ "allow": [], "deny": [OCTOS_SHELL], "owner": POLICY_OWNER }) +} + +/// What [`enforce`] did. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Enforced { + /// The policy was written (or already current). + Written, + /// No profile yet: no provider, no turns, nothing to do. + NoProfile, + /// Refused: why (a foreign policy, the person's own octos home, an + /// unreadable profile). Logged as a warning. + Refused(String), +} + +/// Write [`tool_policy`] into `/profiles/_main.json` +/// (`config.tool_policy`), keeping every other key. It replaces only a +/// policy OctoSense wrote (owner [`POLICY_OWNER`]) or none, and never +/// touches the person's own octos home (`$HOME/octos-home/.octos`). +pub fn enforce(core_dir: &Path) -> Enforced { + let outcome = enforce_unless_shared(core_dir, octosense_llm_config::profile::default_core_dir().as_deref()); + if let Enforced::Refused(why) = &outcome { + log::warn!("octos-core: tool policy NOT written: {why}"); + } + outcome +} + +pub(crate) fn enforce_unless_shared(core_dir: &Path, shared: Option<&Path>) -> Enforced { + if shared.is_some_and(|shared| same_dir(shared, core_dir)) { + return Enforced::Refused(format!( + "{} is the person's own octos home, not OctoSense's", + core_dir.display() + )); + } + let path = crate::dirs::profile_path(core_dir); + let Ok(bytes) = std::fs::read(&path) else { return Enforced::NoProfile }; + let refused = |what: &str| Enforced::Refused(format!("{} {what}", path.display())); + let Ok(mut root) = serde_json::from_slice::(&bytes) else { return refused("is not JSON") }; + let Some(obj) = root.as_object_mut() else { return refused("is not a JSON object") }; + let config = obj.entry("config").or_insert_with(|| json!({})); + let Some(config) = config.as_object_mut() else { return refused("has a `config` that is not an object") }; + let policy = tool_policy(); + match config.get("tool_policy") { + Some(current) if *current == policy => return Enforced::Written, + None | Some(Value::Null) => {} + Some(current) if current.get("owner").and_then(Value::as_str) == Some(POLICY_OWNER) => {} + Some(_) => return refused("has a tool policy OctoSense did not write; leaving it"), + } + config.insert("tool_policy".into(), policy); + let result = serde_json::to_vec_pretty(&root) + .map_err(|e| e.to_string()) + .and_then(|body| { + let dir = path.parent().unwrap_or(core_dir); + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("_main.json"); + crate::network::write_private(dir, name, &body).map_err(|e| e.to_string()) + }); + match result { + Ok(()) => { + log::info!("octos-core: wrote OctoSense's tool policy to {}", path.display()); + Enforced::Written + } + Err(e) => Enforced::Refused(format!("could not write {}: {e}", path.display())), + } +} + +fn same_dir(a: &Path, b: &Path) -> bool { + a == b || matches!((std::fs::canonicalize(a), std::fs::canonicalize(b)), (Ok(x), Ok(y)) if x == y) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tmp(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("octos-systools-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("profiles")).unwrap(); + dir + } + + fn read(path: &Path) -> Value { + serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap() + } + + #[test] + fn only_octos_shell_is_excluded_and_the_system_agent_default_has_none_of_it() { + let policy = tool_policy(); + assert_eq!(policy["allow"], json!([]), "everything a grant can give"); + assert_eq!(policy["deny"], json!(["group:runtime"]), "octos's shell, and nothing else"); + for shell in ["shell", "bash", "exec_command", "write_stdin"] { + assert!(!SYSTEM_AGENT_TOOLS.contains(&shell)); + assert!(!EXTERNAL_TURN_TOOLS.contains(&shell), "external clients lose nothing"); + } + assert!(!SYSTEM_AGENT_TOOLS.contains(&"peer_handoff")); + } + + #[test] + fn grants_join_the_system_agents_set_as_host_tools() { + let shipped = SystemAgentTools::new(); + assert!(!shipped.command_execution(), "command execution is off by default"); + assert!(shipped.host_tools().is_empty()); + assert_eq!(shipped.names().len(), SYSTEM_AGENT_TOOLS.len(), "no duplicates"); + let mut granted = SystemAgentTools::new(); + granted + .grant_toolbox("toolbox.search") + .grant_cross_app("mail.send") + .grant_command_execution(true); + let host: Vec = granted.host_tools().into_iter().collect(); + assert_eq!(host, ["mail.send", COMMAND_EXECUTION_TOOL, "toolbox.search"]); + assert!(!granted.names().contains("shell"), "never octos's shell"); + } + + #[test] + fn enforce_writes_ours_keeps_the_rest_and_replaces_only_our_own() { + let dir = tmp("keep"); + let path = dir.join("profiles/_main.json"); + std::fs::write(&path, r#"{"id":"_main","config":{"llm":{"primary":{"family_id":"x"}}}}"#).unwrap(); + assert_eq!(enforce_unless_shared(&dir, None), Enforced::Written); + let v = read(&path); + assert_eq!(v["id"], "_main"); + assert_eq!(v["config"]["llm"]["primary"]["family_id"], "x"); + assert_eq!(v["config"]["tool_policy"], tool_policy()); + // An older policy of ours is replaced. + std::fs::write(&path, r#"{"config":{"tool_policy":{"allow":["read_file"],"owner":"octosense"}}}"#).unwrap(); + assert_eq!(enforce_unless_shared(&dir, None), Enforced::Written); + assert_eq!(read(&path)["config"]["tool_policy"], tool_policy()); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn enforce_refuses_a_foreign_policy_and_the_persons_own_octos_home() { + let dir = tmp("foreign"); + let path = dir.join("profiles/_main.json"); + let foreign = r#"{"config":{"tool_policy":{"allow":["*"]}}}"#; + std::fs::write(&path, foreign).unwrap(); + assert!(matches!(enforce_unless_shared(&dir, None), Enforced::Refused(_))); + assert_eq!(std::fs::read_to_string(&path).unwrap(), foreign, "untouched"); + // The person's own octos home: never written, policy or not. + let own = r#"{"config":{"llm":{}}}"#; + std::fs::write(&path, own).unwrap(); + assert!(matches!(enforce_unless_shared(&dir, Some(&dir)), Enforced::Refused(_))); + assert_eq!(std::fs::read_to_string(&path).unwrap(), own, "untouched"); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn enforce_without_a_profile_or_with_a_broken_one_leaves_it() { + let dir = tmp("none"); + assert_eq!(enforce_unless_shared(&dir, None), Enforced::NoProfile); + assert!(!dir.join("profiles/_main.json").exists(), "no profile is invented"); + std::fs::write(dir.join("profiles/_main.json"), "{not json").unwrap(); + assert!(matches!(enforce_unless_shared(&dir, None), Enforced::Refused(_))); + assert_eq!(std::fs::read_to_string(dir.join("profiles/_main.json")).unwrap(), "{not json"); + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/crates/kernel/tests/real_kernel.rs b/crates/kernel/tests/real_kernel.rs index 9d532818..791aa615 100644 --- a/crates/kernel/tests/real_kernel.rs +++ b/crates/kernel/tests/real_kernel.rs @@ -463,6 +463,141 @@ async fn native_and_browser_talk_to_the_same_system_agent() { let _ = std::fs::remove_dir_all(dir); } +/// What the system agent's turns were offered: `(stdio, host over Talk to +/// Octos, external client)`. The scripted model logs the tool names each +/// request offered. The profile carries an older OctoSense policy that allows +/// `shell`: the kernel start replaces it with the current one. +async fn offered_to_system_turns(program: &Path, tag: &str) -> [std::collections::BTreeSet; 3] { + use std::collections::BTreeSet; + use std::io::BufRead; + let dir = std::env::temp_dir().join(format!("octos-system-tools-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("profiles")).unwrap(); + let log = dir.join("offered.jsonl"); + let script = Path::new(env!("CARGO_MANIFEST_DIR")).join("../app-peers/tests/fixtures/mock_llm.py"); + let mut model = std::process::Command::new("python3").arg(script).env("MOCK_LLM_TOOLS_LOG", &log) + .stdout(std::process::Stdio::piped()).spawn().unwrap(); + let mut line = String::new(); + std::io::BufReader::new(model.stdout.take().unwrap()).read_line(&mut line).unwrap(); + let port: u16 = line.trim().parse().unwrap(); + let _model = Defer(move || { let _ = model.kill(); let _ = model.wait(); }); + std::fs::write(dir.join("profiles/_main.json"), json!({ + "id":"_main", "name":"Main", "enabled":true, + "created_at":"2026-09-27T00:00:00Z", "updated_at":"2026-09-27T00:00:00Z", + "config":{"llm":{"primary":{"family_id":"local", "model_id":"mock-model", + "route":{"base_url":format!("http://127.0.0.1:{port}/v1"), "api_type":"openai"}}}, + "tool_policy":{"allow":["read_file","shell"],"owner":"octosense"}} + }).to_string()).unwrap(); + std::fs::write(dir.join("web-client-origin.txt"), WEB).unwrap(); + let offered = |probe: &str| -> Option> { + let text = std::fs::read_to_string(&log).ok()?; + text.lines().filter_map(|l| serde_json::from_str::(l).ok()) + .filter(|v| v["user"].as_str().is_some_and(|u| u.contains(probe))) + .find(|v| v["tools"].as_array().is_some_and(|t| !t.is_empty())) + .map(|v| v["tools"].as_array().unwrap().iter().filter_map(Value::as_str).map(str::to_owned).collect()) + }; + let wait_for = |probe: &'static str| { + let offered = &offered; + async move { + tokio::time::timeout(Duration::from_secs(60), async { + loop { + if let Some(tools) = offered(probe) { return tools; } + tokio::time::sleep(Duration::from_millis(100)).await; + } + }).await.unwrap_or_else(|_| panic!("the model never saw the {probe} turn")) + } + }; + let input = |text: &str| json!({"session_id":SYSTEM_SESSION,"turn_id":uuid::Uuid::new_v4(), + "input":[{"kind":"text","text":text}]}); + let open = json!({"session_id":SYSTEM_SESSION,"profile_id":"_main"}); + + // The private stdio kernel (Talk to Octos off): the host's own turn. + let core = Core::new(Options::default().program(program).core_dir(&dir)); + let mut native = core.connect().unwrap(); + call(&mut native, "open", "session/open", open.clone()).await; + call(&mut native, "turn", "turn/start", input("SYSTEM_TOOLS_STDIO")).await; + let stdio = wait_for("SYSTEM_TOOLS_STDIO").await; + drop(native); + core.shutdown_within(Duration::from_secs(15)); + + // Talk to Octos on: the host's turn over the WebSocket, then an external + // client's turn on the same system conversation. + let core = Core::new(Options::default().program(program).core_dir(&dir)); + let c = core.clone(); + blocking(move || c.set_external_access(true)).await.unwrap(); + let mut native = core.connect().unwrap(); + let access = native.client_access().await.unwrap(); + call(&mut native, "open", "session/open", open.clone()).await; + let host_turn = input("SYSTEM_TOOLS_HOST"); + call(&mut native, "turn", "turn/start", host_turn.clone()).await; + let host = wait_for("SYSTEM_TOOLS_HOST").await; + // One turn at a time on a session: let the host's finish first. + tokio::time::timeout(Duration::from_secs(30), async { + loop { + let state = call(&mut native, "state", "turn/state/get", json!({ + "session_id":SYSTEM_SESSION,"turn_id":host_turn["turn_id"]})).await; + if state["state"] == "completed" { break; } + tokio::time::sleep(Duration::from_millis(100)).await; + } + }).await.expect("the host's turn completed"); + let mut browser = external(&access).await; + ws_call(&mut browser, "open", "session/open", open).await; + ws_call(&mut browser, "turn", "turn/start", input("SYSTEM_TOOLS_EXTERNAL")).await; + let external_tools = wait_for("SYSTEM_TOOLS_EXTERNAL").await; + drop((native, browser)); + let c = core.clone(); + blocking(move || c.set_external_access(false)).await.unwrap(); + core.shutdown_within(Duration::from_secs(15)); + let _ = std::fs::remove_dir_all(dir); + [stdio, host, external_tools] +} + +/// ADR 0004 §12, what is enforced today: a system-agent turn, whoever starts +/// it, is offered none of octos's shell (`group:runtime`: `shell`, `bash`, +/// `exec_command`, `write_stdin`) and every tool of its own list octos +/// registers; beyond that it is bounded only by the grantable ceiling +/// (everything else), not its exact list. A Talk to Octos external client's +/// turn keeps octos's external allowlist. +#[tokio::test(flavor = "multi_thread")] +async fn a_system_agent_turn_is_offered_no_octos_shell() { + use octosense_kernel::system_tools::{EXTERNAL_TURN_TOOLS, SYSTEM_AGENT_TOOLS}; + use std::collections::BTreeSet; + let Some(program) = kernel() else { return }; + let [stdio, host, external_tools] = offered_to_system_turns(&program, "ceiling").await; + // `recall` (a session's evicted tool outputs) is registered only on + // octos's session-actor turns, not on UI Protocol turns. + let own: BTreeSet = SYSTEM_AGENT_TOOLS.iter().filter(|t| **t != "recall") + .map(|t| t.to_string()).collect(); + for (how, offered) in [("stdio", &stdio), ("Talk to Octos", &host)] { + for shell in ["shell", "bash", "exec_command", "write_stdin"] { + assert!(!offered.contains(shell), "{how}: {shell} offered: {offered:?}"); + } + assert!(own.is_subset(offered), "{how}: missing its own tools: {:?}", &own - offered); + } + assert_eq!(stdio, host, "the same set over Talk to Octos"); + let allowlist: BTreeSet = EXTERNAL_TURN_TOOLS.iter().map(|t| t.to_string()).collect(); + assert!(external_tools.is_subset(&allowlist), "{external_tools:?}"); + assert_eq!(external_tools, &stdio & &allowlist, "external clients keep every allowlisted tool the kernel offers"); +} + +/// ADR 0004 §12, the target, NOT yet enforced: a system-agent turn is +/// offered EXACTLY its grants (its default list; nothing granted here). +/// Needs session-targeted registration and tool lists in octos (octos#2567, +/// reviewer item M1; our item 5); until then the grantable ceiling bounds it +/// (above). +#[tokio::test(flavor = "multi_thread")] +#[ignore = "needs session-targeted tool lists in octos (octos#2567 M1, our item 5); today the system agent is bounded by the grantable ceiling"] +async fn a_system_agent_turn_is_offered_exactly_the_system_agent_tools() { + use octosense_kernel::system_tools::SystemAgentTools; + use std::collections::BTreeSet; + let Some(program) = kernel() else { return }; + let [stdio, host, _] = offered_to_system_turns(&program, "exact").await; + let expected: BTreeSet = SystemAgentTools::new().names().into_iter() + .filter(|t| t != "recall").collect(); + assert_eq!(stdio, expected); + assert_eq!(host, expected); +} + /// The kernel must not outlive a host that dies without stopping it /// (a crash or SIGKILL): its stdin closes and it stops. This test re-runs /// itself as the host in a child process and kills that process. diff --git a/crates/shell/src/lib.rs b/crates/shell/src/lib.rs index 92d5453c..a4ec47da 100644 --- a/crates/shell/src/lib.rs +++ b/crates/shell/src/lib.rs @@ -4452,7 +4452,11 @@ impl MatchEvent for App { // configured here and started when a consumer (AppCard, Rinx) // connects, and AI providers' `llm` service, which writes its profile // and restarts it after a change. A no-op where the build links none. - ai_host::start(ai_host::Host::platform(cx.get_data_dir())); + // Its octos home is OctoSense's own (`/octos-home`; on a + // desktop, OctoSense's state dir), never the person's `~/octos-home`. + ai_host::start(ai_host::Host::platform(cx.get_data_dir().or_else(|| { + Some(octosense::paths::home().to_string_lossy().into_owned()) + }))); // CLI: --import-theme pulls an omarchy theme and converts // it to splash before the desktop appears. let mut args = std::env::args(); diff --git a/docs/ai-services.md b/docs/ai-services.md index 3b7e814b..9c727bc2 100644 --- a/docs/ai-services.md +++ b/docs/ai-services.md @@ -72,7 +72,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 `OCTOS_APP_CORE_BIN` there is no kernel**, and a developer's own `octos serve` is never touched. Packaging the kernel next to the desktop binary is in progress ([#85](https://github.com/OctoSense-org/OctoSense/pull/85)). | `$OCTOS_APP_CORE_DIR`, else `~/octos-home/.octos` | +| Desktop (macOS; Windows and Linux untested) | `$OCTOS_APP_CORE_BIN serve --stdio --data-dir ` (plus `--config /config.json` if present). **Without `OCTOS_APP_CORE_BIN` there is no kernel**, and a developer's own `octos serve` is never touched. Packaging the kernel next to the desktop binary is in progress ([#85](https://github.com/OctoSense-org/OctoSense/pull/85)). | `$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 | – | diff --git a/docs/ai-services.zh-CN.md b/docs/ai-services.zh-CN.md index b0dcae61..9f715ade 100644 --- a/docs/ai-services.zh-CN.md +++ b/docs/ai-services.zh-CN.md @@ -72,7 +72,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` | +| 桌面端(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`(只从中复制一次提供商设置) | | 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 | **没有。** 提供方仍会保存;没有应用能获得助手 | – |