7 genuine passive-voice rewrites (actor named, or the passive
construction replaced with a natural active verb/imperative); 5 false
positives left untouched — a temporal 'when it was written' reference,
two predicate-adjective necessity descriptions ('is needed'/'is
expected', same category as 'is required'), one predicate-adjective
state description ('are gone'), and one quoted literal + predicate
adjective ('these inputs are unused by rust').
6 genuine passive-voice rewrites (actor named or replaced with an
active construction); 7 false positives left untouched — the
design-intent 'is meant to have' idiom, and the six identical
'must be stated' renewal-column table cells (a deliberate terse
placeholder directive, not a describable action with an actor).
Part of #4548 — rewriting the 353 hand-written-docs write-good.Passive
hits so #4546 can flip the rule to error. This batch: docs/networking/gateway.md
(27 hits), docs/tools/subagent.md (19), docs/web-ui/dashboard.md (18).
Minimal, meaning-preserving rewrites to active voice only — no
restructuring beyond what each flagged sentence needed. Genuine false
positives (predicate-adjective state descriptions like "is required",
"is broken", "is unchanged", adjectival past participles like "named
buckets", and one quoted literal UI string) are left in place; the
per-line reasoning is on the tracking issue.
Note: docs/tools/forge-cli.md (37 hits, would have been the single
biggest file) turned out to be generated from hive-forge's own clap
help text (see hive-forge/src/main.rs's `MarkdownDocs` verb) — same
category as the already-excluded hivectl-cli.md/swarmctl-cli.md, so
it's untouched here. Flagged on #4548.
argus caught it on PR #4557 review: 'certificateFiles reads at system
build time' hands the option itself a verb it can't perform. The
system reads the option's value at build time, not the other way
around.
Part of #4548 — third batch. This PR: docs/swarm/secrets.md (16 hits),
docs/swarm/README.md (15 hits) = 31 more of the 353 (130 total across
the three PRs so far).
Same method as #4551/#4553: minimal, meaning-preserving active-voice
rewrites, no restructuring beyond what each flagged sentence needed,
no vale-off silencing.
Genuine false positives left in place: predicate-adjective state
descriptions ("is required" x4, "is enabled", "is disabled"), one
systemd terminology reference ("is wanted (not required)" — literally
naming systemd's Wants=/Requires= dependency-type distinction, not
passive voice), and one design-intent idiom ("both are expected" —
same shape as earlier docs-lint work).
Part of #4548 — second batch. This PR: docs/integrations/matrix.md
(18 hits), docs/agent-lifecycle/persistence.md (17 hits) = 35 more of
the 353 (99 total across the two PRs so far).
Same method as the first batch (#4551): minimal, meaning-preserving
active-voice rewrites only, no restructuring beyond what each flagged
sentence needed, no vale-off silencing.
Genuine false positives left in place, with per-file reasoning in the
PR body: predicate-adjective state descriptions ("is wedged", "is
stopped", "are unrelated", "is unaffected", "is needed"), one
design-intent idiom ("was meant to prevent" — same shape as earlier
docs-lint work), and one vale parsing artifact: "is read" flagged
inside the hyphenated compound adjective "read-only", not real passive
voice at all.
Puts the default on #[arg(long, default_value = "origin")] instead of
duplicating unwrap_or("origin") at both call sites (REST/--push path
and agit_create). clap now renders the default in --help itself, so
the hand-written doc-comment default is dropped in favor of it.
Regenerated docs/tools/forge-cli.md to match.
Fix wording flagged on #4521 review: "Single nginx in front of every
hyperhive web surface" and "Runs on the host" claimed a deployment-wide
topology. There's one nginx per host that has something on it, and
potentially more inside service containers. Reword to describe what
this module builds on this host, with no count claim swarm-wide.
`services.hyperhive.gateway.enable`, `gateway.dns.enable` and
`network.enable` replace the `hyperhive.enable` gate on all three. Each
defaults to false; the modules that need one assert it with `mkDefault
true` from inside the guard their own deployment already carries, and
`swarm-required-services.nix` — the module that owns what the
swarm-services toggle implies — asserts all three explicitly.
hive-c0re asserts all three unconditionally, so an ordinary hive keeps
getting them with no opt-in: it is the host's only knowledge that agent
containers exist.
The resolver moves to its own `hive-gateway/dns.nix` so it can be gated
without reindenting the nginx half of the module.
Reinstates `network.enable`, dropping its `mkRemovedOptionModule` shim.
A config still carrying `network.enable = false` from before the removal
now switches the bridge off instead of failing eval.
Also deletes a duplicate `centralToggleOff` fixture in nix/module-eval.nix.
Two sibling slices added it independently (c5f60fd5, ce3b3d94); the merge
was textually clean and left `main` failing to evaluate at all, so this
file could not be gated without removing one.
Container lifecycle from inside an agent goes away: an agent no longer
starts, stops, restarts or rebuilds a container in its subtree, and no
longer reads another container's journal. Those are operator actions —
the dashboard and hivectl keep their own paths to the same job-queue
and hive-priv plumbing, which is why none of that machinery is removed
here, only the five MCP verbs and what they alone reached.
What went with them: the `Request` variants and `Response::Logs` on the
agent socket, the five tool definitions and their arg structs, the four
lifecycle handlers plus `handle_get_logs`, and `require_descendant` —
the topology guard those five were the only remaining callers of.
`ToolGroup::Diagnostics` goes too: `get_logs` was its only tool, so it
would otherwise be a grantable group that grants nothing. `lifecycle`
stays, now carrying `list_containers` alone.
An agent that gets a `needs_update` or `container_crash` helper event
has no remedy of its own left, so the system prompt and the docs now
send it to the operator instead of to a tool that no longer exists.
Refs #4480
`swarm-controller` mints an agent's mTLS leaf at creation and publishes it
at `swarm/agents/<agent>/bao-mtls`. Nothing read it back. This adds the
hop that carries it the rest of the way, and the in-container consumer
that proves the hop works.
Host side, `lifecycle::agent_identity` reads the row under *this hive's*
own certificate — the hive is a principal the store already knows — and
stages the leaf and its key `0600` under a new `agent-identity/<name>`
state dir, deliberately outside every bind-mounted tree. Both files go in
as systemd credentials rather than binds, the same answer and the same
mode reason as the queue secret beside it: the staged key is unreadable
to the unprivileged agent user, and the container manager reads a
`--load-credential` source as root before re-exposing it under the
consuming unit's own `User=`. The agent is never asked to authenticate in
order to obtain the thing it authenticates with.
Container side, `hive-agent-bao-identity.service` logs in with that
certificate and reads the agent's own path back, failing the unit when
either step does not succeed. It fails loudly where the hive-side readers
degrade quietly, because a refused certificate means an agent that
believes it reaches the store and never does — a cause only the login
itself can name.
The address is the whole switch, no separate `enable`, matching how
`queue.nix` and `logs.nix` already gate themselves. A hive with a store
forwards `HIVE_AGENT_BAO_ADDR` and every agent on it gets the check; a
hive without one forwards nothing and no agent does. That is what keeps
the delivery from landing in a container with nothing to read it.
The hive can now reach an agent's identity, so hive privilege covers
agent privilege. Accepted, not mitigated: the alternative is an agent
fetching its own credential with a credential it does not yet have.
Refs #4137
pr-create/pr-status/etc no longer parse -- clap deleted them along
with the renamed() helper. The remaining flat verbs (view, comment,
close, ...) are #[command(hide = true)] in clap; documenting a
deliberately hidden set as a group defeats the point of hiding it.
The examples further down that demonstrate individual hidden verbs
are unaffected.
Refs #4509
`swarm/agents/<agent>/bao-mtls` did not exist, and neither did any
per-agent identity at the secret store: `policy::agent_object_name`,
`render_agent` and `render_agent_with_queue` had been written and never
called outside their own tests. An agent's only "per-agent" secret today
is read under the HIVE's certificate, through a wide grant on
`swarm/agents/*` — so "per-agent" was presentational.
The swarm now mints the certificate, so no hive ever needs the capability
to mint one. `swarm-controller` is the service that does it: it already
logs in to the store, and its existing grant already covers exactly the
three objects written here (`create/update` on
`secret/data/swarm/agents/*`, `sys/policies/acl/hive-*` and
`auth/cert/certs/hive-*`). No new bao grant, and nothing co-located — a
cert-auth role pins its authority by value, per role, so the controller
issues from its own CA on its own host and pins that CA in the role it
writes. No existing role changes.
The mint node does not report success on a write. After publishing it
connects again, with the leaf it just issued and under the role it just
wrote, and reads the path back — so the policy, the role, the common name
and the leaf are exercised in production on every agent creation. A
certificate this code mints that the role this code writes will not accept
turns the job node red at creation time instead of surfacing later as an
agent container that cannot start.
`TriggerDeploy` gains an `after_any` edge on the mint, not `after_ok`: a
hive cannot pass down a certificate the swarm has not published, but a
host with no authority configured must still create agents exactly as it
does today.
The private key is generated in memory and never written to disk on the
controller — `SecretStore::connect_with_identity` takes the PEM the minter
is already holding, so nothing is written out purely to be logged in with.
Refs #4137
`services.hyperhive.agent.matrix.enable` was a second source of truth for
a fact the account set already carried: after ①-③ the hive-internal
`main` account is an ordinary `matrixAccounts` entry, so "does this agent
have matrix" and "does this agent have an account" were the same question
asked twice, with the boolean able to disagree.
The option is gone and a non-empty `matrixAccounts` now gates the daemon
unit, its token path-watcher and the injected `extraMcpServers.matrix`
entry.
That is only a real condition because `matrixAccounts.main` is itself
gated: it is declared when `matrix.url != null`, never unconditionally. A
`main` with no homeserver is an account the daemon can never log in as,
so declaring one always would have made the signal trivially true and
turned matrix on for every agent in every hive. With the URL gate, the
empty set is reachable exactly for an agent the hive gave no homeserver
and whose operator declared no account of its own — the state the old
`enable = false` expressed.
Assertions: "extras require enable" is deleted, having become the
definition of the thing it checked (an external-only account with its own
homeserver is now rendered rather than rejected). `main.tokenFile` stays
pinned, re-guarded on `? main` instead of on the flag, since `main` is
absent whenever the URL is null and an unguarded index would throw there.
Both spellings of the option get `mkRemovedOptionModule`, following
../host-modules/deploy.nix's registrationTokenFile pair rather than a
silent delete: the definition whose meaning changes is `false`, and left
undeclared it would be ignored and hand the agent the tools its operator
turned off. Failing the eval with the replacement spelling is the only
outcome that cannot.
module-eval gains the three arms — URL, nothing, external-only — with the
middle one carrying why it exists: it is the only thing in the suite that
would notice `main` becoming unconditional again.
Refs #4475
`matrixAccounts` is meant to be the agent's full account list, but the
hive-internal `main` account was outside it: the nix module emitted only
the extras and `hive-matrix-daemon` prepended a `main` it synthesized
from the per-agent paths, with the option schema forbidding the name
outright.
nix/agent-modules/matrix.nix now declares `main` itself, as an ordinary
entry under `matrix.enable`, from the state-dir paths the module already
used for its token path-watcher (now a shared `stateDir` binding) plus
`matrix.url`. The whole set, `main` included, is serialized to
HIVE_MATRIX_ACCOUNTS.
accounts::configured therefore synthesizes `main` only when the parsed
list carries none, and otherwise takes the declared one verbatim —
hoisting it to index 0, since the daemon reads index 0 as the primary
and nix serializes an attrset, so `main` sorts wherever its key falls.
Declared xor synthesized: an agent whose harness predates this entry
keeps working, a current one gets its own, and there is no arrangement
where `main` is duplicated or missing.
The reserved-name assertion is replaced rather than dropped: the name
must now be legal (the module uses it), but `main`'s tokenFile stays
pinned to `<state>/matrix-token`, since hive-c0re provisions the
hive-internal token there and nowhere else — a retarget would evaluate
fine and then never restore. The other two fields are mkDefault and free
to override.
Refs #4475
Line 26's list named two identity kinds; mara flagged it as
should-be-three and the third went unnamed. A hive holds its own mTLS
leaf (glue-bao-tls.nix:130-131, CN = hiveName from :49) and logs in
with it (glue-queue-agent-credential.nix:171, bao login
-method=cert); swarm-bao.nix mints matching cert-auth roles for it
alongside swarm-controller (:1049-1052) and swarm-secret-publisher
(:1101-1104), and swarm-controller/src/read_policy.rs:184-188 writes
the per-hive role dynamically. The table further down this page
already lists a hive as a reader (swarm/hives/<hive>/matrix/appservice-token),
so the line-26 list was inconsistent with the rest of the page.
The committed doc was stale (missing help text under --limit/--format
that the binary's clap-markdown output now includes). Regenerated with
the documented command. While at it: docs/tools/swarm-logs-cli.md was
never added to .prettierignore alongside its three siblings, so nix fmt
silently reformatted it back to a stale layout right after
regeneration — the exact failure mode .prettierignore's own comment
warns about. Added it so the *-docs-fresh check in nix/checks.nix stays
satisfiable.
An agent can reach VictoriaLogs only through the gateway, and since the
machine query route landed the way to read it has been to hand-roll a
client_credentials token request and a curl, per query. This is the CLI
that closes that: `swarm-logs query '<LogsQL>'`, matched log lines on
stdout, so the answer pipes into grep like any other command's.
Built to the plan posted on the tracker thread: own crate, own
docs/tools reference generated off the clap tree, `query` as the one
verb, and the JSON error body surfaced on a non-200 rather than
swallowed. No `tail`: streaming is a different endpoint with a different
response shape, and folding it in here would be a fatter scope than the
ask.
Minting the token is NOT implemented here — swarm-queue-client already
owns the client_credentials request, its error type and its CA handling,
and a token-endpoint fix has to be findable in one place. What this crate
adds is the agent-shaped half: the client id arrives as a *file* beside
the secret, so nothing outside nix/agent-modules/queue.nix spells
`hive-<name>-agent` twice. That is the same problem hive-agent's
swarm_queue module solves, and swarm-logs/src/auth.rs is its `decide`
restated over this binary's inputs.
⚠️ The plan named one thing to verify empirically before calling the auth
settled: whether authelia's bearer policy for the logs vhost accepts the
agent client's audience. Measured from inside a container: it does not.
The client minted a token fine but with `aud: []` and `scp: []`, asking
for the logs URL as an audience answered `invalid_target`, and presenting
the audience-less token to the gateway answered a bare 401. So
swarm-authelia.nix's agentClients gains `authelia.bearer.authz` and the
query URL as a second audience — authelia authorises a bearer token by
the URL being requested, and that URL is now one binding read by three
places rather than three spellings of one address.
The URL reaches an agent the same way its queue coordinates do: computed
on the host (a container cannot derive a gateway address), forwarded by
hive_c0re::meta into the container's option set, and consumed by a new
agent module that installs the binary *wrapped* with its coordinates —
the shape swarm-controller.nix installs swarmctl in. Gated on the queue
credential as well as on the URL: a binary that can only answer 401 is
worse than no binary, because an agent reads a 401 as "no logs", which is
the exact confusion the store's machine route was added to end.
The rewrite in #4445 flattened this section's new-functionality-vs-
existing-functionality split into unconditional rules, because it had
been anchored to the current-state table that PR deleted. Restore the
split on its own terms, per #4445's own description of the rule: new
functionality has to match the target shape immediately; existing
functionality can move step by step, provided each step moves toward
the target shape.
Refs #4445
Two lines still framed the invariant as one file total: line 19 ('plus
exactly one file') and line 21 ('the only credential on disk') implied
a single mTLS cert page-wide, contradicting the earlier fix that made
line 26 say 'every host has at least one'. Agents and swarm-level
services each need their own identity certificate, so a host running
several holds several. Restate both as per-identity, keep the target
shape (store + certs on disk, nothing else) unchanged.
Also relax the touching-a-credential rule: moving a secret into bao
without resolving its renewal strategy in the same change is
acceptable while the migration to this shape is in progress, provided
a follow-up issue is opened to settle renewal.
Refs #4445
The citation for the nix option description was 529-531, but the quoted
phrase 'A path, never a value.' appears on line 533. The correct range
529-533 includes both the 'operator places by hand' claim and the
'path, never a value' assertion that the doc attributes to that file.
Refs #4445
Drop the current-state table and the prose describing how the swarm
currently breaks the target (unrotated secrets persisted to disk outside
the store, no agent store identity, the appservice token's second
minter). Scope the path-never-value rule to the mTLS client certificate
— the one credential a nix option can carry, now that everything else
goes through bao. Trim the migration checklist to unconditional rules
for any credential-touching change, since there's no current-state table
left for the old existing-functionality carve-outs to reference.
Refs #4445
The target section described the target as a shorter list of secret files —
the intro promised "what changes is how many paths there are", and the
per-secret contract pointed at the current-state table's file-shaped columns.
That is today's shape with fewer rows, not the target.
Rewrite it around the invariant: no secret the store holds is ever written to
disk. The mTLS client certificate is the sole credential on disk, and the
section now says why — it authenticates the puller to the store, so it is the
one credential that cannot be fetched from it. Per secret the target still
states minter, reader and renewal, but the reader is now a process pulling a
store path at runtime under its own certificate, not a path being read off
disk.
The current-state section and all of its citations are untouched: the current
state genuinely is files, and that contrast is what the page is for.
Two vale rewrites in c33cc441 changed meaning, not just wording:
- "on track to become" asserted a trajectory the page's own inventory
contradicts (no bao identity anywhere, all renewals NONE, all
secrets plaintext). Restored intent: "what it should become".
- "most often misread" claimed an observed reader frequency nobody
measured. Dropped the claim about readers entirely; states the fact
directly instead.
Both stay vale-clean; no suppressions.
Fixes the Microsoft.Contractions/write-good.ThereIs/alex.Condescending/
Microsoft.Auto errors vale flagged in CI (prose-lint-errors, required):
contractions throughout, no sentences opening with 'There is', dropped
the 'auto-mints' hyphenation and the 'easily' condescension flag, and
renamed the credentials table's 'what it is' header to 'kind' (also
flagged once styles were resynced). No technical claim or file:line
citation changed. Same fix applied to the one new line in
docs/README.md's pointer to this page.
Audited all 22 file:line citations against source on this head, per
argus's PR review. Fixed:
- matrix_account.rs writer cell pointed at path-computation line 163;
the actual store.write() call is at 189-197 (now anchored to the
put_matrix_account function name too).
- swarm-secret-publisher.nix appservice-token minter cited the
"put on every run" comment (280-286) instead of the mint itself
(271-278).
- glue-matrix-bao-token.nix persisted-token cell cited the
registration-restamp comment (186-188) instead of the printf+chmod
(180-181).
- glue-queue-agent-credential.nix persisted-files cell missed the
secret's chmod 0600 at 219, citing 224-231; corrected to 218-224.
- swarm-otel.nix persisted-file cell cited 763-767, one line short of
the chmod 0400 at 768; corrected to 764-768.
- hive-priv/src/main.rs:368,413 (forge/github token cell) landed in
unrelated DestroyContainer/ListContainers/WriteAgentGithubToken match
arms; replaced with the write_forge_token/write_github_token function
names as a greppable, edit-resistant anchor.
- swarm-bao.nix:527-528 (the "operator places by hand" quote) missed
the quoted text, which starts at 529; corrected to 529-531.
15 of 22 already resolved exactly; no claim lacked supporting code, so
nothing was removed. 22 of 22 citations checked.
The swarm's credential docs say where every file lives. They do not say
whether it should be a file at all, so a discussion about direction has
had nothing to point at and each one re-derived the same table.
This page carries that table with the three columns the target contract
is written in — minter, reader, renewal — plus the column the target is
really about: whether the value is persisted outside the store. Stating
it flatly is the point. All four stored families are plaintext files on
disk, the appservice token twice; every renewal cell reads NONE; no agent
container holds a store identity at all, so the hive reads on its behalf
and writes a file in; and the appservice token has a second, uncoordinated
local mint that can diverge from the published one.
The target section is marked as a target throughout, because its first
line is the one most easily misread as fact: every host needing a store
mTLS certificate is where this is going, while today only the store's own
host auto-mints and swarm-bao.nix calls it the credential an operator
places by hand everywhere else.
The progressive-enhancement rule is stated as a table of questions a
reviewer applies to a pull request rather than as prose, since a rule
nobody can check is a preference. New functionality matches the target
immediately; existing functionality moves stepwise, and the questions
distinguish a step from churn.
Indexed from the docs root and the swarm README. It supersedes
secrets.md when the migration completes — at which point that file is
deleted and this one moves into its place.
Add attribution-trailer lint row to the ci.md checks table (modeled
on the vale-errors row: not yet a required check) so the doc doesn't
assert stale completeness once attribution-trailers merges. Delete
the dead CI_COMMIT_REF_NAME elif in check-attribution-trailers.sh —
byte-identical to the else arm, never exercised, and its comment
overclaimed GitLab/Forgejo support nothing here can test.
Refs #4432
`glue-matrix-bao-token.nix` has read
`secret/swarm/hives/<hive>/matrix/appservice-token` since it landed, but
nothing ever wrote that path. The store was empty in every deployment, so
every read degraded to "keep what activation minted" and each hive stayed
the origin of a value the swarm has to agree on — two hives never
converged.
`swarm-secret-publish` is now the producer. It already holds a store
identity, already writes under the hive prefix, and already runs per
hive in the roster, so the mint is a third loop beside the two OIDC
copies rather than a second shape of this unit.
Idempotence comes from a record of its own, not from the store: this
principal is granted `create`/`update` with no `read`, so it cannot ask
whether a hive already has a token. It keeps what it minted under
`StateDirectory=` (0700 dir, 0600 file) and mints only when that file is
missing or empty; the `put` runs every time, because re-putting the same
bytes changes nothing for a reader while a mint whose publish failed must
not be left as a token this host holds and no hive can reach.
The token never becomes a nix literal and never reaches argv: the mint
redirects into a file, and the publish hands bao `value=@<path>` so bao
opens it itself — the same handling the OIDC loops use.
`hive-matrix.nix`'s activation mint stays as the genuine first-boot
fallback. It already fires only when the token file is absent, so it
cannot clobber a value the store delivered; `hs_token` has no swarm half
and is still minted there for real.
Refs #4402
Nothing reads it any more: hive-c0re creates accounts as the hive's
appservice, so the mint, the host file, the bind mount, the
`LoadCredential` entry and tuwunel's `registration_token_file` all go.
⚠️ `allow_registration` has to go to `false` in the same change, and not
as hardening. tuwunel refuses to START when registration is allowed with
no token configured — it demands
`yes_i_am_very_very_sure_…_open_registration_…` instead — so dropping the
token and leaving the flag true is not a lax homeserver, it is one that
does not boot. The flag is checked only for requests arriving without an
appservice token, so hive-c0re provisions exactly as before and everyone
else is refused outright.
The swarm secret store keeps its role, repointed at the credential that
replaced the token (`swarm/hives/<hive>/matrix/appservice-token`). Its
unit now also re-runs hive-matrix's own registration renderer after
writing the file: the token is half an agreement, and a registration
still naming the previous value authenticates nobody. The renderer is
shared through an internal option rather than copied, so the
registration's shape has one home.
Both spellings of `registrationTokenFile` become
`mkRemovedOptionModule` with a message naming what replaced them. A hive
that never set the option — the default — is unaffected; one that pinned
it fails to evaluate with instructions instead of a silent no-op.
An upgraded hive needs no intervention: the activation script has both
halves in place before the homeserver restarts, existing agents keep the
tokens their devices already hold, and the old token file is left on
disk read by nothing. docs/integrations/matrix.md spells the path out.
Refs #4402
A subagent inherited the parent's built-in tool list, which correctly has
no `Bash` -- the agent reaches a shell through the `bash` MCP server, not
the built-in. Subagents get no such server, so the intersection was empty
and they could not run a command at all: no commits, no pushes, no gates.
Add `subagent_builtin_tools_for`/`_arg`, which reuse the shared resolver
and append `Bash` only when `Execution` -- the group that gates the `bash`
MCP server -- is present. Only the subagent spawn path calls them, so the
harness's own `--tools`/`--allowedTools` are unchanged.
The capability transfers; the mechanism does not.
Refs #4422
The comments and docs around the empty-`--tools` assert stated a
mechanism: that an empty value parses as *unset* and therefore grants
MORE built-ins than omitting the flag. That claim came from a
measurement, and the installed `claude --help` (2.1.268) says the
opposite in as many words — `Use "" to disable all tools`. One of the two
is wrong and this repo is not the place that settles it.
The rule the code enforces is right under both readings, which is
precisely why it should not be argued from either: we never emit an empty
`--tools`, because what an empty one means is release-dependent and
nothing here wants a subagent with no built-in tools regardless. So the
prose now states the rule and the ambiguity, and asserts nothing about
`""` in either direction — replacing the claim with its opposite would be
the same mistake sourced differently.
No behaviour change: the assert, the resolution and every test are
untouched.
Two test comments also gained the limit they were quietly missing.
`no_spawned_tool_escapes_the_session` hardcodes ten names, so it is a
canary for tools someone thought to list, not a guarantee — the
guarantee is the subset property, which covers tools nobody has invented
yet. And `a_subagent_gets_no_builtin_its_parent_lacks` resolves both
sides in one process off one env var, so it catches a code divergence but
not the two real systemd units disagreeing about `HIVE_TOOL_GROUPS` —
which is what they did until the previous commit, and is not a thing a
unit test can reach.
Refs #4416
`build_config` spawned a subagent with `--dangerously-skip-permissions`
and no `--tools` at all, so it got claude's entire built-in set —
`SendMessage` and `ListAgents` (message peers, or the operator, as its
parent), `Task*` including `TaskStop`, which takes an *agent* id and so
reaches clean outside the run, `Cron*`, `RemoteTrigger` and
`EnterWorktree`/`ExitWorktree`. None of that is part of "do this bounded
task in this directory", and none of it is something the parent agent
itself can do: the harness has always passed `--tools`.
Pass the same one. The value comes from
`hive_sh4re::permissions::builtin_tools_arg()` — literally the function
the harness resolves its own session with — so the subagent's set is the
parent's set, `HIVE_TOOL_GROUPS` and all. That inheritance is the
requirement, not an implementation detail: a hardcoded subagent list
would hand `WebFetch`/`WebSearch` to the subagent of an agent without the
`web_tools` group, which is a privilege escalation, and would drift from
the parent's list the first time anyone added a tool to either.
`--tools` is the real gate: it holds under
`--dangerously-skip-permissions`, unlike `--allowedTools`, which only
auto-approves prompts. It does not filter MCP tools, so the
`goal_reached`/`need_help` signal surface is deliberately unnamed in it
and survives on `--strict-mcp-config` alone.
`build_config`'s doc comment claimed `strict_mcp_config` was *the* safety
property and that a subagent got "nothing implicit and nothing more".
That was false for built-ins, and is what hid this gap for as long as it
did; it now says which flag covers which half and that neither
substitutes for the other.
An empty `--tools` value parses as *unset* and grants more than omitting
the flag, so an empty resolution can only be a bug — `build_config`
asserts against it and a test pins the non-emptiness alongside the
subset-of-parent property.
Refs #4416
The `--tools` list a harness session gets is not a constant: the base set
plus whatever the agent's `HIVE_TOOL_GROUPS` add (today, `web_tools` →
`WebFetch`/`WebSearch`). That resolution lived in `hive-agent`'s
`mcp_config`, which is fine while the harness is the only thing that
spawns a `claude` — and it is not: `hive-subagent-mcp` spawns one too.
`hive-agent` is binary-only (no `src/lib.rs`, no lib target), so nothing
can depend on it to reach `builtin_tools_arg`. The alternative to a
shared home is a second list in the subagent daemon, which diverges on
the first tool anyone adds to either — and diverging upward is a
subagent holding a built-in its parent does not have.
So move the base list, the `HIVE_TOOL_GROUPS` parse and the resolution
into `hive_sh4re::permissions`, beside `ToolGroup` — whose
`builtin_tools()` was already half of the answer. `hive-agent`
re-exports them, so `mcp_config::builtin_tools_arg()` still reads the
same at the call site, and `allowed_tools_arg` now derives its built-in
half from the same function rather than repeating the merge loop.
Behaviour is unchanged. The parse is `strum::EnumString` rather than a
`serde_json::from_value` round-trip through a `Value::String`: same
`snake_case` names (a test pins the two derives against each other),
without `hive-sh4re` needing `serde_json` outside its dev-dependencies.
It is now a pure function of its input, so the fallbacks are testable
without mutating the environment — which under edition 2024 is `unsafe`
and racy across a test binary's threads.
Refs #4416
`goal_reached`/`need_help` took the session name as a tool argument, so
identity was an assertion by the caller and the only guard on it was
`occupancy()` — "does that name have a turn in flight", which two
concurrently running siblings both satisfy for each other. A subagent
could stop its sibling's run by naming it.
Identity moves into the URL. Each spawned run is minted an unguessable
token (`Uuid::new_v4`, the OS CSPRNG), the URL carrying it goes into that
one subagent's own `--mcp-config`, and the route resolves it back to a
session before dispatching to a handler bound to that session. Neither
tool takes a `name` any more: a subagent has no field in which to name a
sibling, and a sibling's name — which a brief may well mention — is not a
token.
One route with a path parameter, not a route per session: the `Router` is
built once at startup and subagents come and go for the daemon's whole
life. An unminted or revoked token gets a bare 404, the same answer either
way, so nothing enumerates. A run's token is revoked when the run ends
(`finish_turn`) or when a call never reached a spawn.
Two things fall out of that:
- the config file becomes one per session. A single shared path was
already a race between two `start`s; with a per-session URL in it, the
loser would read the winner's identity.
- `occupancy()` stops being the identity guard and is gone from the signal
path entirely rather than kept "just in case" — a revoked token can't
reach it, and it never answered the question it was standing in for.
It still backs `status`, which is what it was always actually for.
Refs #4403
Refs #4413
`start` takes an optional `goal`. With one set a session stops being a
single turn: when a turn ends and nothing has said to stop, the daemon
spawns another turn re-prompting the subagent toward that goal, up to
`max_turns` (default 5, per-session). Without a goal nothing changes —
one turn, one todo, same as before.
Four things end a run, each recorded distinctly and reported by `status`:
the turn ending with no goal, `goal_reached`, `need_help`, and the turn
cap. The last says so out loud rather than stopping quietly — the todo
states the harness limit was reached and the goal was never reported
reached. Every stop extends the done message rather than replacing it,
and lands in the session's report file when it has one. The path is
never inferred: it comes from `start`'s `report_file` or from the
subagent naming where it wrote.
`goal_reached` and `need_help` are the subagent's own, served on a second
route (`/signal/mcp`) that carries those two tools and nothing else, so
reporting on a run can't become starting one. `goal_reached` is built as
a label, never a gate: it is self-reported by a subagent that has just
been re-prompted with "you haven't reached the goal", which is exactly
the incentive to claim it — the same failure class as a build report
asserting the tests pass. Every surface that renders it says so.
`need_help` is the blocking signal, and shows in `status` as its own
state so a parent polling it sees the block without reading a file.
`status` also carries `turn N of M`: with 4330's last-event age, that
separates working from wedged from out of turns off one answer.
Two bugs the new tests caught: a `tokio::fs::File` was dropped without
flushing, so the report line was written to nothing, and the plain idle
answer dropped the turn counter.
Also documents `await_resume`'s third case — a closed channel with no
send, which fails open the same as `Underway` — per argus on #4411.
Refs #4403
`continue` returned "started" the instant `Claude::spawn` handed back a
pid, and a resume that matched nothing only surfaced later, as an
end-of-turn todo. By then the caller had moved on believing it had a
running subagent.
A pid is proof enough for `start`, which creates its session: the spawn
succeeding is the whole story. It is not proof for a resume — claude
exits non-zero a fraction of a second *after* the process exists. So
`continue` now waits for the first real answer and reports a miss as its
own `Err`, carrying claude's message and the directory searched.
The wait ends on whichever comes first, so a successful `continue` pays
no fixed delay: the turn's first non-terminal stream event settles it at
about the same moment a miss's exit would have. Measured on this box:
14 runs of the driver's own invocation against a missing session took
550-1087 ms spawn to exit, and a healthy turn's first event lands at
roughly 500 ms. The five-second cap is ~4.6x the slowest miss and is only
ever reached by a child that neither speaks nor exits.
The underway signal reads the event's kind, not its content: a missed
resume is not silent — it emits a terminal `result` event and stderr
before exiting — so "any sink callback" would have reported every miss as
a successful start. Liveness still counts all three callbacks.
The end-of-turn todo is unchanged for every failure later in the turn;
the only one it no longer repeats is the miss the caller was just handed.
Refs #4405
A session cannot be moved between directories, so a hint reading
"pass dir" could be misread as pointing an existing session at any
directory. Say instead that dir names the directory the session was
started in.
Refs #4405
`status` could only answer running / starting / idle / killed / none,
because every turn ran against `&NoopSink` and the whole stream-json
stream was discarded. "Running" describes a wedged subagent exactly as
well as a busy one, leaving a caller to tell them apart from `ps` output
and CPU-time deltas.
So the daemon now keeps a `name -> last_event_at` clock, bumped by
`LivenessSink` on every line of every stream — stream-json events, plain
stdout chatter and stderr alike — and `status` reports its age on a
running answer: a few seconds means working, an age climbing into the
minutes with no end-of-turn todo means wedged. Nothing is read out of the
content; classifying *what* a subagent is doing is a separate question
and waits on its own driver work. In memory with the rest of this
daemon's state, dropped when the turn ends, no persistence.
The clock is seeded at the spawn rather than at the first line, so a
subagent that wedged before emitting anything still reports a climbing
age rather than no age at all — the case an age is worth most in.
Separately, `continue`'s existence pre-check is gone. It could only
repeat the lookup `Claude::spawn` was about to do, and its message —
"no session named `x` exists" — was false in the common failure: the
session existed, just not under the claude home + cwd `build_store`
resolved from. claude's own `--resume` is the authority and exits
non-zero (`does not match any session title`) rather than quietly
starting a fresh session, so the turn fails on its own. `classify_end`
appends the one fact the CLI's message lacks — the directory searched:
claude error: no session matched the requested id or title (searched
<claude_home> for cwd <cwd>; if the session was started elsewhere,
pass `dir`)
The `dirs` map's durability is untouched; whether to persist it stays an
open operator decision.
Module doc, `docs/tools/subagent.md`, the `continue`/`status` tool
descriptions and the `base:claude-subagents` skill all updated — including
`continue`'s `dir` doc, which said "the daemon remembers it" without
saying that a restart is both when it forgets and when you most want it.
Refs #4330
Refs #4405
`swarmctl agent create <name> --hive <hive>` POSTs `/api/agents` to
swarm-controller over the daemon's unix socket and prints the queued
job's node id.
It deliberately does not wait. The endpoint queues a DAG whose last node
*publishes* a deploy message; the hive's `hive-c0re` then converges on
its own clock, out of the controller's sight. So even a fully settled
graph would not mean the agent is up, and there is nothing this CLI
could wait for that would let it claim otherwise. Printing the id is
exactly what the response says and all of what it says.
Transport is a bare hyper HTTP/1.1 client handshaked onto a tokio
`UnixStream` via `hyper_util::rt::TokioIo` — the same crate family
`hivectl/src/watch.rs` and `hive-agent/src/web_ui/proxy.rs` already use,
all of it already workspace-pinned. The request/response shapes are a
local mirror rather than a shared crate: the controller's own types are
private to its binary and this crate does not link it, the same
separation `hivectl` keeps from `hive-c0re`.
Errors are reduced to one actionable line — the controller answers
RFC 9457 problem+json, so an unknown `--hive` reaches the operator as
the roster of hives that would have worked rather than a body dump.
Response `warnings` are printed when non-empty.
The nix module wraps the binary with `SWARM_CONTROLLER_SOCKET`, read
from the same `socketPath` the daemon binds.
Refs #4399
swarm-controller's `InitAgentConfigRepo` node already covers config-repo
creation, so this deletes a duplicate rather than a capability; old
`init_config` rows are skipped by `collect_lenient` with no migration, by
operator decision.
Refs #4398
- swarm_agent_state.rs: HEADROOM in swarm_term.rs is private, so the
intra-doc link can never resolve; switch to plain backticks.
- docs/swarm/README.md: move closing period inside the quoted phrase
per Microsoft.Quotes.
Refs #3802
The swarm can already tell whether an agent is alive — the `agent-status`
KV bucket republishes once a minute — but not what it is doing right now.
A header bar wants the second thing, and a minute-old answer to "is this
agent thinking" is the wrong answer most of the time it is read.
`hive-agent` now publishes a turn-state header to
`$SWARM.agent-state.<hive>.<agent>`, a core subject beside the terminal
rows it already sends. It goes out **on transition, not on a timer**: the
publisher watches the event bus, rebuilds the header, and sends only when
the serialised result differs from the last one it sent — so a second
periodic writer, which is the problem this exists to fix, is not what
replaces the bucket.
The payload is the published contract a swarm-level renderer is written
against, so the test asserts on the serialised JSON keys rather than on
Rust field names. Two fields deliberately depart from the per-agent web
UI's `StateSnapshot`: `turn_state_since` is an ISO 8601 UTC string rather
than unix seconds, matching the sibling `$SWARM.term` subject's stamp, and
`agent_state` carries the swarm's own `AgentState` vocabulary rather than
a `paused` boolean, so a reader can compare actual against wanted without
translating. `turn_state` and `agent_state` stay two separate fields:
neither vocabulary contains the other's values.
Swarm-side, `GET /api/agents/{name}/state/stream` relays the subject as
SSE, resolving the agent's hive at request time exactly as the terminal
stream does and passing the bytes through without parsing them.
The broker grant is a second `--agent-publish-subject` rather than a
widening of the existing one, so the terminal family and the header family
stay independently revocable, and a `module-eval` arm pins the rendered
flag and its argument together — the doubled dollar included, since a
single one expands to nothing in `ExecStart` and yields a grant that
matches nothing.
Refs #3802