mlua_swarm/types.rs
1//! Fundamental types: Role / Verb / RoleVerbGate / CapToken / IDs.
2
3/// The logical Operator role handle (`main-ai`, `phase_a_op`, ...),
4/// re-exported here so it sits alongside [`SessionId`] — the other half of
5/// the operator-login identity pair — despite being defined one layer down.
6///
7/// It has to be defined in `mlua-swarm-schema` rather than in this module:
8/// [`crate::BindRequest`] carries one as `binding_target`, and that struct
9/// lives in the schema crate, which does not (and must not) depend on this
10/// one. Not to be confused with [`Role`] below, which is the closed
11/// authorization enum (`Operator` / `Worker` / `Observer` / `Senior`).
12pub use mlua_swarm_schema::{EmptyOperatorRef, OperatorRef};
13
14use hmac::{Hmac, Mac};
15use serde::{Deserialize, Serialize};
16use sha2::Sha256;
17use std::collections::{HashMap, HashSet};
18use std::time::{Duration, SystemTime, UNIX_EPOCH};
19
20// ─── ID newtypes ───────────────────────────────────────────────────────────
21
22/// Error returned when an ID string does not carry the expected prefix.
23///
24/// Produced by the fallible constructors on the ID newtypes
25/// ([`StepId::parse`], [`TaskId::parse`], ...) and by their serde
26/// `Deserialize` impls (which route through `TryFrom<String>`), so a
27/// misrouted or malformed id fails at the boundary instead of deep inside
28/// a store lookup.
29#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
30#[error("invalid {kind} id `{got}`: expected `{expected}` prefix")]
31pub struct IdParseError {
32 /// Human-readable ID kind (`"step"`, `"task"`, ...).
33 pub kind: &'static str,
34 /// The prefix this ID kind requires, e.g. `ST-`.
35 pub expected: &'static str,
36 /// The rejected input.
37 pub got: String,
38}
39
40/// Defines a prefix-validated ID newtype.
41///
42/// The generated type keeps its inner `String` **private**: the only ways
43/// to obtain a value are `new()` (mint) and `parse()` / `TryFrom<String>` /
44/// `FromStr` (validated) — which is what makes the prefix check impossible
45/// to bypass at call sites. Serde deserialization routes through
46/// `TryFrom<String>` (`#[serde(try_from = "String")]`), and serialization
47/// stays the plain inner string, so the wire format is byte-for-byte
48/// unchanged from the `pub String` era.
49macro_rules! id_newtype {
50 ($(#[$meta:meta])* $name:ident, $prefix:literal, $kind:literal) => {
51 $(#[$meta])*
52 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
53 #[serde(try_from = "String")]
54 pub struct $name(String);
55
56 impl $name {
57 /// The prefix this ID kind carries on the wire.
58 pub const PREFIX: &'static str = $prefix;
59
60 /// Mint a fresh id with the kind prefix and a process-unique
61 /// nonce (see [`uid_hex`]).
62 pub fn new() -> Self {
63 Self(format!(concat!($prefix, "{}"), uid_hex(8)))
64 }
65
66 /// Parse an externally-supplied string, rejecting values that
67 /// do not start with the kind prefix or carry nothing after it.
68 pub fn parse(s: impl Into<String>) -> Result<Self, IdParseError> {
69 let s = s.into();
70 if s.len() > $prefix.len() && s.starts_with($prefix) {
71 Ok(Self(s))
72 } else {
73 Err(IdParseError {
74 kind: $kind,
75 expected: $prefix,
76 got: s,
77 })
78 }
79 }
80
81 /// View the id as a string slice.
82 pub fn as_str(&self) -> &str {
83 &self.0
84 }
85
86 /// Consume the id and return the inner string.
87 pub fn into_string(self) -> String {
88 self.0
89 }
90 }
91
92 impl Default for $name {
93 fn default() -> Self {
94 Self::new()
95 }
96 }
97
98 impl std::fmt::Display for $name {
99 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100 f.write_str(&self.0)
101 }
102 }
103
104 impl TryFrom<String> for $name {
105 type Error = IdParseError;
106 fn try_from(s: String) -> Result<Self, IdParseError> {
107 Self::parse(s)
108 }
109 }
110
111 impl std::str::FromStr for $name {
112 type Err = IdParseError;
113 fn from_str(s: &str) -> Result<Self, IdParseError> {
114 Self::parse(s)
115 }
116 }
117
118 impl AsRef<str> for $name {
119 fn as_ref(&self) -> &str {
120 &self.0
121 }
122 }
123
124 impl From<$name> for String {
125 fn from(id: $name) -> String {
126 id.0
127 }
128 }
129 };
130}
131
132id_newtype!(
133 /// Opaque per-step identifier, e.g. `ST-<hex>`. Newtype over `String` so
134 /// step, session, and worker ids can't be swapped by accident at call
135 /// sites.
136 ///
137 /// One `StepId` is minted per dispatched Blueprint step (the engine's
138 /// dispatcher "spins up a fresh task per `Step.ref`"). It is scoped to a
139 /// single step execution — the whole-kick identity is [`RunId`], and the
140 /// work-item identity is [`TaskId`].
141 ///
142 /// Renamed from `TaskId` (`T-` prefix) in the issue #13 ID-hierarchy
143 /// reconciliation: Blueprint → Task → Run → Step → Attempt.
144 StepId, "ST-", "step"
145);
146
147id_newtype!(
148 /// Opaque work-item identifier, e.g. `T-<hex>`. One `TaskId` names one
149 /// unit of work ("resolve issue #10" + a Blueprint ref + input ctx),
150 /// persisted in the task store. A task can be kicked N times; each kick
151 /// is a [`RunId`].
152 ///
153 /// Not to be confused with [`StepId`] (the per-step id that carried the
154 /// `TaskId` name before issue #13).
155 TaskId, "T-", "task"
156);
157
158id_newtype!(
159 /// Opaque run identifier, e.g. `R-<hex>`. One `RunId` names one kick of a
160 /// [`TaskId`] — minted server-side when a task is started, propagated
161 /// through the engine ctx to every wire frame so steps, workers, and
162 /// outputs correlate back to the run.
163 ///
164 /// The `R-` prefix is reserved for run ids; the engine's resume keys
165 /// moved to `RK-` in issue #14 so the two can't shadow each other under
166 /// prefix validation.
167 RunId, "R-", "run"
168);
169
170id_newtype!(
171 /// Opaque session identifier, e.g. `S-<hex>`. See [`StepId`] for the
172 /// newtype rationale.
173 ///
174 /// This is the one session-id shape across the system (issue #11): the
175 /// engine mints it for attached operator sessions, and the server's
176 /// `POST /v1/operators` login path mints the WS operator `sid` in the
177 /// same shape (the old `op-<uuid>` sid form is retired). A `SessionId`
178 /// is an identifier, not a credential — bearer secrets use `secure_hex`
179 /// tokens.
180 SessionId, "S-", "session"
181);
182
183id_newtype!(
184 /// Opaque worker identifier, e.g. `W-<hex>`. See [`StepId`] for the
185 /// newtype rationale.
186 WorkerId, "W-", "worker"
187);
188
189// ─── Role × Verb ───────────────────────────────────────────────────────────
190
191/// The four participant roles in the swarm. Every [`Verb`] a caller wants to
192/// invoke must be allow-listed for its role in a [`RoleVerbGate`].
193#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
194#[serde(rename_all = "snake_case")]
195pub enum Role {
196 /// Drives task lifecycle: starts tasks, dispatches attempts, reads
197 /// state, manages sessions.
198 Operator,
199 /// Executes a dispatched attempt: fetches its prompt/data, posts a
200 /// result, verifies its own token.
201 Worker,
202 /// Read-only: subscribes to events and reads trace/state without
203 /// mutating anything.
204 Observer,
205 /// Human/oversight role: answers queries, overrides verdicts, and can
206 /// pause/resume the loop or inject a directive.
207 Senior,
208}
209
210/// Every action a participant can request. Grouped by the [`Role`] that
211/// typically performs it (see the `// operator` / `// worker` / ... section
212/// comments below); the grouping is documentation only — actual
213/// authorization is decided by [`RoleVerbGate::is_allowed`].
214#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
215#[serde(rename_all = "snake_case")]
216pub enum Verb {
217 // operator
218 /// Create a new task.
219 StartTask,
220 /// Dispatch (or re-dispatch) an attempt for a task.
221 DispatchAttempt,
222 /// Mint a [`CapToken`] for a worker.
223 MintWorkerToken,
224 /// Read the current state of a task.
225 ReadTaskState,
226 /// Cancel a task.
227 CancelTask,
228 /// Ask a [`Role::Senior`] a question about a task.
229 QuerySenior,
230 /// Mark a task/attempt as passed.
231 MarkPass,
232 /// Mark a task/attempt as blocked.
233 MarkBlocked,
234 /// Attach a session to a task.
235 AttachSession,
236 /// Detach a session from a task.
237 DetachSession,
238 /// Emit a liveness heartbeat.
239 Heartbeat,
240 /// Poll for task progress/completion.
241 PollTask,
242 // worker
243 /// Fetch the rendered prompt for the current attempt.
244 FetchPrompt,
245 /// Fetch task input data.
246 FetchData,
247 /// Post the result of an attempt.
248 PostResult,
249 /// Verify a presented [`CapToken`].
250 VerifyToken,
251 /// Emit intermediate output for observers.
252 EmitOutput,
253 // observer
254 /// Subscribe to the task's event stream.
255 SubscribeEvents,
256 /// Read the accumulated trace of a task.
257 ReadTrace,
258 // senior
259 /// Answer a query raised via [`Verb::QuerySenior`].
260 AnswerQuery,
261 /// Override a previously recorded verdict.
262 OverrideVerdict,
263 /// Pause the dispatch loop.
264 PauseLoop,
265 /// Resume a paused dispatch loop.
266 ResumeLoop,
267 /// Inject a directive into a running task.
268 InjectDirective,
269}
270
271/// Role × Verb gate table. Const-style storage.
272#[derive(Debug, Clone)]
273pub struct RoleVerbGate {
274 table: HashMap<Role, HashSet<Verb>>,
275}
276
277impl RoleVerbGate {
278 /// Build an empty gate (nothing allowed until [`Self::allow`] is called).
279 pub fn new() -> Self {
280 Self {
281 table: HashMap::new(),
282 }
283 }
284
285 /// Allow-list `verbs` for `role`, merging with any existing entries.
286 /// Returns `self` for chained construction (see
287 /// [`default_role_verb_table`]).
288 pub fn allow(mut self, role: Role, verbs: &[Verb]) -> Self {
289 let set = self.table.entry(role).or_default();
290 for v in verbs {
291 set.insert(*v);
292 }
293 self
294 }
295
296 /// Whether `role` is allow-listed to invoke `verb`.
297 pub fn is_allowed(&self, role: Role, verb: Verb) -> bool {
298 self.table
299 .get(&role)
300 .map(|s| s.contains(&verb))
301 .unwrap_or(false)
302 }
303}
304
305impl Default for RoleVerbGate {
306 fn default() -> Self {
307 default_role_verb_table()
308 }
309}
310
311// ─── Verb tables (const slices, swap-out points for future Role splits) ──
312
313/// Verbs an Operator may invoke — covers task lifecycle, session, and
314/// senior interactions.
315pub const OPERATOR_VERBS: &[Verb] = &[
316 Verb::StartTask,
317 Verb::DispatchAttempt,
318 Verb::MintWorkerToken,
319 Verb::ReadTaskState,
320 Verb::CancelTask,
321 Verb::QuerySenior,
322 Verb::MarkPass,
323 Verb::MarkBlocked,
324 Verb::AttachSession,
325 Verb::DetachSession,
326 Verb::Heartbeat,
327 Verb::PollTask,
328];
329
330/// The Worker verbs shared across all workers — the minimum a leaf
331/// needs, with no sub-task spawning. If we introduce
332/// `Role::WorkerLeaf` in the future, that role gets allowed against
333/// this slice.
334pub const WORKER_LEAF_VERBS: &[Verb] = &[
335 Verb::FetchPrompt,
336 Verb::FetchData,
337 Verb::PostResult,
338 Verb::VerifyToken,
339 Verb::EmitOutput,
340];
341
342/// Worker verbs for recursive swarming: sub-task spawn and
343/// observation. When `Role::WorkerSwarm` splits out in the future,
344/// that role gets allowed against `WORKER_LEAF_VERBS` plus this
345/// slice. The safety valves are `EngineCfg.max_spawn_depth` today,
346/// and a task-ownership gate down the line.
347pub const WORKER_SWARM_VERBS: &[Verb] = &[
348 Verb::StartTask,
349 Verb::DispatchAttempt,
350 Verb::ReadTaskState,
351 Verb::PollTask,
352 Verb::CancelTask,
353];
354
355/// Verbs an Observer may invoke — strictly read-only (event subscription
356/// and trace/state reads, no mutation).
357pub const OBSERVER_VERBS: &[Verb] = &[Verb::SubscribeEvents, Verb::ReadTrace, Verb::ReadTaskState];
358
359/// Verbs a Senior may invoke — human/oversight actions: answering
360/// queries, overriding verdicts, and pausing/resuming/injecting into the
361/// dispatch loop.
362pub const SENIOR_VERBS: &[Verb] = &[
363 Verb::AnswerQuery,
364 Verb::OverrideVerdict,
365 Verb::PauseLoop,
366 Verb::ResumeLoop,
367 Verb::InjectDirective,
368];
369
370/// The default Role × Verb table.
371///
372/// Today `Role::Worker` holds both leaf and swarm capabilities. When
373/// we split it into `WorkerLeaf` / `WorkerSwarm` in the future, the
374/// only change needed is swapping the `allow(Role::Worker, ...)` line
375/// here for two lines — the verb slices themselves stay `const` and
376/// get reused as-is.
377pub fn default_role_verb_table() -> RoleVerbGate {
378 RoleVerbGate::new()
379 .allow(Role::Operator, OPERATOR_VERBS)
380 .allow(Role::Worker, WORKER_LEAF_VERBS)
381 .allow(Role::Worker, WORKER_SWARM_VERBS)
382 .allow(Role::Observer, OBSERVER_VERBS)
383 .allow(Role::Senior, SENIOR_VERBS)
384}
385
386// ─── CapToken ──────────────────────────────────────────────────────────────
387
388/// Capability token. `max_uses` picks between OneTime / Session /
389/// Limited.
390///
391/// The `uses_left` counter is **server-side, on `EngineState`**: the
392/// token stays immutable, and the record holds the counter.
393#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
394pub struct CapToken {
395 /// Identifier of the agent this token was minted for.
396 pub agent_id: String,
397 /// The [`Role`] the bearer is authorized to act as.
398 pub role: Role,
399 /// Free-form scope strings (interpretation is caller-defined; `"*"`
400 /// conventionally means unrestricted).
401 pub scopes: Vec<String>,
402 /// Unix timestamp (seconds) when the token was minted.
403 pub issued_at: u64,
404 /// Unix timestamp (seconds) after which the token is expired.
405 pub expire_at: u64,
406 /// Remaining-use budget: `None` = unlimited (session token), `Some(n)`
407 /// = at most `n` uses (one-time when `n == 1`).
408 pub max_uses: Option<u32>,
409 /// Random per-mint value — **secret material** (it rides inside
410 /// `encode()` and the `MSE_TOKEN_NONCE` env). The server-side lookup
411 /// key is [`CapToken::fingerprint`] (its SHA-256), never the nonce
412 /// itself (issue #14).
413 pub nonce: String,
414 /// Hex-encoded HMAC-SHA256 signature over [`CapToken::signing_input`].
415 pub sig_hex: String,
416}
417
418impl CapToken {
419 /// Server-side lookup key for this token: hex SHA-256 of the `nonce`.
420 ///
421 /// The nonce is the token's secret material, so the server never uses
422 /// it directly as a map key or prints it in diagnostics — the
423 /// fingerprint is the loggable identity (issue #14; the sibling
424 /// pattern is the operator login flow's sid / token split). Replaces
425 /// the former `id()` accessor, which returned the raw nonce.
426 pub fn fingerprint(&self) -> String {
427 token_fingerprint(&self.nonce)
428 }
429
430 /// Input for the HMAC signature — the concatenation of every field
431 /// except `sig` itself.
432 pub fn signing_input(&self) -> Vec<u8> {
433 let s = format!(
434 "{}|{:?}|{}|{}|{}|{:?}|{}",
435 self.agent_id,
436 self.role,
437 self.scopes.join(","),
438 self.issued_at,
439 self.expire_at,
440 self.max_uses,
441 self.nonce,
442 );
443 s.into_bytes()
444 }
445
446 /// Whether `now_unix` is at or past [`CapToken::expire_at`].
447 pub fn is_expired(&self, now_unix: u64) -> bool {
448 now_unix >= self.expire_at
449 }
450
451 /// Transport-safe string encoding — URL-safe base64 of the
452 /// `serde_json` representation. Used when SubAgents put the token
453 /// on the HTTP path via `Authorization: Bearer <encode()>`. The
454 /// HMAC signature covers every field, so the server verifies with
455 /// `verify_sig` after decoding.
456 pub fn encode(&self) -> String {
457 use base64::Engine as _;
458 let json = serde_json::to_vec(self).expect("CapToken is always JSON-serializable");
459 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json)
460 }
461
462 /// The inverse of `encode()`: base64 decode followed by JSON
463 /// parse. Either failure returns `CapTokenDecodeError` — this is
464 /// the input-validation step when the server receives a `Bearer`
465 /// token.
466 pub fn decode(s: &str) -> Result<Self, CapTokenDecodeError> {
467 use base64::Engine as _;
468 let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
469 .decode(s)
470 .map_err(|e| CapTokenDecodeError::Base64(e.to_string()))?;
471 serde_json::from_slice(&bytes).map_err(|e| CapTokenDecodeError::Json(e.to_string()))
472 }
473}
474
475/// Response body for `HTTP /v1/worker/prompt` — the shape that lets a
476/// SubAgent pull its task input in a single round-trip.
477///
478/// - `system`: the rendered `AgentDef.profile.system_prompt` (`None`
479/// when the profile is absent, or when it was baked but is delivered
480/// by reference instead — see `system_ref` below).
481/// - `prompt`: `TaskSpec.initial_directive` rendered to `String` at
482/// this boundary (issue #18). The engine stores
483/// `initial_directive` as `Value` end-to-end
484/// (`EngineState.prompts` / `Engine::fetch_prompt`); the coercion
485/// to `String` (strings verbatim, anything else serde-stringified)
486/// happens here in `Engine::fetch_worker_payload*` because the
487/// `/v1/worker/prompt` HTTP wire format is a plain string.
488/// - `agent`: `TaskSpec.agent` — the agent name this dispatch is
489/// targeting.
490/// - `attempt`: the 1-based attempt number, matching the current
491/// `task.attempt`.
492/// - `context`: GH #20 Contract C — the materialized
493/// [`crate::core::agent_context::AgentContextView`] for this
494/// `(task_id, attempt)`, when `AgentContextMiddleware` was layered onto
495/// the spawner stack that dispatched it. `None` on pre-#20 payloads
496/// (backward compat) and whenever the middleware was never layered.
497/// - `system_ref`: GH #31 — populated instead of `system` when the baked
498/// `system_prompt` exceeds the server's configured size threshold. See
499/// [`SystemRef`].
500#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
501pub struct WorkerPayload {
502 /// The task this payload was fetched for. Typed [`StepId`] since issue
503 /// #14 — serde keeps the wire shape a plain string.
504 #[schemars(with = "String")]
505 pub task_id: StepId,
506 /// 1-based attempt number, matching the current `task.attempt`.
507 pub attempt: u32,
508 /// Name of the agent this dispatch is targeting.
509 pub agent: String,
510 /// Rendered system prompt, if the agent profile defines one.
511 #[serde(skip_serializing_if = "Option::is_none")]
512 pub system: Option<String>,
513 /// The task's initial directive, baked in at dispatch preparation.
514 pub prompt: String,
515 /// GH #20 Contract C: the materialized task-level context view — see
516 /// the struct doc above.
517 #[serde(default, skip_serializing_if = "Option::is_none")]
518 pub context: Option<crate::core::agent_context::AgentContextView>,
519 /// GH #31: by-reference alternative to `system` when the baked
520 /// `system_prompt` exceeds the server's `SystemRefConfig.threshold_bytes`.
521 /// Exactly one of `system` / `system_ref` is ever `Some` when a
522 /// `system_prompt` was baked for this dispatch — never both `Some`,
523 /// never both `None` in that case. Absent (both `None`) only when no
524 /// `system_prompt` was baked at all (pre-existing `system: None`
525 /// semantics, unchanged).
526 #[serde(default, skip_serializing_if = "Option::is_none")]
527 pub system_ref: Option<SystemRef>,
528}
529
530/// GH #31: a by-reference pointer to a baked `system_prompt` too large to
531/// inline into `WorkerPayload.system` — the fetch-time alternative chosen
532/// by `Engine::fetch_worker_payload{,_trusted}` once the rendered string
533/// exceeds `SystemRefConfig.threshold_bytes`. Carries enough to let a
534/// SubAgent (or any caller re-emitting this payload) fetch and verify the
535/// referenced content without re-deriving it from engine state.
536#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
537pub struct SystemRef {
538 /// Scheme-qualified location of the full `system_prompt` body.
539 /// `mode: File` carries a `file://<path>` URI (the exact path
540 /// `SystemRefMode::File` wrote to). `mode: Http` carries only the
541 /// **path** portion the engine can construct on its own
542 /// (`/v1/worker/prompt/system?task_id=<id>&attempt=<n>`) — see
543 /// [`SystemRefMode::Http`]'s doc for why the engine cannot fill in
544 /// scheme/host itself, and who is responsible for doing so.
545 pub uri: String,
546 /// Lowercase hex-encoded SHA-256 digest of the full referenced
547 /// `system_prompt` string (the same bytes `size_bytes` measures),
548 /// letting a fetcher verify the content it retrieves from `uri`
549 /// matches what was baked at dispatch time.
550 pub sha256: String,
551 /// Byte length of the referenced `system_prompt` content itself (not
552 /// of `uri`, not of any wrapper/envelope around it).
553 pub size_bytes: u64,
554 /// Which delivery mechanism `uri` uses — see [`SystemRefMode`].
555 pub mode: SystemRefMode,
556}
557
558/// GH #31: where a [`SystemRef`]'s content is actually served from.
559#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
560#[serde(rename_all = "snake_case")]
561pub enum SystemRefMode {
562 /// Content is served live by the HTTP surface at `SystemRef.uri`,
563 /// backed by the unchanged `EngineState.systems` map — no persisted
564 /// storage is created for this mode. The engine itself only
565 /// constructs the **path** portion of `uri`
566 /// (`/v1/worker/prompt/system?task_id=<id>&attempt=<n>`): it has no
567 /// knowledge of scheme/host at fetch time, so the HTTP-layer caller
568 /// that re-emits this `SystemRef` (the route handler serving
569 /// `worker_prompt`) is responsible for prefixing `scheme://host` if a
570 /// fully-qualified URI is desired.
571 Http,
572 /// Content was written once, as a side effect of the fetch that
573 /// produced this `SystemRef`, to a local file under
574 /// `SystemRefConfig.store_dir`; `SystemRef.uri` is that file's
575 /// `file://<path>` URI. Re-fetching the same `(task_id, attempt)`
576 /// re-writes the same bytes to the same path — harmless in practice
577 /// (SubAgents fetch their prompt once per attempt) but not
578 /// deduplicated.
579 File,
580}
581
582/// Error returned when `CapToken::decode` fails.
583#[derive(Debug, thiserror::Error)]
584pub enum CapTokenDecodeError {
585 /// The input was not valid URL-safe base64.
586 #[error("base64 decode failed: {0}")]
587 Base64(String),
588 /// The decoded bytes were not valid `CapToken` JSON.
589 #[error("json parse failed: {0}")]
590 Json(String),
591}
592
593/// Server-side machinery for minting and verifying tokens.
594#[derive(Debug, Clone)]
595pub struct TokenSigner {
596 secret: Vec<u8>,
597}
598
599impl TokenSigner {
600 /// Build a signer from a raw HMAC secret (any length; HMAC accepts it).
601 pub fn new(secret: impl AsRef<[u8]>) -> Self {
602 Self {
603 secret: secret.as_ref().to_vec(),
604 }
605 }
606
607 /// Mint and sign a [`CapToken`] with an explicit `max_uses` policy.
608 /// Prefer [`Self::one_time`] / [`Self::session`] / [`Self::limited`]
609 /// for the common cases.
610 pub fn mint(
611 &self,
612 agent_id: impl Into<String>,
613 role: Role,
614 scopes: Vec<String>,
615 ttl: Duration,
616 max_uses: Option<u32>,
617 ) -> CapToken {
618 let now = now_unix();
619 let mut token = CapToken {
620 agent_id: agent_id.into(),
621 role,
622 scopes,
623 issued_at: now,
624 expire_at: now + ttl.as_secs(),
625 max_uses,
626 nonce: secure_hex(16),
627 sig_hex: String::new(),
628 };
629 let mut mac =
630 Hmac::<Sha256>::new_from_slice(&self.secret).expect("HMAC accepts any key length");
631 mac.update(&token.signing_input());
632 let sig = mac.finalize().into_bytes();
633 token.sig_hex = hex::encode(sig);
634 token
635 }
636
637 /// HMAC sig verify (constant-time eq for timing side-channel resistance).
638 pub fn verify_sig(&self, token: &CapToken) -> bool {
639 let mut mac =
640 Hmac::<Sha256>::new_from_slice(&self.secret).expect("HMAC accepts any key length");
641 mac.update(&token.signing_input());
642 let expected = mac.finalize().into_bytes();
643 let Ok(provided) = hex::decode(&token.sig_hex) else {
644 return false;
645 };
646 ct_eq(&expected, &provided)
647 }
648
649 /// Builder convenience: one-time token.
650 pub fn one_time(
651 &self,
652 agent_id: impl Into<String>,
653 role: Role,
654 scopes: Vec<String>,
655 ttl: Duration,
656 ) -> CapToken {
657 self.mint(agent_id, role, scopes, ttl, Some(1))
658 }
659
660 /// Builder convenience: session token (unlimited uses until expire).
661 pub fn session(
662 &self,
663 agent_id: impl Into<String>,
664 role: Role,
665 scopes: Vec<String>,
666 ttl: Duration,
667 ) -> CapToken {
668 self.mint(agent_id, role, scopes, ttl, None)
669 }
670
671 /// Builder convenience: limited (N uses).
672 pub fn limited(
673 &self,
674 agent_id: impl Into<String>,
675 role: Role,
676 scopes: Vec<String>,
677 ttl: Duration,
678 max_uses: u32,
679 ) -> CapToken {
680 self.mint(agent_id, role, scopes, ttl, Some(max_uses))
681 }
682}
683
684// ─── helpers ───────────────────────────────────────────────────────────────
685
686pub(crate) fn now_unix() -> u64 {
687 // A clock reporting before the epoch means the host clock is broken in a
688 // way that would otherwise silently mint `issued_at: 0` / `expire_at: 0`
689 // tokens (indistinguishable from "already expired" *and* from "minted at
690 // the epoch") — fail loud instead of laundering that into a bogus
691 // timestamp.
692 SystemTime::now()
693 .duration_since(UNIX_EPOCH)
694 .expect("system clock is before UNIX_EPOCH")
695 .as_secs()
696}
697
698/// In-process-unique, restart-decorrelated hex id.
699///
700/// Combines a monotonic per-process counter (bijective — guarantees no two
701/// calls in the same process ever collide) with a random per-process salt
702/// drawn once from the OS RNG (decorrelates ids across restarts, so a
703/// long-lived id from a previous process run can't be mistaken for one
704/// minted by the current process). The high bits of the 128-bit XOR are
705/// dominated by the salt (a process fingerprint); the low bits change on
706/// every call.
707///
708/// **Not unguessable.** The counter is a public, low-entropy sequence once
709/// the salt leaks (e.g. via any single id from this process) — never use
710/// this for bearer credentials, signing nonces, or anything else that must
711/// resist an adversary who can observe some ids and guess others. Use
712/// [`secure_hex`] for that.
713pub fn uid_hex(bytes: usize) -> String {
714 use std::sync::atomic::{AtomicU64, Ordering};
715 use std::sync::OnceLock;
716 static COUNTER: AtomicU64 = AtomicU64::new(0);
717 static SALT: OnceLock<u128> = OnceLock::new();
718 let salt = *SALT.get_or_init(|| {
719 let mut b = [0u8; 16];
720 getrandom::fill(&mut b).expect("OS RNG unavailable");
721 u128::from_le_bytes(b)
722 });
723 let c = COUNTER.fetch_add(1, Ordering::Relaxed) as u128;
724 // XOR keeps the counter's in-process uniqueness (bijection) while the
725 // per-process random salt decorrelates restarts. High 64 bits are pure
726 // salt (a process fingerprint); low bits change every call.
727 let v = salt ^ c;
728 let raw = format!("{:032x}", v);
729 let n = (bytes * 2).min(32);
730 raw[32 - n..].to_string()
731}
732
733/// OS-RNG hex, safe for bearer credentials.
734///
735/// Every byte comes from the OS random source (`getrandom`) on every call —
736/// unpredictable across calls *and* across process restarts, unlike
737/// [`uid_hex`]. Use this whenever the value itself is the secret: the
738/// [`CapToken`] nonce (its server-side lookup key and part of the signed
739/// material) and worker/session bearer handles.
740pub fn secure_hex(bytes: usize) -> String {
741 let mut buf = vec![0u8; bytes];
742 getrandom::fill(&mut buf).expect("OS RNG unavailable");
743 hex::encode(buf)
744}
745
746/// Number of OS-random bytes behind an Operator login bearer token
747/// ([`operator_bearer_token`]).
748///
749/// 16 bytes = 128 bits. Sized against *offline* attack rather than online
750/// guessing: the token is only ever stored as `hex(SHA-256(bearer))` (see
751/// [`crate::store::operator_session::OperatorSessionRecord`]), and SHA-256
752/// is fast, so the digest's resistance is bounded by the bearer's own
753/// entropy. At the 40 bits an earlier 5-byte token carried, a digest is
754/// recoverable by brute force and would have been little better than
755/// storing the plaintext; 128 bits puts that out of reach.
756pub const OPERATOR_BEARER_TOKEN_BYTES: usize = 16;
757
758/// Mint an Operator login bearer token — [`secure_hex`] at
759/// [`OPERATOR_BEARER_TOKEN_BYTES`].
760///
761/// Named rather than inlined so the entropy choice lives in one place with
762/// its rationale, next to the digest-at-rest decision that depends on it.
763pub fn operator_bearer_token() -> String {
764 secure_hex(OPERATOR_BEARER_TOKEN_BYTES)
765}
766
767/// Hex SHA-256 of a token nonce / bearer string — the lookup-key shape
768/// used by [`CapToken::fingerprint`]. Standalone so callers holding only
769/// the bearer string (not a decoded token) can derive the same key.
770pub fn token_fingerprint(nonce: &str) -> String {
771 use sha2::Digest as _;
772 hex::encode(Sha256::digest(nonce.as_bytes()))
773}
774
775/// Constant-time byte-slice equality (XOR accumulate). Public so bearer
776/// comparisons outside this module (e.g. the operator login token check)
777/// can avoid the timing side channel of `==`.
778pub fn ct_eq(a: &[u8], b: &[u8]) -> bool {
779 if a.len() != b.len() {
780 return false;
781 }
782 let mut diff: u8 = 0;
783 for (x, y) in a.iter().zip(b.iter()) {
784 diff |= x ^ y;
785 }
786 diff == 0
787}
788
789#[cfg(test)]
790mod id_newtype_tests {
791 use super::*;
792
793 #[test]
794 fn parse_accepts_prefixed_ids() {
795 assert_eq!(StepId::parse("ST-abc123").unwrap().as_str(), "ST-abc123");
796 assert_eq!(TaskId::parse("T-abc123").unwrap().as_str(), "T-abc123");
797 assert_eq!(RunId::parse("R-abc123").unwrap().as_str(), "R-abc123");
798 assert_eq!(SessionId::parse("S-abc123").unwrap().as_str(), "S-abc123");
799 assert_eq!(WorkerId::parse("W-abc123").unwrap().as_str(), "W-abc123");
800 }
801
802 #[test]
803 fn parse_rejects_wrong_prefix_and_empty_suffix() {
804 // Wrong prefix (including another kind's prefix).
805 assert!(StepId::parse("T-abc").is_err());
806 assert!(TaskId::parse("ST-abc").is_err());
807 assert!(
808 RunId::parse("RK-abc").is_err(),
809 "resume keys are not run ids"
810 );
811 assert!(SessionId::parse("W-abc").is_err());
812 assert!(WorkerId::parse("S-abc").is_err());
813 // Case-sensitive.
814 assert!(StepId::parse("st-abc").is_err());
815 // Prefix alone (nothing after it).
816 assert!(TaskId::parse("T-").is_err());
817 // Garbage / empty.
818 assert!(RunId::parse("nope").is_err());
819 assert!(WorkerId::parse("").is_err());
820 }
821
822 #[test]
823 fn parse_error_carries_kind_prefix_and_input() {
824 let err = TaskId::parse("R-xyz").unwrap_err();
825 assert_eq!(err.kind, "task");
826 assert_eq!(err.expected, "T-");
827 assert_eq!(err.got, "R-xyz");
828 assert_eq!(
829 err.to_string(),
830 "invalid task id `R-xyz`: expected `T-` prefix"
831 );
832 }
833
834 #[test]
835 fn minted_ids_round_trip_through_parse() {
836 assert!(StepId::parse(StepId::new().into_string()).is_ok());
837 assert!(TaskId::parse(TaskId::new().into_string()).is_ok());
838 assert!(RunId::parse(RunId::new().into_string()).is_ok());
839 assert!(SessionId::parse(SessionId::new().into_string()).is_ok());
840 assert!(WorkerId::parse(WorkerId::new().into_string()).is_ok());
841 }
842
843 #[test]
844 fn serde_wire_format_is_a_plain_string() {
845 let id = TaskId::parse("T-abc").unwrap();
846 assert_eq!(
847 serde_json::to_value(&id).unwrap(),
848 serde_json::json!("T-abc")
849 );
850 let back: TaskId = serde_json::from_value(serde_json::json!("T-abc")).unwrap();
851 assert_eq!(back, id);
852 }
853
854 #[test]
855 fn serde_deserialize_validates_prefix() {
856 let err = serde_json::from_value::<TaskId>(serde_json::json!("ST-abc"));
857 assert!(err.is_err(), "deserialize must route through parse");
858 }
859}
860
861#[cfg(test)]
862mod cap_token_fingerprint_tests {
863 use super::*;
864 use std::time::Duration;
865
866 #[test]
867 fn fingerprint_is_sha256_of_nonce_and_not_the_nonce() {
868 let signer = TokenSigner::new("test-secret");
869 let token = signer.session("a", Role::Worker, vec!["*".into()], Duration::from_secs(60));
870 let fp = token.fingerprint();
871 // 32-byte SHA-256, hex-encoded.
872 assert_eq!(fp.len(), 64);
873 // The lookup key must never equal (or contain) the secret nonce.
874 assert_ne!(fp, token.nonce);
875 assert!(!fp.contains(&token.nonce));
876 // Standalone helper derives the same key from the bare bearer string.
877 assert_eq!(fp, token_fingerprint(&token.nonce));
878 // Deterministic per token, distinct across mints.
879 assert_eq!(fp, token.fingerprint());
880 let other = signer.session("a", Role::Worker, vec!["*".into()], Duration::from_secs(60));
881 assert_ne!(fp, other.fingerprint());
882 }
883}
884
885#[cfg(test)]
886mod cap_token_transport_tests {
887 use super::*;
888 use std::time::Duration;
889
890 #[test]
891 fn encode_decode_round_trips() {
892 let signer = TokenSigner::new("test-secret");
893 let token = signer.session(
894 "worker-of-task-x",
895 Role::Worker,
896 vec!["*".into()],
897 Duration::from_secs(600),
898 );
899 let s = token.encode();
900 // URL-safe base64 should not contain `+` `/` `=`
901 assert!(!s.contains('+'));
902 assert!(!s.contains('/'));
903 assert!(!s.contains('='));
904
905 let decoded = CapToken::decode(&s).expect("decode ok");
906 assert_eq!(decoded, token);
907 assert!(
908 signer.verify_sig(&decoded),
909 "HMAC sig still verifies after round-trip"
910 );
911 }
912
913 #[test]
914 fn decode_rejects_garbage() {
915 let err = CapToken::decode("not-base64!!!").expect_err("should fail");
916 assert!(matches!(err, CapTokenDecodeError::Base64(_)));
917 }
918
919 #[test]
920 fn decode_rejects_non_token_json() {
921 use base64::Engine as _;
922 let bogus = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"{\"oops\":1}");
923 let err = CapToken::decode(&bogus).expect_err("should fail json shape");
924 assert!(matches!(err, CapTokenDecodeError::Json(_)));
925 }
926}
927
928// GH #20 Contract C: `WorkerPayload.context` backcompat round-trip.
929#[cfg(test)]
930mod worker_payload_context_tests {
931 use super::*;
932
933 #[test]
934 fn legacy_json_without_context_deserializes_to_none() {
935 // Shape a pre-#20 WorkerPayload would have serialized (no
936 // `context` key at all).
937 let legacy = serde_json::json!({
938 "task_id": "ST-1",
939 "attempt": 1,
940 "agent": "planner",
941 "prompt": "do the thing",
942 });
943 let payload: WorkerPayload =
944 serde_json::from_value(legacy).expect("legacy shape must deserialize");
945 assert!(payload.context.is_none());
946 }
947
948 #[test]
949 fn context_none_serializes_with_key_absent() {
950 let payload = WorkerPayload {
951 task_id: StepId::parse("ST-1").unwrap(),
952 attempt: 1,
953 agent: "planner".to_string(),
954 system: None,
955 prompt: "do the thing".to_string(),
956 context: None,
957 system_ref: None,
958 };
959 let json = serde_json::to_value(&payload).unwrap();
960 assert!(
961 json.as_object().unwrap().get("context").is_none(),
962 "context: None must not appear in the serialized object"
963 );
964 }
965
966 #[test]
967 fn context_some_round_trips() {
968 let view = crate::core::agent_context::AgentContextView::default();
969 let payload = WorkerPayload {
970 task_id: StepId::parse("ST-1").unwrap(),
971 attempt: 1,
972 agent: "planner".to_string(),
973 system: None,
974 prompt: "do the thing".to_string(),
975 context: Some(view.clone()),
976 system_ref: None,
977 };
978 let json = serde_json::to_value(&payload).unwrap();
979 let round_tripped: WorkerPayload = serde_json::from_value(json).unwrap();
980 assert_eq!(round_tripped.context, Some(view));
981 }
982}
983
984// GH #31: `WorkerPayload.system_ref` / `SystemRef` / `SystemRefMode` shape
985// round-trip and backcompat.
986#[cfg(test)]
987mod system_ref_tests {
988 use super::*;
989
990 #[test]
991 fn legacy_json_without_system_ref_deserializes_to_none() {
992 // Shape a pre-#31 WorkerPayload would have serialized (no
993 // `system_ref` key at all).
994 let legacy = serde_json::json!({
995 "task_id": "ST-1",
996 "attempt": 1,
997 "agent": "planner",
998 "prompt": "do the thing",
999 });
1000 let payload: WorkerPayload =
1001 serde_json::from_value(legacy).expect("legacy shape must deserialize");
1002 assert!(payload.system_ref.is_none());
1003 }
1004
1005 #[test]
1006 fn system_ref_none_serializes_with_key_absent() {
1007 let payload = WorkerPayload {
1008 task_id: StepId::parse("ST-1").unwrap(),
1009 attempt: 1,
1010 agent: "planner".to_string(),
1011 system: Some("small prompt".to_string()),
1012 prompt: "do the thing".to_string(),
1013 context: None,
1014 system_ref: None,
1015 };
1016 let json = serde_json::to_value(&payload).unwrap();
1017 assert!(
1018 json.as_object().unwrap().get("system_ref").is_none(),
1019 "system_ref: None must not appear in the serialized object"
1020 );
1021 }
1022
1023 #[test]
1024 fn system_ref_some_round_trips_and_excludes_system() {
1025 let system_ref = SystemRef {
1026 uri: "file:///tmp/mse-system-ref/ST-1-1.md".to_string(),
1027 sha256: "a".repeat(64),
1028 size_bytes: 30_000,
1029 mode: SystemRefMode::File,
1030 };
1031 let payload = WorkerPayload {
1032 task_id: StepId::parse("ST-1").unwrap(),
1033 attempt: 1,
1034 agent: "planner".to_string(),
1035 system: None,
1036 prompt: "do the thing".to_string(),
1037 context: None,
1038 system_ref: Some(system_ref.clone()),
1039 };
1040 let json = serde_json::to_value(&payload).unwrap();
1041 assert!(
1042 json.as_object().unwrap().get("system").is_none(),
1043 "system: None must not appear in the serialized object"
1044 );
1045 let round_tripped: WorkerPayload = serde_json::from_value(json).unwrap();
1046 assert_eq!(round_tripped.system_ref, Some(system_ref));
1047 assert!(round_tripped.system.is_none());
1048 }
1049
1050 #[test]
1051 fn system_ref_mode_serializes_snake_case() {
1052 assert_eq!(
1053 serde_json::to_value(SystemRefMode::Http).unwrap(),
1054 serde_json::json!("http")
1055 );
1056 assert_eq!(
1057 serde_json::to_value(SystemRefMode::File).unwrap(),
1058 serde_json::json!("file")
1059 );
1060 }
1061}