From f17f5324ddf291a133838083dc30569a4dcc9142 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:48:34 -0700 Subject: [PATCH] approvals: a contacts source behind consent; refresh ai-services docs [skip ci] - approvals/contacts.rs: ContactsSource (is_known for email addresses and Matrix IDs), ContactList/NoContacts test doubles, MailContacts reading Mail's host service data, and ContactsGate: off by default, on only by the person's Settings tap ("Use my contacts in approval rules"), kept owner-only in approvals/contacts.json. The router evaluates "recipients in contacts" through the gate. - mail host service: contacts::known_addresses (own accounts, recorded send recipients, To/Cc of sent folders; never the secret store); the decoder keeps To/Cc; a removed account forgets its recipients. - docs/ai-services(.zh-CN).md refreshed against main (#106, #113, #117, #118, #120, #124, #95, #86), linking architecture.md; architecture's contacts lines updated. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/mail/host-service/src/contacts.rs | 139 ++++++++++++++++ apps/mail/host-service/src/lib.rs | 38 ++++- apps/mail/host-service/src/network.rs | 2 +- crates/shell/src/approvals/contacts.rs | 168 ++++++++++++++++++++ crates/shell/src/approvals/mod.rs | 17 +- crates/shell/src/approvals/router.rs | 26 ++- crates/shell/src/approvals/rules.rs | 33 +--- crates/shell/src/approvals/settings_page.rs | 29 +++- crates/shell/src/approvals/sheet.rs | 9 +- crates/shell/src/approvals/tests.rs | 61 ++++++- docs/ai-services.md | 66 +++++--- docs/ai-services.zh-CN.md | 66 +++++--- docs/architecture.md | 6 +- docs/architecture.zh-CN.md | 6 +- 14 files changed, 547 insertions(+), 119 deletions(-) create mode 100644 apps/mail/host-service/src/contacts.rs create mode 100644 crates/shell/src/approvals/contacts.rs diff --git a/apps/mail/host-service/src/contacts.rs b/apps/mail/host-service/src/contacts.rs new file mode 100644 index 00000000..0f662c3e --- /dev/null +++ b/apps/mail/host-service/src/contacts.rs @@ -0,0 +1,139 @@ +//! Who the person writes to, for the shell's approval rules ("recipients in +//! my contacts", ADR 0004 §8). Read-only, host-side, and only when the +//! person turned on "Use my contacts in approval rules": no app calls this. +//! +//! The known set is the person's own account addresses plus every address +//! they have sent mail to: the recipients this service sent for them +//! ([`SENT_TO_FILE`], per account) and the `To`/`Cc` of the messages in +//! each account's sent folder. It reads `accounts.json`, `folders-.json`, +//! the sent folders' `box--….json` and [`SENT_TO_FILE`]; never the +//! secret store. + +use crate::{mailbox_file, text, write_atomic, INBOX}; +use serde_json::{json, Value}; +use std::collections::BTreeSet; +use std::path::Path; + +/// Relative to `/mail`: `{"": ["ana@example.org", …]}`. +pub const SENT_TO_FILE: &str = "sent-to.json"; + +/// The addresses of the person's accounts and of everyone they sent mail +/// to, lowercased and sorted. `host_dir` is the one the Card runner hands +/// every host service (the mail lives in `/mail`). +pub fn known_addresses(host_dir: &Path) -> Vec { + let dir = host_dir.join("mail"); + let mut known = BTreeSet::new(); + let accounts: Vec = read(&dir.join("accounts.json")).and_then(|v| v.as_array().cloned()).unwrap_or_default(); + for account in &accounts { + add(&mut known, text(account, "address")); + let id = text(account, "id"); + if id.is_empty() { + continue; + } + let folders = read(&dir.join(format!("folders-{id}.json"))).and_then(|v| v.as_array().cloned()).unwrap_or_default(); + for folder in folders.iter().filter(|f| text(f, "role") == "sent") { + let name = text(folder, "id"); + if name.is_empty() || name == INBOX { + continue; + } + let path = mailbox_file(&dir, id, name); + let messages = read(&path).and_then(|m| m["messages"].as_array().cloned()).unwrap_or_default(); + for message in &messages { + for key in ["to", "cc"] { + add_header(&mut known, text(message, key)); + } + } + } + } + if let Some(Value::Object(sent)) = read(&dir.join(SENT_TO_FILE)) { + for addresses in sent.values() { + for a in addresses.as_array().into_iter().flatten() { + add(&mut known, a.as_str().unwrap_or("")); + } + } + } + known.into_iter().collect() +} + +/// After a send went out: remember its recipient under the account. +pub(crate) fn record_sent(mail_dir: &Path, account_id: &str, to: &str) { + let path = mail_dir.join(SENT_TO_FILE); + let mut sent = read(&path).filter(Value::is_object).unwrap_or_else(|| json!({})); + let mut recipients = BTreeSet::new(); + add_header(&mut recipients, to); + if recipients.is_empty() { + return; + } + let list = sent[account_id].as_array().cloned().unwrap_or_default(); + let mut all: BTreeSet = list.iter().filter_map(|v| v.as_str().map(str::to_string)).collect(); + let before = all.len(); + all.extend(recipients); + if all.len() != before { + sent[account_id] = json!(all.into_iter().collect::>()); + let _ = write_atomic(&path, &serde_json::to_vec_pretty(&sent).unwrap()); + } +} + +/// An account is forgotten: so are the people it sent to. +pub(crate) fn forget(mail_dir: &Path, account_id: &str) { + let path = mail_dir.join(SENT_TO_FILE); + if let Some(Value::Object(mut sent)) = read(&path) { + if sent.remove(account_id).is_some() { + let _ = write_atomic(&path, &serde_json::to_vec_pretty(&Value::Object(sent)).unwrap()); + } + } +} + +fn read(path: &Path) -> Option { + std::fs::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()) +} + +fn add(known: &mut BTreeSet, address: &str) { + let a = address.trim().to_lowercase(); + if a.contains('@') && !a.contains(char::is_whitespace) { + known.insert(a); + } +} + +/// "Ana , bo@example.org" → each address. +fn add_header(known: &mut BTreeSet, header: &str) { + if header.trim().is_empty() { + return; + } + match mailparse::addrparse(header) { + Ok(list) => { + for addr in list.iter() { + match addr { + mailparse::MailAddr::Single(s) => add(known, &s.addr), + mailparse::MailAddr::Group(g) => g.addrs.iter().for_each(|s| add(known, &s.addr)), + } + } + } + Err(_) => header.split([',', ';']).for_each(|a| add(known, a)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_sent_folder_names_whom_the_person_wrote_to() { + let host = std::env::temp_dir().join(format!("mail-contacts-{}", std::process::id())); + let dir = host.join("mail"); + let _ = std::fs::remove_dir_all(&host); + std::fs::create_dir_all(&dir).unwrap(); + let w = |p: std::path::PathBuf, v: Value| std::fs::write(p, v.to_string()).unwrap(); + w(dir.join("accounts.json"), json!([{"id": "a1", "address": "Me@Example.com", "apps": ["os.mail"]}])); + w(dir.join("folders-a1.json"), json!([{"id": INBOX, "role": "inbox"}, {"id": "Sent Items", "role": "sent"}, {"id": "Junk", "role": "junk"}])); + w(mailbox_file(&dir, "a1", "Sent Items"), json!({"messages": [{"to": "Ana , bo@example.org", "cc": "chen@example.org"}]})); + // Senders in the inbox and junk are not contacts. + w(mailbox_file(&dir, "a1", INBOX), json!({"messages": [{"address": "stranger@example.net", "to": "me@example.com"}]})); + w(mailbox_file(&dir, "a1", "Junk"), json!({"messages": [{"to": "spam@example.net"}]})); + record_sent(&dir, "a1", "dee@example.org"); + assert_eq!(known_addresses(&host), ["ana@example.org", "bo@example.org", "chen@example.org", "dee@example.org", "me@example.com"]); + forget(&dir, "a1"); + assert!(!known_addresses(&host).contains(&"dee@example.org".to_string())); + let _ = std::fs::remove_dir_all(&host); + } +} diff --git a/apps/mail/host-service/src/lib.rs b/apps/mail/host-service/src/lib.rs index cc317e6e..8e02b152 100644 --- a/apps/mail/host-service/src/lib.rs +++ b/apps/mail/host-service/src/lib.rs @@ -30,12 +30,18 @@ //! State lives under the host's own directory (`/mail`), outside //! every app's jail: `accounts.json` (no passwords) and `box-…json` (the //! fetched mail). Passwords go to the platform's secret store ([`vault`]). +//! +//! The shell reads one more thing, host-side and only with the person's +//! consent: [`contacts::known_addresses`], their own addresses and the +//! people they sent mail to, for the approval rules' "recipients in my +//! contacts". use octosense_appstore::services::{close_sheet_later, HostService, Replier, ServiceCall, ServiceHost}; use serde_json::{json, Value}; use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; +pub mod contacts; pub mod vault; mod html; mod imap; @@ -175,7 +181,7 @@ impl Transport for DemoTransport { .map(|(uid, sender, address, subject, body, html)| { json!({"id": &network::hash(uid)[..24], "uid": uid, "sender": sender, "address": address, "subject": subject, "body": if body.is_empty() { "(No readable message body)" } else { body }, "preview": body, "html": html, - "time": "Sep 25", "date": "2026-09-25T09:00:00Z", "unread": folder == INBOX}) + "to": if folder == INBOX { "me@example.com" } else { "noah@example.com" }, "time": "Sep 25", "date": "2026-09-25T09:00:00Z", "unread": folder == INBOX}) }) .collect(); seen.extend(messages.iter().map(|m| m["uid"].clone())); @@ -258,13 +264,8 @@ impl Store { Ok(account) } - /// The inbox keeps the name older builds gave it. fn mailbox_path(&self, id: &str, folder: &str) -> PathBuf { - if folder == INBOX { - self.dir.join(format!("box-{id}.json")) - } else { - self.dir.join(format!("box-{id}-{}.json", &network::hash(folder)[..12])) - } + mailbox_file(&self.dir, id, folder) } fn mailbox(&self, id: &str, folder: &str) -> Value { @@ -289,6 +290,7 @@ impl Store { fn forget(&self, id: &str) { self.vault.remove(&self.dir, id); + contacts::forget(&self.dir, id); if let Ok(entries) = std::fs::read_dir(&self.dir) { for entry in entries.flatten() { let name = entry.file_name().to_string_lossy().to_string(); @@ -300,6 +302,16 @@ impl Store { } } +/// Where one folder's fetched mail is kept; the inbox keeps the name older +/// builds gave it. +fn mailbox_file(dir: &Path, id: &str, folder: &str) -> PathBuf { + if folder == INBOX { + dir.join(format!("box-{id}.json")) + } else { + dir.join(format!("box-{id}-{}.json", &network::hash(folder)[..12])) + } +} + fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).map_err(|e| format!("Cannot store mail: {e}"))?; @@ -580,7 +592,13 @@ impl HostService for MailService { "message_id": format!("<{now:x}@{domain}>"), }); let transport = self.transport.clone(); - work(move || reply.send(transport.send(&account, &draft))); + work(move || { + let sent = transport.send(&account, &draft); + if sent.is_ok() { + contacts::record_sent(&store.dir, &account_arg, text(&draft, "to")); + } + reply.send(sent) + }); } other => reply.send(Err(format!("mail has no method {other:?}"))), } @@ -832,9 +850,13 @@ mod tests { ask(&dir, "os.mail", "mail.send", json!({"account": id, "to": "alex@example.com", "subject": "Hi", "body": "Hello"}), false, &mut host).unwrap(); assert_eq!(fake.sent.lock().unwrap()[0]["to"], "alex@example.com"); + // The approval rules' contacts: the account and whom it wrote to, no password. + assert_eq!(contacts::known_addresses(&dir), ["alex@example.com", "me@example.com"]); + assert!(!std::fs::read_to_string(dir.join("mail").join(contacts::SENT_TO_FILE)).unwrap().contains("s3cret")); ask(&dir, "os.mail", "mail.remove_account", json!({"account": id}), false, &mut host).unwrap(); assert!(!dir.join("mail/secrets").join(&id).exists(), "the last app out takes the password with it"); assert!(std::fs::read_dir(dir.join("mail")).unwrap().flatten().all(|e| !e.file_name().to_string_lossy().starts_with("box-")), "and its mail"); + assert!(contacts::known_addresses(&dir).is_empty(), "and whom it wrote to"); } } diff --git a/apps/mail/host-service/src/network.rs b/apps/mail/host-service/src/network.rs index 29f60e9d..34ee28f6 100644 --- a/apps/mail/host-service/src/network.rs +++ b/apps/mail/host-service/src/network.rs @@ -423,7 +423,7 @@ pub fn decode(raw: &[u8], uid: &str) -> Result { }; Ok( json!({"id":&hash(uid)[..24],"uid":uid,"sender":sender,"address":address,"subject":subject, - "message_id":header("Message-ID"),"reply_to":header("Reply-To"),"references":header("References"), + "to":header("To"),"cc":header("Cc"),"message_id":header("Message-ID"),"reply_to":header("Reply-To"),"references":header("References"), "body":plain,"preview":plain.split_whitespace().collect::>().join(" ").chars().take(180).collect::(), "html":html,"inline_images":images,"attachment_items":attachments,"attachments":attachments.len(), "date":date.to_rfc3339(),"time":date.format("%b %d").to_string(),"unread":true,"flagged":false,"archived":false,"source":"gmail"}), diff --git a/crates/shell/src/approvals/contacts.rs b/crates/shell/src/approvals/contacts.rs new file mode 100644 index 00000000..f6e2d692 --- /dev/null +++ b/crates/shell/src/approvals/contacts.rs @@ -0,0 +1,168 @@ +//! The person's contacts for the rule condition "recipients in my contacts" +//! (ADR 0004 §8), and the person's consent to use them. +//! +//! - [`ContactsSource`] answers whether an email address or Matrix ID is +//! known. [`ContactList`] is the fixed list (tests, snapshots), +//! [`NoContacts`] knows nobody, [`MailContacts`] reads Mail's host +//! service data: the person's own accounts and everyone they sent mail to +//! (`octosense_mail_service::contacts::known_addresses`), never the +//! secret store. +//! - [`ContactsGate`] is what the router asks: off by default, it knows +//! nobody until the person turns on Settings → Assistant → Approvals → +//! "Use my contacts in approval rules" (a gesture, like consent at first +//! use); turning it off needs none. The choice is kept per OctoSense home +//! in [`CONTACTS_FILE`], owner-only. +//! +//! OS address books (macOS Contacts, Android's contacts provider) are a +//! follow-up source behind the same trait. + +use super::rules::ApprovalGesture; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +/// Relative to the OctoSense home. +pub const CONTACTS_FILE: &str = "approvals/contacts.json"; + +/// Who the person knows. `address` is an email address or a Matrix ID +/// (`@ana:example.org`), as the rule facts give it (lowercased). +pub trait ContactsSource: Send { + fn is_known(&self, address: &str) -> bool; +} + +/// Nobody is known. +#[derive(Default)] +pub struct NoContacts; + +impl ContactsSource for NoContacts { + fn is_known(&self, _address: &str) -> bool { + false + } +} + +/// A fixed list (tests, and a host-provided snapshot). +#[derive(Default, Clone)] +pub struct ContactList(pub Vec); + +impl ContactsSource for ContactList { + fn is_known(&self, address: &str) -> bool { + let address = address.trim(); + !address.is_empty() && self.0.iter().any(|c| c.trim().eq_ignore_ascii_case(address)) + } +} + +/// Mail's host service data, read where the Card runner keeps it +/// (`/.host`), re-read at most every [`MailContacts::REFRESH`]. +/// `host_dir` answers `None` until App Hub has a data root. +pub struct MailContacts { + host_dir: Box Option + Send>, + read: fn(&Path) -> Vec, + cache: std::sync::Mutex>, +} + +impl MailContacts { + pub const REFRESH: std::time::Duration = std::time::Duration::from_secs(30); + + /// `read` is `octosense_mail_service::contacts::known_addresses` in the + /// shell; tests hand their own. + pub fn new(host_dir: impl Fn() -> Option + Send + 'static, read: fn(&Path) -> Vec) -> MailContacts { + MailContacts { host_dir: Box::new(host_dir), read, cache: std::sync::Mutex::new(None) } + } + + fn snapshot(&self) -> ContactList { + let mut cache = self.cache.lock().unwrap_or_else(|e| e.into_inner()); + if let Some((at, list)) = cache.as_ref() { + if at.elapsed() < Self::REFRESH { + return list.clone(); + } + } + let list = ContactList((self.host_dir)().map(|d| (self.read)(&d)).unwrap_or_default()); + *cache = Some((std::time::Instant::now(), list.clone())); + list + } +} + +impl ContactsSource for MailContacts { + fn is_known(&self, address: &str) -> bool { + self.snapshot().is_known(address) + } +} + +/// The shell's contacts source: Mail's data where Mail's host service is +/// linked (App Hub, and every phone build), else nobody. +pub fn shell_source() -> Box { + #[cfg(any(feature = "app-hub", native_mobile))] + { + Box::new(MailContacts::new( + || octosense_app_hub_app::data_root_if_set().map(|root| root.join(".host")), + octosense_mail_service::contacts::known_addresses, + )) + } + #[cfg(not(any(feature = "app-hub", native_mobile)))] + { + Box::new(NoContacts) + } +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct ContactsFile { + schema: u32, + use_in_rules: bool, + at: u64, +} + +/// The source behind the person's consent: knows nobody while "Use my +/// contacts in approval rules" is off. +pub struct ContactsGate { + path: Option, + allowed: bool, + source: Box, + generation: u64, +} + +impl ContactsGate { + /// Off, kept in memory only. + pub fn memory(source: Box) -> ContactsGate { + ContactsGate { path: None, allowed: false, source, generation: 0 } + } + /// The person's choice in this home (off if never made). + pub fn in_home(home: &Path, source: Box) -> ContactsGate { + let path = home.join(CONTACTS_FILE); + let file: ContactsFile = std::fs::read(&path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default(); + ContactsGate { path: Some(path), allowed: file.use_in_rules, ..ContactsGate::memory(source) } + } + pub fn allowed(&self) -> bool { + self.allowed + } + pub fn generation(&self) -> u64 { + self.generation + } + pub fn set_source(&mut self, source: Box) { + self.source = source; + self.generation += 1; + } + /// The person turned "Use my contacts in approval rules" on. + pub fn allow(&mut self, _gesture: &ApprovalGesture, now: u64) { + self.set(true, now); + } + /// Turning it off needs no gesture (always allowed). + pub fn turn_off(&mut self, now: u64) { + self.set(false, now); + } + fn set(&mut self, allowed: bool, now: u64) { + self.allowed = allowed; + self.generation += 1; + let Some(path) = &self.path else { return }; + let file = ContactsFile { schema: 1, use_in_rules: allowed, at: now }; + if let Ok(bytes) = serde_json::to_vec_pretty(&file) { + if let Err(e) = super::write_private(path, &bytes) { + eprintln!("approvals: could not save the contacts choice: {e}"); + } + } + } +} + +impl ContactsSource for ContactsGate { + fn is_known(&self, address: &str) -> bool { + self.allowed && self.source.is_known(address) + } +} diff --git a/crates/shell/src/approvals/mod.rs b/crates/shell/src/approvals/mod.rs index d5047d02..037ed2e3 100644 --- a/crates/shell/src/approvals/mod.rs +++ b/crates/shell/src/approvals/mod.rs @@ -13,11 +13,12 @@ //! | the sheet model | [`sheet`] | //! | the append-only, owner-only audit | [`audit`] | //! | consent at first use; `consent::granted(app)` | [`consent`] | +//! | contacts for "recipients in my contacts", behind the person's consent | [`contacts`] | //! | the shell-drawn sheet, first-use sheet and time-box indicator | [`view`] | //! | Settings → Assistant → Approvals | [`settings_page`] | //! -//! Files, per OctoSense home: [`rules::RULES_FILE`], [`consent::CONSENT_FILE`] -//! and [`audit::AUDIT_FILE`], all owner-only. +//! Files, per OctoSense home: [`rules::RULES_FILE`], [`consent::CONSENT_FILE`], +//! [`contacts::CONTACTS_FILE`] and [`audit::AUDIT_FILE`], all owner-only. //! //! The shell calls [`init`] at startup, [`tick`] once a second (and shows //! [`take_notices`] as notifications), and gives pointer events to @@ -28,6 +29,7 @@ pub mod audit; pub mod consent; +pub mod contacts; pub mod dev_hooks; pub mod facts; pub mod relay; @@ -86,17 +88,18 @@ impl ApprovalRelay for Dispatch { impl Approvals { pub fn in_home(home: &Path) -> Approvals { - Approvals::with_parts(rules::RuleStore::in_home(home), audit::AuditLog::in_home(home), consent::ConsentStore::in_home(home)) + let contacts = contacts::ContactsGate::in_home(home, contacts::shell_source()); + Approvals::with_parts(rules::RuleStore::in_home(home), audit::AuditLog::in_home(home), consent::ConsentStore::in_home(home), contacts) } pub fn memory() -> Approvals { - Approvals::with_parts(rules::RuleStore::memory(), audit::AuditLog::memory(), consent::ConsentStore::memory()) + Approvals::with_parts(rules::RuleStore::memory(), audit::AuditLog::memory(), consent::ConsentStore::memory(), contacts::ContactsGate::memory(Box::new(contacts::NoContacts))) } - fn with_parts(rules: rules::RuleStore, audit: audit::AuditLog, consent: consent::ConsentStore) -> Approvals { + fn with_parts(rules: rules::RuleStore, audit: audit::AuditLog, consent: consent::ConsentStore, contacts: contacts::ContactsGate) -> Approvals { let queue = RecordingRelay::default(); let bus = RecordingRelay::default(); let external = Arc::new(Mutex::new(None)); let dispatch = Dispatch { bus: bus.clone(), queue: queue.clone(), external: external.clone() }; - let router = Router::new(rules, audit, Box::new(dev_hooks::ShellDevMode), Box::new(rules::NoContacts), Box::new(dispatch)); + let router = Router::new(rules, audit, Box::new(dev_hooks::ShellDevMode), contacts, Box::new(dispatch)); Approvals { router, consent, settings_open: false, queue, bus, external } } /// Sheets, rules, consent and the page: one number for "redraw". @@ -104,7 +107,7 @@ impl Approvals { let now = now(); // The time-box indicator counts minutes down. let minute = if self.router.rules.active_everything(now).is_empty() { 0 } else { now / 60 }; - self.router.generation() + self.consent.generation() + u64::from(self.settings_open) + minute + self.router.generation() + self.consent.generation() + self.router.contacts().generation() + u64::from(self.settings_open) + minute } pub fn consent_granted(&self, app: &str) -> bool { self.consent.granted(app, self.router.hooks().grants_all(app)) diff --git a/crates/shell/src/approvals/router.rs b/crates/shell/src/approvals/router.rs index 50556c08..bb4f44b6 100644 --- a/crates/shell/src/approvals/router.rs +++ b/crates/shell/src/approvals/router.rs @@ -24,7 +24,8 @@ use super::audit::{AuditLog, Entry}; use super::dev_hooks::{DevKind, DevModeHooks}; use super::facts; use super::relay::{ApprovalIntake, ApprovalRelay}; -use super::rules::{Contacts, ApprovalGesture, RuleDraft, RuleStore}; +use super::contacts::{ContactsGate, ContactsSource}; +use super::rules::{ApprovalGesture, RuleDraft, RuleStore}; use super::sheet::{app_label, caller_label, Answer, Line, Place, Sheet, Surfaced}; use super::types::{Caller, Confirm, Connection, Decision, Request, RequestContext, RequestId, RuleId, ToolSpec}; use serde_json::Value; @@ -81,7 +82,8 @@ pub struct Router { pub rules: RuleStore, pub audit: AuditLog, hooks: Box, - contacts: Box, + /// "Recipients in my contacts", behind the person's consent. + contacts: ContactsGate, relay: Box, /// Every request not yet decided. pending: BTreeMap, @@ -102,7 +104,7 @@ pub struct Router { } impl Router { - pub fn new(rules: RuleStore, audit: AuditLog, hooks: Box, contacts: Box, relay: Box) -> Router { + pub fn new(rules: RuleStore, audit: AuditLog, hooks: Box, contacts: ContactsGate, relay: Box) -> Router { Router { rules, audit, @@ -131,8 +133,16 @@ impl Router { pub fn set_hooks(&mut self, hooks: Box) { self.hooks = hooks; } - pub fn set_contacts(&mut self, contacts: Box) { - self.contacts = contacts; + /// Replace where contacts come from; the person's consent stays. + pub fn set_contacts(&mut self, contacts: Box) { + self.contacts.set_source(contacts); + } + pub fn contacts(&self) -> &ContactsGate { + &self.contacts + } + /// Settings' "Use my contacts in approval rules". + pub fn contacts_mut(&mut self) -> &mut ContactsGate { + &mut self.contacts } pub fn hooks(&self) -> &dyn DevModeHooks { &*self.hooks @@ -215,7 +225,7 @@ impl Router { // 4. Standing rules. let surfaced = match always_person { Some(s) => s, - None => match self.rules.find(&req, &*self.contacts, now) { + None => match self.rules.find(&req, &self.contacts, now) { Some(rule) => { self.rules.record_use(&rule, now); self.decide(&req.id, Decision::ApproveByRule(rule.clone()), "rule", Some(&rule), "standing rule", now); @@ -232,7 +242,7 @@ impl Router { } fn surface(&mut self, req: &Request, surfaced: Surfaced, now: u64) -> u64 { - let line = Line::for_request(req, surfaced, &*self.contacts); + let line = Line::for_request(req, surfaced, &self.contacts); let batch = req.context.batch.clone(); self.notice(format!("Needs you: {}", line.heading()), format!("{} asks. Open the sheet to approve or deny.", line.caller)); self.changed(); @@ -350,7 +360,7 @@ impl Router { .collect(); for (sheet, id) in open { let Some(req) = self.pending.get(&id).cloned() else { continue }; - if let Some(rule) = self.rules.find(&req, &*self.contacts, now) { + if let Some(rule) = self.rules.find(&req, &self.contacts, now) { self.rules.record_use(&rule, now); self.decide(&id, Decision::ApproveByRule(rule.clone()), "rule", Some(&rule), "standing rule", now); if let Some(line) = self.sheets.iter_mut().find(|s| s.id == sheet).and_then(|s| s.lines.iter_mut().find(|l| l.request == id)) { diff --git a/crates/shell/src/approvals/rules.rs b/crates/shell/src/approvals/rules.rs index c65bfc72..82a6efa5 100644 --- a/crates/shell/src/approvals/rules.rs +++ b/crates/shell/src/approvals/rules.rs @@ -18,6 +18,7 @@ //! - **One tap turns every rule off** ([`RuleStore::all_off`]). //! - **Persisted per OctoSense home** in [`RULES_FILE`], owner-only. +use super::contacts::ContactsSource; use super::facts; use super::types::{Request, RuleId, Trigger}; use serde::{Deserialize, Serialize}; @@ -189,32 +190,6 @@ pub enum Miss { CapReached, } -/// The person's contacts, for "recipients in contacts". -pub trait Contacts: Send { - fn contains(&self, address: &str) -> bool; -} - -/// Nobody is in contacts: the contacts condition never holds until the -/// shell has a contacts source. -#[derive(Default)] -pub struct NoContacts; - -impl Contacts for NoContacts { - fn contains(&self, _address: &str) -> bool { - false - } -} - -/// A fixed list (tests, and a host-provided snapshot). -#[derive(Default, Clone)] -pub struct ContactList(pub Vec); - -impl Contacts for ContactList { - fn contains(&self, address: &str) -> bool { - self.0.iter().any(|c| c.eq_ignore_ascii_case(address)) - } -} - pub fn day_of(now: u64) -> u64 { now / DAY_S } @@ -240,7 +215,7 @@ impl Rule { /// Whether this rule answers `req`, on its exact arguments. The router /// has already excluded `auto_approvable: false` and unknown outcomes. - pub fn check(&self, req: &Request, contacts: &dyn Contacts, now: u64) -> Result<(), Miss> { + pub fn check(&self, req: &Request, contacts: &dyn ContactsSource, now: u64) -> Result<(), Miss> { if self.app != req.app { return Err(Miss::OtherApp); } @@ -265,7 +240,7 @@ impl Rule { return Err(Miss::NoRecipients); } let in_thread = |r: &String| req.context.thread.iter().any(|t| t.eq_ignore_ascii_case(r)); - let ok = recipients.iter().all(|r| (c.recipients_in_contacts && contacts.contains(r)) || (c.recipients_in_thread && in_thread(r))); + let ok = recipients.iter().all(|r| (c.recipients_in_contacts && contacts.is_known(r)) || (c.recipients_in_thread && in_thread(r))); if !ok { return Err(Miss::RecipientsNotKnown); } @@ -389,7 +364,7 @@ impl RuleStore { /// The first rule that answers `req`. Narrow rules first, then the /// app's time-boxed "everything". - pub fn find(&self, req: &Request, contacts: &dyn Contacts, now: u64) -> Option { + pub fn find(&self, req: &Request, contacts: &dyn ContactsSource, now: u64) -> Option { let narrow = self.rules.iter().filter(|r| !r.is_everything()); let broad = self.rules.iter().filter(|r| r.is_everything()); narrow.chain(broad).find(|r| r.check(req, contacts, now).is_ok()).map(|r| r.id.clone()) diff --git a/crates/shell/src/approvals/settings_page.rs b/crates/shell/src/approvals/settings_page.rs index 6b3dafa6..c1bf070b 100644 --- a/crates/shell/src/approvals/settings_page.rs +++ b/crates/shell/src/approvals/settings_page.rs @@ -4,6 +4,8 @@ //! - **Rules**: each standing rule with its conditions, today's use of its //! daily cap and the minutes left of a time box; Delete (or Turn on for //! one that is off); **Turn every rule off** in one tap. +//! - **Use my contacts in approval rules**: off by default; while off, +//! "people in my contacts" never matches (`contacts.rs`). //! - **App agents**: every app's agent with its consent and an off switch; //! "Everything for 60 min" creates the time-boxed rule for that app. //! - **Recent automatic approvals**: the audit's newest automatic entries @@ -45,6 +47,8 @@ enum Hit { AllOff, Delete(RuleId), Enable(RuleId), + ContactsOn, + ContactsOff, AgentOff(String), AgentAllow(String), Everything(String), @@ -55,6 +59,7 @@ enum Hit { struct Frame { open: bool, rules: Vec, + contacts: bool, agents: Vec<(String, String, State)>, log: Vec, now: u64, @@ -68,6 +73,7 @@ fn frame() -> Frame { Frame { open: true, rules: a.router.rules.rules().to_vec(), + contacts: a.router.contacts().allowed(), agents: a.consent.agents(), log: a.router.audit.recent_automatic(8), now: super::now(), @@ -241,6 +247,23 @@ impl ShellApprovalsSettings { hits.push((off, Hit::AllOff)); } y += 28.0 + 10.0; + { + let (switch, hit) = if f.contacts { ("Turn off", Hit::ContactsOff) } else { ("Turn on", Hit::ContactsOn) }; + let sw = b.width(cx, switch); + let s = b.draw(cx, x + cw - sw, y + 6.0, sw, switch, false); + hits.push((s, hit)); + let tw = cw - sw - 12.0; + let label = "Use my contacts in approval rules"; + let status = if f.contacts { + "On: your mail accounts and the people you sent mail to" + } else { + "Off: \u{201c}people in my contacts\u{201d} never matches" + }; + b.d.label_elided(cx, rect(x, y, tw, 20.0), true, tok.font.body, ink, HAlign::Left, label); + b.d.label_elided(cx, rect(x, y + 20.0, tw, 16.0), false, tok.font.body_small, dim, HAlign::Left, status); + shown.push(format!("{label} | {status}")); + y += ROW_H + 4.0; + } if f.rules.is_empty() { b.d.label_elided(cx, rect(x, y, cw, 18.0), false, tok.font.body, dim, HAlign::Left, "No rules. Every approval asks you."); shown.push("No rules. Every approval asks you.".into()); @@ -326,8 +349,8 @@ impl ShellApprovalsSettings { } } -/// What a press does on the page. Creating or re-enabling a rule and -/// allowing an agent are the person's gestures. +/// What a press does on the page. Creating or re-enabling a rule, allowing +/// an agent and turning contacts on are the person's gestures. fn act(hit: Hit) { let now = super::now(); super::with(|a| match hit { @@ -341,6 +364,8 @@ fn act(hit: Hit) { Hit::Enable(id) => { a.router.rules.enable(&ApprovalGesture::settings_tap(), &id); } + Hit::ContactsOn => a.router.contacts_mut().allow(&ApprovalGesture::settings_tap(), now), + Hit::ContactsOff => a.router.contacts_mut().turn_off(now), Hit::AgentOff(app) => a.consent.turn_off(&app, now), Hit::AgentAllow(app) => a.consent.set(&ApprovalGesture::settings_tap(), &app, true, now), Hit::Everything(app) => { diff --git a/crates/shell/src/approvals/sheet.rs b/crates/shell/src/approvals/sheet.rs index 7143c445..0da24515 100644 --- a/crates/shell/src/approvals/sheet.rs +++ b/crates/shell/src/approvals/sheet.rs @@ -10,7 +10,8 @@ //! nothing else; an agent's own text is never an approval surface. use super::facts; -use super::rules::{Conditions, Contacts, RuleDraft, MAX_EVERYTHING_MINUTES}; +use super::contacts::ContactsSource; +use super::rules::{Conditions, RuleDraft, MAX_EVERYTHING_MINUTES}; use super::types::{Caller, Connection, Request, RequestId, Trigger}; /// Why the router put a request in front of the person. @@ -110,7 +111,7 @@ pub fn caller_label(owning_app: &str, caller: &Caller) -> String { } impl Line { - pub fn for_request(req: &Request, surfaced: Surfaced, contacts: &dyn Contacts) -> Line { + pub fn for_request(req: &Request, surfaced: Surfaced, contacts: &dyn ContactsSource) -> Line { let always = if surfaced.rules_could_answer() && req.tool.auto_approvable && !req.context.outcome_unknown && req.context.connection == Connection::Host { always_choices(req, contacts) } else { @@ -135,7 +136,7 @@ impl Line { } /// The rules a sheet offers for this call, narrowest first. -fn always_choices(req: &Request, contacts: &dyn Contacts) -> Vec { +fn always_choices(req: &Request, contacts: &dyn ContactsSource) -> Vec { let mut out = Vec::new(); let recipients = facts::recipients(&req.args); let no_attachments = !facts::has_attachments(&req.args); @@ -145,7 +146,7 @@ fn always_choices(req: &Request, contacts: &dyn Contacts) -> Vec { c }; if !recipients.is_empty() { - if recipients.iter().all(|r| contacts.contains(r)) { + if recipients.iter().all(|r| contacts.is_known(r)) { let c = with_attachments(Conditions { recipients_in_contacts: true, ..Conditions::default() }); out.push(AlwaysChoice { label: format!("Always for people in my contacts{}", if no_attachments { ", no attachments" } else { "" }), draft: RuleDraft::tool(&req.app, tool, c) }); } diff --git a/crates/shell/src/approvals/tests.rs b/crates/shell/src/approvals/tests.rs index 7c5460e2..78760460 100644 --- a/crates/shell/src/approvals/tests.rs +++ b/crates/shell/src/approvals/tests.rs @@ -7,7 +7,8 @@ use super::consent::{AgentSummary, ConsentStore, State}; use super::dev_hooks::FixedDevMode; use super::relay::RecordingRelay; use super::router::{make_request, AppConfirm, AppConfirmRequest, AutoBy, Route, Router}; -use super::rules::{Conditions, ContactList, ApprovalGesture, RuleDraft, RuleStore, DEFAULT_DAILY_CAP}; +use super::contacts::{ContactList, ContactsGate, ContactsSource, MailContacts, NoContacts}; +use super::rules::{Conditions, ApprovalGesture, RuleDraft, RuleStore, DEFAULT_DAILY_CAP}; use super::sheet::{Answer, Place, Surfaced}; use super::types::*; use serde_json::{json, Value}; @@ -18,10 +19,17 @@ const MAIL: &str = "os.mail"; fn router_with(dev: FixedDevMode) -> (Router, RecordingRelay) { let relay = RecordingRelay::default(); - let contacts = ContactList(vec!["ana@example.org".into(), "bo@example.org".into()]); - let r = Router::new(RuleStore::memory(), AuditLog::memory(), Box::new(dev), Box::new(contacts), Box::new(relay.clone())); + let r = router_with_contacts(dev, relay.clone(), true); (r, relay) } +/// Ana and Bo are contacts; `consent`: "Use my contacts in approval rules" is on. +fn router_with_contacts(dev: FixedDevMode, relay: RecordingRelay, consent: bool) -> Router { + let mut contacts = ContactsGate::memory(Box::new(ContactList(vec!["ana@example.org".into(), "bo@example.org".into(), "@ana:example.org".into()]))); + if consent { + contacts.allow(&ApprovalGesture::settings_tap(), T0); + } + Router::new(RuleStore::memory(), AuditLog::memory(), Box::new(dev), contacts, Box::new(relay)) +} fn router() -> (Router, RecordingRelay) { router_with(FixedDevMode::off()) } @@ -163,6 +171,48 @@ fn condition_recipients_in_contacts() { assert!(!matches(&mut r, "2", json!({"to": ["ana@example.org", "eve@example.org"]}), Trigger::Person, &[])); assert!(!matches(&mut r, "3", json!({"to": "ana@example.org", "bcc": "eve@example.org"}), Trigger::Person, &[]), "bcc counts"); assert!(!matches(&mut r, "4", json!({"subject": "no recipients"}), Trigger::Person, &[]), "a missing fact fails"); + assert!(matches(&mut r, "5", json!({"to": "@Ana:example.org"}), Trigger::Person, &[]), "a Matrix ID"); + assert!(!matches(&mut r, "6", json!({"to": "@eve:example.org"}), Trigger::Person, &[])); +} + +#[test] +fn contacts_never_match_without_consent() { + let relay = RecordingRelay::default(); + let mut r = router_with_contacts(FixedDevMode::off(), relay.clone(), false); + rule(&mut r, contacts_rule()); + assert!(!r.contacts().allowed(), "off by default"); + assert!(!matches(&mut r, "1", json!({"to": "ana@example.org"}), Trigger::Person, &[]), "a known recipient, no consent"); + assert!( + r.front_sheet().unwrap().lines[0].always.iter().all(|c| !c.label.contains("contacts")), + "no \u{201c}always for people in my contacts\u{201d} either" + ); + r.contacts_mut().allow(&ApprovalGesture::settings_tap(), T0); + assert!(matches(&mut r, "2", json!({"to": "ana@example.org"}), Trigger::Person, &[])); + assert!(!matches(&mut r, "3", json!({"to": "eve@example.org"}), Trigger::Person, &[]), "an unknown recipient"); + r.contacts_mut().turn_off(T0); + assert!(!matches(&mut r, "4", json!({"to": "ana@example.org"}), Trigger::Person, &[]), "turned off again"); + // A new source keeps the person's choice. + r.set_contacts(Box::new(ContactList(vec!["ana@example.org".into()]))); + assert!(!r.contacts().is_known("ana@example.org")); +} + +#[test] +fn mail_contacts_read_the_mail_services_data_where_it_lives() { + fn read(dir: &std::path::Path) -> Vec { + // Stands in for the mail service's known_addresses. + if dir.ends_with(".host") { vec!["me@example.com".into(), "ana@example.org".into()] } else { Vec::new() } + } + let mail = MailContacts::new(|| Some(std::path::PathBuf::from("apps-root").join(".host")), read); + assert!(mail.is_known("Ana@Example.org") && mail.is_known("me@example.com")); + assert!(!mail.is_known("eve@example.org") && !mail.is_known("")); + let unset = MailContacts::new(|| None, read); + assert!(!unset.is_known("ana@example.org"), "no App Hub data root yet: nobody"); + assert!(!NoContacts.is_known("ana@example.org")); + // Behind the gate: nothing until the person allows it. + let mut gate = ContactsGate::memory(Box::new(mail)); + assert!(!gate.is_known("ana@example.org")); + gate.allow(&ApprovalGesture::settings_tap(), T0); + assert!(gate.is_known("ana@example.org")); } #[test] @@ -446,6 +496,8 @@ fn rules_consent_and_audit_persist_per_home_owner_only() { { let mut a = super::Approvals::in_home(&home); a.router.set_contacts(Box::new(ContactList(vec!["ana@example.org".into()]))); + assert!(!a.router.contacts().allowed(), "contacts are off in a new home"); + a.router.contacts_mut().allow(&ApprovalGesture::settings_tap(), T0); let id = a.router.create_rule(&ApprovalGesture::settings_tap(), contacts_rule(), T0).unwrap(); a.router.request(send("1", json!({"to": "ana@example.org"})), T0); assert_eq!(a.router.rules.get(&id).unwrap().used_today(T0), 1); @@ -455,11 +507,12 @@ fn rules_consent_and_audit_persist_per_home_owner_only() { assert_eq!(a.router.rules.rules().len(), 1); assert_eq!(a.router.rules.rules()[0].used_today(T0), 1, "the cap's count survives a restart"); assert_eq!(a.consent.state("os.news"), State::Allowed); + assert!(a.router.contacts().allowed(), "the contacts choice survives a restart"); assert_eq!(a.router.audit.recent_automatic(5).len(), 1, "the log is read back"); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - for f in [super::rules::RULES_FILE, super::consent::CONSENT_FILE, super::audit::AUDIT_FILE] { + for f in [super::rules::RULES_FILE, super::consent::CONSENT_FILE, super::contacts::CONTACTS_FILE, super::audit::AUDIT_FILE] { let mode = std::fs::metadata(home.join(f)).unwrap().permissions().mode() & 0o777; assert_eq!(mode, 0o600, "{f}"); } diff --git a/docs/ai-services.md b/docs/ai-services.md index 6e7c54cf..32388afa 100644 --- a/docs/ai-services.md +++ b/docs/ai-services.md @@ -2,11 +2,11 @@ English | [简体中文](ai-services.zh-CN.md) -How the assistant is wired into OctoSense: the [octos](https://github.com/octos-org/octos) agent kernel the shell runs, where the person configures it, which apps may use it and how, and what is planned. It describes `main` as of 2026-09-27 (OctoSense `405139f`, which pins octos `a6ea8505` and OctoSense-App-Hub `46d67e51`). Everything under **Works today** was read in that code; **In progress** means an open pull request; **Planned** means an ADR or an open issue, with nothing merged yet. +How the assistant is wired into OctoSense: the [octos](https://github.com/octos-org/octos) agent kernel the shell runs, where the person configures it, which apps may use it and how, and what is planned. It describes `main` as of 2026-09-28 (OctoSense `ad0d738`, which pins octos `5e7577f0` and OctoSense-App-Hub `e8601b80`). Everything under **Works today** was read in that code; **In progress** means an open pull request; **Planned** means an ADR or an open issue, with nothing merged yet. This page is about the assistant *inside* OctoSense. Building an app needs no AI service and no particular coding agent: the app harness, [OctoScript-App-Design-Flow](https://github.com/OctoSense-org/OctoScript-App-Design-Flow), works with any agent or none. Its [AI-SERVICES](https://github.com/OctoSense-org/OctoScript-App-Design-Flow/blob/main/docs/AI-SERVICES.md) page is the app developer's short version of this one. -For the whole system around it (processes per platform, agents, protocols, tools and grants, approvals, storage and trust boundaries), see [OctoSense architecture](architecture.md). +For the whole system around it (processes per platform, agents, protocols, tools and grants, approvals, storage and trust boundaries), see [OctoSense architecture](architecture.md). This page does not repeat it: the native app manifest (`native-apps.json`), the system agent's tool set, the approval router, first-use consent and developer mode are described there and only linked from here. ## Contents @@ -25,13 +25,17 @@ For the whole system around it (processes per platform, agents, protocols, tools | One octos kernel per shell process, started on first use, restarted after a provider change | Works today (desktop with `OCTOS_APP_CORE_BIN`, Android, OpenHarmony; none on iOS) | [`crates/kernel`](../crates/kernel/README.md) | | AI providers: the person's model providers and keys, keys only on host sheets | Works today | [`apps/ai-providers`](../apps/ai-providers/host-service/README.md) | | The shell's AI entry point (`start`, policy, per-instance offer, QR import) | Works today | [`crates/ai-host`](../crates/ai-host/README.md) | -| Host-owned app peers: one octos peer per granted 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) | +| Host-owned app peers: one octos peer per granted app, owned by the system agent | Works today, for native modules (Rinx) and, behind a switch, contained script apps | [`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 | 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) | +| A contained script app (system or store) asking the assistant | Works where the shell hosts a kernel ([#106](https://github.com/OctoSense-org/OctoSense/pull/106)): the `octos` host service gives each app its own peer (`card.`), while `Policy::contained_apps` is on (**off by default**; `OCTOSENSE_CONTAINED_APPS=1`) and after the person allows the app's agent at first use ([#120](https://github.com/OctoSense-org/OctoSense/pull/120)). Tool approvals the peer raises are still declined. `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`) | Works today ([#95](https://github.com/OctoSense-org/OctoSense/pull/95)): registered by `crates/ai-host` with the `llm` service, for apps granted `model` ([App-Hub#24](https://github.com/OctoSense-org/OctoSense-App-Hub/pull/24), in the shells' App Hub pin) | [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) | -| An app's own agent: `tools.json`, `AGENT.md`, skills, model requirements, triggers | Planned ([ADR 0002](adr/0002-event-driven-app-agents.md), Proposed). App Hub admits the files ([App-Hub#18](https://github.com/OctoSense-org/OctoSense-App-Hub/pull/18)); no shell runs them; the kernel side is open ([octos#2567](https://github.com/octos-org/octos/pull/2567)) | [below](#planned-event-driven-app-agents-adr-0002) | +| `glance.publish`: L0 cards on the glance screen | Merged ([#72](https://github.com/OctoSense-org/OctoSense/pull/72)); contained apps granted the `glance` capability publish ([#86](https://github.com/OctoSense-org/OctoSense/pull/86)). In progress: `sys.digest` sources ([#87](https://github.com/OctoSense-org/OctoSense/pull/87), draft) | [`crates/shell/src/glance.rs`](../crates/shell/src/glance.rs) | +| Approvals, first-use consent, developer mode | Works today ([#120](https://github.com/OctoSense-org/OctoSense/pull/120), [#118](https://github.com/OctoSense-org/OctoSense/pull/118)): the shell's approval router, standing rules and sheets, fed today by the AI services bus only; consent before an app's agent first runs; developer mode turned on only by the person | [architecture § Approvals](architecture.md#5-approvals) | +| The system agent's tool set | Works today, partly enforced ([#117](https://github.com/OctoSense-org/OctoSense/pull/117)): octos's shell is never offered; the exact list waits for octos#2567 | [architecture § Tools and grants](architecture.md#4-tools-and-grants) | +| Native apps declared once (`native-apps.json`), hosting per target; the Terminal a system app in its own process on the desktop | Works today ([#113](https://github.com/OctoSense-org/OctoSense/pull/113)) | [architecture § Native apps](architecture.md#native-apps-in-process-or-their-own-process) | +| An app's own agent: `tools.json`, `AGENT.md`, skills, model requirements, triggers | Planned ([ADR 0002](adr/0002-event-driven-app-agents.md), Proposed). App Hub admits the files ([App-Hub#18](https://github.com/OctoSense-org/OctoSense-App-Hub/pull/18)); no shell runs them; the kernel side is in progress ([octos#2567](https://github.com/octos-org/octos/pull/2567), draft) | [below](#planned-event-driven-app-agents-adr-0002) | +| Native apps in their own process reaching their agent (the peer link, ADR 0004 §5) | Planned (plan step 8). Makepad's client side is merged ([OctoSense-org/makepad#54](https://github.com/OctoSense-org/makepad/pull/54), `makepad_ai_services::peer`); the shell side is not written (nothing in `crates/shell/src/hub.rs`) | [architecture § An app and its own agent](architecture.md#an-app-and-its-own-agent) | ## Architecture @@ -44,7 +48,9 @@ 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, octos"] + runner["Card runner (App Hub)
host services: mail, news, glance, llm, model, octos"] + router["Approval router
crates/shell/src/approvals"] + bus["AI services bus
crates/shell/src/ai_bus.rs"] rinx["Rinx (native module)"] appcard["AppCard (native, opt-in)"] scripts["Script apps (system and store)"] @@ -63,7 +69,10 @@ flowchart TB rinx -- "scoped OctosAppService" --> broker appcard -- "its own connection" --> kernel scripts -- "host.request" --> runner - runner -. "no octos.* service" .- kernel + runner -- "octos.* service
peer card.<app id>" --> broker + rinx -- "typed tools" --> bus + bus -- "confirm: host calls" --> router + router --> person ``` ### The octos kernel: one per shell @@ -96,7 +105,7 @@ It is a contained script app like any other; the privileged half is the **`llm` [`crates/ai-host`](../crates/ai-host/README.md) (`octosense-ai-host`) is what both shells call: `start(Host::platform(data_dir))` at startup (configures the kernel, installs the host policy, registers `llm` with the platform's QR import), `handle_event` every event, `offer(module, scope)` / `finish()` around a native module's `create`, and `shutdown()`. -The host **policy** decides which native modules may use the assistant, by exact `octos.*` service name. `Policy::shipped()` grants only `rinx` the four services; there is no per-app toggle for the person yet. +The host **policy** decides which native modules may use the assistant, by exact `octos.*` service name, and whether contained apps get the `octos` service at all. `Policy::shipped()` grants only `rinx` the four services, and turns the contained apps' switch (`Policy::contained_apps`) on only with `OCTOSENSE_CONTAINED_APPS=1`. The person's per-app switch is **first-use consent** ([#120](https://github.com/OctoSense-org/OctoSense/pull/120), `crates/shell/src/approvals/consent.rs`): the first time an app asks for its agent the shell shows what it may read and use and where the model runs, and **Settings → Assistant → Approvals** lists every app's agent with an off switch ([architecture § Approvals](architecture.md#5-approvals)). ### `crates/app-peers`: host-owned app peers @@ -110,7 +119,7 @@ The host **policy** decides which native modules may use the assistant, by exact ### The system agent -Today the system agent is the **owner** of every app peer: the session that created it and can address it. It does not hold any app's tools, and it cannot answer an app peer's approvals (octos [#2560](https://github.com/octos-org/octos/pull/2560), in the pinned `a6ea8505`). Its larger role in ADR 0002 (supervising app agents, budgets, the kill switch, ranking the glance screen, improving app agents) is **planned**; the glance screen currently orders cards by priority and recency. +Today the system agent is the **owner** of every app peer: the session that created it and can address it. It does not hold any app's tools, and it cannot answer an app peer's approvals (octos [#2560](https://github.com/octos-org/octos/pull/2560)). Its tools are a defined set the shell writes before every kernel start, with octos's shell never offered ([#117](https://github.com/OctoSense-org/OctoSense/pull/117), `crates/kernel/src/system_tools.rs`; see [architecture § Tools and grants](architecture.md#4-tools-and-grants)). The person reaches it today from a Talk to Octos client ([#98](https://github.com/OctoSense-org/OctoSense/pull/98), [ADR 0003](adr/0003-shared-octos-client-access.md)); the shell draws no chat of its own for it yet. Its larger role in ADR 0002 (supervising app agents, budgets, the kill switch, ranking the glance screen, improving app agents) is **planned**; the glance screen currently orders cards by priority and recency. ### Other assistants in the shell @@ -123,7 +132,7 @@ Today the system agent is the **owner** of every app peer: the session that crea | --- | --- | | **Keys stay with the host.** | Only the `llm` service reads or writes keys; they live in the platform vault or the owner-only profile under the kernel's core dir, never in an app's jail. The app-peers contract carries no credentials (`ModelInfo` has none). No `octos.*` service lets an app choose a provider or submit a key. | | **Secrets are the host's.** | No app collects a password, PIN, key or one-time code, not even to pass it on. The runtime makes a password field inert in a policed isolate, App Hub's gate refuses a bundle that declares one, and services ask on their own sheets (`.sheet.*` is accepted only from the sheet). | -| **Approvals belong to the person, in the owning app.** | A tool approval raised in an app's context is answered only by `ContextOp::Approval` from that app's own UI; the system agent never approves for an app (app-peers policy, octos #2560). Rinx's `send_message` shows the room and the exact text in Rinx's own sheet and sends only on the person's yes. | +| **Approvals belong to the person, in the owning app.** | A tool approval raised in an app's context is answered only by `ContextOp::Approval` from that app's own UI; the system agent never approves for an app (app-peers policy, octos #2560). Rinx's `send_message` shows the room and the exact text in Rinx's own sheet and sends only on the person's yes. The shell's approval router ([#120](https://github.com/OctoSense-org/OctoSense/pull/120)) answers only on the person's gesture, live on a shell-drawn sheet or by a standing rule the person made; standing rules on "people in my contacts" use the person's contacts only after they turn on "Use my contacts in approval rules" ([architecture § Approvals](architecture.md#5-approvals)). Developer mode answers every approval for the apps it covers, and only the person turns it on ([#118](https://github.com/OctoSense-org/OctoSense/pull/118)). | | **The system agent drives an app through its app agent.** | It owns the peers but holds no app tools; an app's tools run in the app (Rinx: `src/assistant`, "the system agent never holds these tools itself, it asks Rinx's peer"). | | **Memory is private per app and account.** | Each peer's memory namespace is `app//acct-`; contexts of one account are never restored under another. Promotion to shared memory is planned (ADR 0002 §9). | | **Least privilege, by exact name.** | An app gets `declared ∩ supported ∩ host policy` services, compared by exact name: `octos.` or `octos.admin` grants nothing. | @@ -162,10 +171,10 @@ A script app runs in App Hub's Card runner and reaches the shell only through `h | --- | --- | --- | | `mail` | any app granted `mail` | Mail accounts the person signs in to on the host's sheet | | `llm` | `os.*` apps only | Managing the assistant's providers (`llm.providers`, `llm.add_provider`, `llm.test`, `llm.import_qr`, …); **no prompt or completion method** | -| `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 (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 | +| `news` | `os.*` apps only (`may_call` in `apps/news/host-service/src/lib.rs`) | News's data service (feeds, ledger, `news.list`, `news.read`, …); no model. The shells' App Hub pin knows the `news` capability, but the News bundle does not declare it yet (`apps/news/bundle/manifest.json`), so it still fetches on its own | +| `glance` | apps granted the `glance` capability ([#86](https://github.com/OctoSense-org/OctoSense/pull/86), [App-Hub#22](https://github.com/OctoSense-org/OctoSense-App-Hub/pull/22)) | Publishing L0 cards to the glance screen, under the app's own id (`crates/shell/src/glance.rs`) | +| `model` | apps granted the `model` capability ([App-Hub#24](https://github.com/OctoSense-org/OctoSense-App-Hub/pull/24)) | One-shot `model.complete {task, input, schema, class?, allow_urls?}` and `model.budget` ([#95](https://github.com/OctoSense-org/OctoSense/pull/95), `apps/ai-providers/host-service/src/complete/`, registered in `crates/ai-host/src/lib.rs`): `class` `fast` or `strong`; the host picks the model from the person's providers, validates the reply against the schema, refuses URLs unless asked, keeps a per-app daily budget; no tools, memory or history, and the app never sees a key | +| `octos` | any app whose manifest declares the exact `octos.*` names, while the shell hosts a kernel, `Policy::contained_apps` is on (**off by default**; set `OCTOSENSE_CONTAINED_APPS=1` to try it) and the person allowed the app's agent at first use (`consent_for_contained`, [#120](https://github.com/OctoSense-org/OctoSense/pull/120)) | The assistant, through the app's own host-owned peer `card.` (`crates/ai-host/src/contained.rs`, [#106](https://github.com/OctoSense-org/OctoSense/pull/106)): 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 and listed in the reply's `denied_approvals` (the shell's approval router does not take kernel approvals until octos#2567); in developer mode they are answered for the apps it covers (`crates/app-peers/src/host_approvals.rs`). 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: @@ -175,6 +184,7 @@ So a contained app, store or system, reaches the assistant only through `octos.* | `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.*` before the person allowed the app's agent | `r.error`: `Waiting for the person to allow this app's agent (OctoSense asks the first time)`, and the shell shows its first-use sheet (read in `contained.rs` and `approvals/mod.rs`; **unverified** in a running shell) | | `octos.*` on a desktop without `OCTOS_APP_CORE_BIN` | `r.error`: `no octos kernel: no kernel binary configured (OCTOS_APP_CORE_BIN)` | | `octos.*` 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.` | @@ -191,16 +201,16 @@ The manifest's `agent` field (a permission profile, generic tools, iteration and | Piece | ADR | Status | | --- | --- | --- | | `tools.json`: tools named `.`, JSON Schema input and output, `risk` (`read`, `act`, `destructive`), `confirm` (`host` or `app`), `background`, `shareable`, `implemented_by` (`host-service` or `app`) | §4, §12 | App Hub admits and pins it ([App-Hub#18](https://github.com/OctoSense-org/OctoSense-App-Hub/pull/18), `crates/app-policy/src/agent.rs`). No shell loads it yet. | -| `AGENT.md`, data-only skills, model **requirements** (never a provider or model name), `background`, `triggers` (cron, host-service events) | §2, §3 | Same: admitted by App Hub `main`. The shells pin App Hub `46d67e51`, which predates these fields; manifests refuse unknown fields, so **a shell today refuses a manifest that uses them** until its pin moves. | -| Kernel: host-registered tools per peer (`peer/tools/register`, `peer/tool/call`/`result`/`cancel`), tool-list and risk enforcement, approvals only on the host connection, an allowlist of generic tools | §4, §13 | Open: [octos#2567](https://github.com/octos-org/octos/pull/2567) (UPCR-2026-035). | -| Risk decides supervision: read and in-app act run unattended; destructive waits for the person (`confirm: host`: the kernel's approval path; `confirm: app`: the app's own sheet when the person is present, an approval request in the app's conversation when absent) | §4, §12 | Declared in App Hub (#18) and octos#2567; not enforced in OctoSense yet. Rinx's `send_message` already works the `confirm: app` way on its own surface. | +| `AGENT.md`, data-only skills, model **requirements** (never a provider or model name), `background`, `triggers` (cron, host-service events) | §2, §3 | Same: admitted by App Hub (`crates/app-policy/src/agent.rs`), and the shells' App Hub pin `e8601b80` includes them. No shell runs them yet. | +| Kernel: host-registered tools per peer (`peer/tools/register`, `peer/tool/call`/`result`/`cancel`), tool-list and risk enforcement, approvals only on the host connection, an allowlist of generic tools, `peer/input` | §4, §13 | In progress: [octos#2567](https://github.com/octos-org/octos/pull/2567) (UPCR-2026-035, draft). The shell side is ADR 0004 plan step 6. | +| Risk decides supervision: read and in-app act run unattended; destructive waits for the person (`confirm: host`: the kernel's approval path; `confirm: app`: the app's own sheet when the person is present, an approval request in the app's conversation when absent) | §4, §12 | Declared in App Hub (#18) and octos#2567. The shell's approval router, sheets and standing rules are merged ([#120](https://github.com/OctoSense-org/OctoSense/pull/120)) and take the AI services bus's `confirm: host` calls; kernel tool calls reach it once octos#2567 lands ([architecture § Approvals](architecture.md#5-approvals)). Rinx's `send_message` already works the `confirm: app` way on its own surface. | | News M1: the `news` data service (no model) | First slice | Merged ([#69](https://github.com/OctoSense-org/OctoSense/pull/69)); issue [#60](https://github.com/OctoSense-org/OctoSense/issues/60). | | News M2: `os.news` gets a peer and its tools are registered | First slice | Planned: [#61](https://github.com/OctoSense-org/OctoSense/issues/61). | | News M3: triggers and an unattended run | First slice | Planned: [#62](https://github.com/OctoSense-org/OctoSense/issues/62). | -| News M4: glance cards (`glance.publish`, the glance page and desktop panel) | §7, §8 | `glance.publish` merged ([#72](https://github.com/OctoSense-org/OctoSense/pull/72)); in progress: the `glance` capability for contained apps ([#86](https://github.com/OctoSense-org/OctoSense/pull/86), with [App-Hub#22](https://github.com/OctoSense-org/OctoSense-App-Hub/pull/22)) and `sys.digest` sources ([#87](https://github.com/OctoSense-org/OctoSense/pull/87)); issue [#63](https://github.com/OctoSense-org/OctoSense/issues/63). | -| M5: the system toolbox (research, crawl) as granted tools | §6 | Planned: [#64](https://github.com/OctoSense-org/OctoSense/issues/64); in progress: [#82](https://github.com/OctoSense-org/OctoSense/pull/82) (workflow templates), [octos#2568](https://github.com/octos-org/octos/pull/2568) (research engine). | +| News M4: glance cards (`glance.publish`, the glance page and desktop panel) | §7, §8 | `glance.publish` ([#72](https://github.com/OctoSense-org/OctoSense/pull/72)) and the `glance` capability for contained apps ([#86](https://github.com/OctoSense-org/OctoSense/pull/86), with [App-Hub#22](https://github.com/OctoSense-org/OctoSense-App-Hub/pull/22)) merged; in progress: `sys.digest` sources ([#87](https://github.com/OctoSense-org/OctoSense/pull/87), draft); issue [#63](https://github.com/OctoSense-org/OctoSense/issues/63). | +| M5: the system toolbox (research, crawl) as granted tools | §6 | Planned: [#64](https://github.com/OctoSense-org/OctoSense/issues/64). Merged: the workflow templates ([#82](https://github.com/OctoSense-org/OctoSense/pull/82), `crates/toolbox`) and octos's research engine ([octos#2568](https://github.com/octos-org/octos/pull/2568)); in progress: offering the toolbox to app agents ([#108](https://github.com/OctoSense-org/OctoSense/pull/108), draft). | | M6: render and critique (`card-studio`) | §7 | App Hub's `card-studio` crate merged ([App-Hub#19](https://github.com/OctoSense-org/OctoSense-App-Hub/pull/19)); the skill is planned ([#65](https://github.com/OctoSense-org/OctoSense/issues/65)). | -| M7: the per-app conversation (approvals, questions), memory | §9, §10 | Planned: [#66](https://github.com/OctoSense-org/OctoSense/issues/66). | +| M7: the per-app conversation (approvals, questions), memory | §9, §10 | Planned: [#66](https://github.com/OctoSense-org/OctoSense/issues/66). The shell-side approval sheets and consent exist ([#120](https://github.com/OctoSense-org/OctoSense/pull/120)). | | M8: the outer loop (overlays on `AGENT.md`) | §11 | Planned: [#67](https://github.com/OctoSense-org/OctoSense/issues/67). | The tracking issue is [#68](https://github.com/OctoSense-org/OctoSense/issues/68). App Hub's [PUBLISHING § The app's agent and tools](https://github.com/OctoSense-org/OctoSense-App-Hub/blob/main/docs/PUBLISHING.md#the-apps-agent-and-tools) has the file formats. @@ -209,10 +219,10 @@ The tracking issue is [#68](https://github.com/OctoSense-org/OctoSense/issues/68 ### Desktop, with a throwaway kernel and profile -1. Build octos at the revision the root `Cargo.toml` pins (`a6ea8505`), in a checkout of [octos-org/octos](https://github.com/octos-org/octos): +1. Build octos at the revision the root `Cargo.toml` pins (`5e7577f0`), in a checkout of [octos-org/octos](https://github.com/octos-org/octos) (the flags are `tools/kernel-artifact.py`'s; **unverified** at this revision in this page's refresh): ```sh - git checkout a6ea8505170735f191a12bb47629a6728415d417 + git checkout 5e7577f0cb92cf618b965745434b0200e2b82865 cargo build --release -p octos-cli --bin octos --no-default-features --features api,git,ast ``` @@ -230,7 +240,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 and in-process; open it from the launcher; sign in to Matrix, then use its assistant), or AppCard (`--features app-appcard`). A contained app reaches it through the `octos` host service ([above](#contained-script-apps-system-and-store)). +4. Use the assistant through a consumer: Rinx (linked by default and in-process; open it from the launcher; sign in to Matrix, allow its agent on the first-use sheet, then use its assistant), or AppCard (`--features app-appcard`). A contained app reaches it through the `octos` host service ([above](#contained-script-apps-system-and-store)); add `OCTOSENSE_CONTAINED_APPS=1` to the command in step 2 and allow the app's agent when the shell asks. **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. @@ -240,6 +250,7 @@ The tracking issue is [#68](https://github.com/OctoSense-org/OctoSense/issues/68 cargo test --locked -p octosense-kernel # against a stand-in kernel cargo test --locked -p octosense-app-peers --features octos-core,ws # the broker, scripted kernel cargo test -p octosense-ai-host --features octos-core,llm +cargo test --locked -p octosense-shell --lib approvals # router, rules, sheets, consent, contacts # The real kernel (build it as in step 1): OCTOS_CORE_TEST_KERNEL=/path/to/octos cargo test -p octosense-kernel --test real_kernel -- --nocapture OCTOS_APP_PEERS_TEST_KERNEL=/path/to/octos cargo test -p octosense-app-peers --features octos-core --test real_kernel -- --nocapture @@ -259,6 +270,11 @@ OCTOS_APP_PEERS_TEST_KERNEL=/path/to/octos cargo test -p octosense-app-peers --f | Shell entry point, policy, offer | [`crates/ai-host/src/lib.rs`](../crates/ai-host/src/lib.rs) | | App peers: contract, broker, shell side | [`crates/app-peers/src`](../crates/app-peers/src) (`contract.rs`, `broker.rs`, `hosted.rs`) | | `llm` service, vault, sheets | [`apps/ai-providers/host-service/src`](../apps/ai-providers/host-service/src) | +| `octos` service for contained apps | [`crates/ai-host/src/contained.rs`](../crates/ai-host/src/contained.rs) | +| `model` service | [`apps/ai-providers/host-service/src/complete`](../apps/ai-providers/host-service/src/complete) | | Host services a shell registers | `crates/shell/src/apps.rs` (`register_host_services`), [`crates/shell/src/glance.rs`](../crates/shell/src/glance.rs) | +| Approvals, consent, contacts, Settings page; developer mode | [`crates/shell/src/approvals/`](../crates/shell/src/approvals/mod.rs), [`crates/shell/src/dev_mode.rs`](../crates/shell/src/dev_mode.rs) | +| The system agent's tool set | [`crates/kernel/src/system_tools.rs`](../crates/kernel/src/system_tools.rs) | +| Everything else (native app manifest, hosting, storage, protocols) | [architecture § Source map](architecture.md#source-map) | | Capabilities, `tools.json`, agent fields | OctoSense-App-Hub [`crates/app-policy/src`](https://github.com/OctoSense-org/OctoSense-App-Hub/tree/main/crates/app-policy/src) (`manifest.rs`, `services.rs`, `agent.rs`) | | Rinx's assistant and mini-app adapter | hagency-org/Rinx `src/assistant/`, `src/host/octos.rs` | diff --git a/docs/ai-services.zh-CN.md b/docs/ai-services.zh-CN.md index 97936f2f..ad96ba34 100644 --- a/docs/ai-services.zh-CN.md +++ b/docs/ai-services.zh-CN.md @@ -2,11 +2,11 @@ [English](ai-services.md) | 简体中文 -助手如何接入 OctoSense:Shell 运行的 [octos](https://github.com/octos-org/octos) Agent 内核、用户在哪里配置它、哪些应用可以使用以及如何使用,还有哪些在规划中。本文描述 2026-09-27 的 `main`(OctoSense `405139f`,它锁定 octos `a6ea8505` 和 OctoSense-App-Hub `46d67e51`)。标为**目前可用**的内容都在这份代码中读过;**进行中**表示有未合并的 pull request;**规划中**表示只有 ADR 或未关闭的 issue,尚无代码合入。 +助手如何接入 OctoSense:Shell 运行的 [octos](https://github.com/octos-org/octos) Agent 内核、用户在哪里配置它、哪些应用可以使用以及如何使用,还有哪些在规划中。本文描述 2026-09-28 的 `main`(OctoSense `ad0d738`,它锁定 octos `5e7577f0` 和 OctoSense-App-Hub `e8601b80`)。标为**目前可用**的内容都在这份代码中读过;**进行中**表示有未合并的 pull request;**规划中**表示只有 ADR 或未关闭的 issue,尚无代码合入。 本文讨论的是 OctoSense *内部*的助手。开发应用不需要任何 AI 服务,也不需要特定的编程 Agent:应用开发工具集 [OctoScript-App-Design-Flow](https://github.com/OctoSense-org/OctoScript-App-Design-Flow) 适用于任何 Agent,也可以不用 Agent。它的 [AI-SERVICES](https://github.com/OctoSense-org/OctoScript-App-Design-Flow/blob/main/docs/AI-SERVICES.zh-CN.md) 页面是本文面向应用开发者的简短版本。 -它所处的整个系统(各平台的进程、Agent、协议、工具与授权、审批、存储和信任边界)见 [OctoSense 架构](architecture.zh-CN.md)。 +它所处的整个系统(各平台的进程、Agent、协议、工具与授权、审批、存储和信任边界)见 [OctoSense 架构](architecture.zh-CN.md)。本文不重复那些内容:原生应用清单(`native-apps.json`)、系统 Agent 的工具集、审批路由、首次使用同意和开发者模式都在那里描述,这里只给出链接。 ## 目录 @@ -25,13 +25,17 @@ | 每个 Shell 进程一个 octos 内核,首次使用时启动,提供方变更后重启 | 目前可用(设置了 `OCTOS_APP_CORE_BIN` 的桌面端、Android、OpenHarmony;iOS 没有) | [`crates/kernel`](../crates/kernel/README.md) | | AI providers:用户的模型提供方和密钥,密钥只在宿主面板上输入 | 目前可用 | [`apps/ai-providers`](../apps/ai-providers/host-service/README.md) | | Shell 的 AI 入口(`start`、策略、按实例提供服务、QR 导入) | 目前可用 | [`crates/ai-host`](../crates/ai-host/README.md) | -| 宿主拥有的应用 peer:每个获授权的原生应用一个 octos peer,归系统 Agent 所有 | 目前可用,仅限原生模块(Rinx) | [`crates/app-peers`](../crates/app-peers/README.md)、[Rinx ADR 0007](https://github.com/hagency-org/Rinx/blob/main/docs/adr/0007-host-owned-octos-app-peers.md) | +| 宿主拥有的应用 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 中可用:`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` 服务尚未实现 | [见下文](#隔离运行的脚本应用系统应用和商店应用) | +| 隔离运行的脚本应用(系统应用或商店应用)向助手提问 | 在托管了内核的 Shell 中可用([#106](https://github.com/OctoSense-org/OctoSense/pull/106)):`octos` 宿主服务为每个应用分配自己的 peer(`card.<应用 id>`),前提是 `Policy::contained_apps` 为开(**默认关闭**;`OCTOSENSE_CONTAINED_APPS=1`),且用户在首次使用时允许了该应用的 Agent([#120](https://github.com/OctoSense-org/OctoSense/pull/120))。peer 发起的工具审批仍会被拒绝。`llm` 仍是只供 `os.*` 应用使用的提供方管理服务 | [见下文](#隔离运行的脚本应用系统应用和商店应用) | +| 供隔离应用使用的一次性模型调用(`model`,`model.complete`) | 目前可用([#95](https://github.com/OctoSense-org/OctoSense/pull/95)):由 `crates/ai-host` 与 `llm` 服务一同注册,供获得 `model` 权限的应用使用([App-Hub#24](https://github.com/OctoSense-org/OctoSense-App-Hub/pull/24),已在 Shell 锁定的 App Hub 中) | [见下文](#隔离运行的脚本应用系统应用和商店应用) | | 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) | -| 应用自己的 Agent:`tools.json`、`AGENT.md`、skills、模型需求、触发器 | 规划中([ADR 0002](adr/0002-event-driven-app-agents.md),Proposed)。App Hub 接受这些文件([App-Hub#18](https://github.com/OctoSense-org/OctoSense-App-Hub/pull/18));没有 Shell 运行它们;内核一侧尚未合并([octos#2567](https://github.com/octos-org/octos/pull/2567)) | [见下文](#规划中事件驱动的应用-agentadr-0002) | +| `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) | +| 审批、首次使用同意、开发者模式 | 目前可用([#120](https://github.com/OctoSense-org/OctoSense/pull/120)、[#118](https://github.com/OctoSense-org/OctoSense/pull/118)):Shell 的审批路由、常设规则和审批面板,目前只接收 AI 服务总线的调用;应用的 Agent 首次运行前征求同意;开发者模式只能由用户打开 | [架构 § 审批](architecture.zh-CN.md#5-审批) | +| 系统 Agent 的工具集 | 目前可用,部分强制([#117](https://github.com/OctoSense-org/OctoSense/pull/117)):从不提供 octos 的 shell;精确列表要等 octos#2567 | [架构 § 工具与授权](architecture.zh-CN.md#4-工具与授权) | +| 原生应用只声明一次(`native-apps.json`),按目标平台决定托管方式;Terminal 作为系统应用在桌面端以独立进程运行 | 目前可用([#113](https://github.com/OctoSense-org/OctoSense/pull/113)) | [架构 § 原生应用](architecture.zh-CN.md#原生应用进程内还是独立进程) | +| 应用自己的 Agent:`tools.json`、`AGENT.md`、skills、模型需求、触发器 | 规划中([ADR 0002](adr/0002-event-driven-app-agents.md),Proposed)。App Hub 接受这些文件([App-Hub#18](https://github.com/OctoSense-org/OctoSense-App-Hub/pull/18));没有 Shell 运行它们;内核一侧进行中([octos#2567](https://github.com/octos-org/octos/pull/2567),草稿) | [见下文](#规划中事件驱动的应用-agentadr-0002) | +| 独立进程中的原生应用访问自己的 Agent(peer link,ADR 0004 §5) | 规划中(计划第 8 步)。Makepad 的客户端一侧已合入([OctoSense-org/makepad#54](https://github.com/OctoSense-org/makepad/pull/54),`makepad_ai_services::peer`);Shell 一侧尚未实现(`crates/shell/src/hub.rs` 中没有) | [架构 § 应用与它自己的 Agent](architecture.zh-CN.md#应用与它自己的-agent) | ## 架构 @@ -44,7 +48,9 @@ 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、octos"] + runner["Card runner(App Hub)
宿主服务:mail、news、glance、llm、model、octos"] + router["审批路由
crates/shell/src/approvals"] + bus["AI 服务总线
crates/shell/src/ai_bus.rs"] rinx["Rinx(原生模块)"] appcard["AppCard(原生,需主动开启)"] scripts["脚本应用(系统和商店)"] @@ -63,7 +69,10 @@ flowchart TB rinx -- "受限的 OctosAppService" --> broker appcard -- "自己的连接" --> kernel scripts -- "host.request" --> runner - runner -. "没有 octos.* 服务" .- kernel + runner -- "octos.* 服务
peer card.<应用 id>" --> broker + rinx -- "类型化工具" --> bus + bus -- "confirm: host 调用" --> router + router --> person ``` ### octos 内核:每个 Shell 一个 @@ -96,7 +105,7 @@ flowchart TB [`crates/ai-host`](../crates/ai-host/README.md)(`octosense-ai-host`)是两个 Shell 都调用的入口:启动时调用 `start(Host::platform(data_dir))`(配置内核、安装宿主策略、注册带平台 QR 导入能力的 `llm`),每个事件调用 `handle_event`,在原生模块 `create` 前后调用 `offer(module, scope)` / `finish()`,退出时调用 `shutdown()`。 -宿主**策略**按精确的 `octos.*` 服务名决定哪些原生模块可以使用助手。`Policy::shipped()` 只把这四个服务授予 `rinx`;用户目前还没有按应用的开关。 +宿主**策略**按精确的 `octos.*` 服务名决定哪些原生模块可以使用助手,以及隔离应用是否能使用 `octos` 服务。`Policy::shipped()` 只把这四个服务授予 `rinx`,并且只有设置 `OCTOSENSE_CONTAINED_APPS=1` 时才打开隔离应用的开关(`Policy::contained_apps`)。用户按应用的开关是**首次使用同意**([#120](https://github.com/OctoSense-org/OctoSense/pull/120),`crates/shell/src/approvals/consent.rs`):应用第一次请求它的 Agent 时,Shell 显示它可以读取和使用什么、模型在哪里运行;**设置 → 助手 → 审批**列出每个应用的 Agent,并带有关闭开关([架构 § 审批](architecture.zh-CN.md#5-审批))。 ### `crates/app-peers`:宿主拥有的应用 peer @@ -110,7 +119,7 @@ flowchart TB ### 系统 Agent -目前系统 Agent 是所有应用 peer 的**所有者**:创建它们并能与之通信的会话。它不持有任何应用的工具,也无法回答应用 peer 的审批(octos [#2560](https://github.com/octos-org/octos/pull/2560),已包含在锁定的 `a6ea8505` 中)。它在 ADR 0002 中更大的角色(监督应用 Agent、预算、紧急停止、对 glance 屏幕排序、改进应用 Agent)还在**规划中**;glance 屏幕目前按优先级和时间排序卡片。 +目前系统 Agent 是所有应用 peer 的**所有者**:创建它们并能与之通信的会话。它不持有任何应用的工具,也无法回答应用 peer 的审批(octos [#2560](https://github.com/octos-org/octos/pull/2560))。它的工具是 Shell 在每次启动内核前写入的一个确定集合,从不提供 octos 的 shell([#117](https://github.com/OctoSense-org/OctoSense/pull/117),`crates/kernel/src/system_tools.rs`;见[架构 § 工具与授权](architecture.zh-CN.md#4-工具与授权))。目前用户通过 Talk to Octos 客户端与它对话([#98](https://github.com/OctoSense-org/OctoSense/pull/98),[ADR 0003](adr/0003-shared-octos-client-access.md));Shell 还没有为它绘制自己的对话界面。它在 ADR 0002 中更大的角色(监督应用 Agent、预算、紧急停止、对 glance 屏幕排序、改进应用 Agent)还在**规划中**;glance 屏幕目前按优先级和时间排序卡片。 ### Shell 中的其他助手 @@ -123,7 +132,7 @@ flowchart TB | --- | --- | | **密钥留在宿主。** | 只有 `llm` 服务读写密钥;密钥保存在平台密钥存储或内核 core 目录下仅所有者可读的配置中,从不进入应用的 jail。app-peers 约定中不携带任何凭据(`ModelInfo` 中没有)。没有任何 `octos.*` 服务允许应用选择提供方或提交密钥。 | | **机密归宿主所有。** | 应用不收集密码、PIN、密钥或一次性验证码,即使只是转交也不行。运行时让受管控隔离环境中的密码输入框失效,App Hub 的准入检查拒绝声明了此类输入框的应用包,服务在自己的面板上询问(`.sheet.*` 只接受来自面板的调用)。 | -| **审批属于用户,在所属应用中进行。** | 应用上下文中发起的工具审批只能由该应用自己的界面通过 `ContextOp::Approval` 回答;系统 Agent 从不替应用审批(app-peers 策略,octos #2560)。Rinx 的 `send_message` 会在 Rinx 自己的面板上显示房间和确切文字,只有用户确认才发送。 | +| **审批属于用户,在所属应用中进行。** | 应用上下文中发起的工具审批只能由该应用自己的界面通过 `ContextOp::Approval` 回答;系统 Agent 从不替应用审批(app-peers 策略,octos #2560)。Rinx 的 `send_message` 会在 Rinx 自己的面板上显示房间和确切文字,只有用户确认才发送。Shell 的审批路由([#120](https://github.com/OctoSense-org/OctoSense/pull/120))只凭用户的操作作答:在 Shell 绘制的面板上当场确认,或按用户自己建立的常设规则;“我的联系人”类规则只有在用户打开“在审批规则中使用我的联系人”之后才会使用联系人([架构 § 审批](architecture.zh-CN.md#5-审批))。开发者模式为它覆盖的应用回答所有审批,只能由用户打开([#118](https://github.com/OctoSense-org/OctoSense/pull/118))。 | | **系统 Agent 通过应用的 Agent 驱动应用。** | 它拥有 peer,但不持有应用工具;应用的工具在应用中运行(Rinx:`src/assistant`,“系统 Agent 自己从不持有这些工具,而是请求 Rinx 的 peer”)。 | | **记忆按应用和账号私有。** | 每个 peer 的记忆命名空间是 `app//acct-`;一个账号的上下文从不在另一个账号下恢复。提升到共享记忆还在规划中(ADR 0002 §9)。 | | **最小权限,按精确名称。** | 应用得到的是 `声明 ∩ 支持 ∩ 宿主策略` 的服务,按精确名称比较:`octos.` 或 `octos.admin` 不授予任何权限。 | @@ -162,10 +171,10 @@ flowchart TB | --- | --- | --- | | `mail` | 获得 `mail` 授权的任何应用 | 用户在宿主面板上登录的邮件账号 | | `llm` | 仅 `os.*` 应用 | 管理助手的提供方(`llm.providers`、`llm.add_provider`、`llm.test`、`llm.import_qr` 等);**没有提示词或补全方法** | -| `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` 为开(在首次使用同意功能落地前,出厂策略为关,见 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 的回复会被拒绝 | +| `news` | 仅 `os.*` 应用(`apps/news/host-service/src/lib.rs` 中的 `may_call`) | News 的数据服务(订阅源、已读记录、`news.list`、`news.read` 等),不使用模型。Shell 锁定的 App Hub 已认识 `news` 权限,但 News 应用包还没有声明它(`apps/news/bundle/manifest.json`),所以仍自己抓取 | +| `glance` | 获得 `glance` 权限的应用([#86](https://github.com/OctoSense-org/OctoSense/pull/86)、[App-Hub#22](https://github.com/OctoSense-org/OctoSense-App-Hub/pull/22)) | 以应用自己的 id 向 glance 屏幕发布 L0 卡片(`crates/shell/src/glance.rs`) | +| `model` | 获得 `model` 权限的应用([App-Hub#24](https://github.com/OctoSense-org/OctoSense-App-Hub/pull/24)) | 一次性的 `model.complete {task, input, schema, class?, allow_urls?}` 和 `model.budget`([#95](https://github.com/OctoSense-org/OctoSense/pull/95),`apps/ai-providers/host-service/src/complete/`,在 `crates/ai-host/src/lib.rs` 中注册):`class` 为 `fast` 或 `strong`;宿主从用户的提供方中挑选模型,按 schema 校验回复,除非请求否则拒绝含 URL 的回复,按应用管理每日预算;没有工具、记忆和历史,应用也看不到任何密钥 | +| `octos` | manifest 声明了确切 `octos.*` 名称的任何应用;前提是 Shell 托管了内核、`Policy::contained_apps` 为开(**默认关闭**;设置 `OCTOSENSE_CONTAINED_APPS=1` 可试用),且用户在首次使用时允许了该应用的 Agent(`consent_for_contained`,[#120](https://github.com/OctoSense-org/OctoSense/pull/120)) | 助手:经应用自己的、由宿主拥有的 peer `card.<应用 id>`(`crates/ai-host/src/contained.rs`,[#106](https://github.com/OctoSense-org/OctoSense/pull/106))。四个 `octos.*` 调用沿用 Rinx 的参数规则(`octos.turn.start` 只接受 `text`,非空白、最多 32 KiB;其余只接受 `{}`)。peer 发起的工具审批会被拒绝并列在回复的 `denied_approvals` 中(在 octos#2567 之前,Shell 的审批路由不接收内核审批);开发者模式下由它为覆盖的应用作答(`crates/app-peers/src/host_approvals.rs`)。超过 2 MiB 的回复会被拒绝 | 因此,隔离运行的应用(商店应用或系统应用)只能通过 `octos.*` 使用助手,且需要 Shell 托管了内核。它会收到: @@ -175,6 +184,7 @@ flowchart TB | 已获授权的 `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` | +| 用户尚未允许该应用的 Agent 时的 `octos.*` | `r.error`:`Waiting for the person to allow this app's agent (OctoSense asks the first time)`,同时 Shell 显示首次使用面板(在 `contained.rs` 和 `approvals/mod.rs` 中读到;未在运行的 Shell 中验证,**unverified**) | | 在未设置 `OCTOS_APP_CORE_BIN` 的桌面端调用 `octos.*` | `r.error`:`no octos kernel: no kernel binary configured (OCTOS_APP_CORE_BIN)` | | 在不链接内核的构建(iOS)中调用 `octos.*` | `r.error`:`no service answers "octos" on this device`(**未验证**) | | 获得 `llm` 授权的商店应用调用 `llm.*` | `llm is for OctoSense's own apps.` | @@ -191,16 +201,16 @@ manifest 的 `agent` 字段(权限档位、通用工具、迭代和 token 上 | 组成部分 | ADR | 状态 | | --- | --- | --- | | `tools.json`:名为 `.` 的工具,JSON Schema 输入输出,`risk`(`read`、`act`、`destructive`),`confirm`(`host` 或 `app`),`background`、`shareable`、`implemented_by`(`host-service` 或 `app`) | §4、§12 | App Hub 接受并锁定([App-Hub#18](https://github.com/OctoSense-org/OctoSense-App-Hub/pull/18),`crates/app-policy/src/agent.rs`)。目前没有 Shell 加载它。 | -| `AGENT.md`、只含数据的 skills、模型**需求**(从不写提供方或模型名)、`background`、`triggers`(cron、宿主服务事件) | §2、§3 | 同上:App Hub `main` 已接受。Shell 锁定的 App Hub `46d67e51` 早于这些字段;manifest 拒绝未知字段,所以在锁定版本更新之前,**目前的 Shell 会拒绝使用这些字段的 manifest**。 | -| 内核:按 peer 由宿主注册工具(`peer/tools/register`、`peer/tool/call`/`result`/`cancel`),强制工具列表和风险等级,审批只在宿主连接上进行,通用工具白名单 | §4、§13 | 尚未合并:[octos#2567](https://github.com/octos-org/octos/pull/2567)(UPCR-2026-035)。 | -| 风险决定监督方式:read 和应用内 act 无人值守运行;destructive 等待用户(`confirm: host`:内核的审批路径;`confirm: app`:用户在场时用应用自己的面板,不在场时在应用的对话中生成审批请求) | §4、§12 | 已在 App Hub(#18)和 octos#2567 中声明;OctoSense 尚未强制执行。Rinx 的 `send_message` 已经在自己的界面上按 `confirm: app` 的方式工作。 | +| `AGENT.md`、只含数据的 skills、模型**需求**(从不写提供方或模型名)、`background`、`triggers`(cron、宿主服务事件) | §2、§3 | 同上:App Hub 已接受(`crates/app-policy/src/agent.rs`),Shell 锁定的 App Hub `e8601b80` 已包含这些字段。目前还没有 Shell 运行它们。 | +| 内核:按 peer 由宿主注册工具(`peer/tools/register`、`peer/tool/call`/`result`/`cancel`),强制工具列表和风险等级,审批只在宿主连接上进行,通用工具白名单,`peer/input` | §4、§13 | 进行中:[octos#2567](https://github.com/octos-org/octos/pull/2567)(UPCR-2026-035,草稿)。Shell 一侧是 ADR 0004 计划第 6 步。 | +| 风险决定监督方式:read 和应用内 act 无人值守运行;destructive 等待用户(`confirm: host`:内核的审批路径;`confirm: app`:用户在场时用应用自己的面板,不在场时在应用的对话中生成审批请求) | §4、§12 | 已在 App Hub(#18)和 octos#2567 中声明。Shell 的审批路由、审批面板和常设规则已合入([#120](https://github.com/OctoSense-org/OctoSense/pull/120)),接收 AI 服务总线的 `confirm: host` 调用;内核工具调用要等 octos#2567 合入后才会进入([架构 § 审批](architecture.zh-CN.md#5-审批))。Rinx 的 `send_message` 已经在自己的界面上按 `confirm: app` 的方式工作。 | | News M1:`news` 数据服务(不使用模型) | 第一个切片 | 已合入([#69](https://github.com/OctoSense-org/OctoSense/pull/69));issue [#60](https://github.com/OctoSense-org/OctoSense/issues/60)。 | | News M2:`os.news` 获得 peer,并注册它的工具 | 第一个切片 | 规划中:[#61](https://github.com/OctoSense-org/OctoSense/issues/61)。 | | News M3:触发器和无人值守运行 | 第一个切片 | 规划中:[#62](https://github.com/OctoSense-org/OctoSense/issues/62)。 | -| News M4:glance 卡片(`glance.publish`、glance 页面和桌面面板) | §7、§8 | `glance.publish` 已合入([#72](https://github.com/OctoSense-org/OctoSense/pull/72));进行中:供隔离应用使用的 `glance` 权限([#86](https://github.com/OctoSense-org/OctoSense/pull/86),配合 [App-Hub#22](https://github.com/OctoSense-org/OctoSense-App-Hub/pull/22))和 `sys.digest` 数据源([#87](https://github.com/OctoSense-org/OctoSense/pull/87));issue [#63](https://github.com/OctoSense-org/OctoSense/issues/63)。 | -| M5:系统工具箱(检索、抓取)作为可授予的工具 | §6 | 规划中:[#64](https://github.com/OctoSense-org/OctoSense/issues/64);进行中:[#82](https://github.com/OctoSense-org/OctoSense/pull/82)(工作流模板)、[octos#2568](https://github.com/octos-org/octos/pull/2568)(检索引擎)。 | +| News M4:glance 卡片(`glance.publish`、glance 页面和桌面面板) | §7、§8 | `glance.publish`([#72](https://github.com/OctoSense-org/OctoSense/pull/72))和供隔离应用使用的 `glance` 权限([#86](https://github.com/OctoSense-org/OctoSense/pull/86),配合 [App-Hub#22](https://github.com/OctoSense-org/OctoSense-App-Hub/pull/22))已合入;进行中:`sys.digest` 数据源([#87](https://github.com/OctoSense-org/OctoSense/pull/87),草稿);issue [#63](https://github.com/OctoSense-org/OctoSense/issues/63)。 | +| M5:系统工具箱(检索、抓取)作为可授予的工具 | §6 | 规划中:[#64](https://github.com/OctoSense-org/OctoSense/issues/64)。已合入:工作流模板([#82](https://github.com/OctoSense-org/OctoSense/pull/82),`crates/toolbox`)和 octos 的检索引擎([octos#2568](https://github.com/octos-org/octos/pull/2568));进行中:把工具箱提供给应用 Agent([#108](https://github.com/OctoSense-org/OctoSense/pull/108),草稿)。 | | M6:渲染与评审(`card-studio`) | §7 | App Hub 的 `card-studio` crate 已合入([App-Hub#19](https://github.com/OctoSense-org/OctoSense-App-Hub/pull/19));对应 skill 规划中([#65](https://github.com/OctoSense-org/OctoSense/issues/65))。 | -| M7:每个应用的对话(审批、提问)、记忆 | §9、§10 | 规划中:[#66](https://github.com/OctoSense-org/OctoSense/issues/66)。 | +| M7:每个应用的对话(审批、提问)、记忆 | §9、§10 | 规划中:[#66](https://github.com/OctoSense-org/OctoSense/issues/66)。Shell 一侧的审批面板和同意已存在([#120](https://github.com/OctoSense-org/OctoSense/pull/120))。 | | M8:外层循环(叠加在 `AGENT.md` 上的 overlay) | §11 | 规划中:[#67](https://github.com/OctoSense-org/OctoSense/issues/67)。 | 跟踪 issue 是 [#68](https://github.com/OctoSense-org/OctoSense/issues/68)。文件格式见 App Hub 的 [PUBLISHING § The app's agent and tools](https://github.com/OctoSense-org/OctoSense-App-Hub/blob/main/docs/PUBLISHING.md#the-apps-agent-and-tools)。 @@ -209,10 +219,10 @@ manifest 的 `agent` 字段(权限档位、通用工具、迭代和 token 上 ### 桌面端:使用临时的内核与配置 -1. 在 [octos-org/octos](https://github.com/octos-org/octos) 的检出中,构建根 `Cargo.toml` 锁定的版本(`a6ea8505`): +1. 在 [octos-org/octos](https://github.com/octos-org/octos) 的检出中,构建根 `Cargo.toml` 锁定的版本(`5e7577f0`;参数与 `tools/kernel-artifact.py` 相同;本次更新未在此版本上运行,**unverified**): ```sh - git checkout a6ea8505170735f191a12bb47629a6728415d417 + git checkout 5e7577f0cb92cf618b965745434b0200e2b82865 cargo build --release -p octos-cli --bin octos --no-default-features --features api,git,ast ``` @@ -230,7 +240,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(默认链接并在进程内运行;从启动器打开它;登录 Matrix 后使用它的助手),或 AppCard(`--features app-appcard`)。隔离运行的应用通过 `octos` 宿主服务访问它([见上文](#隔离运行的脚本应用系统应用和商店应用))。 +4. 通过某个使用者来使用助手:Rinx(默认链接并在进程内运行;从启动器打开它;登录 Matrix,在首次使用面板上允许它的 Agent,然后使用它的助手),或 AppCard(`--features app-appcard`)。隔离运行的应用通过 `octos` 宿主服务访问它([见上文](#隔离运行的脚本应用系统应用和商店应用));在第 2 步的命令中加上 `OCTOSENSE_CONTAINED_APPS=1`,并在 Shell 询问时允许该应用的 Agent。 **隐藏窗口。** 加上 `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 面板做同样的事。 @@ -240,6 +250,7 @@ manifest 的 `agent` 字段(权限档位、通用工具、迭代和 token 上 cargo test --locked -p octosense-kernel # 使用替身内核 cargo test --locked -p octosense-app-peers --features octos-core,ws # broker,脚本化内核 cargo test -p octosense-ai-host --features octos-core,llm +cargo test --locked -p octosense-shell --lib approvals # 路由、规则、审批面板、同意、联系人 # 真实内核(按第 1 步构建): OCTOS_CORE_TEST_KERNEL=/path/to/octos cargo test -p octosense-kernel --test real_kernel -- --nocapture OCTOS_APP_PEERS_TEST_KERNEL=/path/to/octos cargo test -p octosense-app-peers --features octos-core --test real_kernel -- --nocapture @@ -259,6 +270,11 @@ OCTOS_APP_PEERS_TEST_KERNEL=/path/to/octos cargo test -p octosense-app-peers --f | Shell 入口、策略、offer | [`crates/ai-host/src/lib.rs`](../crates/ai-host/src/lib.rs) | | 应用 peer:约定、broker、Shell 一侧 | [`crates/app-peers/src`](../crates/app-peers/src)(`contract.rs`、`broker.rs`、`hosted.rs`) | | `llm` 服务、密钥库、面板 | [`apps/ai-providers/host-service/src`](../apps/ai-providers/host-service/src) | +| 隔离应用的 `octos` 服务 | [`crates/ai-host/src/contained.rs`](../crates/ai-host/src/contained.rs) | +| `model` 服务 | [`apps/ai-providers/host-service/src/complete`](../apps/ai-providers/host-service/src/complete) | | Shell 注册的宿主服务 | `crates/shell/src/apps.rs`(`register_host_services`)、[`crates/shell/src/glance.rs`](../crates/shell/src/glance.rs) | +| 审批、同意、联系人、设置页面;开发者模式 | [`crates/shell/src/approvals/`](../crates/shell/src/approvals/mod.rs)、[`crates/shell/src/dev_mode.rs`](../crates/shell/src/dev_mode.rs) | +| 系统 Agent 的工具集 | [`crates/kernel/src/system_tools.rs`](../crates/kernel/src/system_tools.rs) | +| 其他(原生应用清单、托管、存储、协议) | [架构 § 源码位置](architecture.zh-CN.md#源码位置) | | 权限、`tools.json`、agent 字段 | OctoSense-App-Hub [`crates/app-policy/src`](https://github.com/OctoSense-org/OctoSense-App-Hub/tree/main/crates/app-policy/src)(`manifest.rs`、`services.rs`、`agent.rs`) | | Rinx 的助手和迷你应用适配器 | hagency-org/Rinx `src/assistant/`、`src/host/octos.rs` | diff --git a/docs/architecture.md b/docs/architecture.md index 6292d04f..185975f4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -308,7 +308,7 @@ flowchart TB 1. **Developer mode** (`dev_hooks.rs`, `crates/shell/src/dev_mode.rs`, [#118](https://github.com/OctoSense-org/OctoSense/pull/118)) approves everything of the apps it covers, `auto_approvable: false` and `confirm: app` included, never for an external client. Only the person turns it on: a development build with `OCTOSENSE_DEV_MODE=all` (or a list of apps), a release build only with `--dev-grant-all`, or the desktop's Developer options with a typed phrase; store builds never. It shows a banner, audits every call, and ends after 8 hours or at restart outside a developer profile. `dev.run` is not registered yet (step 11). 2. **`confirm: app`** tools go to the owning app's own sheet (an `AppConfirm` the app registers with `register_app_confirm`), with the caller; no rule answers them. An app that has not registered one gets 120 s (`app_wait_s`), then the call is refused visibly. No app registers one on `main` yet; Rinx's send sheet still answers inside Rinx. 3. **`auto_approvable: false`**, **outcome unknown** and **external clients'** calls always go to the person. -4. **Standing rules** (`rules.rs`), keyed on **(owning app, tool)** whoever calls, with conditions on the exact arguments (recipients in contacts or in the thread, no attachments, triggered by the person, count or amount limits; a condition fails when the call lacks the fact), a daily cap (20 by default for a tool rule), a time box of at most 60 minutes for the broadest rule ("everything this app asks for the next hour"; there is no "everything, forever"), and one "all rules off". The shell has no contacts source yet, so "recipients in contacts" never matches today. Runs started by incoming content are skipped unless a rule opts in. The person creates rules in Settings → Assistant → Approvals (`settings_page.rs`) or from a sheet; the system agent may only suggest one. +4. **Standing rules** (`rules.rs`), keyed on **(owning app, tool)** whoever calls, with conditions on the exact arguments (recipients in contacts or in the thread, no attachments, triggered by the person, count or amount limits; a condition fails when the call lacks the fact), a daily cap (20 by default for a tool rule), a time box of at most 60 minutes for the broadest rule ("everything this app asks for the next hour"; there is no "everything, forever"), and one "all rules off". Contacts come from Mail's host service data (the person's own accounts and the addresses they sent mail to; `contacts.rs`), and only after the person turns on "Use my contacts in approval rules" in Settings (off by default); until then "recipients in contacts" never matches. OS address books are a follow-up. Runs started by incoming content are skipped unless a rule opts in. The person creates rules in Settings → Assistant → Approvals (`settings_page.rs`) or from a sheet; the system agent may only suggest one. 5. Otherwise a **shell-drawn sheet** (`sheet.rs`, `view.rs`): each line shows the owning app, the tool, the exact arguments and, for a cross-app call, the calling app; the system agent's `confirm: host` approvals of one request may be batched into one sheet in its chat. Every decision reaches the relay once and the **audit** (`audit.rs`: one JSON line per decision in `/logs/approvals-audit.jsonl`, owner-only, with a digest of the arguments rather than the arguments; developer mode keeps its own full audit in `logs/dev-audit.jsonl`); every automatic one is also a notification. Rules are in `approvals/rules.json`, consent in `approvals/consent.json`, under the OctoSense home. @@ -417,7 +417,7 @@ sequenceDiagram | 3 | octos delivers the input to the shell as `peer/input`; the shell starts the turn on Calendar's peer with `turn/start`, so it has Calendar's tools, memory and account context. If Calendar's peer has no host connection, the system agent is told the app is not connected. | In progress ([octos#2567](https://github.com/octos-org/octos/pull/2567)); shell side planned (step 6) | | 4 | Calendar's agent calls `calendar.create_event` (its own tool) and Mail's shareable `mail.send` once per invitee. Each call reaches the shell as `peer/tool/call`; the shell stamps the caller (Calendar's agent), account and context. | Planned (ADR 0004 §5, §7; step 6) | | 5 | The shell checks that Calendar's manifest was granted `mail.send` (at install for a script app); no second, agent-level consent. | Planned (step 6) | -| 6 | `mail.send` is outward and `confirm: host`, so the approval router takes it: developer mode off; not `confirm: app`; not `auto_approvable: false`; a standing rule on (Mail, `mail.send`) such as "send to my contacts" approves it (notified and audited; the contacts condition needs a contacts source the shell does not have yet), else one batched shell sheet shows each invitation: owning app Mail, tool `mail.send`, calling app Calendar, the exact arguments. | Router, rules, sheets and audit on main ([#120](https://github.com/OctoSense-org/OctoSense/pull/120)); fed by kernel tool calls once octos#2567 lands | +| 6 | `mail.send` is outward and `confirm: host`, so the approval router takes it: developer mode off; not `confirm: app`; not `auto_approvable: false`; a standing rule on (Mail, `mail.send`) such as "send to my contacts" approves it (notified and audited; the contacts condition holds only once the person turned on "Use my contacts in approval rules"), else one batched shell sheet shows each invitation: owning app Mail, tool `mail.send`, calling app Calendar, the exact arguments. | Router, rules, sheets and audit on main ([#120](https://github.com/OctoSense-org/OctoSense/pull/120)); fed by kernel tool calls once octos#2567 lands | | 7 | On approval the shell hands each call to Mail's host service, which sends with the account the person signed in to on Mail's host sheet (the password never reaches the agent), and returns the result to the kernel as `peer/tool/result`. | Mail's host service on main (`apps/mail/host-service`); its tools as agent tools planned | | 8 | Calendar's turn ends and octos writes `peers//result.md` and `turns.txt`; the system agent reads it with `peer_gather`. A failed invitation is named, and one with an unknown outcome is never retried without the person. | Blackboard on main (octos) | | 9 | The system agent announces "Booked Tue 3 pm; invitations sent to 3". Calendar's and Mail's own UIs show the change because their data changed. | Planned | @@ -430,7 +430,7 @@ Found while writing this page (2026-09-28); none is fixed here, as this change i 2. **Restart of a dead process app.** ADR 0004 §2 says its tile shows it closed with a restart. The shell removes the client and posts "App stopped"; the Restart face exists only for in-process modules (`module_view.rs`). 3. **Agent workspace = account folder.** ADR 0004 §11 names it the `peer/prepare` `cwd`. The broker sends no `cwd` to `peer/prepare` and binds the peer to the kernel-provisioned workspace; the storage API's `agent_workspace` is not connected to it. The account folder name (`account_hash`, SHA-256) and the memory namespace tag (FNV-1a in `broker.rs`) are also different hashes of the account. 4. **ADR 0003 "What the profile runs"** says OctoSense configures neither the tool set nor the sandbox. Since [#117](https://github.com/OctoSense-org/OctoSense/pull/117) the shell writes a `tool_policy` denying `group:runtime` into the `_main` profile before every start, so the host's own turns have no octos shell either. -5. **Stale context lines.** Fixed: ADR 0004's context table and the README layout tables no longer list the native News, Maps and Photos modules ADR 0004 step 1 deleted (#113). Still open: [ai-services.md](ai-services.md) is dated 2026-09-27: its architecture diagram shows "no `octos.*` service" for the Card runner (merged in #106), and it says contained apps are off "until first-use consent lands" (consent landed in #120; the `Policy::contained_apps` switch is still off by default). +5. **Stale context lines.** Fixed: ADR 0004's context table and the README layout tables no longer list the native News, Maps and Photos modules ADR 0004 step 1 deleted (#113), and [ai-services.md](ai-services.md) now describes `main` on 2026-09-28 (the Card runner's `octos` service from #106, consent from #120; the `Policy::contained_apps` switch is still off by default). 6. **Command execution "turned on in Settings".** Fixed in the docs: `crates/kernel/README.md` and `system_tools.rs` now say it is planned (no Settings switch sets `SystemAgentTools::grant_command_execution`; the host tool needs octos#2567). 7. **`host::processes_available()`'s test** checks only `wasm32` while the function also excludes native mobile. 8. **Approvals, ADR 0004 §8.** Every app tool call is to reach the router through `peer/tool/call`; today only the AI bus feeds it (above). No app registers its own `confirm: app` sheet, so such a call would wait and be refused. The audit keeps a digest of the arguments, not the arguments. The send queue and undo window are not built. diff --git a/docs/architecture.zh-CN.md b/docs/architecture.zh-CN.md index 548966bb..fc9febce 100644 --- a/docs/architecture.zh-CN.md +++ b/docs/architecture.zh-CN.md @@ -308,7 +308,7 @@ flowchart TB 1. **开发者模式**(`dev_hooks.rs`、`crates/shell/src/dev_mode.rs`,[#118](https://github.com/OctoSense-org/OctoSense/pull/118))批准它所覆盖应用的一切,包括 `auto_approvable: false` 和 `confirm: app`,但从不替外部客户端批准。只有用户能开启它:开发构建中用 `OCTOSENSE_DEV_MODE=all`(或应用列表),发布构建只能用 `--dev-grant-all`,或在桌面端的 Developer options 中输入确认短语;商店构建永远不能。开启时显示横幅,审计每次调用,不在开发者 profile 中时 8 小时后或重启时自动结束。`dev.run` 尚未注册(步骤 11)。 2. **`confirm: app`** 工具交给所属应用自己的面板(应用通过 `register_app_confirm` 注册的 `AppConfirm`),并附上调用方;规则不回答它们。未注册面板的应用有 120 秒(`app_wait_s`),之后调用被明确拒绝。`main` 上目前还没有应用注册面板;Rinx 的发送面板仍在 Rinx 内部作答。 3. **`auto_approvable: false`**、**结果未知**和**外部客户端**的调用总是交给用户。 -4. **常设规则**(`rules.rs`),以**(所属应用,工具)**为键,不论谁调用;可以对确切参数设条件(收件人在联系人中或在该会话中、无附件、由用户触发、次数或金额上限;调用缺少所需信息时条件不成立),有每日上限(工具规则默认 20 次),范围最宽的规则(“这个应用接下来一小时的所有请求”)最多 60 分钟(没有“一切、永久”的规则),还有一个“关闭所有规则”。Shell 目前还没有联系人来源,因此“收件人在联系人中”目前从不匹配。由收到的内容触发的运行会被跳过,除非规则明确包含。用户在 Settings → Assistant → Approvals(`settings_page.rs`)中或从面板上创建规则;系统 Agent 只能建议。 +4. **常设规则**(`rules.rs`),以**(所属应用,工具)**为键,不论谁调用;可以对确切参数设条件(收件人在联系人中或在该会话中、无附件、由用户触发、次数或金额上限;调用缺少所需信息时条件不成立),有每日上限(工具规则默认 20 次),范围最宽的规则(“这个应用接下来一小时的所有请求”)最多 60 分钟(没有“一切、永久”的规则),还有一个“关闭所有规则”。联系人来自 Mail 宿主服务的数据(用户自己的账户,以及用户发过邮件的地址;`contacts.rs`),并且只有在用户于设置中打开“在审批规则中使用我的联系人”(默认关闭)之后才会使用;在此之前“收件人在联系人中”从不匹配。系统通讯录是后续工作。由收到的内容触发的运行会被跳过,除非规则明确包含。用户在 Settings → Assistant → Approvals(`settings_page.rs`)中或从面板上创建规则;系统 Agent 只能建议。 5. 否则显示 **Shell 绘制的面板**(`sheet.rs`、`view.rs`):每一行显示所属应用、工具、确切参数,跨应用调用时还显示调用方应用;系统 Agent 同一请求的 `confirm: host` 审批可以合并到它对话中的一个面板。 每个决定都只向中继发送一次,并写入**审计**(`audit.rs`:`/logs/approvals-audit.jsonl` 中每个决定一行 JSON,仅所有者可读,记录参数的摘要而不是参数本身;开发者模式在 `logs/dev-audit.jsonl` 中另有完整审计);每个自动决定也会作为通知告知用户。规则存放在 `approvals/rules.json`,同意记录在 `approvals/consent.json`,都在 OctoSense 主目录下。 @@ -417,7 +417,7 @@ sequenceDiagram | 3 | octos 把输入以 `peer/input` 送到 Shell;Shell 在 Calendar 的 peer 上用 `turn/start` 启动回合,使其带有 Calendar 的工具、记忆和账号上下文。如果 Calendar 的 peer 没有宿主连接,系统 Agent 会被告知该应用未连接。 | 进行中([octos#2567](https://github.com/octos-org/octos/pull/2567));Shell 一侧规划中(步骤 6) | | 4 | Calendar 的 Agent 调用 `calendar.create_event`(自己的工具),并为每位受邀者调用一次 Mail 可共享的 `mail.send`。每次调用都以 `peer/tool/call` 到达 Shell;Shell 盖上调用方(Calendar 的 Agent)、账号和上下文。 | 规划中(ADR 0004 §5、§7;步骤 6) | | 5 | Shell 检查 Calendar 的清单是否获授权 `mail.send`(脚本应用在安装时授权);不需要第二道 Agent 级别的同意。 | 规划中(步骤 6) | -| 6 | `mail.send` 是对外的 `confirm: host` 工具,由审批路由处理:开发者模式未开启;不是 `confirm: app`;不是 `auto_approvable: false`;如果有针对(Mail,`mail.send`)的常设规则(例如“发给我的联系人”),就由规则批准(通知并审计;联系人条件需要 Shell 目前还没有的联系人来源),否则一个合并的 Shell 面板列出每封邀请:所属应用 Mail、工具 `mail.send`、调用方应用 Calendar 以及确切参数。 | 路由、规则、面板和审计已在 main([#120](https://github.com/OctoSense-org/OctoSense/pull/120));octos#2567 合入后由内核的工具调用提交请求 | +| 6 | `mail.send` 是对外的 `confirm: host` 工具,由审批路由处理:开发者模式未开启;不是 `confirm: app`;不是 `auto_approvable: false`;如果有针对(Mail,`mail.send`)的常设规则(例如“发给我的联系人”),就由规则批准(通知并审计;只有用户打开了“在审批规则中使用我的联系人”,联系人条件才会成立),否则一个合并的 Shell 面板列出每封邀请:所属应用 Mail、工具 `mail.send`、调用方应用 Calendar 以及确切参数。 | 路由、规则、面板和审计已在 main([#120](https://github.com/OctoSense-org/OctoSense/pull/120));octos#2567 合入后由内核的工具调用提交请求 | | 7 | 批准后,Shell 把每次调用交给 Mail 的宿主服务,它用用户在 Mail 宿主面板上登录的账号发送(密码永远不会到达 Agent),并把结果以 `peer/tool/result` 返回内核。 | Mail 的宿主服务已在 main(`apps/mail/host-service`);把它的工具作为 Agent 工具还在规划中 | | 8 | Calendar 的回合结束,octos 写入 `peers//result.md` 和 `turns.txt`;系统 Agent 用 `peer_gather` 读取。失败的邀请会被点名,结果未知的邀请未经用户同意绝不重试。 | 黑板已在 main(octos) | | 9 | 系统 Agent 宣布“已预订周二下午 3 点;已向 3 人发送邀请”。Calendar 和 Mail 自己的界面会显示变化,因为它们的数据变了。 | 规划中 | @@ -430,7 +430,7 @@ sequenceDiagram 2. **崩溃进程应用的重启。** ADR 0004 §2 说它的磁贴会显示已关闭并提供重启。Shell 实际上移除客户端并发出 “App stopped” 通知;Restart 界面只用于进程内模块(`module_view.rs`)。 3. **Agent 工作区 = 账号目录。** ADR 0004 §11 说它就是 `peer/prepare` 的 `cwd`。broker 不向 `peer/prepare` 发送 `cwd`,把 peer 绑定到内核分配的工作区;存储 API 的 `agent_workspace` 没有与之连接。账号目录名(`account_hash`,SHA-256)和记忆命名空间标签(`broker.rs` 中的 FNV-1a)也是对账号的两种不同 hash。 4. **ADR 0003 的 “What the profile runs”** 说 OctoSense 既不配置工具集也不配置沙箱。自 [#117](https://github.com/OctoSense-org/OctoSense/pull/117) 起,Shell 每次启动前都向 `_main` profile 写入拒绝 `group:runtime` 的 `tool_policy`,因此宿主自己的回合也没有 octos shell。 -5. **过时的背景描述。** 已修复:ADR 0004 的背景表格和各 README 的目录结构表不再列出 ADR 0004 步骤 1 删除的原生 News、Maps 和 Photos 模块(#113)。仍未修复:[ai-services.zh-CN.md](ai-services.zh-CN.md) 的日期是 2026-09-27:其架构图显示 Card runner “没有 `octos.*` 服务”(#106 已合入),并说脚本应用“在首次使用同意落地之前”关闭(同意机制已在 #120 落地;`Policy::contained_apps` 开关默认仍关闭)。 +5. **过时的背景描述。** 已修复:ADR 0004 的背景表格和各 README 的目录结构表不再列出 ADR 0004 步骤 1 删除的原生 News、Maps 和 Photos 模块(#113)。[ai-services.zh-CN.md](ai-services.zh-CN.md) 现已描述 2026-09-28 的 `main`(#106 的 Card runner `octos` 服务、#120 的同意机制;`Policy::contained_apps` 开关默认仍关闭)。 6. **“在 Settings 中开启”命令执行。** 文档已修复:`crates/kernel/README.md` 和 `system_tools.rs` 现在说明它尚在计划中(没有 Settings 开关调用 `SystemAgentTools::grant_command_execution`;宿主工具需要 octos#2567)。 7. **`host::processes_available()` 的测试**只检查 `wasm32`,而函数本身还排除了原生移动平台。 8. **审批,ADR 0004 §8。** 每次应用工具调用都应通过 `peer/tool/call` 到达路由;目前只有 AI 总线向它提交请求(见上文)。没有应用注册自己的 `confirm: app` 面板,因此这类调用会等待后被拒绝。审计记录的是参数摘要而不是参数。发送队列和撤销窗口尚未实现。