From e4a9005468c8fa23df11bc8a88f5b465aae331c4 Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 18 Oct 2026 10:14:30 +0200 Subject: [PATCH 1/2] move Permission into hive-types, drop swarm-controller's hive-sh4re dep --- Cargo.lock | 5 +- docs/process/conventions.md | 4 +- docs/turn-loop/mcp.md | 3 +- hive-agent-mcp/src/mcp/mod.rs | 2 +- hive-agent/Cargo.toml | 1 + hive-agent/src/mcp_config.rs | 8 +- hive-agent/src/swarm_permissions.rs | 2 +- hive-agent/src/turn.rs | 3 +- hive-c0re/src/agent_config/permissions.rs | 2 +- hive-c0re/src/agent_config/topology.rs | 4 +- hive-c0re/src/lifecycle/host_config.rs | 2 +- hive-c0re/src/workers/permissions_import.rs | 4 +- hive-c0re/src/workers/swarm_permissions.rs | 10 +- hive-sh4re/Cargo.toml | 1 - hive-sh4re/src/permissions.rs | 123 +------------- hive-subagent-mcp/src/session.rs | 2 +- hive-subagent-mcp/src/swarm_models.rs | 2 +- hive-types/README.md | 2 + hive-types/src/lib.rs | 6 +- hive-types/src/permission.rs | 169 ++++++++++++++++++++ swarm-controller/Cargo.toml | 1 - swarm-controller/src/agent_permissions.rs | 4 +- swarm-controller/src/archetypes.rs | 2 +- swarm-controller/src/routes/agents.rs | 8 +- swarm-queue-client/Cargo.toml | 6 +- swarm-queue-client/src/agent_permissions.rs | 18 +-- swarmctl/src/permissions.rs | 2 +- 27 files changed, 221 insertions(+), 175 deletions(-) create mode 100644 hive-types/src/permission.rs diff --git a/Cargo.lock b/Cargo.lock index 8d1f634d..375f1664 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1675,6 +1675,7 @@ dependencies = [ "hive-runtime", "hive-sh4re", "hive-sock-client", + "hive-types", "http-body-util", "hyper", "hyper-util", @@ -2024,7 +2025,6 @@ dependencies = [ "schemars", "serde", "serde_json", - "strum", ] [[package]] @@ -4876,7 +4876,6 @@ dependencies = [ "hive-jobq-metrics", "hive-jobq-wire", "hive-log", - "hive-sh4re", "hive-types", "hmac 0.13.0", "http", @@ -5031,7 +5030,7 @@ dependencies = [ "async-nats", "axum", "data-encoding", - "hive-sh4re", + "hive-types", "reqwest", "serde", "serde_json", diff --git a/docs/process/conventions.md b/docs/process/conventions.md index 347131ba..d868c6b9 100644 --- a/docs/process/conventions.md +++ b/docs/process/conventions.md @@ -272,7 +272,7 @@ JSON-line protocol with their own result types; RFC 9457 is an HTTP format. ## Permissions -One set of named `Permission` values (`hive_sh4re::permissions::Permission`) +One set of named `Permission` values (`hive_types::Permission`) decides what an agent may do: which MCP and built-in tools it gets, and which host access it has. @@ -342,7 +342,7 @@ agent grant itself any tool by submitting a config commit, bypassing the operator gate. **Adding to the set** — add a variant to `Permission` in -`hive-sh4re/src/permissions.rs` and to `Permission::ALL`, and enforce it where +`hive-types/src/permission.rs` and to `Permission::ALL`, and enforce it where it applies. A new `#[tool]` fn on `AgentServer` (`hive-agent-mcp/src/mcp/mod.rs`) goes into the matching `Permission::tools()` slice: that's the single source of truth `mcp_config::allowed_mcp_tools` (`hive-agent/src/mcp_config.rs`) diff --git a/docs/turn-loop/mcp.md b/docs/turn-loop/mcp.md index 67853254..37dcc503 100644 --- a/docs/turn-loop/mcp.md +++ b/docs/turn-loop/mcp.md @@ -197,7 +197,8 @@ status hint lives in the wake prompt + UI header, not here. ## Tool allowlist (`hive_sh4re::permissions::ALLOWED_BUILTIN_TOOLS`) The built-in list and the `--tools` value it resolves to live in -`hive-sh4re` alongside `Permission`, not in the harness, because the +`hive-sh4re` (the `Permission` enum itself is in `hive-types`), not in the +harness, because the subagent daemon spawns its own `claude` and must resolve the same set — see [`docs/tools/subagent.md`](../tools/subagent.md). diff --git a/hive-agent-mcp/src/mcp/mod.rs b/hive-agent-mcp/src/mcp/mod.rs index 11e27d9c..3342d99a 100644 --- a/hive-agent-mcp/src/mcp/mod.rs +++ b/hive-agent-mcp/src/mcp/mod.rs @@ -135,7 +135,7 @@ impl AgentServer { } // IMPORTANT: when adding a new `#[tool]` fn to this impl, also add -// its name to the matching `Permission::tools()` slice in hive-sh4re. +// its name to the matching `Permission::tools()` slice in hive-types. // Claude Code's permission gate refuses uninlisted MCP tools in // non-interactive `--print` mode with "permissions not granted yet". #[tool_router] diff --git a/hive-agent/Cargo.toml b/hive-agent/Cargo.toml index bcbc2ddc..6a64d302 100644 --- a/hive-agent/Cargo.toml +++ b/hive-agent/Cargo.toml @@ -29,6 +29,7 @@ hive-core-agent-sock.workspace = true hive-log.workspace = true hive-sh4re.workspace = true hive-sock-client.workspace = true +hive-types.workspace = true libc.workspace = true opentelemetry.workspace = true opentelemetry_sdk.workspace = true diff --git a/hive-agent/src/mcp_config.rs b/hive-agent/src/mcp_config.rs index c57e5b06..6429f2b0 100644 --- a/hive-agent/src/mcp_config.rs +++ b/hive-agent/src/mcp_config.rs @@ -6,7 +6,7 @@ //! running MCP server (a separate binary) — the `send` allow-list check that //! server enforces lives alongside it in the `hive-agent-mcp` crate. -use hive_sh4re::permissions::Permission; +use hive_types::Permission; /// Name of the hyperhive MCP server inside claude's view. Claude prefixes /// tools as `mcp____` (e.g. `mcp__hyperhive__send`). @@ -55,7 +55,7 @@ fn extra_server_enabled(server: &str, perms: &[Permission]) -> bool { #[cfg(test)] mod extra_server_gate_tests { use super::{extra_server_enabled, extra_server_required_permission}; - use hive_sh4re::permissions::Permission; + use hive_types::Permission; #[test] fn bash_is_gated_behind_execution() { @@ -84,7 +84,7 @@ mod extra_server_gate_tests { /// MCP tools claude is allowed to call without prompting, derived from /// the supplied permissions. Adding a new `#[tool]` fn to a server impl -/// requires updating the matching `Permission::tools()` slice in hive-sh4re +/// requires updating the matching `Permission::tools()` slice in hive-types /// (single source of truth). #[must_use] pub fn allowed_mcp_tools(perms: &[Permission]) -> Vec { @@ -212,7 +212,7 @@ fn build_mcp_servers() -> serde_json::Map { #[cfg(test)] mod tests { use super::{SERVER_NAME, allowed_mcp_tools}; - use hive_sh4re::permissions::Permission; + use hive_types::Permission; fn qualified(tool: &str) -> String { format!("mcp__{SERVER_NAME}__{tool}") diff --git a/hive-agent/src/swarm_permissions.rs b/hive-agent/src/swarm_permissions.rs index 2e869543..62b46faa 100644 --- a/hive-agent/src/swarm_permissions.rs +++ b/hive-agent/src/swarm_permissions.rs @@ -17,7 +17,7 @@ use std::path::Path; use std::sync::{PoisonError, RwLock}; use std::time::Duration; -use hive_sh4re::permissions::Permission; +use hive_types::Permission; use swarm_queue_client::agent_permissions::{HIVE_COPY_PATH, HiveCopy, Read, read}; /// How often the key is read. Also the longest a change can take to reach diff --git a/hive-agent/src/turn.rs b/hive-agent/src/turn.rs index f0dff1f9..e5dfd237 100644 --- a/hive-agent/src/turn.rs +++ b/hive-agent/src/turn.rs @@ -351,8 +351,7 @@ pub fn make_session(bus: &Bus) -> Result { /// against the agent's permissions at the moment it asks. fn acp_permission_policy() -> PermissionPolicy { std::sync::Arc::new(|ask: &PermissionAsk<'_>| { - let web = crate::swarm_permissions::current() - .contains(&hive_sh4re::permissions::Permission::WebTools); + let web = crate::swarm_permissions::current().contains(&hive_types::Permission::WebTools); acp_permits(ask, web) }) } diff --git a/hive-c0re/src/agent_config/permissions.rs b/hive-c0re/src/agent_config/permissions.rs index 1b09fe04..f0649c5b 100644 --- a/hive-c0re/src/agent_config/permissions.rs +++ b/hive-c0re/src/agent_config/permissions.rs @@ -17,7 +17,7 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; -use hive_sh4re::permissions::Permission; +use hive_types::Permission; use swarm_queue_client::agent_permissions::{AgentPermissions, HIVE_COPY_FILE, HiveCopy}; const FILE: &str = "swarm-agent-permissions.json"; diff --git a/hive-c0re/src/agent_config/topology.rs b/hive-c0re/src/agent_config/topology.rs index e2374596..d5104fc8 100644 --- a/hive-c0re/src/agent_config/topology.rs +++ b/hive-c0re/src/agent_config/topology.rs @@ -9,7 +9,7 @@ //! dashboard's tree view. All of that is gone; what the file is //! *for* now is the one thing that survived the removal — naming every //! agent, which is the set a -//! [`hive_sh4re::permissions::Permission::ManageRootAgent`] holder gets +//! [`hive_types::Permission::ManageRootAgent`] holder gets //! bind-mounted ([`all_agents`]). //! //! Broader-than-your-own bind-mount grants are **not** stored here: they @@ -65,7 +65,7 @@ fn read_from(path: &Path) -> std::io::Result> { } /// Every agent the roster knows about, in name order. This is the set -/// a [`hive_sh4re::permissions::Permission::ManageRootAgent`] holder gets +/// a [`hive_types::Permission::ManageRootAgent`] holder gets /// bind-mounted, and it is deliberately unfiltered: that permission means /// "may manage any agent", so the set is all of them. /// diff --git a/hive-c0re/src/lifecycle/host_config.rs b/hive-c0re/src/lifecycle/host_config.rs index b11d715d..4204ede5 100644 --- a/hive-c0re/src/lifecycle/host_config.rs +++ b/hive-c0re/src/lifecycle/host_config.rs @@ -7,7 +7,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use hive_priv_sock::{BindMount, CredentialMount}; -use hive_sh4re::permissions::Permission; +use hive_types::Permission; use crate::coordinator::{AgentPaths, HiveEnv}; diff --git a/hive-c0re/src/workers/permissions_import.rs b/hive-c0re/src/workers/permissions_import.rs index 3b0dca89..e139d2a1 100644 --- a/hive-c0re/src/workers/permissions_import.rs +++ b/hive-c0re/src/workers/permissions_import.rs @@ -13,7 +13,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; -use hive_sh4re::permissions::Permission; +use hive_types::Permission; use swarm_queue_client::agent_permissions::{ self, AgentPermissions, IMPORT_REPLY_DEADLINE, Import, ImportReply, }; @@ -240,7 +240,7 @@ async fn send_at(client: &async_nats::Client, hive: &str, meta: &Path) -> Result #[cfg(test)] mod tests { use super::{FileMap, grants, seed_local_at}; - use hive_sh4re::permissions::Permission::{self, *}; + use hive_types::Permission::{self, *}; fn file(json: &str) -> FileMap { serde_json::from_str(json).expect("decodes") diff --git a/hive-c0re/src/workers/swarm_permissions.rs b/hive-c0re/src/workers/swarm_permissions.rs index 52955684..9ed88432 100644 --- a/hive-c0re/src/workers/swarm_permissions.rs +++ b/hive-c0re/src/workers/swarm_permissions.rs @@ -18,7 +18,7 @@ use std::sync::Arc; use std::time::Duration; use anyhow::{Context, Result}; -use hive_sh4re::permissions::Permission; +use hive_types::Permission; use swarm_queue_client::agent_permissions::{self, AgentPermissions, Read}; use crate::coordinator::Coordinator; @@ -216,12 +216,8 @@ mod tests { assert!(changed.is_empty()); assert!(crate::permissions::read_at(&path).expect("read").is_empty()); assert!( - !crate::permissions::holds( - &path, - "ruth", - hive_sh4re::permissions::Permission::ManageRootAgent - ) - .expect("read") + !crate::permissions::holds(&path, "ruth", hive_types::Permission::ManageRootAgent) + .expect("read") ); } diff --git a/hive-sh4re/Cargo.toml b/hive-sh4re/Cargo.toml index 0ade61b6..da9d4744 100644 --- a/hive-sh4re/Cargo.toml +++ b/hive-sh4re/Cargo.toml @@ -15,4 +15,3 @@ schemars.workspace = true serde.workspace = true # `stream_enrich` walks raw claude `stream-json` values into `term_msg` rows. serde_json.workspace = true -strum.workspace = true diff --git a/hive-sh4re/src/permissions.rs b/hive-sh4re/src/permissions.rs index b309f86a..ab125c24 100644 --- a/hive-sh4re/src/permissions.rs +++ b/hive-sh4re/src/permissions.rs @@ -18,7 +18,7 @@ //! server instead); the permission transfers to a subagent, not the //! mechanism. -use serde::{Deserialize, Serialize}; +use hive_types::Permission; /// Built-in claude tools present in every session regardless of permissions. /// Anything not in this list (and not added by a permission's @@ -90,131 +90,10 @@ pub fn subagent_builtin_tools_for(perms: &[Permission]) -> Vec<&'static str> { tools } -/// One thing an agent may be permitted. Stored by its `snake_case` name. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, strum::IntoStaticStr)] -#[serde(rename_all = "snake_case")] -#[strum(serialize_all = "snake_case")] -pub enum Permission { - /// `send`, `recv`, `ack_until` - Messaging, - /// `get_agent_meta` (`set_status` is always-on — see `ALWAYS_ON_TOOLS`) - Meta, - /// `get_loose_ends`, `cancel_loose_end`, `remind` - Inbox, - /// Gates whether the `bash` MCP server (`mcp__bash__run`/`status`/ - /// `kill`) is rendered into the agent's config at all — see - /// `extra_server_required_permission` in `hive-agent/src/mcp_config.rs`. - /// `tools()` returns `&[]`: this isn't a `mcp__hyperhive__*` - /// allowlist entry, the gate lives at config-render time instead. - Execution, - /// Claude built-in web egress tools: `WebFetch` (retrieve a URL) and - /// `WebSearch` (search the web). Both are omitted from `--tools` - /// without it; holding it enables them in the session and in - /// `--allowedTools` so they run without a confirmation prompt. - WebTools, - /// Agent may manage *any* agent in the hive. Concretely, hive-c0re - /// bind-mounts every agent's state dir (RW) and config dir (RO) into the - /// holder's container, plus `/applied` and `/meta` read-only, so the - /// holder can recover an agent that is down. See - /// `lifecycle::host_config::set_nspawn_flags`. - /// - /// The name is historical, from when "the root agent" was the widest - /// thing to manage. Kept under that spelling because every stored grant - /// carries it. - ManageRootAgent, -} - -impl Permission { - /// Every permission in a stable order. - pub const ALL: &'static [Self] = &[ - Self::Messaging, - Self::Meta, - Self::Inbox, - Self::Execution, - Self::WebTools, - Self::ManageRootAgent, - ]; - - /// What an agent the swarm sets nothing for holds, whatever its name. - pub const DEFAULT: &'static [Self] = - &[Self::Messaging, Self::Meta, Self::Inbox, Self::Execution]; - - /// The MCP tool names (without the `mcp__hyperhive__` prefix) this - /// permission grants. Empty for the permissions that grant something - /// else; see each variant's doc comment. - #[must_use] - pub fn tools(self) -> &'static [&'static str] { - match self { - Self::Messaging => &["send", "recv", "ack_until"], - Self::Meta => &["get_agent_meta"], - Self::Inbox => &["get_loose_ends", "cancel_loose_end", "remind"], - // `Execution` grants the out-of-process `bash` MCP server, - // gated at config-render time in `hive-agent/src/mcp_config.rs` - // — an out-of-process server has no later enforcement point, so - // that gate is the actual security boundary. `WebTools` grants - // Claude built-ins (`builtin_tools()`); `ManageRootAgent` grants - // bind mounts. - Self::Execution | Self::WebTools | Self::ManageRootAgent => &[], - } - } - - /// MCP tools that are always exposed regardless of which permissions an - /// agent holds. `set_status` lives here because the operator - /// dashboard depends on every agent being able to report its status - /// chip — gating it would let a misconfigured agent go dark on the - /// dashboard. The server-side `SetStatus` handler has no permission - /// check either (only length validation), so listing it here keeps the - /// `--allowedTools` list honest with that reality. - /// - /// `compact` lives here too: it's pure self-management (no cross-agent - /// effect, no privilege), gated server-side on context usage, and every - /// agent should be able to reach for it — same reasoning as `set_status`. - /// - /// `mark_todos_done` too: todos are pushed to an agent whether or not it - /// holds `Inbox` (which only gates `get_loose_ends`/`cancel_loose_end`/ - /// `remind`), so an agent without `Inbox` must still be able to clear - /// them. - pub const ALWAYS_ON_TOOLS: &'static [&'static str] = - &["set_status", "compact", "mark_todos_done"]; - - /// The Claude built-in tool names this permission enables. Only - /// `WebTools` returns a non-empty slice. - #[must_use] - pub fn builtin_tools(self) -> &'static [&'static str] { - match self { - Self::WebTools => &["WebFetch", "WebSearch"], - _ => &[], - } - } -} - #[cfg(test)] mod tests { use super::*; - /// The stored names are the `snake_case` spellings the old tool-group - /// and capability files used, so an imported grant decodes. - #[test] - fn stored_names_are_snake_case() { - let names: Vec<&str> = Permission::ALL.iter().map(|p| <&str>::from(*p)).collect(); - assert_eq!( - names, - [ - "messaging", - "meta", - "inbox", - "execution", - "web_tools", - "manage_root_agent" - ] - ); - for perm in Permission::ALL { - let json = serde_json::to_string(perm).unwrap(); - assert_eq!(json, format!("\"{}\"", <&str>::from(*perm))); - } - assert!(serde_json::from_str::("\"lifecycle\"").is_err()); - } - /// Adding permissions only ever adds built-ins — the base set is in every /// resolution, and no permission can remove one. Both `--tools` callers (the /// harness and the subagent daemon) rely on this to reason about "the diff --git a/hive-subagent-mcp/src/session.rs b/hive-subagent-mcp/src/session.rs index 912a9015..46319197 100644 --- a/hive-subagent-mcp/src/session.rs +++ b/hive-subagent-mcp/src/session.rs @@ -98,7 +98,7 @@ use hive_claude::{Attach, Claude, Config, SessionStore}; use hive_runtime::{ AcpCommand, AcpRuntime, PermissionAsk, PermissionPolicy, Runtime as _, RuntimeSpec, TurnStop, }; -use hive_sh4re::permissions::Permission; +use hive_types::Permission; use swarm_wire::agent_inference::AgentInference; use tokio::sync::oneshot; diff --git a/hive-subagent-mcp/src/swarm_models.rs b/hive-subagent-mcp/src/swarm_models.rs index c28356f5..b2c41e55 100644 --- a/hive-subagent-mcp/src/swarm_models.rs +++ b/hive-subagent-mcp/src/swarm_models.rs @@ -26,7 +26,7 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, OnceLock}; use std::time::Duration; -use hive_sh4re::permissions::Permission; +use hive_types::Permission; use swarm_queue_client::agent_inference::{self, read_own}; use swarm_queue_client::agent_permissions::{self, HIVE_COPY_PATH, HiveCopy}; use swarm_wire::agent_inference::AgentInference; diff --git a/hive-types/README.md b/hive-types/README.md index cc942401..819ca9cb 100644 --- a/hive-types/README.md +++ b/hive-types/README.md @@ -8,6 +8,8 @@ Foundational shared **newtypes** for the hyperhive workspace. A zero-dependency ## What's here +- **`Permission`** — the set of named permissions an agent may hold, with its + stored `snake_case` names and the MCP and built-in tools each grants. - **`Ident`** — a validated agent-name newtype. Typing agent-name fields as `Ident` (rather than bare `String`) gets serde-validated parsing **at the socket boundary for free**: a malformed name is rejected at deserialize time diff --git a/hive-types/src/lib.rs b/hive-types/src/lib.rs index 961e557d..f769777f 100644 --- a/hive-types/src/lib.rs +++ b/hive-types/src/lib.rs @@ -1,4 +1,4 @@ -//! Foundational shared newtypes for the hyperhive workspace. +//! Foundational shared types for the hyperhive workspace: [`Ident`] and [`Permission`]. //! //! A zero-dependency (bar `serde`) leaf crate so every wire-type crate //! (`hive-sh4re`, `hive-host-sock`, `hive-core-agent-sock`) and both binaries @@ -6,6 +6,10 @@ //! and get serde-validated parsing at the socket boundary for free — with //! no cross-crate coupling and without growing `hive-sh4re`. +mod permission; + +pub use permission::Permission; + /// The environment variable nix uses to hand this process the blacklist of /// names that already mean something to the message layer. /// diff --git a/hive-types/src/permission.rs b/hive-types/src/permission.rs new file mode 100644 index 00000000..83395480 --- /dev/null +++ b/hive-types/src/permission.rs @@ -0,0 +1,169 @@ +//! The [`Permission`] set an agent may hold. +//! +//! Lives in this leaf crate so every crate that names a permission, hive and +//! swarm side alike, can do so without depending on `hive-sh4re` (and through +//! it `hive-priv-sock`). The built-in tool resolution that consumes it is +//! `hive_sh4re::permissions`. + +use serde::{Deserialize, Serialize}; + +/// One thing an agent may be permitted. Stored by its `snake_case` name. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Permission { + /// `send`, `recv`, `ack_until` + Messaging, + /// `get_agent_meta` (`set_status` is always-on — see `ALWAYS_ON_TOOLS`) + Meta, + /// `get_loose_ends`, `cancel_loose_end`, `remind` + Inbox, + /// Gates whether the `bash` MCP server (`mcp__bash__run`/`status`/ + /// `kill`) is rendered into the agent's config at all — see + /// `extra_server_required_permission` in `hive-agent/src/mcp_config.rs`. + /// `tools()` returns `&[]`: this isn't a `mcp__hyperhive__*` + /// allowlist entry, the gate lives at config-render time instead. + Execution, + /// Claude built-in web egress tools: `WebFetch` (retrieve a URL) and + /// `WebSearch` (search the web). Both are omitted from `--tools` + /// without it; holding it enables them in the session and in + /// `--allowedTools` so they run without a confirmation prompt. + WebTools, + /// Agent may manage *any* agent in the hive. Concretely, hive-c0re + /// bind-mounts every agent's state dir (RW) and config dir (RO) into the + /// holder's container, plus `/applied` and `/meta` read-only, so the + /// holder can recover an agent that is down. See + /// `lifecycle::host_config::set_nspawn_flags`. + /// + /// The name is historical, from when "the root agent" was the widest + /// thing to manage. Kept under that spelling because every stored grant + /// carries it. + ManageRootAgent, +} + +impl Permission { + /// Every permission in a stable order. + pub const ALL: &'static [Self] = &[ + Self::Messaging, + Self::Meta, + Self::Inbox, + Self::Execution, + Self::WebTools, + Self::ManageRootAgent, + ]; + + /// What an agent the swarm sets nothing for holds, whatever its name. + pub const DEFAULT: &'static [Self] = + &[Self::Messaging, Self::Meta, Self::Inbox, Self::Execution]; + + /// The MCP tool names (without the `mcp__hyperhive__` prefix) this + /// permission grants. Empty for the permissions that grant something + /// else; see each variant's doc comment. + #[must_use] + pub fn tools(self) -> &'static [&'static str] { + match self { + Self::Messaging => &["send", "recv", "ack_until"], + Self::Meta => &["get_agent_meta"], + Self::Inbox => &["get_loose_ends", "cancel_loose_end", "remind"], + // `Execution` grants the out-of-process `bash` MCP server, + // gated at config-render time in `hive-agent/src/mcp_config.rs` + // — an out-of-process server has no later enforcement point, so + // that gate is the actual security boundary. `WebTools` grants + // Claude built-ins (`builtin_tools()`); `ManageRootAgent` grants + // bind mounts. + Self::Execution | Self::WebTools | Self::ManageRootAgent => &[], + } + } + + /// MCP tools that are always exposed regardless of which permissions an + /// agent holds. `set_status` lives here because the operator + /// dashboard depends on every agent being able to report its status + /// chip — gating it would let a misconfigured agent go dark on the + /// dashboard. The server-side `SetStatus` handler has no permission + /// check either (only length validation), so listing it here keeps the + /// `--allowedTools` list honest with that reality. + /// + /// `compact` lives here too: it's pure self-management (no cross-agent + /// effect, no privilege), gated server-side on context usage, and every + /// agent should be able to reach for it — same reasoning as `set_status`. + /// + /// `mark_todos_done` too: todos are pushed to an agent whether or not it + /// holds `Inbox` (which only gates `get_loose_ends`/`cancel_loose_end`/ + /// `remind`), so an agent without `Inbox` must still be able to clear + /// them. + pub const ALWAYS_ON_TOOLS: &'static [&'static str] = + &["set_status", "compact", "mark_todos_done"]; + + /// The Claude built-in tool names this permission enables. Only + /// `WebTools` returns a non-empty slice. + #[must_use] + pub fn builtin_tools(self) -> &'static [&'static str] { + match self { + Self::WebTools => &["WebFetch", "WebSearch"], + _ => &[], + } + } +} + +impl Permission { + /// The stored `snake_case` name; identical to the serde form. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Messaging => "messaging", + Self::Meta => "meta", + Self::Inbox => "inbox", + Self::Execution => "execution", + Self::WebTools => "web_tools", + Self::ManageRootAgent => "manage_root_agent", + } + } +} + +impl From for &'static str { + fn from(perm: Permission) -> Self { + perm.as_str() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The `as_str` spelling of every variant. `ALL` is stable-ordered, so + /// a variant added to the enum but not to `ALL` or `as_str` shows here. + #[test] + fn as_str_pins_every_variants_name() { + let names: Vec<&str> = Permission::ALL.iter().map(|p| <&str>::from(*p)).collect(); + assert_eq!( + names, + [ + "messaging", + "meta", + "inbox", + "execution", + "web_tools", + "manage_root_agent" + ] + ); + } + + /// The serde form of every variant, pinned independently of `as_str`: + /// stored grants decode by it, so it must stay byte-identical. + #[test] + fn serde_form_pins_every_variants_name() { + let want = [ + (Permission::Messaging, "\"messaging\""), + (Permission::Meta, "\"meta\""), + (Permission::Inbox, "\"inbox\""), + (Permission::Execution, "\"execution\""), + (Permission::WebTools, "\"web_tools\""), + (Permission::ManageRootAgent, "\"manage_root_agent\""), + ]; + assert_eq!(want.len(), Permission::ALL.len()); + for (perm, json) in want { + assert_eq!(serde_json::to_string(&perm).unwrap(), json); + assert_eq!(serde_json::from_str::(json).unwrap(), perm); + } + assert!(serde_json::from_str::("\"lifecycle\"").is_err()); + } +} diff --git a/swarm-controller/Cargo.toml b/swarm-controller/Cargo.toml index 04e8a644..5b46f230 100644 --- a/swarm-controller/Cargo.toml +++ b/swarm-controller/Cargo.toml @@ -69,7 +69,6 @@ sha2.workspace = true similar.workspace = true # The `Permission` names `/api/agents/{name}/permissions` accepts, the same # enum the hive enforces. -hive-sh4re.workspace = true # Validates `POST /api/agents`' `name` before it becomes `agent`/`repo` # everywhere downstream — see `create_agent`'s doc comment for why this is # defense-in-depth, not the only gate (per an argus review finding). diff --git a/swarm-controller/src/agent_permissions.rs b/swarm-controller/src/agent_permissions.rs index 8bb65c28..7aacea6a 100644 --- a/swarm-controller/src/agent_permissions.rs +++ b/swarm-controller/src/agent_permissions.rs @@ -17,7 +17,7 @@ use axum::extract::{Path, State}; use axum::http::StatusCode; use backon::{ConstantBuilder, Retryable as _}; use futures_util::StreamExt as _; -use hive_sh4re::permissions::Permission; +use hive_types::Permission; use problem_details::ProblemDetails; use serde::{Deserialize, Serialize}; use swarm_queue_client::agent_permissions::{self, AgentPermissions, Import, ImportReply}; @@ -528,7 +528,7 @@ mod tests { use crate::test_nats::TestNats; use axum::extract::{Path, State}; use axum::http::StatusCode; - use hive_sh4re::permissions::Permission; + use hive_types::Permission; use swarm_queue_client::agent_permissions::{Import, ImportReply}; async fn published(client: &async_nats::Client, agent: &str) -> Option { diff --git a/swarm-controller/src/archetypes.rs b/swarm-controller/src/archetypes.rs index 1fb3a0dd..e2f31e81 100644 --- a/swarm-controller/src/archetypes.rs +++ b/swarm-controller/src/archetypes.rs @@ -12,7 +12,7 @@ use std::time::Duration; use axum::Json; use axum::extract::State; -use hive_sh4re::permissions::Permission; +use hive_types::Permission; use problem_details::ProblemDetails; use serde::{Deserialize, Serialize}; use swarm_queue_client::archetypes::{ArchetypeMeta, BUILTIN_SOURCE, SourceRecord}; diff --git a/swarm-controller/src/routes/agents.rs b/swarm-controller/src/routes/agents.rs index 86b75330..7414b380 100644 --- a/swarm-controller/src/routes/agents.rs +++ b/swarm-controller/src/routes/agents.rs @@ -510,7 +510,7 @@ pub(crate) struct CreateAgentRequest { /// agent holds the default set. #[serde(default)] #[schema(value_type = Option>)] - pub(crate) permissions: Option>, + pub(crate) permissions: Option>, /// The archetype to build the agent from, one `GET /api/archetypes` /// lists. The agent then gets no `agent-configs/` repo: its hive /// builds it from the archetype's module. Absent is a custom agent. @@ -1513,10 +1513,8 @@ mod tests { .await .expect("a bucket that refuses any value"); let state = crate::agent_limits::tests::state(&client); - let earlier = crate::agent_permissions::at_create(vec![ - hive_sh4re::permissions::Permission::Messaging, - ]) - .expect("valid"); + let earlier = crate::agent_permissions::at_create(vec![hive_types::Permission::Messaging]) + .expect("valid"); crate::agent_permissions::store(&client, "atlas", &earlier) .await .expect("an earlier grant"); diff --git a/swarm-queue-client/Cargo.toml b/swarm-queue-client/Cargo.toml index 2f3f16f2..7c8de72c 100644 --- a/swarm-queue-client/Cargo.toml +++ b/swarm-queue-client/Cargo.toml @@ -52,10 +52,10 @@ operator-profiles = ["async-nats/jetstream"] # runs the same way. Explicit for the same reason as `notices`. agent-limits = ["async-nats/jetstream"] # `agent_permissions::AgentPermissions` and `read`, for hive-c0re, the agent -# harness, the subagent daemon and swarm-controller. Pulls `hive-sh4re` for the +# harness, the subagent daemon and swarm-controller. Pulls `hive-types` for the # `Permission` names, so the crates that only need the bucket name do not link # it. -agent-permissions = ["dep:hive-sh4re", "async-nats/jetstream"] +agent-permissions = ["dep:hive-types", "async-nats/jetstream"] # `agent_archetype::read`, for hive-c0re, which reads the archetype of each # agent it provisions or rebuilds. Explicit for the same reason as `notices`. agent-archetype = ["async-nats/jetstream"] @@ -68,7 +68,7 @@ agents-md-initial = ["async-nats/jetstream"] # Bare (no `kv`/`jetstream`) unless a consumer opts into the `kv` feature # above - the connect itself needs none of them. async-nats.workspace = true -hive-sh4re = { workspace = true, optional = true } +hive-types = { workspace = true, optional = true } # `blocking` on top of the workspace default (`form`/`json`/`rustls`) — # `mint_token_for_blocking` needs `reqwest::blocking::Client` for a caller # with no tokio reactor to `.await` an async request on (an OTLP exporter's diff --git a/swarm-queue-client/src/agent_permissions.rs b/swarm-queue-client/src/agent_permissions.rs index e2d379da..306fe6df 100644 --- a/swarm-queue-client/src/agent_permissions.rs +++ b/swarm-queue-client/src/agent_permissions.rs @@ -1,5 +1,5 @@ //! The per-agent permissions KV bucket: one key per agent, holding the -//! [`hive_sh4re::permissions::Permission`] set the swarm grants it. +//! [`hive_types::Permission`] set the swarm grants it. //! //! **swarm-controller is the only writer**, and creates the bucket when it //! starts. A missing key or an unset `permissions` field leaves the agent on @@ -71,14 +71,14 @@ pub const IMPORT_REPLY_DEADLINE: std::time::Duration = std::time::Duration::from #[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct AgentPermissions { #[serde(default, skip_serializing_if = "Option::is_none")] - pub permissions: Option>, + pub permissions: Option>, } /// What a hive sends once: the permissions its meta repo held, by agent. #[cfg(feature = "agent-permissions")] #[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct Import { - pub permissions: std::collections::BTreeMap>, + pub permissions: std::collections::BTreeMap>, } /// The controller's answer to an [`Import`]. @@ -174,22 +174,22 @@ impl HiveCopy { /// What the agent holds before any read of its key has succeeded: the /// stored list, or the default when the hive stored none. #[must_use] - pub fn before_read(&self) -> Vec { + pub fn before_read(&self) -> Vec { self.stored .permissions .clone() - .unwrap_or_else(|| hive_sh4re::permissions::Permission::DEFAULT.to_vec()) + .unwrap_or_else(|| hive_types::Permission::DEFAULT.to_vec()) } /// What the agent holds after reading `read` from its key. An unset key /// while the import is pending may only mean the grant is not imported /// yet, so the stored list stands; otherwise unset is the default. #[must_use] - pub fn after_read(&self, read: &AgentPermissions) -> Vec { + pub fn after_read(&self, read: &AgentPermissions) -> Vec { match &read.permissions { Some(list) => list.clone(), None if self.import_pending => self.before_read(), - None => hive_sh4re::permissions::Permission::DEFAULT.to_vec(), + None => hive_types::Permission::DEFAULT.to_vec(), } } } @@ -280,7 +280,7 @@ mod tests { #[cfg(feature = "agent-permissions")] #[test] fn unset_and_empty_are_different_values() { - use hive_sh4re::permissions::Permission; + use hive_types::Permission; assert_eq!( serde_json::to_string(&AgentPermissions::default()).unwrap(), "{}" @@ -297,7 +297,7 @@ mod tests { #[cfg(feature = "agent-permissions")] #[test] fn the_hive_copy_stands_until_read_and_while_the_import_is_pending() { - use hive_sh4re::permissions::Permission; + use hive_types::Permission; let record = |json: &str| -> AgentPermissions { serde_json::from_str(json).unwrap() }; let copy = |json: &str| -> HiveCopy { serde_json::from_str(json).unwrap() }; let web = vec![Permission::WebTools]; diff --git a/swarmctl/src/permissions.rs b/swarmctl/src/permissions.rs index 3bd15f28..d1a67eb2 100644 --- a/swarmctl/src/permissions.rs +++ b/swarmctl/src/permissions.rs @@ -6,7 +6,7 @@ //! Unset leaves the agent on the default set; an empty list grants nothing. //! //! The shape below mirrors the controller's `Permissions`, whose names are -//! `hive_sh4re::permissions::Permission`, and `swarm-controller-api` does not +//! `hive_types::Permission`, and `swarm-controller-api` does not //! depend on `hive-sh4re`. Permission names are checked by the controller, //! which answers 422 for one it does not know. From 6084fb6329f92da0475eb5c08994add3c0b2cdf7 Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 18 Oct 2026 19:06:36 +0200 Subject: [PATCH 2/2] move the send and turn row markers into hive-types --- Cargo.lock | 1 + hive-agent/src/term_msg.rs | 2 +- hive-sh4re/src/stream_enrich.rs | 14 +++-- hive-sh4re/src/term_msg.rs | 35 ------------- hive-types/README.md | 3 ++ hive-types/src/lib.rs | 5 +- hive-types/src/term_msg.rs | 72 ++++++++++++++++++++++++++ swarm-controller/Cargo.toml | 4 ++ swarm-controller/src/message_stream.rs | 26 +++++++++- 9 files changed, 119 insertions(+), 43 deletions(-) create mode 100644 hive-types/src/term_msg.rs diff --git a/Cargo.lock b/Cargo.lock index 375f1664..f6252caa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4869,6 +4869,7 @@ dependencies = [ "backon", "base64", "bytes", + "chrono", "forgejo-api", "futures-util", "getrandom 0.3.4", diff --git a/hive-agent/src/term_msg.rs b/hive-agent/src/term_msg.rs index 55553c95..d3db1179 100644 --- a/hive-agent/src/term_msg.rs +++ b/hive-agent/src/term_msg.rs @@ -11,7 +11,7 @@ //! both a text block and a `tool_use` block produces two rows. pub use hive_sh4re::term_msg::{ClassifyCtx, Level, TermMsg, iso8601_utc}; -use hive_sh4re::term_msg::{TURN_ICON, TURN_SUMMARY_PREFIX}; +use hive_types::term_msg::{TURN_ICON, TURN_SUMMARY_PREFIX}; use crate::events::LiveEvent; diff --git a/hive-sh4re/src/stream_enrich.rs b/hive-sh4re/src/stream_enrich.rs index 67ac545d..05beff1e 100644 --- a/hive-sh4re/src/stream_enrich.rs +++ b/hive-sh4re/src/stream_enrich.rs @@ -15,7 +15,8 @@ //! `mcp__hyperhive__send`'s row say, which tools get an expandable body) //! didn't change; only the shape it gets packed into did. -use crate::term_msg::{BodyFormat, ClassifyCtx, Level, SEND_ICON, SEND_SUMMARY_PREFIX, TermMsg}; +use crate::term_msg::{BodyFormat, ClassifyCtx, Level, TermMsg}; +use hive_types::term_msg::{SEND_ICON, SEND_SUMMARY_PREFIX}; use serde_json::{Value, json}; /// Classify one raw claude stream-json line into zero or more terminal rows. @@ -1346,13 +1347,16 @@ mod tests { })); assert_eq!(row.icon.as_deref(), Some(SEND_ICON)); assert_eq!(row.summary.contains(" · 3L"), lines, "{}", row.summary); - assert_eq!(crate::term_msg::send_recipient(&row.summary), Some("iris")); + assert_eq!( + hive_types::term_msg::send_recipient(&row.summary), + Some("iris") + ); } - assert_eq!(crate::term_msg::send_recipient("send* →"), None); - assert_eq!(crate::term_msg::send_recipient("Read(x)"), None); + assert_eq!(hive_types::term_msg::send_recipient("send* →"), None); + assert_eq!(hive_types::term_msg::send_recipient("Read(x)"), None); // A suffix lookalike in the name is not stripped. assert_eq!( - crate::term_msg::send_recipient("send* → a · xL"), + hive_types::term_msg::send_recipient("send* → a · xL"), Some("a · xL") ); } diff --git a/hive-sh4re/src/term_msg.rs b/hive-sh4re/src/term_msg.rs index 429e45be..d896934c 100644 --- a/hive-sh4re/src/term_msg.rs +++ b/hive-sh4re/src/term_msg.rs @@ -128,41 +128,6 @@ pub fn iso8601_utc(unix_seconds: i64) -> String { .to_rfc3339_opts(SecondsFormat::Secs, true) } -/// Icon of the row an agent's `send` tool call makes. -pub const SEND_ICON: &str = "📤"; - -/// What a `send` row's summary starts with; the recipient follows. A -/// multi-line body appends ` · L`, which [`send_recipient`] drops. -pub const SEND_SUMMARY_PREFIX: &str = "send* → "; - -/// Icon of the row a turn started by a message makes. -pub const TURN_ICON: &str = "◆"; - -/// What a turn-start row's summary starts with; the sender follows. -pub const TURN_SUMMARY_PREFIX: &str = "TURN ← "; - -/// The recipient a `send` row's summary names, without the line-count suffix. -#[must_use] -pub fn send_recipient(summary: &str) -> Option<&str> { - let rest = summary.strip_prefix(SEND_SUMMARY_PREFIX)?; - Some(match rest.rsplit_once(" · ") { - Some((to, lines)) - if lines - .strip_suffix('L') - .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) => - { - to - } - _ => rest, - }) -} - -/// The sender a turn-start row's summary names. -#[must_use] -pub fn turn_sender(summary: &str) -> Option<&str> { - summary.strip_prefix(TURN_SUMMARY_PREFIX) -} - /// Per-connection/per-request classification state. A live SSE stream keeps /// one of these alive for the connection's lifetime — `tool_use` id → name /// correlation, so a `tool_result` can tell it's answering a `recv` call and diff --git a/hive-types/README.md b/hive-types/README.md index 819ca9cb..9a057917 100644 --- a/hive-types/README.md +++ b/hive-types/README.md @@ -14,6 +14,9 @@ Foundational shared **newtypes** for the hyperhive workspace. A zero-dependency `Ident` (rather than bare `String`) gets serde-validated parsing **at the socket boundary for free**: a malformed name is rejected at deserialize time instead of flowing into a handler that has to re-validate by hand. +- **`term_msg`** — the icons and summary prefixes of a terminal row that sent a + message or started a turn from one, and the parsers that read the recipient + or sender back out. The crate is deliberately a leaf with the smallest possible dependency footprint so anything in the workspace can depend on it. See `docs/process/conventions.md` diff --git a/hive-types/src/lib.rs b/hive-types/src/lib.rs index f769777f..0ef2e23d 100644 --- a/hive-types/src/lib.rs +++ b/hive-types/src/lib.rs @@ -1,4 +1,5 @@ -//! Foundational shared types for the hyperhive workspace: [`Ident`] and [`Permission`]. +//! Foundational shared types for the hyperhive workspace: [`Ident`], [`Permission`] +//! and the [`term_msg`] row markers. //! //! A zero-dependency (bar `serde`) leaf crate so every wire-type crate //! (`hive-sh4re`, `hive-host-sock`, `hive-core-agent-sock`) and both binaries @@ -10,6 +11,8 @@ mod permission; pub use permission::Permission; +pub mod term_msg; + /// The environment variable nix uses to hand this process the blacklist of /// names that already mean something to the message layer. /// diff --git a/hive-types/src/term_msg.rs b/hive-types/src/term_msg.rs new file mode 100644 index 00000000..4a4bf6e6 --- /dev/null +++ b/hive-types/src/term_msg.rs @@ -0,0 +1,72 @@ +//! The icons and summary prefixes that mark a terminal row as a sent message +//! or a message-started turn, and the parsers that read the peer back out. +//! +//! The classifier in `hive-sh4re` builds the summaries and +//! `swarm-controller`'s message stream parses them, so both sides name the +//! same constants and the same parsers rather than each spelling the format. + +/// Icon of the row an agent's `send` tool call makes. +pub const SEND_ICON: &str = "📤"; + +/// What a `send` row's summary starts with; the recipient follows. A +/// multi-line body appends ` · L`, which [`send_recipient`] drops. +pub const SEND_SUMMARY_PREFIX: &str = "send* → "; + +/// Icon of the row a turn started by a message makes. +pub const TURN_ICON: &str = "◆"; + +/// What a turn-start row's summary starts with; the sender follows. +pub const TURN_SUMMARY_PREFIX: &str = "TURN ← "; + +/// The recipient a `send` row's summary names, without the line-count suffix. +#[must_use] +pub fn send_recipient(summary: &str) -> Option<&str> { + let rest = summary.strip_prefix(SEND_SUMMARY_PREFIX)?; + Some(match rest.rsplit_once(" · ") { + Some((to, lines)) + if lines + .strip_suffix('L') + .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) => + { + to + } + _ => rest, + }) +} + +/// The sender a turn-start row's summary names. +#[must_use] +pub fn turn_sender(summary: &str) -> Option<&str> { + summary.strip_prefix(TURN_SUMMARY_PREFIX) +} + +#[cfg(test)] +mod tests { + use super::{SEND_SUMMARY_PREFIX, TURN_SUMMARY_PREFIX, send_recipient, turn_sender}; + + #[test] + fn a_send_summary_names_its_recipient_with_and_without_the_line_count() { + assert_eq!( + send_recipient(&format!("{SEND_SUMMARY_PREFIX}iris")), + Some("iris") + ); + assert_eq!( + send_recipient(&format!("{SEND_SUMMARY_PREFIX}iris · 3L")), + Some("iris") + ); + assert_eq!(send_recipient("send* →"), None); + assert_eq!(send_recipient("Read(x)"), None); + // A suffix lookalike in the name is not stripped. + assert_eq!(send_recipient("send* → a · xL"), Some("a · xL")); + } + + #[test] + fn a_turn_summary_names_its_sender() { + assert_eq!( + turn_sender(&format!("{TURN_SUMMARY_PREFIX}atlas")), + Some("atlas") + ); + assert_eq!(turn_sender("TURN ←"), None); + assert_eq!(turn_sender("send* → iris"), None); + } +} diff --git a/swarm-controller/Cargo.toml b/swarm-controller/Cargo.toml index 5b46f230..cb153ca8 100644 --- a/swarm-controller/Cargo.toml +++ b/swarm-controller/Cargo.toml @@ -27,6 +27,8 @@ forgejo-api.workspace = true # decodes blobs it reads: forgejo's content and blob APIs carry base64, # never raw bytes. base64.workspace = true +# `message_stream.rs` renders the history cutoff in the spelling of a row's `ts`. +chrono.workspace = true # `agent_identity.rs` mints the per-agent queue secret, the one value in this # tree this daemon invents rather than receives. Straight from the kernel's # CSPRNG — see the workspace entry for why this and not `rand`. @@ -69,6 +71,8 @@ sha2.workspace = true similar.workspace = true # The `Permission` names `/api/agents/{name}/permissions` accepts, the same # enum the hive enforces. +# The send and turn row markers `message_stream.rs` reads a message back out +# of. # Validates `POST /api/agents`' `name` before it becomes `agent`/`repo` # everywhere downstream — see `create_agent`'s doc comment for why this is # defense-in-depth, not the only gate (per an argus review finding). diff --git a/swarm-controller/src/message_stream.rs b/swarm-controller/src/message_stream.rs index c130686b..9f07e6fa 100644 --- a/swarm-controller/src/message_stream.rs +++ b/swarm-controller/src/message_stream.rs @@ -32,7 +32,7 @@ use axum::extract::{Query, State}; use axum::http::StatusCode; use axum::response::sse::{Event, KeepAlive, Sse}; use futures_util::{Stream, StreamExt as _, stream}; -use hive_sh4re::term_msg::{SEND_ICON, TURN_ICON, iso8601_utc, send_recipient, turn_sender}; +use hive_types::term_msg::{SEND_ICON, TURN_ICON, send_recipient, turn_sender}; use serde::Deserialize; use swarm_controller_api::messages::SwarmMessage; use swarm_queue_client::agent_term; @@ -125,6 +125,17 @@ fn message_event(message: &SwarmMessage) -> Event { Event::default().data(serde_json::to_string(message).unwrap_or_default()) } +/// A unix-seconds stamp as ISO 8601 / RFC 3339 UTC, the spelling a row's `ts` +/// carries, so the two compare as strings. It must equal +/// `hive_sh4re::term_msg::iso8601_utc`, which stamps those rows. +/// +/// A stamp outside the representable range renders as the epoch. +fn iso8601_utc(unix_seconds: i64) -> String { + chrono::DateTime::from_timestamp(unix_seconds, 0) + .unwrap_or_default() + .to_rfc3339_opts(chrono::SecondsFormat::Secs, true) +} + /// The messages in the newest rows of each of `roster`'s streams from the last /// `minutes`, oldest first. An agent whose stream cannot be read adds none. async fn history( @@ -244,6 +255,19 @@ mod tests { use crate::test_nats::TestNats; + /// The spelling is the one the agents stamp rows with (RFC 3339, whole + /// seconds, `Z`): the history cutoff is compared with `ts` as a string. + #[test] + fn iso8601_utc_spells_a_stamp_the_way_a_row_ts_does() { + assert_eq!(iso8601_utc(0), "1970-01-01T00:00:00Z"); + assert_eq!(iso8601_utc(1_789_302_903), "2026-09-13T12:35:03Z"); + } + + #[test] + fn iso8601_utc_renders_an_out_of_range_stamp_as_the_epoch() { + assert_eq!(iso8601_utc(i64::MAX), "1970-01-01T00:00:00Z"); + } + fn row(icon: &str, summary: &str, body: &str) -> bytes::Bytes { json!({ "ts": "2026-10-18T12:00:00Z", "icon": icon, "level": "info",