Watch
0
0
Fork
You've already forked hyperhive
0

grant manage_root_agent by stored permission only, not by the name ruth

Permission::default_for and hive-sh4re's MANAGER_AGENT are gone: every
agent with no permissions key holds Permission::DEFAULT, ruth included.
The agent-name parameters that only fed default_for go with it
(permissions::effective, HiveCopy::before_read/after_read and their
callers).

swarm-controller seeds the grant ruth held by name: at start, a declared
ruth whose permissions are unset gets DEFAULT plus manage_root_agent
written into her key, once, logged. A set key is never touched. It is a
one-shot to delete once every swarm has passed it.

The swarm UI setup assistant opens the create form with
manage_root_agent checked, so a new ruth is created with the grant.
permissions_import no longer adds ruth to the import or splits her
default. Docs and help text no longer say ruth holds the grant by
default; swarmctl-cli.md regenerated.
This commit is contained in:
atlas 2026-10-16 23:10:05 +02:00 • committed by mara
commit 7b5400ecb8
26 changed files with 380 additions and 216 deletions

View file

@ -78,9 +78,9 @@ module docs for the exact call chain.
## Hard-coded manager behaviour ## Hard-coded manager behaviour
The **manager (`ruth`)** differs from other agents in naming/bootstrap, The **manager (`ruth`)** differs from other agents in naming/bootstrap
its socket flavour and its default permissions; its prompt, tool and its socket flavour; its permissions, prompt, tool allow-list and
allow-list and state dirs work as for every other agent. state dirs work as for every other agent.
<!-- vale write-good.Passive = NO --> <!-- vale write-good.Passive = NO -->
@ -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 both flavours; matrix, bash and forge don't call it — their
notifications push a todo on the harness's in-agent socket (see 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)). [`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 `/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 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 real config change is a PR from a clone), plus RO mounts for
`/applied` (diff against what's deployed) and `/meta` (system-wide `/applied` (diff against what's deployed) and `/meta` (system-wide
deploy log). That grant is the `manage_root_agent` permission: ruth deploy log). Any agent holding it gets the same mounts.
holds it by default, and any agent holding it gets the same mounts.
The operator dashboard's `POST /api/meta-update` is the only path The operator dashboard's `POST /api/meta-update` is the only path
that writes `flake.lock`. that writes `flake.lock`.
- **Prompt** — identical for every container: `prompts/system.md` - **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")`). (`filter_role_blocks(SAMPLE, "manager")`).
- **Tool allow-list** — the MCP tools claude sees come from the agent's - **Tool allow-list** — the MCP tools claude sees come from the agent's
permissions alone, the same mechanism for every agent permissions alone, the same mechanism for every agent
([`docs/turn-loop/mcp.md`](../turn-loop/mcp.md)). Ruth's default ([`docs/turn-loop/mcp.md`](../turn-loop/mcp.md)). Ruth's permissions
permissions add only `manage_root_agent` (`Permission::default_for`). are the default plus `manage_root_agent`, stored for her like any grant.
- **State dirs** — _not_ special-cased: `HYPERHIVE_STATE_DIR` is - **State dirs** — _not_ special-cased: `HYPERHIVE_STATE_DIR` is
injected uniformly via `systemd.globalEnvironment` for every injected uniformly via `systemd.globalEnvironment` for every
container including the manager, so all token/state paths resolve container including the manager, so all token/state paths resolve

View file

@ -270,10 +270,13 @@ dirs.
⚠️ nspawn bakes bind flags at container start, so hive-c0re restarts ⚠️ nspawn bakes bind flags at container start, so hive-c0re restarts
an agent, after its current turn, when its `manage_root_agent` changes. an agent, after its current turn, when its `manage_root_agent` changes.
The root agent holds the permission by default: an agent whose record No agent holds the permission by default: an agent whose record leaves
leaves `permissions` unset gets the default, which includes `permissions` unset gets the default, which doesn't include
`manage_root_agent` for `ruth` and for no other agent. Setting her `manage_root_agent`, whatever the agent's name. The swarm UI's setup
permissions without it revokes it. 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/<n>/config/`) is pre-configured Each proposed repo (`/agents/<n>/config/`) is pre-configured
with `applied` as a git remote pointing at with `applied` as a git remote pointing at
@ -331,8 +334,10 @@ Differences from sub-agents:
(vs `agent-base`). (vs `agent-base`).
- Web UI port via `lifecycle::agent_web_port("ruth")` — same - Web UI port via `lifecycle::agent_web_port("ruth")` — same
FNV-1a hash as every other agent (8100..8999 range). FNV-1a hash as every other agent (8100..8999 range).
- `set_nspawn_flags` adds two extra binds: `/var/lib/hyperhive/agents` - The root agent holds `manage_root_agent` (the swarm UI's setup
→ `/agents` (RW) so the root agent can edit per-agent proposed repos, 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 and `/var/lib/hyperhive/applied` → `/applied` (RO) so the root agent
can `git fetch` deployed/failed/denied tags from any agent's can `git fetch` deployed/failed/denied tags from any agent's
authoritative applied repo (see "Root-agent view of applied" below). authoritative applied repo (see "Root-agent view of applied" below).

View file

@ -408,7 +408,8 @@ Contents:
to live here; nothing reads or writes it now. An old copy in the meta repo to live here; nothing reads or writes it now. An old copy in the meta repo
is harmless and you can delete it. 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` Outside the meta repo, `/var/lib/hyperhive/swarm-agent-permissions.json`
holds the permissions the swarm grants this hive's agents, as holds the permissions the swarm grants this hive's agents, as

View file

@ -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, 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 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 opens the create form with `ruth` filled in and the `manage_root_agent`
her config repo and deploys her to the hive you pick. 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 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 but drives no turns. Open `/agents` in the swarm UI and set her `up` from her

View file

@ -301,8 +301,8 @@ the agent's key in the `agent-permissions` KV bucket
writes. Set them with `swarmctl agent permissions get|set|clear <agent>` writes. Set them with `swarmctl agent permissions get|set|clear <agent>`
(`/api/agents/{name}/permissions`), or on the swarm UI's create-agent screen (`/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 (`permissions` in `POST /api/agents`). A missing key or an unset field means
the default (`Permission::default_for`): `messaging`, `meta`, `inbox`, the default (`Permission::DEFAULT`): `messaging`, `meta`, `inbox`,
`execution`, plus `manage_root_agent` for `ruth`. `"permissions": []` grants `execution`, for every agent whatever its name. `"permissions": []` grants
none. none.
**How a change applies** — **How a change applies** —

View file

@ -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. 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 <COMMAND>` **Usage:** `swarmctl agent permissions <COMMAND>`

View file

@ -26,8 +26,8 @@
// shared `ui/` form kit (`TextField`/`SelectField`/`Button`) rather than // shared `ui/` form kit (`TextField`/`SelectField`/`Button`) rather than
// page-scoped input/button chrome — see that kit's own comments for why. // page-scoped input/button chrome — see that kit's own comments for why.
// //
// Permissions: checkboxes from `GET /api/permissions`, on the default set; // Permissions: checkboxes from `GET /api/permissions`, on the default set plus
// sent only when changed, so an untouched agent follows the default. // `extraPermissions`; sent only when that differs from the default.
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import { Link } from "wouter-preact"; import { Link } from "wouter-preact";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
@ -50,6 +50,7 @@ import {
import { FormField } from "../ui/form-field/FormField.js"; import { FormField } from "../ui/form-field/FormField.js";
import { MultiselectFilterOptions } from "../ui/multiselect-filter/MultiselectFilter.js"; import { MultiselectFilterOptions } from "../ui/multiselect-filter/MultiselectFilter.js";
import { import {
initialSelection,
requestPermissions, requestPermissions,
togglePermission, togglePermission,
} from "./CreateAgentPermissions.js"; } from "./CreateAgentPermissions.js";
@ -98,9 +99,12 @@ const NAME_PATTERN = "[a-z0-9\\-]{1,63}";
export function CreateAgentForm({ export function CreateAgentForm({
onClose, onClose,
initialName = "", initialName = "",
extraPermissions = [],
}: { }: {
/** The name field's starting value, for a caller that suggests one. */ /** The name field's starting value, for a caller that suggests one. */
initialName?: string; 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 /** Rendered as the form panel's own close button — this form always
* mounts inside a `plain` `Dialog` (see `AgentsPage`), which no longer * mounts inside a `plain` `Dialog` (see `AgentsPage`), which no longer
* floats its own. */ * floats its own. */
@ -157,7 +161,7 @@ export function CreateAgentForm({
} }
const data = (await r.json()) as PermissionCatalogue; const data = (await r.json()) as PermissionCatalogue;
setCatalogue(data); setCatalogue(data);
setSelected(data.default); setSelected(initialSelection(data.all, data.default, extraPermissions));
})().catch((e: unknown) => setCatalogueError({ detail: String(e) })); })().catch((e: unknown) => setCatalogueError({ detail: String(e) }));
}, []); }, []);
@ -195,7 +199,10 @@ export function CreateAgentForm({
const data = (await r.json()) as CreateAgentResponse; const data = (await r.json()) as CreateAgentResponse;
setResult({ status: "done", nodeId: data.node_id }); setResult({ status: "done", nodeId: data.node_id });
setName(""); setName("");
if (catalogue) setSelected(catalogue.default); if (catalogue)
setSelected(
initialSelection(catalogue.all, catalogue.default, extraPermissions),
);
} catch (err) { } catch (err) {
setResult({ status: "error", problem: { detail: String(err) } }); setResult({ status: "error", problem: { detail: String(err) } });
} }

View file

@ -22,3 +22,19 @@ export function requestPermissions(
selected.every((p) => defaults.includes(p)); selected.every((p) => defaults.includes(p));
return same ? undefined : selected; 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)),
];
}

View file

@ -1,11 +1,15 @@
// <SetupAssistant> - the start page's first-time helper: a paperclip with a // <SetupAssistant> - 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 // 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 // has no `ruth` (see `ruthIsMissing`), and a click opens the create-agent
// form with her name filled in; the form already selects the default // form with her name filled in and `manage_root_agent` checked on top of the
// permissions and the only hive. No dismiss: once ruth exists it goes away. // 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 { useEffect, useState } from "preact/hooks";
import { Dialog } from "../ui/dialog/Dialog.js"; 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 { CreateAgentForm } from "./CreateAgentForm.js";
import "./SetupAssistant.css"; import "./SetupAssistant.css";
@ -70,7 +74,11 @@ export function SetupAssistant() {
label="create agent" label="create agent"
plain plain
> >
<CreateAgentForm initialName="ruth" onClose={() => setOpen(false)} /> <CreateAgentForm
initialName="ruth"
extraPermissions={RUTH_EXTRA_PERMISSIONS}
onClose={() => setOpen(false)}
/>
</Dialog> </Dialog>
</> </>
); );

View file

@ -20,3 +20,7 @@ export async function ruthIsMissing(fetchImpl: Fetch): Promise<boolean> {
return false; 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"];

View file

@ -3,6 +3,7 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import { import {
initialSelection,
requestPermissions, requestPermissions,
togglePermission, togglePermission,
} from "../src/pages/CreateAgentPermissions.ts"; } from "../src/pages/CreateAgentPermissions.ts";
@ -29,3 +30,17 @@ test("a changed set is sent, an empty one included", () => {
"web_tools", "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,
);
});

View file

@ -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 assert from "node:assert/strict";
import { test } from "node:test"; 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 = const answering =
(body, ok = true) => (body, ok = true) =>
@ -35,3 +43,21 @@ test("anything but a clear answer is not missing", async () => {
false, 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,
);
});

View file

@ -34,20 +34,20 @@ pub fn current() -> Vec<Permission> {
.read() .read()
.unwrap_or_else(PoisonError::into_inner) .unwrap_or_else(PoisonError::into_inner)
.clone(); .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. /// [`current`] with the last read and the hive's copy given.
fn current_with(last: Option<Vec<Permission>>, copy: &Path, agent: &str) -> Vec<Permission> { fn current_with(last: Option<Vec<Permission>>, copy: &Path) -> Vec<Permission> {
last.unwrap_or_else(|| HiveCopy::read_at(copy).before_read(agent)) last.unwrap_or_else(|| HiveCopy::read_at(copy).before_read())
} }
/// What one read of the key leaves the agent holding, judged against the /// 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 /// hive's copy at `copy`; `None` (keep the last value) when the read failed
/// or the bucket did not answer. /// or the bucket did not answer.
fn after(agent: &str, copy: &Path, read: Option<Read>) -> Option<Vec<Permission>> { fn after(copy: &Path, read: Option<Read>) -> Option<Vec<Permission>> {
match read? { 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, Read::NoBucket => None,
} }
} }
@ -63,7 +63,7 @@ pub async fn run() {
tracing::info!(%agent, "reading swarm permissions from the swarm queue"); tracing::info!(%agent, "reading swarm permissions from the swarm queue");
loop { loop {
let read = read_once(&client, &agent).await; 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); let mut current = CURRENT.write().unwrap_or_else(PoisonError::into_inner);
if current.as_ref() != Some(&perms) { if current.as_ref() != Some(&perms) {
tracing::info!(?perms, "permissions: the swarm's set changed"); 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() { fn a_read_sets_the_list_or_the_default_and_a_failure_keeps_the_last() {
let none = Path::new("/nonexistent/swarm-permissions.json"); let none = Path::new("/nonexistent/swarm-permissions.json");
assert_eq!( assert_eq!(
after( after(none, Some(record(r#"{"permissions":["web_tools"]}"#))),
"atlas",
none,
Some(record(r#"{"permissions":["web_tools"]}"#))
),
Some(vec![Permission::WebTools]) Some(vec![Permission::WebTools])
); );
assert_eq!( assert_eq!(
after("atlas", none, Some(record(r#"{"permissions":[]}"#))), after(none, Some(record(r#"{"permissions":[]}"#))),
Some(Vec::new()) Some(Vec::new())
); );
assert_eq!( assert_eq!(
after("atlas", none, Some(record("{}"))), after(none, Some(record("{}"))),
Some(Permission::DEFAULT.to_vec()) Some(Permission::DEFAULT.to_vec())
); );
assert_eq!(after("atlas", none, Some(Read::NoBucket)), None); assert_eq!(after(none, Some(Read::NoBucket)), None);
assert_eq!(after("atlas", none, None), None); assert_eq!(after(none, None), None);
} }
#[test] #[test]
fn before_the_first_read_the_stored_grant_applies() { fn before_the_first_read_the_stored_grant_applies() {
let (_dir, path) = copy(r#"{"permissions":["web_tools"]}"#); let (_dir, path) = copy(r#"{"permissions":["web_tools"]}"#);
assert_eq!( assert_eq!(current_with(None, &path), vec![Permission::WebTools]);
current_with(None, &path, "atlas"),
vec![Permission::WebTools]
);
let (_dir, path) = copy(r#"{"permissions":[]}"#); 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!( assert_eq!(
current_with(Some(vec![Permission::Meta]), &path, "atlas"), current_with(Some(vec![Permission::Meta]), &path),
vec![Permission::Meta], vec![Permission::Meta],
"a read outranks the copy" "a read outranks the copy"
); );
@ -146,17 +139,17 @@ mod tests {
fn while_the_import_is_pending_an_unset_key_keeps_the_stored_grant() { fn while_the_import_is_pending_an_unset_key_keeps_the_stored_grant() {
let (_dir, path) = copy(r#"{"permissions":["web_tools"],"import_pending":true}"#); let (_dir, path) = copy(r#"{"permissions":["web_tools"],"import_pending":true}"#);
assert_eq!( assert_eq!(
after("atlas", &path, Some(record("{}"))), after(&path, Some(record("{}"))),
Some(vec![Permission::WebTools]) Some(vec![Permission::WebTools])
); );
assert_eq!( assert_eq!(
after("atlas", &path, Some(record(r#"{"permissions":["meta"]}"#))), after(&path, Some(record(r#"{"permissions":["meta"]}"#))),
Some(vec![Permission::Meta]), Some(vec![Permission::Meta]),
"a set key still wins" "a set key still wins"
); );
let (_dir, path) = copy(r#"{"permissions":["web_tools"]}"#); let (_dir, path) = copy(r#"{"permissions":["web_tools"]}"#);
assert_eq!( assert_eq!(
after("atlas", &path, Some(record("{}"))), after(&path, Some(record("{}"))),
Some(Permission::DEFAULT.to_vec()), Some(Permission::DEFAULT.to_vec()),
"once imported, unset is the default" "once imported, unset is the default"
); );
@ -169,13 +162,10 @@ mod tests {
let (_dir, path) = copy(r#"{"permissions":["messaging"],"import_pending":true}"#); let (_dir, path) = copy(r#"{"permissions":["messaging"],"import_pending":true}"#);
let mut last = None; let mut last = None;
for read in [None, Some(Read::NoBucket), 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); last = Some(perms);
} }
} }
assert_eq!( assert_eq!(current_with(last, &path), vec![Permission::Messaging]);
current_with(last, &path, "atlas"),
vec![Permission::Messaging]
);
} }
} }

View file

@ -6,7 +6,7 @@
//! Format: agent name → the stored //! Format: agent name → the stored
//! [`swarm_queue_client::agent_permissions::AgentPermissions`]. An agent with //! [`swarm_queue_client::agent_permissions::AgentPermissions`]. An agent with
//! no entry, or an entry with `permissions` unset, holds //! 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 //! hive-c0re enforces one member, [`Permission::ManageRootAgent`]: read by
//! `lifecycle::host_config::set_nspawn_flags` on every container start, which //! `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) super::write_map(path, stored)
} }
/// The permissions `agent` holds under `record`. /// The permissions an agent holds under `record`.
#[must_use] #[must_use]
pub fn effective(agent: &str, record: Option<&AgentPermissions>) -> Vec<Permission> { pub fn effective(record: Option<&AgentPermissions>) -> Vec<Permission> {
record record
.and_then(|r| r.permissions.clone()) .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`]). /// Whether `agent` holds `perm` per the file at `path` (normally [`path`]).
@ -54,7 +54,7 @@ pub fn effective(agent: &str, record: Option<&AgentPermissions>) -> Vec<Permissi
/// ///
/// As [`read_at`]. /// As [`read_at`].
pub fn holds(path: &Path, agent: &str, perm: Permission) -> std::io::Result<bool> { pub fn holds(path: &Path, agent: &str, perm: Permission) -> std::io::Result<bool> {
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 /// Write `agent`'s [`HiveCopy`] into `runtime_dir`: its entry in the file at
@ -91,25 +91,41 @@ mod tests {
#[test] #[test]
fn unset_is_the_default_and_an_empty_list_is_none() { fn unset_is_the_default_and_an_empty_list_is_none() {
assert!(effective("ruth", None).contains(&Permission::ManageRootAgent)); assert_eq!(effective(None), Permission::DEFAULT.to_vec());
assert!(effective("ruth", Some(&record("{}"))).contains(&Permission::ManageRootAgent)); assert_eq!(effective(Some(&record("{}"))), Permission::DEFAULT.to_vec());
assert!(effective("ruth", Some(&record(r#"{"permissions":[]}"#))).is_empty()); assert!(effective(Some(&record(r#"{"permissions":[]}"#))).is_empty());
assert_eq!(effective("atlas", None), Permission::DEFAULT.to_vec());
assert_eq!( assert_eq!(
effective( effective(Some(&record(r#"{"permissions":["manage_root_agent"]}"#))),
"atlas",
Some(&record(r#"{"permissions":["manage_root_agent"]}"#))
),
vec![Permission::ManageRootAgent] 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] #[test]
fn a_missing_file_holds_the_defaults() { fn ruth_with_no_entry_holds_the_default() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(FILE); let path = dir.path().join(FILE);
assert!(holds(&path, "ruth", Permission::ManageRootAgent).expect("missing is empty")); for agent in ["ruth", "atlas"] {
assert!(!holds(&path, "atlas", Permission::ManageRootAgent).expect("missing is empty")); 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 /// 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"); hand_at(&stored, true, "atlas", &runtime).expect("hand");
let copy = HiveCopy::read_at(&runtime.join(HIVE_COPY_FILE)); let copy = HiveCopy::read_at(&runtime.join(HIVE_COPY_FILE));
assert!(copy.import_pending); 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"); hand_at(&stored, false, "iris", &runtime).expect("hand");
let copy = HiveCopy::read_at(&runtime.join(HIVE_COPY_FILE)); let copy = HiveCopy::read_at(&runtime.join(HIVE_COPY_FILE));

View file

@ -866,11 +866,10 @@ impl Coordinator {
/// --continue's session). Best-effort; broker errors are logged /// --continue's session). Best-effort; broker errors are logged
/// but don't propagate. /// but don't propagate.
pub fn kick_agent(&self, name: &str, reason: &str) { pub fn kick_agent(&self, name: &str, reason: &str) {
// Sub-agents bind their state at /agents/<name>/state. The // Every agent binds its state at /agents/<name>/state, and a
// manager has both /state (legacy mount) and /agents // `manage_root_agent` holder has all of /agents bind-mounted, so
// bind-mounted, so /agents/<name>/state resolves there too — // the path resolves there too — use that uniformly so the wake
// use that uniformly so the wake message has one canonical // message has one canonical path that works everywhere.
// path that works everywhere.
let body = format!( let body = format!(
"{reason}\n\nYou were just (re)started by the operator. \ "{reason}\n\nYou were just (re)started by the operator. \
If you were mid-task, check `/agents/{name}/state/` for \ If you were mid-task, check `/agents/{name}/state/` for \

View file

@ -843,13 +843,13 @@ mod tests {
assert_eq!(std::fs::read(&path).expect("read"), truncated.as_bytes()); assert_eq!(std::fs::read(&path).expect("read"), truncated.as_bytes());
} }
/// Unset is the default, which gives ruth the recovery mounts and nobody /// Unset is the default, which gives nobody the recovery mounts, `ruth`
/// else; a grant or an explicit empty list replaces it. /// included; only a grant does.
#[test] #[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 dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("swarm-agent-permissions.json"); 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)); assert!(!holds_manage_root_agent("alice", &path));
std::fs::write( std::fs::write(
&path, &path,

View file

@ -11,10 +11,9 @@
//! only if it cannot be expressed as convergence, and expect to delete //! only if it cannot be expressed as convergence, and expect to delete
//! it once every hive has passed it. //! it once every hive has passed it.
//! - **Create-time setup.** A thing that is true from birth is set where //! - **Create-time setup.** A thing that is true from birth is set where
//! the agent is created, not re-asserted each morning. Ruth's //! the agent is created, not re-asserted each morning: ruth's
//! permissions need neither: with no key in the swarm's //! `manage_root_agent`, for one, is stored in the swarm's
//! `agent-permissions` bucket she holds the code-constant default //! `agent-permissions` bucket when the swarm UI creates her.
//! (`Permission::default_for`).
//! //!
//! Kill-switch: `HIVE_SKIP_META_MIGRATION=1`. Full sequence and phase //! Kill-switch: `HIVE_SKIP_META_MIGRATION=1`. Full sequence and phase
//! details: `docs/agent-lifecycle/config-changes.md::Startup migrations (older hosts)`. //! details: `docs/agent-lifecycle/config-changes.md::Startup migrations (older hosts)`.

View file

@ -1,10 +1,8 @@
//! Publishing lifecycle notices onto the swarm queue. //! Publishing lifecycle notices onto the swarm queue.
//! //!
//! Replaces the old `push_todo(MANAGER_AGENT, ...)` fallback the //! There is no fallback recipient agent: every hive is swarm-controlled,
//! lifecycle-notice call sites used to reach for when there was nobody //! so a notice always goes to the swarm queue, and no agent receives it by
//! else to tell. Every hive is swarm-controlled now, so there is no case //! name.
//! left that needs a manager-agent recipient — this module has no such
//! fallback, on purpose, not by omission.
//! //!
//! **Why a stream and not the [`crate::swarm_status`] KV bucket shape**: //! **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 //! a status snapshot has a current value a late reader can always ask

View file

@ -54,27 +54,17 @@ fn parse(agent: &str, name: &str) -> Option<Permission> {
/// only unknown names, was the default groups. `lifecycle`/`forge` count as /// only unknown names, was the default groups. `lifecycle`/`forge` count as
/// known (they parsed) and are then dropped, so an entry of only those /// known (they parsed) and are then dropped, so an entry of only those
/// held no tools and imports as none. /// held no tools and imports as none.
/// - capabilities: the entry's names. With the file present, `ruth` having no /// - capabilities: the entry's names; no entry, or no file, is none.
/// entry means her grant was revoked: the file was created holding it, and
/// a revoke removed the entry. With no file, the default.
/// ///
/// An agent whose list equals [`Permission::default_for`] is left out, so its /// An agent whose list equals [`Permission::DEFAULT`] is left out, so its key
/// key stays unset and follows the default. /// stays unset and follows the default.
fn grants(caps: Option<&FileMap>, groups: Option<&FileMap>) -> Grants { fn grants(caps: Option<&FileMap>, groups: Option<&FileMap>) -> Grants {
let mut agents: BTreeSet<&str> = BTreeSet::new(); let mut agents: BTreeSet<&str> = BTreeSet::new();
agents.extend(caps.iter().flat_map(|m| m.keys().map(String::as_str))); 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))); agents.extend(groups.iter().flat_map(|m| m.keys().map(String::as_str)));
if caps.is_some() { let default = Permission::DEFAULT;
agents.insert(crate::lifecycle::MANAGER_NAME);
}
let mut out = Grants::new(); let mut out = Grants::new();
for agent in agents { for agent in agents {
let default = Permission::default_for(agent);
let tool_default: Vec<Permission> = default
.iter()
.copied()
.filter(|p| *p != Permission::ManageRootAgent)
.collect();
let tool_part = match groups.and_then(|m| m.get(agent)) { let tool_part = match groups.and_then(|m| m.get(agent)) {
Some(names) Some(names)
if names.iter().any(|n| { 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() names.iter().filter_map(|n| parse(agent, n)).collect()
} }
_ => tool_default, _ => default.to_vec(),
}; };
let cap_part: Vec<Permission> = match caps { let cap_part: Vec<Permission> = caps
Some(m) => m .and_then(|m| m.get(agent))
.get(agent)
.map(|names| names.iter().filter_map(|n| parse(agent, n)).collect()) .map(|names| names.iter().filter_map(|n| parse(agent, n)).collect())
.unwrap_or_default(), .unwrap_or_default();
None => default
.iter()
.copied()
.filter(|p| *p == Permission::ManageRootAgent)
.collect(),
};
let mut list: Vec<Permission> = Vec::new(); let mut list: Vec<Permission> = Vec::new();
for perm in tool_part.into_iter().chain(cap_part) { for perm in tool_part.into_iter().chain(cap_part) {
if !list.contains(&perm) { if !list.contains(&perm) {
@ -275,24 +258,15 @@ mod tests {
"sock": ["lifecycle"]}"#, "sock": ["lifecycle"]}"#,
); );
let out = grants(Some(&caps), Some(&groups)); 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["atlas"], vec![Messaging, WebTools, ManageRootAgent]);
assert_eq!(out["iris"], vec![Messaging]); assert_eq!(out["iris"], vec![Messaging]);
assert_eq!(out["sock"], Vec::<Permission>::new()); assert_eq!(out["sock"], Vec::<Permission>::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. /// The seed writes once, from the files, and never over a stored read.
#[test] #[test]
fn the_seed_copies_the_files_only_when_nothing_is_stored() { fn the_seed_copies_the_files_only_when_nothing_is_stored() {

View file

@ -155,7 +155,7 @@ async fn hand_all() {
/// Whether `agent` holds the one permission hive-c0re enforces under `stored`. /// Whether `agent` holds the one permission hive-c0re enforces under `stored`.
fn manages(agent: &str, stored: &Stored) -> bool { 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 /// Restart each running agent in `changed` once its current turn is done, so
@ -216,7 +216,7 @@ mod tests {
assert!(changed.is_empty()); assert!(changed.is_empty());
assert!(crate::permissions::read_at(&path).expect("read").is_empty()); assert!(crate::permissions::read_at(&path).expect("read").is_empty());
assert!( assert!(
crate::permissions::holds( !crate::permissions::holds(
&path, &path,
"ruth", "ruth",
hive_sh4re::permissions::Permission::ManageRootAgent hive_sh4re::permissions::Permission::ManageRootAgent
@ -225,33 +225,22 @@ mod tests {
); );
} }
/// Revoking ruth's default, and granting atlas, restarts both; clearing /// Granting `manage_root_agent` to agents on the default restarts them;
/// them again restarts both back. /// clearing it again restarts them back.
#[tokio::test] #[tokio::test]
async fn a_changed_manage_root_agent_asks_for_a_restart() { async fn a_changed_manage_root_agent_asks_for_a_restart() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("p.json"); let path = dir.path().join("p.json");
let changed = sync(&path, |agent| { let granted = || {
Ok(Read::Current(record(if agent == "ruth" { Ok(Read::Current(record(
r#"{"permissions":[]}"# r#"{"permissions":["manage_root_agent"]}"#,
} else { )))
r#"{"permissions":["manage_root_agent"]}"# };
}))) let changed = sync(&path, |_| granted()).await.expect("read");
})
.await
.expect("read");
assert_eq!(changed, agents()); assert_eq!(changed, agents());
// The same answer again changes nothing. // The same answer again changes nothing.
let unchanged = sync(&path, |agent| { let unchanged = sync(&path, |_| granted()).await.expect("read");
Ok(Read::Current(record(if agent == "ruth" {
r#"{"permissions":[]}"#
} else {
r#"{"permissions":["manage_root_agent"]}"#
})))
})
.await
.expect("read");
assert!(unchanged.is_empty()); assert!(unchanged.is_empty());
let cleared = sync(&path, |_| Ok(Read::Current(AgentPermissions::default()))) let cleared = sync(&path, |_| Ok(Read::Current(AgentPermissions::default())))
@ -268,7 +257,7 @@ mod tests {
let path = dir.path().join("p.json"); let path = dir.path().join("p.json");
let changed = sync(&path, |agent| { let changed = sync(&path, |agent| {
Ok(Read::Current(if agent == "ruth" { Ok(Read::Current(if agent == "ruth" {
record(r#"{"permissions":["manage_root_agent"]}"#) record(r#"{"permissions":["meta"]}"#)
} else { } else {
record(r#"{"permissions":["messaging","web_tools"]}"#) record(r#"{"permissions":["messaging","web_tools"]}"#)
})) }))
@ -289,10 +278,16 @@ mod tests {
async fn a_failed_read_keeps_the_last_value() { async fn a_failed_read_keeps_the_last_value() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("p.json"); let path = dir.path().join("p.json");
let revoked = || Ok(Read::Current(record(r#"{"permissions":[]}"#))); // atlas stays on the default, so only ruth changes.
// atlas holds no manage_root_agent either way, so only ruth changes. let ruth_granted = |agent: &str| {
Ok(Read::Current(record(if agent == "ruth" {
r#"{"permissions":["manage_root_agent"]}"#
} else {
"{}"
})))
};
assert_eq!( assert_eq!(
sync(&path, |_| revoked()).await.expect("read"), sync(&path, ruth_granted).await.expect("read"),
vec!["ruth".to_owned()] vec!["ruth".to_owned()]
); );
let before = std::fs::read(&path).expect("stored"); let before = std::fs::read(&path).expect("stored");

View file

@ -4,9 +4,6 @@
use hive_types::Ident; use hive_types::Ident;
use serde::{Deserialize, Serialize}; 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 /// Logical name of the human operator: the sender of operator-authored
/// messages. Not a recipient — `send` to it fails with /// messages. Not a recipient — `send` to it fails with
/// [`OPERATOR_SEND_REFUSAL`]. /// [`OPERATOR_SEND_REFUSAL`].
@ -57,7 +54,7 @@ pub enum HelperEvent {
#[cfg(test)] #[cfg(test)]
mod reserved_name_tests { 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}; use hive_types::{Ident, RESERVED_NAMES_ENV, is_reserved_name};
/// The blacklist as nix rendered it for this test run. /// 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() { fn manager_name_is_taken_not_reserved() {
let owned = reserved(); let owned = reserved();
let reserved: Vec<&str> = owned.iter().map(String::as_str).collect(); let reserved: Vec<&str> = owned.iter().map(String::as_str).collect();
assert!(Ident::parse(MANAGER_AGENT).is_ok()); assert!(Ident::parse("ruth").is_ok());
assert!(!is_reserved_name(MANAGER_AGENT, &reserved)); assert!(!is_reserved_name("ruth", &reserved));
} }
} }

View file

@ -5,7 +5,7 @@
//! is enforced where it applies: the MCP-tool and built-in-tool members by //! 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 //! the processes that spawn `claude` (the agent harness and the subagent
//! daemon), [`Permission::ManageRootAgent`] by hive-c0re's bind mounts. An //! 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`]): //! 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 //! that set is permission-gated too (`web_tools`), so it belongs here rather
@ -135,22 +135,10 @@ impl Permission {
Self::ManageRootAgent, 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] = pub const DEFAULT: &'static [Self] =
&[Self::Messaging, Self::Meta, Self::Inbox, Self::Execution]; &[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<Self> {
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 /// The MCP tool names (without the `mcp__hyperhive__` prefix) this
/// permission grants. Empty for the permissions that grant something /// permission grants. Empty for the permissions that grant something
/// else; see each variant's doc comment. /// else; see each variant's doc comment.
@ -227,17 +215,6 @@ mod tests {
assert!(serde_json::from_str::<Permission>("\"lifecycle\"").is_err()); assert!(serde_json::from_str::<Permission>("\"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 /// Adding permissions only ever adds built-ins — the base set is in every
/// resolution, and no permission can remove one. Both `--tools` callers (the /// resolution, and no permission can remove one. Both `--tools` callers (the
/// harness and the subagent daemon) rely on this to reason about "the /// harness and the subagent daemon) rely on this to reason about "the

View file

@ -45,8 +45,7 @@ pub fn permissions_now() -> Vec<Permission> {
.read() .read()
.unwrap_or_else(std::sync::PoisonError::into_inner) .unwrap_or_else(std::sync::PoisonError::into_inner)
.clone(); .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())
last.unwrap_or_else(|| HiveCopy::read_at(Path::new(HIVE_COPY_PATH)).before_read(&agent))
} }
/// How long after a failed connect the task tries again. /// 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()?; swarm_queue_client::ensure_connected(client).ok()?;
match tokio::time::timeout(READ_TIMEOUT, agent_permissions::read(client, agent)).await { match tokio::time::timeout(READ_TIMEOUT, agent_permissions::read(client, agent)).await {
Ok(Ok(agent_permissions::Read::Current(record))) => { 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(Ok(agent_permissions::Read::NoBucket)) => None,
Ok(Err(e)) => { Ok(Err(e)) => {

View file

@ -348,6 +348,21 @@ pub(crate) fn spawn(client: async_nats::Client, wanted: Arc<WantedWriter>, hives
} }
tokio::time::sleep(RETRY).await; 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(); let subject = agent_permissions::import_subject_wildcard();
loop { loop {
match client.subscribe(subject.clone()).await { 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<String>,
) -> anyhow::Result<bool> {
if !declared.contains(SEEDED_AGENT) {
return Ok(false);
}
let (current, revision, undecodable) =
read_or_default::<AgentPermissions>(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, /// Write each agent's imported permissions under its key when it has none,
/// for the agents in `declared` only. /// for the agents in `declared` only.
async fn import_into( async fn import_into(
@ -440,7 +509,7 @@ async fn import_into(
mod tests { mod tests {
use super::{ use super::{
Permissions, delete_agent_permissions, get_agent_permissions, import_into, Permissions, delete_agent_permissions, get_agent_permissions, import_into,
put_agent_permissions, put_agent_permissions, seed_into,
}; };
use crate::test_nats::TestNats; use crate::test_nats::TestNats;
use axum::extract::{Path, State}; use axum::extract::{Path, State};
@ -636,6 +705,81 @@ mod tests {
assert_eq!(published(&client, "iris").await, None); 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 /// `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 /// key in the same call; a list naming one twice is refused before
/// anything is stored or queued. /// anything is stored or queued.

View file

@ -3,7 +3,7 @@
//! //!
//! **swarm-controller is the only writer**, and creates the bucket when 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 //! 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. //! nothing.
//! //!
//! **Two readers, each enforcing its own part.** The hive running the agent //! **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. /// stored list, or the default when the hive stored none.
#[must_use] #[must_use]
pub fn before_read(&self, agent: &str) -> Vec<hive_sh4re::permissions::Permission> { pub fn before_read(&self) -> Vec<hive_sh4re::permissions::Permission> {
self.stored self.stored
.permissions .permissions
.clone() .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 /// while the import is pending may only mean the grant is not imported
/// yet, so the stored list stands; otherwise unset is the default. /// yet, so the stored list stands; otherwise unset is the default.
#[must_use] #[must_use]
pub fn after_read( pub fn after_read(&self, read: &AgentPermissions) -> Vec<hive_sh4re::permissions::Permission> {
&self,
agent: &str,
read: &AgentPermissions,
) -> Vec<hive_sh4re::permissions::Permission> {
match &read.permissions { match &read.permissions {
Some(list) => list.clone(), Some(list) => list.clone(),
None if self.import_pending => self.before_read(agent), None if self.import_pending => self.before_read(),
None => hive_sh4re::permissions::Permission::default_for(agent), None => hive_sh4re::permissions::Permission::DEFAULT.to_vec(),
} }
} }
} }
@ -307,20 +303,17 @@ mod tests {
let web = vec![Permission::WebTools]; let web = vec![Permission::WebTools];
let default = Permission::DEFAULT.to_vec(); let default = Permission::DEFAULT.to_vec();
assert_eq!( assert_eq!(copy(r#"{"permissions":["web_tools"]}"#).before_read(), web);
copy(r#"{"permissions":["web_tools"]}"#).before_read("atlas"), assert_eq!(copy("{}").before_read(), default);
web
);
assert_eq!(copy("{}").before_read("atlas"), default);
let pending = copy(r#"{"permissions":["web_tools"],"import_pending":true}"#); 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!( assert_eq!(
pending.after_read("atlas", &record(r#"{"permissions":[]}"#)), pending.after_read(&record(r#"{"permissions":[]}"#)),
Vec::new() Vec::new()
); );
let settled = copy(r#"{"permissions":["web_tools"]}"#); 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 /// Fields a newer controller adds must not cost an older hive the ones it

View file

@ -296,8 +296,7 @@ enum AgentVerb {
/// The agent applies its tool permissions at its next turn; the hive /// The agent applies its tool permissions at its next turn; the hive
/// running it restarts it, after its current turn, when /// running it restarts it, after its current turn, when
/// `manage_root_agent` changes. Unset leaves the agent on the default: /// `manage_root_agent` changes. Unset leaves the agent on the default:
/// `messaging`, `meta`, `inbox`, `execution`, plus `manage_root_agent` /// `messaging`, `meta`, `inbox`, `execution`, whatever its name.
/// for `ruth`.
Permissions { Permissions {
#[command(subcommand)] #[command(subcommand)]
command: PermissionsVerb, command: PermissionsVerb,