Commit graph hyperhive/docs
Author SHA1 Message Date
iris
cb504f6863 docs: clear write-good.Passive hits in gateway/subagent/dashboard docs
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.
2026-09-20 04:13:02 +02:00
iris
9fac33a026 docs/swarm/secrets.md: fix sense-mis-attribution in certificateFiles rewrite
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.
2026-09-20 04:10:55 +02:00
iris
c985702bb0 docs: clear write-good.Passive hits in swarm secrets/README docs
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).
2026-09-20 04:10:55 +02:00
iris
18c5b5c5ad docs: clear write-good.Passive hits in matrix/persistence docs
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.
2026-09-20 04:10:41 +02:00
atlas
15424d3e49 hive-forge: move pr create --remote's origin default onto the clap arg
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.
2026-09-19 20:33:48 +02:00
atlas
18f0199566 hive-forge: default pr create --push remote to origin
Fresh clones only have origin, not forge. The --remote override
already covers anyone using a different name.

Closes #4538
2026-09-19 18:16:55 +02:00
atlas
a358889384 docs(gateway): describe nginx as per-host, not a deployment-wide singleton
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.
2026-09-19 13:58:10 +02:00
atlas
d8e26a17bb nix: give the gateway, resolver and bridge their own enable
`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.
2026-09-19 13:53:10 +02:00
atlas
87970a8c93 mcp: remove the restart/kill/start/update/get_logs agent verbs
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
2026-09-19 10:47:39 +02:00
atlas
837e658d4a swarm: courier an agent's store identity into its container, and log in with it
`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
2026-09-19 01:55:31 +02:00
atlas
4a1b5f5d51 docs(forge): drop the flat hidden-verb section from the verb reference
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
2026-09-18 22:43:35 +02:00
atlas
273b2895a1 docs: reword two vale-flagged 'is not' phrasings in credentials.md
Microsoft.Contractions wants 'isn't' over 'is not'; reworded the
mTLS-leaf-exception sentence to match without changing its meaning.

Refs #4137
2026-09-18 15:17:58 +02:00
atlas
676c45bc93 swarm: mint, publish and login-verify an agent's store identity at create
`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
2026-09-18 15:05:24 +02:00
atlas
992468dccc docs: reword three vale-flagged sentences in matrix.md
Preserve meaning exactly: 'There is no' -> 'No ... exists', 'That is'
-> 'that's', 'is not' -> isn't.

Refs #4475
2026-09-18 10:35:16 +02:00
atlas
99b141f5f2 matrix: drop the per-agent matrix.enable; accounts are the enable signal
`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
2026-09-18 10:35:16 +02:00
atlas
c74249f371 matrix: make the hive-internal main account an ordinary matrixAccounts entry
`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
2026-09-18 09:34:44 +02:00
atlas
6050033fbc docs: fix stale hyperhive.* prose left by the repoint
Refs #4479
2026-09-18 03:05:43 +02:00
atlas
7570597190 docs: repoint agent-tier option paths to services.hyperhive.agent.*
Refs #4479
2026-09-18 03:05:43 +02:00
atlas
6e23af36c9 docs/swarm/credentials: a hive is an identity too
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.
2026-09-17 13:56:34 +02:00
atlas
42dcf10064 swarm-logs-cli.md: regenerate from the binary, prettierignore it
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.
2026-09-17 01:02:14 +02:00
atlas
a39399f037 swarm-logs: an agent's CLI for the swarm log store
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.
2026-09-17 01:02:14 +02:00
atlas
b71ff90661 docs/swarm/credentials.md: restore the new-vs-existing progressive-enhancement split
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
2026-09-17 00:02:00 +02:00
atlas
0c50913db9 docs/swarm/credentials.md: mTLS cert is per-identity, not per-host
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
2026-09-16 23:29:39 +02:00
atlas
596e8f006f docs/swarm/credentials.md: every host has at least one certificate
- Change 'Every host has one' to 'Every host has at least one' to clarify
  that a host may hold multiple mTLS client certificates.
2026-09-16 23:24:54 +02:00
atlas
972b80d7b8 docs/swarm/credentials.md: fix citation range to include 'A path, never a value'
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
2026-09-16 22:46:29 +02:00
atlas
cc77db0e7e docs/swarm/credentials.md: nix fmt table alignment 2026-09-16 22:41:53 +02:00
atlas
940e11ecda docs/swarm/credentials.md: cut the page down to the target shape only
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
2026-09-16 22:41:23 +02:00
atlas
f1e5fd5592 docs/swarm/credentials.md: make the target section bao-shaped, not file-shaped
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.
2026-09-16 22:41:23 +02:00
atlas
ae388c48a7 docs/swarm/credentials.md: restore claims changed by vale rewrite
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.
2026-09-16 22:41:23 +02:00
atlas
b2c1378fd3 docs/swarm/credentials.md: reword prose to pass vale
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.
2026-09-16 22:41:23 +02:00
atlas
52fabe7096 docs/swarm/credentials.md: fix 7 drifted/wrong file:line citations
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.
2026-09-16 22:41:23 +02:00
atlas
937289383c Add a credential docs page: current state, target state, the rule between
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.
2026-09-16 22:41:23 +02:00
atlas
f45cecf08c ops: address argus review findings on PR #4433
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
2026-09-16 00:07:02 +02:00
atlas
199afa41c8 matrix: publish the appservice token from the swarm, not just read it
`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
2026-09-15 20:57:49 +02:00
atlas
7ee7080b21 matrix: remove the registration token
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
2026-09-15 19:58:10 +02:00
atlas
5ce0a357b4 subagent: grant claude's Bash when the agent holds the execution group
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
2026-09-15 18:48:51 +02:00
atlas
9cd30a58ba subagent: say why we refuse an empty --tools, not what one would do
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
2026-09-15 17:40:27 +02:00
atlas
d6c8cd5a6f subagent: hand a subagent its parent's built-in tools, and no others
`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
2026-09-15 17:40:27 +02:00
atlas
8b01dbeef1 permissions: give the built-in tool list one home, next to ToolGroup
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
2026-09-15 17:40:27 +02:00
atlas
34129d776c subagent: give each run its own signal URL, and drop the name argument
`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
2026-09-14 22:24:51 +02:00
atlas
b18348bc9a subagent: give a run a goal, turns toward it, and a reason it stopped
`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
2026-09-14 21:46:59 +02:00
atlas
6e2de33f26 subagent: make a missed continue the tool call's own error
`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
2026-09-14 20:56:16 +02:00
atlas
31c76ddf32 subagent: say the dir a session was started in, not "pass dir"
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
2026-09-14 20:56:16 +02:00
atlas
307df77948 subagent: report turn liveness, and stop pre-checking continue
`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
2026-09-14 20:56:16 +02:00
atlas
30fa54cbc6 feat(swarmctl): add agent create, queueing the swarm-controller creation DAG
`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
2026-09-14 19:40:23 +02:00
atlas
a3b672d1d5 refactor(hive-c0re): drop the request_init_config tool and InitConfig approval
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
2026-09-14 19:03:44 +02:00
atlas
20e211f904 fix: resolve unresolved rustdoc link and quote-punctuation lint
- 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
2026-09-14 15:37:09 +02:00
atlas
1ea3d87d7a swarm: publish each agent's turn-state header on its own subject
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
2026-09-14 15:12:23 +02:00
atlas
30b9955ad3 fixture: repair 4 CI failures uncovered on rebase
Refs #4374

- otelNoIdentity: name journaldUnits so the fixture trips the store-
  identity path it's testing instead of swarm-otel's unrelated
  journald-safety assertion (an empty list with log collection on is
  refused as "collect everything", not "collect nothing").
- otelNoStores: give it a bao client identity. The secret gate moved
  from deployCfg.authelia.enable to a real client cert/key pair, so a
  fixture meaning "no telemetry stores" now needs its own secret
  identity to keep exercising the exporter/authenticator wiring it
  was written for.
- docs/swarm/secrets.md: two vale fixes — a contraction, and drop a
  condescending "simply".
2026-09-14 00:58:58 +02:00
atlas
0ff5c8110b swarm-otel: deliver the OIDC client secret through the secret store
The swarm collector's OIDC client secret only existed where authelia
did: `swarm-otel-oidc-secret.service` copied the minted plaintext out
of authelia's container tree, reachable only because the two share a
host's network namespace. A swarm that placed authelia elsewhere
delivered nothing, and the option's own description said so —
"a deployment that places authelia elsewhere points this at a file it
delivers itself." Same gap as #3853 and #4234, and this is the
swarm-otel twin of #4234's fix for Grafana.

Mirrors PR #4361 (Grafana) almost exactly:

- `swarm-bao-otel-oidc.service` reads
  `swarm/services/<client-id>/oidc/client` out of the store, in every
  deployment, replacing the co-located copy unit outright — one
  delivery route, not two, per the ruling that landed under #4234.
- Client registration moved out of `swarm-otel.nix`'s own `config`
  block (gated on this host running the collector) into
  `glue-swarm-otel-oidc-client.nix` (gated on this host running
  authelia), the same split `glue-grafana-oidc-client.nix` made. It
  was broken the same way: a split deployment registered the client
  nowhere at all, so authelia never minted a secret for the publisher
  to send on.
- The publisher's `services` prefix (write grant in `swarm-bao.nix`,
  hive read grant in `policy::render`) already covers any service's
  path — nothing to add there. `swarm-secret-publisher.nix` only grew
  `serviceClientIds` by one entry.

One judgement call, stated rather than buried: the store-reading unit
renders only where this host holds a client identity
(`deploy.bao.clientCertFile`/`clientKeyFile`), rather than asserting
it the way `swarm-grafana.nix` does. Grafana's local login form is
disabled unconditionally, so a Grafana with no OIDC secret has no way
in at all — that earns a hard refusal. This collector without a
credential still receives every hive's telemetry; only its own pushes
to the stores go out unauthenticated and get refused there, an
already-supported degrade the module's own `haveCollectorSecret` flag
named before this change. So the reading unit follows the shape
`glue-matrix-bao-token.nix` and `glue-queue-agent-credential.nix` use
for their own optional readers: no unit when the identity is absent,
not a build refusal.

Fixtures mirror #4361's: `otelBaoWithAuthelia`/`otelBaoRemoteAuthelia`
are the positive pair (co-located and split, both reading through the
store), `otelNoIdentity` is the negative — no reading unit, no
assertion firing, `clientSecretFile` left null.

Refs #4258
2026-09-14 00:58:58 +02:00