diff --git a/crates/ai-host/src/contained.rs b/crates/ai-host/src/contained.rs index f3ae00ad..a05354e3 100644 --- a/crates/ai-host/src/contained.rs +++ b/crates/ai-host/src/contained.rs @@ -8,10 +8,17 @@ //! host-owned peer contract Rinx uses (ADR 0007), named `card.` so a //! store app can never share a native module's peer or memory. //! -//! What an app sends is input text only. It never names a session, profile, -//! workspace or provider, and it cannot decide a tool approval: the Card -//! runner has no approval sheet yet, so every approval the peer raises is -//! declined here and listed in the reply's `denied_approvals`. +//! What an app sends is input text (and what started the turn) only. It +//! never names a session, profile, workspace or provider, and it cannot +//! decide a tool approval: the shell's approval router draws every one +//! (ADR 0004 §8). +//! +//! **Consent and grants** (ADR 0004 §4). The service follows +//! [`crate::Policy::contained_gate`]: behind the person's consent at first +//! use (the shipped default), off, or on for every app (the developer +//! override `OCTOSENSE_CONTAINED_APPS=1`). An app's peer is granted only +//! the `octos.*` services its manifest declares ([`declared`]), and turning +//! its agent off in Settings releases the live peer at once ([`revoke`]). //! //! The logic does not need a kernel: peers come from a [`PeerFactory`]. The //! shell's factory (`cfg(kernel)`) launches them through @@ -20,7 +27,7 @@ use octosense_app_peers::{ContextEvent, ContextOp, ContextSpec, EventSink, OctosAppService, OctosContext, TurnTrigger}; use octosense_appstore::services::{HostService, Replier, ServiceCall, ServiceHost}; use serde_json::Value; -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap}; use std::sync::{Arc, Mutex, Weak}; /// A contained app's peer is `card.`; no native module id starts so. @@ -40,6 +47,7 @@ pub const UNSUPPORTED_ARGS: &str = "Unsupported Octos arguments"; pub const BAD_TEXT: &str = "Provide text (at most 32 KiB)"; pub const NO_SHEET: &str = "The assistant has no sheet; octos calls come from the app"; pub const NO_CONSENT: &str = "Waiting for the person to allow this app's agent (OctoSense asks the first time)"; +pub const NOT_DECLARED: &str = "This app's manifest does not declare that assistant service"; /// The shell's consent at first use (ADR 0004 §4): whether an app may have /// its agent now; asking the person the first time is the shell's part. @@ -52,11 +60,57 @@ pub fn set_consent(check: fn(&str) -> bool) { let _ = CONSENT.set(check); } +/// The `octos.*` services an app's manifest declares (`None`: the shell +/// knows no such app). Unset (this crate's tests): all of them. +static DECLARED: std::sync::OnceLock Option>> = std::sync::OnceLock::new(); + +/// The shell installs its manifest lookup once, at startup. +pub fn set_declared(lookup: fn(&str) -> Option>) { + let _ = DECLARED.set(lookup); +} + +/// What `app_id` may be granted: the `octos.*` services its manifest +/// declares, and only those (ADR 0004 §4). +pub fn declared(app_id: &str) -> Result, String> { + let all = || octosense_app_peers::OCTOS_SERVICES.iter().map(|s| s.to_string()).collect::>(); + let services = match DECLARED.get() { + None => all(), + Some(lookup) => lookup(app_id).ok_or(NOT_DECLARED)?.intersection(&all()).cloned().collect(), + }; + if services.is_empty() { + return Err(NOT_DECLARED.into()); + } + Ok(services) +} + +/// Every contained app's live peer, by app id, so turning its agent off +/// revokes it at once ([`revoke`]). +static LIVE: Mutex>>> = Mutex::new(None); + +fn live(f: impl FnOnce(&mut HashMap>) -> R) -> R { + f(LIVE.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(HashMap::new)) +} + +/// The person turned `app_id`'s agent off (Settings): its peer is released +/// now, closing its contexts and any running turn. A later call needs +/// consent again and then gets a fresh peer. True when one was live. +pub fn revoke(app_id: &str) -> bool { + let service = live(|l| l.remove(app_id)); + match service { + Some(service) => { + service.release(); + true + } + None => false, + } +} + /// Where contained apps' peers come from. pub trait PeerFactory: Send + Sync { - /// The scoped assistant service for `app_id`'s peer `peer_id`, or `None` - /// when this device cannot give one. - fn launch(&self, peer_id: &str, app_id: &str) -> Option>; + /// The scoped assistant service for `app_id`'s peer `peer_id`, with + /// exactly `services` (its manifest's `octos.*`), or `None` when this + /// device cannot give one. + fn launch(&self, peer_id: &str, app_id: &str, services: &BTreeSet) -> Option>; } /// The peer id of `app_id`, or why it cannot name one. @@ -117,24 +171,34 @@ struct AppPeer { /// The `octos` host service for the Card runner's apps. pub struct ContainedOctos { - enabled: bool, + gate: crate::ContainedGate, factory: Arc, apps: HashMap, } impl ContainedOctos { - /// `enabled` is the shell's switch ([`crate::Policy::contained_apps`]). + /// `enabled`: on behind consent, or off ([`crate::Policy::contained_apps`]). pub fn new(enabled: bool, factory: Arc) -> Self { - ContainedOctos { enabled, factory, apps: HashMap::new() } + Self::gated(if enabled { crate::ContainedGate::Consent } else { crate::ContainedGate::Off }, factory) + } + + /// With the policy's gate ([`crate::Policy::contained_gate`]). + pub fn gated(gate: crate::ContainedGate, factory: Arc) -> Self { + ContainedOctos { gate, factory, apps: HashMap::new() } } /// The app's open request context, creating its peer and (re)opening /// the context as needed. - fn context_for(&mut self, app_id: &str) -> Result, String> { + fn context_for(&mut self, app_id: &str, services: &BTreeSet) -> Result, String> { + // A peer revoked since (Settings turned the agent off) is gone. + if self.apps.contains_key(app_id) && !live(|l| l.contains_key(app_id)) { + self.apps.remove(app_id); + } if !self.apps.contains_key(app_id) { let peer = peer_id(app_id)?; - let service = self.factory.launch(&peer, app_id).ok_or(UNAVAILABLE)?; + let service = self.factory.launch(&peer, app_id, services).ok_or(UNAVAILABLE)?; service.set_account(Some(ACCOUNT)); + live(|l| l.insert(app_id.to_owned(), service.clone())); self.apps.insert(app_id.to_owned(), AppPeer { peer, service, context: None, generation: 0 }); } let app = self.apps.get_mut(app_id).expect("inserted above"); @@ -220,17 +284,27 @@ impl HostService for ContainedOctos { if call.from_sheet { return reply.send(Err(NO_SHEET.into())); } - if !self.enabled { - return reply.send(Err(TURNED_OFF.into())); - } - if CONSENT.get().is_some_and(|granted| !granted(&call.app_id)) { - return reply.send(Err(NO_CONSENT.into())); + match self.gate { + crate::ContainedGate::Off => return reply.send(Err(TURNED_OFF.into())), + // The developer override asks nobody. + crate::ContainedGate::Everyone => {} + crate::ContainedGate::Consent => { + if CONSENT.get().is_some_and(|granted| !granted(&call.app_id)) { + return reply.send(Err(NO_CONSENT.into())); + } + } } let op = match parse(&call.service, &call.args) { Ok(op) => op, Err(e) => return reply.send(Err(e)), }; - let context = match self.context_for(&call.app_id) { + // Only what the manifest declares, whatever the isolate let through. + let services = match declared(&call.app_id) { + Ok(s) if s.contains(&call.service) => s, + Ok(_) => return reply.send(Err(NOT_DECLARED.into())), + Err(e) => return reply.send(Err(e)), + }; + let context = match self.context_for(&call.app_id, &services) { Ok(context) => context, Err(e) => return reply.send(Err(e)), }; @@ -247,10 +321,11 @@ pub(crate) struct KernelPeers; #[cfg(kernel)] impl PeerFactory for KernelPeers { - fn launch(&self, peer_id: &str, app_id: &str) -> Option> { - let services = octosense_app_peers::OCTOS_SERVICES; - crate::host_policy().allow(peer_id, services); - let broker = octosense_app_peers::hosted::launch(peer_id, app_id, services, crate::host_policy())?; + fn launch(&self, peer_id: &str, app_id: &str, services: &BTreeSet) -> Option> { + // Only the manifest's `octos.*` services, never all of them. + let services: Vec<&str> = services.iter().map(String::as_str).collect(); + crate::host_policy().allow(peer_id, services.iter().copied()); + let broker = octosense_app_peers::hosted::launch(peer_id, app_id, services.iter().copied(), crate::host_policy())?; Some(Arc::new(broker)) } } diff --git a/crates/ai-host/src/contained/tests.rs b/crates/ai-host/src/contained/tests.rs index 4190111d..adcbf1e0 100644 --- a/crates/ai-host/src/contained/tests.rs +++ b/crates/ai-host/src/contained/tests.rs @@ -85,6 +85,7 @@ struct FakeService { accounts: Mutex>>, specs: Mutex>, contexts: Mutex>>, + released: AtomicBool, } impl OctosAppService for FakeService { @@ -117,7 +118,12 @@ impl OctosAppService for FakeService { self.contexts.lock().unwrap().push(context.clone()); Ok(context) } - fn release(&self) {} + fn release(&self) { + self.released.store(true, Ordering::SeqCst); + for c in self.contexts.lock().unwrap().iter() { + c.open.store(false, Ordering::SeqCst); + } + } fn shutdown(&self) {} } @@ -127,11 +133,13 @@ struct Peers { turn: Turn, close_after_call: bool, launched: Mutex)>>, + /// The services each launch was granted. + granted: Mutex)>>, } impl Peers { fn new(turn: Turn) -> Arc { - Arc::new(Peers { give: true, turn, close_after_call: false, launched: Mutex::default() }) + Arc::new(Peers { give: true, turn, close_after_call: false, launched: Mutex::default(), granted: Mutex::default() }) } fn ids(&self) -> Vec { self.launched.lock().unwrap().iter().map(|(id, _)| id.clone()).collect() @@ -145,7 +153,8 @@ impl Peers { } impl PeerFactory for Peers { - fn launch(&self, peer_id: &str, _app_id: &str) -> Option> { + fn launch(&self, peer_id: &str, _app_id: &str, services: &BTreeSet) -> Option> { + self.granted.lock().unwrap().push((peer_id.to_owned(), services.clone())); if !self.give { return None; } @@ -155,6 +164,7 @@ impl PeerFactory for Peers { accounts: Mutex::default(), specs: Mutex::default(), contexts: Mutex::default(), + released: AtomicBool::new(false), }); self.launched.lock().unwrap().push((peer_id.to_owned(), service.clone())); Some(service) @@ -312,7 +322,7 @@ fn contained_peer_ids_are_namespaced_and_bounded() { #[test] fn contained_unavailable_when_no_peer() { let _g = serial(); - let peers = Arc::new(Peers { give: false, turn: Turn::Reply(json!({})), close_after_call: false, launched: Mutex::default() }); + let peers = Arc::new(Peers { give: false, turn: Turn::Reply(json!({})), close_after_call: false, launched: Mutex::default(), granted: Mutex::default() }); register(true, &peers); let err = ask(APP, "octos.session.open", json!({}), false).unwrap_err(); assert_eq!(err, UNAVAILABLE); @@ -321,7 +331,7 @@ fn contained_unavailable_when_no_peer() { #[test] fn contained_reopens_closed_context() { let _g = serial(); - let peers = Arc::new(Peers { give: true, turn: Turn::Reply(json!({})), close_after_call: true, launched: Mutex::default() }); + let peers = Arc::new(Peers { give: true, turn: Turn::Reply(json!({})), close_after_call: true, launched: Mutex::default(), granted: Mutex::default() }); register(true, &peers); ask(APP, "octos.session.open", json!({}), false).expect("first"); ask(APP, "octos.session.open", json!({}), false).expect("second"); @@ -376,11 +386,59 @@ fn contained_rejects_oversized_reply() { } #[test] -fn policy_contained_apps_defaults() { - // Off until first-use consent exists (ADR 0004 section 4), unless a - // developer turns it on. - if std::env::var("OCTOSENSE_CONTAINED_APPS").as_deref() != Ok("1") { - assert!(!crate::Policy::shipped().contained_apps()); +fn policy_contained_apps_follow_consent() { + use crate::{contained_gate_from, ContainedGate}; + // On behind first-use consent (ADR 0004 section 4); the variable is a + // developer override either way. + assert_eq!(contained_gate_from(None), ContainedGate::Consent); + assert_eq!(contained_gate_from(Some("1")), ContainedGate::Everyone); + assert_eq!(contained_gate_from(Some("0")), ContainedGate::Off); + if std::env::var("OCTOSENSE_CONTAINED_APPS").is_err() { + assert_eq!(crate::Policy::shipped().contained_gate(), ContainedGate::Consent); + assert!(crate::Policy::shipped().contained_apps()); } assert!(!crate::Policy::none().contained_apps()); } + +/// The shell's manifest lookup, as a test sets it once for the whole crate: +/// every app declares everything but these two. +fn manifests(app: &str) -> Option> { + match app { + "com.example.reader" => Some(["octos.session.open", "octos.session.history", "not.octos"].iter().map(|s| s.to_string()).collect()), + "com.example.unknown" => None, + _ => Some(OCTOS_SERVICES.iter().map(|s| s.to_string()).collect()), + } +} + +#[test] +fn contained_apps_get_only_the_octos_services_their_manifest_declares() { + let _g = serial(); + set_declared(manifests); + let peers = Peers::new(Turn::Reply(json!({"turn_id": "t1", "text": "ok"}))); + register(true, &peers); + ask("com.example.reader", "octos.session.open", json!({}), false).expect("declared"); + let granted = peers.granted.lock().unwrap().clone(); + assert_eq!(granted.len(), 1); + assert_eq!(granted[0].1, ["octos.session.history", "octos.session.open"].iter().map(|s| s.to_string()).collect::>(), "never all four"); + let err = ask("com.example.reader", "octos.turn.start", json!({"text": "hi"}), false).unwrap_err(); + assert_eq!(err, NOT_DECLARED); + let err = ask("com.example.unknown", "octos.session.open", json!({}), false).unwrap_err(); + assert_eq!(err, NOT_DECLARED); + assert_eq!(peers.ids(), vec!["card.com.example.reader".to_string()], "no peer for an app the shell does not know"); +} + +#[test] +fn turning_an_agent_off_releases_its_live_peer_at_once() { + let _g = serial(); + let peers = Peers::new(Turn::Reply(json!({"turn_id": "t1", "text": "ok"}))); + register(true, &peers); + ask(APP, "octos.turn.start", json!({"text": "hi"}), false).expect("a reply"); + let first = peers.service("card.com.example.trip"); + assert!(revoke(APP), "the peer was live"); + assert!(first.released.load(Ordering::SeqCst), "released now, not at the next launch"); + assert!(first.contexts.lock().unwrap().iter().all(|c| !c.open.load(Ordering::SeqCst)), "its contexts are closed"); + assert!(!revoke(APP), "once"); + // Allowed again later: a fresh peer, never the revoked one. + ask(APP, "octos.turn.start", json!({"text": "again"}), false).expect("a reply"); + assert_eq!(peers.ids().len(), 2); +} diff --git a/crates/ai-host/src/lib.rs b/crates/ai-host/src/lib.rs index a14b0f7f..b6e181d3 100644 --- a/crates/ai-host/src/lib.rs +++ b/crates/ai-host/src/lib.rs @@ -121,13 +121,28 @@ impl KernelSource { } } +/// Whether contained apps (the Card runner's) get the `octos` service. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ContainedGate { + /// Nobody. + #[default] + Off, + /// Each app once the person allowed its agent (`consent::granted`, + /// ADR 0004 §4; the first call asks), with only the `octos.*` services + /// its manifest declares. + Consent, + /// Every app, without asking: `OCTOSENSE_CONTAINED_APPS=1`, a developer + /// override. Still only the declared services. + Everyone, +} + /// Which native modules may use the assistant, and with which `octos.*` /// services (exact names; others are ignored). #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Policy { grants: Vec<(String, Vec)>, /// Whether contained apps (the Card runner's) get the `octos` service. - contained_apps: bool, + contained: ContainedGate, } impl Policy { @@ -140,24 +155,33 @@ impl Policy { /// host serves these to reviewed mini apps). The person's AI provider /// choice lives in AI providers; a per-app toggle is future work. /// - /// The `octos` service for contained apps is OFF in the shipped policy - /// until the person consents at first use (ADR 0004 section 4). - /// `OCTOSENSE_CONTAINED_APPS=1` turns it on for development and tests. + /// The `octos` service for contained apps follows consent in the + /// shipped policy: an app gets its agent once the person allowed it at + /// first use (ADR 0004 section 4), with the services its manifest + /// declares. `OCTOSENSE_CONTAINED_APPS` is the developer override: `1` + /// on for every app without asking, `0` off. pub fn shipped() -> Self { - let contained = std::env::var("OCTOSENSE_CONTAINED_APPS").is_ok_and(|v| v == "1"); - Policy::none().allow("rinx", octosense_app_peers::OCTOS_SERVICES).with_contained_apps(contained) + let gate = contained_gate_from(std::env::var("OCTOSENSE_CONTAINED_APPS").ok().as_deref()); + Policy::none().allow("rinx", octosense_app_peers::OCTOS_SERVICES).with_contained_gate(gate) } - /// Turn the `octos` service for contained apps on or off (the shell's - /// switch; an app still gets only the services its manifest declares). - pub fn with_contained_apps(mut self, on: bool) -> Self { - self.contained_apps = on; + /// On (behind consent) or off. + pub fn with_contained_apps(self, on: bool) -> Self { + self.with_contained_gate(if on { ContainedGate::Consent } else { ContainedGate::Off }) + } + + pub fn with_contained_gate(mut self, gate: ContainedGate) -> Self { + self.contained = gate; self } - /// Whether contained apps get the `octos` service. + /// Whether contained apps can get the `octos` service at all. pub fn contained_apps(&self) -> bool { - self.contained_apps + self.contained != ContainedGate::Off + } + + pub fn contained_gate(&self) -> ContainedGate { + self.contained } /// Also grant `module` these services. @@ -171,6 +195,16 @@ impl Policy { } } +/// `OCTOSENSE_CONTAINED_APPS`'s value as a gate: `1` every app (developer +/// override), `0` none, anything else (unset) consent. +pub fn contained_gate_from(var: Option<&str>) -> ContainedGate { + match var { + Some("1") => ContainedGate::Everyone, + Some("0") => ContainedGate::Off, + _ => ContainedGate::Consent, + } +} + /// What a shell tells [`start`]. #[derive(Clone, Debug)] pub struct Host { @@ -296,12 +330,12 @@ pub fn core_dir(data_dir: Option) -> Option { fn register_contained(kernel: bool, policy: &Policy) { #[cfg(kernel)] if kernel { - let on = policy.contained_apps(); - octosense_appstore::services::register_host_service(Box::new(contained::ContainedOctos::new( - on, + let gate = policy.contained_gate(); + octosense_appstore::services::register_host_service(Box::new(contained::ContainedOctos::gated( + gate, std::sync::Arc::new(contained::KernelPeers), ))); - log!("octos: contained apps' service registered ({})", if on { "on" } else { "off" }); + log!("octos: contained apps' service registered ({gate:?})"); return; } let _ = (kernel, policy); diff --git a/crates/shell/src/approvals/consent.rs b/crates/shell/src/approvals/consent.rs index a14e8058..718e5c7c 100644 --- a/crates/shell/src/approvals/consent.rs +++ b/crates/shell/src/approvals/consent.rs @@ -103,12 +103,15 @@ pub struct ConsentStore { known: BTreeMap, /// First-use prompts waiting for the person, oldest first. asking: Vec, + /// Apps whose agent was just turned off: the shell revokes their live + /// services ([`ConsentStore::take_revoked`]). + revoked: Vec, generation: u64, } impl ConsentStore { pub fn memory() -> ConsentStore { - ConsentStore { path: None, decided: BTreeMap::new(), known: BTreeMap::new(), asking: Vec::new(), generation: 0 } + ConsentStore { path: None, decided: BTreeMap::new(), known: BTreeMap::new(), asking: Vec::new(), revoked: Vec::new(), generation: 0 } } pub fn in_home(home: &Path) -> ConsentStore { let path = home.join(CONSENT_FILE); @@ -157,6 +160,9 @@ impl ConsentStore { } /// The person chose, on the first-use sheet or Settings' switch. pub fn set(&mut self, _gesture: &ApprovalGesture, app: &str, allowed: bool, now: u64) { + if !allowed { + self.revoke(app); + } self.decided.insert(app.to_string(), Record { allowed, at: now }); self.asking.retain(|a| a != app); self.generation += 1; @@ -164,11 +170,23 @@ impl ConsentStore { } /// Turning an agent off needs no gesture (always allowed). pub fn turn_off(&mut self, app: &str, now: u64) { + self.revoke(app); self.decided.insert(app.to_string(), Record { allowed: false, at: now }); self.asking.retain(|a| a != app); self.generation += 1; self.save(); } + fn revoke(&mut self, app: &str) { + if !self.revoked.iter().any(|a| a == app) { + self.revoked.push(app.to_string()); + } + } + /// The apps whose agent was turned off since the last call: the shell + /// closes their live services (peer links and contexts, an in-process + /// module's service, a contained app's peer) and withdraws the offer. + pub fn take_revoked(&mut self) -> Vec { + std::mem::take(&mut self.revoked) + } /// Settings: every app's agent the shell knows of or has an answer for. pub fn agents(&self) -> Vec<(String, String, State)> { let mut apps: Vec = self.known.keys().cloned().collect(); diff --git a/crates/shell/src/approvals/mod.rs b/crates/shell/src/approvals/mod.rs index 1e9ca293..22bded0d 100644 --- a/crates/shell/src/approvals/mod.rs +++ b/crates/shell/src/approvals/mod.rs @@ -141,8 +141,10 @@ pub fn with(f: impl FnOnce(&mut Approvals) -> R) -> Option { pub fn init(home: &Path) { let a = Approvals::in_home(home); *STATE.lock().unwrap_or_else(|e| e.into_inner()) = Some(a); - // Contained apps' `octos` service asks consent at first use too. + // Contained apps' `octos` service asks consent at first use too, and + // grants only the `octos.*` services the app's manifest declares. crate::ai_host::contained::set_consent(consent_for_contained); + crate::ai_host::contained::set_declared(crate::apps::declared_octos); } /// For tests and headless runs: approvals kept in memory only. @@ -273,9 +275,28 @@ pub fn take_notices() -> Vec { pub fn generation() -> u64 { with(|a| a.generation()).unwrap_or(0) } -/// Settings → Assistant → Approvals. +/// Settings → Assistant → Approvals: every app that declares an agent is +/// listed with its switch, whether or not it has asked yet. pub fn open_settings() { - with(|a| a.settings_open = true); + let apps = crate::apps::agent_apps(); + with(|a| { + register_agents(a, &apps); + a.settings_open = true; + }); +} + +/// Settings learns of every app that declares an agent. +pub fn register_agents(a: &mut Approvals, apps: &[crate::apps::AgentApp]) { + for app in apps { + let summary = consent::AgentSummary::from_manifest(&app.id, &app.name, &app.manifest, &app.octos, "The model set in AI providers"); + a.consent.register(summary); + } +} + +/// The apps whose agent the person just turned off (Settings, or a denial +/// on the first-use sheet): the shell revokes their live services. +pub fn take_revoked() -> Vec { + with(|a| a.consent.take_revoked()).unwrap_or_default() } pub fn close_settings() { with(|a| a.settings_open = false); diff --git a/crates/shell/src/approvals/tests.rs b/crates/shell/src/approvals/tests.rs index 6c5214e6..b86df0b0 100644 --- a/crates/shell/src/approvals/tests.rs +++ b/crates/shell/src/approvals/tests.rs @@ -723,3 +723,38 @@ fn no_rule_matches_an_external_client_even_if_the_caller_is_mislabelled() { assert_eq!(r.front_sheet().unwrap().lines[0].surfaced, Surfaced::External); assert!(relay.take().is_empty()); } + +// ---------------------------------------------------------------- consent (G10, G5) + +#[test] +fn turning_an_agent_off_queues_its_revocation_once() { + let mut c = ConsentStore::memory(); + c.set(&ApprovalGesture::settings_tap(), "rinx", true, T0); + assert!(c.take_revoked().is_empty(), "allowing revokes nothing"); + c.turn_off("rinx", T0 + 1); + c.turn_off("rinx", T0 + 2); + c.set(&ApprovalGesture::sheet_tap(), "os.mail", false, T0 + 3); + assert_eq!(c.take_revoked(), vec!["rinx".to_string(), "os.mail".to_string()]); + assert!(c.take_revoked().is_empty()); + assert!(!c.granted("rinx", false)); +} + +#[test] +fn settings_lists_every_app_that_declares_an_agent_before_it_asks() { + let mut a = super::Approvals::memory(); + let dir = std::env::temp_dir().join(format!("octosense-agent-apps-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("with.json"), r#"{"id":"org.example.trip","capabilities":["storage","octos.session.open","octos.turn.start"]}"#).unwrap(); + std::fs::write(dir.join("without.json"), r#"{"id":"org.example.clock","capabilities":["storage"]}"#).unwrap(); + let trip = crate::apps::script_agent_app(&dir.join("with.json"), "org.example.trip", "Trip").expect("declares octos.*"); + assert_eq!(trip.octos, vec!["octos.session.open".to_string(), "octos.turn.start".to_string()]); + assert!(crate::apps::script_agent_app(&dir.join("without.json"), "org.example.clock", "Clock").is_none()); + let mut apps = crate::apps::agent_apps(); + assert!(apps.iter().any(|a| a.id == "rinx"), "native apps from native-apps.json"); + apps.push(trip); + super::register_agents(&mut a, &apps); + let listed: Vec<(String, State)> = a.consent.agents().into_iter().map(|(id, _, s)| (id, s)).collect(); + assert!(listed.contains(&("rinx".to_string(), State::Undecided)), "{listed:?}"); + assert!(listed.contains(&("org.example.trip".to_string(), State::Undecided)), "listed before it ever asked: {listed:?}"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/shell/src/apps.rs b/crates/shell/src/apps.rs index 23e550af..fd31e394 100644 --- a/crates/shell/src/apps.rs +++ b/crates/shell/src/apps.rs @@ -178,6 +178,96 @@ fn register_news() { octosense_news_service::register_with(options); } +/// One app that declares an agent, for Settings and consent (ADR 0004 §4). +#[derive(Clone, Debug, PartialEq)] +pub struct AgentApp { + /// The consent key: a native app's id (`rinx`), a script app's manifest + /// id (`os.mail`, `org.example.timer`). + pub id: String, + pub name: String, + /// The `octos.*` services it declares. + pub octos: Vec, + /// Its manifest, as the first-use sheet reads it. + pub manifest: serde_json::Value, +} + +/// Every app that declares an agent: native apps whose `native-apps.json` +/// entry grants `octos.*`, and script apps (system and installed) whose +/// manifest declares `octos.*`, whether or not they have asked yet. +pub fn agent_apps() -> Vec { + let mut out: Vec = crate::native_apps::APPS + .iter() + .filter(|a| !a.octos.is_empty()) + .map(|a| AgentApp { + id: a.id.to_string(), + name: crate::approvals::sheet::app_label(a.id), + octos: a.octos.iter().map(|s| s.to_string()).collect(), + manifest: serde_json::json!({ "agent": { "octos": a.octos } }), + }) + .collect(); + out.extend(script_agent_apps()); + out +} + +/// The `octos.*` services script app `app_id`'s manifest declares (`None`: +/// no such app here). The contained `octos` service grants only these. +pub fn declared_octos(app_id: &str) -> Option> { + script_agent_apps().into_iter().find(|a| a.id == app_id).map(|a| a.octos.into_iter().collect()) +} + +fn octos_of(capabilities: &serde_json::Value) -> Vec { + capabilities.as_array().into_iter().flatten().filter_map(|c| c.as_str()).filter(|c| c.starts_with("octos.")).map(str::to_string).collect() +} + +#[cfg(any(feature = "app-hub", native_mobile))] +fn script_agent_apps() -> Vec { + // Read once per data root and App Hub generation (an install or update + // bumps it): the contained service asks on every call. + type Cache = Option<((std::path::PathBuf, u64), Vec)>; + static CACHE: std::sync::Mutex = std::sync::Mutex::new(None); + let Some(root) = octosense_app_hub_app::data_root_if_set() else { return Vec::new() }; + let key = (root.clone(), octosense_app_hub_app::icons::generation()); + let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + if let Some((k, apps)) = cache.as_ref() { + if *k == key { + return apps.clone(); + } + } + let apps = read_script_agent_apps(&root); + *cache = Some((key, apps.clone())); + apps +} + +#[cfg(any(feature = "app-hub", native_mobile))] +fn read_script_agent_apps(root: &Path) -> Vec { + let root = root.to_path_buf(); + let mut out = Vec::new(); + for app in octosense_app_hub_app::system_apps() { + let Ok((dir, _)) = octosense_appstore::system::prepare(&root, &app) else { continue }; + if let Some(a) = script_agent_app(&dir.join("manifest.json"), app.id, app.name) { + out.push(a); + } + } + for app in octosense_app_hub_app::installed_apps(&root) { + if let Some(a) = script_agent_app(&root.join(&app.id).join("bundle").join("manifest.json"), &app.id, &app.name) { + out.push(a); + } + } + out +} + +#[cfg(not(any(feature = "app-hub", native_mobile)))] +fn script_agent_apps() -> Vec { + Vec::new() +} + +/// One script app's agent, from its admitted manifest. +pub fn script_agent_app(manifest: &Path, id: &str, name: &str) -> Option { + let manifest: serde_json::Value = serde_json::from_slice(&std::fs::read(manifest).ok()?).ok()?; + let octos = octos_of(&manifest["capabilities"]); + (!octos.is_empty()).then(|| AgentApp { id: id.to_string(), name: name.to_string(), octos, manifest }) +} + /// Card apps App Hub installed: each is an app of its own in the launcher, /// hosted by the linked `card` module under its `hub:` identity. /// Listed once per data root and App Hub generation: an install or update diff --git a/crates/shell/src/lib.rs b/crates/shell/src/lib.rs index 2d872c8f..3b4a947e 100644 --- a/crates/shell/src/lib.rs +++ b/crates/shell/src/lib.rs @@ -3240,6 +3240,17 @@ impl App { self.system_chat_changed(cx); } + /// Agents the person turned off since the last tick: their live services + /// go now (ADR 0004 §4), whichever way the app is hosted. + fn revoke_agents(&mut self) { + for app in approvals::take_revoked() { + let modules = self.module_host.revoke_assistant(&app); + peer_link::revoke(&app); + let contained = crate::ai_host::contained::revoke(&app); + log!("approvals: {app}'s agent turned off; revoked {modules} module service(s){}", if contained { " and its contained peer" } else { "" }); + } + } + /// The system chat moved (a frame from the kernel, the router decided /// one of its approvals): hand approvals on, and redraw. fn system_chat_changed(&mut self, cx: &mut Cx) { @@ -3340,6 +3351,7 @@ impl App { } fn approvals_changed(&mut self, cx: &mut Cx) { + self.revoke_agents(); // The AI bus's held calls the router has answered go on (or are // refused to the pane). for (id, decision, reason) in approvals::take_bus_decisions() { diff --git a/crates/shell/src/module_host.rs b/crates/shell/src/module_host.rs index 774f9326..f0c07dc1 100644 --- a/crates/shell/src/module_host.rs +++ b/crates/shell/src/module_host.rs @@ -465,6 +465,21 @@ impl ModuleHost { } } + /// The person turned `app`'s agent off (ADR 0004 §4): every instance of + /// it loses its assistant service now (its contexts close, its leases + /// go); the next instance is offered one only once allowed again. How + /// many were revoked. + pub fn revoke_assistant(&mut self, app: &str) -> usize { + let mut n = 0; + for instance in self.instances.values_mut().filter(|i| i.module.id() == app) { + if let Some(assistant) = instance.assistant.take() { + guarded_cleanup("revoking an instance's assistant", || assistant.release()); + n += 1; + } + } + n + } + /// The assistant service the shell gave this instance, if any. pub fn assistant_of(&self, client: ClientId) -> Option<&crate::ai_host::Assistant> { self.instances.get(&client)?.assistant.as_ref() @@ -976,6 +991,12 @@ mod assistant_tests { assert!(host.assistant_of(3).is_none()); // An offer never outlives its create: nothing is left to claim. assert!(octosense_ai_host::app_peers::injection::claim("assistant-probe", "i1g1").is_none()); + // The person turns its agent off: the live instance loses its + // service now, not at its next creation (G10). + assert_eq!(host.revoke_assistant("plain-probe"), 0); + assert_eq!(host.revoke_assistant("assistant-probe"), 1); + assert!(host.assistant_of(1).is_none()); + assert_eq!(host.revoke_assistant("assistant-probe"), 0, "once"); assert!(host.teardown(&mut cx, 1)); } diff --git a/crates/shell/src/peer_link/link.rs b/crates/shell/src/peer_link/link.rs index 623fac6e..51134673 100644 --- a/crates/shell/src/peer_link/link.rs +++ b/crates/shell/src/peer_link/link.rs @@ -597,6 +597,25 @@ impl PeerLinks { } } + /// The person turned `app`'s agent off (ADR 0004 §4): every context of + /// it closes (the app is told), and its service is released and + /// forgotten, so nothing it held stays live. Its links stay: later + /// requests are refused until the person allows it again. + pub fn revoke(&mut self, app: &str) { + let handles: Vec = self.contexts.iter().filter(|(_, c)| c.owner.app == app).map(|(h, _)| h.clone()).collect(); + for handle in handles { + let client_id = self.contexts[&handle].owner.client_id; + self.close_context(&handle); + if let Some(link) = self.links.get(&client_id) { + (link.out)(Down::ContextClosed { context: handle.clone(), reason: "agent_turned_off".to_string() }.to_json()); + } + } + if let Some(service) = self.services.remove(app) { + service.release(); + self.log.push(format!("peer link: {app}'s agent was turned off; its service is released")); + } + } + /// Whether the shell keeps a peer (service) for `app`. pub fn keeps_peer(&self, app: &str) -> bool { self.services.contains_key(app) diff --git a/crates/shell/src/peer_link/mod.rs b/crates/shell/src/peer_link/mod.rs index 265b7a3c..feb0661e 100644 --- a/crates/shell/src/peer_link/mod.rs +++ b/crates/shell/src/peer_link/mod.rs @@ -218,6 +218,12 @@ pub fn has_link(app: &str) -> bool { with(|l| l.has_link(app)) } +/// The person turned `app`'s agent off: its contexts close and its service +/// is released. +pub fn revoke(app: &str) { + with(|l| l.revoke(app)); +} + /// Signing out (§11): close `account`'s contexts of `app`. pub fn close_account(app: &str, account: &str) { with(|l| l.close_account(app, account, "signed_out")); diff --git a/crates/shell/src/peer_link/tests.rs b/crates/shell/src/peer_link/tests.rs index 90cc0096..5c18148c 100644 --- a/crates/shell/src/peer_link/tests.rs +++ b/crates/shell/src/peer_link/tests.rs @@ -23,6 +23,7 @@ struct Shared { decisions: Vec<(RequestId, Decision, String)>, confirmed: Vec<(RequestId, bool)>, services_made: usize, + released: usize, contexts: Vec>, specs: Vec, } @@ -110,7 +111,9 @@ impl OctosAppService for FakeService { }); Ok(ctx) } - fn release(&self) {} + fn release(&self) { + self.0.with(|s| s.released += 1); + } fn shutdown(&self) {} } @@ -464,6 +467,25 @@ fn signing_out_closes_the_accounts_contexts_and_tells_the_app() { assert_eq!(links.tool_call("notes", call("c1", "lookup", wire::Risk::Read, Some(&ctx)), 0.0), Err(Refused::UnknownContext)); } +#[test] +fn turning_the_agent_off_closes_its_contexts_and_releases_its_service() { + let (mut links, world, _) = setup(); + let (o, frames) = out(); + links.connected(1, "notes", o); + let ctx = open(&mut links, 1, &frames, None); + assert!(links.keeps_peer("notes")); + links.revoke("notes"); + assert!(matches!(downs(&frames).as_slice(), [Down::ContextClosed { context, reason }] if *context == ctx && reason == "agent_turned_off")); + assert_eq!(links.open_contexts("notes"), 0); + assert!(!links.keeps_peer("notes"), "the service is forgotten"); + assert_eq!(world.with(|s| s.released), 1, "and released now"); + assert!(world.with(|s| s.contexts.iter().all(|c| c.closed.load(Ordering::SeqCst))), "the live context is closed"); + assert_eq!(links.tool_call("notes", call("c1", "lookup", wire::Risk::Read, Some(&ctx)), 0.0), Err(Refused::UnknownContext)); + // Consent withdrawn: a new request is refused. + world.with(|s| s.consent = false); + links.on_frame(1, "notes", &request(9, "octos.session.open", json!({})), None); + assert!(matches!(downs(&frames).as_slice(), [Down::Reply { req_id: 9, result: Err(e) }] if e.starts_with("consent_pending"))); +} #[test] fn a_call_the_kernel_already_approved_is_not_asked_again_and_confirm_app_goes_to_the_apps_sheet() {