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:
parent
9258563f9c
commit
7b5400ecb8
26 changed files with 380 additions and 216 deletions
|
|
@ -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.
|
||||
|
||||
<!-- 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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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/<n>/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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>`
|
||||
(`/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** —
|
||||
|
|
|
|||
|
|
@ -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 <COMMAND>`
|
||||
|
||||
|
|
|
|||
|
|
@ -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) } });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
// <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
|
||||
// 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
|
||||
>
|
||||
<CreateAgentForm initialName="ruth" onClose={() => setOpen(false)} />
|
||||
<CreateAgentForm
|
||||
initialName="ruth"
|
||||
extraPermissions={RUTH_EXTRA_PERMISSIONS}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -20,3 +20,7 @@ export async function ruthIsMissing(fetchImpl: Fetch): Promise<boolean> {
|
|||
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"];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -34,20 +34,20 @@ pub fn current() -> Vec<Permission> {
|
|||
.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<Vec<Permission>>, copy: &Path, agent: &str) -> Vec<Permission> {
|
||||
last.unwrap_or_else(|| HiveCopy::read_at(copy).before_read(agent))
|
||||
fn current_with(last: Option<Vec<Permission>>, copy: &Path) -> Vec<Permission> {
|
||||
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<Read>) -> Option<Vec<Permission>> {
|
||||
fn after(copy: &Path, read: Option<Read>) -> Option<Vec<Permission>> {
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Permission> {
|
||||
pub fn effective(record: Option<&AgentPermissions>) -> Vec<Permission> {
|
||||
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<Permissi
|
|||
///
|
||||
/// As [`read_at`].
|
||||
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
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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/<name>/state. The
|
||||
// manager has both /state (legacy mount) and /agents
|
||||
// bind-mounted, so /agents/<name>/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/<name>/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 \
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -54,27 +54,17 @@ fn parse(agent: &str, name: &str) -> Option<Permission> {
|
|||
/// 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<Permission> = 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,
|
||||
_ => default.to_vec(),
|
||||
};
|
||||
let cap_part: Vec<Permission> = match caps {
|
||||
Some(m) => m
|
||||
.get(agent)
|
||||
let cap_part: Vec<Permission> = caps
|
||||
.and_then(|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(),
|
||||
};
|
||||
.unwrap_or_default();
|
||||
let mut list: Vec<Permission> = 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::<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.
|
||||
#[test]
|
||||
fn the_seed_copies_the_files_only_when_nothing_is_stored() {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
/// 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::<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
|
||||
/// resolution, and no permission can remove one. Both `--tools` callers (the
|
||||
/// harness and the subagent daemon) rely on this to reason about "the
|
||||
|
|
|
|||
|
|
@ -45,8 +45,7 @@ pub fn permissions_now() -> Vec<Permission> {
|
|||
.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)) => {
|
||||
|
|
|
|||
|
|
@ -348,6 +348,21 @@ pub(crate) fn spawn(client: async_nats::Client, wanted: Arc<WantedWriter>, 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<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,
|
||||
/// 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.
|
||||
|
|
|
|||
|
|
@ -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<hive_sh4re::permissions::Permission> {
|
||||
pub fn before_read(&self) -> Vec<hive_sh4re::permissions::Permission> {
|
||||
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<hive_sh4re::permissions::Permission> {
|
||||
pub fn after_read(&self, read: &AgentPermissions) -> Vec<hive_sh4re::permissions::Permission> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue