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}