From b019470832c5dcebe1e368426204affadb4fd7e8 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:50:05 -0700 Subject: [PATCH 1/9] feat(shell): agent question requests: route ask_user_question to the right conversation [skip ci] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0004 §6 (G11, shell part): octos keeps `ask_user_question` on host-driven turns, so an app agent's question needs a conversation. - app-peers: the broker takes `user_question/requested` on the peer's session and its request contexts, stamps the turn's origin (a context's, the peer's own, or a `peer/input` turn's) and the context's client, and hands it to the host (`ToolHost::user_question`) with a `QuestionAnswer` that sends `user_question/respond` once, on the link it came on. The app's context hears only `user_question/handled_by_host`; the turn's end closes the question (`ToolHost::user_question_closed`); an app's `ContextOp::Approval` for an id the host holds (a question or a `host_tool` approval) is refused. - shell: `crates/shell/src/questions/` is the request model, a routing layer: one `Request` per question (owning app, peer, account, context, client, turn, questions and options, who started the turn, the conversation, state), consumers subscribe to it. A context's or the peer's own turn asks in the app's conversation; a `peer/input` turn (the system agent's) in the system chat. The system chat subscribes and shows its questions in the conversation; the approvals overlay shows the app's. The only answer path is `questions::answer` with a `PersonAnswer`, made only by the shell's surfaces (a test scans the sources); the peer link and the `octos` host service have no method to answer. No `host.ask`. - docs: architecture.md (+zh) item 11 and the questions bullet; the ADR follow-up. Co-Authored-By: Claude Opus 5.5 --- crates/app-peers/src/broker.rs | 117 ++++++- crates/app-peers/src/host_tools.rs | 185 ++++++++++ crates/app-peers/tests/broker.rs | 82 ++++- crates/shell/src/approvals/mod.rs | 3 +- crates/shell/src/approvals/view.rs | 64 ++++ crates/shell/src/host_tools/mod.rs | 12 +- crates/shell/src/lib.rs | 1 + crates/shell/src/questions/mod.rs | 330 ++++++++++++++++++ crates/shell/src/questions/tests.rs | 147 ++++++++ crates/shell/src/system_chat/mod.rs | 87 ++++- .../adr/0004-native-apps-hosting-and-peers.md | 4 +- docs/architecture.md | 4 +- docs/architecture.zh-CN.md | 4 +- 13 files changed, 1023 insertions(+), 17 deletions(-) create mode 100644 crates/shell/src/questions/mod.rs create mode 100644 crates/shell/src/questions/tests.rs diff --git a/crates/app-peers/src/broker.rs b/crates/app-peers/src/broker.rs index 47c5bd0d..009fe27d 100644 --- a/crates/app-peers/src/broker.rs +++ b/crates/app-peers/src/broker.rs @@ -29,7 +29,14 @@ //! closed link end calls before they run. `peer/input` (the system agent's //! input) starts the peer's turn on the same link with the kernel's turn //! id, once per input, queued while the peer is busy; `host_tool` -//! approvals go to the host, never to the app. +//! approvals go to the host, never to the app; +//! - routes the agent's questions (`user_question/requested`, octos's +//! `ask_user_question`) on the peer's session and its contexts to the +//! host with the turn's origin (a context's, the peer's own, or a +//! `peer/input` turn's), answers them only as the host says +//! (`user_question/respond` on this link), tells the app's context only +//! that the host took it, closes them when their turn ends, and refuses +//! an app's attempt to answer an approval or question the host holds. //! //! Nothing here chooses a provider, touches credentials or stops a kernel it //! does not own. @@ -45,7 +52,7 @@ use serde_json::{json, Value}; use tokio::sync::{mpsc, oneshot}; use crate::contract::*; -use crate::host_tools::{self, ApprovalAnswer, CallOrigin, HostToolApproval, HostToolCall, PeerInput, ToolHost, ToolOutcome, ToolReply}; +use crate::host_tools::{self, AgentQuestion, ApprovalAnswer, CallOrigin, HostToolApproval, HostToolCall, PeerInput, QuestionAnswer, ToolHost, ToolOutcome, ToolReply}; /// A boxed future, for the object-safe transport traits. pub type BoxFuture<'a, T> = Pin + Send + 'a>>; @@ -370,6 +377,10 @@ struct State { input_queue: VecDeque, /// Workspaces new peers were created with, when no state dir keeps them. cwds: HashMap, + /// Approval and question ids the host took: only the host answers them. + host_held: VecDeque, + /// Questions the host holds, by id: the turn that asked. + questions: HashMap, } struct Inner { @@ -424,6 +435,8 @@ impl Broker { trigger_order: VecDeque::new(), input_queue: VecDeque::new(), cwds: HashMap::new(), + host_held: VecDeque::new(), + questions: HashMap::new(), }), connecting: tokio::sync::Mutex::new(()), binding: tokio::sync::Mutex::new(()), @@ -771,6 +784,25 @@ impl Inner { let ours = peer_session.as_deref() == Some(session) || route.is_some(); if ours { self.note_terminal(method, ¶ms); + if let Some(turn) = turn_ended(method, ¶ms) { + self.close_questions(turn); + } + } + // An agent's question is the person's, asked by the shell in the + // right conversation: never the app context's to answer. + if ours && method == host_tools::USER_QUESTION_REQUESTED { + if let Some(question) = AgentQuestion::parse(¶ms, session) { + let context = route.as_ref().and_then(|(g, c)| (*g == generation).then(|| c.upgrade()).flatten()); + if route.is_some() && context.is_none() { + return; + } + if self.on_user_question(question, context.as_deref()) { + if let Some(context) = context { + context.notification(host_tools::QUESTION_HANDLED_BY_HOST, ¶ms); + } + return; + } + } } // Every approval on the peer's session or one of its contexts is the // host's to draw (ADR 0004 §8): a host-routed tool's (the owning @@ -790,7 +822,9 @@ impl Inner { a }); if let Some(approval) = approval { + let id = approval.approval_id.clone(); if self.on_host_approval(approval) { + remember(&mut self.lock().host_held, id); if let Some(context) = context { context.notification(host_tools::HANDLED_BY_HOST, ¶ms); } @@ -1391,6 +1425,66 @@ impl Inner { self.tool_host().host_tool_approval(&self.cfg.app_id, account.as_deref(), approval, answer) } + /// An agent's question: stamped with its origin and handed to the host, + /// which answers on this link. False when the host did not take it. + fn on_user_question(self: &Arc, mut question: AgentQuestion, context: Option<&ContextInner>) -> bool { + let (link, account) = { + let st = self.lock(); + question.origin = match context { + Some(_) => CallOrigin::Context, + None if st.input_turns.contains(&question.turn_id) => CallOrigin::PeerInput, + None => CallOrigin::PeerOwn, + }; + (st.link.clone(), st.account.clone()) + }; + if let Some(context) = context { + question.context_id = Some(context.context_id.clone()); + question.client = Some(context.instance.clone()); + } + let Some(link) = link else { return false }; + let weak = Arc::downgrade(self); + let session = question.session_id.clone(); + let question_id = question.question_id.clone(); + let turn = question.turn_id.clone(); + let answer = QuestionAnswer::new(move |answers| { + if let Some(inner) = weak.upgrade() { + inner.lock().questions.remove(&question_id); + inner.fire( + &link, + host_tools::USER_QUESTION_RESPOND, + json!({"session_id": session, "question_id": question_id, "answers": answers}), + ); + } + }); + let id = question.question_id.clone(); + let taken = self.tool_host().user_question(&self.cfg.app_id, account.as_deref(), question, answer); + if taken { + let mut st = self.lock(); + remember(&mut st.host_held, id.clone()); + st.questions.insert(id, turn); + } + taken + } + + /// `turn` ended: its unanswered questions can no longer be answered. + fn close_questions(&self, turn: &str) { + let closed: Vec = { + let mut st = self.lock(); + let ids: Vec = st.questions.iter().filter(|(_, t)| t.as_str() == turn).map(|(q, _)| q.clone()).collect(); + for id in &ids { + st.questions.remove(id); + } + ids + }; + if closed.is_empty() { + return; + } + let host = self.tool_host(); + for id in closed { + host.user_question_closed(&self.cfg.app_id, &id); + } + } + fn close_context_on_kernel( self: &Arc, peer: String, @@ -1442,6 +1536,20 @@ impl Inner { } } +/// The turn a notification ends, if it ends one. +fn turn_ended<'a>(method: &str, params: &'a Value) -> Option<&'a str> { + let ended = match method { + "projection/envelope" => params["payload"]["type"] == "turn_terminal", + "turn/completed" | "turn/error" | "turn/interrupted" => true, + _ => false, + }; + if ended { + params.get("turn_id").and_then(Value::as_str) + } else { + None + } +} + fn rpc_error_text(error: &Value) -> String { let message = error["message"].as_str().unwrap_or("request failed"); match error["data"]["kind"].as_str() { @@ -1791,6 +1899,11 @@ impl ContextInner { .await } ContextOp::Approval { id, approve } => { + // What the host holds (a `host_tool` approval, an agent's + // question) only the host answers, on the person's word. + if inner.lock().host_held.contains(&id) { + return Err("The person answers this in OctoSense, not the app".into()); + } inner .request( "approval/respond", diff --git a/crates/app-peers/src/host_tools.rs b/crates/app-peers/src/host_tools.rs index 3b222e4d..3021a77a 100644 --- a/crates/app-peers/src/host_tools.rs +++ b/crates/app-peers/src/host_tools.rs @@ -17,6 +17,13 @@ //! | `peer/tool/cancel` (timeout, interrupt), the link closing | [`ToolHost::tool_cancel`]; the reply is closed first | //! | `peer/input` (the system agent's input) | the broker starts the turn; [`ToolHost::admit_input`] may refuse it | //! | `approval/requested` with `approval_kind: "host_tool"` | [`ToolHost::host_tool_approval`] with an [`ApprovalAnswer`] | +//! | `user_question/requested` on the peer's session or a context (octos's `ask_user_question`) | [`ToolHost::user_question`] with a [`QuestionAnswer`]; the turn's end closes it ([`ToolHost::user_question_closed`]) | +//! +//! **Only the host answers.** An [`ApprovalAnswer`] or a [`QuestionAnswer`] +//! is made by the broker for the connection the request came on and handed +//! to the host alone; the app's context hears only that the host took it +//! ([`HANDLED_BY_HOST`], [`QUESTION_HANDLED_BY_HOST`]), and the broker +//! refuses an app's attempt to answer an id the host holds. //! //! Apps reach the host through their service ([`crate::OctosAppService`]): //! an in-process module installs its executor @@ -40,6 +47,12 @@ pub const HOST_TOOL_KIND: &str = "host_tool"; /// What an app's context sees instead of a `host_tool` approval: the host /// draws that sheet, the app never answers it. pub const HANDLED_BY_HOST: &str = "approval/handled_by_host"; +/// octos's `ask_user_question` (UPCR-2026-023). +pub const USER_QUESTION_REQUESTED: &str = "user_question/requested"; +pub const USER_QUESTION_RESPOND: &str = "user_question/respond"; +/// What an app's context sees instead of a question the host routes: the +/// shell asks the person in the right conversation; the app never answers. +pub const QUESTION_HANDLED_BY_HOST: &str = "user_question/handled_by_host"; /// The fields a `tools.json` entry (octos `ToolDecl`) may carry; the kernel /// refuses any other. @@ -451,6 +464,150 @@ impl ApprovalAnswer { } } +/// One structured question of a `user_question/requested` (octos +/// `UserQuestion`): 2–4 options, maybe several of them, and always a free +/// text escape. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct QuestionItem { + pub header: String, + pub question: String, + /// (label, description). + pub options: Vec<(String, String)>, + pub multi_select: bool, +} + +/// An agent's `ask_user_question` on an app peer's session or one of its +/// request contexts, with what the host knows about the turn that asked. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AgentQuestion { + /// The kernel's id (`user_question/respond`'s `question_id`). + pub question_id: String, + /// The session it was asked on (the peer's, or a context's). + pub session_id: String, + pub turn_id: String, + pub context_id: Option, + /// The calling context's client (a Rinx mini app), from the host's + /// own context table. + pub client: Option, + pub title: String, + pub body: String, + pub questions: Vec, + /// Which turn asked: a context's (the person, in the app), the peer's + /// own (the app's agent), or a `peer/input` turn (the system agent's). + pub origin: CallOrigin, +} + +impl AgentQuestion { + /// A `user_question/requested`'s params; `session` is the full session + /// key (topic joined). The host fills `context_id`, `client`, `origin`. + pub fn parse(params: &Value, session: &str) -> Option { + let s = |v: &Value, key: &str| v.get(key).and_then(Value::as_str).unwrap_or("").to_owned(); + let question_id = s(params, "question_id"); + if question_id.is_empty() { + return None; + } + let questions = params + .get("questions") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .map(|q| QuestionItem { + header: s(q, "header"), + question: s(q, "question"), + options: q + .get("options") + .and_then(Value::as_array) + .map(|o| o.iter().map(|o| (s(o, "label"), s(o, "description"))).filter(|(l, _)| !l.is_empty()).collect()) + .unwrap_or_default(), + multi_select: q.get("multi_select").and_then(Value::as_bool).unwrap_or(false), + }) + .collect() + }) + .unwrap_or_default(); + Some(AgentQuestion { + question_id, + session_id: session.to_owned(), + turn_id: s(params, "turn_id"), + context_id: None, + client: None, + title: s(params, "title"), + body: s(params, "body"), + questions, + origin: CallOrigin::PeerOwn, + }) + } + + /// How many answers `user_question/respond` carries (one per question, + /// at least one). + pub fn answer_count(&self) -> usize { + self.questions.len().max(1) + } +} + +/// One answer of a `user_question/respond` (octos `UserQuestionAnswer`). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct QuestionReply { + pub selected_labels: Vec, + pub free_text: Option, +} + +impl QuestionReply { + pub fn option(label: impl Into) -> QuestionReply { + QuestionReply { selected_labels: vec![label.into()], free_text: None } + } + pub fn text(text: impl Into) -> QuestionReply { + QuestionReply { selected_labels: Vec::new(), free_text: Some(text.into()) } + } + fn to_json(&self) -> Value { + let mut out = json!({}); + if !self.selected_labels.is_empty() { + out["selected_labels"] = json!(self.selected_labels); + } + if let Some(text) = &self.free_text { + out["free_text"] = json!(text); + } + out + } +} + +/// The one answer to one agent question: `user_question/respond` on the +/// connection it was asked on. Sent once. The broker makes it and hands it +/// to the host only. +#[derive(Clone)] +pub struct QuestionAnswer { + sent: Arc>, + send: Arc, +} + +impl std::fmt::Debug for QuestionAnswer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "QuestionAnswer(sent: {})", *self.sent.lock().unwrap_or_else(|e| e.into_inner())) + } +} + +impl QuestionAnswer { + /// `send` gets the `answers` array. + pub fn new(send: impl Fn(Value) + Send + Sync + 'static) -> QuestionAnswer { + QuestionAnswer { sent: Arc::new(Mutex::new(false)), send: Arc::new(send) } + } + /// The person's answers, one per question. False when already sent. + pub fn respond(&self, answers: &[QuestionReply]) -> bool { + { + let mut sent = self.sent.lock().unwrap_or_else(|e| e.into_inner()); + if *sent { + return false; + } + *sent = true; + } + (self.send)(Value::Array(answers.iter().map(QuestionReply::to_json).collect())); + true + } + pub fn is_sent(&self) -> bool { + *self.sent.lock().unwrap_or_else(|e| e.into_inner()) + } +} + /// The system agent's input to a host-owned peer (`peer/input`). #[derive(Clone, Debug, PartialEq, Eq)] pub struct PeerInput { @@ -598,6 +755,18 @@ pub trait ToolHost: Send + Sync { false } + /// An agent's `ask_user_question` on `app_id`'s peer or one of its + /// contexts. True when the host took it: the shell asks the person in + /// the right conversation and answers through `answer`; false leaves it + /// to the app's context, as before. + fn user_question(&self, _app_id: &str, _account: Option<&str>, _question: AgentQuestion, _answer: QuestionAnswer) -> bool { + false + } + + /// The turn that asked `question_id` ended (answered or not): the + /// question can no longer be answered. + fn user_question_closed(&self, _app_id: &str, _question_id: &str) {} + /// An in-process app installs its executor (through its service). fn set_executor(&self, _app_id: &str, _executor: Option>) {} @@ -686,6 +855,22 @@ mod tests { assert!(declaration(&json!({"description": "no name"}), None).is_none()); } + #[test] + fn a_question_parses_and_is_answered_once() { + let params = json!({"session_id": "_main:api:octosense", "topic": "peer-rinx-1", "question_id": "q1", "turn_id": "t1", "title": "Which room?", "body": "Pick one", + "questions": [{"header": "Room", "question": "Post where?", "options": [{"label": "#a", "description": "A"}, {"label": "#b", "description": ""}], "multi_select": false, "allow_free_text": true}]}); + let q = AgentQuestion::parse(¶ms, "_main:api:octosense#peer-rinx-1").unwrap(); + assert_eq!((q.question_id.as_str(), q.turn_id.as_str(), q.answer_count()), ("q1", "t1", 1)); + assert_eq!(q.questions[0].options, vec![("#a".to_string(), "A".to_string()), ("#b".to_string(), String::new())]); + assert!(AgentQuestion::parse(&json!({"turn_id": "t"}), "s").is_none(), "no id, nothing to answer"); + let sent = Arc::new(Mutex::new(Vec::::new())); + let s = sent.clone(); + let answer = QuestionAnswer::new(move |v| s.lock().unwrap().push(v)); + assert!(answer.respond(&[QuestionReply::option("#b")])); + assert!(!answer.respond(&[QuestionReply::text("again")]), "once"); + assert_eq!(*sent.lock().unwrap(), vec![json!([{"selected_labels": ["#b"]}])]); + } + #[test] fn a_host_tool_approval_parses_from_typed_details() { let params = json!({"session_id": "_main:api:octosense", "topic": "peer-rinx-1", "approval_id": "a1", "turn_id": "t", "tool_name": "mail_send", diff --git a/crates/app-peers/tests/broker.rs b/crates/app-peers/tests/broker.rs index 90a72aa1..8e4e9817 100644 --- a/crates/app-peers/tests/broker.rs +++ b/crates/app-peers/tests/broker.rs @@ -6,7 +6,7 @@ use std::sync::{Arc, Mutex}; use std::time::Duration; use octosense_app_peers::broker::{BoxFuture, Broker, BrokerConfig, Connector, Link, ToolHostHandle}; -use octosense_app_peers::host_tools::{ApprovalAnswer, CallOrigin, HostToolApproval, HostToolCall, PeerInput, ToolHost, ToolOutcome, ToolReply}; +use octosense_app_peers::host_tools::{AgentQuestion, ApprovalAnswer, CallOrigin, HostToolApproval, HostToolCall, PeerInput, QuestionAnswer, QuestionReply, ToolHost, ToolOutcome, ToolReply}; use octosense_app_peers::*; use serde_json::{json, Value}; use tokio::sync::mpsc; @@ -467,6 +467,8 @@ struct RecordingHost { cancels: Mutex>, inputs: Mutex>, approvals: Mutex>, + questions: Mutex>, + closed_questions: Mutex>, } impl ToolHost for RecordingHost { @@ -496,6 +498,13 @@ impl ToolHost for RecordingHost { self.approvals.lock().unwrap().push((approval, answer)); true } + fn user_question(&self, _app: &str, _account: Option<&str>, question: AgentQuestion, answer: QuestionAnswer) -> bool { + self.questions.lock().unwrap().push((question, answer)); + true + } + fn user_question_closed(&self, _app: &str, question_id: &str) { + self.closed_questions.lock().unwrap().push(question_id.to_owned()); + } } fn wait_for(what: &str, mut f: impl FnMut() -> bool) { @@ -769,6 +778,77 @@ fn a_host_tool_approval_goes_to_the_host_and_is_answered_on_its_link() { drop(broker); } +/// ADR 0004 §6 (G11): an agent's `ask_user_question` goes to the host with +/// the turn's origin, never to the app; only the host's answer reaches the +/// kernel, on the link it came on; the app's context cannot answer it (nor +/// a `host_tool` approval the host holds); the turn's end closes it. +#[test] +fn an_agents_question_goes_to_the_host_and_only_the_host_answers_it() { + let host = Arc::new(RecordingHost::default()); + let (broker, script) = new_broker_with(&ALL, Some(host.clone()), None); + script.lock().unwrap().hold_turns = true; + broker.set_account(Some("@a:x")); + let ctx = broker.open_context(spec("@a:x", "mini.news#1", &ALL)).unwrap(); + let (sink, rx) = collect(); + ctx.call(ContextOp::Open, sink).unwrap(); + complete(&rx).unwrap(); + let ctx_session = calls_of(&script, "session/open").last().unwrap().1["session_id"].as_str().unwrap().to_owned(); + let question = |session: &str, id: &str, turn: &str| { + json!({"session_id": session, "question_id": id, "turn_id": turn, "title": "Which room?", "body": "Pick one", + "questions": [{"header": "Room", "question": "Post where?", "options": [{"label": "#a", "description": ""}, {"label": "#b", "description": ""}], "allow_free_text": true}]}) + }; + // A context's turn (the person, in the app): the app's conversation. + let (sink, rx) = collect(); + ctx.call(ContextOp::Turn { text: "post it".into() }, sink).unwrap(); + wait_for("the turn", || position(&script, "turn/start").is_some()); + let ctx_turn = calls_of(&script, "turn/start")[0].1["turn_id"].as_str().unwrap().to_owned(); + notify(&script, "user_question/requested", question(&ctx_session, "q1", &ctx_turn)); + wait_for("the host", || host.questions.lock().unwrap().len() == 1); + let (q, answer) = host.questions.lock().unwrap()[0].clone(); + assert_eq!(q.origin, CallOrigin::Context); + assert_eq!(q.client.as_deref(), Some("mini.news#1"), "stamped from the host's context table"); + assert!(q.context_id.is_some()); + assert_eq!(q.questions[0].options.len(), 2); + let mut seen = Vec::new(); + while let Ok(ContextEvent::Data(d)) = rx.recv_timeout(Duration::from_millis(300)) { + seen.push(d["method"].as_str().unwrap_or("").to_owned()); + } + assert!(seen.iter().any(|m| m == "user_question/handled_by_host"), "{seen:?}"); + assert!(!seen.iter().any(|m| m == "user_question/requested"), "the app is never asked: {seen:?}"); + // The app cannot answer it (nor anything else the host holds). + let (sink, rx2) = collect(); + ctx.call(ContextOp::Approval { id: "q1".into(), approve: true }, sink).unwrap(); + let refused = complete(&rx2).unwrap_err(); + assert!(refused.contains("OctoSense"), "{refused}"); + assert!(position(&script, "approval/respond").is_none() && position(&script, "user_question/respond").is_none()); + // The host's answer goes to the kernel, once, on the link it came on. + assert!(answer.respond(&[QuestionReply::option("#b")])); + wait_for("the answer", || position(&script, "user_question/respond").is_some()); + let (conn, respond) = calls_of(&script, "user_question/respond")[0].clone(); + assert_eq!(respond["question_id"], "q1"); + assert_eq!(respond["session_id"], ctx_session.as_str()); + assert_eq!(respond["answers"], json!([{"selected_labels": ["#b"]}])); + assert_eq!(conn, calls_of(&script, "turn/start")[0].0); + + // The system agent's `peer/input` turn: its question is the system chat's. + let slug = peer_slug(&script); + let peer_session = format!("_main:api:octosense#peer-{slug}"); + notify(&script, "peer/input", json!({"peer": slug, "session_id": peer_session, "input_id": "i1", "turn_id": "turn-in", "text": "ask them"})); + wait_for("the input turn", || calls_of(&script, "turn/start").len() == 2); + notify(&script, "user_question/requested", question(&peer_session, "q2", "turn-in")); + // The peer's own turn (the app's agent): the app's conversation. + notify(&script, "user_question/requested", question(&peer_session, "q3", "turn-own")); + wait_for("both", || host.questions.lock().unwrap().len() == 3); + let origins: Vec<(String, CallOrigin)> = host.questions.lock().unwrap().iter().map(|(q, _)| (q.question_id.clone(), q.origin)).collect(); + assert_eq!(origins[1], ("q2".to_string(), CallOrigin::PeerInput)); + assert_eq!(origins[2], ("q3".to_string(), CallOrigin::PeerOwn)); + // A turn that ends closes its unanswered question. + notify(&script, "turn/completed", json!({"session_id": peer_session, "turn_id": "turn-in"})); + wait_for("closed", || host.closed_questions.lock().unwrap().contains(&"q2".to_string())); + assert!(!host.closed_questions.lock().unwrap().contains(&"q3".to_string())); + drop(broker); +} + #[test] fn a_new_peers_workspace_is_the_account_folder_and_a_resume_keeps_the_one_it_was_made_with() { let dir = std::env::temp_dir().join(format!("app-peers-cwd-{}", std::process::id())); diff --git a/crates/shell/src/approvals/mod.rs b/crates/shell/src/approvals/mod.rs index 22bded0d..b30a22ae 100644 --- a/crates/shell/src/approvals/mod.rs +++ b/crates/shell/src/approvals/mod.rs @@ -123,7 +123,8 @@ 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() + self.router.contacts().generation() + u64::from(self.settings_open) + minute + // An app agent's question is drawn on the same surface. + self.router.generation() + self.consent.generation() + self.router.contacts().generation() + u64::from(self.settings_open) + minute + crate::questions::generation() } 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/view.rs b/crates/shell/src/approvals/view.rs index de728ac9..3a57685a 100644 --- a/crates/shell/src/approvals/view.rs +++ b/crates/shell/src/approvals/view.rs @@ -10,6 +10,10 @@ //! and use and where the model runs; Allow or Don't allow. Modal. //! - **The time-box indicator**: while a rule approves everything one app //! asks, a pill at the top says so, with the minutes left and Stop. +//! - **An app agent's question** ([`crate::questions`], the app's +//! conversation): who asks, the question and its options; a tap answers +//! it (the person's answer, [`crate::questions::PersonAnswer`]). Modal, +//! after any approval sheet. //! //! A press on a button is the person's gesture: only here (and on the //! Settings page) is a [`ApprovalGesture`] made. @@ -49,6 +53,8 @@ pub enum Hit { Answer { sheet: u64, request: RequestId, answer: Answer }, Consent { app: String, allow: bool }, StopRule(RuleId), + /// An option of an app agent's question (`None`: "Don't answer"). + QuestionOption { id: u64, label: Option }, /// The card itself (swallowed). Card, } @@ -101,6 +107,8 @@ struct Frame { more_sheets: usize, consent: Option, everything: Vec, + /// The oldest open question of an app's conversation. + question: Option, now: u64, } @@ -112,9 +120,14 @@ fn frame() -> Frame { more_sheets: a.router.sheets().len().saturating_sub(1), consent: a.consent.prompt().cloned(), everything: a.router.rules.active_everything(now).into_iter().cloned().collect(), + question: None, now, } }) + .map(|mut f| { + f.question = crate::questions::open_in_apps().into_iter().next(); + f + }) .unwrap_or_default() } @@ -209,9 +222,50 @@ impl ShellApprovals { } else if let Some(sheet) = &f.sheet { self.modal = true; self.draw_sheet(cx, screen, sheet, f.more_sheets, tok); + } else if let Some(question) = &f.question { + self.modal = true; + self.draw_question(cx, screen, question, tok); } } + /// An app agent's question, in the app's conversation: who asks, the + /// question, one button per option and "Don't answer". + fn draw_question(&mut self, cx: &mut Cx2d, screen: Rect, q: &crate::questions::Request, tok: ShellTokens) { + self.scrim(cx, screen); + let options = q.options(); + let card = Self::card_rect(screen, PAD * 2.0 + 26.0 + 20.0 + 12.0 + 40.0 + 12.0 + (options.len() as f64 + 1.0) * (BUTTON_H + 8.0)); + self.d.card(cx, card, &tok.popups); + self.hits.push((card, Hit::Card)); + let ink = tok.popups.text; + let dim = alpha(ink, 0.65); + let x = card.pos.x + PAD; + let w = card.size.x - PAD * 2.0; + let mut y = card.pos.y + PAD; + let title = if q.title.is_empty() { q.asked_by() } else { q.title.clone() }; + self.d.label_elided(cx, rect(x, y, w, 24.0), true, tok.font.heading, ink, HAlign::Left, &title); + y += 26.0; + let sub = format!("{} \u{00b7} in {}'s conversation", q.asked_by(), app_label(&q.app)); + self.d.label_elided(cx, rect(x, y, w, 18.0), false, tok.font.body_small, dim, HAlign::Left, &sub); + y += 20.0 + 12.0; + let text = q.text().to_string(); + self.d.label_elided(cx, rect(x, y, w, 36.0), false, tok.font.body, ink, HAlign::Left, &text); + y += 40.0 + 12.0; + let mut buttons = Buttons { d: &mut self.d, tok, hover: self.hover }; + let mut hits = Vec::new(); + for (i, label) in options.iter().enumerate() { + let r = buttons.draw(cx, x, y, w, label, i == 0); + hits.push((r, Hit::QuestionOption { id: q.id, label: Some(label.clone()) })); + y += BUTTON_H + 8.0; + } + let r = buttons.draw(cx, x, y, w, "Don't answer", false); + hits.push((r, Hit::QuestionOption { id: q.id, label: None })); + self.hits.extend(hits); + self.shown.push(title); + self.shown.push(sub); + self.shown.push(text); + self.shown.extend(options); + } + fn draw_indicator(&mut self, cx: &mut Cx2d, screen: Rect, f: &Frame, tok: ShellTokens) { let mut y = screen.pos.y + 36.0; for rule in &f.everything { @@ -400,6 +454,16 @@ fn act(hit: Hit) { Hit::StopRule(id) => { super::with(|a| a.router.delete_rule(&id)); } + Hit::QuestionOption { id, label } => { + use crate::ai_host::app_peers::host_tools::QuestionReply; + let reply = match label { + Some(label) => QuestionReply::option(label), + None => QuestionReply::text("The person chose not to answer."), + }; + if let Err(e) = crate::questions::answer(id, &[reply], &crate::questions::PersonAnswer::from_shell_surface()) { + log!("questions: {e}"); + } + } Hit::Card => {} } } diff --git a/crates/shell/src/host_tools/mod.rs b/crates/shell/src/host_tools/mod.rs index a0360665..61deca48 100644 --- a/crates/shell/src/host_tools/mod.rs +++ b/crates/shell/src/host_tools/mod.rs @@ -10,6 +10,7 @@ //! | `approval/requested` `host_tool` | the approval router ([`crate::approvals::approval_requested`]); its decision answers the kernel | //! | any other `approval/requested` on an app's peer session or context (octos's own tools) | the same router, as the app agent's call on its own app (ADR 0004 §8); the app hears only `approval/handled_by_host` | //! | `peer/input` | admitted here (consent, a suspended account); the broker starts the turn | +//! | `user_question/requested` on an app peer (octos's `ask_user_question`) | [`crate::questions`]: the app's conversation, or the system chat for a `peer/input` turn; answered only by the person on a shell surface | //! | the system session's `terminal.run` (Setup › Assistant › Command execution) | [`crate::system_chat`] registers it; its calls come here | //! | the system toolbox's tools (feature `toolbox-peers`) | the `toolbox` owner: its tools declared once, granted per app, offered after consent, run by its executor ([`toolbox`]) | //! @@ -43,7 +44,7 @@ use std::sync::{Arc, Mutex, OnceLock}; use serde_json::Value; -use crate::ai_host::app_peers::host_tools::{self, ApprovalAnswer, ConfirmRequest, ConfirmSheet, HostToolApproval, HostToolCall, PeerInput, ToolExecutor, ToolHost, ToolOutcome, ToolReply}; +use crate::ai_host::app_peers::host_tools::{self, AgentQuestion, ApprovalAnswer, ConfirmRequest, ConfirmSheet, HostToolApproval, HostToolCall, PeerInput, QuestionAnswer, ToolExecutor, ToolHost, ToolOutcome, ToolReply}; use crate::approvals::{self, Caller, Decision, RequestContext, RequestId, Route, ToolSpec}; use crate::peer_link::{self, KernelToolCall, Refused, ToolCallResult}; pub use relay::{app_of_peer, Event, Relay, APPROVAL_PREFIX, BUS_PREFIX, CONFIRM_PREFIX, SYSTEM, TERMINAL_RUN, TOOLBOX}; @@ -189,6 +190,15 @@ impl ToolHost for ShellToolHost { true } + fn user_question(&self, app_id: &str, account: Option<&str>, question: AgentQuestion, answer: QuestionAnswer) -> bool { + crate::questions::requested(app_id, account, question, answer); + true + } + + fn user_question_closed(&self, app_id: &str, question_id: &str) { + crate::questions::closed(app_id, question_id); + } + fn set_executor(&self, app_id: &str, executor: Option>) { with_relay(|r| r.set_executor(app_of_peer(app_id), executor)); } diff --git a/crates/shell/src/lib.rs b/crates/shell/src/lib.rs index 3b4a947e..5bbec88b 100644 --- a/crates/shell/src/lib.rs +++ b/crates/shell/src/lib.rs @@ -55,6 +55,7 @@ pub mod sandbox; pub mod pane_links; pub mod peer_link; pub mod preview; +pub mod questions; pub mod run_view; pub mod shell; pub mod theme; diff --git a/crates/shell/src/questions/mod.rs b/crates/shell/src/questions/mod.rs new file mode 100644 index 00000000..dbdd0842 --- /dev/null +++ b/crates/shell/src/questions/mod.rs @@ -0,0 +1,330 @@ +//! Agents' questions to the person (ADR 0004 §6): the shell's request +//! model for octos's `ask_user_question` on app peers, and where each +//! question is asked. A routing layer, not a view. +//! +//! | In | Here | Out | +//! | --- | --- | --- | +//! | a broker's `user_question/requested` on an app peer's session or context ([`crate::host_tools::ShellToolHost`]) | [`Questions::requested`]: one [`Request`] with its owning app, peer, account, context, turn, the questions and options, who started the turn ([`Asker`]) and so which [`Conversation`] it belongs to | every [`Consumer`] hears it ([`subscribe`]) | +//! | the turn ended (the broker saw its terminal) | [`Questions::closed`] | the consumers hear it closed | +//! | the person answered on a shell surface | [`answer`], with a [`PersonAnswer`] | `user_question/respond` on the broker's link, once | +//! +//! **Which conversation.** A question from a turn the person or the app +//! started (a request context's turn, the peer's own turn) is the app's: +//! [`Conversation::App`]. A question from a turn the system agent started +//! on the peer (`peer/input`) is the system agent's to relay, so it goes to +//! the system chat: [`Conversation::SystemChat`]. There is no `host.ask`: +//! octos keeps `ask_user_question` on host-driven turns when the peer's +//! `generic_tools` lists it. +//! +//! **Consumers.** The system chat subscribes and shows its questions in +//! its conversation (`crate::system_chat`); the shell's app-conversation +//! surface (the approvals overlay, `crate::approvals::view`) shows the +//! app's. Notification and glance cards can subscribe the same way; none +//! of them owns a question, and none renders anything here. +//! +//! **Only the person answers, only through the shell.** The one answer +//! path is [`answer`], which needs a [`PersonAnswer`]: made only by the +//! shell's own surfaces from the person's tap or typing +//! ([`PersonAnswer::from_shell_surface`]; a test scans the sources). The +//! answer handle ([`QuestionAnswer`]) is the broker's, made for the host +//! connection the question came on; nothing an app reaches (its context, +//! a host service, an executor, the peer link, `host.request`) carries one +//! or can name a question: the broker refuses an app's attempt to answer +//! an id the host holds, and the peer link and the `octos` host service +//! have no method for it. + +#[cfg(test)] +mod tests; + +use std::sync::Mutex; + +use crate::ai_host::app_peers::host_tools::{AgentQuestion, CallOrigin, QuestionAnswer, QuestionItem, QuestionReply}; + +/// Who started the turn that asked. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Asker { + /// A request context's turn: the person, in the app. + Person, + /// The peer's own turn: the app's agent at work. + AppAgent, + /// A `peer/input` turn: the system agent's request to the app's agent. + SystemAgent, +} + +/// Where the question is asked. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Conversation { + /// The owning app's conversation. + App(String), + /// The system chat (the system agent's conversation). + SystemChat, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum State { + Open, + /// Answered by the person (a summary of the answer). + Answered(String), + /// Its turn ended unanswered. + Closed, +} + +/// One agent question, as every consumer sees it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Request { + /// The shell's id for it. + pub id: u64, + /// The owning app (`rinx`, `os.news`). + pub app: String, + /// The broker's app id (a script app's peer is `card.`). + pub peer_app: String, + pub account: Option, + /// The asking request context, and its client (a Rinx mini app). + pub context_id: Option, + pub client: Option, + pub session_id: String, + pub turn_id: String, + /// The kernel's question id. + pub question_id: String, + pub title: String, + pub body: String, + pub items: Vec, + pub asker: Asker, + pub conversation: Conversation, + pub state: State, +} + +impl Request { + /// How many answers the kernel takes (one per question). + pub fn answer_count(&self) -> usize { + self.items.len().max(1) + } + /// "Rinx's agent asks", for a surface's heading. + pub fn asked_by(&self) -> String { + let app = crate::approvals::sheet::app_label(&self.app); + match (&self.asker, &self.client) { + (Asker::SystemAgent, _) => format!("{app}'s agent asks (for the assistant)"), + (Asker::Person, Some(client)) => format!("{app}'s agent asks ({client})"), + _ => format!("{app}'s agent asks"), + } + } + /// The first question's text (or the fallback body). + pub fn text(&self) -> &str { + self.items.first().map(|q| q.question.as_str()).filter(|q| !q.is_empty()).unwrap_or(&self.body) + } + /// The first question's option labels. + pub fn options(&self) -> Vec { + self.items.first().map(|q| q.options.iter().map(|(l, _)| l.clone()).collect()).unwrap_or_default() + } +} + +/// A subscriber: a surface that shows questions (the system chat, the +/// app-conversation overlay, later notification and glance cards). Called +/// with the model's lock held: take what you need and return; never call +/// back into this module from here. +pub trait Consumer: Send { + fn changed(&mut self, request: &Request); +} + +/// Proof that the person answered on one of the shell's own surfaces. Only +/// [`PersonAnswer::from_shell_surface`] makes one, and only the shell's +/// surfaces call it (a test scans the sources). +pub struct PersonAnswer(()); + +impl PersonAnswer { + /// The person tapped an option or typed an answer on a shell surface. + pub(crate) fn from_shell_surface() -> PersonAnswer { + PersonAnswer(()) + } +} + +/// How many answered or closed requests are kept for the surfaces. +const KEPT: usize = 64; + +struct Entry { + request: Request, + answer: QuestionAnswer, +} + +/// The model. +#[derive(Default)] +pub struct Questions { + next: u64, + entries: Vec, + consumers: Vec>, + generation: u64, +} + +fn app_of_peer(peer_app: &str) -> &str { + crate::host_tools::app_of_peer(peer_app) +} + +impl Questions { + pub fn subscribe(&mut self, consumer: Box) { + self.consumers.push(consumer); + } + + pub fn generation(&self) -> u64 { + self.generation + } + + fn changed(&mut self, index: usize) { + self.generation += 1; + let request = self.entries[index].request.clone(); + for consumer in &mut self.consumers { + consumer.changed(&request); + } + } + + /// A broker handed the host an agent's question: route it. + pub fn requested(&mut self, peer_app: &str, account: Option<&str>, question: AgentQuestion, answer: QuestionAnswer) -> u64 { + let app = app_of_peer(peer_app).to_string(); + let asker = match question.origin { + CallOrigin::Context => Asker::Person, + CallOrigin::PeerInput => Asker::SystemAgent, + CallOrigin::PeerOwn => Asker::AppAgent, + // A host session's own question is its own chat's (the system + // chat answers those itself); never routed here in practice. + CallOrigin::System => Asker::SystemAgent, + }; + let conversation = match asker { + Asker::SystemAgent => Conversation::SystemChat, + Asker::Person | Asker::AppAgent => Conversation::App(app.clone()), + }; + self.next += 1; + let id = self.next; + let request = Request { + id, + app, + peer_app: peer_app.to_string(), + account: account.map(str::to_string), + context_id: question.context_id, + client: question.client, + session_id: question.session_id, + turn_id: question.turn_id, + question_id: question.question_id, + title: question.title, + body: question.body, + items: question.questions, + asker, + conversation, + state: State::Open, + }; + self.entries.push(Entry { request, answer }); + let index = self.entries.len() - 1; + self.changed(index); + self.prune(); + id + } + + /// The turn that asked ended: the question can no longer be answered. + pub fn closed(&mut self, peer_app: &str, question_id: &str) { + let Some(index) = self.entries.iter().position(|e| e.request.peer_app == peer_app && e.request.question_id == question_id && e.request.state == State::Open) else { return }; + self.entries[index].request.state = State::Closed; + self.changed(index); + } + + /// The person's answer, from a shell surface: sent to the kernel once. + pub fn answer(&mut self, id: u64, replies: &[QuestionReply], _by: &PersonAnswer) -> Result<(), String> { + let index = self.entries.iter().position(|e| e.request.id == id).ok_or("no such question")?; + let entry = &self.entries[index]; + if entry.request.state != State::Open { + return Err("this question is no longer open".into()); + } + let count = entry.request.answer_count(); + if replies.is_empty() || replies.len() > count { + return Err(format!("{count} answer(s) expected")); + } + // One answer per question: the rest are left blank. + let mut all = replies.to_vec(); + all.resize(count, QuestionReply::default()); + if !entry.answer.respond(&all) { + return Err("already answered".into()); + } + let summary = replies.iter().map(|r| r.free_text.clone().unwrap_or_else(|| r.selected_labels.join(", "))).collect::>().join("; "); + self.entries[index].request.state = State::Answered(summary); + self.changed(index); + Ok(()) + } + + /// The open questions of one conversation, oldest first. + pub fn open(&self, conversation: &Conversation) -> Vec { + self.entries.iter().filter(|e| e.request.state == State::Open && &e.request.conversation == conversation).map(|e| e.request.clone()).collect() + } + + /// The open questions of every app's conversation, oldest first. + pub fn open_in_apps(&self) -> Vec { + self.entries.iter().filter(|e| e.request.state == State::Open && matches!(e.request.conversation, Conversation::App(_))).map(|e| e.request.clone()).collect() + } + + pub fn get(&self, id: u64) -> Option<&Request> { + self.entries.iter().find(|e| e.request.id == id).map(|e| &e.request) + } + + fn prune(&mut self) { + let done = self.entries.iter().filter(|e| e.request.state != State::Open).count(); + if done > KEPT { + let mut drop = done - KEPT; + self.entries.retain(|e| { + if drop > 0 && e.request.state != State::Open { + drop -= 1; + false + } else { + true + } + }); + } + } +} + +static QUESTIONS: Mutex> = Mutex::new(None); + +fn with(f: impl FnOnce(&mut Questions) -> R) -> R { + let mut guard = QUESTIONS.lock().unwrap_or_else(|e| e.into_inner()); + f(guard.get_or_insert_with(Questions::default)) +} + +fn wake() { + makepad_widgets::makepad_platform::thread::SignalToUI::set_ui_signal(); +} + +/// Add a consumer (at startup). +pub fn subscribe(consumer: Box) { + with(|q| q.subscribe(consumer)); +} + +/// From the host (a broker's thread). +pub fn requested(peer_app: &str, account: Option<&str>, question: AgentQuestion, answer: QuestionAnswer) -> u64 { + let id = with(|q| q.requested(peer_app, account, question, answer)); + wake(); + id +} + +pub fn closed(peer_app: &str, question_id: &str) { + with(|q| q.closed(peer_app, question_id)); + wake(); +} + +/// The person's answer, from a shell surface. +pub fn answer(id: u64, replies: &[QuestionReply], by: &PersonAnswer) -> Result<(), String> { + let result = with(|q| q.answer(id, replies, by)); + wake(); + result +} + +pub fn open(conversation: &Conversation) -> Vec { + with(|q| q.open(conversation)) +} + +pub fn open_in_apps() -> Vec { + with(|q| q.open_in_apps()) +} + +pub fn get(id: u64) -> Option { + with(|q| q.get(id).cloned()) +} + +/// Bumped on every change (for redraws). +pub fn generation() -> u64 { + with(|q| q.generation()) +} diff --git a/crates/shell/src/questions/tests.rs b/crates/shell/src/questions/tests.rs new file mode 100644 index 00000000..8e269e33 --- /dev/null +++ b/crates/shell/src/questions/tests.rs @@ -0,0 +1,147 @@ +//! The question model: routing by who started the turn, consumers, the one +//! answer path, and that nothing an app reaches can answer. + +use super::*; +use crate::ai_host::app_peers::host_tools::{AgentQuestion, CallOrigin, QuestionAnswer, QuestionReply}; +use serde_json::{json, Value}; +use std::sync::{Arc, Mutex}; + +type Sent = Arc>>; + +fn question(id: &str, turn: &str, origin: CallOrigin, context: Option<&str>) -> AgentQuestion { + let mut q = AgentQuestion::parse( + &json!({"question_id": id, "turn_id": turn, "title": "Which room?", "body": "Pick one", + "questions": [{"header": "Room", "question": "Post where?", "options": [{"label": "#a", "description": ""}, {"label": "#b", "description": ""}]}]}), + "_main:api:octosense#peer-rinx-1", + ) + .unwrap(); + q.origin = origin; + q.context_id = context.map(str::to_string); + q.client = context.map(|_| "mini.news#1".to_string()); + q +} + +fn answer_handle() -> (QuestionAnswer, Sent) { + let sent: Sent = Arc::default(); + let s = sent.clone(); + (QuestionAnswer::new(move |v| s.lock().unwrap().push(v)), sent) +} + +struct Recorder(Arc>>); +impl Consumer for Recorder { + fn changed(&mut self, r: &Request) { + self.0.lock().unwrap().push((r.id, r.conversation.clone(), r.state.clone())); + } +} + +/// ADR 0004 §6: a context's or the peer's own turn asks in the app's +/// conversation; a `peer/input` turn (the system agent's) in the system +/// chat. Every consumer hears each change. +#[test] +fn a_question_is_routed_by_who_started_the_turn_and_every_consumer_hears_it() { + let mut model = Questions::default(); + let heard = Arc::new(Mutex::new(Vec::new())); + model.subscribe(Box::new(Recorder(heard.clone()))); + let (a1, _) = answer_handle(); + let (a2, _) = answer_handle(); + let (a3, _) = answer_handle(); + let by_person = model.requested("rinx", Some("@a:x"), question("q1", "t1", CallOrigin::Context, Some("ctx1")), a1); + let by_app = model.requested("rinx", Some("@a:x"), question("q2", "t2", CallOrigin::PeerOwn, None), a2); + let by_system = model.requested("card.os.news", None, question("q3", "t3", CallOrigin::PeerInput, None), a3); + let r = model.get(by_person).unwrap(); + assert_eq!((r.asker, &r.conversation), (Asker::Person, &Conversation::App("rinx".into()))); + assert_eq!(r.client.as_deref(), Some("mini.news#1")); + assert_eq!(model.get(by_app).unwrap().conversation, Conversation::App("rinx".into())); + let r = model.get(by_system).unwrap(); + assert_eq!((r.asker, &r.conversation), (Asker::SystemAgent, &Conversation::SystemChat)); + assert_eq!(r.app, "os.news", "a script app's peer names its app"); + assert_eq!(model.open(&Conversation::SystemChat).len(), 1); + assert_eq!(model.open_in_apps().len(), 2); + assert_eq!(heard.lock().unwrap().len(), 3); + assert_eq!(r.options(), vec!["#a".to_string(), "#b".to_string()]); +} + +#[test] +fn the_persons_answer_is_sent_once_and_a_closed_question_takes_none() { + let mut model = Questions::default(); + let heard = Arc::new(Mutex::new(Vec::new())); + model.subscribe(Box::new(Recorder(heard.clone()))); + let (answer, sent) = answer_handle(); + let id = model.requested("rinx", Some("@a:x"), question("q1", "t1", CallOrigin::PeerOwn, None), answer); + let person = PersonAnswer::from_shell_surface(); + assert!(model.answer(id, &[], &person).is_err(), "an answer is needed"); + assert!(model.answer(id, &[QuestionReply::option("#a"), QuestionReply::option("#b")], &person).is_err(), "one question, one answer"); + model.answer(id, &[QuestionReply::option("#b")], &person).unwrap(); + assert_eq!(*sent.lock().unwrap(), vec![json!([{"selected_labels": ["#b"]}])]); + assert!(model.answer(id, &[QuestionReply::option("#a")], &person).is_err(), "answered once"); + assert_eq!(model.get(id).unwrap().state, State::Answered("#b".into())); + // The turn ended before the person answered: closed, nothing sent. + let (answer, sent) = answer_handle(); + let id = model.requested("rinx", Some("@a:x"), question("q2", "t2", CallOrigin::PeerOwn, None), answer); + model.closed("rinx", "q2"); + assert_eq!(model.get(id).unwrap().state, State::Closed); + assert!(model.answer(id, &[QuestionReply::text("late")], &person).is_err()); + assert!(sent.lock().unwrap().is_empty()); + assert!(model.open_in_apps().is_empty()); + let states: Vec = heard.lock().unwrap().iter().map(|(_, _, s)| s.clone()).collect(); + assert_eq!(states, [State::Open, State::Answered("#b".into()), State::Open, State::Closed]); +} + +/// The system chat is a consumer: a `peer/input` turn's question shows in +/// its conversation, and the person's answer from the pane goes back to +/// the app peer's turn through the model (never the chat's own session). +#[test] +fn the_system_chat_shows_the_system_agents_questions_and_answers_them_through_the_model() { + crate::system_chat::subscribe_questions(); + let (answer, sent) = answer_handle(); + let id = super::requested("rinx", Some("@a:x"), question("q-chat", "t-chat", CallOrigin::PeerInput, None), answer); + let routed = format!("{}{id}", crate::system_chat::ROUTED_PREFIX); + let model = crate::system_chat::snapshot(); + let shown = model.items.iter().any(|i| matches!(i, crate::system_chat::model::Item::Question { id: q, answered: None, .. } if *q == routed)); + assert!(shown, "{:?}", model.items); + crate::system_chat::answer_option(&routed, 1, "#a"); + assert_eq!(*sent.lock().unwrap(), vec![json!([{"selected_labels": ["#a"]}])]); + assert_eq!(super::get(id).unwrap().state, State::Answered("#a".into())); + let model = crate::system_chat::snapshot(); + assert!(model.items.iter().any(|i| matches!(i, crate::system_chat::model::Item::Question { id: q, answered: Some(_), .. } if *q == routed))); +} + +/// Only the shell's own surfaces make a [`PersonAnswer`] and answer: no +/// host service, executor, peer link or app-facing code does. +#[test] +fn only_the_shells_surfaces_answer_questions() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let allowed = ["questions/mod.rs", "questions/tests.rs", "system_chat/mod.rs", "approvals/view.rs"]; + let mut found = Vec::new(); + let mut stack = vec![root.clone()]; + while let Some(dir) = stack.pop() { + for entry in std::fs::read_dir(&dir).unwrap().flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + continue; + } + if path.extension().and_then(|e| e.to_str()) != Some("rs") { + continue; + } + let text = std::fs::read_to_string(&path).unwrap(); + if text.contains("PersonAnswer::from_shell_surface(") || text.contains("questions::answer(") { + found.push(path.strip_prefix(&root).unwrap().to_string_lossy().replace('\\', "/")); + } + } + } + for file in &found { + assert!(allowed.contains(&file.as_str()), "{file} answers agents' questions; only the shell's surfaces may"); + } +} + +/// What an app reaches cannot answer a question (or an approval): the peer +/// link has no such method, and the `octos` host service refuses it. +#[test] +fn no_app_facing_path_can_answer() { + for method in ["octos.user_question.respond", "octos.question.answer", "octos.approval.respond", "user_question/respond", "approval/respond"] { + let frame = json!({"octos_peer": {"up": "request", "req_id": 1, "method": method, "args": {"question_id": "q1", "answers": []}}}).to_string(); + assert_eq!(crate::peer_link::wire::Up::parse(&frame), None, "the peer link carries no {method}"); + assert!(crate::ai_host::contained::parse(method, &json!({})).is_err(), "the octos host service has no {method}"); + } +} diff --git a/crates/shell/src/system_chat/mod.rs b/crates/shell/src/system_chat/mod.rs index 6deb4bc5..f2c79075 100644 --- a/crates/shell/src/system_chat/mod.rs +++ b/crates/shell/src/system_chat/mod.rs @@ -28,6 +28,12 @@ //! [`crate::approvals::Caller::External`] on an external connection, which //! holds and answers nothing (no developer mode, no rule, no sheet); the //! pane shows them read-only and the chat never answers them. +//! +//! **App agents' questions.** A question an app's agent asks on a turn the +//! system agent started (`peer/input`, ADR 0004 §6) is the system chat's: +//! the chat subscribes to [`crate::questions`] and shows each one in its +//! conversation (ids `routed:`); the person's answer goes back through +//! [`crate::questions::answer`], never through this chat's own session. pub mod grants; pub mod model; @@ -77,6 +83,47 @@ struct Chat { worker: Option, /// Scroll back from the newest line, in pixels. pub scroll: f64, + /// App agents' questions routed here ([`crate::questions`]), by id. + routed: Vec<(u64, model::Item)>, +} + +/// The id prefix of a routed question in the conversation. +pub const ROUTED_PREFIX: &str = "routed:"; + +/// The system chat as a consumer of [`crate::questions`]: the questions of +/// `peer/input` turns (the system agent's requests to app agents). +struct RoutedQuestions; + +impl crate::questions::Consumer for RoutedQuestions { + fn changed(&mut self, request: &crate::questions::Request) { + if request.conversation != crate::questions::Conversation::SystemChat { + return; + } + let answered = match &request.state { + crate::questions::State::Open => None, + crate::questions::State::Answered(text) => Some(text.clone()), + crate::questions::State::Closed => Some("(no longer asked)".to_string()), + }; + let item = model::Item::Question { + id: format!("{ROUTED_PREFIX}{}", request.id), + turn: request.turn_id.clone(), + title: if request.title.is_empty() { request.asked_by() } else { format!("{}: {}", request.asked_by(), request.title) }, + body: request.text().to_string(), + options: request.options(), + count: request.answer_count(), + answered, + }; + with(|c| { + match c.routed.iter_mut().find(|(id, _)| *id == request.id) { + Some(slot) => slot.1 = item, + None => c.routed.push((request.id, item)), + } + if c.routed.len() > 32 { + c.routed.remove(0); + } + c.ui_generation += 1; + }); + } } static CHAT: Mutex> = Mutex::new(None); @@ -90,6 +137,7 @@ fn with(f: impl FnOnce(&mut Chat) -> R) -> R { shared: Arc::new(Mutex::new(Shared { model: ChatModel::new(), effects: Vec::new() })), worker: None, scroll: 0.0, + routed: Vec::new(), }); f(chat) } @@ -157,9 +205,17 @@ fn command(cmd: Command) { // ------------------------------------------------------------ the pane -/// At startup: the command-execution grant of this home. +/// At startup: the command-execution grant of this home, and the app +/// agents' questions this chat shows. pub fn init(home: &std::path::Path) { grants::init(home); + subscribe_questions(); +} + +/// Show the system agent's routed questions here (once). +pub fn subscribe_questions() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| crate::questions::subscribe(Box::new(RoutedQuestions))); } pub fn is_open() -> bool { @@ -207,15 +263,29 @@ pub fn send(text: &str) { if text.is_empty() { return; } - let question = with(|c| c.shared.lock().unwrap_or_else(|e| e.into_inner()).model.open_question().map(|(id, n)| (id.to_string(), n))); + let question = snapshot().open_question().map(|(id, n)| (id.to_string(), n)); match question { - Some((question, count)) => command(Command::Answer { question, count, text: text.to_string(), option: false }), + Some((question, count)) => answer(&question, count, text, false), None => command(Command::Send(text.to_string())), } } pub fn answer_option(question: &str, count: usize, label: &str) { - command(Command::Answer { question: question.to_string(), count, text: label.to_string(), option: true }); + answer(question, count, label, true); +} + +/// The person answered a question from the pane: the chat's own on its +/// session; an app agent's routed one through [`crate::questions`]. +fn answer(question: &str, count: usize, text: &str, option: bool) { + if let Some(id) = question.strip_prefix(ROUTED_PREFIX).and_then(|n| n.parse::().ok()) { + use crate::ai_host::app_peers::host_tools::QuestionReply; + let reply = if option { QuestionReply::option(text) } else { QuestionReply::text(text) }; + if let Err(e) = crate::questions::answer(id, &[reply], &crate::questions::PersonAnswer::from_shell_surface()) { + log!("system chat: question {id}: {e}"); + } + return; + } + command(Command::Answer { question: question.to_string(), count, text: text.to_string(), option }); } pub fn interrupt() { @@ -237,9 +307,14 @@ pub fn new_conversation() { command(Command::NewConversation); } -/// The model as the pane draws it. +/// The model as the pane draws it: the conversation, then the app agents' +/// questions routed here. pub fn snapshot() -> ChatModel { - with(|c| c.shared.lock().unwrap_or_else(|e| e.into_inner()).model.clone()) + with(|c| { + let mut model = c.shared.lock().unwrap_or_else(|e| e.into_inner()).model.clone(); + model.items.extend(c.routed.iter().map(|(_, item)| item.clone())); + model + }) } pub fn draft() -> String { diff --git a/docs/adr/0004-native-apps-hosting-and-peers.md b/docs/adr/0004-native-apps-hosting-and-peers.md index 2877af66..a7404c5f 100644 --- a/docs/adr/0004-native-apps-hosting-and-peers.md +++ b/docs/adr/0004-native-apps-hosting-and-peers.md @@ -338,11 +338,11 @@ Gaps between this decision and `main` found after it was accepted (2026-09-28; s - **The system agent's exact tool list** (section 12, step 4): the `_main` profile's `tool_policy` is a ceiling, not the list. octos#2567's host session set can narrow the session with `generic_tools`, but that list narrows every client's turns on it, so the shell registers without it (as agreed on #2567); the exact list waits for a durable host-only list (octos#2605). - [x] **The Settings switch for the system agent's command execution** (section 12): done. Setup → Assistant → Command execution sets `SystemAgentTools::grant_command_execution` with the person's typed confirmation, applies from the next kernel start (with a restart offered), and routes each command through the approval router as `auto_approvable: false`. While it is on, the system chat registers `terminal.run` on the system session (octos#2567's host session target; withdrawn when it goes off), and each approved call is typed into the Terminal the person sees. - **The agent workspace is the account folder** (section 11): partly done. A peer created since the octos 665209e5 pin gets its account folder as the `peer/prepare` `cwd`, recorded beside its host token; a peer created before keeps the kernel-provisioned workspace, because octos resumes a peer only under the workspace it was made with. The account folder name (SHA-256, `app_storage`'s `account_hash`) and the memory namespace tag (FNV-1a, `account_tag` in the `crates/app-peers` broker) are two different hashes of one account; one should derive from the other. Changing the memory tag re-keys every app's memory, so it needs a migration (or the folder name follows the tag). -- [x] **octos#2567 in the shell** (plan step 6, in part): the broker registers every app peer's tools after each prepare and reconnect (`generic_tools` omitted), takes `peer/tool/call` (stamped, once per occurrence, nothing after a cancel, calls of an interrupted turn refused), starts `peer/input` turns and hands `host_tool` approvals to the router; the relay (`crates/shell/src/host_tools/`) authorizes each call by (owning app, tool) and caller and routes it to the owning app. Still open: no app declares `tools.json` (so every peer registers an empty set) and nothing grants cross-app tools yet; Rinx must hand its send sheet over (`OctosAppService::set_confirm_sheet`) to be the `rinx.message.send` confirmation; the host read tools are not registered; a peer's `user_question/requested` is not forwarded to any conversation yet (section 6; `host.ask` was dropped on 2026-09-29); a refused `peer/input` is only logged (octos has no way to tell the system agent); script apps' host services have no executor seam wired. +- [x] **octos#2567 in the shell** (plan step 6, in part): the broker registers every app peer's tools after each prepare and reconnect (`generic_tools` omitted), takes `peer/tool/call` (stamped, once per occurrence, nothing after a cancel, calls of an interrupted turn refused), starts `peer/input` turns and hands `host_tool` approvals to the router; the relay (`crates/shell/src/host_tools/`) authorizes each call by (owning app, tool) and caller and routes it to the owning app. Still open: no app declares `tools.json` (so every peer registers an empty set) and nothing grants cross-app tools yet; Rinx must hand its send sheet over (`OctosAppService::set_confirm_sheet`) to be the `rinx.message.send` confirmation; the host read tools are not registered; a peer's `user_question/requested` is forwarded to the shell's request model (`crates/shell/src/questions/`) and asked in the app's conversation or, for a system-agent turn, the system chat, answered only by the person on a shell surface (section 6; done 2026-09-29); a refused `peer/input` is only logged (octos has no way to tell the system agent); script apps' host services have no executor seam wired. - **Gaps from the 2026-09-29 code review** (octos acffad3b, OctoSense `main` ecb3583): - **Kernel-tool approvals on a `peer/input` turn are dropped.** The broker's event filter for the peer's own session (`crates/app-peers/src/broker.rs`, `inbound`) hands only `host_tool` approvals to the router and returns on every other event of that session, so an `approval/requested` for a kernel tool on a `peer/input` turn reaches no one and nobody can answer it. - **Contained script apps' cards** (`crates/ai-host/src/contained.rs`): streamed text is dropped (the app gets only the final answer), every approval is declined (listed in `denied_approvals`), each app has one request context shared by all its cards and UI, and every peer uses the fixed account `device`. - - **`user_question/requested` has no respond op for cards**: a card cannot answer its agent's question (item 11 of [the disagreements](../architecture.md#where-the-code-and-the-adrs-disagree), in progress). + - [x] **`user_question/requested` for cards**: by design a card never answers its agent's question; the broker hands every question on the peer's session or a context to the shell's request model (`crates/shell/src/questions/`), routed by the turn's origin, and the person answers on a shell surface (the app's conversation, or the system chat). - **A failed host `turn/start` for a `peer/input` is only logged** (`start_input` in `broker.rs`); the system agent is not told. - **The `peer/input` route belongs to whichever connection registered the peer's tools last**: octos `set_host_route` (`crates/octos-cli/src/peers/host_tools.rs`) replaces any earlier route. Untested with more than one instance of an app. - [x] **A shell-native chat with the system agent**: done. `crates/shell/src/system_chat/` (Setup → Assistant → Assistant chat, F8; full screen on a phone) talks to `_main:api:octosense#system` over the shell's own kernel connection, and its approvals go through the approval router, batched per request. diff --git a/docs/architecture.md b/docs/architecture.md index 2543fff6..d7627f29 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -232,7 +232,7 @@ octos's peer mechanism, depth 1 (peers cannot create, steer or close peers). **O - **System agent → app agent.** `peer_send_input` (originator only, at most 64 KB) delivers text as the peer's next user turn, through the peer's inbox (a durable queue in serve, drained every few seconds, at least once). - **App agent → system agent: the blackboard.** Each peer turn writes `peers//result.md` (plus `result-.md`) and a line in `turns.txt`; the system agent reads them with `peer_gather` and `peer_list` (`awaiting_input` shows a peer waiting on a question). It is the only cross-peer channel. -- **Questions.** A peer asks with `ask_user_question`; the system agent answers with `peer_respond`. `peer_respond` never answers approvals (octos refuses). octos keeps `ask_user_question` on host-driven turns (`generic_tools` is an exact list the host sets, and omitted keeps the whole roster). ADR 0004 §6 (decided 2026-09-29) has the shell route each question to the right conversation: the app's own conversation, or the system chat for system-agent turns, `peer/input` turns included; there is no `host.ask`. **On main** for the system agent's own questions (the system chat answers `user_question/requested` on the system session); **planned** for app peers (the broker does not forward a peer's questions yet). +- **Questions.** A peer asks with `ask_user_question`; the system agent answers with `peer_respond`. `peer_respond` never answers approvals (octos refuses). octos keeps `ask_user_question` on host-driven turns (`generic_tools` is an exact list the host sets, and omitted keeps the whole roster). ADR 0004 §6 (decided 2026-09-29) has the shell route each question to the right conversation: the app's own conversation, or the system chat for system-agent turns, `peer/input` turns included; there is no `host.ask`. **On main**: the system chat answers the system agent's own questions on its session; the broker hands an app peer's `user_question/requested` (its own session, a request context, a `peer/input` turn) to the shell's request model (`crates/shell/src/questions/`) with the turn's origin (reported by octos once it carries it, else derived: the turns the broker started for `peer/input` are the system agent's), and the model routes it by that origin: a person's or the app's turn to the app's conversation (the shell's overlay), a system-agent turn to the system chat. Only the person answers, on a shell surface (`user_question/respond` on the broker's link); the app's context hears `user_question/handled_by_host`, and the broker refuses an app's attempt to answer an id the host holds. A question closes when its turn ends. - **One shared conversation per app agent.** ADR 0004 §6 (decided 2026-09-29): the person (from the app's UI or its cards) and the system agent talk to the same peer session, each turn tagged with its `origin` (`person`, `system_agent`, `app`; a system-agent turn cannot be relabelled by the host), so the app's conversation shows the system agent's exchanges and the blackboard reflects the person's turns. **Planned**: it needs an octos change (octos PR pending); on main the person's turns run in request contexts and the two sides do not see each other's exchanges. ### The host-owned path: `peer/input` @@ -449,7 +449,7 @@ Found while writing this page (2026-09-28) and rechecked against `main` on 2026- 8. **Approvals, ADR 0004 §8.** Every app tool call reaches the shell through `peer/tool/call` and every gated one the router (above). No app registers its own `confirm: app` sheet yet (Rinx needs to hand its send sheet over through `OctosAppService::set_confirm_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. 9. **Storage, ADR 0004 §11.** Secrets use the OS keychain only on macOS and iOS (plaintext 0600 files elsewhere). The startup check refuses workspaces that reach the secrets through links or containment rather than looking for a `secrets/` path, and does not stop startup. (Fixed: `storage.accounts` defaults to `false` in both app storage and the consent sheet, which now reads `StorageSpec`.) 10. **Developer mode, ADR 0004 §13.** `dev.run` is not registered; Settings turns it on only for all apps (a chosen list only through `OCTOSENSE_DEV_MODE`); there is no phone gesture; in-process modules still show their own confirmation sheets. -11. **Questions, ADR 0004 §6 (decided 2026-09-29).** Agents ask with octos's `ask_user_question` and the shell routes each question to the app's conversation or the system chat; `host.ask` is dropped. The system chat answers the system agent's own questions, but the broker does not forward an app peer's `user_question/requested` (on its own session, a request context or a `peer/input` turn) to any conversation yet, and cards have no op to answer one (in progress). +11. **Questions, ADR 0004 §6 (decided 2026-09-29).** Agents ask with octos's `ask_user_question` and the shell routes each question to the app's conversation or the system chat; `host.ask` is dropped. Fixed: the broker forwards an app peer's `user_question/requested` to the shell's request model (`crates/shell/src/questions/`), which routes it by the turn's origin to the app's conversation or the system chat; only the person answers, on a shell surface (cards never answer). An agent asks only when its `generic_tools` list `ask_user_question`. 12. **Process sandboxes, ADR 0004 §3.** Only the macOS sandbox is built and tested; Linux is built but unvalidated (#138) and Windows is not built (#137). 13. **App agents, from the 2026-09-29 code review** (octos acffad3b, `main` ecb3583; listed in ADR 0004's follow-ups): kernel-tool approvals on a `peer/input` turn are dropped by the broker's event filter for the peer session (`broker.rs`), so nobody can answer them; contained script apps' cards get no streaming, have every approval declined, share one context per app and use the fixed account `device` (`crates/ai-host/src/contained.rs`); a failed host `turn/start` for a `peer/input` is only logged; the `peer/input` route belongs to whichever connection registered the peer's tools last (untested with several instances). diff --git a/docs/architecture.zh-CN.md b/docs/architecture.zh-CN.md index ac04efb5..d98c0384 100644 --- a/docs/architecture.zh-CN.md +++ b/docs/architecture.zh-CN.md @@ -232,7 +232,7 @@ flowchart LR - **系统 Agent → 应用 Agent。** `peer_send_input`(只有发起者可用,最多 64 KB)经 peer 的收件箱把文字作为 peer 的下一个用户回合送达(在 serve 中是持久队列,每隔几秒处理一次,至少送达一次)。 - **应用 Agent → 系统 Agent:黑板。** peer 的每个回合写入 `peers//result.md`(以及 `result-.md`),并在 `turns.txt` 中追加一行;系统 Agent 用 `peer_gather` 和 `peer_list` 读取(`awaiting_input` 表示 peer 正在等待回答问题)。这是 peer 之间唯一的通道。 -- **提问。** peer 用 `ask_user_question` 提问;系统 Agent 用 `peer_respond` 回答。`peer_respond` 从不回答审批(octos 会拒绝)。宿主驱动的回合没有 `ask_user_question`,因此 ADR 0004 §6 增加了 Shell 工具 **`host.ask`**,由 Shell 转给系统 Agent 或应用自己的对话。**规划中**(步骤 6)。 +- **提问。** peer 用 `ask_user_question` 提问;系统 Agent 用 `peer_respond` 回答。`peer_respond` 从不回答审批(octos 会拒绝)。octos 在宿主驱动的回合中保留 `ask_user_question`(`generic_tools` 是宿主设定的精确列表)。ADR 0004 §6(2026-09-29 决定)由 Shell 把每个问题转到正确的对话,没有 `host.ask`。**已在 main 上**:系统对话回答系统 Agent 自己会话上的问题;broker 把应用 peer 的 `user_question/requested`(其自身会话、请求上下文或 `peer/input` 回合)连同回合的发起者(octos 报告时用它,否则推断:broker 为 `peer/input` 发起的回合属于系统 Agent)交给 Shell 的请求模型(`crates/shell/src/questions/`),按发起者路由:用户或应用的回合转到应用的对话(Shell 的浮层),系统 Agent 的回合转到系统对话。只有用户能在 Shell 界面上回答(在 broker 的连接上发送 `user_question/respond`);应用的上下文只收到 `user_question/handled_by_host`,broker 拒绝应用回答宿主持有的 id。回合结束时问题关闭。 - **每个应用 Agent 一个共享对话。** ADR 0004 §6(2026-09-29 决定):用户(来自应用界面或其卡片)和系统 Agent 与同一个 peer 会话对话,每个回合标明说话者 `origin`(`person`、`system_agent`、`app`;宿主不能改写系统 Agent 回合的标记),因此应用的对话能看到系统 Agent 的往来,黑板也反映用户的回合。**规划中**:需要 octos 改动(octos PR 待提交);main 上用户的回合在请求上下文中运行,双方看不到彼此的往来。 ### 宿主拥有的路径:`peer/input` @@ -449,7 +449,7 @@ sequenceDiagram 8. **审批,ADR 0004 §8。** 每次应用工具调用都通过 `peer/tool/call` 到达 Shell,每个有门控的调用都到达路由(见上文)。还没有应用注册自己的 `confirm: app` 面板(Rinx 需要通过 `OctosAppService::set_confirm_sheet` 交出它的发送面板),因此这类调用会等待后被拒绝。审计记录的是参数摘要而不是参数。发送队列和撤销窗口尚未实现。 9. **存储,ADR 0004 §11。** 机密只在 macOS 和 iOS 上使用系统钥匙串(其他平台为 0600 明文文件)。启动检查拒绝通过链接或包含关系通向机密的工作区,而不是查找 `secrets/` 路径,并且不会中止启动。(已修复:app storage 和同意面板中 `storage.accounts` 都默认为 `false`,同意面板现在读取 `StorageSpec`。) 10. **开发者模式,ADR 0004 §13。** `dev.run` 尚未注册;Settings 只能为所有应用开启(选定应用只能通过 `OCTOSENSE_DEV_MODE`);手机上没有开启手势;进程内模块仍会显示自己的确认面板。 -11. **应用 Agent,来自 2026-09-29 的代码审查**(octos acffad3b、`main` ecb3583;列在 ADR 0004 的后续事项中):`peer/input` 回合上内核工具的审批被 broker 对 peer 会话的事件过滤丢弃(`broker.rs`),无人能回答;受限脚本应用的卡片没有流式输出、所有审批都被拒绝、每个应用共用一个上下文并使用固定账号 `device`(`crates/ai-host/src/contained.rs`);卡片没有回答 `user_question/requested` 的操作(进行中);`peer/input` 的宿主 `turn/start` 失败只记日志;`peer/input` 路由归最后注册该 peer 工具的连接所有(多实例未测试)。 +11. **应用 Agent,来自 2026-09-29 的代码审查**(octos acffad3b、`main` ecb3583;列在 ADR 0004 的后续事项中):`peer/input` 回合上内核工具的审批被 broker 对 peer 会话的事件过滤丢弃(`broker.rs`),无人能回答;受限脚本应用的卡片没有流式输出、所有审批都被拒绝、每个应用共用一个上下文并使用固定账号 `device`(`crates/ai-host/src/contained.rs`);卡片从不回答 `user_question/requested`:问题由 Shell 的请求模型按回合发起者路由,只由用户在 Shell 界面上回答(已修复);`peer/input` 的宿主 `turn/start` 失败只记日志;`peer/input` 路由归最后注册该 peer 工具的连接所有(多实例未测试)。 ## 源码位置 From e22667deb9ebc10b18cdbd606bd053a37f592f85 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:52:53 -0700 Subject: [PATCH 2/9] feat(native-apps): the agent block is generated; Rinx's grant comes from the manifest [skip ci] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit native-apps.json's `agent` block now carries what an app's agent gets, reviewed with the entry (ADR 0004 §1, §7, §12): - `tools`: the app's own tools, as `tools.json` entries (the Terminal's `terminal.run`, `terminal.read_screen`, `terminal.read_scrollback`); - `generic_tools`: exactly the octos kernel tools its agent gets (Rinx: its workspace files, `ask_user_question`, memory, the web); never octos's shell (the generator refuses `shell`, `bash`, `exec_command`, `write_stdin`, `group:runtime`); - `grants`: other apps' shareable tools, `{app, tool}` (a native owner's tool must be declared and shareable); - `budget`: tool calls per turn and per day. tools/native_apps.py validates it, generates it into crates/shell/src/native_apps.rs, and generates crates/ai-host/src/native_agents.rs: the `octos.*` grants `Policy::shipped()` now hands out instead of a hard-coded Rinx line (same behaviour: Rinx gets the four `octos.*` services). --check covers both files; generator and ai-host tests. Co-Authored-By: Claude Opus 5.5 --- crates/ai-host/src/lib.rs | 16 ++- crates/ai-host/src/native_agents.rs | 10 ++ crates/ai-host/src/tests.rs | 10 +- crates/shell/src/native_apps.rs | 43 +++++++ crates/shell/src/sandbox/tests.rs | 5 + native-apps.json | 17 ++- tools/native_apps.py | 175 +++++++++++++++++++++++++++- tools/test_native_apps.py | 65 ++++++++++- 8 files changed, 330 insertions(+), 11 deletions(-) create mode 100644 crates/ai-host/src/native_agents.rs diff --git a/crates/ai-host/src/lib.rs b/crates/ai-host/src/lib.rs index b6e181d3..cc1ad5cb 100644 --- a/crates/ai-host/src/lib.rs +++ b/crates/ai-host/src/lib.rs @@ -53,6 +53,8 @@ mod bridge; pub mod contained; +/// Generated from `native-apps.json` (`tools/native_apps.py`). +pub mod native_agents; mod qr; #[cfg(feature = "toolbox-peers")] pub mod toolbox_peers; @@ -151,9 +153,12 @@ impl Policy { Policy::default() } - /// The native modules that ship with OctoSense: Rinx (its native mini-app - /// host serves these to reviewed mini apps). The person's AI provider - /// choice lives in AI providers; a per-app toggle is future work. + /// The native apps that ship with OctoSense, each with the `octos.*` + /// services its reviewed `native-apps.json` entry grants its agent + /// (`agent.octos`; generated into [`native_agents::NATIVE_AGENTS`] by + /// `tools/native_apps.py`): today Rinx, whose native mini-app host serves + /// them to reviewed mini apps. The person's AI provider choice lives in + /// AI providers; the person consents per app at first use. /// /// The `octos` service for contained apps follows consent in the /// shipped policy: an app gets its agent once the person allowed it at @@ -162,7 +167,10 @@ impl Policy { /// on for every app without asking, `0` off. pub fn shipped() -> Self { 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) + native_agents::NATIVE_AGENTS + .iter() + .fold(Policy::none(), |policy, (app, services)| policy.allow(app, services.iter().copied())) + .with_contained_gate(gate) } /// On (behind consent) or off. diff --git a/crates/ai-host/src/native_agents.rs b/crates/ai-host/src/native_agents.rs new file mode 100644 index 00000000..2267c1bd --- /dev/null +++ b/crates/ai-host/src/native_agents.rs @@ -0,0 +1,10 @@ +//! @generated by tools/native_apps.py from native-apps.json: do not edit; +//! change the manifest and run the script (ADR 0004 §1, §4). +//! +//! The native apps whose reviewed entry grants their agent `octos.*` +//! services (`agent.octos`): what [`crate::Policy::shipped`] hands out. + +/// (app id, the `octos.*` services its agent is granted). +pub const NATIVE_AGENTS: &[(&str, &[&str])] = &[ + ("rinx", &["octos.session.open", "octos.session.history", "octos.turn.start", "octos.turn.interrupt"]), +]; diff --git a/crates/ai-host/src/tests.rs b/crates/ai-host/src/tests.rs index cace49e3..7e2dd150 100644 --- a/crates/ai-host/src/tests.rs +++ b/crates/ai-host/src/tests.rs @@ -104,7 +104,8 @@ fn rinx_is_granted_before_start() { assert!(offered.finish().is_some()); } -/// The shipped policy grants Rinx exactly the assistant services. +/// The shipped policy grants Rinx exactly the assistant services, and it +/// comes from `native-apps.json` (the generated `native_agents`), not code. #[test] fn the_shipped_policy_grants_rinx_the_assistant() { let policy = Policy::shipped(); @@ -113,6 +114,13 @@ fn the_shipped_policy_grants_rinx_the_assistant() { assert_eq!(grants[0].0, "rinx"); assert_eq!(grants[0].1, octosense_app_peers::OCTOS_SERVICES.map(String::from)); assert!(Policy::none().grants().next().is_none()); + let generated: Vec<(&str, Vec)> = crate::native_agents::NATIVE_AGENTS.iter().map(|(app, s)| (*app, s.iter().map(|s| s.to_string()).collect())).collect(); + let shipped: Vec<(&str, Vec)> = policy.grants().map(|(app, s)| (app, s.to_vec())).collect(); + assert_eq!(shipped, generated, "Policy::shipped() is the manifest's agent block"); + let manifest: serde_json::Value = serde_json::from_str(include_str!("../../../native-apps.json")).unwrap(); + let rinx = manifest["apps"].as_array().unwrap().iter().find(|a| a["id"] == "rinx").unwrap(); + let declared: Vec = rinx["agent"]["octos"].as_array().unwrap().iter().map(|s| s.as_str().unwrap().to_string()).collect(); + assert_eq!(grants[0].1, declared); } #[test] diff --git a/crates/shell/src/native_apps.rs b/crates/shell/src/native_apps.rs index cb9e1265..2b51889e 100644 --- a/crates/shell/src/native_apps.rs +++ b/crates/shell/src/native_apps.rs @@ -76,6 +76,19 @@ pub struct NativeApp { /// The whole `storage` block as JSON, which the shell parses with /// `app_storage::StorageSpec` at startup (ADR 0004 §11). pub storage: &'static str, + /// `agent.tools`: its own tools, the `tools.json` entries (a JSON + /// array; empty when it declares none). + pub tools_json: &'static str, + /// `agent.generic_tools`: exactly the octos kernel tools its agent + /// gets (none when empty; never octos's shell). + pub generic_tools: &'static [&'static str], + /// `agent.grants`: other apps' shareable tools its agent may call, + /// as (owning app, tool). + pub grants: &'static [(&'static str, &'static str)], + /// `agent.budget`: its agent's tool calls per turn and per day + /// (`None`: the shell's defaults). + pub calls_per_turn: Option, + pub calls_per_day: Option, } pub const APPS: &[NativeApp] = &[ @@ -97,6 +110,11 @@ pub const APPS: &[NativeApp] = &[ accounts: true, external: &[], storage: r#"{"accounts": true, "agent_workspace": "account", "external": []}"#, + tools_json: r##"[]"##, + generic_tools: &["read_file", "write_file", "edit_file", "diff_edit", "apply_patch", "glob", "grep", "list_dir", "code_structure", "ask_user_question", "view_image", "recall", "recall_memory", "memory_search", "memory_load", "save_memory", "memory_note", "web_search", "web_fetch", "tool_search"], + grants: &[], + calls_per_turn: None, + calls_per_day: None, }, NativeApp { id: "reference", @@ -116,6 +134,11 @@ pub const APPS: &[NativeApp] = &[ accounts: false, external: &[], storage: r#"{"accounts": false, "agent_workspace": "none", "external": []}"#, + tools_json: r##"[]"##, + generic_tools: &[], + grants: &[], + calls_per_turn: None, + calls_per_day: None, }, NativeApp { id: "sheets", @@ -135,6 +158,11 @@ pub const APPS: &[NativeApp] = &[ accounts: false, external: &[], storage: r#"{"accounts": false, "agent_workspace": "none", "external": []}"#, + tools_json: r##"[]"##, + generic_tools: &[], + grants: &[], + calls_per_turn: None, + calls_per_day: None, }, NativeApp { id: "terminal", @@ -156,6 +184,11 @@ pub const APPS: &[NativeApp] = &[ accounts: false, external: &["home:rw"], storage: r#"{"accounts": false, "agent_workspace": "none", "external": ["home:rw"]}"#, + tools_json: r##"[{"name":"terminal.run","description":"Type a command followed by Enter into the person's live Terminal. The person approves each command first, on a sheet that shows it exactly; it then runs for real, unsandboxed, in the terminal they see. It returns at once: the output is on the Terminal's screen.","input_schema":{"type":"object","properties":{"command":{"type":"string","maxLength":4096}},"required":["command"],"additionalProperties":false},"risk":"destructive","confirm":"host","shareable":true},{"name":"terminal.read_screen","description":"Read the terminal grid that is visible now, with trailing spaces removed from each row, plus cursor and working-directory context.","input_schema":{"type":"object","properties":{},"additionalProperties":false},"risk":"read","shareable":true},{"name":"terminal.read_scrollback","description":"Read the last requested number of lines from the terminal's scrollback plus screen (default 200, at most 2000).","input_schema":{"type":"object","properties":{"lines":{"type":"integer","minimum":1,"maximum":2000}},"additionalProperties":false},"risk":"read","shareable":true}]"##, + generic_tools: &[], + grants: &[], + calls_per_turn: None, + calls_per_day: None, }, NativeApp { id: "appcard", @@ -175,6 +208,11 @@ pub const APPS: &[NativeApp] = &[ accounts: false, external: &[], storage: r#"{"accounts": false, "agent_workspace": "none", "external": []}"#, + tools_json: r##"[]"##, + generic_tools: &[], + grants: &[], + calls_per_turn: None, + calls_per_day: None, }, NativeApp { id: "apphub", @@ -194,6 +232,11 @@ pub const APPS: &[NativeApp] = &[ accounts: false, external: &[], storage: r#"{"accounts": false, "agent_workspace": "none", "external": []}"#, + tools_json: r##"[]"##, + generic_tools: &[], + grants: &[], + calls_per_turn: None, + calls_per_day: None, }, ]; diff --git a/crates/shell/src/sandbox/tests.rs b/crates/shell/src/sandbox/tests.rs index f2342105..75a01f89 100644 --- a/crates/shell/src/sandbox/tests.rs +++ b/crates/shell/src/sandbox/tests.rs @@ -46,6 +46,11 @@ fn jail_only(root: &Path, hub_port: u16) -> Policy { accounts: false, external: &[], storage: "{}", + tools_json: "[]", + generic_tools: &[], + grants: &[], + calls_per_turn: None, + calls_per_day: None, }; // The scratch root stands in for the person's home: closed but for the // jail. The probe's tools are its program. diff --git a/native-apps.json b/native-apps.json index ad55e8ec..3da4cc6f 100644 --- a/native-apps.json +++ b/native-apps.json @@ -15,7 +15,12 @@ "native_mobile": "feature", "sandbox": { "network": "any", "processes": false }, "storage": { "accounts": true, "agent_workspace": "account", "external": [] }, - "agent": { "octos": ["octos.session.open", "octos.session.history", "octos.turn.start", "octos.turn.interrupt"], "tools": null } + "agent": { + "octos": ["octos.session.open", "octos.session.history", "octos.turn.start", "octos.turn.interrupt"], + "tools": null, + "generic_tools": ["read_file", "write_file", "edit_file", "diff_edit", "apply_patch", "glob", "grep", "list_dir", "code_structure", "ask_user_question", "view_image", "recall", "recall_memory", "memory_search", "memory_load", "save_memory", "memory_note", "web_search", "web_fetch", "tool_search"], + "grants": [] + } }, { "id": "reference", @@ -63,7 +68,15 @@ "native_mobile": "feature", "sandbox": { "network": "any", "processes": true }, "storage": { "accounts": false, "agent_workspace": "none", "external": ["home:rw"] }, - "agent": { "octos": [], "tools": null, "tool_policy": { "run": { "confirm": "host", "auto_approvable": false } } } + "agent": { + "octos": [], + "tools": [ + {"name": "terminal.run", "description": "Type a command followed by Enter into the person's live Terminal. The person approves each command first, on a sheet that shows it exactly; it then runs for real, unsandboxed, in the terminal they see. It returns at once: the output is on the Terminal's screen.", "input_schema": {"type": "object", "properties": {"command": {"type": "string", "maxLength": 4096}}, "required": ["command"], "additionalProperties": false}, "risk": "destructive", "confirm": "host", "shareable": true}, + {"name": "terminal.read_screen", "description": "Read the terminal grid that is visible now, with trailing spaces removed from each row, plus cursor and working-directory context.", "input_schema": {"type": "object", "properties": {}, "additionalProperties": false}, "risk": "read", "shareable": true}, + {"name": "terminal.read_scrollback", "description": "Read the last requested number of lines from the terminal's scrollback plus screen (default 200, at most 2000).", "input_schema": {"type": "object", "properties": {"lines": {"type": "integer", "minimum": 1, "maximum": 2000}}, "additionalProperties": false}, "risk": "read", "shareable": true} + ], + "tool_policy": { "run": { "confirm": "host", "auto_approvable": false } } + } }, { "id": "appcard", diff --git a/tools/native_apps.py b/tools/native_apps.py index 2e8e9838..1d206b73 100644 --- a/tools/native_apps.py +++ b/tools/native_apps.py @@ -17,9 +17,13 @@ desktop/Cargo.toml, the package's default features and the forwarded phone/Cargo.toml `app-` / `mobile-apps` features ("features") -and the whole of `crates/shell/src/native_apps.rs`: what `linked_modules()` -pushes under which feature, and each app's hosting per target (the default -the shell uses when the person has not switched an app). +the whole of `crates/shell/src/native_apps.rs`: what `linked_modules()` +pushes under which feature, each app's hosting per target (the default the +shell uses when the person has not switched an app) and its agent block +(its own tools, the kernel tools and other apps' tools its agent is +granted, its tool-call budget); and the whole of +`crates/ai-host/src/native_agents.rs`: the `octos.*` services each app's +agent is granted, which `Policy::shipped()` hands out. It refuses `process` hosting on mobile or wasm targets, plain `process` on Linux (non-Vulkan Linux runs every app in-process: `process-if-vulkan`), @@ -41,6 +45,7 @@ ROOT = Path(__file__).resolve().parents[1] MANIFEST = "native-apps.json" RUST_FILE = "crates/shell/src/native_apps.rs" +AGENTS_FILE = "crates/ai-host/src/native_agents.rs" PACKAGES = {"desktop": "desktop/Cargo.toml", "phone": "phone/Cargo.toml"} TARGETS = ("macos", "windows", "linux", "android", "ios", "ohos") @@ -169,6 +174,8 @@ def validate(data): problems.append(f"{where}: agent.octos must be a list") if isinstance(app["agent"], dict): problems += [f"{where}: {p}" for p in tool_policy_problems(app["agent"].get("tool_policy", {}))] + problems += [f"{where}: {p}" for p in agent_problems(ident, app["agent"])] + problems += grant_problems([a for a in data["apps"] if isinstance(a, dict) and isinstance(a.get("agent"), dict)]) if problems: raise ManifestError(f"{MANIFEST}:\n " + "\n ".join(problems)) return data["apps"] @@ -242,6 +249,122 @@ def tool_policy_problems(policy): return problems +AGENT_KEYS = {"octos", "tools", "generic_tools", "grants", "tool_policy", "budget"} +# The fields a `tools.json` entry may carry (octos `ToolDecl`, UPCR-2026-035); +# `app` is the shell's to set. +DECL_FIELDS = {"name", "description", "input_schema", "output_schema", "risk", "background", "outward", "confirm", "shareable"} +RISKS = ("read", "act", "destructive") +# octos's own shell: never granted to an app agent (ADR 0004 §12); command +# execution is a host tool (`terminal.run`) with a live approval. +OCTOS_SHELL = ("shell", "bash", "exec_command", "write_stdin", "group:runtime") + + +def agent_problems(ident, agent): + """The agent block (ADR 0004 §1, §7, §12): `octos` (its `octos.*` + services), `tools` (null, or its own `tools.json` entries, named + `.`), `generic_tools` (the exact octos kernel tools its agent + gets; never octos's shell), `grants` (other apps' shareable tools, + `{"app", "tool"}`), `budget` (`calls_per_turn`, `calls_per_day`).""" + problems = [] + unknown = sorted(set(agent) - AGENT_KEYS) + if unknown: + problems.append(f"agent: unknown {', '.join(unknown)}") + tools = agent.get("tools") + names = [] + if tools is not None: + if not isinstance(tools, list) or not tools: + problems.append("agent.tools must be null or a non-empty list of tools.json entries") + tools = [] + for i, tool in enumerate(tools): + where = f"agent.tools[{i}]" + if not isinstance(tool, dict): + problems.append(f"{where}: not an object") + continue + extra = sorted(set(tool) - DECL_FIELDS) + if extra: + problems.append(f"{where}: unknown {', '.join(extra)} (the kernel refuses them)") + name = tool.get("name") + if not isinstance(name, str) or not re.fullmatch(rf"{re.escape(ident)}(\.[a-z][a-z0-9_]*)+", name): + problems.append(f"{where}: name must be {ident}.") + elif name in names: + problems.append(f"{where}: {name} is declared twice") + else: + names.append(name) + if not isinstance(tool.get("description"), str) or not tool["description"].strip(): + problems.append(f"{where}: description is required") + schema = tool.get("input_schema") + if not isinstance(schema, dict) or schema.get("type") != "object": + problems.append(f"{where}: input_schema must describe an object") + if "output_schema" in tool and not isinstance(tool["output_schema"], dict): + problems.append(f"{where}: output_schema must be a schema object") + if tool.get("risk") not in RISKS: + problems.append(f"{where}: risk must be one of {', '.join(RISKS)}") + if tool.get("confirm", "host") not in ("host", "app"): + problems.append(f"{where}: confirm must be 'host' or 'app'") + for flag in ("shareable", "background", "outward"): + if flag in tool and not isinstance(tool[flag], bool): + problems.append(f"{where}: {flag} must be true or false") + policy = agent.get("tool_policy", {}) + if isinstance(policy, dict) and tools is not None: + short = {n.split(".", 1)[1] for n in names} + for rule in policy: + if rule not in short: + problems.append(f"agent.tool_policy.{rule}: {ident} declares no tool {ident}.{rule}") + generic = agent.get("generic_tools", []) + if not isinstance(generic, list): + problems.append("agent.generic_tools must be a list of octos tool names") + generic = [] + for g in generic: + if g in OCTOS_SHELL: + problems.append(f"agent.generic_tools: {g} is octos's own shell, which no app agent gets " + "(ADR 0004 §12: command execution is a host tool)") + elif not (isinstance(g, str) and re.fullmatch(r"[A-Za-z0-9_-]{1,64}", g)): + problems.append(f"agent.generic_tools: {g!r} is not an octos tool name") + if len(set(map(str, generic))) != len(generic): + problems.append("agent.generic_tools names a tool twice") + budget = agent.get("budget") + if budget is not None: + if not isinstance(budget, dict) or not set(budget) <= {"calls_per_turn", "calls_per_day"}: + problems.append("agent.budget is {calls_per_turn?, calls_per_day?}") + else: + for key, value in budget.items(): + if not isinstance(value, int) or isinstance(value, bool) or value < 1: + problems.append(f"agent.budget.{key} must be a positive integer") + grants = agent.get("grants", []) + if not isinstance(grants, list) or not all(isinstance(g, dict) and set(g) == {"app", "tool"} + and all(isinstance(g[k], str) for k in g) for g in grants): + problems.append('agent.grants must be a list of {"app": , "tool": }') + return problems + + +def grant_problems(apps): + """Each cross-app grant names another app's shareable tool: a native + owner's tool must be in its `agent.tools` and `shareable`; a script + owner (`os.*`) is checked when App Hub loads its `tools.json`.""" + problems = [] + declared = {a.get("id"): {t.get("name"): t for t in (a["agent"].get("tools") or []) if isinstance(t, dict)} for a in apps} + for app in apps: + grants = app["agent"].get("grants", []) + if not isinstance(grants, list): + continue + for grant in grants: + if not isinstance(grant, dict) or set(grant) != {"app", "tool"}: + continue + owner, tool = grant["app"], grant["tool"] + where = f"{app.get('id')}: agent.grants {owner}/{tool}" + if owner == app.get("id"): + problems.append(f"{where}: an app's own tools need no grant") + elif owner in declared: + entry = declared[owner].get(tool) + if entry is None: + problems.append(f"{where}: {owner} declares no {tool}") + elif entry.get("shareable") is not True: + problems.append(f"{where}: {tool} is not shareable") + elif not (isinstance(owner, str) and owner.startswith("os.")): + problems.append(f"{where}: no native app {owner}, and not a system app (os.*)") + return problems + + def feature_of(app): return app.get("feature") or f"app-{app['id']}" @@ -453,6 +576,19 @@ def render_rust(apps): " /// The whole `storage` block as JSON, which the shell parses with", " /// `app_storage::StorageSpec` at startup (ADR 0004 §11).", " pub storage: &'static str,", + " /// `agent.tools`: its own tools, the `tools.json` entries (a JSON", + " /// array; empty when it declares none).", + " pub tools_json: &'static str,", + " /// `agent.generic_tools`: exactly the octos kernel tools its agent", + " /// gets (none when empty; never octos's shell).", + " pub generic_tools: &'static [&'static str],", + " /// `agent.grants`: other apps' shareable tools its agent may call,", + " /// as (owning app, tool).", + " pub grants: &'static [(&'static str, &'static str)],", + " /// `agent.budget`: its agent's tool calls per turn and per day", + " /// (`None`: the shell's defaults).", + " pub calls_per_turn: Option,", + " pub calls_per_day: Option,", "}", "", "pub const APPS: &[NativeApp] = &[", @@ -486,6 +622,17 @@ def render_rust(apps): external = ", ".join(s(x) for x in app["storage"]["external"]) out.append(f" external: &[{external}],") out.append(f" storage: {rust_raw(json.dumps(app['storage'], separators=(', ', ': ')))},") + agent = app["agent"] + tools = json.dumps(agent.get("tools") or [], separators=(",", ":"), ensure_ascii=False) + out.append(f" tools_json: r##\"{tools}\"##,") + generic = ", ".join(s(x) for x in agent.get("generic_tools", [])) + out.append(f" generic_tools: &[{generic}],") + grants = ", ".join(f"({s(g['app'])}, {s(g['tool'])})" for g in agent.get("grants", [])) + out.append(f" grants: &[{grants}],") + budget = agent.get("budget") or {} + for key in ("calls_per_turn", "calls_per_day"): + value = f"Some({budget[key]})" if key in budget else "None" + out.append(f" {key}: {value},") out.append(" },") out += [ "];", @@ -542,6 +689,27 @@ def render_rust(apps): return "\n".join(out) +def render_agents(apps): + """`crates/ai-host/src/native_agents.rs`: the agent grants + `Policy::shipped()` hands out, one line per app granted `octos.*`.""" + out = [ + "//! @generated by tools/native_apps.py from native-apps.json: do not edit;", + "//! change the manifest and run the script (ADR 0004 §1, §4).", + "//!", + "//! The native apps whose reviewed entry grants their agent `octos.*`", + "//! services (`agent.octos`): what [`crate::Policy::shipped`] hands out.", + "", + "/// (app id, the `octos.*` services its agent is granted).", + "pub const NATIVE_AGENTS: &[(&str, &[&str])] = &[", + ] + for app in apps: + octos = app["agent"]["octos"] + if octos: + out.append(f" ({s(app['id'])}, &[{', '.join(s(x) for x in octos)}]),") + out += ["];", ""] + return "\n".join(out) + + # --- Marked blocks --------------------------------------------------------- @@ -631,6 +799,7 @@ def read(rel): raise ManifestError(f"{rel} is missing") out[rel] = (text, replace_blocks(text, package_block(apps, shell, text), rel)) out[RUST_FILE] = (read(RUST_FILE), render_rust(apps)) + out[AGENTS_FILE] = (read(AGENTS_FILE), render_agents(apps)) return out diff --git a/tools/test_native_apps.py b/tools/test_native_apps.py index 5141b259..c46f1912 100644 --- a/tools/test_native_apps.py +++ b/tools/test_native_apps.py @@ -3,6 +3,7 @@ import importlib.util import io import json +import re from pathlib import Path import shutil import tempfile @@ -15,7 +16,7 @@ spec.loader.exec_module(native_apps) GENERATED = ["Cargo.toml", "crates/shell/Cargo.toml", "desktop/Cargo.toml", "phone/Cargo.toml", - native_apps.RUST_FILE, native_apps.MANIFEST] + native_apps.RUST_FILE, native_apps.AGENTS_FILE, native_apps.MANIFEST] def quiet(): @@ -142,6 +143,68 @@ def test_the_terminals_commands_are_host_confirmed_and_never_auto_approved(self) rust = native_apps.render_rust(native_apps.validate(self.data)) self.assertIn('ToolPolicy { tool: "run", confirm: Confirm::Host, auto_approvable: false }', rust) + def test_the_agent_block_is_checked(self): + agent = self.app("terminal")["agent"] + agent["tools"][0]["name"] = "run" + self.assertRefused(r"agent\.tools\[0\]: name must be terminal\.") + agent["tools"][0]["name"] = "terminal.run" + agent["tools"][1]["auto_approvable"] = False + self.assertRefused(r"unknown auto_approvable \(the kernel refuses them\)") + del agent["tools"][1]["auto_approvable"] + agent["tools"][1]["risk"] = "harmless" + self.assertRefused(r"risk must be one of read, act, destructive") + agent["tools"][1]["risk"] = "read" + agent["tool_policy"]["type"] = {"confirm": "host", "auto_approvable": False} + self.assertRefused(r"tool_policy\.type: terminal declares no tool terminal\.type") + del agent["tool_policy"]["type"] + agent["budget"] = {"calls_per_turn": 0} + self.assertRefused(r"agent\.budget\.calls_per_turn must be a positive integer") + agent["budget"] = {"calls_per_turn": 5, "calls_per_day": 50} + native_apps.validate(self.data) + + def test_no_app_agent_gets_octos_shell(self): + for shell in ("shell", "bash", "exec_command", "write_stdin", "group:runtime"): + self.app("rinx")["agent"]["generic_tools"] = ["read_file", shell] + self.assertRefused(rf"agent\.generic_tools: {re.escape(shell)} is octos's own shell") + + def test_grants_name_another_apps_shareable_tool(self): + rinx = self.app("rinx")["agent"] + rinx["grants"] = [{"app": "terminal", "tool": "terminal.read_screen"}, {"app": "os.mail", "tool": "mail.send"}] + native_apps.validate(self.data) + rinx["grants"] = [{"app": "terminal", "tool": "terminal.nope"}] + self.assertRefused(r"agent\.grants terminal/terminal\.nope: terminal declares no terminal\.nope") + self.app("terminal")["agent"]["tools"][1]["shareable"] = False + rinx["grants"] = [{"app": "terminal", "tool": "terminal.read_screen"}] + self.assertRefused(r"terminal\.read_screen is not shareable") + rinx["grants"] = [{"app": "rinx", "tool": "rinx.x"}] + self.assertRefused(r"an app's own tools need no grant") + rinx["grants"] = [{"app": "nowhere", "tool": "nowhere.x"}] + self.assertRefused(r"no native app nowhere") + + def test_the_agent_block_is_generated(self): + self.app("rinx")["agent"]["grants"] = [{"app": "terminal", "tool": "terminal.read_screen"}] + self.app("rinx")["agent"]["budget"] = {"calls_per_day": 99} + apps = native_apps.validate(self.data) + rust = native_apps.render_rust(apps) + self.assertIn('grants: &[("terminal", "terminal.read_screen")],', rust) + self.assertIn("calls_per_turn: None,\n calls_per_day: Some(99),", rust) + self.assertIn('generic_tools: &["read_file",', rust) + self.assertIn('tools_json: r##"[{"name":"terminal.run",', rust) + agents = native_apps.render_agents(apps) + self.assertIn('("rinx", &["octos.session.open", "octos.session.history", "octos.turn.start", "octos.turn.interrupt"]),', agents) + self.assertNotIn('"terminal"', agents, "an app granted no octos.* services has no line") + + def test_the_shipped_agent_blocks(self): + rinx = self.app("rinx")["agent"] + self.assertIn("ask_user_question", rinx["generic_tools"], "Rinx's agent may ask the person (ADR 0004 §6)") + terminal = self.app("terminal")["agent"] + self.assertEqual([t["name"] for t in terminal["tools"]], ["terminal.run", "terminal.read_screen", "terminal.read_scrollback"]) + self.assertEqual({t["name"]: t["risk"] for t in terminal["tools"]}, + {"terminal.run": "destructive", "terminal.read_screen": "read", "terminal.read_scrollback": "read"}) + for app in self.data["apps"]: + for tool in app["agent"].get("generic_tools", []): + self.assertNotIn(tool, native_apps.OCTOS_SHELL, app["id"]) + def test_refuses_unknown_keys(self): self.app("reference")["hosted"] = "yes" self.assertRefused(r"reference: unknown hosted") From 1fa7c13f33aa95b26a526246fd43881fb3f881bc Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:07:33 -0700 Subject: [PATCH 3/9] feat(host-tools): apps offer tools end to end; peers keep exactly their granted kernel tools [skip ci] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0004 §4, §7, §12 (G3). - The seams (crates/shell/src/host_tools, for #108 too): `declare(app, tools)` → `grant(caller, owning_app, tool)` (grants are keyed by owner and tool) → an executor per owning app (`set_executor`); `set_generic(app, tools)` sets exactly the octos kernel tools an app's agent keeps (never octos's shell). - Native apps: the shipped catalog is native-apps.json's agent blocks (the Terminal's `terminal.run`, `terminal.read_screen`, `terminal.read_scrollback`; each app's grants and kernel tools). The Terminal's tools run on its AI bus service; declarations always name their owning app. - Script apps (host_tools/script_apps.rs): a system or installed app's `tools.json` and manifest `agent.tools` are loaded with App Hub's own loader (`AgentBundle::load`: digest and gate rules) when its peer first registers and again at install; plain names become its kernel tools, dotted ones cross-app grants marked with their owner. Its host-service tools run on `HostServiceExecutor`: App Hub's dispatch with the app's identity, never from a sheet, only for a granted family; answers come back through `host_tools::pump`. - News offers real tools: apps/news/bundle/tools.json (`news.list`, `news.read`, read-only, shareable) on its host service; its manifest now has the `news` capability. - app-peers: `ToolHost::generic_tools`; the broker registers each peer with the host's exact `generic_tools` (the shell always sets one: an app granted none keeps none). - The real-kernel test (host_tools/real_kernel_tests.rs): octos at the pinned rev → the broker → `ShellToolHost` and the relay → News's host service → the result in the model's turn; the peer is offered News's tools, no kernel tools and never octos's shell. phone.yml builds the pinned octos (cached) and runs it (`OCTOS_SHELL_TEST_KERNEL`); without a binary it says so and passes. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/phone.yml | 21 ++ .sources | 1 + apps/news/bundle/manifest.json | 3 +- apps/news/bundle/tools.json | 51 +++ crates/app-peers/src/broker.rs | 18 +- crates/app-peers/src/host_tools.rs | 15 +- crates/app-peers/tests/broker.rs | 20 +- .../tests/fixtures/mock_agent_llm.py | 7 + crates/shell/src/approvals/tests.rs | 2 +- crates/shell/src/host_tools/mod.rs | 88 ++++- .../shell/src/host_tools/real_kernel_tests.rs | 150 ++++++++ crates/shell/src/host_tools/relay.rs | 140 ++++++-- crates/shell/src/host_tools/script_apps.rs | 322 ++++++++++++++++++ crates/shell/src/host_tools/tests.rs | 64 +++- crates/shell/src/lib.rs | 2 + 15 files changed, 854 insertions(+), 50 deletions(-) create mode 120000 .sources create mode 100644 apps/news/bundle/tools.json create mode 100644 crates/shell/src/host_tools/real_kernel_tests.rs create mode 100644 crates/shell/src/host_tools/script_apps.rs diff --git a/.github/workflows/phone.yml b/.github/workflows/phone.yml index 0b67b3c2..9c757ed1 100644 --- a/.github/workflows/phone.yml +++ b/.github/workflows/phone.yml @@ -74,6 +74,24 @@ jobs: run: python3 tools/setup.py --check --cargo - name: The native apps' generated places match native-apps.json run: python3 tools/native_apps.py --check + # The shell's real-kernel test (crates/shell/src/host_tools/ + # real_kernel_tests.rs: kernel -> app-peers broker -> the shell's relay + # -> News's host service -> the model's turn) needs an octos binary at + # the revision Cargo.lock pins; built once per revision and cached. + - name: The pinned octos revision + id: octos + run: echo "rev=$(python3 -c 'import importlib.util as u; s=u.spec_from_file_location("k","tools/kernel-artifact.py"); m=u.module_from_spec(s); s.loader.exec_module(m); print(m.octos_revision())')" >> "$GITHUB_OUTPUT" + # Outside target/: rust-cache restores and prunes target/. + - uses: actions/cache@v4 + with: + path: ${{ runner.temp }}/octos-kernel/target/release/octos + key: octos-kernel-${{ runner.os }}-${{ steps.octos.outputs.rev }} + - name: Build octos (unless cached) + timeout-minutes: 45 + run: | + if [ ! -x "$RUNNER_TEMP/octos-kernel/target/release/octos" ]; then + python3 tools/kernel-artifact.py --host --work "$RUNNER_TEMP/octos-kernel" + fi - name: Build Home, App Hub admission and runtime policy tests working-directory: phone timeout-minutes: 60 @@ -81,6 +99,9 @@ jobs: - name: Test Home, App Hub admission and runtime policy working-directory: phone timeout-minutes: 30 + env: + # Runs the shell's real-kernel relay test instead of skipping it. + OCTOS_SHELL_TEST_KERNEL: ${{ runner.temp }}/octos-kernel/target/release/octos run: | # octosense-app-hub-app is App Hub's shared shell crate, a git # dependency with dev-dependencies: cargo cannot test it from here; diff --git a/.sources b/.sources new file mode 120000 index 00000000..f7da65d4 --- /dev/null +++ b/.sources @@ -0,0 +1 @@ +../wt-2567/.sources \ No newline at end of file diff --git a/apps/news/bundle/manifest.json b/apps/news/bundle/manifest.json index 282206c7..ed33a9f0 100644 --- a/apps/news/bundle/manifest.json +++ b/apps/news/bundle/manifest.json @@ -3,7 +3,8 @@ "storage", "net", "images", - "web" + "web", + "news" ], "id": "os.news", "integrity": { diff --git a/apps/news/bundle/tools.json b/apps/news/bundle/tools.json new file mode 100644 index 00000000..381603c8 --- /dev/null +++ b/apps/news/bundle/tools.json @@ -0,0 +1,51 @@ +{ + "schema": 1, + "tools": [ + { + "name": "news.list", + "description": "List the stories the device collected from News's feeds and followed topics, newest first. Filter by topic, language, feed or time; page with limit and offset.", + "input_schema": { + "type": "object", + "properties": { + "since": { "type": "integer", "description": "Only stories fetched at or after this Unix time." }, + "topic": { "type": "string", "maxLength": 200 }, + "lang": { "type": "string", "maxLength": 16 }, + "feed": { "type": "string", "maxLength": 200 }, + "limit": { "type": "integer", "minimum": 1, "maximum": 100 }, + "offset": { "type": "integer", "minimum": 0 } + }, + "additionalProperties": false + }, + "output_schema": { + "type": "object", + "properties": { + "total": { "type": "integer" }, + "offset": { "type": "integer" }, + "items": { "type": "array", "items": { "type": "object" } } + }, + "required": ["total", "items"] + }, + "risk": "read", + "shareable": true, + "implemented_by": "host-service" + }, + { + "name": "news.read", + "description": "Read one collected story by its id: the story and its stored summary.", + "input_schema": { + "type": "object", + "properties": { "id": { "type": "string", "minLength": 1, "maxLength": 128 } }, + "required": ["id"], + "additionalProperties": false + }, + "output_schema": { + "type": "object", + "properties": { "item": { "type": "object" }, "text": { "type": "string" } }, + "required": ["item"] + }, + "risk": "read", + "shareable": true, + "implemented_by": "host-service" + } + ] +} diff --git a/crates/app-peers/src/broker.rs b/crates/app-peers/src/broker.rs index 009fe27d..91b354c3 100644 --- a/crates/app-peers/src/broker.rs +++ b/crates/app-peers/src/broker.rs @@ -20,8 +20,8 @@ //! - is the peer's **tool host** (octos UPCR-2026-035): right after every //! `peer/prepare` (and so after every reconnect, which prepares again) it //! registers the app's tools on its own link, the one that drives the -//! peer's turns (`peer/tools/register`, `generic_tools` omitted so the -//! peer keeps its kernel roster). A peer whose registration fails runs no +//! peer's turns (`peer/tools/register`, with the host's exact +//! `generic_tools` for the app's agent when it sets them). A peer whose registration fails runs no //! turn. It takes every `peer/tool/call` on that link, stamps the account, //! the calling context's client and the caller, executes each occurrence //! at most once, refuses calls of turns it interrupted, and hands the call @@ -1047,20 +1047,24 @@ impl Inner { } /// `peer/tools/register` on this link: the app's granted tools plus the - /// cross-app tools granted to it, `generic_tools` omitted (the peer - /// keeps its kernel roster). + /// cross-app tools granted to it, and exactly the kernel tools the host + /// grants its agent (`generic_tools`; omitted only when the host sets + /// none, and then the peer keeps its kernel roster). async fn register_tools(self: &Arc, slug: &str, token: &str, account: &str) -> Result { - let tools = self - .tool_host() + let host = self.tool_host(); + let tools = host .declarations(&self.cfg.app_id, account) .map_err(|e| format!("The app's tools could not be declared ({e}); the assistant runs nothing for it"))?; - let params = json!({ + let mut params = json!({ "profile_id": self.cfg.profile_id, "session_id": self.cfg.originator, "peer": slug, "host_token": token, "tools": tools, }); + if let Some(generic) = host.generic_tools(&self.cfg.app_id, account) { + params["generic_tools"] = json!(generic); + } self.request(host_tools::REGISTER, params) .await .map_err(|e| format!("The assistant did not take the app's tools ({e}); it runs no turn without them")) diff --git a/crates/app-peers/src/host_tools.rs b/crates/app-peers/src/host_tools.rs index 3021a77a..1dc5d558 100644 --- a/crates/app-peers/src/host_tools.rs +++ b/crates/app-peers/src/host_tools.rs @@ -5,8 +5,9 @@ //! The kernel never talks to an app. The shell is every app peer's **tool //! host**: the broker registers the app's tools on the connection that //! drives the peer's turns (`peer/tools/register`, after every `peer/prepare` -//! and every reconnect, `generic_tools` omitted so the peer keeps its kernel -//! roster), receives each `peer/tool/call` on that connection and hands it +//! and every reconnect, with the exact kernel tools the host grants the +//! app's agent as `generic_tools`: [`ToolHost::generic_tools`]; octos's own +//! shell is never among them), receives each `peer/tool/call` on that connection and hands it //! to the installed [`ToolHost`] with the identity the host stamps (account, //! client, calling app). The host authorizes it, routes it to the owning //! app's executor and answers once through the [`ToolReply`] it was given. @@ -719,6 +720,16 @@ pub trait ToolHost: Send + Sync { Ok(Vec::new()) } + /// Exactly the octos kernel tools `app_id`'s agent keeps, sent as the + /// registration's `generic_tools` (octos keeps exactly those of the + /// peer's kernel roster; an empty list keeps none; ADR 0004 §12). + /// `None` omits the field, and the peer keeps its whole kernel roster: + /// only for a host that sets nothing (a standalone app, tests). The + /// shell always sets a list: the manifest's grants. + fn generic_tools(&self, _app_id: &str, _account: &str) -> Option> { + None + } + /// The account's agent workspace (ADR 0004 §11), the `cwd` a NEW peer /// is prepared with. `None`: the kernel provisions one. fn agent_workspace(&self, _app_id: &str, _account: &str) -> Option { diff --git a/crates/app-peers/tests/broker.rs b/crates/app-peers/tests/broker.rs index 8e4e9817..b2bcdb75 100644 --- a/crates/app-peers/tests/broker.rs +++ b/crates/app-peers/tests/broker.rs @@ -469,6 +469,7 @@ struct RecordingHost { approvals: Mutex>, questions: Mutex>, closed_questions: Mutex>, + generic: Mutex>>, } impl ToolHost for RecordingHost { @@ -478,6 +479,9 @@ impl ToolHost for RecordingHost { fn agent_workspace(&self, _app: &str, _account: &str) -> Option { self.workspace.lock().unwrap().clone() } + fn generic_tools(&self, _app: &str, _account: &str) -> Option> { + self.generic.lock().unwrap().clone() + } fn suspended(&self, _app: &str, _account: &str) -> bool { *self.suspended.lock().unwrap() } @@ -560,12 +564,26 @@ fn the_apps_tools_are_registered_on_the_driving_link_after_prepare_and_before_an let (conn, params) = ®istrations[0]; assert_eq!(params["session_id"], "_main:api:octosense#system", "the originator names the peer"); assert_eq!(params["host_token"], "fixture-host-token"); - assert!(params.get("generic_tools").is_none(), "omitted: the peer keeps its kernel roster"); + assert!(params.get("generic_tools").is_none(), "a host that sets none: omitted"); assert_eq!(params["tools"][0]["name"], "rinx.message.send"); let (turn_conn, _) = &calls_of(&script, "turn/start")[0]; assert_eq!(conn, turn_conn, "registered on the connection that drives the turns"); } +/// ADR 0004 §12: the peer keeps exactly the kernel tools the host grants +/// its agent; an empty list keeps none. +#[test] +fn the_hosts_exact_kernel_tools_are_registered_with_the_apps_tools() { + for generic in [vec!["read_file".to_string(), "ask_user_question".to_string()], Vec::new()] { + let host = Arc::new(RecordingHost::default()); + *host.generic.lock().unwrap() = Some(generic.clone()); + let (broker, script) = new_broker_with(&ALL, Some(host), None); + broker.set_account(Some("@a:x")); + wait_for("registered", || calls_of(&script, "peer/tools/register").len() == 1); + assert_eq!(calls_of(&script, "peer/tools/register")[0].1["generic_tools"], json!(generic), "exact, never omitted"); + } +} + #[test] fn an_app_with_no_tools_registers_an_empty_set_and_again_after_a_reconnect() { let (broker, script) = new_broker(&ALL); diff --git a/crates/app-peers/tests/fixtures/mock_agent_llm.py b/crates/app-peers/tests/fixtures/mock_agent_llm.py index 3bc6c9fe..734d5d1d 100644 --- a/crates/app-peers/tests/fixtures/mock_agent_llm.py +++ b/crates/app-peers/tests/fixtures/mock_agent_llm.py @@ -21,6 +21,8 @@ (UPCR-2026-035), else says "NO APP TOOL OFFERED"; - "RUN_TERMINAL": the system agent calls the host tool `terminal_run` registered on its session, else says "NO TERMINAL OFFERED"; + - "CALL_TOOL::": the same, spelled with a colon (the + shell's relay test, G3), else say "NO TOOL "; - a user text "QUESTION_ME" with ask_user_question offered: ask one question; - a message naming a waiting peer with peer_respond offered: answer "42"; - otherwise echo. @@ -88,6 +90,11 @@ def decide(body): return {"tool": "rinx_echo", "args": {"text": "ping"}} if "CALL_APP_TOOL" in last_user: return {"text": "NO APP TOOL OFFERED"} + call = re.search(r"CALL_TOOL:([A-Za-z0-9_]+):(\{.*\})", last_user) + if call: + if call.group(1) in tools: + return {"tool": call.group(1), "args": json.loads(call.group(2))} + return {"text": "NO TOOL " + call.group(1)} if "RUN_TERMINAL" in last_user and "terminal_run" in tools: return {"tool": "terminal_run", "args": {"command": "ls"}} if "RUN_TERMINAL" in last_user: diff --git a/crates/shell/src/approvals/tests.rs b/crates/shell/src/approvals/tests.rs index b86df0b0..a80deaf4 100644 --- a/crates/shell/src/approvals/tests.rs +++ b/crates/shell/src/approvals/tests.rs @@ -610,7 +610,7 @@ fn only_settings_and_the_sheet_make_a_person_gesture() { let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); let mut files = Vec::new(); walk(&src, &mut files); - let allowed = ["approvals/view.rs", "approvals/settings_page.rs", "approvals/rules.rs", "approvals/tests.rs", "system_chat/tests.rs", "host_tools/tests.rs"]; + let allowed = ["approvals/view.rs", "approvals/settings_page.rs", "approvals/rules.rs", "approvals/tests.rs", "system_chat/tests.rs", "host_tools/tests.rs", "host_tools/real_kernel_tests.rs"]; for f in files { let text = std::fs::read_to_string(&f).unwrap(); let rel = f.strip_prefix(&src).unwrap().to_string_lossy().replace('\\', "/"); diff --git a/crates/shell/src/host_tools/mod.rs b/crates/shell/src/host_tools/mod.rs index 61deca48..75ca8bda 100644 --- a/crates/shell/src/host_tools/mod.rs +++ b/crates/shell/src/host_tools/mod.rs @@ -34,9 +34,13 @@ pub mod relay; #[cfg(feature = "toolbox-peers")] pub mod toolbox; +#[cfg(any(feature = "app-hub", native_mobile))] +pub mod script_apps; #[cfg(test)] mod tests; +#[cfg(all(test, kernel, any(feature = "app-hub", native_mobile)))] +mod real_kernel_tests; use std::collections::BTreeSet; use std::path::PathBuf; @@ -63,8 +67,11 @@ pub fn submit(event: Event) { makepad_widgets::makepad_platform::thread::SignalToUI::set_ui_signal(); } -/// On the UI thread: handle everything queued (and what that queues). +/// On the UI thread: handle everything queued (and what that queues), and +/// deliver the host services' answers to script apps' tool calls. pub fn pump() { + #[cfg(any(feature = "app-hub", native_mobile))] + script_apps::poll(); for _ in 0..8 { let events = std::mem::take(&mut *INBOX.lock().unwrap_or_else(|e| e.into_inner())); if events.is_empty() { @@ -92,15 +99,49 @@ pub fn init() { }); } -/// An app's `tools.json` (App Hub at install; tests). +// ------------------------------------------------------------ the seams +// +// How an app offers tools and who may call them (ADR 0004 §7, §12), in +// three steps; the toolbox (#108) uses the same: +// +// 1. `declare(app, tools)`: the owning app's `tools.json` entries (a native +// app's from `native-apps.json` `agent.tools`, loaded at startup; a +// script app's from its admitted bundle, `script_apps`); +// 2. `grant(caller, owning_app, tool)`: another app's shareable tool for a +// caller's agent, marked with its owner (native: `agent.grants`; script: +// the dotted names in its manifest's `agent.tools`, at install); +// `set_generic(app, tools)`: exactly the octos kernel tools its agent +// keeps (native: `agent.generic_tools`; script: the plain names in +// `agent.tools`); +// 3. an `Executor` per owning app runs its calls: `set_executor(app, …)` +// (an in-process module's through `OctosAppService::set_tool_executor`; +// a script app's host services, `script_apps::HostServiceExecutor`); a +// process app's calls go down its peer link, the Terminal's on the AI +// bus. The relay authorizes each call first. + +/// An app's `tools.json` (replaces what it declared before). pub fn declare(app: &str, entries: Vec) { with_relay(|r| r.catalog.declare(app, entries)); } -/// A cross-app grant (App Hub at install for a script app; a native app's -/// reviewed `native-apps.json` entry). -pub fn grant(caller: &str, tool: &str) { - with_relay(|r| r.catalog.grant(caller, tool)); +/// A cross-app grant: `owning_app`'s shareable `tool` for `caller`'s agent. +pub fn grant(caller: &str, owning_app: &str, tool: &str) { + with_relay(|r| r.catalog.grant(caller, owning_app, tool)); +} + +/// Exactly the octos kernel tools `app`'s agent keeps (never octos's shell). +pub fn set_generic(app: &str, tools: Vec) { + with_relay(|r| r.catalog.set_generic(app, tools)); +} + +/// `app`'s executor for its own tools (`None` removes it). +pub fn set_executor(app: &str, executor: Option>) { + with_relay(|r| r.set_executor(app, executor)); +} + +/// The owning app of a declared tool. +pub fn owner_of(tool: &str) -> Option { + with_relay(|r| r.catalog.owner_of(tool).map(str::to_string)) } /// A tool's declaration, as a host registers it (the system chat). @@ -150,6 +191,7 @@ pub struct ShellToolHost; impl ToolHost for ShellToolHost { fn declarations(&self, app_id: &str, _account: &str) -> Result, String> { let app = app_of_peer(app_id).to_string(); + ensure_loaded(app_id); let dev = crate::dev_mode::grants_all(&app); // The toolbox's tools only once the person allowed this app's agent // (ADR 0004 §4); its calls are refused before that too (the relay). @@ -157,6 +199,13 @@ impl ToolHost for ShellToolHost { Ok(with_relay(|r| r.catalog.offered(&app, dev, consented))) } + fn generic_tools(&self, app_id: &str, _account: &str) -> Option> { + let app = app_of_peer(app_id).to_string(); + ensure_loaded(app_id); + let dev = crate::dev_mode::grants_all(&app); + Some(with_relay(|r| r.catalog.generic(&app, dev))) + } + fn agent_workspace(&self, app_id: &str, account: &str) -> Option { agent_workspace(app_id, account) } @@ -212,6 +261,33 @@ impl ToolHost for ShellToolHost { } } +/// A script app's agent block, loaded from its admitted bundle the first +/// time its peer registers (a native app's is in the shipped catalog). +fn ensure_loaded(app_id: &str) { + if app_id == app_of_peer(app_id) { + return; + } + let app = app_of_peer(app_id); + if with_relay(|r| r.catalog.knows(app)) { + return; + } + #[cfg(any(feature = "app-hub", native_mobile))] + if let Err(e) = script_apps::load(app) { + makepad_widgets::log!("host tools: {app}'s tools: {e}"); + } +} + +/// App Hub installed or updated a script app: its tools, grants and +/// kernel tools again (the next registration of its peer takes them). +pub fn script_app_installed(app: &str) { + #[cfg(any(feature = "app-hub", native_mobile))] + if let Err(e) = script_apps::load(app) { + makepad_widgets::log!("host tools: {app}'s tools: {e}"); + } + #[cfg(not(any(feature = "app-hub", native_mobile)))] + let _ = app; +} + /// An app's own sheet, as the approval router's `confirm: app` handler: the /// request carries the tool, the exact arguments and the caller. pub struct SheetBridge { diff --git a/crates/shell/src/host_tools/real_kernel_tests.rs b/crates/shell/src/host_tools/real_kernel_tests.rs new file mode 100644 index 00000000..fec4429d --- /dev/null +++ b/crates/shell/src/host_tools/real_kernel_tests.rs @@ -0,0 +1,150 @@ +//! G3 end to end against a REAL octos kernel (the pinned rev): the app +//! peers' broker registers News's tools from its admitted bundle, a turn in +//! News's request context calls `news.list`, the kernel sends the call to +//! the broker, the broker to the shell's tool host and relay +//! ([`super::ShellToolHost`], [`super::pump`]), the relay to News's real +//! executor (`script_apps::HostServiceExecutor` → App Hub's host-service +//! dispatch → the News host service), and the result goes back to the +//! model's turn. The model is scripted (`crates/app-peers/tests/fixtures/ +//! mock_agent_llm.py`, standard-library Python, no keys). +//! +//! Runs when `OCTOS_SHELL_TEST_KERNEL` (or `OCTOS_APP_PEERS_TEST_KERNEL`) +//! names an `octos` binary built at the pinned revision +//! (`python3 tools/kernel-artifact.py --host`, as CI does); says so and +//! passes without one: +//! +//! ```sh +//! OCTOS_SHELL_TEST_KERNEL= cargo test --locked --features mobile-apps -p octosense-shell real_kernel -- --nocapture # from phone/ +//! ``` + +use std::io::BufRead; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use serde_json::{json, Value}; + +use crate::ai_host::app_peers::broker::{Broker, BrokerConfig, ToolHostHandle}; +use crate::ai_host::app_peers::connectors::CoreConnector; +use crate::ai_host::app_peers::host_tools::ToolHost; +use crate::ai_host::app_peers::{ContextEvent, ContextOp, ContextSpec, Deployment, OctosAppService, OCTOS_SERVICES}; +use crate::ai_host::kernel::{Core, Options}; + +struct Model(std::process::Child, u16); +impl Drop for Model { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +fn kernel() -> Option { + let program = std::env::var_os("OCTOS_SHELL_TEST_KERNEL").or_else(|| std::env::var_os("OCTOS_APP_PEERS_TEST_KERNEL")).map(PathBuf::from); + if program.is_none() { + eprintln!("OCTOS_SHELL_TEST_KERNEL is not set: skipping the shell's real-kernel test"); + } + program +} + +fn start_model(log: &Path) -> Model { + let script = Path::new(env!("CARGO_MANIFEST_DIR")).join("../app-peers/tests/fixtures/mock_agent_llm.py"); + let mut child = std::process::Command::new("python3") + .arg(script) + .env("MOCK_LLM_TOOLS_LOG", log) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::inherit()) + .spawn() + .expect("python3 for the scripted model"); + let mut line = String::new(); + std::io::BufReader::new(child.stdout.take().unwrap()).read_line(&mut line).unwrap(); + Model(child, line.trim().parse().expect("model port")) +} + +fn write_profile(core_dir: &Path, port: u16) { + let dir = core_dir.join("profiles"); + std::fs::create_dir_all(&dir).unwrap(); + let profile = json!({ + "id": "_main", "name": "Main", "enabled": true, + "created_at": "2026-09-27T00:00:00Z", "updated_at": "2026-09-27T00:00:00Z", + "config": {"llm": {"primary": {"family_id": "local", "model_id": "mock-model", + "route": {"base_url": format!("http://127.0.0.1:{port}/v1"), "api_type": "openai"}}}} + }); + std::fs::write(dir.join("_main.json"), serde_json::to_vec_pretty(&profile).unwrap()).unwrap(); +} + +#[test] +fn real_kernel_an_app_agents_turn_calls_news_list_through_the_shells_relay() { + let Some(program) = kernel() else { return }; + let dir = std::env::temp_dir().join(format!("octosense-shell-real-kernel-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let offered_log = dir.join("offered.jsonl"); + let model = start_model(&offered_log); + let core_dir = dir.join("octos-home/.octos"); + write_profile(&core_dir, model.1); + let core = Core::new(Options::default().core_dir(&core_dir).program(&program)); + + // The person allowed News's agent (the first-use sheet, ADR 0004 §4). + let home = dir.join("home"); + std::fs::create_dir_all(&home).unwrap(); + if crate::approvals::with(|_| ()).is_none() { + crate::approvals::init(&home); + } + crate::approvals::with(|a| a.consent.set(&crate::approvals::rules::ApprovalGesture::sheet_tap(), "os.news", true, crate::approvals::now())); + + // News's real host service, on its own host dir (no timer, no fetch). + let host_dir = dir.join("apps/.host"); + std::fs::create_dir_all(&host_dir).unwrap(); + octosense_news_service::register_with(octosense_news_service::Options::default().host_dir(&host_dir).timer(false)); + // News's tools, from its bundle as App Hub admits it. + let bundle = super::script_apps::tests::stamped_bundle("news", "real-kernel", |_, _| {}); + let loaded = super::script_apps::from_bundle(&bundle).expect("News's bundle is admitted"); + super::script_apps::install("os.news", loaded, host_dir.clone()); + + // News's peer (`card.os.news`), with the shell as its tool host. + let services = OCTOS_SERVICES.iter().map(|s| s.to_string()).collect(); + let mut cfg = BrokerConfig::new(Deployment::Hosted, "_main", "_main:api:octosense#system", "card.os.news", "News", services); + cfg.state_dir = Some(dir.join("host-state")); + cfg.tool_host = Some(ToolHostHandle(Arc::new(super::ShellToolHost) as Arc)); + let news = Broker::new(cfg, Arc::new(CoreConnector::shared(core.clone()))); + news.set_account(Some(crate::ai_host::contained::ACCOUNT)); + let ctx = news + .open_context(ContextSpec { account: crate::ai_host::contained::ACCOUNT.into(), instance: "card.os.news-g1".into(), services: OCTOS_SERVICES.iter().map(|s| s.to_string()).collect() }) + .expect("a request context"); + + // The person asks News's agent; the scripted model calls `news_list`. + let (tx, rx) = std::sync::mpsc::channel(); + let tx = Mutex::new(tx); + ctx.call(ContextOp::Turn { text: r#"CALL_TOOL:news_list:{"limit":5}"#.into() }, Arc::new(move |e| { let _ = tx.lock().unwrap().send(e); })).unwrap(); + let deadline = Instant::now() + Duration::from_secs(120); + let answer = loop { + // The shell's UI thread: the relay handles the call, and the host + // service's answer is delivered. + super::pump(); + match rx.recv_timeout(Duration::from_millis(50)) { + Ok(ContextEvent::Complete(result)) => break result, + Ok(ContextEvent::Data(_)) | Err(_) => {} + } + assert!(Instant::now() < deadline, "the turn did not finish"); + }; + let answer = answer.expect("the turn completed"); + let text = answer["text"].as_str().unwrap_or("").to_string(); + eprintln!("[real-kernel] the turn said: {text}"); + + let requests: Vec = std::fs::read_to_string(&offered_log).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + let _ = std::fs::remove_dir_all(&bundle); + news.release(); + drop(news); + core.shutdown_within(Duration::from_secs(5)); + let _ = std::fs::remove_dir_all(&dir); + + assert!(text.starts_with("TOOL SAID"), "the tool's result reached the model's turn: {text}"); + assert!(text.contains("\"total\"") && text.contains("\"items\""), "News's own answer, through the relay: {text}"); + let first = requests.iter().find(|r| r["user"].as_str().unwrap_or("").contains("CALL_TOOL")).expect("the model was asked"); + let tools: Vec<&str> = first["tools"].as_array().unwrap().iter().filter_map(Value::as_str).collect(); + assert!(tools.contains(&"news_list") && tools.contains(&"news_read"), "News's tools are offered: {tools:?}"); + for shell in ["shell", "bash", "exec_command"] { + assert!(!tools.contains(&shell), "never octos's shell: {tools:?}"); + } + assert!(!tools.contains(&"read_file"), "News's manifest grants no kernel tools, so its peer keeps none: {tools:?}"); +} diff --git a/crates/shell/src/host_tools/relay.rs b/crates/shell/src/host_tools/relay.rs index d5d8ab60..67c6adde 100644 --- a/crates/shell/src/host_tools/relay.rs +++ b/crates/shell/src/host_tools/relay.rs @@ -42,6 +42,8 @@ pub const BUS_PREFIX: &str = "hosttool-"; pub const SYSTEM: &str = "system"; /// The Terminal's shareable tool (ADR 0004 §10, §12). pub const TERMINAL_RUN: &str = "terminal.run"; +/// The Terminal, whose tools the relay runs on the AI bus. +pub const TERMINAL_APP: &str = "terminal"; /// Developer mode's command tool (§13). pub const DEV_RUN: &str = "dev.run"; /// The system toolbox (ADR 0002 §6), the owning app of the toolbox tools @@ -98,44 +100,133 @@ pub trait Env { fn log(&mut self, line: String); } -/// Which apps declare which tools, and which are granted to whom. +/// The octos kernel tools an app agent may be granted under developer +/// mode ("everything granted", ADR 0004 §13): its workspace's files, the +/// person, memory, the web, tool discovery. Never octos's shell (§12). +pub const DEV_GENERIC_TOOLS: &[&str] = &[ + "read_file", + "write_file", + "edit_file", + "diff_edit", + "apply_patch", + "glob", + "grep", + "list_dir", + "code_structure", + "check_workspace_contract", + "ask_user_question", + "view_image", + "view_video", + "recall", + "recall_memory", + "memory_search", + "memory_load", + "save_memory", + "memory_note", + "web_search", + "web_fetch", + "deep_search", + "tool_search", +]; + +/// octos's own shell and its aliases: never in an app agent's kernel tools +/// (ADR 0004 §12; command execution is a host tool with a live approval). +pub const OCTOS_SHELL: &[&str] = &["shell", "bash", "exec_command", "write_stdin", "group:runtime"]; + +/// Which apps declare which tools, which are granted to whom, and which +/// octos kernel tools each app's agent keeps. +/// +/// **The seams** (ADR 0004 §7, §12; #108's toolbox tools use the same): +/// [`Catalog::declare`] an owning app's `tools.json` entries, +/// [`Catalog::grant`] a caller one of another app's shareable tools (marked +/// with its owner), [`Catalog::set_generic`] the exact kernel tools an +/// app's agent keeps; the relay then routes each call to the owning app's +/// executor ([`Relay::set_executor`]). #[derive(Default)] pub struct Catalog { /// `tools.json` entries by owning app. tools: BTreeMap>, - /// Cross-app grants: calling app → declared tool names of other apps. - grants: BTreeMap>, + /// Cross-app grants: calling app → (owning app, tool). + grants: BTreeMap>, + /// Exactly the octos kernel tools each app's agent keeps. + generic: BTreeMap>, } impl Catalog { - /// With the tools the shell itself knows: the Terminal's `run`. + /// With what the native apps' reviewed entries declare + /// (`native-apps.json` `agent`): the Terminal's tools, every native + /// app's grants and kernel tools. pub fn shipped() -> Catalog { let mut c = Catalog::default(); - c.declare("terminal", vec![terminal_run_declaration()]); + for app in crate::native_apps::APPS { + c.load_native(app); + } c } + + /// One native app's agent block. + pub fn load_native(&mut self, app: &crate::native_apps::NativeApp) { + let tools: Vec = serde_json::from_str(app.tools_json).unwrap_or_default(); + if !tools.is_empty() { + self.declare(app.id, tools); + } + for (owner, tool) in app.grants { + self.grant(app.id, owner, tool); + } + self.set_generic(app.id, app.generic_tools.iter().map(|t| t.to_string()).collect()); + } + /// An app's `tools.json` (replaces what it declared before). pub fn declare(&mut self, app: &str, entries: Vec) { self.tools.insert(app.to_string(), entries); } - /// A grant of another app's shareable tool to `caller`. - pub fn grant(&mut self, caller: &str, tool: &str) { - self.grants.entry(caller.to_string()).or_default().insert(tool.to_string()); + + /// A grant of `owner`'s shareable `tool` to `caller`'s agent. + pub fn grant(&mut self, caller: &str, owner: &str, tool: &str) { + self.grants.entry(caller.to_string()).or_default().insert((owner.to_string(), tool.to_string())); } /// `caller`'s grants of `owner`'s tools become exactly `tools` (a /// toolbox grant computed again). pub fn set_grants(&mut self, caller: &str, owner: &str, tools: &[&str]) { - let owned: BTreeSet = self.tools.get(owner).into_iter().flatten().filter_map(|e| e["name"].as_str().map(str::to_string)).collect(); let grants = self.grants.entry(caller.to_string()).or_default(); - grants.retain(|t| !owned.contains(t)); - grants.extend(tools.iter().map(|t| t.to_string())); + grants.retain(|(o, _)| o != owner); + grants.extend(tools.iter().map(|t| (owner.to_string(), t.to_string()))); + } + + /// Exactly the octos kernel tools `app`'s agent keeps; octos's shell is + /// dropped whatever the list says. + pub fn set_generic(&mut self, app: &str, tools: Vec) { + let tools = tools.into_iter().filter(|t| !OCTOS_SHELL.contains(&t.as_str())).collect(); + self.generic.insert(app.to_string(), tools); } + + /// The kernel tools `app`'s agent keeps (none unless granted); + /// developer mode's set when it covers the app. + pub fn generic(&self, app: &str, dev_all: bool) -> Vec { + if dev_all { + return DEV_GENERIC_TOOLS.iter().map(|t| t.to_string()).collect(); + } + self.generic.get(app).cloned().unwrap_or_default() + } + + /// Whether anything was declared, granted or set for `app`. + pub fn knows(&self, app: &str) -> bool { + self.tools.contains_key(app) || self.grants.contains_key(app) || self.generic.contains_key(app) + } + pub fn entry(&self, owner: &str, tool: &str) -> Option<&Value> { self.tools.get(owner)?.iter().find(|e| e["name"] == tool) } + + /// The owning app of a declared tool name. + pub fn owner_of(&self, tool: &str) -> Option<&str> { + self.tools.iter().find(|(_, entries)| entries.iter().any(|e| e["name"] == tool)).map(|(owner, _)| owner.as_str()) + } + fn shareable(entry: &Value) -> bool { entry["shareable"] == true } + /// Whether `caller`'s agent may call `owner`'s `tool`. Developer mode /// grants every shareable tool, except the toolbox's: those run under /// the grant's scope, which developer mode cannot invent. @@ -145,12 +236,13 @@ impl Catalog { return true; } let dev_all = dev_all && owner != TOOLBOX; - Self::shareable(entry) && (dev_all || self.grants.get(caller).is_some_and(|g| g.contains(tool))) + Self::shareable(entry) && (dev_all || self.grants.get(caller).is_some_and(|g| g.contains(&(owner.to_string(), tool.to_string())))) } + /// What `app`'s peer registers: its own tools, and the shareable tools of - /// other apps granted to it, each naming its owner. + /// other apps granted to it; each names its owning app (`app`). pub fn declarations(&self, app: &str, dev_all: bool) -> Vec { - let mut out: Vec = self.tools.get(app).into_iter().flatten().filter_map(|e| host_tools::declaration(e, None)).collect(); + let mut out: Vec = self.tools.get(app).into_iter().flatten().filter_map(|e| host_tools::declaration(e, Some(app))).collect(); for (owner, entries) in &self.tools { if owner == app { continue; @@ -176,19 +268,11 @@ impl Catalog { } } -/// `terminal.run` as the Terminal declares it for the host: destructive, the -/// shell's sheet, shareable; `auto_approvable: false` is the shell's rule -/// (`native-apps.json` `agent.tool_policy`), never a declaration field. +/// `terminal.run` as the Terminal declares it (`native-apps.json`): +/// destructive, the shell's sheet, shareable; `auto_approvable: false` is +/// the shell's rule (`agent.tool_policy`), never a declaration field. pub fn terminal_run_declaration() -> Value { - serde_json::json!({ - "name": TERMINAL_RUN, - "app": "terminal", - "description": "Type a command followed by Enter into the person's live Terminal. The person approves each command first, on a sheet that shows it exactly; it then runs for real, unsandboxed, in the terminal they see. It returns at once: the output is on the Terminal's screen.", - "input_schema": {"type": "object", "properties": {"command": {"type": "string", "maxLength": 4096}}, "required": ["command"], "additionalProperties": false}, - "risk": "destructive", - "confirm": "host", - "shareable": true, - }) + Catalog::shipped().entry("terminal", TERMINAL_RUN).cloned().expect("native-apps.json declares terminal.run") } #[derive(Clone, Debug, PartialEq)] @@ -361,7 +445,9 @@ impl Relay { } let target = if self.executors.contains_key(&owner) { Target::Executor(owner.clone()) - } else if tool == TERMINAL_RUN && owner == "terminal" { + } else if owner == TERMINAL_APP { + // The Terminal's tools run on its AI bus service, in the + // terminal the person sees. Target::Bus } else { return refuse(&reply, "app_not_running", format!("{} isn't running", crate::approvals::sheet::app_label(&owner))); diff --git a/crates/shell/src/host_tools/script_apps.rs b/crates/shell/src/host_tools/script_apps.rs new file mode 100644 index 00000000..544433d0 --- /dev/null +++ b/crates/shell/src/host_tools/script_apps.rs @@ -0,0 +1,322 @@ +//! Script apps' tools (ADR 0004 §4, §7, §12; G3): what a store or system +//! app offers its agent and others, from its admitted bundle, and the +//! executor that runs them on its host services. +//! +//! **Where the tools come from.** App Hub's bundle carries `tools.json` next +//! to `manifest.json`; [`load`] reads it with App Hub's own loader +//! (`octosense_app_policy::AgentBundle::load`: the bundle's digest must +//! match its manifest, and every rule the store's gate applies holds), from +//! the unpacked system app (`octosense_appstore::system::prepare`) or the +//! installed bundle (`//bundle`). The manifest's +//! `agent.tools` names both kinds of grant: a plain name (`web_search`) is an +//! octos kernel tool its agent keeps (`generic_tools`; never octos's +//! shell), a dotted one (`mail.send`) another app's shareable tool, granted +//! at install and marked with its owner. +//! +//! **Where the calls go** ([`HostServiceExecutor`]). A tool the bundle says +//! is `implemented_by: "host-service"` runs on the host service of its +//! namespace (`news.list` → the `news` service), exactly as the app's own +//! `host.request("news.list", …)` would: with the app's identity, never +//! from a sheet, and only when the app's manifest was granted that family. +//! A tool the app's own script implements (`implemented_by: "app"`) needs +//! the app open, and is refused visibly until the Card runner can take it. +//! Answers arrive on App Hub's reply queue; [`poll`] (from +//! `host_tools::pump`) hands each back to its call. + +use std::collections::{BTreeSet, HashMap}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use serde_json::{json, Value}; + +use crate::ai_host::app_peers::host_tools::{HostToolCall, ToolExecutor, ToolOutcome, ToolReply}; +use octosense_app_policy::{AgentBundle, AppManifest, ImplementedBy, ToolSpec, MANIFEST_FILE}; +use octosense_appstore::services::{ServiceCall, ServiceHost}; + +/// What one script app's bundle gives the host. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Loaded { + /// Its own tools, as the kernel takes them (`tools.json` entries). + pub tools: Vec, + /// Exactly the octos kernel tools its agent keeps. + pub generic: Vec, + /// Other apps' tools its agent asks for (owner resolved at grant time). + pub asks: Vec, + /// Its tools that run on host services, and the families it was granted. + pub host_service_tools: BTreeSet, + pub families: BTreeSet, + /// The admitted manifest (the toolbox reads its grant from it). + pub manifest: Value, +} + +/// A `tools.json` entry for the kernel (octos `ToolDecl`): `implemented_by`, +/// `private_data` are the shell's, not the kernel's. +fn declaration(tool: &ToolSpec) -> Value { + let mut out = json!({ + "name": tool.name, + "description": tool.description, + "input_schema": tool.input_schema, + "output_schema": tool.output_schema, + "risk": format!("{:?}", tool.risk).to_lowercase(), + "background": tool.background, + "shareable": tool.shareable, + }); + if tool.confirmed_by_app() { + out["confirm"] = json!("app"); + } + out +} + +/// Read an unpacked bundle's agent block (digest and every gate rule +/// checked by App Hub's loader). +pub fn from_bundle(bundle: &Path) -> Result { + let text = std::fs::read_to_string(bundle.join(MANIFEST_FILE)).map_err(|e| format!("{}: {e}", bundle.display()))?; + let manifest = AppManifest::parse(&text)?; + let agent = AgentBundle::load(bundle, &manifest)?; + let families: BTreeSet = manifest.capabilities.iter().cloned().collect(); + let raw: Value = serde_json::from_str(&text).unwrap_or(Value::Null); + let Some(agent) = agent else { return Ok(Loaded { families, manifest: raw, ..Loaded::default() }) }; + let mut loaded = Loaded { families, manifest: raw, ..Loaded::default() }; + for tool in &agent.tools { + loaded.tools.push(declaration(tool)); + if tool.implemented_by == ImplementedBy::HostService { + loaded.host_service_tools.insert(tool.name.clone()); + } + } + for name in &agent.generic_tools { + if name.contains('.') { + loaded.asks.push(name.clone()); + } else if !super::relay::OCTOS_SHELL.contains(&name.as_str()) { + loaded.generic.push(name.clone()); + } + } + Ok(loaded) +} + +/// The owning app of a tool another app asks for: whoever declares it, else +/// the system app of its namespace (`mail.send` → `os.mail`). +fn owner_for(tool: &str) -> String { + super::owner_of(tool).unwrap_or_else(|| format!("{}{}", octosense_appstore::system::SYSTEM_ID_PREFIX, tool.split('.').next().unwrap_or(tool))) +} + +/// Hand one app's agent block to the relay: its tools, its grants (other +/// apps' tools; the toolbox's, with `toolbox-peers`), its kernel tools and +/// its executor. +pub fn install(app: &str, loaded: Loaded, host_dir: PathBuf) { + super::declare(app, loaded.tools.clone()); + for tool in &loaded.asks { + let owner = owner_for(tool); + if owner != app { + super::grant(app, &owner, tool); + } + } + super::set_generic(app, loaded.generic.clone()); + // Its toolbox grant, from the same manifest (ADR 0002 §6, #151). + #[cfg(feature = "toolbox-peers")] + super::toolbox::grant_manifest(app, &loaded.manifest); + let executor = HostServiceExecutor { app: app.to_string(), tools: loaded.host_service_tools, families: loaded.families, host_dir }; + super::set_executor(app, Some(Arc::new(executor))); +} + +/// Load `app`'s agent block from App Hub: a system app's packed bundle, or +/// an installed one. +pub fn load(app: &str) -> Result<(), String> { + let root = octosense_appstore::data_root_if_set().ok_or("App Hub has no apps root yet")?; + let bundle = match octosense_appstore::system::system_app(app) { + Some(system) => octosense_appstore::system::prepare(&root, &system)?.0, + None => root.join(app).join("bundle"), + }; + let loaded = from_bundle(&bundle)?; + install(app, loaded, root.join(".host")); + Ok(()) +} + +// ------------------------------------------------------------ the executor + +/// Runs a script app's host-service tools as the app's own +/// `host.request` would, and answers each call once. +pub struct HostServiceExecutor { + pub app: String, + /// The tools that run on a host service. + pub tools: BTreeSet, + /// The capability families the app's manifest was granted. + pub families: BTreeSet, + /// The directory App Hub hands every host service (`/.host`). + pub host_dir: PathBuf, +} + +/// Where a call's answer goes: App Hub's reply queue, keyed by a heap key no +/// isolate uses. +struct Waiting { + reply: ToolReply, +} + +static WAITING: Mutex>> = Mutex::new(None); +/// Heap keys for tool calls: far above any isolate's. +static NEXT_KEY: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(usize::MAX / 2); + +/// A tool call never raises a sheet: the person is not in the app. +struct NoSheet; +impl ServiceHost for NoSheet { + fn open_sheet(&mut self, _body: String) {} + fn close_sheet(&mut self) {} +} + +impl ToolExecutor for HostServiceExecutor { + fn execute(&self, call: HostToolCall, reply: ToolReply) { + if !reply.is_open() { + return; + } + if !self.tools.contains(&call.name) { + reply.finish(ToolOutcome::error("app_tool_unavailable", format!("{} runs in the app's own script; open the app to use it", call.name))); + return; + } + let family = call.name.split('.').next().unwrap_or(""); + if !self.families.contains(family) { + reply.finish(ToolOutcome::error("not_granted", format!("{} was not granted the {family} service", self.app))); + return; + } + let key = NEXT_KEY.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + WAITING.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(HashMap::new).insert(key, Waiting { reply }); + let service_call = ServiceCall { app_id: self.app.clone(), service: call.name.clone(), args: call.args.clone(), from_sheet: false, host_dir: self.host_dir.clone() }; + octosense_appstore::services::dispatch(service_call, key, 0, &mut NoSheet); + } + + fn cancel(&self, _call_id: &str) { + // The reply is closed: whatever the service answers is dropped. + } +} + +/// Deliver the host services' answers to the calls waiting on them. +pub fn poll() { + let keys: Vec = match WAITING.lock().unwrap_or_else(|e| e.into_inner()).as_ref() { + Some(w) if !w.is_empty() => w.keys().copied().collect(), + _ => return, + }; + for (key, _, result) in octosense_appstore::services::take_replies_for(&keys) { + let Some(waiting) = WAITING.lock().unwrap_or_else(|e| e.into_inner()).as_mut().and_then(|w| w.remove(&key)) else { continue }; + waiting.reply.finish(match result { + Ok(text) => ToolOutcome::Ok(serde_json::from_str(&text).unwrap_or(Value::String(text))), + Err(message) => ToolOutcome::error("app_error", message), + }); + } + // A call cancelled while its service worked stays until the service + // answers (its reply, closed, sends nothing). +} + +/// How many calls wait on a host service. +pub fn waiting() -> usize { + WAITING.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map_or(0, HashMap::len) +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use crate::ai_host::app_peers::host_tools::HostToolCall; + use octosense_appstore::services::{HostService, Replier}; + + /// A copy of `apps//bundle` stamped as App Hub packs it (the + /// manifest carries the bundle's digest), with `edit` applied first. + pub(crate) fn stamped_bundle(name: &str, tag: &str, edit: impl FnOnce(&Path, &mut Value)) -> PathBuf { + let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../apps").join(name).join("bundle"); + let dir = std::env::temp_dir().join(format!("octosense-bundle-{name}-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + for entry in std::fs::read_dir(&src).unwrap().flatten() { + if entry.file_type().unwrap().is_file() { + std::fs::copy(entry.path(), dir.join(entry.file_name())).unwrap(); + } + } + let mut manifest: Value = serde_json::from_str(&std::fs::read_to_string(dir.join(MANIFEST_FILE)).unwrap()).unwrap(); + edit(&dir, &mut manifest); + manifest["integrity"]["bundle_blake3"] = json!(octosense_app_policy::digest_dir(&dir).unwrap()); + std::fs::write(dir.join(MANIFEST_FILE), serde_json::to_vec_pretty(&manifest).unwrap()).unwrap(); + dir + } + + /// G3 (e): News's bundle offers its agent (and, shared, others) real + /// read tools on its host service. + #[test] + fn news_offers_its_read_tools_from_its_bundle() { + let dir = stamped_bundle("news", "tools", |_, _| {}); + let loaded = from_bundle(&dir).unwrap(); + let names: Vec<&str> = loaded.tools.iter().filter_map(|t| t["name"].as_str()).collect(); + assert_eq!(names, ["news.list", "news.read"]); + assert!(loaded.tools.iter().all(|t| t["risk"] == "read" && t["shareable"] == true && t.get("implemented_by").is_none())); + assert_eq!(loaded.host_service_tools.len(), 2); + assert!(loaded.families.contains("news"), "News is granted its service"); + assert!(loaded.generic.is_empty(), "no agent block: no kernel tools"); + // A tampered bundle is refused (App Hub's digest check). + std::fs::write(dir.join("tools.json"), "{}").unwrap(); + assert!(from_bundle(&dir).is_err()); + let _ = std::fs::remove_dir_all(dir); + } + + /// The manifest's `agent.tools`: plain names are kernel tools (never + /// octos's shell), dotted ones other apps' tools, granted with their + /// owner. + #[test] + fn a_script_apps_agent_block_splits_kernel_tools_from_other_apps_tools() { + let dir = stamped_bundle("news", "agent", |_, m| { + m["agent"] = json!({"profile": "read-only", "tools": ["web_search", "ask_user_question", "shell", "mail.send"], + "model": {"needs": ["tool_calling"]}}); + }); + let loaded = from_bundle(&dir).unwrap(); + assert_eq!(loaded.generic, ["web_search", "ask_user_question"]); + assert_eq!(loaded.asks, ["mail.send"]); + assert_eq!(owner_for("mail.send"), "os.mail", "the system app of its namespace until someone declares it"); + let _ = std::fs::remove_dir_all(dir); + } + + struct Probe; + impl HostService for Probe { + fn family(&self) -> &'static str { + "g3probe" + } + fn call(&mut self, call: ServiceCall, reply: Replier, _host: &mut dyn ServiceHost) { + reply.send(Ok(json!({"app": call.app_id, "service": call.service, "args": call.args, "from_sheet": call.from_sheet}))); + } + } + + fn call(name: &str) -> HostToolCall { + HostToolCall::parse(&json!({"peer": "p", "session_id": "s", "turn_id": "t", "call_id": format!("c-{name}"), "name": name, "args": {"q": 1}})).unwrap() + } + + fn reply() -> (ToolReply, Arc>>) { + let sent: Arc>> = Arc::default(); + let s = sent.clone(); + (ToolReply::new("c", move |v| s.lock().unwrap().push(v)), sent) + } + + /// The executor runs a host-service tool as the app's own + /// `host.request` would (its identity, never a sheet), only for a + /// granted family; the answer reaches the call through `poll`. + #[test] + fn a_host_service_tool_runs_as_the_apps_own_request() { + octosense_appstore::services::register_host_service(Box::new(Probe)); + let exec = HostServiceExecutor { + app: "os.g3probe".into(), + tools: ["g3probe.echo".to_string(), "other.x".to_string()].into_iter().collect(), + families: ["g3probe".to_string()].into_iter().collect(), + host_dir: std::env::temp_dir(), + }; + let (r, sent) = reply(); + exec.execute(call("g3probe.echo"), r); + for _ in 0..50 { + poll(); + if !sent.lock().unwrap().is_empty() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + let got = sent.lock().unwrap().clone(); + assert_eq!(got[0]["ok"], true, "{got:?}"); + assert_eq!(got[0]["data"], json!({"app": "os.g3probe", "service": "g3probe.echo", "args": {"q": 1}, "from_sheet": false})); + let (r, sent) = reply(); + exec.execute(call("other.x"), r); + assert_eq!(sent.lock().unwrap()[0]["error"]["kind"], "not_granted", "a family the manifest was not granted"); + let (r, sent) = reply(); + exec.execute(call("g3probe.in_script"), r); + assert_eq!(sent.lock().unwrap()[0]["error"]["kind"], "app_tool_unavailable"); + } +} diff --git a/crates/shell/src/host_tools/tests.rs b/crates/shell/src/host_tools/tests.rs index 83d29a7b..6a9917e8 100644 --- a/crates/shell/src/host_tools/tests.rs +++ b/crates/shell/src/host_tools/tests.rs @@ -171,8 +171,12 @@ fn another_apps_agent_needs_a_grant_and_the_system_agent_its_own() { let (r, sent) = reply("c1"); relay.handle(Event::Call { call: call("c1", "mail.send", "calendar"), reply: r }, &mut w); assert_eq!(sent.lock().unwrap()[0]["error"]["kind"], "not_granted", "no grant yet"); - relay.catalog.grant("calendar", "mail.send"); - relay.catalog.grant("calendar", "mail.purge"); + relay.catalog.grant("calendar", "notes", "mail.send"); + let (r, sent) = reply("c1b"); + relay.handle(Event::Call { call: call("c1b", "mail.send", "calendar"), reply: r }, &mut w); + assert_eq!(sent.lock().unwrap()[0]["error"]["kind"], "not_granted", "a grant names its owning app"); + relay.catalog.grant("calendar", "mail", "mail.send"); + relay.catalog.grant("calendar", "mail", "mail.purge"); let (r, _) = reply("c2"); relay.handle(Event::Call { call: call("c2", "mail.send", "calendar"), reply: r }, &mut w); let (r, sent) = reply("c3"); @@ -230,7 +234,7 @@ impl ConfirmSheet for SendSheet { #[test] fn a_confirm_app_call_is_acknowledged_then_handed_to_the_owning_apps_sheet_and_runs_once_approved() { let (mut relay, exec) = relay_with("rinx", vec![decl("rinx.message.send", true, "app")]); - relay.catalog.grant("calendar", "rinx.message.send"); + relay.catalog.grant("calendar", "rinx", "rinx.message.send"); let mut w = World::new(FixedDevMode::off()); // Rinx's send sheet, registered as the router's confirm: app handler. let sheet = Arc::new(SendSheet::default()); @@ -375,7 +379,57 @@ fn the_shipped_catalog_offers_the_terminals_run_to_those_granted_it() { let run = catalog.entry("terminal", TERMINAL_RUN).unwrap(); assert_eq!((run["risk"].as_str(), run["confirm"].as_str()), (Some("destructive"), Some("host"))); assert!(catalog.declarations("rinx", false).is_empty(), "nobody gets it without a grant"); - assert_eq!(catalog.declarations("rinx", true).len(), 1, "developer mode grants every shareable tool"); + assert_eq!(catalog.declarations("rinx", true).len(), 3, "developer mode grants every shareable tool"); +} + +/// G3: the native apps' agent blocks (`native-apps.json`) are the shipped +/// catalog: the Terminal's own tools, each app's exact kernel tools. +#[test] +fn the_shipped_catalog_is_the_native_apps_agent_blocks() { + let catalog = Catalog::shipped(); + for tool in ["terminal.run", "terminal.read_screen", "terminal.read_scrollback"] { + assert!(catalog.entry("terminal", tool).is_some(), "{tool}"); + assert_eq!(catalog.owner_of(tool), Some("terminal")); + } + let rinx = crate::native_apps::find("rinx").unwrap(); + assert_eq!(catalog.generic("rinx", false), rinx.generic_tools.iter().map(|t| t.to_string()).collect::>()); + assert!(catalog.generic("rinx", false).contains(&"ask_user_question".to_string())); + assert!(catalog.generic("sheets", false).is_empty(), "an app granted no kernel tools keeps none"); + assert!(catalog.generic("nowhere", false).is_empty()); + for dev in [false, true] { + for shell in super::relay::OCTOS_SHELL { + assert!(!catalog.generic("rinx", dev).iter().any(|t| t == shell), "never octos's shell"); + } + } + // Every declaration names its owning app. + let own = Catalog::shipped().declarations("terminal", false); + assert_eq!(own.len(), 3); + assert!(own.iter().all(|d| d["app"] == "terminal" && d.get("auto_approvable").is_none())); +} + +#[test] +fn a_kernel_tool_list_never_keeps_octos_shell() { + let mut catalog = Catalog::default(); + catalog.set_generic("notes", vec!["read_file".into(), "shell".into(), "bash".into(), "exec_command".into(), "web_search".into()]); + assert_eq!(catalog.generic("notes", false), vec!["read_file".to_string(), "web_search".to_string()]); +} + +/// The Terminal's read tools run on its AI bus service, in every hosting. +#[test] +fn the_terminals_read_tools_are_granted_then_read_on_the_bus() { + let mut relay = Relay::default(); + let mut w = World::new(FixedDevMode::off()); + let (r, sent) = reply("c1"); + relay.handle(Event::Call { call: call("c1", "terminal.read_screen", "rinx"), reply: r }, &mut w); + assert_eq!(sent.lock().unwrap()[0]["error"]["kind"], "not_granted"); + relay.catalog.grant("rinx", "terminal", "terminal.read_screen"); + let mut c = call("c2", "terminal.read_screen", "rinx"); + c.args = json!({}); + let (r, sent) = reply("c2"); + relay.handle(Event::Call { call: c, reply: r }, &mut w); + assert_eq!(w.bus, vec![(format!("{BUS_PREFIX}c2"), "terminal".into(), "read_screen".into(), "{}".into())]); + relay.handle(Event::BusResult { call_id: "c2".into(), outcome: ToolOutcome::Ok(json!({"text": "$ ls"})) }, &mut w); + assert_eq!(sent.lock().unwrap()[0]["data"]["text"], "$ ls"); } /// The system toolbox's tools as its catalog declares them (the real ones @@ -420,7 +474,7 @@ fn a_peer_is_offered_exactly_its_granted_toolbox_tools_marked_with_their_owner_a // No grant, no toolbox tools; developer mode does not invent a grant // (the toolbox needs its scope), though it grants other shareable tools. assert!(offered_names(&relay, "calendar", false, true).is_empty()); - assert_eq!(offered_names(&relay, "calendar", true, true), [TERMINAL_RUN]); + assert_eq!(offered_names(&relay, "calendar", true, true), ["terminal.read_screen", "terminal.read_scrollback", TERMINAL_RUN]); } #[test] diff --git a/crates/shell/src/lib.rs b/crates/shell/src/lib.rs index 5bbec88b..fedb42b8 100644 --- a/crates/shell/src/lib.rs +++ b/crates/shell/src/lib.rs @@ -5236,6 +5236,8 @@ impl MatchEvent for App { #[cfg(any(feature = "app-hub", native_mobile))] for id in octosense_app_hub_app::take_completed_installs() { self.installed_app_changed(cx, &id); + // Its tools, grants and kernel tools, as installed (ADR 0004 §7). + host_tools::script_app_installed(&id); } self.drain_hub(cx); self.drain_client_lines(cx); From d68700c1d03cd017f3cb5c1eee829a05e55bff6b Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:11:25 -0700 Subject: [PATCH 4/9] fix(kernel): fail closed when the tool policy cannot be enforced [skip ci] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0004 §12 (G13): `launch::prepare` ignored a refused `tool_policy` write (a foreign policy in the profile, the person's own octos home via OCTOS_APP_CORE_DIR, an unwritable profile) and started the kernel anyway, so the system agent and every app peer had octos's shell. - `system_tools::enforce` reads the profile back after writing and refuses when the policy is not there. - `launch::prepare` returns the refusal; the generation then does not start the kernel and closes every consumer's connection with `CloseReason::Failed` saying why ("The assistant was not started: OctoSense could not enforce its tool policy …"). - App peers no longer rely on the profile policy alone: they are registered with exactly their granted `generic_tools` (previous commit), never octos's shell. - Test: a foreign policy starts no kernel program, leaves the person's policy alone, and a fixed profile starts it with the policy. Co-Authored-By: Claude Opus 5.5 --- crates/kernel/src/kernel.rs | 10 ++++++++-- crates/kernel/src/launch.rs | 17 ++++++++++++++--- crates/kernel/src/lib.rs | 6 ++++-- crates/kernel/src/system_tools.rs | 31 +++++++++++++++++++++++-------- crates/kernel/tests/core.rs | 28 ++++++++++++++++++++++++++++ 5 files changed, 77 insertions(+), 15 deletions(-) diff --git a/crates/kernel/src/kernel.rs b/crates/kernel/src/kernel.rs index 39c6ae8c..5227ea87 100644 --- a/crates/kernel/src/kernel.rs +++ b/crates/kernel/src/kernel.rs @@ -288,6 +288,9 @@ pub(crate) struct GenerationConfig { pub network: Arc, pub core_dir: PathBuf, pub log: LogSink, + /// Why this generation must not start (its tool policy could not be + /// enforced: `launch::prepare`); it then ends as `Failed` at once. + pub refused: Option, } /// Run a generation until it is stopped or its kernel ends. @@ -298,12 +301,15 @@ pub(crate) async fn supervise( done: watch::Sender, ended: impl FnOnce() + Send, ) { - let GenerationConfig { generation, launch, network, core_dir, log } = config; + let GenerationConfig { generation, launch, network, core_dir, log, refused } = config; let mut consumers: HashMap> = HashMap::new(); let mut router = Router::new(&core_dir); let tail = Tail::default(); (log)(&format!("octos-core: starting kernel {generation}: {}", describe(&launch))); - let started = start(&launch, &network, &log, &tail); + let started = match refused { + Some(why) => Err(why), + None => start(&launch, &network, &log, &tail), + }; let reason = match started { Err(e) => { (log)(&format!("octos-core: kernel {generation} did not start: {e}")); diff --git a/crates/kernel/src/launch.rs b/crates/kernel/src/launch.rs index 46a6316f..bd3717f3 100644 --- a/crates/kernel/src/launch.rs +++ b/crates/kernel/src/launch.rs @@ -200,7 +200,13 @@ pub(crate) fn phone_stdio(program: PathBuf, core_dir: &Path, extra: &[(String, S /// spawn's chdir with ENOENT, permanently, since the kernel would create it), /// on Android the kernel config's memory budget (as AppCard did), and the /// system agent's tool policy in the profile ([`crate::system_tools`]). -pub(crate) fn prepare(launch: &Launch, core_dir: &Path) { +/// +/// **Fails closed** (ADR 0004 §12, G13): when the tool policy cannot be +/// written and read back (a foreign policy in the profile, the person's own +/// octos home, an unwritable profile), `Err` says why and the kernel is not +/// started: every consumer's connection closes with that reason. A kernel +/// without the policy would give the system agent octos's shell. +pub(crate) fn prepare(launch: &Launch, core_dir: &Path) -> Result<(), String> { if let Err(e) = std::fs::create_dir_all(core_dir) { log::warn!("octos-core: could not create {}: {e}", core_dir.display()); } @@ -219,11 +225,16 @@ pub(crate) fn prepare(launch: &Launch, core_dir: &Path) { ensure_kernel_config(&dirs::kernel_home(core_dir)); } // Every start: the system agent's tool set (ADR 0004 §12) as the - // profile's tool policy, which octos reads at start. - crate::system_tools::enforce(core_dir); + // profile's tool policy, which octos reads at start. Refused: no start. + if let crate::system_tools::Enforced::Refused(why) = crate::system_tools::enforce(core_dir) { + return Err(format!( + "The assistant was not started: OctoSense could not enforce its tool policy (no octos shell for any agent), because {why}" + )); + } // ... and the grants it starts with (a Settings change applies from the // next start: the shell offers a restart). crate::system_tools::take_grants_for_start(); + Ok(()) } /// Floor for `memory.max_inject_tokens` in the phone kernel's config. diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index eab725cd..45049bef 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -341,7 +341,9 @@ impl Core { if let Some(mut previous) = previous { let _ = previous.wait_for(|stopped| *stopped).await; } - launch::prepare(&launch, &core_dir); + // A tool policy that could not be enforced starts nothing + // (fails closed): the generation ends at once with the reason. + let refused = launch::prepare(&launch, &core_dir).err(); let ended = move || { if let Some(inner) = weak.upgrade() { let mut st = inner.state.lock().unwrap(); @@ -350,7 +352,7 @@ impl Core { } } }; - let config = kernel::GenerationConfig { generation: id, launch, network, core_dir, log }; + let config = kernel::GenerationConfig { generation: id, launch, network, core_dir, log, refused }; kernel::supervise(config, ready_tx, ctl_rx, done_tx, ended).await; }); Ok(Connection { core: self.clone(), id: conn, generation: id, ctl, inbound: rx, ready, closed: None }) diff --git a/crates/kernel/src/system_tools.rs b/crates/kernel/src/system_tools.rs index 6a460ed6..c7ebc72c 100644 --- a/crates/kernel/src/system_tools.rs +++ b/crates/kernel/src/system_tools.rs @@ -32,13 +32,13 @@ //! client's turns on the session, so the shell registers without it; the //! exact list waits for a durable host-only list (octos#2605). The //! real-kernel exact-list test is kept, ignored until then. -//! - **App peers are not capped below what they can be granted**, and are -//! not yet narrowed to their grants either: the broker registers them with -//! `generic_tools` omitted, so each keeps octos's whole kernel roster -//! except what this policy denies (octos's shell). Narrowing each peer to -//! its grants through `generic_tools` is plan step 6, not done yet. -//! Host-routed tools (app, toolbox, cross-app tools, command execution) -//! are registered after the policy, so it never strips them. +//! - **App peers are narrowed to their grants**: the shell registers each +//! with `generic_tools`, exactly the kernel tools its manifest declares +//! and the person granted (`native-apps.json` `agent.generic_tools`, a +//! script app's `agent.tools`; none when it names none), never octos's +//! shell; this policy is a second barrier. Host-routed tools (app, +//! toolbox, cross-app tools, command execution) are registered after the +//! policy, so it never strips them. //! - **Talk to Octos external turns are unaffected**: octos confines them to //! its external allowlist ([`EXTERNAL_TURN_TOOLS`]), none of which is the //! shell (UPCR-2026-036). @@ -48,6 +48,12 @@ //! person's standalone octos home, and [`enforce`] replaces only a policy //! OctoSense wrote ([`POLICY_OWNER`]): it refuses, and warns, on a foreign //! policy and on the person's own octos home. +//! +//! **Fails closed** (G13): a refused (or unwritable, or not read back) +//! policy starts no kernel (`launch::prepare`); every consumer's connection +//! closes with the reason. App peers do not rely on the policy alone: the +//! shell registers each with exactly the kernel tools its manifest grants +//! (`generic_tools`), which never include octos's shell. use std::collections::BTreeSet; use std::path::Path; @@ -313,14 +319,23 @@ pub(crate) fn enforce_unless_shared(core_dir: &Path, shared: Option<&Path>) -> E crate::network::write_private(dir, name, &body).map_err(|e| e.to_string()) }); match result { - Ok(()) => { + Ok(()) if reads_back(&path) => { log::info!("octos-core: wrote OctoSense's tool policy to {}", path.display()); Enforced::Written } + Ok(()) => refused("does not read back with OctoSense's tool policy after writing it"), Err(e) => Enforced::Refused(format!("could not write {}: {e}", path.display())), } } +/// Whether the profile at `path` carries [`tool_policy`] now. +fn reads_back(path: &Path) -> bool { + std::fs::read(path) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .is_some_and(|root| root["config"]["tool_policy"] == tool_policy()) +} + fn same_dir(a: &Path, b: &Path) -> bool { a == b || matches!((std::fs::canonicalize(a), std::fs::canonicalize(b)), (Ok(x), Ok(y)) if x == y) } diff --git a/crates/kernel/tests/core.rs b/crates/kernel/tests/core.rs index f27a8c7e..5d404df2 100644 --- a/crates/kernel/tests/core.rs +++ b/crates/kernel/tests/core.rs @@ -279,3 +279,31 @@ async fn a_kernel_that_cannot_start_fails_the_connection() { let mut a = core.connect().unwrap(); assert!(matches!(next(&mut a).await, Err(CloseReason::Failed(_)))); } + +/// G13 (ADR 0004 §12): a tool policy that cannot be enforced (here a +/// foreign one in the profile) starts no kernel: the connection closes +/// with the reason and the program never runs. Once the profile is +/// OctoSense's again, the next connection starts it with the policy. +#[tokio::test(flavor = "multi_thread")] +async fn a_tool_policy_that_cannot_be_enforced_starts_no_kernel() { + let (core, log) = core("foreign-policy"); + let dir = core.core_dir().unwrap(); + let profile = octosense_kernel::dirs::profile_path(&dir); + std::fs::create_dir_all(profile.parent().unwrap()).unwrap(); + let foreign = r#"{"id":"_main","config":{"tool_policy":{"allow":["*"]}}}"#; + std::fs::write(&profile, foreign).unwrap(); + let mut conn = core.connect().unwrap(); + match next(&mut conn).await { + Err(CloseReason::Failed(why)) => assert!(why.contains("tool policy") && why.contains("not started"), "{why}"), + other => panic!("the kernel must not start: {other:?}"), + } + assert!(!log.exists(), "the kernel program never ran"); + assert_eq!(std::fs::read_to_string(&profile).unwrap(), foreign, "the person's policy is left alone"); + // The profile is OctoSense's again: the next start writes the policy. + std::fs::write(&profile, r#"{"id":"_main","config":{}}"#).unwrap(); + let mut conn = core.connect().unwrap(); + let listed = call(&mut conn, "1", "session/list", json!({})).await; + assert!(listed["pid"].is_u64(), "{listed}"); + let written: Value = serde_json::from_str(&std::fs::read_to_string(&profile).unwrap()).unwrap(); + assert_eq!(written["config"]["tool_policy"], octosense_kernel::system_tools::tool_policy()); +} From e92b3447e6cf296ec6e75ebc77fba675b22a9b98 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:44:29 -0700 Subject: [PATCH 5/9] feat(shell): agent questions are routed by their turn's origin [skip ci] G11 follow-up (the person's decision): an app agent has ONE shared conversation on its peer session, driven by the person (the app's UI, its cards), the app and the system agent; a question follows the TURN's origin, not its session. - app-peers: `TurnOrigin` (person, app, system_agent) on each `AgentQuestion`: the kernel's `origin` on the event when octos carries it (`origin_reported`), until then the broker's derivation: the turns it started for `peer/input` are the system agent's, a context's the person's, any other the app's. - shell: the request model keeps the `Origin` (and whether it was reported); person and app turns ask in the app's conversation, system agent turns in the system chat. The later octos field replaces the derivation without touching the consumers. Co-Authored-By: Claude Opus 5.5 --- crates/app-peers/src/broker.rs | 14 ++++++ crates/app-peers/src/host_tools.rs | 42 +++++++++++++++++ crates/app-peers/tests/broker.rs | 11 ++++- crates/shell/src/questions/mod.rs | 73 +++++++++++++++++------------ crates/shell/src/questions/tests.rs | 35 ++++++++++++-- 5 files changed, 141 insertions(+), 34 deletions(-) diff --git a/crates/app-peers/src/broker.rs b/crates/app-peers/src/broker.rs index 91b354c3..16cde929 100644 --- a/crates/app-peers/src/broker.rs +++ b/crates/app-peers/src/broker.rs @@ -1439,6 +1439,20 @@ impl Inner { None if st.input_turns.contains(&question.turn_id) => CallOrigin::PeerInput, None => CallOrigin::PeerOwn, }; + // Until octos reports the turn's origin, derive it from what + // this host knows (what started each turn it started, G2): the + // turns it started for `peer/input` are the system agent's; a + // turn its starter said the person or the app started is theirs; + // an unsaid context turn is the person's, any other the app's. + if !question.origin_reported { + question.turn_origin = match (st.trigger_of(&question.turn_id), question.origin) { + (TurnTrigger::SystemAgent, _) | (_, CallOrigin::PeerInput) => host_tools::TurnOrigin::SystemAgent, + (TurnTrigger::Person, _) => host_tools::TurnOrigin::Person, + (TurnTrigger::App | TurnTrigger::Incoming { .. }, _) => host_tools::TurnOrigin::App, + (TurnTrigger::Unknown, CallOrigin::Context) => host_tools::TurnOrigin::Person, + (TurnTrigger::Unknown, _) => host_tools::TurnOrigin::App, + }; + } (st.link.clone(), st.account.clone()) }; if let Some(context) = context { diff --git a/crates/app-peers/src/host_tools.rs b/crates/app-peers/src/host_tools.rs index 1dc5d558..94dadf3f 100644 --- a/crates/app-peers/src/host_tools.rs +++ b/crates/app-peers/src/host_tools.rs @@ -477,6 +477,30 @@ pub struct QuestionItem { pub multi_select: bool, } +/// Who started the turn that asked (the peer's one conversation is shared: +/// the person or the app drive some turns, the system agent others). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TurnOrigin { + /// The person, in the app (its UI, its cards). + Person, + /// The app itself (a trigger, its own run). + App, + /// The system agent (`peer/input`). + SystemAgent, +} + +impl TurnOrigin { + /// octos's spelling (`person` | `app` | `system_agent`). + pub fn parse(text: &str) -> Option { + match text { + "person" | "user" => Some(TurnOrigin::Person), + "app" => Some(TurnOrigin::App), + "system_agent" | "system" => Some(TurnOrigin::SystemAgent), + _ => None, + } + } +} + /// An agent's `ask_user_question` on an app peer's session or one of its /// request contexts, with what the host knows about the turn that asked. #[derive(Clone, Debug, PartialEq, Eq)] @@ -496,6 +520,14 @@ pub struct AgentQuestion { /// Which turn asked: a context's (the person, in the app), the peer's /// own (the app's agent), or a `peer/input` turn (the system agent's). pub origin: CallOrigin, + /// Who started that turn: what the kernel reports (`origin` on the + /// event, once octos carries it), else what the host derives: the turns + /// it started for `peer/input` are the system agent's, a context's the + /// person's, any other the app's (or the person's: one shared + /// conversation). + pub turn_origin: TurnOrigin, + /// Whether `turn_origin` came from the kernel (not derived). + pub origin_reported: bool, } impl AgentQuestion { @@ -536,6 +568,16 @@ impl AgentQuestion { body: s(params, "body"), questions, origin: CallOrigin::PeerOwn, + turn_origin: TurnOrigin::App, + origin_reported: false, + }) + .map(|mut q| { + let reported = ["origin", "turn_origin"].iter().find_map(|k| params.get(*k).and_then(Value::as_str).and_then(TurnOrigin::parse)); + if let Some(origin) = reported { + q.turn_origin = origin; + q.origin_reported = true; + } + q }) } diff --git a/crates/app-peers/tests/broker.rs b/crates/app-peers/tests/broker.rs index b2bcdb75..f3730294 100644 --- a/crates/app-peers/tests/broker.rs +++ b/crates/app-peers/tests/broker.rs @@ -6,7 +6,7 @@ use std::sync::{Arc, Mutex}; use std::time::Duration; use octosense_app_peers::broker::{BoxFuture, Broker, BrokerConfig, Connector, Link, ToolHostHandle}; -use octosense_app_peers::host_tools::{AgentQuestion, ApprovalAnswer, CallOrigin, HostToolApproval, HostToolCall, PeerInput, QuestionAnswer, QuestionReply, ToolHost, ToolOutcome, ToolReply}; +use octosense_app_peers::host_tools::{AgentQuestion, ApprovalAnswer, CallOrigin, HostToolApproval, HostToolCall, PeerInput, QuestionAnswer, QuestionReply, ToolHost, ToolOutcome, ToolReply, TurnOrigin}; use octosense_app_peers::*; use serde_json::{json, Value}; use tokio::sync::mpsc; @@ -860,6 +860,15 @@ fn an_agents_question_goes_to_the_host_and_only_the_host_answers_it() { let origins: Vec<(String, CallOrigin)> = host.questions.lock().unwrap().iter().map(|(q, _)| (q.question_id.clone(), q.origin)).collect(); assert_eq!(origins[1], ("q2".to_string(), CallOrigin::PeerInput)); assert_eq!(origins[2], ("q3".to_string(), CallOrigin::PeerOwn)); + let turn_origins: Vec<(TurnOrigin, bool)> = host.questions.lock().unwrap().iter().map(|(q, _)| (q.turn_origin, q.origin_reported)).collect(); + assert_eq!(turn_origins, [(TurnOrigin::Person, false), (TurnOrigin::SystemAgent, false), (TurnOrigin::App, false)], "derived by the host"); + // A turn origin the kernel reports replaces the derivation. + let mut reported = question(&peer_session, "q4", "turn-own"); + reported["origin"] = json!("system_agent"); + notify(&script, "user_question/requested", reported); + wait_for("the reported one", || host.questions.lock().unwrap().len() == 4); + let fourth = host.questions.lock().unwrap()[3].0.clone(); + assert_eq!((fourth.turn_origin, fourth.origin_reported), (TurnOrigin::SystemAgent, true)); // A turn that ends closes its unanswered question. notify(&script, "turn/completed", json!({"session_id": peer_session, "turn_id": "turn-in"})); wait_for("closed", || host.closed_questions.lock().unwrap().contains(&"q2".to_string())); diff --git a/crates/shell/src/questions/mod.rs b/crates/shell/src/questions/mod.rs index dbdd0842..896958ee 100644 --- a/crates/shell/src/questions/mod.rs +++ b/crates/shell/src/questions/mod.rs @@ -4,17 +4,23 @@ //! //! | In | Here | Out | //! | --- | --- | --- | -//! | a broker's `user_question/requested` on an app peer's session or context ([`crate::host_tools::ShellToolHost`]) | [`Questions::requested`]: one [`Request`] with its owning app, peer, account, context, turn, the questions and options, who started the turn ([`Asker`]) and so which [`Conversation`] it belongs to | every [`Consumer`] hears it ([`subscribe`]) | +//! | a broker's `user_question/requested` on an app peer's session or context ([`crate::host_tools::ShellToolHost`]) | [`Questions::requested`]: one [`Request`] with its owning app, peer, account, context, turn, the questions and options, who started the turn ([`Origin`]) and so which [`Conversation`] it belongs to | every [`Consumer`] hears it ([`subscribe`]) | //! | the turn ended (the broker saw its terminal) | [`Questions::closed`] | the consumers hear it closed | //! | the person answered on a shell surface | [`answer`], with a [`PersonAnswer`] | `user_question/respond` on the broker's link, once | //! -//! **Which conversation.** A question from a turn the person or the app -//! started (a request context's turn, the peer's own turn) is the app's: -//! [`Conversation::App`]. A question from a turn the system agent started -//! on the peer (`peer/input`) is the system agent's to relay, so it goes to -//! the system chat: [`Conversation::SystemChat`]. There is no `host.ask`: -//! octos keeps `ask_user_question` on host-driven turns when the peer's -//! `generic_tools` lists it. +//! **Which conversation: the turn's origin, not the session.** An app +//! agent has one shared conversation on its peer session (and its request +//! contexts); the person (the app's UI, its cards), the app and the system +//! agent all drive turns there. A question from a turn the person or the +//! app started is the app's: [`Conversation::App`]. A question from a turn +//! the system agent started (`peer/input`) is the system agent's to relay, +//! so it goes to the system chat: [`Conversation::SystemChat`]. The +//! request keeps the turn's [`Origin`]: the one octos reports once it +//! carries `origin` on the event ([`Request::origin_reported`]), until then +//! the broker's derivation (the turns it started for `peer/input` are the +//! system agent's; a context's the person's; any other the app's). There +//! is no `host.ask`: octos keeps `ask_user_question` on host-driven turns +//! when the peer's `generic_tools` lists it. //! //! **Consumers.** The system chat subscribes and shows its questions in //! its conversation (`crate::system_chat`); the shell's app-conversation @@ -38,19 +44,29 @@ mod tests; use std::sync::Mutex; -use crate::ai_host::app_peers::host_tools::{AgentQuestion, CallOrigin, QuestionAnswer, QuestionItem, QuestionReply}; +use crate::ai_host::app_peers::host_tools::{AgentQuestion, QuestionAnswer, QuestionItem, QuestionReply, TurnOrigin}; /// Who started the turn that asked. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Asker { - /// A request context's turn: the person, in the app. +pub enum Origin { + /// The person, in the app (its UI, its cards). Person, - /// The peer's own turn: the app's agent at work. - AppAgent, - /// A `peer/input` turn: the system agent's request to the app's agent. + /// The app itself (a trigger, its own run). + App, + /// The system agent (`peer/input`): its request to the app's agent. SystemAgent, } +impl From for Origin { + fn from(o: TurnOrigin) -> Origin { + match o { + TurnOrigin::Person => Origin::Person, + TurnOrigin::App => Origin::App, + TurnOrigin::SystemAgent => Origin::SystemAgent, + } + } +} + /// Where the question is asked. #[derive(Clone, Debug, PartialEq, Eq)] pub enum Conversation { @@ -89,7 +105,10 @@ pub struct Request { pub title: String, pub body: String, pub items: Vec, - pub asker: Asker, + /// Who started the turn that asked; the conversation follows from it. + pub origin: Origin, + /// Whether octos reported the origin (else the broker derived it). + pub origin_reported: bool, pub conversation: Conversation, pub state: State, } @@ -102,9 +121,9 @@ impl Request { /// "Rinx's agent asks", for a surface's heading. pub fn asked_by(&self) -> String { let app = crate::approvals::sheet::app_label(&self.app); - match (&self.asker, &self.client) { - (Asker::SystemAgent, _) => format!("{app}'s agent asks (for the assistant)"), - (Asker::Person, Some(client)) => format!("{app}'s agent asks ({client})"), + match (&self.origin, &self.client) { + (Origin::SystemAgent, _) => format!("{app}'s agent asks (for the assistant)"), + (_, Some(client)) => format!("{app}'s agent asks ({client})"), _ => format!("{app}'s agent asks"), } } @@ -179,17 +198,10 @@ impl Questions { /// A broker handed the host an agent's question: route it. pub fn requested(&mut self, peer_app: &str, account: Option<&str>, question: AgentQuestion, answer: QuestionAnswer) -> u64 { let app = app_of_peer(peer_app).to_string(); - let asker = match question.origin { - CallOrigin::Context => Asker::Person, - CallOrigin::PeerInput => Asker::SystemAgent, - CallOrigin::PeerOwn => Asker::AppAgent, - // A host session's own question is its own chat's (the system - // chat answers those itself); never routed here in practice. - CallOrigin::System => Asker::SystemAgent, - }; - let conversation = match asker { - Asker::SystemAgent => Conversation::SystemChat, - Asker::Person | Asker::AppAgent => Conversation::App(app.clone()), + let origin = Origin::from(question.turn_origin); + let conversation = match origin { + Origin::SystemAgent => Conversation::SystemChat, + Origin::Person | Origin::App => Conversation::App(app.clone()), }; self.next += 1; let id = self.next; @@ -206,7 +218,8 @@ impl Questions { title: question.title, body: question.body, items: question.questions, - asker, + origin, + origin_reported: question.origin_reported, conversation, state: State::Open, }; diff --git a/crates/shell/src/questions/tests.rs b/crates/shell/src/questions/tests.rs index 8e269e33..e6d00c15 100644 --- a/crates/shell/src/questions/tests.rs +++ b/crates/shell/src/questions/tests.rs @@ -2,7 +2,7 @@ //! answer path, and that nothing an app reaches can answer. use super::*; -use crate::ai_host::app_peers::host_tools::{AgentQuestion, CallOrigin, QuestionAnswer, QuestionReply}; +use crate::ai_host::app_peers::host_tools::{AgentQuestion, CallOrigin, QuestionAnswer, QuestionReply, TurnOrigin}; use serde_json::{json, Value}; use std::sync::{Arc, Mutex}; @@ -16,6 +16,12 @@ fn question(id: &str, turn: &str, origin: CallOrigin, context: Option<&str>) -> ) .unwrap(); q.origin = origin; + // As the broker derives it until octos reports it. + q.turn_origin = match origin { + CallOrigin::PeerInput => TurnOrigin::SystemAgent, + CallOrigin::Context => TurnOrigin::Person, + _ => TurnOrigin::App, + }; q.context_id = context.map(str::to_string); q.client = context.map(|_| "mini.news#1".to_string()); q @@ -49,11 +55,12 @@ fn a_question_is_routed_by_who_started_the_turn_and_every_consumer_hears_it() { let by_app = model.requested("rinx", Some("@a:x"), question("q2", "t2", CallOrigin::PeerOwn, None), a2); let by_system = model.requested("card.os.news", None, question("q3", "t3", CallOrigin::PeerInput, None), a3); let r = model.get(by_person).unwrap(); - assert_eq!((r.asker, &r.conversation), (Asker::Person, &Conversation::App("rinx".into()))); + assert_eq!((r.origin, &r.conversation), (Origin::Person, &Conversation::App("rinx".into()))); assert_eq!(r.client.as_deref(), Some("mini.news#1")); assert_eq!(model.get(by_app).unwrap().conversation, Conversation::App("rinx".into())); let r = model.get(by_system).unwrap(); - assert_eq!((r.asker, &r.conversation), (Asker::SystemAgent, &Conversation::SystemChat)); + assert_eq!((r.origin, &r.conversation), (Origin::SystemAgent, &Conversation::SystemChat)); + assert!(!r.origin_reported); assert_eq!(r.app, "os.news", "a script app's peer names its app"); assert_eq!(model.open(&Conversation::SystemChat).len(), 1); assert_eq!(model.open_in_apps().len(), 2); @@ -61,6 +68,28 @@ fn a_question_is_routed_by_who_started_the_turn_and_every_consumer_hears_it() { assert_eq!(r.options(), vec!["#a".to_string(), "#b".to_string()]); } +/// The TURN's origin routes a question, not its session: on the peer's one +/// shared conversation a person's turn asks in the app, a system agent's +/// turn in the system chat; an origin octos reports wins over the +/// derivation. +#[test] +fn a_question_follows_its_turns_origin_not_its_session() { + let mut model = Questions::default(); + let mut person_on_peer = question("q1", "t1", CallOrigin::PeerOwn, None); + person_on_peer.turn_origin = TurnOrigin::Person; + person_on_peer.origin_reported = true; + let mut system_on_peer = question("q2", "t2", CallOrigin::PeerOwn, None); + system_on_peer.turn_origin = TurnOrigin::SystemAgent; + system_on_peer.origin_reported = true; + let (a1, _) = answer_handle(); + let (a2, _) = answer_handle(); + let person = model.requested("rinx", None, person_on_peer, a1); + let system = model.requested("rinx", None, system_on_peer, a2); + assert_eq!(model.get(person).unwrap().conversation, Conversation::App("rinx".into())); + assert_eq!(model.get(system).unwrap().conversation, Conversation::SystemChat); + assert!(model.get(system).unwrap().origin_reported); +} + #[test] fn the_persons_answer_is_sent_once_and_a_closed_question_takes_none() { let mut model = Questions::default(); From b5a649889a60955b1479a0068a49f34dc2a8b8dc Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:55:19 -0700 Subject: [PATCH 6/9] feat(host-tools): check every relayed call's arguments and result, and budget each agent [skip ci] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0004 §3 (G8): the relay authorized by (owning app, tool) and caller only. - Arguments: checked against the tool's declared `input_schema` (the `tools.json` subset: type, properties, required, additionalProperties, items, enum, const, bounds, lengths, item counts; host_tools/schema.rs) and capped at 64 KiB, before anything runs; refused as `invalid_args` with the path of the first violation. - Results: every answer goes back through a checked reply: over 256 KiB is `result_too_large`, outside the declared `output_schema` is `invalid_result`; errors pass through; a cancel closes both replies. - Budgets: each calling agent (and the system agent) has calls per turn and per day, from its manifest (`native-apps.json` `agent.budget`) or the defaults (32 per turn, 1000 per day); spent is `budget_exceeded`; a new turn and a new day start again. - Tests for each. Co-Authored-By: Claude Opus 5.5 --- crates/shell/src/host_tools/mod.rs | 1 + crates/shell/src/host_tools/relay.rs | 147 +++++++++++++++++++++++++- crates/shell/src/host_tools/schema.rs | 142 +++++++++++++++++++++++++ crates/shell/src/host_tools/tests.rs | 105 +++++++++++++++++- 4 files changed, 393 insertions(+), 2 deletions(-) create mode 100644 crates/shell/src/host_tools/schema.rs diff --git a/crates/shell/src/host_tools/mod.rs b/crates/shell/src/host_tools/mod.rs index 75ca8bda..30273138 100644 --- a/crates/shell/src/host_tools/mod.rs +++ b/crates/shell/src/host_tools/mod.rs @@ -32,6 +32,7 @@ //! router here ([`SheetBridge`]). pub mod relay; +pub mod schema; #[cfg(feature = "toolbox-peers")] pub mod toolbox; #[cfg(any(feature = "app-hub", native_mobile))] diff --git a/crates/shell/src/host_tools/relay.rs b/crates/shell/src/host_tools/relay.rs index 67c6adde..ee51ef6b 100644 --- a/crates/shell/src/host_tools/relay.rs +++ b/crates/shell/src/host_tools/relay.rs @@ -3,6 +3,11 @@ //! //! One [`Relay::handle`] per [`Event`], on the UI thread. For a call: //! +//! 0. **Check** (ADR 0004 §3, G8): the arguments against the tool's +//! declared `input_schema` (and a size cap), and the calling agent's +//! budget (calls per turn and per day, from its manifest or the +//! defaults); the result, on its way back, against its `output_schema` +//! and [`MAX_RESULT_BYTES`] ([`schema`](super::schema)). //! 1. **Authorize** by (owning app, tool) and caller: the owning app's own //! agent calls its own declared tools; another app's agent only the tools //! granted to it ([`Catalog::may_call`]); the system agent only its @@ -98,6 +103,41 @@ pub trait Env { fn bus_call(&mut self, call_id: &str, app: &str, tool: &str, args: String); fn bus_cancel(&mut self, call_id: &str); fn log(&mut self, line: String); + /// Unix seconds (the day a budget counts in). + fn now(&self) -> u64 { + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) + } +} + +/// The largest arguments a call may carry. +pub const MAX_ARGS_BYTES: usize = 64 * 1024; +/// The largest result the relay hands back (octos's own default cap). +pub const MAX_RESULT_BYTES: usize = 256 * 1024; +/// An agent's tool calls per turn and per day, unless its manifest says +/// otherwise (`native-apps.json` `agent.budget`). +pub const DEFAULT_CALLS_PER_TURN: u32 = 32; +pub const DEFAULT_CALLS_PER_DAY: u32 = 1000; + +/// One calling agent's budget. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Budget { + pub per_turn: u32, + pub per_day: u32, +} + +impl Default for Budget { + fn default() -> Self { + Budget { per_turn: DEFAULT_CALLS_PER_TURN, per_day: DEFAULT_CALLS_PER_DAY } + } +} + +/// What one calling agent spent. +#[derive(Default)] +struct Usage { + day: u64, + today: u32, + /// Calls per turn, the newest turns only. + turns: std::collections::VecDeque<(String, u32)>, } /// The octos kernel tools an app agent may be granted under developer @@ -150,6 +190,8 @@ pub struct Catalog { grants: BTreeMap>, /// Exactly the octos kernel tools each app's agent keeps. generic: BTreeMap>, + /// Each calling agent's budget (the defaults unless set). + budgets: BTreeMap, } impl Catalog { @@ -174,6 +216,17 @@ impl Catalog { self.grant(app.id, owner, tool); } self.set_generic(app.id, app.generic_tools.iter().map(|t| t.to_string()).collect()); + self.set_budget(app.id, app.calls_per_turn, app.calls_per_day); + } + + /// `app`'s agent's budget; `None` keeps the default. + pub fn set_budget(&mut self, app: &str, per_turn: Option, per_day: Option) { + let d = Budget::default(); + self.budgets.insert(app.to_string(), Budget { per_turn: per_turn.unwrap_or(d.per_turn), per_day: per_day.unwrap_or(d.per_day) }); + } + + pub fn budget(&self, app: &str) -> Budget { + self.budgets.get(app).copied().unwrap_or_default() } /// An app's `tools.json` (replaces what it declared before). @@ -302,14 +355,50 @@ pub struct Relay { executors: HashMap>, calls: HashMap, approvals: HashMap, + /// Each calling agent's spending against its budget. + usage: HashMap, + /// The caller's own reply for each checked one (a cancel closes both). + outers: HashMap, } impl Default for Relay { fn default() -> Self { - Relay { catalog: Catalog::shipped(), executors: HashMap::new(), calls: HashMap::new(), approvals: HashMap::new() } + Relay { catalog: Catalog::shipped(), executors: HashMap::new(), calls: HashMap::new(), approvals: HashMap::new(), usage: HashMap::new(), outers: HashMap::new() } } } +/// How many turns' counts are kept per agent. +const TURNS_KEPT: usize = 64; + +/// `reply`, with the result checked on its way back (G8): at most +/// `max_bytes`, and matching `schema` (the tool's `output_schema`) when it +/// declares one. Acknowledgements and errors pass through. +fn checked_reply(reply: ToolReply, tool: &str, schema: Option, max_bytes: usize) -> ToolReply { + let outer = reply.clone(); + let tool = tool.to_string(); + ToolReply::new(reply.call_id().to_string(), move |fields: Value| { + if fields.get("status").is_some() { + outer.acknowledge(); + return; + } + if fields["ok"] != true { + let kind = fields["error"]["kind"].as_str().unwrap_or("error"); + outer.finish(ToolOutcome::error(kind, fields["error"]["message"].as_str().unwrap_or("").to_string())); + return; + } + let data = fields.get("data").cloned().unwrap_or(Value::Null); + let size = data.to_string().len(); + let outcome = if size > max_bytes { + ToolOutcome::error("result_too_large", format!("{tool} answered {size} bytes, over the {max_bytes}-byte cap")) + } else if let Some(Err(why)) = schema.as_ref().map(|s| super::schema::check(s, &data)) { + ToolOutcome::error("invalid_result", format!("{tool} answered outside its declared result: {why}")) + } else { + ToolOutcome::Ok(data) + }; + outer.finish(outcome); + }) +} + /// What started a turn, as its host stamped it (G2): never "the person" /// unless the host that started the turn saw the person ask. A turn nobody /// vouched for is [`Trigger::Unknown`], which standing rules skip unless @@ -377,6 +466,37 @@ impl Relay { } // Executors answer on their own; forget what they finished. self.calls.retain(|_, p| p.reply.is_open()); + let calls = &self.calls; + self.outers.retain(|id, outer| outer.is_open() && calls.contains_key(id)); + } + + /// Spend one call of `agent`'s budget in `turn`; why not, when spent. + fn spend(&mut self, agent: &str, turn: &str, now: u64) -> Result<(), String> { + let budget = self.catalog.budget(agent); + let usage = self.usage.entry(agent.to_string()).or_default(); + let day = now / 86_400; + if usage.day != day { + usage.day = day; + usage.today = 0; + } + if usage.today >= budget.per_day { + return Err(format!("its agent used its {} tool calls for today", budget.per_day)); + } + let this_turn = usage.turns.iter().find(|(t, _)| t == turn).map_or(0, |(_, n)| *n); + if !turn.is_empty() && this_turn >= budget.per_turn { + return Err(format!("its agent used its {} tool calls for this turn", budget.per_turn)); + } + usage.today += 1; + match usage.turns.iter_mut().find(|(t, _)| t == turn) { + Some((_, n)) => *n += 1, + None => { + usage.turns.push_back((turn.to_string(), 1)); + while usage.turns.len() > TURNS_KEPT { + usage.turns.pop_front(); + } + } + } + Ok(()) } fn call(&mut self, call: HostToolCall, reply: ToolReply, env: &mut dyn Env) { @@ -410,6 +530,28 @@ impl Relay { return refuse(&reply, "signed_out", "the account is signed out".into()); } } + // 0. The arguments against the declared schema (G8). + let entry = self.catalog.entry(&owner, &tool).cloned(); + let size = call.args.to_string().len(); + if size > MAX_ARGS_BYTES { + return refuse(&reply, "invalid_args", format!("{tool}'s arguments are {size} bytes, over the {MAX_ARGS_BYTES}-byte cap")); + } + if let Some(schema) = entry.as_ref().and_then(|e| e.get("input_schema")) { + if let Err(why) = super::schema::check(schema, &call.args) { + env.log(format!("host tools: {tool} refused for {}: arguments {why}", caller.as_audit())); + return refuse(&reply, "invalid_args", format!("{tool}: {why}")); + } + } + // ... and the calling agent's budget. + let spender = if call.caller_kind == CallerKind::System { SYSTEM.to_string() } else { calling.clone() }; + if let Err(why) = self.spend(&spender, &call.turn_id, env.now()) { + env.log(format!("host tools: {tool} refused for {}: {why}", caller.as_audit())); + return refuse(&reply, "budget_exceeded", why); + } + // The result is checked on its way back. + let outer = reply.clone(); + let reply = checked_reply(reply, &tool, entry.as_ref().and_then(|e| e.get("output_schema")).filter(|s| !s.is_null()).cloned(), MAX_RESULT_BYTES); + self.outers.insert(call.call_id.clone(), outer); // 2. Route to the owning app's executor. if env.has_link(&owner) { let kernel_call = KernelToolCall { @@ -505,6 +647,9 @@ impl Relay { } fn cancel(&mut self, call_id: &str, reason: &str, env: &mut dyn Env) { + if let Some(outer) = self.outers.remove(call_id) { + outer.cancel(); + } let Some(p) = self.calls.remove(call_id) else { return }; p.reply.cancel(); match p.at { diff --git a/crates/shell/src/host_tools/schema.rs b/crates/shell/src/host_tools/schema.rs new file mode 100644 index 00000000..5400d2fe --- /dev/null +++ b/crates/shell/src/host_tools/schema.rs @@ -0,0 +1,142 @@ +//! A tool's declared JSON Schema, checked on what crosses the relay (ADR +//! 0004 §3, G8): every call's arguments against its `input_schema`, every +//! result against its `output_schema`. +//! +//! The subset `tools.json` may use (App Hub's gate refuses anything else): +//! `type` (one or a list), `properties`, `required`, `additionalProperties` +//! (a bool or a schema), `items`, `enum`, `const`, `minimum`, `maximum`, +//! `minLength`, `maxLength`, `minItems`, `maxItems`. `pattern` and +//! `format` are descriptive here (not enforced); `title`, `description` +//! and `default` never constrain. An absent or empty schema accepts +//! anything. + +use serde_json::Value; + +/// How deep a value may nest before it is refused outright. +const MAX_DEPTH: usize = 32; + +/// Check `value` against `schema`; the first violation, with its path. +pub fn check(schema: &Value, value: &Value) -> Result<(), String> { + node(schema, value, "$", 0) +} + +fn type_matches(t: &str, value: &Value) -> bool { + match t { + "object" => value.is_object(), + "array" => value.is_array(), + "string" => value.is_string(), + "boolean" => value.is_boolean(), + "null" => value.is_null(), + "number" => value.is_number(), + "integer" => value.as_i64().is_some() || value.as_u64().is_some() || value.as_f64().is_some_and(|f| f.fract() == 0.0), + _ => true, + } +} + +fn node(schema: &Value, value: &Value, at: &str, depth: usize) -> Result<(), String> { + if depth > MAX_DEPTH { + return Err(format!("{at}: nested deeper than {MAX_DEPTH}")); + } + let Some(schema) = schema.as_object() else { return Ok(()) }; + match schema.get("type") { + Some(Value::String(t)) if !type_matches(t, value) => return Err(format!("{at}: expected {t}")), + Some(Value::Array(types)) if !types.iter().filter_map(Value::as_str).any(|t| type_matches(t, value)) => { + let names: Vec<&str> = types.iter().filter_map(Value::as_str).collect(); + return Err(format!("{at}: expected one of {}", names.join(", "))); + } + _ => {} + } + if let Some(Value::Array(allowed)) = schema.get("enum") { + if !allowed.contains(value) { + return Err(format!("{at}: not one of the allowed values")); + } + } + if let Some(expected) = schema.get("const") { + if expected != value { + return Err(format!("{at}: must be {expected}")); + } + } + if let Some(n) = value.as_f64() { + if schema.get("minimum").and_then(Value::as_f64).is_some_and(|min| n < min) { + return Err(format!("{at}: below the minimum {}", schema["minimum"])); + } + if schema.get("maximum").and_then(Value::as_f64).is_some_and(|max| n > max) { + return Err(format!("{at}: above the maximum {}", schema["maximum"])); + } + } + if let Some(s) = value.as_str() { + let len = s.chars().count() as u64; + if schema.get("minLength").and_then(Value::as_u64).is_some_and(|min| len < min) { + return Err(format!("{at}: shorter than {}", schema["minLength"])); + } + if schema.get("maxLength").and_then(Value::as_u64).is_some_and(|max| len > max) { + return Err(format!("{at}: longer than {}", schema["maxLength"])); + } + } + if let Some(items) = value.as_array() { + let len = items.len() as u64; + if schema.get("minItems").and_then(Value::as_u64).is_some_and(|min| len < min) { + return Err(format!("{at}: fewer than {} items", schema["minItems"])); + } + if schema.get("maxItems").and_then(Value::as_u64).is_some_and(|max| len > max) { + return Err(format!("{at}: more than {} items", schema["maxItems"])); + } + if let Some(item) = schema.get("items") { + for (i, v) in items.iter().enumerate() { + node(item, v, &format!("{at}[{i}]"), depth + 1)?; + } + } + } + if let Some(object) = value.as_object() { + let properties = schema.get("properties").and_then(Value::as_object); + if let Some(Value::Array(required)) = schema.get("required") { + for key in required.iter().filter_map(Value::as_str) { + if !object.contains_key(key) { + return Err(format!("{at}: {key} is required")); + } + } + } + for (key, v) in object { + let path = format!("{at}.{key}"); + match properties.and_then(|p| p.get(key)) { + Some(sub) => node(sub, v, &path, depth + 1)?, + None => match schema.get("additionalProperties") { + Some(Value::Bool(false)) => return Err(format!("{at}: {key} is not a declared field")), + Some(extra @ Value::Object(_)) => node(extra, v, &path, depth + 1)?, + _ => {} + }, + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::check; + use serde_json::json; + + #[test] + fn the_subset_is_checked_with_a_path() { + let schema = json!({"type": "object", "properties": { + "command": {"type": "string", "minLength": 1, "maxLength": 5}, + "lines": {"type": "integer", "minimum": 1, "maximum": 2000}, + "to": {"type": "array", "items": {"type": "string"}, "maxItems": 2}, + "mode": {"enum": ["a", "b"]}}, + "required": ["command"], "additionalProperties": false}); + assert!(check(&schema, &json!({"command": "ls"})).is_ok()); + assert!(check(&schema, &json!({"command": "ls", "lines": 5, "to": ["x"], "mode": "a"})).is_ok()); + assert!(check(&schema, &json!({})).unwrap_err().contains("command is required")); + assert!(check(&schema, &json!({"command": 3})).unwrap_err().contains("$.command: expected string")); + assert!(check(&schema, &json!({"command": "toolong"})).unwrap_err().contains("longer than 5")); + assert!(check(&schema, &json!({"command": "ls", "lines": 0})).unwrap_err().contains("below the minimum")); + assert!(check(&schema, &json!({"command": "ls", "lines": 1.5})).unwrap_err().contains("expected integer")); + assert!(check(&schema, &json!({"command": "ls", "to": ["a", 2]})).unwrap_err().contains("$.to[1]")); + assert!(check(&schema, &json!({"command": "ls", "to": ["a", "b", "c"]})).unwrap_err().contains("more than 2 items")); + assert!(check(&schema, &json!({"command": "ls", "mode": "c"})).is_err()); + assert!(check(&schema, &json!({"command": "ls", "sudo": true})).unwrap_err().contains("sudo is not a declared field")); + assert!(check(&json!({"type": ["string", "null"]}), &json!(null)).is_ok()); + assert!(check(&json!({}), &json!({"anything": [1, 2]})).is_ok(), "no schema, no constraint"); + assert!(check(&json!({"type": "object"}), &json!("x")).is_err()); + } +} diff --git a/crates/shell/src/host_tools/tests.rs b/crates/shell/src/host_tools/tests.rs index 6a9917e8..ad0fddeb 100644 --- a/crates/shell/src/host_tools/tests.rs +++ b/crates/shell/src/host_tools/tests.rs @@ -29,6 +29,8 @@ struct World { bus: Vec<(String, String, String, String)>, bus_cancels: Vec, asked: Vec<(String, ToolSpec, Caller, RequestContext)>, + /// The clock budgets count days by. + now: u64, } impl World { @@ -48,6 +50,7 @@ impl World { bus: Vec::new(), bus_cancels: Vec::new(), asked: Vec::new(), + now: 1_000_000, } } /// The router's decisions, as the shell hands them back to the relay. @@ -95,6 +98,9 @@ impl Env for World { self.bus_cancels.push(call_id.into()); } fn log(&mut self, _line: String) {} + fn now(&self) -> u64 { + self.now + } } type Sent = Arc>>; @@ -191,6 +197,7 @@ fn another_apps_agent_needs_a_grant_and_the_system_agent_its_own() { // The system agent calls only what Setup granted it. let mut system = call("c4", TERMINAL_RUN, "system"); + system.args = json!({"command": "ls -la"}); system.caller_kind = CallerKind::System; system.origin = CallOrigin::System; let (r, sent) = reply("c4"); @@ -201,7 +208,7 @@ fn another_apps_agent_needs_a_grant_and_the_system_agent_its_own() { system.call_id = "c5".into(); relay.handle(Event::Call { call: system, reply: r }, &mut w); // Typed into the Terminal the person sees, on the AI bus, by its short name. - assert_eq!(w.bus, vec![(format!("{BUS_PREFIX}c5"), "terminal".into(), "run".into(), json!({"to": ["ana@example.org"], "text": "hi"}).to_string())]); + assert_eq!(w.bus, vec![(format!("{BUS_PREFIX}c5"), "terminal".into(), "run".into(), json!({"command": "ls -la"}).to_string())]); relay.handle(Event::BusResult { call_id: "c5".into(), outcome: ToolOutcome::Ok(json!({"text": "typed"})) }, &mut w); assert_eq!(sent.lock().unwrap()[0]["ok"], true); } @@ -486,6 +493,8 @@ fn no_toolbox_call_runs_before_consent_or_without_a_grant() { let mut c = call(id, name, calling); c.app = super::TOOLBOX.into(); c.risk = "read".into(); + // Arguments the tool declares (the relay checks them, G8). + c.args = if name == "toolbox.deep_crawl" { json!({"url": "https://example.org"}) } else { json!({"query": "news"}) }; c }; w.consent = false; @@ -638,3 +647,97 @@ fn developer_mode_answers_octos_own_approvals_through_the_router() { assert_eq!(answers.lock().unwrap().as_slice(), &[true]); assert_eq!(w.router.audit.all()[0].by, "developer_mode"); } + + +// ---------------------------------------------------------------- G8 + +fn schema_decl() -> Value { + json!({"name": "notes.find", "description": "d", "risk": "read", "shareable": false, + "input_schema": {"type": "object", "properties": {"q": {"type": "string", "maxLength": 8}, "limit": {"type": "integer", "minimum": 1, "maximum": 10}}, "required": ["q"], "additionalProperties": false}, + "output_schema": {"type": "object", "properties": {"hits": {"type": "array", "items": {"type": "string"}}}, "required": ["hits"]}}) +} + +fn find(id: &str, turn: &str, args: Value) -> HostToolCall { + let mut c = call(id, "notes.find", "notes"); + c.args = args; + c.turn_id = turn.to_string(); + c +} + +/// ADR 0004 §3 (G8): each call's arguments are checked against the tool's +/// declared schema before anything runs. +#[test] +fn arguments_outside_the_declared_schema_are_refused_before_anything_runs() { + let (mut relay, exec) = relay_with("notes", vec![schema_decl()]); + let mut w = World::new(FixedDevMode::off()); + for (id, args, why) in [ + ("c1", json!({}), "q is required"), + ("c2", json!({"q": 7}), "expected string"), + ("c3", json!({"q": "far too long"}), "longer than 8"), + ("c4", json!({"q": "x", "limit": 99}), "above the maximum"), + ("c5", json!({"q": "x", "sudo": true}), "sudo is not a declared field"), + ] { + let (r, sent) = reply(id); + relay.handle(Event::Call { call: find(id, "t1", args), reply: r }, &mut w); + let sent = sent.lock().unwrap().clone(); + assert_eq!(sent[0]["error"]["kind"], "invalid_args", "{id}"); + assert!(sent[0]["error"]["message"].as_str().unwrap().contains(why), "{id}: {sent:?}"); + } + let (r, sent) = reply("c6"); + relay.handle(Event::Call { call: find("c6", "t1", json!({"q": "x" .repeat(70_000)})), reply: r }, &mut w); + assert_eq!(sent.lock().unwrap()[0]["error"]["kind"], "invalid_args", "over the size cap"); + assert!(exec.0.lock().unwrap().is_empty(), "nothing reached the app"); + let (r, _) = reply("c7"); + relay.handle(Event::Call { call: find("c7", "t1", json!({"q": "ok", "limit": 3})), reply: r }, &mut w); + assert_eq!(exec.0.lock().unwrap().len(), 1); +} + +/// Results are capped and checked against the declared result schema. +#[test] +fn results_are_capped_and_checked_against_the_declared_result() { + let (mut relay, exec) = relay_with("notes", vec![schema_decl()]); + let mut w = World::new(FixedDevMode::off()); + let mut sents = Vec::new(); + for id in ["r1", "r2", "r3"] { + let (r, sent) = reply(id); + relay.handle(Event::Call { call: find(id, "t1", json!({"q": "x"})), reply: r }, &mut w); + sents.push(sent); + } + let replies: Vec = exec.0.lock().unwrap().iter().map(|(_, r)| r.clone()).collect(); + replies[0].finish(ToolOutcome::Ok(json!({"hits": ["a", "b"]}))); + replies[1].finish(ToolOutcome::Ok(json!({"hits": [1]}))); + replies[2].finish(ToolOutcome::Ok(json!({"hits": ["x".repeat(super::relay::MAX_RESULT_BYTES)]}))); + assert_eq!(sents[0].lock().unwrap()[0]["data"], json!({"hits": ["a", "b"]})); + assert_eq!(sents[1].lock().unwrap()[0]["error"]["kind"], "invalid_result"); + assert_eq!(sents[2].lock().unwrap()[0]["error"]["kind"], "result_too_large"); + // An error passes through as the app said it. + let (r, sent) = reply("r4"); + relay.handle(Event::Call { call: find("r4", "t1", json!({"q": "x"})), reply: r }, &mut w); + exec.0.lock().unwrap()[3].1.finish(ToolOutcome::error("not_found", "none")); + assert_eq!(sent.lock().unwrap()[0]["error"], json!({"kind": "not_found", "message": "none"})); +} + +/// Per-app budgets: calls per turn and per day, from the manifest (or the +/// defaults); a new turn, and a new day, start again. +#[test] +fn an_agents_calls_are_budgeted_per_turn_and_per_day() { + let (mut relay, exec) = relay_with("notes", vec![schema_decl()]); + relay.catalog.set_budget("notes", Some(2), Some(3)); + let mut w = World::new(FixedDevMode::off()); + let mut kinds = Vec::new(); + for (id, turn) in [("b1", "t1"), ("b2", "t1"), ("b3", "t1"), ("b4", "t2"), ("b5", "t2")] { + let (r, sent) = reply(id); + relay.handle(Event::Call { call: find(id, turn, json!({"q": "x"})), reply: r }, &mut w); + kinds.push(sent.lock().unwrap().first().map(|s| s["error"]["kind"].as_str().unwrap_or("").to_string()).unwrap_or_default()); + } + assert_eq!(kinds, ["", "", "budget_exceeded", "", "budget_exceeded"], "2 per turn, 3 per day"); + assert_eq!(exec.0.lock().unwrap().len(), 3); + w.now += 86_400; + let (r, sent) = reply("b6"); + relay.handle(Event::Call { call: find("b6", "t3", json!({"q": "x"})), reply: r }, &mut w); + assert!(sent.lock().unwrap().is_empty(), "a new day"); + assert_eq!(exec.0.lock().unwrap().len(), 4); + // The defaults, and a native app's own budget from its manifest. + assert_eq!(Catalog::default().budget("anyone"), super::relay::Budget::default()); + assert_eq!(super::relay::Budget::default().per_turn, super::relay::DEFAULT_CALLS_PER_TURN); +} From 00a8dbce3953b700823ef4d930e0cfc6777ed5e3 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:57:10 -0700 Subject: [PATCH 7/9] feat(system-chat): terminal.run only where the Terminal runs as a process on this device [skip ci] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0004 §10, §12 (G12, decided 2026-09-29): the in-process Terminal offers its read tools only, so `terminal.run` has a target only where the Terminal runs as its own process. - `apps::terminal_runs_as_process`: processes on this device, the Terminal's hosting resolved to a process (`process`, or `process-if-vulkan` on a Vulkan build in a Wayland session, unless the person switched it to a module) and a process form to start. - The system chat registers `terminal.run` on the system session (and the relay lets the system agent call it) only when command execution is granted AND that holds; granted without it, nothing is registered. - Setup › Assistant › Command execution says "needs Terminal as a process on this device" there, on or off. - Tests: the hosting resolution, the registration gate, the Setup rows. Co-Authored-By: Claude Opus 5.5 --- crates/shell/src/apps.rs | 34 ++++++++++++++++++++++++++ crates/shell/src/shell/menu.rs | 34 ++++++++++++++++++++++++-- crates/shell/src/system_chat/grants.rs | 19 +++++++++++++- crates/shell/src/system_chat/tests.rs | 13 ++++++++++ 4 files changed, 97 insertions(+), 3 deletions(-) diff --git a/crates/shell/src/apps.rs b/crates/shell/src/apps.rs index fd31e394..91d89984 100644 --- a/crates/shell/src/apps.rs +++ b/crates/shell/src/apps.rs @@ -428,6 +428,26 @@ pub fn process_form(id: &str) -> bool { crate::clients::find_app(id).is_some_and(|app| app.is_available()) } +/// Whether the Terminal runs as its own process on this device (ADR 0004 +/// §10, §12, G12): a device with processes, the Terminal's hosting resolved +/// to a process (its manifest's `process`, or `process-if-vulkan` on a +/// Vulkan build in a Wayland session, unless the person switched it to a +/// module) and a process form to start. Only then does `terminal.run` +/// exist: the in-process Terminal offers its read tools only. +pub fn terminal_runs_as_process() -> bool { + let args: Vec = std::env::args().collect(); + let registry = AppRegistry::load(&crate::theme::makepad_home().join("wm/apps.splash"), &args); + runs_as_process(registry.hosting(TERMINAL), crate::host::processes_available(), process_form(TERMINAL)) +} + +/// The Terminal's app id. +pub const TERMINAL: &str = "terminal"; + +/// [`terminal_runs_as_process`] for a given hosting, device and process form. +pub fn runs_as_process(hosting: Hosting, processes: bool, process_form: bool) -> bool { + hosting == Hosting::Process && processes && process_form +} + /// A Makepad Vulkan build (`MAKEPAD=vulkan`, crates/shell/build.rs) running /// in a Wayland session: where Linux shares a child's frames zero-copy /// (DMA-BUF). Vulkan windowing panics on X11. @@ -702,6 +722,20 @@ mod tests { } } + /// G12: `terminal.run` has a target only where the Terminal runs as a + /// process on this device. + #[test] + fn the_terminal_runs_as_a_process_only_with_processes_a_process_hosting_and_a_process_form() { + assert!(runs_as_process(Hosting::Process, true, true)); + assert!(!runs_as_process(Hosting::Module, true, true), "in-process (Linux without Vulkan, switched to a module)"); + assert!(!runs_as_process(Hosting::Process, false, true), "no processes (phones, wasm)"); + assert!(!runs_as_process(Hosting::Process, true, false), "no binary to start"); + // process-if-vulkan resolves to a module without Vulkan and Wayland. + use crate::native_apps::Hosting as Declared; + assert!(!runs_as_process(manifest_default(Declared::ProcessIfVulkan, || true, false), true, true)); + assert!(runs_as_process(manifest_default(Declared::ProcessIfVulkan, || true, true), true, true)); + } + /// A declared process runs in-process where it cannot start one; /// `process-if-vulkan` is a process only on Vulkan with Wayland. #[test] diff --git a/crates/shell/src/shell/menu.rs b/crates/shell/src/shell/menu.rs index 9e227411..288ea4a0 100644 --- a/crates/shell/src/shell/menu.rs +++ b/crates/shell/src/shell/menu.rs @@ -826,12 +826,28 @@ fn assistant_items(existing: &[MenuItem]) -> Vec { /// needs the confirmation typed into the menu's filter (the row carries it /// as an alias, so typing keeps it listed) and says what it risks. fn command_items() -> Vec { + command_items_given(crate::system_chat::grants::command_execution(), crate::apps::terminal_runs_as_process()) +} + +/// [`command_items`] for a switch and a Terminal: without a process +/// Terminal on this device the switch cannot take effect, and says so (G12). +fn command_items_given(on: bool, terminal_process: bool) -> Vec { use crate::system_chat::grants; - let on = grants::command_execution(); + let describe = match (on, terminal_process) { + (_, false) => format!("{}: {}", if on { "On, but it" } else { "It" }, grants::NEEDS_PROCESS_TERMINAL), + (true, true) => "On: each command asks you first".to_string(), + (false, true) => "Off: the assistant runs no commands".to_string(), + }; let mut items = vec![MenuItem::new(COMMANDS_ROW, "Command execution", MenuKind::Menu) .icon(Ico::Keyboard) .aliases(&["commands", "terminal", "run commands"]) - .describe(if on { "On: each command asks you first" } else { "Off: the assistant runs no commands" })]; + .describe(&describe)]; + if !terminal_process { + items.push( + MenuItem::new(&format!("{COMMANDS_ROW}.needs"), "Command execution needs Terminal as a process on this device", MenuKind::Inert) + .describe("Commands are typed into a Terminal that runs as its own sandboxed process; here the Terminal runs inside OctoSense (or not at all), so the assistant is not offered commands even if you allow them."), + ); + } items.push(MenuItem::new(&format!("{COMMANDS_ROW}.risk"), "What this allows", MenuKind::Inert).describe(grants::RISK)); if on { items.push(MenuItem::new(COMMANDS_OFF_ROW, "Turn off command execution", MenuKind::Action).icon(Ico::Close).describe("The assistant runs no commands")); @@ -1796,6 +1812,20 @@ mod tests { assert_eq!(model.activate().as_deref(), Some("setup.assistant.commands.allow:let the assistant run commands")); } + /// G12: without the Terminal as a process on this device, Setup › + /// Assistant › Command execution says so, on or off. + #[test] + fn command_execution_says_it_needs_a_process_terminal_where_there_is_none() { + let text = |items: &[MenuItem]| items.iter().map(|i| format!("{} {}", i.label, i.description)).collect::>().join("\n"); + for on in [false, true] { + let items = command_items_given(on, false); + assert!(text(&items).contains("needs Terminal as a process on this device"), "{on}: {}", text(&items)); + } + let items = command_items_given(true, true); + assert!(!text(&items).contains("needs Terminal as a process"), "{}", text(&items)); + assert!(text(&items).contains("On: each command asks you first")); + } + /// Setup → Assistant → Approvals opens the Approvals page. #[test] fn setup_assistant_offers_the_approvals_page() { diff --git a/crates/shell/src/system_chat/grants.rs b/crates/shell/src/system_chat/grants.rs index 8f4ac978..4b69f82d 100644 --- a/crates/shell/src/system_chat/grants.rs +++ b/crates/shell/src/system_chat/grants.rs @@ -19,6 +19,12 @@ //! offers the restart. //! - **Persisted** per OctoSense home in [`GRANTS_FILE`], owner-only. //! +//! **Only with a process Terminal** (G12): `terminal.run` exists only where +//! the Terminal runs as its own process on this device +//! (`crate::apps::terminal_runs_as_process`); elsewhere it is not +//! registered even when granted, and Setup says it +//! [`NEEDS_PROCESS_TERMINAL`]. +//! //! **Registered on the system session** (octos#2567's host session target): //! while the switch is on, the system chat registers `terminal.run` on its //! own connection (`peer/tools/register` without `peer`, `generic_tools` @@ -164,9 +170,20 @@ pub fn command_execution() -> bool { /// registers on its session (`peer/tools/register` without `peer`, /// UPCR-2026-035) and the relay checks every call against. pub fn host_tools() -> std::collections::BTreeSet { - command_execution().then(|| COMMAND_TOOL.to_string()).into_iter().collect() + host_tools_given(command_execution(), crate::apps::terminal_runs_as_process()) +} + +/// [`host_tools`] for a switch and a Terminal: `terminal.run` only when the +/// person turned command execution on AND the Terminal runs as its own +/// process on this device (ADR 0004 §10, §12, G12). Granted but without a +/// process Terminal, it is not registered at all. +pub fn host_tools_given(command_execution: bool, terminal_process: bool) -> std::collections::BTreeSet { + (command_execution && terminal_process).then(|| COMMAND_TOOL.to_string()).into_iter().collect() } +/// What Setup says when command execution cannot take effect here. +pub const NEEDS_PROCESS_TERMINAL: &str = "needs Terminal as a process on this device"; + /// Settings' switch. On needs the person's gesture; off never does. pub fn set_command_execution(on: bool, gesture: Option) -> Result<(), String> { with(|s| -> Result<(), String> { diff --git a/crates/shell/src/system_chat/tests.rs b/crates/shell/src/system_chat/tests.rs index 0bd91ca4..00b22802 100644 --- a/crates/shell/src/system_chat/tests.rs +++ b/crates/shell/src/system_chat/tests.rs @@ -769,3 +769,16 @@ fn only_the_chats_own_turns_calls_are_triggered_by_the_person() { let triggers: Vec<(String, TurnTrigger)> = d.effects.iter().filter_map(|e| match e { Effect::ToolCall { call, .. } => Some((call.call_id.clone(), call.trigger.clone())), _ => None }).collect(); assert_eq!(triggers, vec![("c1".to_string(), TurnTrigger::Person), ("c2".to_string(), TurnTrigger::Unknown)]); } + + +/// G12: `terminal.run` is registered on the system session only when the +/// person granted command execution AND the Terminal runs as a process on +/// this device; granted without one, nothing is registered. +#[test] +fn terminal_run_needs_a_process_terminal_even_when_granted() { + use super::grants::{host_tools_given, COMMAND_TOOL}; + assert!(host_tools_given(true, true).contains(COMMAND_TOOL)); + assert!(host_tools_given(true, false).is_empty(), "granted, but the Terminal runs in-process here"); + assert!(host_tools_given(false, true).is_empty()); + assert!(host_tools_given(false, false).is_empty()); +} From 8a8c18260fa1fd6660d9c10c9cbe1d5ddf3407e5 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:01:44 -0700 Subject: [PATCH 8/9] feat(app-peers): pin octos c608384d and refuse a peer/input with its reason [skip ci] octos#2621 (merged as octos main c608384d) adds `peer/input/reject`: the host can refuse the system agent's `peer/input`, and the system agent learns why instead of the refusal being only logged (octos#2618, the ADR 0004 follow-up "a refused peer/input is only logged"). - Pin octos c608384d: octos-core, octos-cli and octos-research move together; tools/test_kernel_artifact.py and the docs that name the current pin follow. - `ToolHost::admit_input` refuses with an `InputRefusal` (`signed_out`, `no_consent`, `busy`, `other` + a one-line message); the shell's host says `signed_out` for a suspended account and `no_consent` without the person's consent. - The broker sends `peer/input/reject {session_id, peer, host_token, profile_id, input_id, reason, message?}` on the connection the input came on, before any `turn/start` with its turn id: a signed-out or suspended account, the host's refusal, or a busy peer past `MAX_QUEUED_INPUTS` (8). Queued inputs still start later with their own turn ids. - Tests: each reason, the message only with `other`, the connection, no turn for a refused input, the queue limit. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 42 +++++----- Cargo.toml | 8 +- apps/README.md | 4 +- apps/README.zh-CN.md | 4 +- apps/appcard/README.md | 2 +- apps/appcard/README.zh-CN.md | 2 +- crates/app-peers/src/broker.rs | 32 +++++++- crates/app-peers/src/host_tools.rs | 76 ++++++++++++++++++- crates/app-peers/tests/broker.rs | 73 +++++++++++++++++- crates/shell/src/host_tools/mod.rs | 8 +- crates/toolbox/README.md | 2 +- .../adr/0004-native-apps-hosting-and-peers.md | 4 +- docs/ai-services.md | 4 +- docs/ai-services.zh-CN.md | 4 +- tools/test_kernel_artifact.py | 2 +- 15 files changed, 214 insertions(+), 53 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 33de708b..137ccb6b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2870,8 +2870,8 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.1.3", - "windows-result 0.3.4", + "windows-link 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", + "windows-result 0.4.1 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -4180,7 +4180,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -6700,7 +6700,7 @@ dependencies = [ [[package]] name = "octos-agent" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=acffad3b493b70d6620b57394265ec84d260ce13#acffad3b493b70d6620b57394265ec84d260ce13" +source = "git+https://github.com/octos-org/octos.git?rev=c608384ddd217c0d857656488b3d93ba3df0dc5a#c608384ddd217c0d857656488b3d93ba3df0dc5a" dependencies = [ "async-trait", "base64 0.22.1", @@ -6829,7 +6829,7 @@ dependencies = [ [[package]] name = "octos-bus" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=acffad3b493b70d6620b57394265ec84d260ce13#acffad3b493b70d6620b57394265ec84d260ce13" +source = "git+https://github.com/octos-org/octos.git?rev=c608384ddd217c0d857656488b3d93ba3df0dc5a#c608384ddd217c0d857656488b3d93ba3df0dc5a" dependencies = [ "aes 0.8.4", "async-trait", @@ -6858,7 +6858,7 @@ dependencies = [ [[package]] name = "octos-cli" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=acffad3b493b70d6620b57394265ec84d260ce13#acffad3b493b70d6620b57394265ec84d260ce13" +source = "git+https://github.com/octos-org/octos.git?rev=c608384ddd217c0d857656488b3d93ba3df0dc5a#c608384ddd217c0d857656488b3d93ba3df0dc5a" dependencies = [ "agent-client-protocol", "argon2", @@ -6939,7 +6939,7 @@ dependencies = [ [[package]] name = "octos-core" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=acffad3b493b70d6620b57394265ec84d260ce13#acffad3b493b70d6620b57394265ec84d260ce13" +source = "git+https://github.com/octos-org/octos.git?rev=c608384ddd217c0d857656488b3d93ba3df0dc5a#c608384ddd217c0d857656488b3d93ba3df0dc5a" dependencies = [ "chrono", "eyre", @@ -6953,7 +6953,7 @@ dependencies = [ [[package]] name = "octos-diagnostics" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=acffad3b493b70d6620b57394265ec84d260ce13#acffad3b493b70d6620b57394265ec84d260ce13" +source = "git+https://github.com/octos-org/octos.git?rev=c608384ddd217c0d857656488b3d93ba3df0dc5a#c608384ddd217c0d857656488b3d93ba3df0dc5a" dependencies = [ "eyre", "octos-core", @@ -6965,7 +6965,7 @@ dependencies = [ [[package]] name = "octos-fleet" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=acffad3b493b70d6620b57394265ec84d260ce13#acffad3b493b70d6620b57394265ec84d260ce13" +source = "git+https://github.com/octos-org/octos.git?rev=c608384ddd217c0d857656488b3d93ba3df0dc5a#c608384ddd217c0d857656488b3d93ba3df0dc5a" dependencies = [ "eyre", "octos-core", @@ -6981,7 +6981,7 @@ dependencies = [ [[package]] name = "octos-fleet-worker" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=acffad3b493b70d6620b57394265ec84d260ce13#acffad3b493b70d6620b57394265ec84d260ce13" +source = "git+https://github.com/octos-org/octos.git?rev=c608384ddd217c0d857656488b3d93ba3df0dc5a#c608384ddd217c0d857656488b3d93ba3df0dc5a" dependencies = [ "async-trait", "eyre", @@ -6998,7 +6998,7 @@ dependencies = [ [[package]] name = "octos-llm" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=acffad3b493b70d6620b57394265ec84d260ce13#acffad3b493b70d6620b57394265ec84d260ce13" +source = "git+https://github.com/octos-org/octos.git?rev=c608384ddd217c0d857656488b3d93ba3df0dc5a#c608384ddd217c0d857656488b3d93ba3df0dc5a" dependencies = [ "async-trait", "base64 0.22.1", @@ -7022,7 +7022,7 @@ dependencies = [ [[package]] name = "octos-memory" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=acffad3b493b70d6620b57394265ec84d260ce13#acffad3b493b70d6620b57394265ec84d260ce13" +source = "git+https://github.com/octos-org/octos.git?rev=c608384ddd217c0d857656488b3d93ba3df0dc5a#c608384ddd217c0d857656488b3d93ba3df0dc5a" dependencies = [ "bincode", "chrono", @@ -7041,7 +7041,7 @@ dependencies = [ [[package]] name = "octos-pipeline" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=acffad3b493b70d6620b57394265ec84d260ce13#acffad3b493b70d6620b57394265ec84d260ce13" +source = "git+https://github.com/octos-org/octos.git?rev=c608384ddd217c0d857656488b3d93ba3df0dc5a#c608384ddd217c0d857656488b3d93ba3df0dc5a" dependencies = [ "async-trait", "chrono", @@ -7063,7 +7063,7 @@ dependencies = [ [[package]] name = "octos-plugin" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=acffad3b493b70d6620b57394265ec84d260ce13#acffad3b493b70d6620b57394265ec84d260ce13" +source = "git+https://github.com/octos-org/octos.git?rev=c608384ddd217c0d857656488b3d93ba3df0dc5a#c608384ddd217c0d857656488b3d93ba3df0dc5a" dependencies = [ "eyre", "metrics", @@ -7077,7 +7077,7 @@ dependencies = [ [[package]] name = "octos-research" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=acffad3b493b70d6620b57394265ec84d260ce13#acffad3b493b70d6620b57394265ec84d260ce13" +source = "git+https://github.com/octos-org/octos.git?rev=c608384ddd217c0d857656488b3d93ba3df0dc5a#c608384ddd217c0d857656488b3d93ba3df0dc5a" dependencies = [ "chrono", "dom_smoothie", @@ -7098,7 +7098,7 @@ dependencies = [ [[package]] name = "octos-sandbox" version = "0.1.0" -source = "git+https://github.com/octos-org/octos.git?rev=acffad3b493b70d6620b57394265ec84d260ce13#acffad3b493b70d6620b57394265ec84d260ce13" +source = "git+https://github.com/octos-org/octos.git?rev=c608384ddd217c0d857656488b3d93ba3df0dc5a#c608384ddd217c0d857656488b3d93ba3df0dc5a" dependencies = [ "clap", "eyre", @@ -7113,7 +7113,7 @@ dependencies = [ [[package]] name = "octos-services" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=acffad3b493b70d6620b57394265ec84d260ce13#acffad3b493b70d6620b57394265ec84d260ce13" +source = "git+https://github.com/octos-org/octos.git?rev=c608384ddd217c0d857656488b3d93ba3df0dc5a#c608384ddd217c0d857656488b3d93ba3df0dc5a" dependencies = [ "chrono", "dirs", @@ -7135,7 +7135,7 @@ dependencies = [ [[package]] name = "octos-store" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=acffad3b493b70d6620b57394265ec84d260ce13#acffad3b493b70d6620b57394265ec84d260ce13" +source = "git+https://github.com/octos-org/octos.git?rev=c608384ddd217c0d857656488b3d93ba3df0dc5a#c608384ddd217c0d857656488b3d93ba3df0dc5a" dependencies = [ "base64 0.22.1", "chrono", @@ -7155,7 +7155,7 @@ dependencies = [ [[package]] name = "octos-swarm" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=acffad3b493b70d6620b57394265ec84d260ce13#acffad3b493b70d6620b57394265ec84d260ce13" +source = "git+https://github.com/octos-org/octos.git?rev=c608384ddd217c0d857656488b3d93ba3df0dc5a#c608384ddd217c0d857656488b3d93ba3df0dc5a" dependencies = [ "async-trait", "chrono", @@ -7174,7 +7174,7 @@ dependencies = [ [[package]] name = "octos-workflows" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=acffad3b493b70d6620b57394265ec84d260ce13#acffad3b493b70d6620b57394265ec84d260ce13" +source = "git+https://github.com/octos-org/octos.git?rev=c608384ddd217c0d857656488b3d93ba3df0dc5a#c608384ddd217c0d857656488b3d93ba3df0dc5a" dependencies = [ "chrono", "eyre", @@ -10378,7 +10378,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell 1.21.4", "rustix", "windows-sys 0.61.2", diff --git a/Cargo.toml b/Cargo.toml index ef663f03..fe4036cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -100,15 +100,15 @@ octosense-appstore = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub octosense-app-policy = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "e8601b80ce104db2e48208094714bdcffdce6b5a" } octosense-app-hub = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "e8601b80ce104db2e48208094714bdcffdce6b5a" } # --- octos: ONE source for every octos crate (octos-org/octos main at the -# merge of octos#2616, the #2567 follow-ups). +# merge of octos#2621, `peer/input/reject`; after #2616, the #2567 follow-ups). # Move octos-core, octos-cli and octos-research together; the Android kernel # artifact (liboctos.so) is built from this same revision. -octos-core = { git = "https://github.com/octos-org/octos.git", rev = "acffad3b493b70d6620b57394265ec84d260ce13" } -octos-cli = { git = "https://github.com/octos-org/octos.git", rev = "acffad3b493b70d6620b57394265ec84d260ce13", default-features = false } +octos-core = { git = "https://github.com/octos-org/octos.git", rev = "c608384ddd217c0d857656488b3d93ba3df0dc5a" } +octos-cli = { git = "https://github.com/octos-org/octos.git", rev = "c608384ddd217c0d857656488b3d93ba3df0dc5a", default-features = false } # The research engine (the metasearch, the SSRF-safe polite reader and the # toolbox scope) for the toolbox's `octos-engine` feature. Same source and # revision as above. -octos-research = { git = "https://github.com/octos-org/octos.git", rev = "acffad3b493b70d6620b57394265ec84d260ce13", default-features = false } +octos-research = { git = "https://github.com/octos-org/octos.git", rev = "c608384ddd217c0d857656488b3d93ba3df0dc5a", default-features = false } # --- AppCard's inline renderers. streaming-markdown-kit = { git = "https://github.com/Project-Robius-China/streaming-markdown-kit.git", branch = "main" } makepad-diagram-kit = { git = "https://github.com/Project-Robius-China/makepad-diagram-kit.git", branch = "main" } diff --git a/apps/README.md b/apps/README.md index 5c027d08..f56a7c9e 100644 --- a/apps/README.md +++ b/apps/README.md @@ -356,7 +356,7 @@ octos UI Protocol v1. kernel, a WebSocket or REST) and `octos-app-render` (streaming-markdown renderer). - **octos**: every octos crate comes from git `octos-org/octos` at the one - rev in the root `Cargo.toml` `[workspace.dependencies]` (today `acffad3b`, + rev in the root `Cargo.toml` `[workspace.dependencies]` (today `c608384d`, octos `main`), shared with `crates/kernel` and the shells. AppCard starts no kernel of its own: it connects to the shell's ([The octos kernel](#the-octos-kernel)). - **Makepad**: not vendored. Makepad, Octoscript and Octoscript-Makepad are @@ -440,7 +440,7 @@ plus an entry in each shell's `system-apps.json`. | [OctoScript-App-Design-Flow](https://github.com/OctoSense-org/OctoScript-App-Design-Flow) | how to design, build, check and publish an app | | [OctoScript](https://github.com/OctoSense-org/OctoScript), [OctoScript-Makepad](https://github.com/OctoSense-org/OctoScript-Makepad), [makepad](https://github.com/OctoSense-org/makepad) | the language and runtime | | [Rinx](https://github.com/hagency-org/Rinx) | Matrix chats and mini apps, a native module; reaches the assistant through `crates/app-peers` | -| [octos](https://github.com/octos-org/octos) | the agent kernel: run as a shell service by `crates/kernel`, configured by AI providers, used by AppCard and other consumers (one rev, `acffad3b`) | +| [octos](https://github.com/octos-org/octos) | the agent kernel: run as a shell service by `crates/kernel`, configured by AI providers, used by AppCard and other consumers (one rev, `c608384d`) | ## Contributing diff --git a/apps/README.zh-CN.md b/apps/README.zh-CN.md index 0a2666d1..f9f4afea 100644 --- a/apps/README.zh-CN.md +++ b/apps/README.zh-CN.md @@ -315,7 +315,7 @@ octos UI Protocol v1 与 octos 通信。 `octos-app-render`(流式 markdown 渲染)。 - **octos**:所有 octos crate 都来自 git `octos-org/octos`,版本为根目录 `Cargo.toml` 的 `[workspace.dependencies]` 中唯一的 rev(目前是 octos `main` 上的 - `acffad3b`),与 `crates/kernel` 和 Shell 共用。AppCard 不再自己启动内核,而是连接 Shell 的内核 + `c608384d`),与 `crates/kernel` 和 Shell 共用。AppCard 不再自己启动内核,而是连接 Shell 的内核 (见 [octos 内核](#octos-内核))。 - **Makepad**:不内置。Makepad、Octoscript 和 Octoscript-Makepad 是仓库根目录下 `.sources/` 中由 `tools/setup.py` 准备的检出,版本由 `native-runtime.lock.json` @@ -388,7 +388,7 @@ Shell 的 `system-apps.json` 中加入它。 | [OctoScript-App-Design-Flow](https://github.com/OctoSense-org/OctoScript-App-Design-Flow) | 如何设计、构建、检查和发布应用 | | [OctoScript](https://github.com/OctoSense-org/OctoScript)、[OctoScript-Makepad](https://github.com/OctoSense-org/OctoScript-Makepad)、[makepad](https://github.com/OctoSense-org/makepad) | 语言与运行时 | | [Rinx](https://github.com/hagency-org/Rinx) | Matrix 聊天与小程序,原生模块;通过 `crates/app-peers` 访问助手 | -| [octos](https://github.com/octos-org/octos) | Agent 内核:由 `crates/kernel` 作为 Shell 服务运行,由 AI providers 配置,供 AppCard 等使用方使用(只用一个版本 `acffad3b`) | +| [octos](https://github.com/octos-org/octos) | Agent 内核:由 `crates/kernel` 作为 Shell 服务运行,由 AI providers 配置,供 AppCard 等使用方使用(只用一个版本 `c608384d`) | ## 参与贡献 diff --git a/apps/appcard/README.md b/apps/appcard/README.md index 190f8330..c17371c0 100644 --- a/apps/appcard/README.md +++ b/apps/appcard/README.md @@ -42,7 +42,7 @@ docs/ Architecture, protocol, build and review notes. Every octos crate (`octos-core`, and on OpenHarmony `octos-cli` with the ~20 crates it pulls in) comes from **one** source: git `https://github.com/octos-org/octos.git` at the single rev in the repository's -root `Cargo.toml` `[workspace.dependencies]` (today `acffad3b`, octos main), +root `Cargo.toml` `[workspace.dependencies]` (today `c608384d`, octos main), shared with `crates/kernel` and both shells. octos's OpenHarmony-safe `nix` is patched in the root `[patch.crates-io]` (from octos `18fcd3f1`). There is no octos submodule; check the graph keeps one octos with diff --git a/apps/appcard/README.zh-CN.md b/apps/appcard/README.zh-CN.md index 141d19b3..8cbbbee2 100644 --- a/apps/appcard/README.zh-CN.md +++ b/apps/appcard/README.zh-CN.md @@ -39,7 +39,7 @@ docs/ 架构、协议、构建与评审笔记。 所有 octos crate(`octos-core`,以及 OpenHarmony 上的 `octos-cli` 和它引入的约 20 个 crate)都来自**同一个**来源:git `https://github.com/octos-org/octos.git`, 版本为仓库根目录 `Cargo.toml` 的 `[workspace.dependencies]` 中唯一的 rev(目前是 -octos main 上的 `acffad3b`),与 `crates/kernel` 和两个 Shell 共用。octos 中适配 +octos main 上的 `c608384d`),与 `crates/kernel` 和两个 Shell 共用。octos 中适配 OpenHarmony 的 `nix` 在根目录的 `[patch.crates-io]` 中 patch(来自 octos `18fcd3f1`)。 没有 octos submodule;可用 `cargo tree --locked -p octos-app -i octos-core --target all --depth 0` 检查依赖图中只有一份 octos。 diff --git a/crates/app-peers/src/broker.rs b/crates/app-peers/src/broker.rs index 16cde929..793e29e2 100644 --- a/crates/app-peers/src/broker.rs +++ b/crates/app-peers/src/broker.rs @@ -52,7 +52,7 @@ use serde_json::{json, Value}; use tokio::sync::{mpsc, oneshot}; use crate::contract::*; -use crate::host_tools::{self, AgentQuestion, ApprovalAnswer, CallOrigin, HostToolApproval, HostToolCall, PeerInput, QuestionAnswer, ToolHost, ToolOutcome, ToolReply}; +use crate::host_tools::{self, AgentQuestion, ApprovalAnswer, CallOrigin, HostToolApproval, HostToolCall, InputRefusal, PeerInput, QuestionAnswer, ToolHost, ToolOutcome, ToolReply}; /// A boxed future, for the object-safe transport traits. pub type BoxFuture<'a, T> = Pin + Send + 'a>>; @@ -1337,21 +1337,45 @@ impl Inner { let Some(peer) = peer.filter(|p| p.slug == input.peer && p.session == input.session_id) else { return eprintln!("app-peers: {}: input {} is not for this app's peer", self.cfg.app_id, input.input_id); }; - let _ = peer; + // A refusal the host already knows is said to the kernel before any + // turn starts (octos#2621), so the system agent learns why. let host = self.tool_host(); let Some(account) = account.filter(|a| !host.suspended(&self.cfg.app_id, a)) else { - return eprintln!("app-peers: {}: input {} refused: the account is signed out", self.cfg.app_id, input.input_id); + return self.reject_input(&peer, &input, InputRefusal::SignedOut); }; if let Err(why) = host.admit_input(&self.cfg.app_id, &account, &input) { - return eprintln!("app-peers: {}: input {} refused: {why}", self.cfg.app_id, input.input_id); + return self.reject_input(&peer, &input, why); } if busy { + let full = self.lock().input_queue.len() >= host_tools::MAX_QUEUED_INPUTS; + if full { + return self.reject_input(&peer, &input, InputRefusal::Busy); + } + // Queued: it starts later, with its own turn id. self.lock().input_queue.push_back(input); } else { self.start_input(input); } } + /// `peer/input/reject` on this link (the one the input came on), with + /// the peer's credential, before any `turn/start` with its turn id. + fn reject_input(self: &Arc, peer: &PeerInfo, input: &PeerInput, why: InputRefusal) { + eprintln!("app-peers: {}: input {} refused: {why}", self.cfg.app_id, input.input_id); + let Some(link) = self.lock().link.clone() else { return }; + let mut params = json!({ + "profile_id": self.cfg.profile_id, + "session_id": self.cfg.originator, + "peer": peer.slug, + "host_token": peer.token, + "input_id": input.input_id, + }); + for (k, v) in why.fields().as_object().into_iter().flatten() { + params[k] = v.clone(); + } + self.fire(&link, host_tools::PEER_INPUT_REJECT, params); + } + fn start_input(self: &Arc, input: PeerInput) { { let mut st = self.lock(); diff --git a/crates/app-peers/src/host_tools.rs b/crates/app-peers/src/host_tools.rs index 94dadf3f..6ae7ab75 100644 --- a/crates/app-peers/src/host_tools.rs +++ b/crates/app-peers/src/host_tools.rs @@ -16,7 +16,7 @@ //! | --- | --- | //! | `peer/tool/call` | [`ToolHost::tool_call`] with a [`HostToolCall`] and its [`ToolReply`] | //! | `peer/tool/cancel` (timeout, interrupt), the link closing | [`ToolHost::tool_cancel`]; the reply is closed first | -//! | `peer/input` (the system agent's input) | the broker starts the turn; [`ToolHost::admit_input`] may refuse it | +//! | `peer/input` (the system agent's input) | the broker starts the turn; [`ToolHost::admit_input`] may refuse it, and the broker says why (`peer/input/reject`, [`InputRefusal`]) | //! | `approval/requested` with `approval_kind: "host_tool"` | [`ToolHost::host_tool_approval`] with an [`ApprovalAnswer`] | //! | `user_question/requested` on the peer's session or a context (octos's `ask_user_question`) | [`ToolHost::user_question`] with a [`QuestionAnswer`]; the turn's end closes it ([`ToolHost::user_question_closed`]) | //! @@ -43,6 +43,65 @@ pub const TOOL_CALL: &str = "peer/tool/call"; pub const TOOL_RESULT: &str = "peer/tool/result"; pub const TOOL_CANCEL: &str = "peer/tool/cancel"; pub const PEER_INPUT: &str = "peer/input"; +/// The host refuses a `peer/input` it will not act on (octos#2621). +pub const PEER_INPUT_REJECT: &str = "peer/input/reject"; +/// How many of the system agent's inputs wait for a busy peer before the +/// host refuses more (`busy`). +pub const MAX_QUEUED_INPUTS: usize = 8; + +/// Why the host refuses a `peer/input` (`peer/input/reject`'s `reason`). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum InputRefusal { + /// The account is signed out or suspended (ADR 0004 §11). + SignedOut, + /// The person has not allowed the app's agent (ADR 0004 §4). + NoConsent, + /// The peer is busy past the host's queue limit. + Busy, + /// Anything else, said in one line. + Other(String), +} + +impl InputRefusal { + pub fn reason(&self) -> &'static str { + match self { + InputRefusal::SignedOut => "signed_out", + InputRefusal::NoConsent => "no_consent", + InputRefusal::Busy => "busy", + InputRefusal::Other(_) => "other", + } + } + /// The message octos takes with `other` only: one line, 1–256 bytes, + /// no control characters. + pub fn message(&self) -> Option { + let InputRefusal::Other(text) = self else { return None }; + let mut line: String = text.chars().map(|c| if c.is_control() { ' ' } else { c }).collect::().trim().to_owned(); + if line.is_empty() { + line = "refused by the host".into(); + } + while line.len() > 256 { + line.pop(); + } + Some(line) + } + /// `peer/input/reject`'s `reason` and `message` fields. + pub fn fields(&self) -> Value { + let mut out = json!({"reason": self.reason()}); + if let Some(message) = self.message() { + out["message"] = json!(message); + } + out + } +} + +impl std::fmt::Display for InputRefusal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + InputRefusal::Other(text) => write!(f, "other: {text}"), + other => f.write_str(other.reason()), + } + } +} /// `approval/requested`'s `approval_kind` for a host-routed tool. pub const HOST_TOOL_KIND: &str = "host_tool"; /// What an app's context sees instead of a `host_tool` approval: the host @@ -794,8 +853,9 @@ pub trait ToolHost: Send + Sync { fn tool_cancel(&self, _app_id: &str, _call_id: &str, _reason: &str) {} /// A `peer/input` for `app_id`'s peer, before its turn starts: `Err` - /// refuses it (no turn runs). - fn admit_input(&self, _app_id: &str, _account: &str, _input: &PeerInput) -> Result<(), String> { + /// refuses it; no turn runs and the broker tells the kernel why + /// (`peer/input/reject`), so the system agent learns it. + fn admit_input(&self, _app_id: &str, _account: &str, _input: &PeerInput) -> Result<(), InputRefusal> { Ok(()) } @@ -908,6 +968,16 @@ mod tests { assert!(declaration(&json!({"description": "no name"}), None).is_none()); } + #[test] + fn an_input_refusal_carries_octos_reasons_and_a_message_only_for_other() { + assert_eq!(InputRefusal::SignedOut.fields(), json!({"reason": "signed_out"})); + assert_eq!(InputRefusal::NoConsent.fields(), json!({"reason": "no_consent"})); + assert_eq!(InputRefusal::Busy.fields(), json!({"reason": "busy"})); + assert_eq!(InputRefusal::Other("a\nb".into()).fields(), json!({"reason": "other", "message": "a b"})); + assert_eq!(InputRefusal::Other("x".repeat(400)).message().unwrap().len(), 256); + assert_eq!(InputRefusal::Other(" ".into()).message().unwrap(), "refused by the host"); + } + #[test] fn a_question_parses_and_is_answered_once() { let params = json!({"session_id": "_main:api:octosense", "topic": "peer-rinx-1", "question_id": "q1", "turn_id": "t1", "title": "Which room?", "body": "Pick one", diff --git a/crates/app-peers/tests/broker.rs b/crates/app-peers/tests/broker.rs index f3730294..3a231727 100644 --- a/crates/app-peers/tests/broker.rs +++ b/crates/app-peers/tests/broker.rs @@ -6,7 +6,7 @@ use std::sync::{Arc, Mutex}; use std::time::Duration; use octosense_app_peers::broker::{BoxFuture, Broker, BrokerConfig, Connector, Link, ToolHostHandle}; -use octosense_app_peers::host_tools::{AgentQuestion, ApprovalAnswer, CallOrigin, HostToolApproval, HostToolCall, PeerInput, QuestionAnswer, QuestionReply, ToolHost, ToolOutcome, ToolReply, TurnOrigin}; +use octosense_app_peers::host_tools::{AgentQuestion, ApprovalAnswer, CallOrigin, HostToolApproval, HostToolCall, InputRefusal, PeerInput, QuestionAnswer, QuestionReply, ToolHost, ToolOutcome, ToolReply, TurnOrigin}; use octosense_app_peers::*; use serde_json::{json, Value}; use tokio::sync::mpsc; @@ -470,6 +470,8 @@ struct RecordingHost { questions: Mutex>, closed_questions: Mutex>, generic: Mutex>>, + /// Refuse each `peer/input` with this. + refuse_input: Mutex>, } impl ToolHost for RecordingHost { @@ -494,9 +496,12 @@ impl ToolHost for RecordingHost { fn tool_cancel(&self, _app: &str, call_id: &str, reason: &str) { self.cancels.lock().unwrap().push((call_id.into(), reason.into())); } - fn admit_input(&self, _app: &str, _account: &str, input: &PeerInput) -> Result<(), String> { + fn admit_input(&self, _app: &str, _account: &str, input: &PeerInput) -> Result<(), InputRefusal> { self.inputs.lock().unwrap().push(input.clone()); - Ok(()) + match self.refuse_input.lock().unwrap().clone() { + Some(why) => Err(why), + None => Ok(()), + } } fn host_tool_approval(&self, _app: &str, _account: Option<&str>, approval: HostToolApproval, answer: ApprovalAnswer) -> bool { self.approvals.lock().unwrap().push((approval, answer)); @@ -983,3 +988,65 @@ fn octos_own_approvals_on_a_context_or_the_peers_session_go_to_the_host() { assert!(approval.octos && approval.context_id.is_none() && approval.client.is_none()); drop(broker); } + + +/// octos#2621: a `peer/input` the host will not act on is refused on the +/// connection it came on, with the reason (signed_out, no_consent, busy, +/// other + message), before any `turn/start` with its turn id; queued +/// inputs still start later with theirs. +#[test] +fn a_refused_input_is_rejected_with_its_reason_before_any_turn() { + let host = Arc::new(RecordingHost::default()); + let (broker, script) = new_broker_with(&ALL, Some(host.clone()), None); + script.lock().unwrap().hold_turns = true; + broker.set_account(Some("@a:x")); + wait_for("ready", || broker.availability() == Availability::Ready); + let slug = peer_slug(&script); + let session = format!("_main:api:octosense#peer-{slug}"); + let input = |id: &str| json!({"peer": slug, "session_id": session, "input_id": id, "turn_id": format!("turn-{id}"), "text": id}); + let rejects = || calls_of(&script, "peer/input/reject"); + + // no_consent, other (with its one-line message), from the host. + *host.refuse_input.lock().unwrap() = Some(InputRefusal::NoConsent); + notify(&script, "peer/input", input("n1")); + wait_for("no_consent", || rejects().len() == 1); + *host.refuse_input.lock().unwrap() = Some(InputRefusal::Other("the app is updating".into())); + notify(&script, "peer/input", input("o1")); + wait_for("other", || rejects().len() == 2); + *host.refuse_input.lock().unwrap() = None; + // signed_out: the account is suspended. + *host.suspended.lock().unwrap() = true; + notify(&script, "peer/input", input("s1")); + wait_for("signed_out", || rejects().len() == 3); + *host.suspended.lock().unwrap() = false; + let got: Vec<(String, Value)> = rejects().iter().map(|(_, p)| (p["input_id"].as_str().unwrap().to_string(), p.clone())).collect(); + assert_eq!(got[0].1["reason"], "no_consent"); + assert!(got[0].1.get("message").is_none(), "a message only with other"); + assert_eq!((got[1].1["reason"].as_str(), got[1].1["message"].as_str()), (Some("other"), Some("the app is updating"))); + assert_eq!(got[2].1["reason"], "signed_out"); + for (_, p) in &got { + assert_eq!(p["peer"], slug.as_str()); + assert_eq!(p["session_id"], "_main:api:octosense#system", "the peer's originator"); + assert_eq!(p["host_token"], "fixture-host-token"); + } + assert!(calls_of(&script, "turn/start").is_empty(), "no turn for a refused input"); + let register_conn = calls_of(&script, "peer/tools/register")[0].0; + assert!(rejects().iter().all(|(c, _)| *c == register_conn), "on the connection the input came on"); + + // busy: one running, MAX_QUEUED_INPUTS queued, the next refused. + notify(&script, "peer/input", input("b0")); + wait_for("the running turn", || calls_of(&script, "turn/start").len() == 1); + for i in 1..=octosense_app_peers::host_tools::MAX_QUEUED_INPUTS { + notify(&script, "peer/input", input(&format!("b{i}"))); + } + wait_for("the queue", || broker.queued_inputs() == octosense_app_peers::host_tools::MAX_QUEUED_INPUTS); + notify(&script, "peer/input", input("full")); + wait_for("busy", || rejects().len() == 4); + assert_eq!(rejects()[3].1["reason"], "busy"); + assert_eq!(rejects()[3].1["input_id"], "full"); + // The queued ones still start, each with its own turn id. + notify(&script, "turn/completed", json!({"session_id": session, "turn_id": "turn-b0"})); + wait_for("the next queued input", || calls_of(&script, "turn/start").len() == 2); + assert_eq!(calls_of(&script, "turn/start")[1].1["turn_id"], "turn-b1"); + assert!(!calls_of(&script, "turn/start").iter().any(|(_, p)| p["turn_id"] == "turn-full")); +} diff --git a/crates/shell/src/host_tools/mod.rs b/crates/shell/src/host_tools/mod.rs index 30273138..9b9d090a 100644 --- a/crates/shell/src/host_tools/mod.rs +++ b/crates/shell/src/host_tools/mod.rs @@ -49,7 +49,7 @@ use std::sync::{Arc, Mutex, OnceLock}; use serde_json::Value; -use crate::ai_host::app_peers::host_tools::{self, AgentQuestion, ApprovalAnswer, ConfirmRequest, ConfirmSheet, HostToolApproval, HostToolCall, PeerInput, QuestionAnswer, ToolExecutor, ToolHost, ToolOutcome, ToolReply}; +use crate::ai_host::app_peers::host_tools::{self, AgentQuestion, ApprovalAnswer, InputRefusal, ConfirmRequest, ConfirmSheet, HostToolApproval, HostToolCall, PeerInput, QuestionAnswer, ToolExecutor, ToolHost, ToolOutcome, ToolReply}; use crate::approvals::{self, Caller, Decision, RequestContext, RequestId, Route, ToolSpec}; use crate::peer_link::{self, KernelToolCall, Refused, ToolCallResult}; pub use relay::{app_of_peer, Event, Relay, APPROVAL_PREFIX, BUS_PREFIX, CONFIRM_PREFIX, SYSTEM, TERMINAL_RUN, TOOLBOX}; @@ -223,13 +223,13 @@ impl ToolHost for ShellToolHost { submit(Event::Cancel { call_id: call_id.to_string(), reason: reason.to_string() }); } - fn admit_input(&self, app_id: &str, account: &str, input: &PeerInput) -> Result<(), String> { + fn admit_input(&self, app_id: &str, account: &str, input: &PeerInput) -> Result<(), InputRefusal> { let app = app_of_peer(app_id); if suspended(app_id, Some(account)) { - return Err("the account is signed out".into()); + return Err(InputRefusal::SignedOut); } if !approvals::consent_granted(app) && !crate::dev_mode::grants_all(app) { - return Err("the person has not allowed this app's agent".into()); + return Err(InputRefusal::NoConsent); } makepad_widgets::log!("host tools: the system agent's input {} starts {app}'s turn {}", input.input_id, input.turn_id); Ok(()) diff --git a/crates/toolbox/README.md b/crates/toolbox/README.md index 523e8aeb..9cd690c3 100644 --- a/crates/toolbox/README.md +++ b/crates/toolbox/README.md @@ -166,7 +166,7 @@ Backends: - no cookies, credentials or proxies, and 401/402/403 are failures, so there is no paywall or login bypass; - **SSRF blocking** on every fetch and every redirect hop. Loopback, private, link-local (including cloud-metadata 169.254.169.254 and `fd00:ec2::254`), unique-local, shared (CGNAT), multicast, reserved and IPv4-mapped forms are refused, both as URL literals and in DNS answers. A resolver filters the addresses the connection actually uses, so DNS rebinding cannot get past the check. `localhost` names are refused. - **Licenses**: `dom_smoothie`, `dom_query`, `gjson`, `html-escape` (MIT), `flagset` (Apache-2.0), `quick-xml` (MIT), and Mozilla's `cssparser` and `selectors` (MPL-2.0, unmodified; the workspace already links them through `scraper`). All of these are behind the `live` feature. -- **The octos research engine** (`research::octos::OctosResearch`, feature `octos-engine`). It depends on `octos-research` through the workspace's one octos pin, and needs octos#2568 (reader), #2582 (metasearch), #2585 (`octos_research::toolbox`), #2590 (read-failure reasons, posts marked as posts) and #2594 (stub and consent pages, whole-topic feed matching, a soft deadline for slow engines), all in the pin (octos acffad3b, which contains 3b5d17a4). It only finds and reads; everything under "The policy" above stays in the host unchanged. +- **The octos research engine** (`research::octos::OctosResearch`, feature `octos-engine`). It depends on `octos-research` through the workspace's one octos pin, and needs octos#2568 (reader), #2582 (metasearch), #2585 (`octos_research::toolbox`), #2590 (read-failure reasons, posts marked as posts) and #2594 (stub and consent pages, whole-topic feed matching, a soft deadline for slow engines), all in the pin (octos c608384d, which contains 3b5d17a4). It only finds and reads; everything under "The policy" above stays in the host unchanged. - **One definition of an app's reach.** The app's grant **is** `octos_research::toolbox::Scope`; nothing is mapped. Each search is narrowed by octos's `Scope::search_args`, in the host (above) and again here, so a language, region, category or domain outside the grant is refused there and the recency is clamped. The search asks the engines for a candidate pool of three times the app's `limit` (at most 30, `candidate_pool`) so the host's filters still leave `limit`; `max_results` bounds what the app gets, not the pool. Each read is checked with `Scope::check_domain` twice: the link, then the page the browser ended on, the publisher for a Google News link. - **Search** is the octos metasearch (`octos_research::metasearch`), category `news`: Google News in the language's own edition, GDELT, publisher feeds, Hacker News and Mastodon, plus keyed engines when their keys are set. - Octos fans the query out under a 15 s deadline, then merges, deduplicates and ranks the results. It spaces requests per host, honours `Retry-After`, caches with ETags, and suspends a failing engine with doubling backoff. That backoff is how GDELT's 429s are handled. diff --git a/docs/adr/0004-native-apps-hosting-and-peers.md b/docs/adr/0004-native-apps-hosting-and-peers.md index a7404c5f..8d1b22c2 100644 --- a/docs/adr/0004-native-apps-hosting-and-peers.md +++ b/docs/adr/0004-native-apps-hosting-and-peers.md @@ -3,7 +3,7 @@ - **Date:** 2026-09-28 - **Status:** Accepted (2026-09-28); amended 2026-09-29 (section 6: `host.ask` dropped for octos's `ask_user_question`; sections 10 and 12: `terminal.run` only where the Terminal runs as a process; factual corrections from the gap review, marked where made; sections 4 and 8: interactive app cards; section 6: one shared conversation per app agent; follow-ups from the 2026-09-29 code review) - **Scope:** How OctoSense declares, links, hosts, isolates and trusts its bundled native Rust apps on each target; where every app keeps its data and what its agent may read; how every bundled app, native or script, owns an octos app agent; how the system agent and app agents work across apps; and how the person approves what they do, live or in advance. -- **Relates to:** [ADR 0001](0001-one-octosense-repository.md); [ADR 0002](0002-event-driven-app-agents.md) (app agents; amended by this ADR); [Home ADR 0002](home/0002-agentic-app-security-model.md) and [Home ADR 0004](home/0004-system-apps-are-contained-script-apps.md) (amended); Rinx [ADR 0007](https://github.com/hagency-org/Rinx/blob/main/docs/adr/0007-host-owned-octos-app-peers.md); octos UPCR-2026-034 (host-owned app peers, merged), UPCR-2026-035 ([octos#2567](https://github.com/octos-org/octos/pull/2567), host tools per app peer, merged; OctoSense pins its follow-ups, octos acffad3b) and UPCR-2026-036 (`serve --host-managed`, [octos#2591](https://github.com/octos-org/octos/pull/2591), merged); [octos#2601](https://github.com/octos-org/octos/pull/2601) (tool origin, per-connection ownership); [ADR 0003](0003-shared-octos-client-access.md) (Talk to Octos, OctoSense [#98](https://github.com/OctoSense-org/OctoSense/pull/98): external clients never reach app agents). +- **Relates to:** [ADR 0001](0001-one-octosense-repository.md); [ADR 0002](0002-event-driven-app-agents.md) (app agents; amended by this ADR); [Home ADR 0002](home/0002-agentic-app-security-model.md) and [Home ADR 0004](home/0004-system-apps-are-contained-script-apps.md) (amended); Rinx [ADR 0007](https://github.com/hagency-org/Rinx/blob/main/docs/adr/0007-host-owned-octos-app-peers.md); octos UPCR-2026-034 (host-owned app peers, merged), UPCR-2026-035 ([octos#2567](https://github.com/octos-org/octos/pull/2567), host tools per app peer, merged; OctoSense pins its follow-ups, now octos c608384d with octos#2621) and UPCR-2026-036 (`serve --host-managed`, [octos#2591](https://github.com/octos-org/octos/pull/2591), merged); [octos#2601](https://github.com/octos-org/octos/pull/2601) (tool origin, per-connection ownership); [ADR 0003](0003-shared-octos-client-access.md) (Talk to Octos, OctoSense [#98](https://github.com/OctoSense-org/OctoSense/pull/98): external clients never reach app agents). ## Context @@ -43,7 +43,7 @@ ### What octos provides (octos `main` e6223ef, verified) -Verified at e6223ef when this ADR was written. OctoSense now pins octos acffad3b (the merge of octos#2616, the #2567 follow-ups); the corrections dated 2026-09-29 below were checked against that pin. +Verified at e6223ef when this ADR was written. OctoSense now pins octos c608384d (the merge of octos#2621, `peer/input/reject`, after #2616, the #2567 follow-ups); the corrections dated 2026-09-29 below were checked against that pin. - **Peers are sessions, not processes.** One kernel process per shell; every peer is a session in it (tokio tasks) with its own workspace, memory namespace, transcript and model lane. Tools such as the shell run as short child processes in the peer's workspace. - **No 8-peer ceiling.** `peer/prepare` stages 1–8 peers **per call** (`n`); the only total is a soft cap of 8192 live peer registrations. The model's `peer_handoff` tool is limited to 4 per turn; host-owned app peers are not created through it. OctoSense's broker prepares one peer per call. diff --git a/docs/ai-services.md b/docs/ai-services.md index eb14a6e3..f77c5021 100644 --- a/docs/ai-services.md +++ b/docs/ai-services.md @@ -219,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 (`acffad3b`), 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): +1. Build octos at the revision the root `Cargo.toml` pins (`c608384d`), 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 acffad3b493b70d6620b57394265ec84d260ce13 + git checkout c608384ddd217c0d857656488b3d93ba3df0dc5a cargo build --release -p octos-cli --bin octos --no-default-features --features api,git,ast ``` diff --git a/docs/ai-services.zh-CN.md b/docs/ai-services.zh-CN.md index 3d0b97d9..b6badf85 100644 --- a/docs/ai-services.zh-CN.md +++ b/docs/ai-services.zh-CN.md @@ -219,10 +219,10 @@ manifest 的 `agent` 字段(权限档位、通用工具、迭代和 token 上 ### 桌面端:使用临时的内核与配置 -1. 在 [octos-org/octos](https://github.com/octos-org/octos) 的检出中,构建根 `Cargo.toml` 锁定的版本(`acffad3b`;参数与 `tools/kernel-artifact.py` 相同;本次更新未在此版本上运行,**unverified**): +1. 在 [octos-org/octos](https://github.com/octos-org/octos) 的检出中,构建根 `Cargo.toml` 锁定的版本(`c608384d`;参数与 `tools/kernel-artifact.py` 相同;本次更新未在此版本上运行,**unverified**): ```sh - git checkout acffad3b493b70d6620b57394265ec84d260ce13 + git checkout c608384ddd217c0d857656488b3d93ba3df0dc5a cargo build --release -p octos-cli --bin octos --no-default-features --features api,git,ast ``` diff --git a/tools/test_kernel_artifact.py b/tools/test_kernel_artifact.py index 2210c636..fbf5d533 100644 --- a/tools/test_kernel_artifact.py +++ b/tools/test_kernel_artifact.py @@ -13,7 +13,7 @@ kernel = importlib.util.module_from_spec(spec) spec.loader.exec_module(kernel) -REV = "acffad3b493b70d6620b57394265ec84d260ce13" +REV = "c608384ddd217c0d857656488b3d93ba3df0dc5a" def lock_with(*revs): From 169a40af4b90e894c85e3316cf51132622241e26 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:02:51 -0700 Subject: [PATCH 9/9] docs: ADR 0004 app tools, kernel-tool grants, fail-closed policy and checks are on main [skip ci] architecture.md (+zh): an app peer's tool list is exactly its granted `generic_tools` plus its declared and granted tools (the Terminal's, News's `news.list`/`news.read`); Rinx's grant comes from native-apps.json; the relay checks arguments and results against the declared schemas and budgets each agent; disagreement 4's fail-open note is fixed (G13). ADR 0004 follow-ups: what G3/G8/G13 and `peer/input/reject` closed, and what stays open. ai-services.md: the shell side of #2567. Co-Authored-By: Claude Opus 5.5 --- docs/adr/0004-native-apps-hosting-and-peers.md | 4 ++-- docs/ai-services.md | 2 +- docs/architecture.md | 14 +++++++------- docs/architecture.zh-CN.md | 4 ++-- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/adr/0004-native-apps-hosting-and-peers.md b/docs/adr/0004-native-apps-hosting-and-peers.md index 8d1b22c2..96598afd 100644 --- a/docs/adr/0004-native-apps-hosting-and-peers.md +++ b/docs/adr/0004-native-apps-hosting-and-peers.md @@ -267,7 +267,7 @@ An app agent gets **whatever tools its manifest declares, once the person grants **The system agent's tool set is its grants, enforced by the shell (decided on #110).** Today the system agent's turns get octos's full default tool set, shell included, granted or not. *(Since [#117](https://github.com/OctoSense-org/OctoSense/pull/117), 2026-09-28: the shell writes a `tool_policy` denying `group:runtime` into the `_main` profile, so octos's shell is gone; the rest of the default set remains, a ceiling rather than the exact list; see the follow-ups.)* The system agent gets an explicit list instead: its supervision tools (the peer tools, glance curation, policy and budgets), the toolbox tools it is granted, the cross-app tools granted to it (section 7), and command execution only when the person grants it in Settings, off by default, each command going through the shell with a live approval (section 8). The shell enforces the list (with octos's help where needed), and a test starts a system-agent turn and checks it is offered exactly that list (in particular, no `shell` unless granted). This is a plan step (step 4), and it lands before any cross-app grant reaches the system agent. -**Command execution is a grantable capability like any other, and the widest.** A shell escapes every other boundary: the workspace fence (`cat` reads anywhere, including `secrets/`), the network policy (`curl` reaches any host), budgets and audit (one call does anything), and it turns prompt injection into code execution. So the install-time grant says so plainly, and each destructive or outward call goes through section 8. A narrow tool often serves better (for example a sandboxed `toolbox.run_python`: no network, only its folder, time and memory limits). Running real commands on the person's machine is the Terminal app's shareable `terminal.run`: destructive, `auto_approvable: false`, each command approved live with the exact command shown, run in a terminal the person sees. `terminal.run` exists only where the Terminal runs as its own sandboxed process (macOS, Windows, Linux with Vulkan and Wayland; section 10), so a grant of command execution, the system agent's Settings switch included, takes effect only there; on Linux without Vulkan and on phones it has no target (decided 2026-09-29). Every live approval and `auto_approvable: false` in this section holds outside developer mode, which approves them all (section 13). octos refuses its own `shell` to external clients (#98). For app peers and the system agent, octos's `shell` is kept out by the `_main` profile's `tool_policy`, which denies `group:runtime` (#117), so a granted app reaches command execution through a host tool. *(Corrected 2026-09-29: this sentence said octos refuses `shell` to app peers through #2567's generic-tool allowlist. It does not: with `generic_tools` omitted, as the broker registers every peer today, a peer keeps octos's whole roster, `shell` included (octos test `should_offer_exactly_the_registered_tools_and_refuse_an_unlisted_one`); the profile's `tool_policy` is the only barrier (and it fails open: when `enforce` refuses a foreign policy the kernel still starts without it) until each peer's `generic_tools` is set from its grants, plan step 6.)* +**Command execution is a grantable capability like any other, and the widest.** A shell escapes every other boundary: the workspace fence (`cat` reads anywhere, including `secrets/`), the network policy (`curl` reaches any host), budgets and audit (one call does anything), and it turns prompt injection into code execution. So the install-time grant says so plainly, and each destructive or outward call goes through section 8. A narrow tool often serves better (for example a sandboxed `toolbox.run_python`: no network, only its folder, time and memory limits). Running real commands on the person's machine is the Terminal app's shareable `terminal.run`: destructive, `auto_approvable: false`, each command approved live with the exact command shown, run in a terminal the person sees. `terminal.run` exists only where the Terminal runs as its own sandboxed process (macOS, Windows, Linux with Vulkan and Wayland; section 10), so a grant of command execution, the system agent's Settings switch included, takes effect only there; on Linux without Vulkan and on phones it has no target (decided 2026-09-29). Every live approval and `auto_approvable: false` in this section holds outside developer mode, which approves them all (section 13). octos refuses its own `shell` to external clients (#98). For app peers and the system agent, octos's `shell` is kept out by the `_main` profile's `tool_policy`, which denies `group:runtime` (#117), so a granted app reaches command execution through a host tool. *(Corrected 2026-09-29: this sentence said octos refuses `shell` to app peers through #2567's generic-tool allowlist. It does not: with `generic_tools` omitted, as the broker registered every peer until G3, a peer keeps octos's whole roster, `shell` included (octos test `should_offer_exactly_the_registered_tools_and_refuse_an_unlisted_one`); the profile's `tool_policy` is the only barrier (and it failed open until G13: now a policy that cannot be enforced starts no kernel) until each peer's `generic_tools` is set from its grants, plan step 6 (done: each peer is registered with exactly its granted `generic_tools`).)* ### 13. Developer mode: everything granted, for building apps fast @@ -338,7 +338,7 @@ Gaps between this decision and `main` found after it was accepted (2026-09-28; s - **The system agent's exact tool list** (section 12, step 4): the `_main` profile's `tool_policy` is a ceiling, not the list. octos#2567's host session set can narrow the session with `generic_tools`, but that list narrows every client's turns on it, so the shell registers without it (as agreed on #2567); the exact list waits for a durable host-only list (octos#2605). - [x] **The Settings switch for the system agent's command execution** (section 12): done. Setup → Assistant → Command execution sets `SystemAgentTools::grant_command_execution` with the person's typed confirmation, applies from the next kernel start (with a restart offered), and routes each command through the approval router as `auto_approvable: false`. While it is on, the system chat registers `terminal.run` on the system session (octos#2567's host session target; withdrawn when it goes off), and each approved call is typed into the Terminal the person sees. - **The agent workspace is the account folder** (section 11): partly done. A peer created since the octos 665209e5 pin gets its account folder as the `peer/prepare` `cwd`, recorded beside its host token; a peer created before keeps the kernel-provisioned workspace, because octos resumes a peer only under the workspace it was made with. The account folder name (SHA-256, `app_storage`'s `account_hash`) and the memory namespace tag (FNV-1a, `account_tag` in the `crates/app-peers` broker) are two different hashes of one account; one should derive from the other. Changing the memory tag re-keys every app's memory, so it needs a migration (or the folder name follows the tag). -- [x] **octos#2567 in the shell** (plan step 6, in part): the broker registers every app peer's tools after each prepare and reconnect (`generic_tools` omitted), takes `peer/tool/call` (stamped, once per occurrence, nothing after a cancel, calls of an interrupted turn refused), starts `peer/input` turns and hands `host_tool` approvals to the router; the relay (`crates/shell/src/host_tools/`) authorizes each call by (owning app, tool) and caller and routes it to the owning app. Still open: no app declares `tools.json` (so every peer registers an empty set) and nothing grants cross-app tools yet; Rinx must hand its send sheet over (`OctosAppService::set_confirm_sheet`) to be the `rinx.message.send` confirmation; the host read tools are not registered; a peer's `user_question/requested` is forwarded to the shell's request model (`crates/shell/src/questions/`) and asked in the app's conversation or, for a system-agent turn, the system chat, answered only by the person on a shell surface (section 6; done 2026-09-29); a refused `peer/input` is only logged (octos has no way to tell the system agent); script apps' host services have no executor seam wired. +- [x] **octos#2567 in the shell** (plan step 6, in part): the broker registers every app peer's tools after each prepare and reconnect (with exactly its granted `generic_tools` since G3), takes `peer/tool/call` (stamped, once per occurrence, nothing after a cancel, calls of an interrupted turn refused), starts `peer/input` turns and hands `host_tool` approvals to the router; the relay (`crates/shell/src/host_tools/`) authorizes each call by (owning app, tool) and caller and routes it to the owning app. Done since (G3, G8): apps declare tools (`native-apps.json` `agent.tools`, a script app's `tools.json` loaded with App Hub's loader), cross-app grants come from `agent.grants` and a script app's manifest at install, each peer is registered with exactly its granted `generic_tools` (never octos's shell), script apps' host services have an executor (`HostServiceExecutor`), and the relay checks arguments and results against the declared schemas and budgets each agent. Still open: Rinx must hand its send sheet over (`OctosAppService::set_confirm_sheet`) to be the `rinx.message.send` confirmation; the host read tools are not registered; a peer's `user_question/requested` is forwarded to the shell's request model (`crates/shell/src/questions/`) and asked in the app's conversation or, for a system-agent turn, the system chat, answered only by the person on a shell surface (section 6; done 2026-09-29); a refused `peer/input` is now refused to the kernel with its reason (`peer/input/reject`, octos#2621, pinned c608384d). - **Gaps from the 2026-09-29 code review** (octos acffad3b, OctoSense `main` ecb3583): - **Kernel-tool approvals on a `peer/input` turn are dropped.** The broker's event filter for the peer's own session (`crates/app-peers/src/broker.rs`, `inbound`) hands only `host_tool` approvals to the router and returns on every other event of that session, so an `approval/requested` for a kernel tool on a `peer/input` turn reaches no one and nobody can answer it. - **Contained script apps' cards** (`crates/ai-host/src/contained.rs`): streamed text is dropped (the app gets only the final answer), every approval is declined (listed in `denied_approvals`), each app has one request context shared by all its cards and UI, and every peer uses the fixed account `device`. diff --git a/docs/ai-services.md b/docs/ai-services.md index f77c5021..fd139848 100644 --- a/docs/ai-services.md +++ b/docs/ai-services.md @@ -202,7 +202,7 @@ The manifest's `agent` field (a permission profile, generic tools, iteration and | --- | --- | --- | | `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 (`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 | Kernel side merged: [octos#2567](https://github.com/octos-org/octos/pull/2567) (UPCR-2026-035), in the pin with its follow-ups (octos#2616). The shell side is in part on main ([#145](https://github.com/OctoSense-org/OctoSense/pull/145): tool registration with `generic_tools` omitted, the relay, `peer/input`); per-app `generic_tools`, `tools.json` loading and grants are ADR 0004 plan step 6. | +| 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 | Kernel side merged: [octos#2567](https://github.com/octos-org/octos/pull/2567) (UPCR-2026-035), in the pin with its follow-ups (octos#2616). The shell side is on main: [#145](https://github.com/OctoSense-org/OctoSense/pull/145) (tool registration, the relay, `peer/input`) and G3 (per-app `generic_tools` from the manifests, `tools.json` loading, grants, executors for script apps' host services, schema and budget checks; `peer/input/reject`, octos#2621). | | 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; since [#145](https://github.com/OctoSense-org/OctoSense/pull/145) the kernel's `host_tool` approvals reach it through the relay ([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). | diff --git a/docs/architecture.md b/docs/architecture.md index d7627f29..a1713823 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -172,12 +172,12 @@ Each app peer has, separately from every other: - a **workspace**: the folder the peer's session is bound to. A peer created since the octos 665209e5 pin gets its account folder (`apps//accounts//`, ADR 0004 §11) as `peer/prepare`'s `cwd` (`host_tools::agent_workspace`), recorded beside its host token so a resume names the same folder; a peer created before keeps the kernel-provisioned one, since octos refuses to resume a peer under another workspace (see [the disagreements](#where-the-code-and-the-adrs-disagree)); - a **memory namespace** `app//acct-` (`app_namespace()` in `crates/app-peers/src/broker.rs`); a kernel that does not return it is refused; - its own **transcript** and **model** lane (the host sets the model with `peer/prepare` / `peer/model/set`); -- its own **tool list**: the kernel's default roster (less what the `_main` profile's `tool_policy` denies) plus the tools the shell registers (octos [#2567](https://github.com/octos-org/octos/pull/2567), UPCR-2026-035): after every `peer/prepare` and reconnect the broker registers, on the connection that drives the peer's turns, the app's declared tools and the other apps' shareable tools granted to it, each marked with its owning app (`crates/shell/src/host_tools/`), with `generic_tools` omitted. Omitted means the peer keeps octos's whole kernel roster (octos's `shell` included, except that the `_main` profile's `tool_policy` denies it: see [4. Tools and grants](#4-tools-and-grants)); per-app `generic_tools` lists from the manifest and grants are planned (step 6). No app declares `tools.json` on `main` yet, so every peer registers an empty set, which still gives its turns the app's memory and context (a peer that cannot register runs no turn); +- its own **tool list**: exactly the kernel tools its manifest grants (`generic_tools`, never octos's `shell`) plus the tools the shell registers (octos [#2567](https://github.com/octos-org/octos/pull/2567), UPCR-2026-035): after every `peer/prepare` and reconnect the broker registers, on the connection that drives the peer's turns, the app's declared tools and the other apps' shareable tools granted to it, each marked with its owning app (`crates/shell/src/host_tools/`), with `generic_tools` set to exactly the kernel tools the app's manifest declares and the person granted (a native app's `native-apps.json` `agent.generic_tools`, a script app's plain names in its manifest's `agent.tools`; an app that names none keeps none). The Terminal declares `terminal.run`, `terminal.read_screen` and `terminal.read_scrollback`; News declares `news.list` and `news.read` (`apps/news/bundle/tools.json`, on its host service). A peer that cannot register runs no turn; - **request contexts** (`peer/context/open`): one per client instance (a Rinx mini app), each with its own transcript, folder `contexts//` inside the peer's workspace and child memory namespace. A context cannot read the files beside it. ADR 0004 §6 (decided 2026-09-29) keeps them for per-client work only: the person's own conversation with an app agent, from the app's UI or its cards, moves to the peer's own session, shared with the system agent (below). On main the person's turns still run in a context. Who gets a peer today (`crates/ai-host/src/lib.rs`, `Policy::shipped()`; `crates/app-peers/src/hosted.rs`, `effective_services` = declared ∩ supported ∩ policy): -- **Rinx**, the only native app granted the assistant (the four `octos.*` services), after first-use consent. +- **Rinx**, the only native app granted the assistant (the four `octos.*` services, from its `native-apps.json` `agent.octos`, generated into `crates/ai-host/src/native_agents.rs`), after first-use consent. - **Script apps** that declare `octos.*`: one peer `card.` each, account `device` (`crates/ai-host/src/contained.rs`, [#106](https://github.com/OctoSense-org/OctoSense/pull/106)), only while `Policy::contained_apps` is on (off in the shipped policy; `OCTOSENSE_CONTAINED_APPS=1` turns it on) and the person consented at first use. - **AppCard** (opt-in) takes its own kernel connection and sessions, not a peer. - **Process apps** whose `native-apps.json` entry grants `agent.octos` services reach their peer through the **peer link** (`crates/shell/src/peer_link/`, [#130](https://github.com/OctoSense-org/OctoSense/pull/130)), after first-use consent. None does yet: the Terminal, the only process app, grants none. @@ -268,10 +268,10 @@ Where an agent's tools come from: | Source | Example | Runs | Status | | --- | --- | --- | --- | -| The app's own tools (`tools.json`: name `.`, schemas, `risk`, `confirm: host` or `app`, `shareable`) | `mail.send`, `rinx.message.send` | the app's host service, module or process, called by the shell | Registered and routed on main (`crates/shell/src/host_tools/`): authorized by (owning app, tool) and caller, stamped with account, context and client, routed to a process app's peer link or an in-process module's executor (`OctosAppService::set_tool_executor`). No app declares `tools.json` yet, so none is registered; Rinx's tools reach the AI pane over the bus | +| The app's own tools (`tools.json`: name `.`, schemas, `risk`, `confirm: host` or `app`, `shareable`) | `news.list`, `terminal.read_screen`, `mail.send`, `rinx.message.send` | the app's host service, module or process, called by the shell | On main (`crates/shell/src/host_tools/`): declared from `native-apps.json` `agent.tools` (native) or the admitted bundle's `tools.json` (script, `host_tools/script_apps.rs`, with App Hub's loader), authorized by (owning app, tool) and caller, arguments and results checked against the declared schemas, budgeted, stamped with account, context and client, and routed to a process app's peer link, an in-process module's executor (`OctosAppService::set_tool_executor`), a script app's host service (`HostServiceExecutor`) or the Terminal's AI bus service. News offers `news.list` and `news.read`; the Terminal `terminal.run` and its read tools | | The system toolbox, by capability (`research`, `crawl`, `model`) | `toolbox.search`, `toolbox.web_read`, `toolbox.deep_crawl`, `workflow.run` | host | In progress ([#108](https://github.com/OctoSense-org/OctoSense/pull/108)); `model` not registered yet | -| octos's generic tools | file reads fenced to the workspace, memory, `web_search`, `deep_search` | octos | On main as octos's whole kernel roster (`generic_tools` omitted), less octos's `shell`, which the `_main` profile's `tool_policy` denies; per-app `generic_tools` lists planned (step 6) | -| Other apps' shareable tools, routed by the shell | Calendar's agent calling `mail.send` | the owning app, via the shell | Registered, checked on every call and routed on main (`relay::Catalog`); nothing grants one yet (App Hub at install and `native-apps.json` grants are step 6) | +| octos's generic tools | file reads fenced to the workspace, memory, `web_search`, `deep_search` | octos | On main per app: each peer is registered with exactly its granted `generic_tools` (Rinx: its workspace files, `ask_user_question`, memory, the web); an app granted none keeps none; octos's `shell` is never among them (the generator, the catalog and the script loader drop it), and the `_main` profile's `tool_policy` denies it too | +| Other apps' shareable tools, routed by the shell | Calendar's agent calling `mail.send` | the owning app, via the shell | On main (`relay::Catalog`): granted by (owning app, tool), from `native-apps.json` `agent.grants` (native) or the dotted names in a script app's `agent.tools` (at install), registered marked with the owner, checked on every call. None of today's apps is granted one | | Command execution | `terminal.run` (the Terminal's shareable tool: `confirm: host`, `auto_approvable: false`) | host tool, in a terminal the person sees | On main for the system agent: registered on its session while Setup › Assistant › Command execution is on, each call approved live through the router as a command, typed into the running Terminal (a process app on macOS, Windows, and Linux with Vulkan and Wayland; the in-process Terminal offers its reads only, and by ADR 0004 §10, decided 2026-09-29, stays read-only, so the grant takes effect only where a process Terminal exists). Granting it to app agents is step 11 | **The system agent's tool set** (`crates/kernel/src/system_tools.rs`, [#117](https://github.com/OctoSense-org/OctoSense/pull/117)). **On main**, partly enforced: @@ -384,7 +384,7 @@ One host-owned layout for every app, declared by its manifest (`storage` block) | Agent ↔ secrets | secrets outside every jail and workspace; startup check | On main | | External client ↔ kernel | external token, method and tool allowlists, `Host` and origin checks, no peer access | On main ([ADR 0003](adr/0003-shared-octos-client-access.md)) | -**What the shell checks on every call** (ADR 0004 §3): grants and consent; budgets, rate limits and background policy; each tool call's name and arguments against the declared `tools.json`; each result against its schema and size; its own audit; crash cleanup. On main today: consent, the `octos.*` service grants by exact name (declared ∩ supported ∩ policy), argument rules and size caps for script apps (text at most 32 KiB, replies at most 2 MiB), the approval router and audit for bus calls. For app tool calls (`crates/shell/src/host_tools/`): the grant by (owning app, tool) and caller, consent, a signed-out account, the confirmation, once per occurrence, nothing after a cancel. Schema checks of arguments and results, and budgets beyond octos's, are still to come. The actions a person takes in an app's cards do not pass through this relay: they take the app's own path (ADR 0004 §4), so these checks never cover them. +**What the shell checks on every call** (ADR 0004 §3): grants and consent; budgets, rate limits and background policy; each tool call's name and arguments against the declared `tools.json`; each result against its schema and size; its own audit; crash cleanup. On main today: consent, the `octos.*` service grants by exact name (declared ∩ supported ∩ policy), argument rules and size caps for script apps (text at most 32 KiB, replies at most 2 MiB), the approval router and audit for bus calls. For app tool calls (`crates/shell/src/host_tools/`): the grant by (owning app, tool) and caller, consent, a signed-out account, each call's arguments against the tool's declared `input_schema` (and 64 KiB), each result against its `output_schema` and 256 KiB, each calling agent's budget (calls per turn and per day, `native-apps.json` `agent.budget` or 32 and 1000), the confirmation, once per occurrence, nothing after a cancel. Rate limits beyond the budgets and background policy are still to come. The actions a person takes in an app's cards do not pass through this relay: they take the app's own path (ADR 0004 §4), so these checks never cover them. **What nobody can check**: what a native app's code does inside its own tool, or why it starts a turn. Review and, for process apps, the sandbox are the controls. @@ -442,7 +442,7 @@ Found while writing this page (2026-09-28) and rechecked against `main` on 2026- 1. **Process apps beyond the Terminal.** Fixed: the opt-in Sheets and Reference are `module` on every target, and the generator refuses plain `process` on Linux (ADR 0004 §2). 2. **Restart of a dead process app.** Fixed ([#130](https://github.com/OctoSense-org/OctoSense/pull/130)): an unexpected death keeps the tile, closed with a Restart (`ClientSlot::stops_in_place`; test `an_unexpected_death_keeps_the_tile_closed_with_restart`). 3. **Agent workspace = account folder.** Partly fixed: a peer created since the octos 665209e5 pin gets its account folder as the `peer/prepare` `cwd`; a peer created before keeps the kernel-provisioned workspace, because octos resumes a peer only under the workspace it was made with (moving it needs a migration octos does not offer). The account folder name (`account_hash`, SHA-256) and the memory namespace tag (FNV-1a in `broker.rs`) are still different hashes of the account; changing the tag would re-key every app's memory ([#139](https://github.com/OctoSense-org/OctoSense/issues/139)). -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. Fixed in the text: ADR 0003 carries a dated amendment (2026-09-29), as do ADR 0002 §12 and its amendment. (The policy fails open: when `enforce` refuses a foreign policy or the person's own octos home, the kernel still starts without it.) +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. Fixed in the text: ADR 0003 carries a dated amendment (2026-09-29), as do ADR 0002 §12 and its amendment. (Fixed, G13: when `enforce` refuses a foreign policy or the person's own octos home, or the policy does not read back, the kernel does not start and every consumer is told why; and each app peer keeps exactly its granted `generic_tools`, never the shell.) 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: Setup › Assistant › Command execution sets it (#132), and the system chat registers `terminal.run` on the system session while it is on. 7. **`host::processes_available()`'s test** checks only `wasm32` while the function also excludes native mobile. diff --git a/docs/architecture.zh-CN.md b/docs/architecture.zh-CN.md index d98c0384..ea45a6b7 100644 --- a/docs/architecture.zh-CN.md +++ b/docs/architecture.zh-CN.md @@ -270,7 +270,7 @@ Agent 的工具来源: | --- | --- | --- | --- | | 应用自己的工具(`tools.json`:名称 `.`、schema、`risk`、`confirm: host` 或 `app`、`shareable`) | `mail.send`、`rinx.message.send` | 应用的宿主服务、模块或进程,由 Shell 调用 | 已在 main 上注册和路由(`crates/shell/src/host_tools/`):按(所属应用,工具)和调用方授权,盖上账号、上下文和客户端,路由到进程应用的 peer link 或进程内模块的执行器(`OctosAppService::set_tool_executor`)。还没有应用声明 `tools.json`,所以尚未注册任何工具;Rinx 的工具经总线到达 AI 面板 | | 系统工具箱,按能力授予(`research`、`crawl`、`model`) | `toolbox.search`、`toolbox.web_read`、`toolbox.deep_crawl`、`workflow.run` | 宿主 | 进行中([#108](https://github.com/OctoSense-org/OctoSense/pull/108));`model` 尚未注册 | -| octos 的通用工具 | 限定在工作区内的文件读取、记忆、`web_search`、`deep_search` | octos | 作为 octos 对 peer 安全的默认工具已在 main;按应用设置的 `generic_tools` 列表规划中(步骤 6) | +| octos 的通用工具 | 限定在工作区内的文件读取、记忆、`web_search`、`deep_search` | octos | 已在 main 上按应用设置:每个 peer 注册时带着它被授予的精确 `generic_tools`(Rinx:工作区文件、`ask_user_question`、记忆、网页);没有授予的应用一个也没有;octos 的 `shell` 从不在其中(生成器、目录和脚本加载器都会去掉它),`_main` profile 的 `tool_policy` 也拒绝它 | | 其他应用可共享的工具,由 Shell 路由 | Calendar 的 Agent 调用 `mail.send` | 所属应用,经 Shell | 已在 main 上注册、逐次检查并路由(`relay::Catalog`);还没有任何授权(App Hub 安装时授权和 `native-apps.json` 授权属于步骤 6) | | 命令执行 | `terminal.run`(Terminal 的可共享工具:`confirm: host`、`auto_approvable: false`) | 宿主工具,在用户可见的终端中 | 系统 Agent 已在 main:Setup › Assistant › Command execution 开启时注册在其会话上,每次调用经路由作为命令实时批准,输入到正在运行的 Terminal(macOS 和 Windows 上是进程应用;进程内 Terminal 只提供读取工具)。授予应用 Agent 属于步骤 11 | @@ -384,7 +384,7 @@ flowchart TB | Agent ↔ 机密 | 机密位于所有 jail 和工作区之外;启动检查 | 已在 main | | 外部客户端 ↔ 内核 | 外部 token、方法和工具允许列表、`Host` 和 origin 检查、无法访问 peer | 已在 main([ADR 0003](adr/0003-shared-octos-client-access.md)) | -**Shell 在每次调用时检查什么**(ADR 0004 §3):授权和同意;预算、速率限制和后台策略;每次工具调用的名称和参数是否符合声明的 `tools.json`;每个结果是否符合其 schema 和大小;自己的审计;崩溃清理。目前 main 上已有:同意;按精确名称授予 `octos.*` 服务(声明 ∩ 支持 ∩ 策略);脚本应用的参数规则和大小上限(文字最多 32 KiB,回复最多 2 MiB);总线调用的审批路由和审计。对应用工具调用(`crates/shell/src/host_tools/`):按(所属应用,工具)和调用方的授权、同意、已登出的账号、确认、每次出现只执行一次、取消后不再执行。参数和结果的 schema 检查以及 octos 之外的预算尚未实现。用户在应用卡片中的操作不经过这个中继:它们走应用自己的路径(ADR 0004 §4),因此这些检查从不覆盖它们。 +**Shell 在每次调用时检查什么**(ADR 0004 §3):授权和同意;预算、速率限制和后台策略;每次工具调用的名称和参数是否符合声明的 `tools.json`;每个结果是否符合其 schema 和大小;自己的审计;崩溃清理。目前 main 上已有:同意;应用工具调用的参数按声明的 `input_schema`(及 64 KiB)检查,结果按 `output_schema` 和 256 KiB 检查,每个调用方 Agent 有预算(每回合、每天的调用次数,来自 `native-apps.json` 的 `agent.budget`,默认 32 和 1000);按精确名称授予 `octos.*` 服务(声明 ∩ 支持 ∩ 策略);脚本应用的参数规则和大小上限(文字最多 32 KiB,回复最多 2 MiB);总线调用的审批路由和审计。对应用工具调用(`crates/shell/src/host_tools/`):按(所属应用,工具)和调用方的授权、同意、已登出的账号、确认、每次出现只执行一次、取消后不再执行。参数和结果的 schema 检查以及 octos 之外的预算尚未实现。用户在应用卡片中的操作不经过这个中继:它们走应用自己的路径(ADR 0004 §4),因此这些检查从不覆盖它们。 **谁都无法检查的**:原生应用的代码在它自己的工具里做了什么,或它为什么发起一个回合。控制手段是评审,以及进程应用的沙箱。