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