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 `allowed_targets`: the list the server
189/// gates the ACL on, which the handshake carries (and a reload's role row
190/// re-carries). A session of that role must have delivered to every downstream
191/// name on it before it may report a terminal outcome, and a role that declares
192/// no target owes nothing.
193///
194/// The role this session's task came from is never one of them. The completion
195/// is itself a delivery to that role — the one the ledger books the answer
196/// against — so owing a second one would make the obligation unsatisfiable for
197/// the self-addressed entry `onlyne-client init` prints and about twenty
198/// fixtures restate (`e2e/acp-tools.sh`), and for a ring's return edge
199/// (`e2e/running-lights.sh`). What the exclusion drops is the origin alone, not
200/// the edge: an entry naming a downstream role beside it still owes that one.
201/// An origin this client does not hold as a role — a principal naming none, or
202/// no origin at all — buys no exclusion, which keeps the guard strict wherever
203/// the exclusion cannot be justified.
204///
205/// The refusal names every role still owed and the set the session actually
206/// delivered to. That sentence is what a model reads, and it is the same one
207/// both drives see — the guard is here, where the frame is handled, so the pi
208/// drive and the ACP drive refuse identically.
209fn relay_refusal(inner: &DispatchInner, key: &str) -> Option<String> {
210 let slot = inner.sessions.get(key)?;
211 if inner.required_targets.is_empty() {
212 return None;
213 }
214 let origin = slot.origin.as_ref().and_then(Principal::role_name);
215 let missing: Vec<&str> = inner
216 .required_targets
217 .iter()
218 .filter(|role| {
219 Some(role.as_str()) != origin && !slot.delivered_roles.contains(role.as_str())
220 })
221 .map(String::as_str)
222 .collect();
223 if missing.is_empty() {
224 return None;
225 }
226 let delivered = if slot.delivered_roles.is_empty() {
227 "none".to_string()
228 } else {
229 slot.delivered_roles
230 .iter()
231 .cloned()
232 .collect::<Vec<_>>()
233 .join(", ")
234 };
235 Some(format!(
236 "relay guard: missing handoff to: {} (this session delivered to: {delivered})",
237 missing.join(", "),
238 ))
239}
240
241/// The shape rule one completion carries: `details` inside the cap, and `files`
242/// naming absolute paths.
243fn shape_refusal(report: &Report) -> Option<(String, &'static str)> {
244 let Report::Complete { details, files, .. } = report else {
245 return None;
246 };
247 if let Some(details) = details {
248 if details.len() > DETAILS_MAX_BYTES {
249 return Some((
250 format!("details exceeds the {DETAILS_MAX_BYTES}-byte cap"),
251 "details",
252 ));
253 }
254 }
255 for path in files {
256 if !Path::new(path).is_absolute() {
257 return Some((format!("files must name absolute paths: {path}"), "files"));
258 }
259 }
260 None
261}
262
263impl DispatchState {
264 /// The refusal one incoming completion meets, when a client-side constraint
265 /// refuses it; `None` when the frame may settle.
266 ///
267 /// `from` is the connection the frame arrived on. A completion that arrives
268 /// with no connection — the local operator surface, and the fault a refused
269 /// `focus` files — is measured against the shape rule alone: the relay guard
270 /// reads the session's own delivery record, and a door with no sender has no
271 /// session whose record it could read.
272 pub fn completion_refusal(&self, from: Option<&AdapterIo>, report: &Report) -> Option<ResBody> {
273 if let Some((message, field)) = shape_refusal(report) {
274 return Some(ResBody::err(
275 ErrorCode::Invalid,
276 message,
277 Some(field.to_string()),
278 ));
279 }
280 let Report::Complete { .. } = report else {
281 return None;
282 };
283 let io = from?;
284 let inner = self.inner.lock();
285 let key = session_key_of_connection(&inner, io)?;
286 let message = relay_refusal(&inner, &key)?;
287 Some(ResBody::err(ErrorCode::Invalid, message, None))
288 }
289
290 /// The refusal one `handoff` frame meets when the family's hop budget is
291 /// spent; `None` when the frame may be built.
292 ///
293 /// A child sits one hop below the task that hands it on, and a family's
294 /// `hop_budget` is the depth the chain may reach: the frame that would sit
295 /// over it is refused here and names the budget it would break, rather than
296 /// being minted for the server to sort out. A frame from a connection that
297 /// does not serve the task is left for `plugin_handoff`'s own authority
298 /// answer, so this check reveals nothing to a foreign connection.
299 pub fn handoff_refusal(&self, io: &AdapterIo, args: &HandoffArgs) -> Option<ResBody> {
300 let inner = self.inner.lock();
301 let key = slot_key_serving_task(&inner, &args.task_id)?;
302 if !serves_session(&inner, &key, io) {
303 return None;
304 }
305 hop_refusal(&inner, &key, "handoff")
306 }
307}
308
309/// The hop budget's own refusal for one session, naming the frame it answered.
310///
311/// A child sits one hop below the task its session serves, and a family's
312/// `hop_budget` is the depth the chain may reach: the frame that would sit over
313/// it is refused here and names the budget it would break, rather than being
314/// minted for the server to sort out. Only a `handoff` reaches this door: a
315/// `send` starts a family of its own and spends no hop of this one.
316fn hop_refusal(inner: &DispatchInner, key: &str, what: &str) -> Option<ResBody> {
317 let slot = inner.sessions.get(key)?;
318 let budget = slot.causality.hop_budget?;
319 let next = slot.causality.hop.saturating_add(1);
320 (next > budget).then(|| {
321 ResBody::err(
322 ErrorCode::Invalid,
323 format!("hop budget exhausted: this {what} would sit at hop {next} of {budget}"),
324 None,
325 )
326 })
327}
328
329#[cfg(test)]
330mod tests {
331 use super::shape_refusal;
332 use onlyne_proto::{DETAILS_MAX_BYTES, Outcome, Report};
333
334 /// One completion's shape, at the client's own door.
335 ///
336 /// The plan names this case by hand: "`complete` carrying a `details` body
337 /// over the cap is refused with the cap named, and one at the cap passes"
338 /// (`docs/v2-CONTRACT.md` §3c). The boundary is `>` and not `>=`, so the
339 /// table carries the exact cap and one byte over it — a guard that read
340 /// `>=` would refuse a completion the plan says passes, and a model would be
341 /// told it had written too much when it had written exactly the limit.
342 #[test]
343 fn a_completion_at_the_cap_passes_and_one_byte_over_it_is_refused() {
344 let at_cap = "x".repeat(DETAILS_MAX_BYTES);
345 let over = "x".repeat(DETAILS_MAX_BYTES + 1);
346
347 let at = shape_refusal(&complete(Some(at_cap.clone()), &[]));
348 assert_eq!(at, None, "the cap itself is inside the cap");
349
350 let over = shape_refusal(&complete(Some(over), &[]));
351 let (message, field) = over.expect("one byte over the cap is refused");
352 assert_eq!(field, "details", "the refusal names the field");
353 assert!(
354 message.contains(&DETAILS_MAX_BYTES.to_string()),
355 "the refusal names the cap it measured against, so the model can \
356 count its own bytes: {message}"
357 );
358
359 // No details at all is not a zero-length details: an absent field is
360 // absent, and a cap that read `None` as empty would refuse a completion
361 // that simply reported nothing.
362 assert_eq!(shape_refusal(&complete(None, &[])), None);
363 }
364
365 /// `files` names paths the model is expected to read.
366 ///
367 /// A relative path resolves against whatever the runtime's working directory
368 /// happens to be — a pane, a tab, a child's cwd — so a completion carrying
369 /// one points the next agent at a file that is not there, and the delivery
370 /// reads as a truncated result rather than a wrong one. The refusal quotes
371 /// the offending path so the model can see which of its own entries was the
372 /// problem.
373 #[test]
374 fn a_file_that_is_not_an_absolute_path_is_refused_by_name() {
375 let refused = shape_refusal(&complete(
376 None,
377 &["relative/out.txt".to_string(), "/abs/ok.txt".to_string()],
378 ));
379 let (message, field) = refused.expect("a relative path is refused");
380 assert_eq!(field, "files", "the refusal names the field");
381 assert!(
382 message.contains("relative/out.txt"),
383 "the refusal quotes the path: {message}"
384 );
385
386 assert_eq!(
387 shape_refusal(&complete(None, &["/abs/a.png".to_string()])),
388 None,
389 "an absolute path is the shape the contract asks for"
390 );
391 assert_eq!(
392 shape_refusal(&complete(None, &[])),
393 None,
394 "no files, no rule"
395 );
396 }
397
398 /// The shape rule reads one report, and a report that is not a completion has
399 /// no shape to check.
400 #[test]
401 fn a_report_that_is_not_a_completion_is_never_refused_for_shape() {
402 let ready = Report::Ready {
403 task_id: "t-1".to_string(),
404 session_id: "s-1".to_string(),
405 generation: 1,
406 seq: 7,
407 cluster_ref: None,
408 };
409 assert_eq!(shape_refusal(&ready), None);
410 }
411
412 fn complete(details: Option<String>, files: &[String]) -> Report {
413 Report::Complete {
414 task_id: "t-1".to_string(),
415 outcome: Outcome::Done,
416 head: Some("done".to_string()),
417 details,
418 files: files.to_vec(),
419 reply_to: None,
420 cluster_ref: None,
421 }
422 }
423}