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
|
## 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
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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** —
|
||||||
|
|
|
||||||
|
|
@ -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>`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) } });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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"];
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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]
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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 \
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)`.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
|
||||||
Some(m) => m
|
|
||||||
.get(agent)
|
|
||||||
.map(|names| names.iter().filter_map(|n| parse(agent, n)).collect())
|
|
||||||
.unwrap_or_default(),
|
|
||||||
None => default
|
|
||||||
.iter()
|
|
||||||
.copied()
|
|
||||||
.filter(|p| *p == Permission::ManageRootAgent)
|
|
||||||
.collect(),
|
|
||||||
};
|
};
|
||||||
|
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();
|
||||||
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() {
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)) => {
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue