diff --git a/docs/agent-lifecycle/agent-roster.md b/docs/agent-lifecycle/agent-roster.md index 14189b60..bb0b9683 100644 --- a/docs/agent-lifecycle/agent-roster.md +++ b/docs/agent-lifecycle/agent-roster.md @@ -78,9 +78,9 @@ module docs for the exact call chain. ## Hard-coded manager behaviour -The **manager (`ruth`)** differs from other agents in naming/bootstrap, -its socket flavour and its default permissions; its prompt, tool -allow-list and state dirs work as for every other agent. +The **manager (`ruth`)** differs from other agents in naming/bootstrap +and its socket flavour; its permissions, prompt, tool allow-list and +state dirs work as for every other agent. @@ -97,13 +97,13 @@ allow-list and state dirs work as for every other agent. both flavours; matrix, bash and forge don't call it — their notifications push a todo on the harness's in-agent socket (see [`docs/turn-loop/mcp.md`](../turn-loop/mcp.md#waking-the-agent-from-inside-the-container)). -- **Storage/mounts** — the manager container gets +- **Storage/mounts** — the `manage_root_agent` permission, which the swarm + UI's setup assistant creates ruth with, gets a container `/var/lib/hyperhive/agents` bind-mounted RW at `/agents` (so it can manage any agent's state dir — config isn't authored there, since a real config change is a PR from a clone), plus RO mounts for `/applied` (diff against what's deployed) and `/meta` (system-wide - deploy log). That grant is the `manage_root_agent` permission: ruth - holds it by default, and any agent holding it gets the same mounts. + deploy log). Any agent holding it gets the same mounts. The operator dashboard's `POST /api/meta-update` is the only path that writes `flake.lock`. - **Prompt** — identical for every container: `prompts/system.md` @@ -115,8 +115,8 @@ allow-list and state dirs work as for every other agent. (`filter_role_blocks(SAMPLE, "manager")`). - **Tool allow-list** — the MCP tools claude sees come from the agent's permissions alone, the same mechanism for every agent - ([`docs/turn-loop/mcp.md`](../turn-loop/mcp.md)). Ruth's default - permissions add only `manage_root_agent` (`Permission::default_for`). + ([`docs/turn-loop/mcp.md`](../turn-loop/mcp.md)). Ruth's permissions + are the default plus `manage_root_agent`, stored for her like any grant. - **State dirs** — _not_ special-cased: `HYPERHIVE_STATE_DIR` is injected uniformly via `systemd.globalEnvironment` for every container including the manager, so all token/state paths resolve diff --git a/docs/agent-lifecycle/config-changes.md b/docs/agent-lifecycle/config-changes.md index c39ca99f..d6f02a9b 100644 --- a/docs/agent-lifecycle/config-changes.md +++ b/docs/agent-lifecycle/config-changes.md @@ -270,10 +270,13 @@ dirs. ⚠️ nspawn bakes bind flags at container start, so hive-c0re restarts an agent, after its current turn, when its `manage_root_agent` changes. -The root agent holds the permission by default: an agent whose record -leaves `permissions` unset gets the default, which includes -`manage_root_agent` for `ruth` and for no other agent. Setting her -permissions without it revokes it. +No agent holds the permission by default: an agent whose record leaves +`permissions` unset gets the default, which doesn't include +`manage_root_agent`, whatever the agent's name. The swarm UI's setup +assistant creates ruth with it. For a ruth that predates that, whose +permissions were unset, swarm-controller writes the default plus +`manage_root_agent` into her key once at start, so she keeps her mounts; +it never touches a key that's already set. Each proposed repo (`/agents//config/`) is pre-configured with `applied` as a git remote pointing at @@ -331,8 +334,10 @@ Differences from sub-agents: (vs `agent-base`). - Web UI port via `lifecycle::agent_web_port("ruth")` — same FNV-1a hash as every other agent (8100..8999 range). -- `set_nspawn_flags` adds two extra binds: `/var/lib/hyperhive/agents` - → `/agents` (RW) so the root agent can edit per-agent proposed repos, +- The root agent holds `manage_root_agent` (the swarm UI's setup + assistant creates her with it), for which `set_nspawn_flags` adds two extra binds: + `/var/lib/hyperhive/agents` → `/agents` (RW) so the root agent can edit + per-agent proposed repos, and `/var/lib/hyperhive/applied` → `/applied` (RO) so the root agent can `git fetch` deployed/failed/denied tags from any agent's authoritative applied repo (see "Root-agent view of applied" below). diff --git a/docs/agent-lifecycle/persistence.md b/docs/agent-lifecycle/persistence.md index bdcc6bf8..26929d39 100644 --- a/docs/agent-lifecycle/persistence.md +++ b/docs/agent-lifecycle/persistence.md @@ -408,7 +408,8 @@ Contents: to live here; nothing reads or writes it now. An old copy in the meta repo is harmless and you can delete it. -The root agent has the meta dir RO-mounted at `/meta/`. +An agent holding `manage_root_agent`, normally ruth, has the meta dir +RO-mounted at `/meta/`. Outside the meta repo, `/var/lib/hyperhive/swarm-agent-permissions.json` holds the permissions the swarm grants this hive's agents, as diff --git a/docs/getting-started/setup.md b/docs/getting-started/setup.md index 973fdf8a..8d09aa3f 100644 --- a/docs/getting-started/setup.md +++ b/docs/getting-started/setup.md @@ -93,8 +93,10 @@ SSO login asks for its forge password once, to link the two. hive-c0re creates no agent at startup. You create ruth, the manager agent, like any other: open the swarm UI start page and click the paperclip, which -opens the create form with `ruth` filled in. The form declares her, creates -her config repo and deploys her to the hive you pick. +opens the create form with `ruth` filled in and the `manage_root_agent` +permission checked: that permission mounts the other agents into her +container so she can recover them. The form declares her, creates her +config repo and deploys her to the hive you pick. The swarm declares a new agent `paused`, so without a further step ruth runs but drives no turns. Open `/agents` in the swarm UI and set her `up` from her diff --git a/docs/process/conventions.md b/docs/process/conventions.md index 0e5ad74a..2f083c0a 100644 --- a/docs/process/conventions.md +++ b/docs/process/conventions.md @@ -301,8 +301,8 @@ the agent's key in the `agent-permissions` KV bucket writes. Set them with `swarmctl agent permissions get|set|clear ` (`/api/agents/{name}/permissions`), or on the swarm UI's create-agent screen (`permissions` in `POST /api/agents`). A missing key or an unset field means -the default (`Permission::default_for`): `messaging`, `meta`, `inbox`, -`execution`, plus `manage_root_agent` for `ruth`. `"permissions": []` grants +the default (`Permission::DEFAULT`): `messaging`, `meta`, `inbox`, +`execution`, for every agent whatever its name. `"permissions": []` grants none. **How a change applies** — diff --git a/docs/tools/swarmctl-cli.md b/docs/tools/swarmctl-cli.md index 5cfb4440..3d0c89de 100644 --- a/docs/tools/swarmctl-cli.md +++ b/docs/tools/swarmctl-cli.md @@ -207,7 +207,7 @@ Clear every field for an agent, so it goes back to its own values Show or change the permissions the swarm grants an agent. -The agent applies its tool permissions at its next turn; the hive running it restarts it, after its current turn, when `manage_root_agent` changes. Unset leaves the agent on the default: `messaging`, `meta`, `inbox`, `execution`, plus `manage_root_agent` for `ruth`. +The agent applies its tool permissions at its next turn; the hive running it restarts it, after its current turn, when `manage_root_agent` changes. Unset leaves the agent on the default: `messaging`, `meta`, `inbox`, `execution`, whatever its name. **Usage:** `swarmctl agent permissions ` diff --git a/frontend/packages/swarm-ui/src/pages/CreateAgentForm.tsx b/frontend/packages/swarm-ui/src/pages/CreateAgentForm.tsx index 95eb5453..d47cfd73 100644 --- a/frontend/packages/swarm-ui/src/pages/CreateAgentForm.tsx +++ b/frontend/packages/swarm-ui/src/pages/CreateAgentForm.tsx @@ -26,8 +26,8 @@ // shared `ui/` form kit (`TextField`/`SelectField`/`Button`) rather than // page-scoped input/button chrome — see that kit's own comments for why. // -// Permissions: checkboxes from `GET /api/permissions`, on the default set; -// sent only when changed, so an untouched agent follows the default. +// Permissions: checkboxes from `GET /api/permissions`, on the default set plus +// `extraPermissions`; sent only when that differs from the default. import { useEffect, useState } from "preact/hooks"; import { Link } from "wouter-preact"; import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; @@ -50,6 +50,7 @@ import { import { FormField } from "../ui/form-field/FormField.js"; import { MultiselectFilterOptions } from "../ui/multiselect-filter/MultiselectFilter.js"; import { + initialSelection, requestPermissions, togglePermission, } from "./CreateAgentPermissions.js"; @@ -98,9 +99,12 @@ const NAME_PATTERN = "[a-z0-9\\-]{1,63}"; export function CreateAgentForm({ onClose, initialName = "", + extraPermissions = [], }: { /** The name field's starting value, for a caller that suggests one. */ initialName?: string; + /** Permissions checked on top of the default set when the form opens. */ + extraPermissions?: string[]; /** Rendered as the form panel's own close button — this form always * mounts inside a `plain` `Dialog` (see `AgentsPage`), which no longer * floats its own. */ @@ -157,7 +161,7 @@ export function CreateAgentForm({ } const data = (await r.json()) as PermissionCatalogue; setCatalogue(data); - setSelected(data.default); + setSelected(initialSelection(data.all, data.default, extraPermissions)); })().catch((e: unknown) => setCatalogueError({ detail: String(e) })); }, []); @@ -195,7 +199,10 @@ export function CreateAgentForm({ const data = (await r.json()) as CreateAgentResponse; setResult({ status: "done", nodeId: data.node_id }); setName(""); - if (catalogue) setSelected(catalogue.default); + if (catalogue) + setSelected( + initialSelection(catalogue.all, catalogue.default, extraPermissions), + ); } catch (err) { setResult({ status: "error", problem: { detail: String(err) } }); } diff --git a/frontend/packages/swarm-ui/src/pages/CreateAgentPermissions.ts b/frontend/packages/swarm-ui/src/pages/CreateAgentPermissions.ts index 6e641600..be62c338 100644 --- a/frontend/packages/swarm-ui/src/pages/CreateAgentPermissions.ts +++ b/frontend/packages/swarm-ui/src/pages/CreateAgentPermissions.ts @@ -22,3 +22,19 @@ export function requestPermissions( selected.every((p) => defaults.includes(p)); return same ? undefined : selected; } + +/** + * The form's starting selection: `defaults` plus each of `extra` the + * catalogue lists (`all`), so a name the controller does not know is not + * sent. + */ +export function initialSelection( + all: string[], + defaults: string[], + extra: string[], +): string[] { + return [ + ...defaults, + ...extra.filter((p) => all.includes(p) && !defaults.includes(p)), + ]; +} diff --git a/frontend/packages/swarm-ui/src/pages/SetupAssistant.tsx b/frontend/packages/swarm-ui/src/pages/SetupAssistant.tsx index 7d6cfbcd..0e1cd1b1 100644 --- a/frontend/packages/swarm-ui/src/pages/SetupAssistant.tsx +++ b/frontend/packages/swarm-ui/src/pages/SetupAssistant.tsx @@ -1,11 +1,15 @@ // - the start page's first-time helper: a paperclip with a // speech bubble offering to set up an agent. It renders only while the swarm // has no `ruth` (see `ruthIsMissing`), and a click opens the create-agent -// form with her name filled in; the form already selects the default -// permissions and the only hive. No dismiss: once ruth exists it goes away. +// form with her name filled in and `manage_root_agent` checked on top of the +// default permissions (`RUTH_EXTRA_PERMISSIONS`); the form already selects +// the only hive. No dismiss: once ruth exists it goes away. import { useEffect, useState } from "preact/hooks"; import { Dialog } from "../ui/dialog/Dialog.js"; -import { ruthIsMissing } from "../shell/ruth-missing.js"; +import { + RUTH_EXTRA_PERMISSIONS, + ruthIsMissing, +} from "../shell/ruth-missing.js"; import { CreateAgentForm } from "./CreateAgentForm.js"; import "./SetupAssistant.css"; @@ -70,7 +74,11 @@ export function SetupAssistant() { label="create agent" plain > - setOpen(false)} /> + setOpen(false)} + /> ); diff --git a/frontend/packages/swarm-ui/src/shell/ruth-missing.ts b/frontend/packages/swarm-ui/src/shell/ruth-missing.ts index d1655e74..b09ba5aa 100644 --- a/frontend/packages/swarm-ui/src/shell/ruth-missing.ts +++ b/frontend/packages/swarm-ui/src/shell/ruth-missing.ts @@ -20,3 +20,7 @@ export async function ruthIsMissing(fetchImpl: Fetch): Promise { return false; } } + +/** What the setup assistant's create form checks on top of the default set: + * ruth recovers the other agents, which takes `manage_root_agent`. */ +export const RUTH_EXTRA_PERMISSIONS = ["manage_root_agent"]; diff --git a/frontend/packages/swarm-ui/test/create-agent-permissions.test.mjs b/frontend/packages/swarm-ui/test/create-agent-permissions.test.mjs index 4fb28e2b..07109b23 100644 --- a/frontend/packages/swarm-ui/test/create-agent-permissions.test.mjs +++ b/frontend/packages/swarm-ui/test/create-agent-permissions.test.mjs @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { + initialSelection, requestPermissions, togglePermission, } from "../src/pages/CreateAgentPermissions.ts"; @@ -29,3 +30,17 @@ test("a changed set is sent, an empty one included", () => { "web_tools", ]); }); + +const all = [...defaults, "web_tools", "manage_root_agent"]; + +test("the starting selection is the default plus the known extras", () => { + assert.deepEqual(initialSelection(all, defaults, []), defaults); + assert.deepEqual(initialSelection(all, defaults, ["manage_root_agent"]), [ + ...defaults, + "manage_root_agent", + ]); + assert.deepEqual( + initialSelection(all, defaults, ["meta", "fly_to_the_moon"]), + defaults, + ); +}); diff --git a/frontend/packages/swarm-ui/test/ruth-missing.test.mjs b/frontend/packages/swarm-ui/test/ruth-missing.test.mjs index 17f28f87..0af86895 100644 --- a/frontend/packages/swarm-ui/test/ruth-missing.test.mjs +++ b/frontend/packages/swarm-ui/test/ruth-missing.test.mjs @@ -1,7 +1,15 @@ -// The setup assistant's "is ruth missing" check. Run by `npm test`. +// The setup assistant: its "is ruth missing" check and the permissions it +// creates her with. Run by `npm test`. import assert from "node:assert/strict"; import { test } from "node:test"; -import { ruthIsMissing } from "../src/shell/ruth-missing.ts"; +import { + RUTH_EXTRA_PERMISSIONS, + ruthIsMissing, +} from "../src/shell/ruth-missing.ts"; +import { + initialSelection, + requestPermissions, +} from "../src/pages/CreateAgentPermissions.ts"; const answering = (body, ok = true) => @@ -35,3 +43,21 @@ test("anything but a clear answer is not missing", async () => { false, ); }); + +// The assistant's create form sends `manage_root_agent` with the default +// set, so a new ruth holds it from her first start; the default alone does not. +test("the assistant creates ruth with manage_root_agent", () => { + const defaults = ["messaging", "meta", "inbox", "execution"]; + const all = [...defaults, "web_tools", "manage_root_agent"]; + assert.deepEqual( + requestPermissions( + initialSelection(all, defaults, RUTH_EXTRA_PERMISSIONS), + defaults, + ), + [...defaults, "manage_root_agent"], + ); + assert.equal( + requestPermissions(initialSelection(all, defaults, []), defaults), + undefined, + ); +}); diff --git a/hive-agent/src/swarm_permissions.rs b/hive-agent/src/swarm_permissions.rs index 59d0aed2..2e869543 100644 --- a/hive-agent/src/swarm_permissions.rs +++ b/hive-agent/src/swarm_permissions.rs @@ -34,20 +34,20 @@ pub fn current() -> Vec { .read() .unwrap_or_else(PoisonError::into_inner) .clone(); - current_with(last, Path::new(HIVE_COPY_PATH), &crate::identity::label()) + current_with(last, Path::new(HIVE_COPY_PATH)) } /// [`current`] with the last read and the hive's copy given. -fn current_with(last: Option>, copy: &Path, agent: &str) -> Vec { - last.unwrap_or_else(|| HiveCopy::read_at(copy).before_read(agent)) +fn current_with(last: Option>, copy: &Path) -> Vec { + last.unwrap_or_else(|| HiveCopy::read_at(copy).before_read()) } /// What one read of the key leaves the agent holding, judged against the /// hive's copy at `copy`; `None` (keep the last value) when the read failed /// or the bucket did not answer. -fn after(agent: &str, copy: &Path, read: Option) -> Option> { +fn after(copy: &Path, read: Option) -> Option> { match read? { - Read::Current(record) => Some(HiveCopy::read_at(copy).after_read(agent, &record)), + Read::Current(record) => Some(HiveCopy::read_at(copy).after_read(&record)), Read::NoBucket => None, } } @@ -63,7 +63,7 @@ pub async fn run() { tracing::info!(%agent, "reading swarm permissions from the swarm queue"); loop { let read = read_once(&client, &agent).await; - if let Some(perms) = after(&agent, Path::new(HIVE_COPY_PATH), read) { + if let Some(perms) = after(Path::new(HIVE_COPY_PATH), read) { let mut current = CURRENT.write().unwrap_or_else(PoisonError::into_inner); if current.as_ref() != Some(&perms) { tracing::info!(?perms, "permissions: the swarm's set changed"); @@ -107,36 +107,29 @@ mod tests { fn a_read_sets_the_list_or_the_default_and_a_failure_keeps_the_last() { let none = Path::new("/nonexistent/swarm-permissions.json"); assert_eq!( - after( - "atlas", - none, - Some(record(r#"{"permissions":["web_tools"]}"#)) - ), + after(none, Some(record(r#"{"permissions":["web_tools"]}"#))), Some(vec![Permission::WebTools]) ); assert_eq!( - after("atlas", none, Some(record(r#"{"permissions":[]}"#))), + after(none, Some(record(r#"{"permissions":[]}"#))), Some(Vec::new()) ); assert_eq!( - after("atlas", none, Some(record("{}"))), + after(none, Some(record("{}"))), Some(Permission::DEFAULT.to_vec()) ); - assert_eq!(after("atlas", none, Some(Read::NoBucket)), None); - assert_eq!(after("atlas", none, None), None); + assert_eq!(after(none, Some(Read::NoBucket)), None); + assert_eq!(after(none, None), None); } #[test] fn before_the_first_read_the_stored_grant_applies() { let (_dir, path) = copy(r#"{"permissions":["web_tools"]}"#); - assert_eq!( - current_with(None, &path, "atlas"), - vec![Permission::WebTools] - ); + assert_eq!(current_with(None, &path), vec![Permission::WebTools]); let (_dir, path) = copy(r#"{"permissions":[]}"#); - assert_eq!(current_with(None, &path, "atlas"), Vec::new()); + assert_eq!(current_with(None, &path), Vec::new()); assert_eq!( - current_with(Some(vec![Permission::Meta]), &path, "atlas"), + current_with(Some(vec![Permission::Meta]), &path), vec![Permission::Meta], "a read outranks the copy" ); @@ -146,17 +139,17 @@ mod tests { fn while_the_import_is_pending_an_unset_key_keeps_the_stored_grant() { let (_dir, path) = copy(r#"{"permissions":["web_tools"],"import_pending":true}"#); assert_eq!( - after("atlas", &path, Some(record("{}"))), + after(&path, Some(record("{}"))), Some(vec![Permission::WebTools]) ); assert_eq!( - after("atlas", &path, Some(record(r#"{"permissions":["meta"]}"#))), + after(&path, Some(record(r#"{"permissions":["meta"]}"#))), Some(vec![Permission::Meta]), "a set key still wins" ); let (_dir, path) = copy(r#"{"permissions":["web_tools"]}"#); assert_eq!( - after("atlas", &path, Some(record("{}"))), + after(&path, Some(record("{}"))), Some(Permission::DEFAULT.to_vec()), "once imported, unset is the default" ); @@ -169,13 +162,10 @@ mod tests { let (_dir, path) = copy(r#"{"permissions":["messaging"],"import_pending":true}"#); let mut last = None; for read in [None, Some(Read::NoBucket), None] { - if let Some(perms) = after("atlas", &path, read) { + if let Some(perms) = after(&path, read) { last = Some(perms); } } - assert_eq!( - current_with(last, &path, "atlas"), - vec![Permission::Messaging] - ); + assert_eq!(current_with(last, &path), vec![Permission::Messaging]); } } diff --git a/hive-c0re/src/agent_config/permissions.rs b/hive-c0re/src/agent_config/permissions.rs index fc4f94c6..1b09fe04 100644 --- a/hive-c0re/src/agent_config/permissions.rs +++ b/hive-c0re/src/agent_config/permissions.rs @@ -6,7 +6,7 @@ //! Format: agent name → the stored //! [`swarm_queue_client::agent_permissions::AgentPermissions`]. An agent with //! no entry, or an entry with `permissions` unset, holds -//! [`Permission::default_for`]; `"permissions": []` holds none. +//! [`Permission::DEFAULT`]; `"permissions": []` holds none. //! //! hive-c0re enforces one member, [`Permission::ManageRootAgent`]: read by //! `lifecycle::host_config::set_nspawn_flags` on every container start, which @@ -40,12 +40,12 @@ pub fn write_at(path: &Path, stored: &Stored) -> std::io::Result<()> { super::write_map(path, stored) } -/// The permissions `agent` holds under `record`. +/// The permissions an agent holds under `record`. #[must_use] -pub fn effective(agent: &str, record: Option<&AgentPermissions>) -> Vec { +pub fn effective(record: Option<&AgentPermissions>) -> Vec { record .and_then(|r| r.permissions.clone()) - .unwrap_or_else(|| Permission::default_for(agent)) + .unwrap_or_else(|| Permission::DEFAULT.to_vec()) } /// Whether `agent` holds `perm` per the file at `path` (normally [`path`]). @@ -54,7 +54,7 @@ pub fn effective(agent: &str, record: Option<&AgentPermissions>) -> Vec std::io::Result { - Ok(effective(agent, read_at(path)?.get(agent)).contains(&perm)) + Ok(effective(read_at(path)?.get(agent)).contains(&perm)) } /// Write `agent`'s [`HiveCopy`] into `runtime_dir`: its entry in the file at @@ -91,25 +91,41 @@ mod tests { #[test] fn unset_is_the_default_and_an_empty_list_is_none() { - assert!(effective("ruth", None).contains(&Permission::ManageRootAgent)); - assert!(effective("ruth", Some(&record("{}"))).contains(&Permission::ManageRootAgent)); - assert!(effective("ruth", Some(&record(r#"{"permissions":[]}"#))).is_empty()); - assert_eq!(effective("atlas", None), Permission::DEFAULT.to_vec()); + assert_eq!(effective(None), Permission::DEFAULT.to_vec()); + assert_eq!(effective(Some(&record("{}"))), Permission::DEFAULT.to_vec()); + assert!(effective(Some(&record(r#"{"permissions":[]}"#))).is_empty()); assert_eq!( - effective( - "atlas", - Some(&record(r#"{"permissions":["manage_root_agent"]}"#)) - ), + effective(Some(&record(r#"{"permissions":["manage_root_agent"]}"#))), vec![Permission::ManageRootAgent] ); } + /// No name holds more than the default without an entry: `ruth` with no + /// entry holds `DEFAULT`, and only an entry grants her `manage_root_agent`. #[test] - fn a_missing_file_holds_the_defaults() { + fn ruth_with_no_entry_holds_the_default() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join(FILE); - assert!(holds(&path, "ruth", Permission::ManageRootAgent).expect("missing is empty")); - assert!(!holds(&path, "atlas", Permission::ManageRootAgent).expect("missing is empty")); + for agent in ["ruth", "atlas"] { + for perm in Permission::ALL { + assert_eq!( + holds(&path, agent, *perm).expect("missing is empty"), + Permission::DEFAULT.contains(perm), + "{agent} {perm:?}" + ); + } + } + + write_at( + &path, + &Stored::from([( + "ruth".to_owned(), + record(r#"{"permissions":["manage_root_agent"]}"#), + )]), + ) + .expect("seed"); + assert!(holds(&path, "ruth", Permission::ManageRootAgent).expect("read")); + assert!(!holds(&path, "atlas", Permission::ManageRootAgent).expect("read")); } /// With the queue down nothing is read, so the agent starts on the copy @@ -131,7 +147,7 @@ mod tests { hand_at(&stored, true, "atlas", &runtime).expect("hand"); let copy = HiveCopy::read_at(&runtime.join(HIVE_COPY_FILE)); assert!(copy.import_pending); - assert_eq!(copy.before_read("atlas"), vec![Permission::WebTools]); + assert_eq!(copy.before_read(), vec![Permission::WebTools]); hand_at(&stored, false, "iris", &runtime).expect("hand"); let copy = HiveCopy::read_at(&runtime.join(HIVE_COPY_FILE)); diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index 8a074225..c14b5d53 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -866,11 +866,10 @@ impl Coordinator { /// --continue's session). Best-effort; broker errors are logged /// but don't propagate. pub fn kick_agent(&self, name: &str, reason: &str) { - // Sub-agents bind their state at /agents//state. The - // manager has both /state (legacy mount) and /agents - // bind-mounted, so /agents//state resolves there too — - // use that uniformly so the wake message has one canonical - // path that works everywhere. + // Every agent binds its state at /agents//state, and a + // `manage_root_agent` holder has all of /agents bind-mounted, so + // the path resolves there too — use that uniformly so the wake + // message has one canonical path that works everywhere. let body = format!( "{reason}\n\nYou were just (re)started by the operator. \ If you were mid-task, check `/agents/{name}/state/` for \ diff --git a/hive-c0re/src/lifecycle/host_config.rs b/hive-c0re/src/lifecycle/host_config.rs index 06fc6b57..6b848ba8 100644 --- a/hive-c0re/src/lifecycle/host_config.rs +++ b/hive-c0re/src/lifecycle/host_config.rs @@ -843,13 +843,13 @@ mod tests { assert_eq!(std::fs::read(&path).expect("read"), truncated.as_bytes()); } - /// Unset is the default, which gives ruth the recovery mounts and nobody - /// else; a grant or an explicit empty list replaces it. + /// Unset is the default, which gives nobody the recovery mounts, `ruth` + /// included; only a grant does. #[test] - fn the_swarm_grant_or_the_default_decides_the_recovery_mounts() { + fn the_swarm_grant_decides_the_recovery_mounts() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("swarm-agent-permissions.json"); - assert!(holds_manage_root_agent("ruth", &path)); + assert!(!holds_manage_root_agent("ruth", &path)); assert!(!holds_manage_root_agent("alice", &path)); std::fs::write( &path, diff --git a/hive-c0re/src/migrate.rs b/hive-c0re/src/migrate.rs index 002ae255..09942e60 100644 --- a/hive-c0re/src/migrate.rs +++ b/hive-c0re/src/migrate.rs @@ -11,10 +11,9 @@ //! only if it cannot be expressed as convergence, and expect to delete //! it once every hive has passed it. //! - **Create-time setup.** A thing that is true from birth is set where -//! the agent is created, not re-asserted each morning. Ruth's -//! permissions need neither: with no key in the swarm's -//! `agent-permissions` bucket she holds the code-constant default -//! (`Permission::default_for`). +//! the agent is created, not re-asserted each morning: ruth's +//! `manage_root_agent`, for one, is stored in the swarm's +//! `agent-permissions` bucket when the swarm UI creates her. //! //! Kill-switch: `HIVE_SKIP_META_MIGRATION=1`. Full sequence and phase //! details: `docs/agent-lifecycle/config-changes.md::Startup migrations (older hosts)`. diff --git a/hive-c0re/src/swarm_notices.rs b/hive-c0re/src/swarm_notices.rs index 914fcd70..ac663341 100644 --- a/hive-c0re/src/swarm_notices.rs +++ b/hive-c0re/src/swarm_notices.rs @@ -1,10 +1,8 @@ //! Publishing lifecycle notices onto the swarm queue. //! -//! Replaces the old `push_todo(MANAGER_AGENT, ...)` fallback the -//! lifecycle-notice call sites used to reach for when there was nobody -//! else to tell. Every hive is swarm-controlled now, so there is no case -//! left that needs a manager-agent recipient — this module has no such -//! fallback, on purpose, not by omission. +//! There is no fallback recipient agent: every hive is swarm-controlled, +//! so a notice always goes to the swarm queue, and no agent receives it by +//! name. //! //! **Why a stream and not the [`crate::swarm_status`] KV bucket shape**: //! a status snapshot has a current value a late reader can always ask diff --git a/hive-c0re/src/workers/permissions_import.rs b/hive-c0re/src/workers/permissions_import.rs index 97db2a07..3b0dca89 100644 --- a/hive-c0re/src/workers/permissions_import.rs +++ b/hive-c0re/src/workers/permissions_import.rs @@ -54,27 +54,17 @@ fn parse(agent: &str, name: &str) -> Option { /// only unknown names, was the default groups. `lifecycle`/`forge` count as /// known (they parsed) and are then dropped, so an entry of only those /// held no tools and imports as none. -/// - capabilities: the entry's names. With the file present, `ruth` having no -/// entry means her grant was revoked: the file was created holding it, and -/// a revoke removed the entry. With no file, the default. +/// - capabilities: the entry's names; no entry, or no file, is none. /// -/// An agent whose list equals [`Permission::default_for`] is left out, so its -/// key stays unset and follows the default. +/// An agent whose list equals [`Permission::DEFAULT`] is left out, so its key +/// stays unset and follows the default. fn grants(caps: Option<&FileMap>, groups: Option<&FileMap>) -> Grants { let mut agents: BTreeSet<&str> = BTreeSet::new(); agents.extend(caps.iter().flat_map(|m| m.keys().map(String::as_str))); agents.extend(groups.iter().flat_map(|m| m.keys().map(String::as_str))); - if caps.is_some() { - agents.insert(crate::lifecycle::MANAGER_NAME); - } + let default = Permission::DEFAULT; let mut out = Grants::new(); for agent in agents { - let default = Permission::default_for(agent); - let tool_default: Vec = default - .iter() - .copied() - .filter(|p| *p != Permission::ManageRootAgent) - .collect(); let tool_part = match groups.and_then(|m| m.get(agent)) { Some(names) if names.iter().any(|n| { @@ -84,19 +74,12 @@ fn grants(caps: Option<&FileMap>, groups: Option<&FileMap>) -> Grants { { names.iter().filter_map(|n| parse(agent, n)).collect() } - _ => tool_default, - }; - let cap_part: Vec = match caps { - Some(m) => m - .get(agent) - .map(|names| names.iter().filter_map(|n| parse(agent, n)).collect()) - .unwrap_or_default(), - None => default - .iter() - .copied() - .filter(|p| *p == Permission::ManageRootAgent) - .collect(), + _ => default.to_vec(), }; + let cap_part: Vec = caps + .and_then(|m| m.get(agent)) + .map(|names| names.iter().filter_map(|n| parse(agent, n)).collect()) + .unwrap_or_default(); let mut list: Vec = Vec::new(); for perm in tool_part.into_iter().chain(cap_part) { if !list.contains(&perm) { @@ -275,24 +258,15 @@ mod tests { "sock": ["lifecycle"]}"#, ); let out = grants(Some(&caps), Some(&groups)); - assert!(!out.contains_key("ruth"), "ruth's seed is her default"); + assert_eq!( + out["ruth"], + vec![Messaging, Meta, Inbox, Execution, ManageRootAgent] + ); assert_eq!(out["atlas"], vec![Messaging, WebTools, ManageRootAgent]); assert_eq!(out["iris"], vec![Messaging]); assert_eq!(out["sock"], Vec::::new()); } - /// Without a capabilities entry, ruth's existing file means a revoke; with - /// no capabilities file at all, her default stands. - #[test] - fn a_revoked_ruth_stays_revoked() { - let out = grants(Some(&file("{}")), None); - assert_eq!(out["ruth"], vec![Messaging, Meta, Inbox, Execution]); - - let out = grants(None, Some(&file(r#"{"atlas": ["messaging"]}"#))); - assert!(!out.contains_key("ruth")); - assert_eq!(out["atlas"], vec![Messaging]); - } - /// The seed writes once, from the files, and never over a stored read. #[test] fn the_seed_copies_the_files_only_when_nothing_is_stored() { diff --git a/hive-c0re/src/workers/swarm_permissions.rs b/hive-c0re/src/workers/swarm_permissions.rs index cc881da9..52955684 100644 --- a/hive-c0re/src/workers/swarm_permissions.rs +++ b/hive-c0re/src/workers/swarm_permissions.rs @@ -155,7 +155,7 @@ async fn hand_all() { /// Whether `agent` holds the one permission hive-c0re enforces under `stored`. fn manages(agent: &str, stored: &Stored) -> bool { - permissions::effective(agent, stored.get(agent)).contains(&Permission::ManageRootAgent) + permissions::effective(stored.get(agent)).contains(&Permission::ManageRootAgent) } /// Restart each running agent in `changed` once its current turn is done, so @@ -216,7 +216,7 @@ mod tests { assert!(changed.is_empty()); assert!(crate::permissions::read_at(&path).expect("read").is_empty()); assert!( - crate::permissions::holds( + !crate::permissions::holds( &path, "ruth", hive_sh4re::permissions::Permission::ManageRootAgent @@ -225,33 +225,22 @@ mod tests { ); } - /// Revoking ruth's default, and granting atlas, restarts both; clearing - /// them again restarts both back. + /// Granting `manage_root_agent` to agents on the default restarts them; + /// clearing it again restarts them back. #[tokio::test] async fn a_changed_manage_root_agent_asks_for_a_restart() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("p.json"); - let changed = sync(&path, |agent| { - Ok(Read::Current(record(if agent == "ruth" { - r#"{"permissions":[]}"# - } else { - r#"{"permissions":["manage_root_agent"]}"# - }))) - }) - .await - .expect("read"); + let granted = || { + Ok(Read::Current(record( + r#"{"permissions":["manage_root_agent"]}"#, + ))) + }; + let changed = sync(&path, |_| granted()).await.expect("read"); assert_eq!(changed, agents()); // The same answer again changes nothing. - let unchanged = sync(&path, |agent| { - Ok(Read::Current(record(if agent == "ruth" { - r#"{"permissions":[]}"# - } else { - r#"{"permissions":["manage_root_agent"]}"# - }))) - }) - .await - .expect("read"); + let unchanged = sync(&path, |_| granted()).await.expect("read"); assert!(unchanged.is_empty()); let cleared = sync(&path, |_| Ok(Read::Current(AgentPermissions::default()))) @@ -268,7 +257,7 @@ mod tests { let path = dir.path().join("p.json"); let changed = sync(&path, |agent| { Ok(Read::Current(if agent == "ruth" { - record(r#"{"permissions":["manage_root_agent"]}"#) + record(r#"{"permissions":["meta"]}"#) } else { record(r#"{"permissions":["messaging","web_tools"]}"#) })) @@ -289,10 +278,16 @@ mod tests { async fn a_failed_read_keeps_the_last_value() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("p.json"); - let revoked = || Ok(Read::Current(record(r#"{"permissions":[]}"#))); - // atlas holds no manage_root_agent either way, so only ruth changes. + // atlas stays on the default, so only ruth changes. + let ruth_granted = |agent: &str| { + Ok(Read::Current(record(if agent == "ruth" { + r#"{"permissions":["manage_root_agent"]}"# + } else { + "{}" + }))) + }; assert_eq!( - sync(&path, |_| revoked()).await.expect("read"), + sync(&path, ruth_granted).await.expect("read"), vec!["ruth".to_owned()] ); let before = std::fs::read(&path).expect("stored"); diff --git a/hive-sh4re/src/manager.rs b/hive-sh4re/src/manager.rs index f5b24920..028c96aa 100644 --- a/hive-sh4re/src/manager.rs +++ b/hive-sh4re/src/manager.rs @@ -4,9 +4,6 @@ use hive_types::Ident; use serde::{Deserialize, Serialize}; -/// Logical name the broker uses for the manager. -pub const MANAGER_AGENT: &str = "ruth"; - /// Logical name of the human operator: the sender of operator-authored /// messages. Not a recipient — `send` to it fails with /// [`OPERATOR_SEND_REFUSAL`]. @@ -57,7 +54,7 @@ pub enum HelperEvent { #[cfg(test)] mod reserved_name_tests { - use super::{MANAGER_AGENT, OPERATOR_RECIPIENT, SYSTEM_SENDER}; + use super::{OPERATOR_RECIPIENT, SYSTEM_SENDER}; use hive_types::{Ident, RESERVED_NAMES_ENV, is_reserved_name}; /// The blacklist as nix rendered it for this test run. @@ -132,7 +129,7 @@ mod reserved_name_tests { fn manager_name_is_taken_not_reserved() { let owned = reserved(); let reserved: Vec<&str> = owned.iter().map(String::as_str).collect(); - assert!(Ident::parse(MANAGER_AGENT).is_ok()); - assert!(!is_reserved_name(MANAGER_AGENT, &reserved)); + assert!(Ident::parse("ruth").is_ok()); + assert!(!is_reserved_name("ruth", &reserved)); } } diff --git a/hive-sh4re/src/permissions.rs b/hive-sh4re/src/permissions.rs index 2c08fced..b309f86a 100644 --- a/hive-sh4re/src/permissions.rs +++ b/hive-sh4re/src/permissions.rs @@ -5,7 +5,7 @@ //! is enforced where it applies: the MCP-tool and built-in-tool members by //! the processes that spawn `claude` (the agent harness and the subagent //! daemon), [`Permission::ManageRootAgent`] by hive-c0re's bind mounts. An -//! agent the swarm sets nothing for holds [`Permission::default_for`]. +//! agent the swarm sets nothing for holds [`Permission::DEFAULT`]. //! //! It also resolves the *built-in* claude tool list ([`builtin_tools_for`]): //! that set is permission-gated too (`web_tools`), so it belongs here rather @@ -135,22 +135,10 @@ impl Permission { Self::ManageRootAgent, ]; - /// What an agent the swarm sets nothing for holds. + /// 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]; - /// What `agent` holds when the swarm sets nothing for it: [`Self::DEFAULT`], - /// plus [`Self::ManageRootAgent`] for the root agent - /// ([`crate::manager::MANAGER_AGENT`]), which recovers the others. - #[must_use] - pub fn default_for(agent: &str) -> Vec { - let mut perms = Self::DEFAULT.to_vec(); - if agent == crate::manager::MANAGER_AGENT { - perms.push(Self::ManageRootAgent); - } - perms - } - /// 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. @@ -227,17 +215,6 @@ mod tests { assert!(serde_json::from_str::("\"lifecycle\"").is_err()); } - #[test] - fn only_the_root_agent_defaults_to_managing_the_others() { - assert_eq!( - Permission::default_for("atlas"), - Permission::DEFAULT.to_vec() - ); - let ruth = Permission::default_for(crate::manager::MANAGER_AGENT); - assert!(ruth.contains(&Permission::ManageRootAgent)); - assert_eq!(&ruth[..Permission::DEFAULT.len()], Permission::DEFAULT); - } - /// 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/swarm_models.rs b/hive-subagent-mcp/src/swarm_models.rs index 5b6a74a9..46f35aa8 100644 --- a/hive-subagent-mcp/src/swarm_models.rs +++ b/hive-subagent-mcp/src/swarm_models.rs @@ -45,8 +45,7 @@ pub fn permissions_now() -> Vec { .read() .unwrap_or_else(std::sync::PoisonError::into_inner) .clone(); - let agent = std::env::var(crate::swarm_term::ENV_AGENT_NAME).unwrap_or_default(); - last.unwrap_or_else(|| HiveCopy::read_at(Path::new(HIVE_COPY_PATH)).before_read(&agent)) + last.unwrap_or_else(|| HiveCopy::read_at(Path::new(HIVE_COPY_PATH)).before_read()) } /// How long after a failed connect the task tries again. @@ -174,7 +173,7 @@ async fn read_permissions( swarm_queue_client::ensure_connected(client).ok()?; match tokio::time::timeout(READ_TIMEOUT, agent_permissions::read(client, agent)).await { Ok(Ok(agent_permissions::Read::Current(record))) => { - Some(HiveCopy::read_at(Path::new(HIVE_COPY_PATH)).after_read(agent, &record)) + Some(HiveCopy::read_at(Path::new(HIVE_COPY_PATH)).after_read(&record)) } Ok(Ok(agent_permissions::Read::NoBucket)) => None, Ok(Err(e)) => { diff --git a/swarm-controller/src/agent_permissions.rs b/swarm-controller/src/agent_permissions.rs index ba2d186f..26c32005 100644 --- a/swarm-controller/src/agent_permissions.rs +++ b/swarm-controller/src/agent_permissions.rs @@ -348,6 +348,21 @@ pub(crate) fn spawn(client: async_nats::Client, wanted: Arc, hives } tokio::time::sleep(RETRY).await; }; + tokio::spawn({ + let (store, wanted, hives) = (store.clone(), Arc::clone(&wanted), hives.clone()); + async move { + loop { + match seed_ruth_grant(&store, &wanted, &hives).await { + Ok(()) => break, + Err(e) => tracing::warn!( + error = %format!("{e:#}"), + "agent permissions: seeding ruth's grant failed; retrying" + ), + } + tokio::time::sleep(RETRY).await; + } + } + }); let subject = agent_permissions::import_subject_wildcard(); loop { match client.subscribe(subject.clone()).await { @@ -407,6 +422,60 @@ async fn answer_import( } } +/// The agent [`seed_into`] seeds. +const SEEDED_AGENT: &str = "ruth"; + +/// One-shot migration, run at every start; expect to delete it (with +/// [`seed_into`] and its call in [`spawn`]) once every swarm has passed it. +/// Builds before it granted `manage_root_agent` by name to an agent called +/// `ruth` whose permissions were unset; this writes that grant into her key +/// so she keeps her recovery mounts. Idempotent: a set key is never touched. +async fn seed_ruth_grant( + store: &async_nats::jetstream::kv::Store, + wanted: &WantedWriter, + hives: &[String], +) -> anyhow::Result<()> { + let mut declarations = Vec::with_capacity(hives.len()); + for hive in hives { + if let Some(d) = wanted.view(hive).await? { + declarations.push(d); + } + } + seed_into(store, &crate::agent_renewal::live_agents(&declarations)).await?; + Ok(()) +} + +/// Write [`Permission::DEFAULT`] plus `manage_root_agent` under +/// [`SEEDED_AGENT`]'s key when `declared` holds her and her `permissions` +/// are unset (no key, a deleted key, or `{}`). `true` when it wrote. +async fn seed_into( + store: &async_nats::jetstream::kv::Store, + declared: &BTreeSet, +) -> anyhow::Result { + if !declared.contains(SEEDED_AGENT) { + return Ok(false); + } + let (current, revision, undecodable) = + read_or_default::(store, SEEDED_AGENT).await?; + if current.permissions.is_some() || undecodable { + return Ok(false); + } + let mut perms = Permission::DEFAULT.to_vec(); + perms.push(Permission::ManageRootAgent); + let value = AgentPermissions { + permissions: Some(perms), + }; + let written = write(store, SEEDED_AGENT, &value, revision).await?; + if written { + tracing::info!( + agent = SEEDED_AGENT, + ?value, + "agent permissions: one-shot seed of the grant ruth held by name" + ); + } + Ok(written) +} + /// Write each agent's imported permissions under its key when it has none, /// for the agents in `declared` only. async fn import_into( @@ -440,7 +509,7 @@ async fn import_into( mod tests { use super::{ Permissions, delete_agent_permissions, get_agent_permissions, import_into, - put_agent_permissions, + put_agent_permissions, seed_into, }; use crate::test_nats::TestNats; use axum::extract::{Path, State}; @@ -636,6 +705,81 @@ mod tests { assert_eq!(published(&client, "iris").await, None); } + const SEEDED: &str = + r#"{"permissions":["messaging","meta","inbox","execution","manage_root_agent"]}"#; + + /// A declared `ruth` whose permissions are unset (no key, or a deleted + /// one, or `{}`) gets the default plus `manage_root_agent`, once. + #[tokio::test] + async fn the_seed_writes_an_unset_ruth_grant() { + let nats = TestNats::start(); + let client = nats.client().await; + let store = swarm_queue_client::agent_permissions::open_or_create(&client) + .await + .expect("bucket"); + let declared = ["atlas".to_owned(), "ruth".to_owned()].into(); + + assert!(seed_into(&store, &declared).await.expect("seeded")); + assert_eq!(published(&client, "ruth").await.as_deref(), Some(SEEDED)); + assert_eq!(published(&client, "atlas").await, None, "only ruth"); + assert!(!seed_into(&store, &declared).await.expect("idempotent")); + + store.delete("ruth").await.expect("delete"); + assert!(seed_into(&store, &declared).await.expect("seeded")); + assert_eq!(published(&client, "ruth").await.as_deref(), Some(SEEDED)); + + store + .put("ruth", b"{}".to_vec().into()) + .await + .expect("unset"); + assert!(seed_into(&store, &declared).await.expect("seeded")); + assert_eq!(published(&client, "ruth").await.as_deref(), Some(SEEDED)); + } + + /// A ruth key the swarm already set, even to nothing, is left as it is; + /// so is one this build cannot decode. + #[tokio::test] + async fn the_seed_leaves_a_set_ruth_key_alone() { + let nats = TestNats::start(); + let client = nats.client().await; + let store = swarm_queue_client::agent_permissions::open_or_create(&client) + .await + .expect("bucket"); + let declared = ["ruth".to_owned()].into(); + for set in [ + r#"{"permissions":[]}"#, + r#"{"permissions":["web_tools"]}"#, + r#"{"permissions":["fly_to_the_moon"]}"#, + ] { + store + .put("ruth", set.as_bytes().to_vec().into()) + .await + .expect("set"); + assert!(!seed_into(&store, &declared).await.expect("read"), "{set}"); + assert_eq!(published(&client, "ruth").await.as_deref(), Some(set)); + } + } + + /// With no `ruth` declared nothing is written; declaring her is what + /// makes the same call write. + #[tokio::test] + async fn the_seed_writes_nothing_without_a_declared_ruth() { + let nats = TestNats::start(); + let client = nats.client().await; + let store = swarm_queue_client::agent_permissions::open_or_create(&client) + .await + .expect("bucket"); + + let declared = ["atlas".to_owned()].into(); + assert!(!seed_into(&store, &declared).await.expect("read")); + assert_eq!(published(&client, "ruth").await, None); + assert_eq!(published(&client, "atlas").await, None); + + let declared = ["atlas".to_owned(), "ruth".to_owned()].into(); + assert!(seed_into(&store, &declared).await.expect("seeded")); + assert_eq!(published(&client, "ruth").await.as_deref(), Some(SEEDED)); + } + /// `POST /api/agents` with `permissions` stores them under the new agent's /// key in the same call; a list naming one twice is refused before /// anything is stored or queued. diff --git a/swarm-queue-client/src/agent_permissions.rs b/swarm-queue-client/src/agent_permissions.rs index 9dd80ca8..e2d379da 100644 --- a/swarm-queue-client/src/agent_permissions.rs +++ b/swarm-queue-client/src/agent_permissions.rs @@ -3,7 +3,7 @@ //! //! **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 -//! the compiled-in default (`Permission::default_for`); an empty list grants +//! the compiled-in default (`Permission::DEFAULT`); an empty list grants //! nothing. //! //! **Two readers, each enforcing its own part.** The hive running the agent @@ -171,29 +171,25 @@ impl HiveCopy { }) } - /// What `agent` holds before any read of its key has succeeded: the + /// 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, agent: &str) -> Vec { + pub fn before_read(&self) -> Vec { self.stored .permissions .clone() - .unwrap_or_else(|| hive_sh4re::permissions::Permission::default_for(agent)) + .unwrap_or_else(|| hive_sh4re::permissions::Permission::DEFAULT.to_vec()) } - /// What `agent` holds after reading `read` from its key. An unset key + /// 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, - agent: &str, - 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(agent), - None => hive_sh4re::permissions::Permission::default_for(agent), + None if self.import_pending => self.before_read(), + None => hive_sh4re::permissions::Permission::DEFAULT.to_vec(), } } } @@ -307,20 +303,17 @@ mod tests { let web = vec![Permission::WebTools]; let default = Permission::DEFAULT.to_vec(); - assert_eq!( - copy(r#"{"permissions":["web_tools"]}"#).before_read("atlas"), - web - ); - assert_eq!(copy("{}").before_read("atlas"), default); + assert_eq!(copy(r#"{"permissions":["web_tools"]}"#).before_read(), web); + assert_eq!(copy("{}").before_read(), default); let pending = copy(r#"{"permissions":["web_tools"],"import_pending":true}"#); - assert_eq!(pending.after_read("atlas", &record("{}")), web); + assert_eq!(pending.after_read(&record("{}")), web); assert_eq!( - pending.after_read("atlas", &record(r#"{"permissions":[]}"#)), + pending.after_read(&record(r#"{"permissions":[]}"#)), Vec::new() ); let settled = copy(r#"{"permissions":["web_tools"]}"#); - assert_eq!(settled.after_read("atlas", &record("{}")), default); + assert_eq!(settled.after_read(&record("{}")), default); } /// Fields a newer controller adds must not cost an older hive the ones it diff --git a/swarmctl/src/main.rs b/swarmctl/src/main.rs index 4875734e..c4a9c914 100644 --- a/swarmctl/src/main.rs +++ b/swarmctl/src/main.rs @@ -296,8 +296,7 @@ enum AgentVerb { /// The agent applies its tool permissions at its next turn; the hive /// running it restarts it, after its current turn, when /// `manage_root_agent` changes. Unset leaves the agent on the default: - /// `messaging`, `meta`, `inbox`, `execution`, plus `manage_root_agent` - /// for `ruth`. + /// `messaging`, `meta`, `inbox`, `execution`, whatever its name. Permissions { #[command(subcommand)] command: PermissionsVerb,