Skip to main content

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/// In the layered credential model (`mse://guides/auth-token-model`) this
392/// is the worker's **L1 identity + L2 capability in one object**: the
393/// server learns *which* dispatch/agent is calling from `agent_id`, and
394/// what it may do from `role` × `scopes`. Both are minted at dispatch and
395/// die with the token's TTL, which is why they share one artifact. It is
396/// **not** the L0 perimeter credential (the access token in
397/// `X-MSE-Access-Token`) — that layer says nothing about identity.
398///
399/// The `uses_left` counter is **server-side, on `EngineState`**: the
400/// token stays immutable, and the record holds the counter.
401#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
402pub struct CapToken {
403    /// Identifier of the agent this token was minted for.
404    pub agent_id: String,
405    /// The [`Role`] the bearer is authorized to act as.
406    pub role: Role,
407    /// Free-form scope strings (interpretation is caller-defined; `"*"`
408    /// conventionally means unrestricted).
409    pub scopes: Vec<String>,
410    /// Unix timestamp (seconds) when the token was minted.
411    pub issued_at: u64,
412    /// Unix timestamp (seconds) after which the token is expired.
413    pub expire_at: u64,
414    /// Remaining-use budget: `None` = unlimited (session token), `Some(n)`
415    /// = at most `n` uses (one-time when `n == 1`).
416    pub max_uses: Option<u32>,
417    /// Random per-mint value — **secret material** (it rides inside
418    /// `encode()` and the `MSE_TOKEN_NONCE` env). The server-side lookup
419    /// key is [`CapToken::fingerprint`] (its SHA-256), never the nonce
420    /// itself (issue #14).
421    pub nonce: String,
422    /// Hex-encoded HMAC-SHA256 signature over [`CapToken::signing_input`].
423    pub sig_hex: String,
424}
425
426impl CapToken {
427    /// Server-side lookup key for this token: hex SHA-256 of the `nonce`.
428    ///
429    /// The nonce is the token's secret material, so the server never uses
430    /// it directly as a map key or prints it in diagnostics — the
431    /// fingerprint is the loggable identity (issue #14; the sibling
432    /// pattern is the operator login flow's sid / token split). Replaces
433    /// the former `id()` accessor, which returned the raw nonce.
434    pub fn fingerprint(&self) -> String {
435        token_fingerprint(&self.nonce)
436    }
437
438    /// Input for the HMAC signature — the concatenation of every field
439    /// except `sig` itself.
440    pub fn signing_input(&self) -> Vec<u8> {
441        let s = format!(
442            "{}|{:?}|{}|{}|{}|{:?}|{}",
443            self.agent_id,
444            self.role,
445            self.scopes.join(","),
446            self.issued_at,
447            self.expire_at,
448            self.max_uses,
449            self.nonce,
450        );
451        s.into_bytes()
452    }
453
454    /// Whether `now_unix` is at or past [`CapToken::expire_at`].
455    pub fn is_expired(&self, now_unix: u64) -> bool {
456        now_unix >= self.expire_at
457    }
458
459    /// Transport-safe string encoding — URL-safe base64 of the
460    /// `serde_json` representation. Used when SubAgents put the token
461    /// on the HTTP path via `Authorization: Bearer <encode()>`. The
462    /// HMAC signature covers every field, so the server verifies with
463    /// `verify_sig` after decoding.
464    pub fn encode(&self) -> String {
465        use base64::Engine as _;
466        let json = serde_json::to_vec(self).expect("CapToken is always JSON-serializable");
467        base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json)
468    }
469
470    /// The inverse of `encode()`: base64 decode followed by JSON
471    /// parse. Either failure returns `CapTokenDecodeError` — this is
472    /// the input-validation step when the server receives a `Bearer`
473    /// token.
474    pub fn decode(s: &str) -> Result<Self, CapTokenDecodeError> {
475        use base64::Engine as _;
476        let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
477            .decode(s)
478            .map_err(|e| CapTokenDecodeError::Base64(e.to_string()))?;
479        serde_json::from_slice(&bytes).map_err(|e| CapTokenDecodeError::Json(e.to_string()))
480    }
481}
482
483/// Response body for `HTTP /v1/worker/prompt` — the shape that lets a
484/// SubAgent pull its task input in a single round-trip.
485///
486/// - `system`: the rendered `AgentDef.profile.system_prompt` (`None`
487///   when the profile is absent, or when it was baked but is delivered
488///   by reference instead — see `system_ref` below).
489/// - `prompt`: `TaskSpec.initial_directive` rendered to `String` at
490///   this boundary (issue #18). The engine stores
491///   `initial_directive` as `Value` end-to-end
492///   (`EngineState.prompts` / `Engine::fetch_prompt`); the coercion
493///   to `String` (strings verbatim, anything else serde-stringified)
494///   happens here in `Engine::fetch_worker_payload*` because the
495///   `/v1/worker/prompt` HTTP wire format is a plain string.
496/// - `agent`: `TaskSpec.agent` — the agent name this dispatch is
497///   targeting.
498/// - `attempt`: the 1-based attempt number, matching the current
499///   `task.attempt`.
500/// - `context`: GH #20 Contract C — the materialized
501///   [`crate::core::agent_context::AgentContextView`] for this
502///   `(task_id, attempt)`, when `AgentContextMiddleware` was layered onto
503///   the spawner stack that dispatched it. `None` on pre-#20 payloads
504///   (backward compat) and whenever the middleware was never layered.
505/// - `system_ref`: GH #31 — populated instead of `system` when the baked
506///   `system_prompt` exceeds the server's configured size threshold. See
507///   [`SystemRef`].
508#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
509pub struct WorkerPayload {
510    /// The task this payload was fetched for. Typed [`StepId`] since issue
511    /// #14 — serde keeps the wire shape a plain string.
512    #[schemars(with = "String")]
513    pub task_id: StepId,
514    /// 1-based attempt number, matching the current `task.attempt`.
515    pub attempt: u32,
516    /// Name of the agent this dispatch is targeting.
517    pub agent: String,
518    /// Rendered system prompt, if the agent profile defines one.
519    #[serde(skip_serializing_if = "Option::is_none")]
520    pub system: Option<String>,
521    /// The task's initial directive, baked in at dispatch preparation.
522    pub prompt: String,
523    /// GH #20 Contract C: the materialized task-level context view — see
524    /// the struct doc above.
525    #[serde(default, skip_serializing_if = "Option::is_none")]
526    pub context: Option<crate::core::agent_context::AgentContextView>,
527    /// GH #31: by-reference alternative to `system` when the baked
528    /// `system_prompt` exceeds the server's `SystemRefConfig.threshold_bytes`.
529    /// Exactly one of `system` / `system_ref` is ever `Some` when a
530    /// `system_prompt` was baked for this dispatch — never both `Some`,
531    /// never both `None` in that case. Absent (both `None`) only when no
532    /// `system_prompt` was baked at all (pre-existing `system: None`
533    /// semantics, unchanged).
534    #[serde(default, skip_serializing_if = "Option::is_none")]
535    pub system_ref: Option<SystemRef>,
536}
537
538/// GH #31: a by-reference pointer to a baked `system_prompt` too large to
539/// inline into `WorkerPayload.system` — the fetch-time alternative chosen
540/// by `Engine::fetch_worker_payload{,_trusted}` once the rendered string
541/// exceeds `SystemRefConfig.threshold_bytes`. Carries enough to let a
542/// SubAgent (or any caller re-emitting this payload) fetch and verify the
543/// referenced content without re-deriving it from engine state.
544#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
545pub struct SystemRef {
546    /// Scheme-qualified location of the full `system_prompt` body.
547    /// `mode: File` carries a `file://<path>` URI (the exact path
548    /// `SystemRefMode::File` wrote to). `mode: Http` carries only the
549    /// **path** portion the engine can construct on its own
550    /// (`/v1/worker/prompt/system?task_id=<id>&attempt=<n>`) — see
551    /// [`SystemRefMode::Http`]'s doc for why the engine cannot fill in
552    /// scheme/host itself, and who is responsible for doing so.
553    pub uri: String,
554    /// Lowercase hex-encoded SHA-256 digest of the full referenced
555    /// `system_prompt` string (the same bytes `size_bytes` measures),
556    /// letting a fetcher verify the content it retrieves from `uri`
557    /// matches what was baked at dispatch time.
558    pub sha256: String,
559    /// Byte length of the referenced `system_prompt` content itself (not
560    /// of `uri`, not of any wrapper/envelope around it).
561    pub size_bytes: u64,
562    /// Which delivery mechanism `uri` uses — see [`SystemRefMode`].
563    pub mode: SystemRefMode,
564}
565
566/// GH #31: where a [`SystemRef`]'s content is actually served from.
567#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
568#[serde(rename_all = "snake_case")]
569pub enum SystemRefMode {
570    /// Content is served live by the HTTP surface at `SystemRef.uri`,
571    /// backed by the unchanged `EngineState.systems` map — no persisted
572    /// storage is created for this mode. The engine itself only
573    /// constructs the **path** portion of `uri`
574    /// (`/v1/worker/prompt/system?task_id=<id>&attempt=<n>`): it has no
575    /// knowledge of scheme/host at fetch time, so the HTTP-layer caller
576    /// that re-emits this `SystemRef` (the route handler serving
577    /// `worker_prompt`) is responsible for prefixing `scheme://host` if a
578    /// fully-qualified URI is desired.
579    Http,
580    /// Content was written once, as a side effect of the fetch that
581    /// produced this `SystemRef`, to a local file under
582    /// `SystemRefConfig.store_dir`; `SystemRef.uri` is that file's
583    /// `file://<path>` URI. Re-fetching the same `(task_id, attempt)`
584    /// re-writes the same bytes to the same path — harmless in practice
585    /// (SubAgents fetch their prompt once per attempt) but not
586    /// deduplicated.
587    File,
588}
589
590/// Error returned when `CapToken::decode` fails.
591#[derive(Debug, thiserror::Error)]
592pub enum CapTokenDecodeError {
593    /// The input was not valid URL-safe base64.
594    #[error("base64 decode failed: {0}")]
595    Base64(String),
596    /// The decoded bytes were not valid `CapToken` JSON.
597    #[error("json parse failed: {0}")]
598    Json(String),
599}
600
601/// Server-side machinery for minting and verifying tokens.
602#[derive(Debug, Clone)]
603pub struct TokenSigner {
604    secret: Vec<u8>,
605}
606
607impl TokenSigner {
608    /// Build a signer from a raw HMAC secret (any length; HMAC accepts it).
609    pub fn new(secret: impl AsRef<[u8]>) -> Self {
610        Self {
611            secret: secret.as_ref().to_vec(),
612        }
613    }
614
615    /// Mint and sign a [`CapToken`] with an explicit `max_uses` policy.
616    /// Prefer [`Self::one_time`] / [`Self::session`] / [`Self::limited`]
617    /// for the common cases.
618    pub fn mint(
619        &self,
620        agent_id: impl Into<String>,
621        role: Role,
622        scopes: Vec<String>,
623        ttl: Duration,
624        max_uses: Option<u32>,
625    ) -> CapToken {
626        let now = now_unix();
627        let mut token = CapToken {
628            agent_id: agent_id.into(),
629            role,
630            scopes,
631            issued_at: now,
632            expire_at: now + ttl.as_secs(),
633            max_uses,
634            nonce: secure_hex(16),
635            sig_hex: String::new(),
636        };
637        let mut mac =
638            Hmac::<Sha256>::new_from_slice(&self.secret).expect("HMAC accepts any key length");
639        mac.update(&token.signing_input());
640        let sig = mac.finalize().into_bytes();
641        token.sig_hex = hex::encode(sig);
642        token
643    }
644
645    /// HMAC sig verify (constant-time eq for timing side-channel resistance).
646    pub fn verify_sig(&self, token: &CapToken) -> bool {
647        let mut mac =
648            Hmac::<Sha256>::new_from_slice(&self.secret).expect("HMAC accepts any key length");
649        mac.update(&token.signing_input());
650        let expected = mac.finalize().into_bytes();
651        let Ok(provided) = hex::decode(&token.sig_hex) else {
652            return false;
653        };
654        ct_eq(&expected, &provided)
655    }
656
657    /// Builder convenience: one-time token.
658    pub fn one_time(
659        &self,
660        agent_id: impl Into<String>,
661        role: Role,
662        scopes: Vec<String>,
663        ttl: Duration,
664    ) -> CapToken {
665        self.mint(agent_id, role, scopes, ttl, Some(1))
666    }
667
668    /// Builder convenience: session token (unlimited uses until expire).
669    pub fn session(
670        &self,
671        agent_id: impl Into<String>,
672        role: Role,
673        scopes: Vec<String>,
674        ttl: Duration,
675    ) -> CapToken {
676        self.mint(agent_id, role, scopes, ttl, None)
677    }
678
679    /// Builder convenience: limited (N uses).
680    pub fn limited(
681        &self,
682        agent_id: impl Into<String>,
683        role: Role,
684        scopes: Vec<String>,
685        ttl: Duration,
686        max_uses: u32,
687    ) -> CapToken {
688        self.mint(agent_id, role, scopes, ttl, Some(max_uses))
689    }
690}
691
692// ─── helpers ───────────────────────────────────────────────────────────────
693
694pub(crate) fn now_unix() -> u64 {
695    // A clock reporting before the epoch means the host clock is broken in a
696    // way that would otherwise silently mint `issued_at: 0` / `expire_at: 0`
697    // tokens (indistinguishable from "already expired" *and* from "minted at
698    // the epoch") — fail loud instead of laundering that into a bogus
699    // timestamp.
700    SystemTime::now()
701        .duration_since(UNIX_EPOCH)
702        .expect("system clock is before UNIX_EPOCH")
703        .as_secs()
704}
705
706/// In-process-unique, restart-decorrelated hex id.
707///
708/// Combines a monotonic per-process counter (bijective — guarantees no two
709/// calls in the same process ever collide) with a random per-process salt
710/// drawn once from the OS RNG (decorrelates ids across restarts, so a
711/// long-lived id from a previous process run can't be mistaken for one
712/// minted by the current process). The high bits of the 128-bit XOR are
713/// dominated by the salt (a process fingerprint); the low bits change on
714/// every call.
715///
716/// **Not unguessable.** The counter is a public, low-entropy sequence once
717/// the salt leaks (e.g. via any single id from this process) — never use
718/// this for bearer credentials, signing nonces, or anything else that must
719/// resist an adversary who can observe some ids and guess others. Use
720/// [`secure_hex`] for that.
721pub fn uid_hex(bytes: usize) -> String {
722    use std::sync::atomic::{AtomicU64, Ordering};
723    use std::sync::OnceLock;
724    static COUNTER: AtomicU64 = AtomicU64::new(0);
725    static SALT: OnceLock<u128> = OnceLock::new();
726    let salt = *SALT.get_or_init(|| {
727        let mut b = [0u8; 16];
728        getrandom::fill(&mut b).expect("OS RNG unavailable");
729        u128::from_le_bytes(b)
730    });
731    let c = COUNTER.fetch_add(1, Ordering::Relaxed) as u128;
732    // XOR keeps the counter's in-process uniqueness (bijection) while the
733    // per-process random salt decorrelates restarts. High 64 bits are pure
734    // salt (a process fingerprint); low bits change every call.
735    let v = salt ^ c;
736    let raw = format!("{:032x}", v);
737    let n = (bytes * 2).min(32);
738    raw[32 - n..].to_string()
739}
740
741/// OS-RNG hex, safe for bearer credentials.
742///
743/// Every byte comes from the OS random source (`getrandom`) on every call —
744/// unpredictable across calls *and* across process restarts, unlike
745/// [`uid_hex`]. Use this whenever the value itself is the secret: the
746/// [`CapToken`] nonce (its server-side lookup key and part of the signed
747/// material) and worker/session bearer handles.
748pub fn secure_hex(bytes: usize) -> String {
749    let mut buf = vec![0u8; bytes];
750    getrandom::fill(&mut buf).expect("OS RNG unavailable");
751    hex::encode(buf)
752}
753
754/// Number of OS-random bytes behind an Operator login bearer token
755/// ([`operator_bearer_token`]).
756///
757/// 16 bytes = 128 bits. Sized against *offline* attack rather than online
758/// guessing: the token is only ever stored as `hex(SHA-256(bearer))` (see
759/// [`crate::store::operator_session::OperatorSessionRecord`]), and SHA-256
760/// is fast, so the digest's resistance is bounded by the bearer's own
761/// entropy. At the 40 bits an earlier 5-byte token carried, a digest is
762/// recoverable by brute force and would have been little better than
763/// storing the plaintext; 128 bits puts that out of reach.
764pub const OPERATOR_BEARER_TOKEN_BYTES: usize = 16;
765
766/// Mint an Operator login bearer token — [`secure_hex`] at
767/// [`OPERATOR_BEARER_TOKEN_BYTES`].
768///
769/// Named rather than inlined so the entropy choice lives in one place with
770/// its rationale, next to the digest-at-rest decision that depends on it.
771pub fn operator_bearer_token() -> String {
772    secure_hex(OPERATOR_BEARER_TOKEN_BYTES)
773}
774
775/// Hex SHA-256 of a token nonce / bearer string — the lookup-key shape
776/// used by [`CapToken::fingerprint`]. Standalone so callers holding only
777/// the bearer string (not a decoded token) can derive the same key.
778pub fn token_fingerprint(nonce: &str) -> String {
779    use sha2::Digest as _;
780    hex::encode(Sha256::digest(nonce.as_bytes()))
781}
782
783/// Constant-time byte-slice equality (XOR accumulate). Public so bearer
784/// comparisons outside this module (e.g. the operator login token check)
785/// can avoid the timing side channel of `==`.
786pub fn ct_eq(a: &[u8], b: &[u8]) -> bool {
787    if a.len() != b.len() {
788        return false;
789    }
790    let mut diff: u8 = 0;
791    for (x, y) in a.iter().zip(b.iter()) {
792        diff |= x ^ y;
793    }
794    diff == 0
795}
796
797#[cfg(test)]
798mod id_newtype_tests {
799    use super::*;
800
801    #[test]
802    fn parse_accepts_prefixed_ids() {
803        assert_eq!(StepId::parse("ST-abc123").unwrap().as_str(), "ST-abc123");
804        assert_eq!(TaskId::parse("T-abc123").unwrap().as_str(), "T-abc123");
805        assert_eq!(RunId::parse("R-abc123").unwrap().as_str(), "R-abc123");
806        assert_eq!(SessionId::parse("S-abc123").unwrap().as_str(), "S-abc123");
807        assert_eq!(WorkerId::parse("W-abc123").unwrap().as_str(), "W-abc123");
808    }
809
810    #[test]
811    fn parse_rejects_wrong_prefix_and_empty_suffix() {
812        // Wrong prefix (including another kind's prefix).
813        assert!(StepId::parse("T-abc").is_err());
814        assert!(TaskId::parse("ST-abc").is_err());
815        assert!(
816            RunId::parse("RK-abc").is_err(),
817            "resume keys are not run ids"
818        );
819        assert!(SessionId::parse("W-abc").is_err());
820        assert!(WorkerId::parse("S-abc").is_err());
821        // Case-sensitive.
822        assert!(StepId::parse("st-abc").is_err());
823        // Prefix alone (nothing after it).
824        assert!(TaskId::parse("T-").is_err());
825        // Garbage / empty.
826        assert!(RunId::parse("nope").is_err());
827        assert!(WorkerId::parse("").is_err());
828    }
829
830    #[test]
831    fn parse_error_carries_kind_prefix_and_input() {
832        let err = TaskId::parse("R-xyz").unwrap_err();
833        assert_eq!(err.kind, "task");
834        assert_eq!(err.expected, "T-");
835        assert_eq!(err.got, "R-xyz");
836        assert_eq!(
837            err.to_string(),
838            "invalid task id `R-xyz`: expected `T-` prefix"
839        );
840    }
841
842    #[test]
843    fn minted_ids_round_trip_through_parse() {
844        assert!(StepId::parse(StepId::new().into_string()).is_ok());
845        assert!(TaskId::parse(TaskId::new().into_string()).is_ok());
846        assert!(RunId::parse(RunId::new().into_string()).is_ok());
847        assert!(SessionId::parse(SessionId::new().into_string()).is_ok());
848        assert!(WorkerId::parse(WorkerId::new().into_string()).is_ok());
849    }
850
851    #[test]
852    fn serde_wire_format_is_a_plain_string() {
853        let id = TaskId::parse("T-abc").unwrap();
854        assert_eq!(
855            serde_json::to_value(&id).unwrap(),
856            serde_json::json!("T-abc")
857        );
858        let back: TaskId = serde_json::from_value(serde_json::json!("T-abc")).unwrap();
859        assert_eq!(back, id);
860    }
861
862    #[test]
863    fn serde_deserialize_validates_prefix() {
864        let err = serde_json::from_value::<TaskId>(serde_json::json!("ST-abc"));
865        assert!(err.is_err(), "deserialize must route through parse");
866    }
867}
868
869#[cfg(test)]
870mod cap_token_fingerprint_tests {
871    use super::*;
872    use std::time::Duration;
873
874    #[test]
875    fn fingerprint_is_sha256_of_nonce_and_not_the_nonce() {
876        let signer = TokenSigner::new("test-secret");
877        let token = signer.session("a", Role::Worker, vec!["*".into()], Duration::from_secs(60));
878        let fp = token.fingerprint();
879        // 32-byte SHA-256, hex-encoded.
880        assert_eq!(fp.len(), 64);
881        // The lookup key must never equal (or contain) the secret nonce.
882        assert_ne!(fp, token.nonce);
883        assert!(!fp.contains(&token.nonce));
884        // Standalone helper derives the same key from the bare bearer string.
885        assert_eq!(fp, token_fingerprint(&token.nonce));
886        // Deterministic per token, distinct across mints.
887        assert_eq!(fp, token.fingerprint());
888        let other = signer.session("a", Role::Worker, vec!["*".into()], Duration::from_secs(60));
889        assert_ne!(fp, other.fingerprint());
890    }
891}
892
893#[cfg(test)]
894mod cap_token_transport_tests {
895    use super::*;
896    use std::time::Duration;
897
898    #[test]
899    fn encode_decode_round_trips() {
900        let signer = TokenSigner::new("test-secret");
901        let token = signer.session(
902            "worker-of-task-x",
903            Role::Worker,
904            vec!["*".into()],
905            Duration::from_secs(600),
906        );
907        let s = token.encode();
908        // URL-safe base64 should not contain `+` `/` `=`
909        assert!(!s.contains('+'));
910        assert!(!s.contains('/'));
911        assert!(!s.contains('='));
912
913        let decoded = CapToken::decode(&s).expect("decode ok");
914        assert_eq!(decoded, token);
915        assert!(
916            signer.verify_sig(&decoded),
917            "HMAC sig still verifies after round-trip"
918        );
919    }
920
921    #[test]
922    fn decode_rejects_garbage() {
923        let err = CapToken::decode("not-base64!!!").expect_err("should fail");
924        assert!(matches!(err, CapTokenDecodeError::Base64(_)));
925    }
926
927    #[test]
928    fn decode_rejects_non_token_json() {
929        use base64::Engine as _;
930        let bogus = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"{\"oops\":1}");
931        let err = CapToken::decode(&bogus).expect_err("should fail json shape");
932        assert!(matches!(err, CapTokenDecodeError::Json(_)));
933    }
934}
935
936// GH #20 Contract C: `WorkerPayload.context` backcompat round-trip.
937#[cfg(test)]
938mod worker_payload_context_tests {
939    use super::*;
940
941    #[test]
942    fn legacy_json_without_context_deserializes_to_none() {
943        // Shape a pre-#20 WorkerPayload would have serialized (no
944        // `context` key at all).
945        let legacy = serde_json::json!({
946            "task_id": "ST-1",
947            "attempt": 1,
948            "agent": "planner",
949            "prompt": "do the thing",
950        });
951        let payload: WorkerPayload =
952            serde_json::from_value(legacy).expect("legacy shape must deserialize");
953        assert!(payload.context.is_none());
954    }
955
956    #[test]
957    fn context_none_serializes_with_key_absent() {
958        let payload = WorkerPayload {
959            task_id: StepId::parse("ST-1").unwrap(),
960            attempt: 1,
961            agent: "planner".to_string(),
962            system: None,
963            prompt: "do the thing".to_string(),
964            context: None,
965            system_ref: None,
966        };
967        let json = serde_json::to_value(&payload).unwrap();
968        assert!(
969            json.as_object().unwrap().get("context").is_none(),
970            "context: None must not appear in the serialized object"
971        );
972    }
973
974    #[test]
975    fn context_some_round_trips() {
976        let view = crate::core::agent_context::AgentContextView::default();
977        let payload = WorkerPayload {
978            task_id: StepId::parse("ST-1").unwrap(),
979            attempt: 1,
980            agent: "planner".to_string(),
981            system: None,
982            prompt: "do the thing".to_string(),
983            context: Some(view.clone()),
984            system_ref: None,
985        };
986        let json = serde_json::to_value(&payload).unwrap();
987        let round_tripped: WorkerPayload = serde_json::from_value(json).unwrap();
988        assert_eq!(round_tripped.context, Some(view));
989    }
990}
991
992// GH #31: `WorkerPayload.system_ref` / `SystemRef` / `SystemRefMode` shape
993// round-trip and backcompat.
994#[cfg(test)]
995mod system_ref_tests {
996    use super::*;
997
998    #[test]
999    fn legacy_json_without_system_ref_deserializes_to_none() {
1000        // Shape a pre-#31 WorkerPayload would have serialized (no
1001        // `system_ref` key at all).
1002        let legacy = serde_json::json!({
1003            "task_id": "ST-1",
1004            "attempt": 1,
1005            "agent": "planner",
1006            "prompt": "do the thing",
1007        });
1008        let payload: WorkerPayload =
1009            serde_json::from_value(legacy).expect("legacy shape must deserialize");
1010        assert!(payload.system_ref.is_none());
1011    }
1012
1013    #[test]
1014    fn system_ref_none_serializes_with_key_absent() {
1015        let payload = WorkerPayload {
1016            task_id: StepId::parse("ST-1").unwrap(),
1017            attempt: 1,
1018            agent: "planner".to_string(),
1019            system: Some("small prompt".to_string()),
1020            prompt: "do the thing".to_string(),
1021            context: None,
1022            system_ref: None,
1023        };
1024        let json = serde_json::to_value(&payload).unwrap();
1025        assert!(
1026            json.as_object().unwrap().get("system_ref").is_none(),
1027            "system_ref: None must not appear in the serialized object"
1028        );
1029    }
1030
1031    #[test]
1032    fn system_ref_some_round_trips_and_excludes_system() {
1033        let system_ref = SystemRef {
1034            uri: "file:///tmp/mse-system-ref/ST-1-1.md".to_string(),
1035            sha256: "a".repeat(64),
1036            size_bytes: 30_000,
1037            mode: SystemRefMode::File,
1038        };
1039        let payload = WorkerPayload {
1040            task_id: StepId::parse("ST-1").unwrap(),
1041            attempt: 1,
1042            agent: "planner".to_string(),
1043            system: None,
1044            prompt: "do the thing".to_string(),
1045            context: None,
1046            system_ref: Some(system_ref.clone()),
1047        };
1048        let json = serde_json::to_value(&payload).unwrap();
1049        assert!(
1050            json.as_object().unwrap().get("system").is_none(),
1051            "system: None must not appear in the serialized object"
1052        );
1053        let round_tripped: WorkerPayload = serde_json::from_value(json).unwrap();
1054        assert_eq!(round_tripped.system_ref, Some(system_ref));
1055        assert!(round_tripped.system.is_none());
1056    }
1057
1058    #[test]
1059    fn system_ref_mode_serializes_snake_case() {
1060        assert_eq!(
1061            serde_json::to_value(SystemRefMode::Http).unwrap(),
1062            serde_json::json!("http")
1063        );
1064        assert_eq!(
1065            serde_json::to_value(SystemRefMode::File).unwrap(),
1066            serde_json::json!("file")
1067        );
1068    }
1069}