Skip to main content

onlyne_client/session/dispatch/
guards.rs

1//! The client-side constraints a session's tool calls are measured against.
2//!
3//! A `tools` mount carries no policy of its own (`docs/v2-CONTRACT.md` §3b):
4//! the hop budget, the relay requirement, the completion's shape and the
5//! `details` ceiling are enforced here, where the frame is handled, so the pi
6//! drive and the ACP drive refuse identically. The frame's answer is its own
7//! refusal — a tool call is answered as a tool error, not as a state frame that
8//! also refused something — which is why these read a report and return the
9//! `ResBody` the sender is handed.
10//!
11//! Two of the checks are the pi plugin's private guard moved down: the relay
12//! requirement (a session that owes a downstream handoff may not report a
13//! terminal outcome) and the completion's shape. The plugin's own wording is
14//! kept so the sentence a model reads does not change with the drive that
15//! delivered it. There is no waiver: the plugin's `force`/`reason` escape hatch
16//! went with its guard, so a session that cannot make the handoff it owes
17//! reports nothing and is settled by the turn-end rule — which is the honest
18//! outcome, and what to do about it is operator policy (§3c).
19//!
20//! One duty of this module is attribution rather than constraint. A `tools`
21//! mount names nothing session-scoped — the token in its hello is the binding —
22//! so the task a frame names, the sender it leaves as, and its whole causality
23//! chain are *stamped* from this client's own record of the session that token
24//! names. `onlyne mcp` supplies the recipient, the text, the kind, and the
25//! image, and nothing else (`docs/v2-CONTRACT.md` §3b): a caller that named its
26//! own task or role would be asserting facts only this client's books hold.
27
28use super::state::{DispatchInner, DispatchState, slot_key_named, slot_key_serving_task};
29use super::transport::serves_session;
30use super::*;
31use onlyne_proto::adapter::HandoffArgs;
32use onlyne_proto::{DETAILS_MAX_BYTES, ErrorCode, Report, ResBody};
33
34/// The slot key a connection speaks for, live transport or tools mount alike.
35///
36/// The tools binding and the transport map are the two ways a connection is
37/// tied to a session, and a frame's sender is the connection: resolving the
38/// key here is what lets the relay guard read the session's own delivery
39/// record rather than one the frame claims.
40pub(super) fn session_key_of_connection(inner: &DispatchInner, io: &AdapterIo) -> Option<String> {
41    let named = inner
42        .transports
43        .iter()
44        .find(|(_, (transport, _))| transport.same_connection(io))
45        .map(|(session, _)| session.clone())
46        .or_else(|| {
47            inner
48                .tools_mounts
49                .iter()
50                .find(|(_, bound)| bound.same_connection(io))
51                .map(|(key, _)| key.clone())
52        })?;
53    slot_key_named(inner, &named)
54}
55
56/// A `tools` mount's own bookkeeping, stamped from this client's record.
57///
58/// The scope is [`DispatchState::tools_scope`]'s answer — one locked pass over
59/// the connection's binding and the session's slot — and the stamps below are
60/// pure over it, so a frame cannot be attributed from a state that moved between
61/// the lookup and the write.
62impl DispatchState {
63    /// The one sentence a `tools` mount reads when the session its token names is
64    /// gone.
65    ///
66    /// The handshake and the per-frame gate answer it. The adapter's welcome
67    /// refusal carries a code and a message and has no field slot, so the
68    /// sentence is where the field name goes; the per-frame [`Self::tools_gone`]
69    /// names the same field in the slot it has.
70    pub const TOOLS_GONE_MESSAGE: &'static str = "token names no live session";
71
72    /// The refusal a `tools` frame earns when its connection speaks for no live
73    /// session.
74    ///
75    /// The field is named and the token's own value is not: the token is a
76    /// capability the caller already holds, and a sentence that echoed it would
77    /// put one in a fault a model reads (`AGENTS.md` §8). A session retires
78    /// between one frame's liveness check and its stamping, and both doors read
79    /// one answer because it is one fact.
80    pub fn tools_gone() -> ResBody {
81        ResBody::err(
82            ErrorCode::Unauthorized,
83            Self::TOOLS_GONE_MESSAGE,
84            Some("token".to_string()),
85        )
86    }
87
88    /// Stamp one `report` or `handoff` frame from a `tools` mount with the task
89    /// the session serves.
90    ///
91    /// An empty `task_id` is this path's spelling of "the session's own open
92    /// task" and is replaced with it. A non-empty one that disagrees with the
93    /// session's own binding is refused rather than silently overwritten — a
94    /// caller wrong about the session it speaks for is a bug in the caller, and
95    /// a correction that hides it is the wrong answer. A session holding no open
96    /// task answers `invalid` on the same field, because a completion for a task
97    /// nobody holds cannot be recorded.
98    pub fn stamp_tools_task(
99        &self,
100        io: &AdapterIo,
101        task_id: &mut String,
102    ) -> std::result::Result<(), ResBody> {
103        let Some(scope) = self.tools_scope(io) else {
104            return Err(Self::tools_gone());
105        };
106        let Some(open) = scope.task_id else {
107            return Err(ResBody::err(
108                ErrorCode::Invalid,
109                "this session holds no open task",
110                Some("task_id".to_string()),
111            ));
112        };
113        if task_id.is_empty() {
114            *task_id = open;
115            return Ok(());
116        }
117        if *task_id != open {
118            return Err(ResBody::err(
119                ErrorCode::Forbidden,
120                format!("this session serves task {open}, not {task_id}"),
121                Some("task_id".to_string()),
122            ));
123        }
124        Ok(())
125    }
126
127    /// The sender one `send` frame from a `tools` mount leaves as, and the chain
128    /// that frame starts.
129    ///
130    /// The bridge supplies the recipient, the body, the kind, and the image; the
131    /// role the frame leaves as comes from this client's own record, so nothing
132    /// here is read off the frame or off `welcome`. A session serving no
133    /// delivery is refused: a send it made would belong to no work at all.
134    ///
135    /// `handoff` continues a family and `send` starts one, so a `task` send is a
136    /// **root** — a fresh task id at hop 0, attempt 0, with a fresh `op_id` — and
137    /// nothing of the served delivery's family, budget, origin, or deadline rides
138    /// along. The hop budget belongs to the family, which is why a spent budget
139    /// stops a forward and never new work. A reader who "simplifies" this into
140    /// [`Causality::child_of`] would make a new task spend its sender's hop and
141    /// hand the server an envelope that `plugins/onlyne-agent-pi`'s own
142    /// `sendEnvelope` never mints: two drives, one obligation, two shapes. A
143    /// `note` joins no family: no chain and no `op_id`.
144    pub fn stamp_tools_send(
145        &self,
146        io: &AdapterIo,
147        envelope: &mut Envelope,
148    ) -> std::result::Result<(), ResBody> {
149        let Some(scope) = self.tools_scope(io) else {
150            return Err(Self::tools_gone());
151        };
152        if scope.task_id.is_none() {
153            return Err(ResBody::err(
154                ErrorCode::Invalid,
155                "this session serves no delivery, so a send it makes belongs to no work",
156                None,
157            ));
158        }
159        envelope.from = Principal::role(self.inner.lock().role.clone());
160        if envelope.kind == MsgKind::Task {
161            envelope.op_id = Some(onlyne_proto::new_op_id());
162            envelope.causality = Some(Causality::root(onlyne_proto::new_task_id()));
163        } else {
164            envelope.op_id = None;
165            envelope.causality = None;
166        }
167        Ok(())
168    }
169}
170
171/// Record one delivery a session made, which is the relay guard's evidence.
172///
173/// Any envelope kind counts — a `note` and a `task` are both the session
174/// reaching that role — and a recipient that names no role (a gateway, a
175/// cluster) is not a downstream handoff. The caller records only sends the
176/// client has carried: a refused envelope was never a delivery.
177pub(super) fn record_delivery(inner: &mut DispatchInner, key: &str, to: &Principal) {
178    let Some(role) = to.role_name() else {
179        return;
180    };
181    if let Some(slot) = inner.sessions.get_mut(key) {
182        slot.delivered_roles.insert(role.to_string());
183    }
184}
185
186/// The relay guard's refusal, when the session still owes a delivery.
187///
188/// The obligation is the role's own `owes_targets`: its own declaration,
189/// carried by the handshake (and re-carried by a reload's role row), separate
190/// from the `allowed_targets` the server gates the ACL on. Reach is permission
191/// and never compels a delivery. A session of that role must have delivered to
192/// every downstream name on the list before it may report a terminal outcome,
193/// and a role that declares none owes nothing.
194///
195/// The role this session's task came from is never one of them. The completion
196/// is itself a delivery to that role — the one the ledger books the answer
197/// against — so owing a second one would make the obligation unsatisfiable for
198/// the self-addressed entry `onlyne-client init` prints and about twenty
199/// fixtures restate (`e2e/acp-tools.sh`), and for a ring's return edge
200/// (`e2e/running-lights.sh`). What the exclusion drops is the origin alone, not
201/// the edge: an entry naming a downstream role beside it still owes that one.
202/// An origin this client does not hold as a role — a principal naming none, or
203/// no origin at all — buys no exclusion, which keeps the guard strict wherever
204/// the exclusion cannot be justified.
205///
206/// The refusal names every role still owed and the set the session actually
207/// delivered to. That sentence is what a model reads, and it is the same one
208/// both drives see — the guard is here, where the frame is handled, so the pi
209/// drive and the ACP drive refuse identically.
210fn relay_refusal(inner: &DispatchInner, key: &str) -> Option<String> {
211    let slot = inner.sessions.get(key)?;
212    if inner.required_targets.is_empty() {
213        return None;
214    }
215    let origin = slot.origin.as_ref().and_then(Principal::role_name);
216    let missing: Vec<&str> = inner
217        .required_targets
218        .iter()
219        .filter(|role| {
220            Some(role.as_str()) != origin && !slot.delivered_roles.contains(role.as_str())
221        })
222        .map(String::as_str)
223        .collect();
224    if missing.is_empty() {
225        return None;
226    }
227    let delivered = if slot.delivered_roles.is_empty() {
228        "none".to_string()
229    } else {
230        slot.delivered_roles
231            .iter()
232            .cloned()
233            .collect::<Vec<_>>()
234            .join(", ")
235    };
236    Some(format!(
237        "relay guard: missing handoff to: {} (this session delivered to: {delivered})",
238        missing.join(", "),
239    ))
240}
241
242/// The shape rule one completion carries: `details` inside the cap, and `files`
243/// naming absolute paths.
244fn shape_refusal(report: &Report) -> Option<(String, &'static str)> {
245    let Report::Complete { details, files, .. } = report else {
246        return None;
247    };
248    if let Some(details) = details {
249        if details.len() > DETAILS_MAX_BYTES {
250            return Some((
251                format!("details exceeds the {DETAILS_MAX_BYTES}-byte cap"),
252                "details",
253            ));
254        }
255    }
256    for path in files {
257        if !Path::new(path).is_absolute() {
258            return Some((format!("files must name absolute paths: {path}"), "files"));
259        }
260    }
261    None
262}
263
264impl DispatchState {
265    /// The refusal one incoming completion meets, when a client-side constraint
266    /// refuses it; `None` when the frame may settle.
267    ///
268    /// `from` is the connection the frame arrived on. A completion that arrives
269    /// with no connection — the local operator surface, and the fault a refused
270    /// `focus` files — is measured against the shape rule alone: the relay guard
271    /// reads the session's own delivery record, and a door with no sender has no
272    /// session whose record it could read.
273    pub fn completion_refusal(&self, from: Option<&AdapterIo>, report: &Report) -> Option<ResBody> {
274        if let Some((message, field)) = shape_refusal(report) {
275            return Some(ResBody::err(
276                ErrorCode::Invalid,
277                message,
278                Some(field.to_string()),
279            ));
280        }
281        let Report::Complete { .. } = report else {
282            return None;
283        };
284        let io = from?;
285        let inner = self.inner.lock();
286        let key = session_key_of_connection(&inner, io)?;
287        let message = relay_refusal(&inner, &key)?;
288        Some(ResBody::err(ErrorCode::Invalid, message, None))
289    }
290
291    /// The refusal one `handoff` frame meets when the family's hop budget is
292    /// spent; `None` when the frame may be built.
293    ///
294    /// A child sits one hop below the task that hands it on, and a family's
295    /// `hop_budget` is the depth the chain may reach: the frame that would sit
296    /// over it is refused here and names the budget it would break, rather than
297    /// being minted for the server to sort out. A frame from a connection that
298    /// does not serve the task is left for `plugin_handoff`'s own authority
299    /// answer, so this check reveals nothing to a foreign connection.
300    pub fn handoff_refusal(&self, io: &AdapterIo, args: &HandoffArgs) -> Option<ResBody> {
301        let inner = self.inner.lock();
302        let key = slot_key_serving_task(&inner, &args.task_id)?;
303        if !serves_session(&inner, &key, io) {
304            return None;
305        }
306        hop_refusal(&inner, &key, "handoff")
307    }
308}
309
310/// The hop budget's own refusal for one session, naming the frame it answered.
311///
312/// A child sits one hop below the task its session serves, and a family's
313/// `hop_budget` is the depth the chain may reach: the frame that would sit over
314/// it is refused here and names the budget it would break, rather than being
315/// minted for the server to sort out. Only a `handoff` reaches this door: a
316/// `send` starts a family of its own and spends no hop of this one.
317fn hop_refusal(inner: &DispatchInner, key: &str, what: &str) -> Option<ResBody> {
318    let slot = inner.sessions.get(key)?;
319    let budget = slot.causality.hop_budget?;
320    let next = slot.causality.hop.saturating_add(1);
321    (next > budget).then(|| {
322        ResBody::err(
323            ErrorCode::Invalid,
324            format!("hop budget exhausted: this {what} would sit at hop {next} of {budget}"),
325            None,
326        )
327    })
328}
329
330#[cfg(test)]
331mod tests {
332    use super::shape_refusal;
333    use onlyne_proto::{DETAILS_MAX_BYTES, Outcome, Report};
334
335    /// One completion's shape, at the client's own door.
336    ///
337    /// The plan names this case by hand: "`complete` carrying a `details` body
338    /// over the cap is refused with the cap named, and one at the cap passes"
339    /// (`docs/v2-CONTRACT.md` §3c). The boundary is `>` and not `>=`, so the
340    /// table carries the exact cap and one byte over it — a guard that read
341    /// `>=` would refuse a completion the plan says passes, and a model would be
342    /// told it had written too much when it had written exactly the limit.
343    #[test]
344    fn a_completion_at_the_cap_passes_and_one_byte_over_it_is_refused() {
345        let at_cap = "x".repeat(DETAILS_MAX_BYTES);
346        let over = "x".repeat(DETAILS_MAX_BYTES + 1);
347
348        let at = shape_refusal(&complete(Some(at_cap.clone()), &[]));
349        assert_eq!(at, None, "the cap itself is inside the cap");
350
351        let over = shape_refusal(&complete(Some(over), &[]));
352        let (message, field) = over.expect("one byte over the cap is refused");
353        assert_eq!(field, "details", "the refusal names the field");
354        assert!(
355            message.contains(&DETAILS_MAX_BYTES.to_string()),
356            "the refusal names the cap it measured against, so the model can \
357             count its own bytes: {message}"
358        );
359
360        // No details at all is not a zero-length details: an absent field is
361        // absent, and a cap that read `None` as empty would refuse a completion
362        // that simply reported nothing.
363        assert_eq!(shape_refusal(&complete(None, &[])), None);
364    }
365
366    /// `files` names paths the model is expected to read.
367    ///
368    /// A relative path resolves against whatever the runtime's working directory
369    /// happens to be — a pane, a tab, a child's cwd — so a completion carrying
370    /// one points the next agent at a file that is not there, and the delivery
371    /// reads as a truncated result rather than a wrong one. The refusal quotes
372    /// the offending path so the model can see which of its own entries was the
373    /// problem.
374    #[test]
375    fn a_file_that_is_not_an_absolute_path_is_refused_by_name() {
376        let refused = shape_refusal(&complete(
377            None,
378            &["relative/out.txt".to_string(), "/abs/ok.txt".to_string()],
379        ));
380        let (message, field) = refused.expect("a relative path is refused");
381        assert_eq!(field, "files", "the refusal names the field");
382        assert!(
383            message.contains("relative/out.txt"),
384            "the refusal quotes the path: {message}"
385        );
386
387        assert_eq!(
388            shape_refusal(&complete(None, &["/abs/a.png".to_string()])),
389            None,
390            "an absolute path is the shape the contract asks for"
391        );
392        assert_eq!(
393            shape_refusal(&complete(None, &[])),
394            None,
395            "no files, no rule"
396        );
397    }
398
399    /// The shape rule reads one report, and a report that is not a completion has
400    /// no shape to check.
401    #[test]
402    fn a_report_that_is_not_a_completion_is_never_refused_for_shape() {
403        let ready = Report::Ready {
404            task_id: "t-1".to_string(),
405            session_id: "s-1".to_string(),
406            generation: 1,
407            seq: 7,
408            cluster_ref: None,
409        };
410        assert_eq!(shape_refusal(&ready), None);
411    }
412
413    fn complete(details: Option<String>, files: &[String]) -> Report {
414        Report::Complete {
415            task_id: "t-1".to_string(),
416            outcome: Outcome::Done,
417            head: Some("done".to_string()),
418            details,
419            files: files.to_vec(),
420            reply_to: None,
421            cluster_ref: None,
422        }
423    }
424}