From 3d77b7f80d85acf61595ecc48a5c01e4b65efaa3 Mon Sep 17 00:00:00 2001 From: AlexZhang Date: Mon, 28 Sep 2026 20:04:22 +0800 Subject: [PATCH 1/2] ai-host: the octos host service for contained apps (Card runner) Store and system apps the Card runner hosts that declare octos.* now reach the shell's assistant instead of `no service answers "octos"`. The new `octos` host service (crates/ai-host/src/contained.rs) hands each app's calls to ONE host-owned peer, `card.`, owned by the system agent on the shell's kernel: the same app-peers contract Rinx uses, namespaced so a store app can never share a native module's peer or memory. - Arguments as Rinx: turn.start takes only `text` (non-blank, <= 32 KiB), the other three services take `{}`; unknown octos methods and sheet calls are refused. - One peer per app (created on first use, account `device`), one request context per app, reopened with a new generation when it closes. - Tool approvals are declined (the Card runner has no approval sheet yet) and listed in the reply's `denied_approvals`. - Replies over 2 MiB are refused. - Policy::contained_apps is the shell's switch: on in shipped(), off in none(); with it off the service answers that the assistant is off. - The logic runs without a kernel through a PeerFactory; the shell's factory (cfg(kernel)) launches peers with app_peers::hosted::launch. Tests use a fake factory over App Hub's real dispatch path. Docs: docs/ai-services{,.zh-CN}.md and README{,.zh-CN}.md describe what a contained app can now use and hears, and how each answer was verified. The task contract is specs/card-runner-octos.spec.md. Spec-Name: The octos host service for the Card runner Spec-Passing: true Spec-Summary: 14/14 passed, 0 failed, 0 skipped, 0 uncertain Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 + README.md | 2 +- README.zh-CN.md | 2 +- crates/ai-host/Cargo.toml | 3 + crates/ai-host/src/contained.rs | 235 ++++++++++++++++++ crates/ai-host/src/contained/tests.rs | 340 ++++++++++++++++++++++++++ crates/ai-host/src/lib.rs | 38 ++- docs/ai-services.md | 22 +- docs/ai-services.zh-CN.md | 22 +- specs/card-runner-octos.spec.md | 164 +++++++++++++ 10 files changed, 809 insertions(+), 21 deletions(-) create mode 100644 crates/ai-host/src/contained.rs create mode 100644 crates/ai-host/src/contained/tests.rs create mode 100644 specs/card-runner-octos.spec.md diff --git a/Cargo.lock b/Cargo.lock index c74b3662..d9d11a23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7271,9 +7271,11 @@ dependencies = [ "makepad-app-module", "makepad-widgets", "octosense-app-peers", + "octosense-appstore", "octosense-kernel", "octosense-llm-config", "octosense-llm-service", + "serde_json", ] [[package]] diff --git a/README.md b/README.md index f4d77b38..a08b6551 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Related, not build inputs: [OctoScript-App-Design-Flow](https://github.com/OctoS Each shell runs one [octos](https://github.com/octos-org/octos) agent kernel, started on first use: the APK's `liboctos.so` on Android, in process on OpenHarmony, the binary `OCTOS_APP_CORE_BIN` names on a desktop, none on iOS. The person chooses its models and types keys in the **AI providers** system app, on host sheets; keys stay in the platform's secret store and never reach an app. [`crates/ai-host`](crates/ai-host/README.md) is the shells' one entry point, and [`crates/app-peers`](crates/app-peers/README.md) gives each granted native app its own octos peer (private contexts, workspace and memory `app//acct-`), owned by the shell's system agent. A peer's tool approvals are answered only by the person, in that app; the system agent cannot answer them. -What works today: native modules (Rinx) use their peer; AppCard (opt-in) uses the kernel directly. Contained script apps, system or store, **cannot reach the assistant yet**: no shell serves `octos.*` to the Card runner, and the `llm` service manages providers for `os.*` apps only. An app's own agent (`tools.json`, `AGENT.md`, skills, triggers, glance cards) is [ADR 0002](docs/adr/0002-event-driven-app-agents.md), Proposed, with its first pieces merged or in review. +What works today: native modules (Rinx) use their peer; AppCard (opt-in) uses the kernel directly. Contained script apps, system or store, reach it through the `octos` host service in a shell that hosts a kernel: each app gets its own host-owned peer (`card.`), and tool approvals are declined until the Card runner has an approval sheet. The `llm` service manages providers for `os.*` apps only. An app's own agent (`tools.json`, `AGENT.md`, skills, triggers, glance cards) is [ADR 0002](docs/adr/0002-event-driven-app-agents.md), Proposed, with its first pieces merged or in review. The architecture, the trust model, what each kind of app can use, the plan with its status, and how to run and test it locally: [docs/ai-services.md](docs/ai-services.md). For app developers: OctoScript-App-Design-Flow's [AI-SERVICES](https://github.com/OctoSense-org/OctoScript-App-Design-Flow/blob/main/docs/AI-SERVICES.md). diff --git a/README.zh-CN.md b/README.zh-CN.md index f0aa33b8..3fbeb87c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -51,7 +51,7 @@ Shell 只有一份,位于 `crates/shell`([ADR 0001(英文)](docs/adr/000 每个 Shell 运行一个 [octos](https://github.com/octos-org/octos) Agent 内核,首次使用时启动:Android 上是 APK 中的 `liboctos.so`,OpenHarmony 上在进程内运行,桌面端运行 `OCTOS_APP_CORE_BIN` 指定的二进制,iOS 上没有。用户在系统应用 **AI providers** 中、在宿主面板上选择模型并输入密钥;密钥保存在平台的密钥存储中,永远不会到达应用。[`crates/ai-host`](crates/ai-host/README.md) 是两个 Shell 的统一入口,[`crates/app-peers`](crates/app-peers/README.md) 为每个获授权的原生应用分配自己的 octos peer(私有的上下文、工作区和记忆 `app//acct-`),归 Shell 的系统 Agent 所有。peer 的工具审批只能由用户在该应用中回答,系统 Agent 无法代答。 -目前可用的:原生模块(Rinx)使用自己的 peer;AppCard(需主动开启)直接使用内核。隔离运行的脚本应用,无论系统应用还是商店应用,**目前都还无法使用助手**:没有任何 Shell 向 Card runner 提供 `octos.*`,而 `llm` 服务只为 `os.*` 应用管理提供方。应用自己的 Agent(`tools.json`、`AGENT.md`、skills、触发器、glance 卡片)见 [ADR 0002](docs/adr/0002-event-driven-app-agents.md)(Proposed),其中最早的几部分已合入或正在评审。 +目前可用的:原生模块(Rinx)使用自己的 peer;AppCard(需主动开启)直接使用内核。隔离运行的脚本应用,无论系统应用还是商店应用,在托管了内核的 Shell 中通过 `octos` 宿主服务使用助手:每个应用有自己的、由宿主拥有的 peer(`card.<应用 id>`),在 Card runner 有审批面板之前工具审批一律被拒绝。`llm` 服务只为 `os.*` 应用管理提供方。应用自己的 Agent(`tools.json`、`AGENT.md`、skills、触发器、glance 卡片)见 [ADR 0002](docs/adr/0002-event-driven-app-agents.md)(Proposed),其中最早的几部分已合入或正在评审。 架构、信任模型、各类应用能用什么、规划及其状态,以及如何在本地运行和测试:[docs/ai-services.zh-CN.md](docs/ai-services.zh-CN.md)。面向应用开发者:OctoScript-App-Design-Flow 的 [AI-SERVICES](https://github.com/OctoSense-org/OctoScript-App-Design-Flow/blob/main/docs/AI-SERVICES.zh-CN.md)。 diff --git a/crates/ai-host/Cargo.toml b/crates/ai-host/Cargo.toml index 5bd8ee3e..ac0f73e0 100644 --- a/crates/ai-host/Cargo.toml +++ b/crates/ai-host/Cargo.toml @@ -46,6 +46,9 @@ makepad-app-module = { workspace = true } octosense-llm-config = { workspace = true } # The contract (OCTOS_SERVICES, injection) always; the broker with `octos-core`. octosense-app-peers = { workspace = true } +# The Card runner's host-service API: the `octos` service for contained apps. +octosense-appstore = { workspace = true } +serde_json = { workspace = true } octosense-kernel = { workspace = true, optional = true } octosense-llm-service = { workspace = true, optional = true } diff --git a/crates/ai-host/src/contained.rs b/crates/ai-host/src/contained.rs new file mode 100644 index 00000000..61c235f2 --- /dev/null +++ b/crates/ai-host/src/contained.rs @@ -0,0 +1,235 @@ +//! `octos` for contained apps: the Card runner's assistant service. +//! +//! A store app (an App Hub bundle the Card runner hosts) that declares +//! `octos.*` services calls `host.request("octos.turn.start", {text}, …)`. +//! The isolate's gate already refused every service the manifest did not +//! declare; what reaches this service is handed to ONE octos peer per app, +//! owned by the shell's system agent on the shell's kernel — the same +//! host-owned peer contract Rinx uses (ADR 0007), named `card.` so a +//! store app can never share a native module's peer or memory. +//! +//! What an app sends is input text only. It never names a session, profile, +//! workspace or provider, and it cannot decide a tool approval: the Card +//! runner has no approval sheet yet, so every approval the peer raises is +//! declined here and listed in the reply's `denied_approvals`. +//! +//! The logic does not need a kernel: peers come from a [`PeerFactory`]. The +//! shell's factory (`cfg(kernel)`) launches them through +//! `octosense_app_peers::hosted`; tests use a fake one. + +use octosense_app_peers::{ContextEvent, ContextOp, ContextSpec, EventSink, OctosAppService, OctosContext}; +use octosense_appstore::services::{HostService, Replier, ServiceCall, ServiceHost}; +use serde_json::Value; +use std::collections::HashMap; +use std::sync::{Arc, Mutex, Weak}; + +/// A contained app's peer is `card.`; no native module id starts so. +pub const PEER_PREFIX: &str = "card."; +/// The Card runner has no accounts: every app's peer acts for the device. +pub const ACCOUNT: &str = "device"; +/// The longest turn text an app may send. +pub const MAX_TEXT_BYTES: usize = 32 * 1024; +/// The largest reply delivered to an app (serialized JSON). +pub const MAX_REPLY_BYTES: usize = 2 * 1024 * 1024; +/// The longest peer id the kernel's memory namespace takes. +const MAX_PEER_ID: usize = 64; + +pub const TURNED_OFF: &str = "The assistant is turned off for apps on this device"; +pub const UNAVAILABLE: &str = "The assistant is not available on this device"; +pub const UNSUPPORTED_ARGS: &str = "Unsupported Octos arguments"; +pub const BAD_TEXT: &str = "Provide text (at most 32 KiB)"; +pub const NO_SHEET: &str = "The assistant has no sheet; octos calls come from the app"; + +/// Where contained apps' peers come from. +pub trait PeerFactory: Send + Sync { + /// The scoped assistant service for `app_id`'s peer `peer_id`, or `None` + /// when this device cannot give one. + fn launch(&self, peer_id: &str, app_id: &str) -> Option>; +} + +/// The peer id of `app_id`, or why it cannot name one. +pub fn peer_id(app_id: &str) -> Result { + let id = format!("{PEER_PREFIX}{app_id}"); + if namespace_segment(&id) { + Ok(id) + } else { + Err(format!( + "The app id {app_id:?} cannot name an assistant peer (lowercase letters, digits, '.', '_' or '-', at most {} characters)", + MAX_PEER_ID - PEER_PREFIX.len() + )) + } +} + +/// The kernel's memory-namespace segment rule (`octosense_app_peers::hosted`). +fn namespace_segment(id: &str) -> bool { + let bytes = id.as_bytes(); + !bytes.is_empty() + && bytes.len() <= MAX_PEER_ID + && (bytes[0].is_ascii_lowercase() || bytes[0].is_ascii_digit()) + && bytes.iter().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'_' | b'-')) +} + +/// The operation an app's call asks for. Only `text` for a turn; nothing for +/// the rest. +pub fn parse(service: &str, args: &Value) -> Result { + let allowed: &[&str] = match service { + "octos.turn.start" => &["text"], + "octos.session.open" | "octos.session.history" | "octos.turn.interrupt" => &[], + other => return Err(format!("Unknown Octos service {other}")), + }; + if args.as_object().is_none_or(|o| o.keys().any(|k| !allowed.contains(&k.as_str()))) { + return Err(UNSUPPORTED_ARGS.into()); + } + Ok(match service { + "octos.turn.start" => ContextOp::Turn { + text: args["text"] + .as_str() + .filter(|s| !s.trim().is_empty() && s.len() <= MAX_TEXT_BYTES) + .ok_or(BAD_TEXT)? + .to_owned(), + }, + "octos.session.open" => ContextOp::Open, + "octos.session.history" => ContextOp::History, + _ => ContextOp::Interrupt, + }) +} + +struct AppPeer { + peer: String, + service: Arc, + context: Option>, + generation: u64, +} + +/// The `octos` host service for the Card runner's apps. +pub struct ContainedOctos { + enabled: bool, + factory: Arc, + apps: HashMap, +} + +impl ContainedOctos { + /// `enabled` is the shell's switch ([`crate::Policy::contained_apps`]). + pub fn new(enabled: bool, factory: Arc) -> Self { + ContainedOctos { enabled, factory, apps: HashMap::new() } + } + + /// The app's open request context, creating its peer and (re)opening + /// the context as needed. + fn context_for(&mut self, app_id: &str) -> Result, String> { + if !self.apps.contains_key(app_id) { + let peer = peer_id(app_id)?; + let service = self.factory.launch(&peer, app_id).ok_or(UNAVAILABLE)?; + service.set_account(Some(ACCOUNT)); + self.apps.insert(app_id.to_owned(), AppPeer { peer, service, context: None, generation: 0 }); + } + let app = self.apps.get_mut(app_id).expect("inserted above"); + if let Some(context) = app.context.as_ref().filter(|c| c.is_open()) { + return Ok(context.clone()); + } + app.generation += 1; + let context = app.service.open_context(ContextSpec { + account: ACCOUNT.to_owned(), + instance: format!("{}-g{}", app.peer, app.generation), + services: app.service.services(), + })?; + app.context = Some(context.clone()); + Ok(context) + } +} + +/// One call's answer: sent exactly once. +type Once = Arc>>; + +fn answer(once: &Once, result: Result) { + if let Some(reply) = once.lock().unwrap_or_else(|e| e.into_inner()).take() { + reply.send(result); + } +} + +/// The reply an app receives: the kernel's result, with the tools declined +/// during the call, bounded. +fn finish(result: Result, denied: &[String]) -> Result { + let mut value = result?; + if !denied.is_empty() { + if let Some(object) = value.as_object_mut() { + object.insert("denied_approvals".into(), denied.iter().cloned().map(Value::String).collect()); + } + } + if value.to_string().len() > MAX_REPLY_BYTES { + return Err(format!("The assistant's reply is over MAX_REPLY_BYTES ({MAX_REPLY_BYTES} bytes)")); + } + Ok(value) +} + +/// Where one call's events go: approvals are declined, the completion is +/// the app's answer, streamed text is dropped (the answer carries it). +fn sink(context: Weak, once: Once) -> EventSink { + let denied: Arc>> = Arc::default(); + Arc::new(move |event| match event { + ContextEvent::Data(data) => { + if data["method"] != "approval/requested" { + return; + } + let params = &data["params"]; + let Some(id) = params["approval_id"].as_str().map(str::to_owned) else { return }; + let title = params["title"].as_str().unwrap_or("a tool").to_owned(); + denied.lock().unwrap_or_else(|e| e.into_inner()).push(title); + // Declined off this thread: the context may be delivering this + // event under its own lock. + if let Some(context) = context.upgrade() { + std::thread::spawn(move || { + let _ = context.call(ContextOp::Approval { id, approve: false }, Arc::new(|_| {})); + }); + } + } + ContextEvent::Complete(result) => { + let denied = denied.lock().unwrap_or_else(|e| e.into_inner()).clone(); + answer(&once, finish(result, &denied)); + } + }) +} + +impl HostService for ContainedOctos { + fn family(&self) -> &'static str { + "octos" + } + + fn call(&mut self, call: ServiceCall, reply: Replier, _host: &mut dyn ServiceHost) { + if call.from_sheet { + return reply.send(Err(NO_SHEET.into())); + } + if !self.enabled { + return reply.send(Err(TURNED_OFF.into())); + } + let op = match parse(&call.service, &call.args) { + Ok(op) => op, + Err(e) => return reply.send(Err(e)), + }; + let context = match self.context_for(&call.app_id) { + Ok(context) => context, + Err(e) => return reply.send(Err(e)), + }; + let once: Once = Arc::new(Mutex::new(Some(reply))); + if let Err(e) = context.call(op, sink(Arc::downgrade(&context), once.clone())) { + answer(&once, Err(e)); + } + } +} + +/// The shell's peers: host-owned app peers on the shell's kernel. +#[cfg(kernel)] +pub(crate) struct KernelPeers; + +#[cfg(kernel)] +impl PeerFactory for KernelPeers { + fn launch(&self, peer_id: &str, app_id: &str) -> Option> { + let services = octosense_app_peers::OCTOS_SERVICES; + crate::host_policy().allow(peer_id, services); + let broker = octosense_app_peers::hosted::launch(peer_id, app_id, services, crate::host_policy())?; + Some(Arc::new(broker)) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/ai-host/src/contained/tests.rs b/crates/ai-host/src/contained/tests.rs new file mode 100644 index 00000000..72fabed6 --- /dev/null +++ b/crates/ai-host/src/contained/tests.rs @@ -0,0 +1,340 @@ +//! The `octos` service through App Hub's real dispatch path, with fake peers: +//! no kernel, no network. Each test names the spec scenario it proves +//! (`specs/card-runner-octos.spec.md`). + +use super::*; +use octosense_app_peers::{Availability, Deployment, ModelInfo, SettingsEntry, OCTOS_SERVICES}; +use octosense_appstore::services::{dispatch, register_host_service, take_replies_for}; +use serde_json::json; +use std::collections::BTreeSet; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::time::{Duration, Instant}; + +/// One `octos` service is registered at a time (the registry is global). +static SERIAL: Mutex<()> = Mutex::new(()); +static NEXT_HEAP: AtomicUsize = AtomicUsize::new(52_000); + +const APP: &str = "com.example.trip"; + +struct NoSheets; +impl ServiceHost for NoSheets { + fn open_sheet(&mut self, _body: String) {} + fn close_sheet(&mut self) {} +} + +/// What a fake context does with a turn. +#[derive(Clone)] +enum Turn { + /// Complete with this value. + Reply(Value), + /// Raise an approval (`id`, `title`), then complete with this value. + ApproveThenReply(&'static str, &'static str, Value), +} + +struct FakeContext { + ops: Mutex>, + open: AtomicBool, + turn: Turn, + /// Close after the first call (a revoked or expired context). + close_after_call: bool, +} + +impl OctosContext for FakeContext { + fn call(&self, op: ContextOp, sink: EventSink) -> Result<(), String> { + self.ops.lock().unwrap().push(op.clone()); + if self.close_after_call { + self.open.store(false, Ordering::SeqCst); + } + match op { + ContextOp::Approval { .. } => {} + ContextOp::Turn { .. } => match &self.turn { + Turn::Reply(v) => sink(ContextEvent::Complete(Ok(v.clone()))), + Turn::ApproveThenReply(id, title, v) => { + sink(ContextEvent::Data(json!({ + "method": "approval/requested", + "params": {"approval_id": id, "title": title, "body": "ls"}, + }))); + sink(ContextEvent::Complete(Ok(v.clone()))); + } + }, + _ => sink(ContextEvent::Complete(Ok(json!({"open": true})))), + } + Ok(()) + } + fn close(&self) { + self.open.store(false, Ordering::SeqCst); + } + fn is_open(&self) -> bool { + self.open.load(Ordering::SeqCst) + } +} + +struct FakeService { + turn: Turn, + close_after_call: bool, + accounts: Mutex>>, + specs: Mutex>, + contexts: Mutex>>, +} + +impl OctosAppService for FakeService { + fn deployment(&self) -> Deployment { + Deployment::Hosted + } + fn availability(&self) -> Availability { + Availability::Ready + } + fn services(&self) -> BTreeSet { + OCTOS_SERVICES.iter().map(|s| s.to_string()).collect() + } + fn model(&self) -> Option { + None + } + fn settings_entry(&self) -> SettingsEntry { + SettingsEntry::Host + } + fn set_account(&self, account: Option<&str>) { + self.accounts.lock().unwrap().push(account.map(str::to_owned)); + } + fn open_context(&self, spec: ContextSpec) -> Result, String> { + self.specs.lock().unwrap().push(spec); + let context = Arc::new(FakeContext { + ops: Mutex::default(), + open: AtomicBool::new(true), + turn: self.turn.clone(), + close_after_call: self.close_after_call, + }); + self.contexts.lock().unwrap().push(context.clone()); + Ok(context) + } + fn release(&self) {} + fn shutdown(&self) {} +} + +/// Launches a [`FakeService`] per peer (or none), recording every launch. +struct Peers { + give: bool, + turn: Turn, + close_after_call: bool, + launched: Mutex)>>, +} + +impl Peers { + fn new(turn: Turn) -> Arc { + Arc::new(Peers { give: true, turn, close_after_call: false, launched: Mutex::default() }) + } + fn ids(&self) -> Vec { + self.launched.lock().unwrap().iter().map(|(id, _)| id.clone()).collect() + } + fn service(&self, peer: &str) -> Arc { + self.launched.lock().unwrap().iter().find(|(id, _)| id == peer).expect("launched").1.clone() + } + fn ops(&self, peer: &str) -> Vec { + self.service(peer).contexts.lock().unwrap().iter().flat_map(|c| c.ops.lock().unwrap().clone()).collect() + } +} + +impl PeerFactory for Peers { + fn launch(&self, peer_id: &str, _app_id: &str) -> Option> { + if !self.give { + return None; + } + let service = Arc::new(FakeService { + turn: self.turn.clone(), + close_after_call: self.close_after_call, + accounts: Mutex::default(), + specs: Mutex::default(), + contexts: Mutex::default(), + }); + self.launched.lock().unwrap().push((peer_id.to_owned(), service.clone())); + Some(service) + } +} + +fn register(enabled: bool, peers: &Arc) { + register_host_service(Box::new(ContainedOctos::new(enabled, peers.clone()))); +} + +/// One call from `app` through App Hub's dispatch; its reply. +fn ask(app: &str, service: &str, args: Value, from_sheet: bool) -> Result { + let heap = NEXT_HEAP.fetch_add(1, Ordering::SeqCst); + let call = ServiceCall { + app_id: app.to_owned(), + service: service.to_owned(), + args, + from_sheet, + host_dir: std::env::temp_dir().join("octosense-contained-tests"), + }; + dispatch(call, heap, 1, &mut NoSheets); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if let Some((_, _, result)) = take_replies_for(&[heap]).pop() { + return result.map(|s| serde_json::from_str(&s).expect("a JSON reply")); + } + assert!(Instant::now() < deadline, "no reply to {service}"); + std::thread::sleep(Duration::from_millis(5)); + } +} + +fn serial() -> std::sync::MutexGuard<'static, ()> { + SERIAL.lock().unwrap_or_else(|e| e.into_inner()) +} + +#[test] +fn contained_turn_reaches_app_peer_and_replies() { + let _g = serial(); + let peers = Peers::new(Turn::Reply(json!({"turn_id": "t1", "text": "你好"}))); + register(true, &peers); + let reply = ask(APP, "octos.turn.start", json!({"text": "hi"}), false).expect("a reply"); + assert_eq!(reply["text"], "你好"); + assert_eq!(peers.ids(), vec!["card.com.example.trip".to_string()]); + assert_eq!(peers.ops("card.com.example.trip"), vec![ContextOp::Turn { text: "hi".into() }]); + let service = peers.service("card.com.example.trip"); + assert_eq!(service.accounts.lock().unwrap().clone(), vec![Some(ACCOUNT.to_string())]); + assert_eq!(service.specs.lock().unwrap()[0].account, ACCOUNT); +} + +#[test] +fn contained_each_app_gets_its_own_peer() { + let _g = serial(); + let peers = Peers::new(Turn::Reply(json!({}))); + register(true, &peers); + for app in ["com.example.a", "com.example.a", "com.example.b"] { + ask(app, "octos.session.open", json!({}), false).expect("open"); + } + assert_eq!(peers.ids(), vec!["card.com.example.a".to_string(), "card.com.example.b".to_string()]); +} + +#[test] +fn contained_session_calls_map_to_context_ops() { + let _g = serial(); + let peers = Peers::new(Turn::Reply(json!({}))); + register(true, &peers); + for service in ["octos.session.open", "octos.session.history", "octos.turn.interrupt"] { + ask(APP, service, json!({}), false).expect("a reply"); + } + assert_eq!( + peers.ops("card.com.example.trip"), + vec![ContextOp::Open, ContextOp::History, ContextOp::Interrupt] + ); +} + +#[test] +fn contained_rejects_unsupported_arguments() { + let _g = serial(); + let peers = Peers::new(Turn::Reply(json!({}))); + register(true, &peers); + let err = ask(APP, "octos.turn.start", json!({"text": "hi", "session_id": "x"}), false).unwrap_err(); + assert_eq!(err, UNSUPPORTED_ARGS); + let err = ask(APP, "octos.session.open", json!({"profile": "_main"}), false).unwrap_err(); + assert_eq!(err, UNSUPPORTED_ARGS); + assert!(peers.ids().is_empty(), "no peer for a refused call"); +} + +#[test] +fn contained_rejects_empty_or_oversized_text() { + let _g = serial(); + let peers = Peers::new(Turn::Reply(json!({}))); + register(true, &peers); + for text in [" ".to_string(), "x".repeat(MAX_TEXT_BYTES + 1)] { + let err = ask(APP, "octos.turn.start", json!({"text": text}), false).unwrap_err(); + assert_eq!(err, BAD_TEXT); + } + assert!(peers.ids().is_empty()); +} + +#[test] +fn contained_rejects_unknown_method() { + let _g = serial(); + let peers = Peers::new(Turn::Reply(json!({}))); + register(true, &peers); + let err = ask(APP, "octos.admin", json!({}), false).unwrap_err(); + assert_eq!(err, "Unknown Octos service octos.admin"); +} + +#[test] +fn contained_refuses_sheet_calls() { + let _g = serial(); + let peers = Peers::new(Turn::Reply(json!({}))); + register(true, &peers); + let err = ask(APP, "octos.turn.start", json!({"text": "hi"}), true).unwrap_err(); + assert_eq!(err, NO_SHEET); + assert!(peers.ids().is_empty()); +} + +#[test] +fn contained_switch_off_refuses_without_peer() { + let _g = serial(); + let peers = Peers::new(Turn::Reply(json!({}))); + register(false, &peers); + let err = ask(APP, "octos.turn.start", json!({"text": "hi"}), false).unwrap_err(); + assert_eq!(err, TURNED_OFF); + assert!(peers.ids().is_empty()); +} + +#[test] +fn contained_peer_ids_are_namespaced_and_bounded() { + assert_eq!(peer_id("rinx").unwrap(), "card.rinx"); + assert_ne!(peer_id("rinx").unwrap(), "rinx"); + let long = "a".repeat(60); + let err = peer_id(&long).unwrap_err(); + assert!(err.contains("cannot name an assistant peer"), "{err}"); + assert!(peer_id(&"a".repeat(64 - PEER_PREFIX.len())).is_ok()); +} + +#[test] +fn contained_unavailable_when_no_peer() { + let _g = serial(); + let peers = Arc::new(Peers { give: false, turn: Turn::Reply(json!({})), close_after_call: false, launched: Mutex::default() }); + register(true, &peers); + let err = ask(APP, "octos.session.open", json!({}), false).unwrap_err(); + assert_eq!(err, UNAVAILABLE); +} + +#[test] +fn contained_reopens_closed_context() { + let _g = serial(); + let peers = Arc::new(Peers { give: true, turn: Turn::Reply(json!({})), close_after_call: true, launched: Mutex::default() }); + register(true, &peers); + ask(APP, "octos.session.open", json!({}), false).expect("first"); + ask(APP, "octos.session.open", json!({}), false).expect("second"); + let instances: Vec = + peers.service("card.com.example.trip").specs.lock().unwrap().iter().map(|s| s.instance.clone()).collect(); + assert_eq!(instances, vec!["card.com.example.trip-g1".to_string(), "card.com.example.trip-g2".to_string()]); +} + +#[test] +fn contained_denies_tool_approvals() { + let _g = serial(); + let peers = Peers::new(Turn::ApproveThenReply("a1", "Run shell", json!({"turn_id": "t1", "text": "done"}))); + register(true, &peers); + let reply = ask(APP, "octos.turn.start", json!({"text": "list files"}), false).expect("a reply"); + assert_eq!(reply["text"], "done"); + assert_eq!(reply["denied_approvals"], json!(["Run shell"])); + // The decline is sent off the delivering thread. + let deadline = Instant::now() + Duration::from_secs(5); + let declined = ContextOp::Approval { id: "a1".into(), approve: false }; + while !peers.ops("card.com.example.trip").contains(&declined) { + assert!(Instant::now() < deadline, "the approval was not declined"); + std::thread::sleep(Duration::from_millis(5)); + } + assert!(!peers + .ops("card.com.example.trip") + .iter() + .any(|op| matches!(op, ContextOp::Approval { approve: true, .. }))); +} + +#[test] +fn contained_rejects_oversized_reply() { + let _g = serial(); + let peers = Peers::new(Turn::Reply(json!({"text": "x".repeat(MAX_REPLY_BYTES)}))); + register(true, &peers); + let err = ask(APP, "octos.turn.start", json!({"text": "hi"}), false).unwrap_err(); + assert!(err.contains("MAX_REPLY_BYTES"), "{err}"); +} + +#[test] +fn policy_contained_apps_defaults() { + assert!(crate::Policy::shipped().contained_apps()); + assert!(!crate::Policy::none().contained_apps()); +} diff --git a/crates/ai-host/src/lib.rs b/crates/ai-host/src/lib.rs index 2de1fa9d..28716da4 100644 --- a/crates/ai-host/src/lib.rs +++ b/crates/ai-host/src/lib.rs @@ -41,8 +41,13 @@ //! declares or is granted nothing gets nothing, and no peer is allocated. //! Dropping the [`Assistant`] releases the instance's leases and //! interrupts its peer's running work; the kernel and other apps go on. +//! - **The `octos` host service** ([`contained`]): contained apps the Card +//! runner hosts that declare `octos.*` get their own peer (`card.`) +//! under the same contract, while [`Policy::contained_apps`] is on. Tool +//! approvals are declined until the Card runner has an approval sheet. mod bridge; +pub mod contained; mod qr; pub use bridge::{Bridge, Done}; @@ -114,6 +119,8 @@ impl KernelSource { #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Policy { grants: Vec<(String, Vec)>, + /// Whether contained apps (the Card runner's) get the `octos` service. + contained_apps: bool, } impl Policy { @@ -126,7 +133,19 @@ impl Policy { /// host serves these to reviewed mini apps). The person's AI provider /// choice lives in AI providers; a per-app toggle is future work. pub fn shipped() -> Self { - Policy::none().allow("rinx", octosense_app_peers::OCTOS_SERVICES) + Policy::none().allow("rinx", octosense_app_peers::OCTOS_SERVICES).with_contained_apps(true) + } + + /// Turn the `octos` service for contained apps on or off (the shell's + /// switch; an app still gets only the services its manifest declares). + pub fn with_contained_apps(mut self, on: bool) -> Self { + self.contained_apps = on; + self + } + + /// Whether contained apps get the `octos` service. + pub fn contained_apps(&self) -> bool { + self.contained_apps } /// Also grant `module` these services. @@ -187,6 +206,7 @@ pub fn start(host: Host) -> &'static Started { grant_policy(&host.policy, kernel); let core_dir = core_dir(host.data_dir.clone()); let llm = register_llm(core_dir.clone(), host.qr_import); + register_contained(kernel, &host.policy); State { qr_import: host.qr_import, kernel, started: Started { core_dir, kernel: kernel_status, llm } } }); &state.started @@ -260,6 +280,22 @@ pub fn core_dir(data_dir: Option) -> Option { octosense_llm_config::profile::default_core_dir() } +/// The `octos` service for contained apps, where this shell hosts a kernel. +/// Registered even with the switch off, so an app hears why it gets nothing. +fn register_contained(kernel: bool, policy: &Policy) { + #[cfg(kernel)] + if kernel { + let on = policy.contained_apps(); + octosense_appstore::services::register_host_service(Box::new(contained::ContainedOctos::new( + on, + std::sync::Arc::new(contained::KernelPeers), + ))); + log!("octos: contained apps' service registered ({})", if on { "on" } else { "off" }); + return; + } + let _ = (kernel, policy); +} + #[cfg(feature = "llm")] fn register_llm(core_dir: Option, import: QrImport) -> bool { let mut options = octosense_llm_service::Options::default(); diff --git a/docs/ai-services.md b/docs/ai-services.md index 80b73efe..d143e1cb 100644 --- a/docs/ai-services.md +++ b/docs/ai-services.md @@ -25,7 +25,7 @@ This page is about the assistant *inside* OctoSense. Building an app needs no AI | The shell's AI entry point (`start`, policy, per-instance offer, QR import) | Works today | [`crates/ai-host`](../crates/ai-host/README.md) | | Host-owned app peers: one octos peer per granted native app, owned by the system agent | Works today, for native modules (Rinx) | [`crates/app-peers`](../crates/app-peers/README.md), [Rinx ADR 0007](https://github.com/hagency-org/Rinx/blob/main/docs/adr/0007-host-owned-octos-app-peers.md) | | AppCard ("Ask anything") on the shell's kernel | Works today, opt-in (`--features app-appcard`), not shipped | [`apps/appcard`](../apps/appcard) | -| A contained script app (system or store) asking the assistant | **Not available.** No shell serves `octos.*` to the Card runner, and `llm` is provider management for `os.*` apps only | [below](#contained-script-apps-system-and-store) | +| A contained script app (system or store) asking the assistant | Available where the shell hosts a kernel: the `octos` host service gives each app its own peer (`card.`); tool approvals are declined until the Card runner has an approval sheet. `llm` stays provider management for `os.*` apps only | [below](#contained-script-apps-system-and-store) | | One-shot model calls for contained apps (`model`, `model.complete`) | Coming: the capability is merged in App Hub ([App-Hub#24](https://github.com/OctoSense-org/OctoSense-App-Hub/pull/24)); the OctoSense `model` service is not written yet | [below](#contained-script-apps-system-and-store) | | News data service (`news`, no model) | Merged ([#69](https://github.com/OctoSense-org/OctoSense/pull/69)); answers `os.*` apps only | [`apps/news/host-service`](../apps/news/host-service/README.md) | | `glance.publish`: L0 cards on the glance screen | Merged ([#72](https://github.com/OctoSense-org/OctoSense/pull/72)); contained apps cannot reach it yet. In progress: the `glance` capability ([#86](https://github.com/OctoSense-org/OctoSense/pull/86)) and `sys.digest` sources ([#87](https://github.com/OctoSense-org/OctoSense/pull/87)) | [`crates/shell/src/glance.rs`](../crates/shell/src/glance.rs) | @@ -42,7 +42,7 @@ flowchart TB llm["llm host service
writes the provider profile"] aihost["crates/ai-host
start, policy, offer"] broker["crates/app-peers broker
one peer per granted native app"] - runner["Card runner (App Hub)
host services: mail, news, glance, llm"] + runner["Card runner (App Hub)
host services: mail, news, glance, llm, octos"] rinx["Rinx (native module)"] appcard["AppCard (native, opt-in)"] scripts["Script apps (system and store)"] @@ -163,18 +163,22 @@ A script app runs in App Hub's Card runner and reaches the shell only through `h | `news` | `os.*` apps only | News's data service (feeds, ledger, `news.list`, `news.read`, …); no model. The shells' App Hub pin has no `news` capability yet, so the News bundle still fetches on its own | | `glance` | `os.*` apps only, and no manifest can request `glance` at the shells' App Hub pin | Publishing L0 cards to the glance screen. Today only the shell's `OCTOSENSE_GLANCE_DEMO=1` card reaches it; the capability is [#86](https://github.com/OctoSense-org/OctoSense/pull/86) | | `model` | – | **Not registered yet.** App Hub `main` admits the `model` capability ([App-Hub#24](https://github.com/OctoSense-org/OctoSense-App-Hub/pull/24)) for a one-shot `model.complete {task, input, schema, class}` (`class` `fast` or `strong`; the host picks the model from the person's providers, validates the reply against the schema, keeps a per-app daily budget; no tools or memory). The OctoSense service is still to come; a call answers `no service answers "model" on this device` (run in `card-host` at App Hub `e8601b8`), and the shells' App Hub pin does not know the name | -| `octos.*` | – | **Not registered.** App Hub's gate accepts the four `octos.*` names ([App-Hub#14](https://github.com/OctoSense-org/OctoSense-App-Hub/pull/14)), but in an OctoSense shell a call answers `no service answers "octos" on this device` | +| `octos` | any app whose manifest declares the exact `octos.*` names, while the shell hosts a kernel and `Policy::contained_apps` is on (the shipped policy) | The assistant, through the app's own host-owned peer `card.` (`crates/ai-host/src/contained.rs`): the four `octos.*` calls with Rinx's argument rules (`octos.turn.start` takes only `text`, non-blank, at most 32 KiB; the others take `{}`). Tool approvals the peer raises are declined, since the Card runner has no approval sheet yet, and listed in the reply's `denied_approvals`; replies over 2 MiB are refused | -So **a contained app has no path to the assistant in OctoSense today**, whether a store app or a system app. What it hears: +So a contained app, store or system, reaches the assistant only through `octos.*`, in a shell that hosts a kernel. What it hears: -| Call | Answer (`r.error`) | +| Call | Answer | | --- | --- | -| a family the manifest did not grant | `this app was not granted "", which "" needs` (at once, from the isolate) | -| `octos.*` with the capability granted | `no service answers "octos" on this device` | +| a family the manifest did not grant | `r.error`: `this app was not granted "", which "" needs` (at once, from the isolate) | +| `octos.turn.start {text}`, granted, kernel and provider configured | `r.data`: `{turn_id, text}`, the reply of the app's peer | +| `octos.*` with arguments beyond the rules | `r.error`: `Unsupported Octos arguments`, or `Provide text (at most 32 KiB)` | +| `octos.*` with `Policy::contained_apps` off | `r.error`: `The assistant is turned off for apps on this device` | +| `octos.*` on a desktop without `OCTOS_APP_CORE_BIN` | `r.error`: `no octos kernel: no kernel binary configured (OCTOS_APP_CORE_BIN)` | +| `octos.*` in a build that links no kernel (iOS) | `r.error`: `no service answers "octos" on this device` (**unverified**) | | `llm.*` from a store app granted `llm` | `llm is for OctoSense's own apps.` | | anything in App Hub's `card-host` | `no service answers "" on this device` (`card-host` registers no services) | -The first two rows and the last were run in `card-host` (App Hub `362d832`) on 2026-09-27; the shells use the same dispatch (`octosense_appstore::services::dispatch`) with the services above registered. OctoScript-App-Design-Flow's [AI-SERVICES](https://github.com/OctoSense-org/OctoScript-App-Design-Flow/blob/main/docs/AI-SERVICES.md) has the example app and the argument and answer shapes of the four `octos.*` calls as Rinx serves them. +The first row, the `llm` row and the `card-host` row were run in `card-host` (App Hub `362d832`) on 2026-09-27. The reply, the unsupported-arguments answer and the missing-kernel answer were run on macOS on 2026-09-28 in a release desktop with hidden windows, through a system app declaring `octos.session.open` and `octos.turn.start` opened from the launcher; with a kernel built at the then-pinned octos `7bec0918` and the person's provider, the reply came from the peer `card.`, whose memory namespace `app/card./…` appeared in the kernel's data. The text-length and switch-off answers are covered by `cargo test -p octosense-ai-host`, through the same dispatch. OctoScript-App-Design-Flow's [AI-SERVICES](https://github.com/OctoSense-org/OctoScript-App-Design-Flow/blob/main/docs/AI-SERVICES.md) has the example app and the argument and answer shapes of the four `octos.*` calls as Rinx serves them. The manifest's `agent` field (a permission profile, generic tools, iteration and token limits) is admitted and clamped by App Hub, but **nothing in the shells runs an agent for it**, and Rinx refuses to import a bundle that declares one. An app must not depend on it. @@ -224,7 +228,7 @@ The tracking issue is [#68](https://github.com/OctoSense-org/OctoSense/issues/68 The log says `octos: kernel service ready (starts on first use), core dir …`. Without `OCTOS_APP_CORE_BIN` it says there is no kernel, and AI providers still saves providers. 3. Open **Start → Settings → AI providers**, add a model (family, model, route, key, **Test connection**, save). The profile is `$T/octos-home/.octos/profiles/_main.json`; with the file vault the key is in it, so delete `$T` afterwards. -4. Use the assistant through a consumer: Rinx (linked by default; `-- --module rinx` opens it; sign in to Matrix, then use its assistant), or AppCard (`--features app-appcard -- --module appcard`). A contained app cannot reach it ([above](#contained-script-apps-system-and-store)). +4. Use the assistant through a consumer: Rinx (linked by default; `-- --module rinx` opens it; sign in to Matrix, then use its assistant), or AppCard (`--features app-appcard -- --module appcard`). A contained app reaches it through the `octos` host service ([above](#contained-script-apps-system-and-store)). **Hidden windows.** Add `MAKEPAD_HIDE_WINDOWS=1 MAKEPAD_REMOTE=` to drive the shell over the remote bridge without taking the screen ([desktop README § Remote-control bridge](../desktop/README.md#remote-control-bridge)). `desktop/scripts/ai_providers_remote.sh` runs AI providers end to end this way with fake keys and outbound HTTPS denied, and `desktop/scripts/glance_remote.sh` does the same for the glance panel. diff --git a/docs/ai-services.zh-CN.md b/docs/ai-services.zh-CN.md index b7f76f1b..5d385f54 100644 --- a/docs/ai-services.zh-CN.md +++ b/docs/ai-services.zh-CN.md @@ -25,7 +25,7 @@ | Shell 的 AI 入口(`start`、策略、按实例提供服务、QR 导入) | 目前可用 | [`crates/ai-host`](../crates/ai-host/README.md) | | 宿主拥有的应用 peer:每个获授权的原生应用一个 octos peer,归系统 Agent 所有 | 目前可用,仅限原生模块(Rinx) | [`crates/app-peers`](../crates/app-peers/README.md)、[Rinx ADR 0007](https://github.com/hagency-org/Rinx/blob/main/docs/adr/0007-host-owned-octos-app-peers.md) | | 在 Shell 内核上运行的 AppCard("Ask anything") | 目前可用,需主动开启(`--features app-appcard`),不随产品发布 | [`apps/appcard`](../apps/appcard) | -| 隔离运行的脚本应用(系统应用或商店应用)向助手提问 | **不可用。** 没有任何 Shell 向 Card runner 提供 `octos.*`,而 `llm` 是只供 `os.*` 应用使用的提供方管理服务 | [见下文](#隔离运行的脚本应用系统应用和商店应用) | +| 隔离运行的脚本应用(系统应用或商店应用)向助手提问 | 在托管了内核的 Shell 中可用:`octos` 宿主服务为每个应用分配自己的 peer(`card.<应用 id>`);在 Card runner 有审批面板之前,工具审批一律被拒绝。`llm` 仍是只供 `os.*` 应用使用的提供方管理服务 | [见下文](#隔离运行的脚本应用系统应用和商店应用) | | 供隔离应用使用的一次性模型调用(`model`,`model.complete`) | 即将到来:权限已合入 App Hub([App-Hub#24](https://github.com/OctoSense-org/OctoSense-App-Hub/pull/24));OctoSense 的 `model` 服务尚未实现 | [见下文](#隔离运行的脚本应用系统应用和商店应用) | | News 数据服务(`news`,不使用模型) | 已合入([#69](https://github.com/OctoSense-org/OctoSense/pull/69));只响应 `os.*` 应用 | [`apps/news/host-service`](../apps/news/host-service/README.md) | | `glance.publish`:glance 屏幕上的 L0 卡片 | 已合入([#72](https://github.com/OctoSense-org/OctoSense/pull/72));隔离运行的应用暂时无法调用。进行中:`glance` 权限([#86](https://github.com/OctoSense-org/OctoSense/pull/86))和 `sys.digest` 数据源([#87](https://github.com/OctoSense-org/OctoSense/pull/87)) | [`crates/shell/src/glance.rs`](../crates/shell/src/glance.rs) | @@ -42,7 +42,7 @@ flowchart TB llm["llm 宿主服务
写入提供方配置"] aihost["crates/ai-host
start、策略、offer"] broker["crates/app-peers broker
每个获授权的原生应用一个 peer"] - runner["Card runner(App Hub)
宿主服务:mail、news、glance、llm"] + runner["Card runner(App Hub)
宿主服务:mail、news、glance、llm、octos"] rinx["Rinx(原生模块)"] appcard["AppCard(原生,需主动开启)"] scripts["脚本应用(系统和商店)"] @@ -163,18 +163,22 @@ flowchart TB | `news` | 仅 `os.*` 应用 | News 的数据服务(订阅源、已读记录、`news.list`、`news.read` 等),不使用模型。Shell 锁定的 App Hub 还没有 `news` 权限,所以 News 应用包仍自己抓取 | | `glance` | 仅 `os.*` 应用,并且在 Shell 锁定的 App Hub 下没有 manifest 能申请 `glance` | 向 glance 屏幕发布 L0 卡片。目前只有 Shell 的 `OCTOSENSE_GLANCE_DEMO=1` 演示卡片会用到它;权限见 [#86](https://github.com/OctoSense-org/OctoSense/pull/86) | | `model` | – | **尚未注册。** App Hub `main` 接受 `model` 权限([App-Hub#24](https://github.com/OctoSense-org/OctoSense-App-Hub/pull/24)),用于一次性的 `model.complete {task, input, schema, class}`(`class` 为 `fast` 或 `strong`;宿主从用户的提供方中挑选模型,按 schema 校验回复,按应用管理每日预算;没有工具和记忆)。OctoSense 服务尚未实现;调用返回 `no service answers "model" on this device`(在 App Hub `e8601b8` 的 `card-host` 中运行),而且 Shell 锁定的 App Hub 不认识这个名称 | -| `octos.*` | – | **未注册。** App Hub 的准入检查接受四个 `octos.*` 名称([App-Hub#14](https://github.com/OctoSense-org/OctoSense-App-Hub/pull/14)),但在 OctoSense Shell 中调用会返回 `no service answers "octos" on this device` | +| `octos` | manifest 声明了确切 `octos.*` 名称的任何应用;前提是 Shell 托管了内核,且 `Policy::contained_apps` 为开(出厂策略即为开) | 助手:经应用自己的、由宿主拥有的 peer `card.<应用 id>`(`crates/ai-host/src/contained.rs`)。四个 `octos.*` 调用沿用 Rinx 的参数规则(`octos.turn.start` 只接受 `text`,非空白、最多 32 KiB;其余只接受 `{}`)。peer 发起的工具审批会被拒绝(Card runner 还没有审批面板),并列在回复的 `denied_approvals` 中;超过 2 MiB 的回复会被拒绝 | -因此,**隔离运行的应用目前在 OctoSense 中没有任何途径使用助手**,无论是商店应用还是系统应用。它会收到: +因此,隔离运行的应用(商店应用或系统应用)只能通过 `octos.*` 使用助手,且需要 Shell 托管了内核。它会收到: -| 调用 | 返回(`r.error`) | +| 调用 | 返回 | | --- | --- | -| manifest 未授予的 family | `this app was not granted "", which "" needs`(由隔离环境立即返回) | -| 已获授权的 `octos.*` | `no service answers "octos" on this device` | +| manifest 未授予的 family | `r.error`:`this app was not granted "", which "" needs`(由隔离环境立即返回) | +| 已获授权的 `octos.turn.start {text}`,内核与提供方已配置 | `r.data`:`{turn_id, text}`,即应用 peer 的回复 | +| 参数超出规则的 `octos.*` | `r.error`:`Unsupported Octos arguments` 或 `Provide text (at most 32 KiB)` | +| `Policy::contained_apps` 为关时的 `octos.*` | `r.error`:`The assistant is turned off for apps on this device` | +| 在未设置 `OCTOS_APP_CORE_BIN` 的桌面端调用 `octos.*` | `r.error`:`no octos kernel: no kernel binary configured (OCTOS_APP_CORE_BIN)` | +| 在不链接内核的构建(iOS)中调用 `octos.*` | `r.error`:`no service answers "octos" on this device`(**未验证**) | | 获得 `llm` 授权的商店应用调用 `llm.*` | `llm is for OctoSense's own apps.` | | 在 App Hub 的 `card-host` 中调用任何服务 | `no service answers "" on this device`(`card-host` 不注册任何服务) | -第一、二行和最后一行已于 2026-09-27 在 `card-host`(App Hub `362d832`)中运行验证;Shell 使用同一个分发函数(`octosense_appstore::services::dispatch`),注册的服务如上表。OctoScript-App-Design-Flow 的 [AI-SERVICES](https://github.com/OctoSense-org/OctoScript-App-Design-Flow/blob/main/docs/AI-SERVICES.zh-CN.md) 给出了示例应用,以及 Rinx 提供的四个 `octos.*` 调用的参数和返回形状。 +第一行、`llm` 一行和 `card-host` 一行已于 2026-09-27 在 `card-host`(App Hub `362d832`)中运行验证。回复、参数不合规的返回和缺少内核的返回已于 2026-09-28 在 macOS 上运行验证:release 版桌面端、隐藏窗口,经启动器打开一个声明了 `octos.session.open` 和 `octos.turn.start` 的系统应用;使用按当时锁定的 octos `7bec0918` 构建的内核和用户自己的提供方时,回复来自 peer `card.<应用 id>`,内核数据中出现了它的记忆命名空间 `app/card.<应用 id>/…`。文本长度和开关关闭的返回由 `cargo test -p octosense-ai-host` 覆盖,走的是同一个分发函数。OctoScript-App-Design-Flow 的 [AI-SERVICES](https://github.com/OctoSense-org/OctoScript-App-Design-Flow/blob/main/docs/AI-SERVICES.zh-CN.md) 给出了示例应用,以及 Rinx 提供的四个 `octos.*` 调用的参数和返回形状。 manifest 的 `agent` 字段(权限档位、通用工具、迭代和 token 上限)会被 App Hub 接受并按上限裁剪,但 **Shell 中没有任何东西为它运行 Agent**,Rinx 也会拒绝导入声明了它的应用包。应用不能依赖它。 @@ -224,7 +228,7 @@ manifest 的 `agent` 字段(权限档位、通用工具、迭代和 token 上 日志中会出现 `octos: kernel service ready (starts on first use), core dir …`。没有 `OCTOS_APP_CORE_BIN` 时日志会说明没有内核,AI providers 仍会保存提供方。 3. 打开 **Start → Settings → AI providers**,添加一个模型(family、模型、路由、密钥、**Test connection**、保存)。配置文件是 `$T/octos-home/.octos/profiles/_main.json`;使用文件密钥库时密钥就在其中,用完后请删除 `$T`。 -4. 通过某个使用者来使用助手:Rinx(默认链接;`-- --module rinx` 打开它;登录 Matrix 后使用它的助手),或 AppCard(`--features app-appcard -- --module appcard`)。隔离运行的应用无法访问它([见上文](#隔离运行的脚本应用系统应用和商店应用))。 +4. 通过某个使用者来使用助手:Rinx(默认链接;`-- --module rinx` 打开它;登录 Matrix 后使用它的助手),或 AppCard(`--features app-appcard -- --module appcard`)。隔离运行的应用通过 `octos` 宿主服务访问它([见上文](#隔离运行的脚本应用系统应用和商店应用))。 **隐藏窗口。** 加上 `MAKEPAD_HIDE_WINDOWS=1 MAKEPAD_REMOTE=`,即可通过远程控制桥操作 Shell 而不占用屏幕([桌面端 README § Remote-control bridge](../desktop/README.md#remote-control-bridge))。`desktop/scripts/ai_providers_remote.sh` 以这种方式端到端运行 AI providers,使用假密钥并禁止出站 HTTPS;`desktop/scripts/glance_remote.sh` 对 glance 面板做同样的事。 diff --git a/specs/card-runner-octos.spec.md b/specs/card-runner-octos.spec.md new file mode 100644 index 00000000..44c4442a --- /dev/null +++ b/specs/card-runner-octos.spec.md @@ -0,0 +1,164 @@ +spec: task +name: "The octos host service for the Card runner" +tags: [ai-host, card-runner, octos, contained-apps] +estimate: 1d +--- + +## Intent + +Let OctoScript apps that App Hub installs and the Card runner hosts use the shell's assistant: a new `octos` host service hands an app's `host.request("octos.*")` to a peer the system octos allocates for that app alone. The system agent (`_main:api:octosense#system`) already owns such a peer for Rinx; this task wires the same mechanism to the Card runner, with no change to App Hub or to app bundles. Afterwards a store app that declares `octos.*` gets a reply on a shell that hosts a kernel, instead of `no service answers "octos"`. + +## Decisions + +- The service implements App Hub's `HostService` with family `octos`, and `start()` in `octosense-ai-host` registers it when the shell hosts a kernel, next to `register_llm`, with `Policy.contained_apps` as its on/off state; `crates/shell` is unchanged. +- The peer id is `card.` followed by the app id (for example `card.com.example.trip`), checked with the `is_namespace_segment` character rule and at most 64 characters, so a store app can never share a native module's (for example `rinx`) peer or memory. +- One `OctosAppService` per app (created on its first call, then reused) and one request context per app; the account is always `device`, the instance key is `card.-g`, and a closed context is reopened with the next generation on the next call. +- The argument rules are Rinx's: `octos.turn.start` takes only `text`, non-blank after trimming and at most 32 KiB; `octos.session.open`, `octos.session.history` and `octos.turn.interrupt` take only the empty object `{}`. +- Tool approvals: an `approval/requested` event is answered with `ContextOp::Approval { approve: false }`; a successful reply object gains `denied_approvals`, the titles of the declined tools. +- Replies are capped at 2 MiB (`MAX_REPLY_BYTES`); a larger one is an error, never truncated. +- `Policy` gains `contained_apps`: true in `Policy::shipped()`, false in `Policy::none()`; it is the shell's switch. +- The logic does not depend on `cfg(kernel)`: peers come from a `PeerFactory`. Only the shell's factory, under `cfg(kernel)`, creates brokers through `hosted::launch`. Tests use a fake factory over App Hub's real `dispatch` and `take_replies_for`. +- `octosense-appstore` becomes a regular dependency of `octosense-ai-host` (it is already in the workspace graph). + + +## Boundaries + +### Allowed Changes +- crates/ai-host/** +- Cargo.lock +- specs/** +- docs/ai-services.md +- docs/ai-services.zh-CN.md +- README.md +- README.zh-CN.md + +### Forbidden +- Do not change App Hub, anything under `.sources/`, or the dependency pins OctoSense locks +- Do not change the behaviour of Rinx's existing grant `allow("rinx", …)` +- Do not let an app pass or read session, profile, workspace or approval decisions +- Do not add a crate that is not already in the workspace graph + +## Out of Scope + +- An approval sheet for tool approvals (needs an App Hub API to open a sheet from a worker thread; a second phase) +- Limiting the tools a peer may use (needs octos `peer/prepare` support; a second phase) +- Closing a context when its app instance closes (`ServiceCall` carries no instance identity; a second phase) +- A Settings switch for `contained_apps` +- `model.complete` + +## Completion Criteria + +### Rule: routes-app-requests — hand each app's requests to its own peer +Scenario: A turn reaches the app's own peer and returns its reply + Test: + Package: octosense-ai-host + Filter: contained_turn_reaches_app_peer_and_replies + Given the switch is on and the fake factory gives app "com.example.trip" a context that replies "你好" + When the app calls "octos.turn.start" through App Hub's `dispatch` with {"text": "hi"} + Then the app receives a successful reply whose "text" is "你好" + And the fake factory received the peer id "card.com.example.trip" + And the context received a Turn operation with the text "hi" + +Scenario: One app reuses its peer and each app has its own + Test: + Package: octosense-ai-host + Filter: contained_each_app_gets_its_own_peer + Given the switch is on + When app "com.example.a" calls "octos.session.open" twice and app "com.example.b" calls it once + Then the fake factory created exactly two peers, "card.com.example.a" and "card.com.example.b" + +Scenario: Session calls map to their context operations + Test: + Package: octosense-ai-host + Filter: contained_session_calls_map_to_context_ops + Given the switch is on + When the app calls "octos.session.open", "octos.session.history" and "octos.turn.interrupt", each with {} + Then the context receives Open, History and Interrupt in that order + +### Rule: refuses-bad-requests — refuse requests outside the rules +Scenario: Extra arguments are refused + Test: + Package: octosense-ai-host + Filter: contained_rejects_unsupported_arguments + When the app calls "octos.turn.start" with {"text": "hi", "session_id": "x"} + Then the app receives the error "Unsupported Octos arguments" + And the fake factory created no peer + +Scenario: Blank text and text over 32 KiB are refused + Test: + Package: octosense-ai-host + Filter: contained_rejects_empty_or_oversized_text + When the app calls "octos.turn.start" with the text " " and with 32769 bytes + Then both calls receive the error "Provide text (at most 32 KiB)" + +Scenario: An unknown octos method is refused + Test: + Package: octosense-ai-host + Filter: contained_rejects_unknown_method + When the app calls "octos.admin" + Then the app receives the error "Unknown Octos service octos.admin" + +Scenario: A call marked as coming from a sheet is refused + Test: + Package: octosense-ai-host + Filter: contained_refuses_sheet_calls + When an "octos.turn.start" call marked from_sheet arrives + Then the app receives an error saying octos has no sheet + +### Rule: stays-contained — containment and degraded states +Scenario: With the switch off the call is refused and no peer is made + Test: + Package: octosense-ai-host + Filter: contained_switch_off_refuses_without_peer + Given the switch is off + When the app calls "octos.turn.start" with {"text": "hi"} + Then the app receives the error "The assistant is turned off for apps on this device" + And the fake factory created no peer + +Scenario: Peer ids are namespaced and bounded + Test: + Package: octosense-ai-host + Filter: contained_peer_ids_are_namespaced_and_bounded + When the peer id is computed for app "rinx" and for a 60-character app id + Then the first is "card.rinx", which differs from the native module "rinx" + And the second is an error saying the app id is too long + +Scenario: A device that cannot give a peer answers unavailable + Test: + Package: octosense-ai-host + Filter: contained_unavailable_when_no_peer + Given the fake factory gives no peer to any app + When the app calls "octos.session.open" + Then the app receives the error "The assistant is not available on this device" + +Scenario: A closed context is reopened with the next generation + Test: + Package: octosense-ai-host + Filter: contained_reopens_closed_context + Given the app's first context closes after one call + When the app calls "octos.session.open" again + Then the fake service received a second open_context with the instance key "card.com.example.trip-g2" + +Scenario: Tool approvals are declined and reported to the app + Test: + Package: octosense-ai-host + Filter: contained_denies_tool_approvals + Given the context raises approval/requested (id "a1", title "Run shell") during the turn and then completes with {"text": "done"} + When the app calls "octos.turn.start" + Then the context receives Approval with id "a1" and approve false + And the app's reply has "denied_approvals" equal to ["Run shell"] + +Scenario: A reply over 2 MiB is refused + Test: + Package: octosense-ai-host + Filter: contained_rejects_oversized_reply + Given the context completes the turn with more than 2 MiB of text + When the app calls "octos.turn.start" + Then the app receives an error saying the reply is over `MAX_REPLY_BYTES` (2 MiB) + +Scenario: The switch's defaults + Test: + Package: octosense-ai-host + Filter: policy_contained_apps_defaults + When contained_apps is read from `Policy::shipped()` and `Policy::none()` + Then the first is true and the second is false From f1cb8a2a8355825d383b4cc61125e05d8b8024fa Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:44:31 -0700 Subject: [PATCH 2/2] ai-host: contained apps' assistant off in the shipped policy until first-use consent ADR 0004 section 4: an app's agent needs the person's consent the first time the app asks. Until that exists, Policy::shipped() leaves the octos service for contained apps off; OCTOSENSE_CONTAINED_APPS=1 turns it on for development and tests. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ai-host/src/contained/tests.rs | 6 +++++- crates/ai-host/src/lib.rs | 7 ++++++- docs/ai-services.md | 2 +- docs/ai-services.zh-CN.md | 2 +- specs/card-runner-octos.spec.md | 4 ++-- 5 files changed, 15 insertions(+), 6 deletions(-) diff --git a/crates/ai-host/src/contained/tests.rs b/crates/ai-host/src/contained/tests.rs index 72fabed6..ddfe80f3 100644 --- a/crates/ai-host/src/contained/tests.rs +++ b/crates/ai-host/src/contained/tests.rs @@ -335,6 +335,10 @@ fn contained_rejects_oversized_reply() { #[test] fn policy_contained_apps_defaults() { - assert!(crate::Policy::shipped().contained_apps()); + // Off until first-use consent exists (ADR 0004 section 4), unless a + // developer turns it on. + if std::env::var("OCTOSENSE_CONTAINED_APPS").as_deref() != Ok("1") { + assert!(!crate::Policy::shipped().contained_apps()); + } assert!(!crate::Policy::none().contained_apps()); } diff --git a/crates/ai-host/src/lib.rs b/crates/ai-host/src/lib.rs index 4bfd3dac..716129d7 100644 --- a/crates/ai-host/src/lib.rs +++ b/crates/ai-host/src/lib.rs @@ -132,8 +132,13 @@ impl Policy { /// The native modules that ship with OctoSense: Rinx (its native mini-app /// host serves these to reviewed mini apps). The person's AI provider /// choice lives in AI providers; a per-app toggle is future work. + /// + /// The `octos` service for contained apps is OFF in the shipped policy + /// until the person consents at first use (ADR 0004 section 4). + /// `OCTOSENSE_CONTAINED_APPS=1` turns it on for development and tests. pub fn shipped() -> Self { - Policy::none().allow("rinx", octosense_app_peers::OCTOS_SERVICES).with_contained_apps(true) + let contained = std::env::var("OCTOSENSE_CONTAINED_APPS").is_ok_and(|v| v == "1"); + Policy::none().allow("rinx", octosense_app_peers::OCTOS_SERVICES).with_contained_apps(contained) } /// Turn the `octos` service for contained apps on or off (the shell's diff --git a/docs/ai-services.md b/docs/ai-services.md index d143e1cb..9798d1cf 100644 --- a/docs/ai-services.md +++ b/docs/ai-services.md @@ -163,7 +163,7 @@ A script app runs in App Hub's Card runner and reaches the shell only through `h | `news` | `os.*` apps only | News's data service (feeds, ledger, `news.list`, `news.read`, …); no model. The shells' App Hub pin has no `news` capability yet, so the News bundle still fetches on its own | | `glance` | `os.*` apps only, and no manifest can request `glance` at the shells' App Hub pin | Publishing L0 cards to the glance screen. Today only the shell's `OCTOSENSE_GLANCE_DEMO=1` card reaches it; the capability is [#86](https://github.com/OctoSense-org/OctoSense/pull/86) | | `model` | – | **Not registered yet.** App Hub `main` admits the `model` capability ([App-Hub#24](https://github.com/OctoSense-org/OctoSense-App-Hub/pull/24)) for a one-shot `model.complete {task, input, schema, class}` (`class` `fast` or `strong`; the host picks the model from the person's providers, validates the reply against the schema, keeps a per-app daily budget; no tools or memory). The OctoSense service is still to come; a call answers `no service answers "model" on this device` (run in `card-host` at App Hub `e8601b8`), and the shells' App Hub pin does not know the name | -| `octos` | any app whose manifest declares the exact `octos.*` names, while the shell hosts a kernel and `Policy::contained_apps` is on (the shipped policy) | The assistant, through the app's own host-owned peer `card.` (`crates/ai-host/src/contained.rs`): the four `octos.*` calls with Rinx's argument rules (`octos.turn.start` takes only `text`, non-blank, at most 32 KiB; the others take `{}`). Tool approvals the peer raises are declined, since the Card runner has no approval sheet yet, and listed in the reply's `denied_approvals`; replies over 2 MiB are refused | +| `octos` | any app whose manifest declares the exact `octos.*` names, while the shell hosts a kernel and `Policy::contained_apps` is on (off in the shipped policy until first-use consent lands, ADR 0004 section 4; set `OCTOSENSE_CONTAINED_APPS=1` to try it) | The assistant, through the app's own host-owned peer `card.` (`crates/ai-host/src/contained.rs`): the four `octos.*` calls with Rinx's argument rules (`octos.turn.start` takes only `text`, non-blank, at most 32 KiB; the others take `{}`). Tool approvals the peer raises are declined, since the Card runner has no approval sheet yet, and listed in the reply's `denied_approvals`; replies over 2 MiB are refused | So a contained app, store or system, reaches the assistant only through `octos.*`, in a shell that hosts a kernel. What it hears: diff --git a/docs/ai-services.zh-CN.md b/docs/ai-services.zh-CN.md index 5d385f54..481f82fd 100644 --- a/docs/ai-services.zh-CN.md +++ b/docs/ai-services.zh-CN.md @@ -163,7 +163,7 @@ flowchart TB | `news` | 仅 `os.*` 应用 | News 的数据服务(订阅源、已读记录、`news.list`、`news.read` 等),不使用模型。Shell 锁定的 App Hub 还没有 `news` 权限,所以 News 应用包仍自己抓取 | | `glance` | 仅 `os.*` 应用,并且在 Shell 锁定的 App Hub 下没有 manifest 能申请 `glance` | 向 glance 屏幕发布 L0 卡片。目前只有 Shell 的 `OCTOSENSE_GLANCE_DEMO=1` 演示卡片会用到它;权限见 [#86](https://github.com/OctoSense-org/OctoSense/pull/86) | | `model` | – | **尚未注册。** App Hub `main` 接受 `model` 权限([App-Hub#24](https://github.com/OctoSense-org/OctoSense-App-Hub/pull/24)),用于一次性的 `model.complete {task, input, schema, class}`(`class` 为 `fast` 或 `strong`;宿主从用户的提供方中挑选模型,按 schema 校验回复,按应用管理每日预算;没有工具和记忆)。OctoSense 服务尚未实现;调用返回 `no service answers "model" on this device`(在 App Hub `e8601b8` 的 `card-host` 中运行),而且 Shell 锁定的 App Hub 不认识这个名称 | -| `octos` | manifest 声明了确切 `octos.*` 名称的任何应用;前提是 Shell 托管了内核,且 `Policy::contained_apps` 为开(出厂策略即为开) | 助手:经应用自己的、由宿主拥有的 peer `card.<应用 id>`(`crates/ai-host/src/contained.rs`)。四个 `octos.*` 调用沿用 Rinx 的参数规则(`octos.turn.start` 只接受 `text`,非空白、最多 32 KiB;其余只接受 `{}`)。peer 发起的工具审批会被拒绝(Card runner 还没有审批面板),并列在回复的 `denied_approvals` 中;超过 2 MiB 的回复会被拒绝 | +| `octos` | manifest 声明了确切 `octos.*` 名称的任何应用;前提是 Shell 托管了内核,且 `Policy::contained_apps` 为开(在首次使用同意功能落地前,出厂策略为关,见 ADR 0004 第 4 节;设置 `OCTOSENSE_CONTAINED_APPS=1` 可试用) | 助手:经应用自己的、由宿主拥有的 peer `card.<应用 id>`(`crates/ai-host/src/contained.rs`)。四个 `octos.*` 调用沿用 Rinx 的参数规则(`octos.turn.start` 只接受 `text`,非空白、最多 32 KiB;其余只接受 `{}`)。peer 发起的工具审批会被拒绝(Card runner 还没有审批面板),并列在回复的 `denied_approvals` 中;超过 2 MiB 的回复会被拒绝 | 因此,隔离运行的应用(商店应用或系统应用)只能通过 `octos.*` 使用助手,且需要 Shell 托管了内核。它会收到: diff --git a/specs/card-runner-octos.spec.md b/specs/card-runner-octos.spec.md index 44c4442a..65b76202 100644 --- a/specs/card-runner-octos.spec.md +++ b/specs/card-runner-octos.spec.md @@ -16,7 +16,7 @@ Let OctoScript apps that App Hub installs and the Card runner hosts use the shel - The argument rules are Rinx's: `octos.turn.start` takes only `text`, non-blank after trimming and at most 32 KiB; `octos.session.open`, `octos.session.history` and `octos.turn.interrupt` take only the empty object `{}`. - Tool approvals: an `approval/requested` event is answered with `ContextOp::Approval { approve: false }`; a successful reply object gains `denied_approvals`, the titles of the declined tools. - Replies are capped at 2 MiB (`MAX_REPLY_BYTES`); a larger one is an error, never truncated. -- `Policy` gains `contained_apps`: true in `Policy::shipped()`, false in `Policy::none()`; it is the shell's switch. +- `Policy` gains `contained_apps`: false in `Policy::shipped()` until first-use consent exists (ADR 0004 section 4; `OCTOSENSE_CONTAINED_APPS=1` turns it on for development), false in `Policy::none()`; it is the shell's switch. - The logic does not depend on `cfg(kernel)`: peers come from a `PeerFactory`. Only the shell's factory, under `cfg(kernel)`, creates brokers through `hosted::launch`. Tests use a fake factory over App Hub's real `dispatch` and `take_replies_for`. - `octosense-appstore` becomes a regular dependency of `octosense-ai-host` (it is already in the workspace graph). @@ -161,4 +161,4 @@ Scenario: The switch's defaults Package: octosense-ai-host Filter: policy_contained_apps_defaults When contained_apps is read from `Policy::shipped()` and `Policy::none()` - Then the first is true and the second is false + Then both are false unless `OCTOSENSE_CONTAINED_APPS=1` is set