An agent's subagent daemon publishes each subagent's output as terminal
rows on `$SWARM.term.<agent>.sub.<subagent>`, as the agent, into a
per-agent stream it creates itself; swarm-controller lists an agent's
subagents from that stream's subjects and relays one subagent's rows as
SSE; the swarm UI lists them under the agent's terminal preview and
reuses AgentTermPreview, full-screen tab included, with no input.
- swarm-nats.nix: the agent token may also publish
`$SWARM.term.{agent}.sub.>` and `$JS.API.STREAM.CREATE|INFO` on
`term-sub-{agent}`, and nothing else of JetStream. A module-eval arm
pins the agent-token grant as an exact list.
- mcp.nix: hive-subagent-daemon loads the agent's store identity
(`hive-agent-bao-cert/-key/-server-ca`, the ones hive-agent loads)
whenever the agent has a store, not only on the opencode preset. The
agent's own queue secret lives in the store, so this is the credential
the harness connects with.
- hive-subagent-mcp: `swarm_term` reads the agent's queue secret under
that identity, connects with the agent token, opens or creates
`term-sub-<agent>` (max_age 24h), and publishes classified rows from
the sink every subagent line already passes through. The sink only
queues (bounded, drop-and-count); a missing store, refused credential,
failed stream create or failed publish is a log line.
- The stream-json classifier (`stream_enrich`) and the `TermMsg` row
types plus `fit` move from the hive-agent binary into hive-sh4re, so
the subagent daemon publishes the rows AgentTermPreview already
renders. hive-agent keeps its LiveEvent classifier on top.
- swarm-controller: `GET /api/agents/{name}/subagents` and
`GET /api/agents/{name}/subagents/{subagent}/term/stream`.
- docs/swarm: what the UI shows and what the queue carries.
Closes #4827
297 lines
14 KiB
Nix
297 lines
14 KiB
Nix
# `checks.module-eval-nats-authelia` — see ./lib.nix for the shared
|
|
# rationale (why this suite exists, naming convention, "evaluates
|
|
# not executes").
|
|
{
|
|
pkgs,
|
|
lib,
|
|
self,
|
|
nixosSystem,
|
|
}:
|
|
let
|
|
inherit
|
|
(import ./lib.nix {
|
|
inherit
|
|
pkgs
|
|
lib
|
|
self
|
|
nixosSystem
|
|
;
|
|
})
|
|
hive
|
|
runGroup
|
|
;
|
|
|
|
# The queue's callout identity, fourth split slice. `autoGenerateCallout` is
|
|
# left FALSE on purpose: that is what makes the seed paths the thing deciding
|
|
# `responderConfigured`, so the assertion below is about the seeds rather
|
|
# than about the auto-mint branch. Every one of the seven old paths is
|
|
# defined — `enable` included, which is why it is spelled the old way here
|
|
# while the fixture below uses the new one — so dropping any single nats
|
|
# shim fails the eval, not just the arms read.
|
|
natsOldPath = hive {
|
|
swarm.nats.enable = true;
|
|
swarm.nats.autoGenerateCallout = false;
|
|
swarm.nats.calloutUserPublicKey = "UTESTUSERPUBKEYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
|
swarm.nats.calloutIssuerPublicKey = "ATESTISSUERPUBKEYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
|
swarm.nats.calloutUserSeedFile = "/run/secrets/nats-user.seed";
|
|
swarm.nats.calloutIssuerSeedFile = "/run/secrets/nats-issuer.seed";
|
|
swarm.nats.authPackage = pkgs.emptyDirectory;
|
|
};
|
|
|
|
# Seventh split slice, plus slice 10's two authelia packages. Of slice 7's
|
|
# movers only `usersFile` has a rename entry — the other two are `readOnly`,
|
|
# and a rename module contributes a definition, which a read-only option
|
|
# refuses; see ./host-modules/deploy.nix. `package` and `bridgePackage` are
|
|
# ordinary options, so they do carry one. The two arms
|
|
# below have different jobs. `usersFile` tests the rename; the nats one tests
|
|
# that a reader repointed to the new namespace still renders the derived
|
|
# path, which is the failure this slice could actually have shipped — seven
|
|
# of those reads went through an alias a path-shaped grep cannot see.
|
|
autheliaOldPath = hive {
|
|
deploy.authelia.enable = true;
|
|
deploy.nats.enable = true;
|
|
swarm.authelia.usersFile = "/var/lib/test-authelia/users.yml";
|
|
swarm.authelia.package = pkgs.emptyDirectory;
|
|
swarm.authelia.bridgePackage = pkgs.emptyDirectory;
|
|
swarm.nats.autoGenerateCallout = false;
|
|
swarm.nats.calloutUserPublicKey = "UTESTUSERPUBKEYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
|
swarm.nats.calloutIssuerPublicKey = "ATESTISSUERPUBKEYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
|
swarm.nats.calloutUserSeedFile = "/run/secrets/nats-user.seed";
|
|
swarm.nats.calloutIssuerSeedFile = "/run/secrets/nats-issuer.seed";
|
|
};
|
|
|
|
# The queue with a store identity for its responder, placed by hand: the
|
|
# queue host that is not the store's.
|
|
natsWithStoreIdentity = hive {
|
|
deploy.nats.enable = true;
|
|
deploy.nats.autoGenerateCallout = false;
|
|
deploy.nats.calloutUserPublicKey = "UTESTUSERPUBKEYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
|
deploy.nats.calloutIssuerPublicKey = "ATESTISSUERPUBKEYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
|
deploy.nats.calloutUserSeedFile = "/run/secrets/nats-user.seed";
|
|
deploy.nats.calloutIssuerSeedFile = "/run/secrets/nats-issuer.seed";
|
|
deploy.nats.authPackage = pkgs.emptyDirectory;
|
|
deploy.nats.authBaoClientCertFile = "/etc/pki/nats-auth.pem";
|
|
deploy.nats.authBaoClientKeyFile = "/etc/pki/nats-auth-key.pem";
|
|
};
|
|
|
|
# The queue on the store's own host, where the PKI glue mints every leaf.
|
|
natsOnStoreHost = hive {
|
|
deploy.bao.enable = true;
|
|
deploy.nats.enable = true;
|
|
};
|
|
|
|
responderOf = h: h.containers.swarm-nats.config.systemd.services.swarm-nats-auth;
|
|
|
|
# A hive running NOTHING of the swarm's own services — no IdP here, no
|
|
# `swarm.authelia.url` set by hand. The whole point of the fixture is what it
|
|
# does *not* say: it is the shape whose IdP address used to be null, and
|
|
# before that a co-location-derived guess.
|
|
autheliaNotColocated = hive { };
|
|
# The same swarm's IdP host, for the arm that the two agree.
|
|
autheliaColocated = hive { deploy.authelia.enable = true; };
|
|
|
|
cases = [
|
|
{
|
|
# Reads the RENDERED settings, not the option: `calloutBlocks {…} // {
|
|
# … }` is a shallow merge, and a future edit that dropped or shadowed
|
|
# this key would still evaluate cleanly — the only reader that would
|
|
# notice is a publisher whose row exceeds upstream's much smaller
|
|
# default, and by then it is a dropped row, not an eval failure.
|
|
# Piggybacks on the pre-rename nats fixture above, which already
|
|
# renders this container's full config.
|
|
name = "the queue's payload ceiling is set, not inherited from the server's default";
|
|
ok = natsOldPath.containers.swarm-nats.config.services.nats.settings.max_payload == 8388608;
|
|
}
|
|
{
|
|
# Reads the RENDERED unit text, not the module's source, because the
|
|
# failure this defends against renders perfectly: systemd substitutes
|
|
# `$NAME` in `ExecStart` regardless of quoting, so a single dollar
|
|
# here hands the responder `.term.{hive}.>` — a grant that parses, is
|
|
# accepted, and matches nothing an agent ever publishes to. Asserting
|
|
# the doubled dollar is the only way to tell the two apart before
|
|
# deploy. The flag's presence is asserted separately so that dropping
|
|
# the grant entirely fails as its own arm rather than as an escaping
|
|
# complaint.
|
|
name = "the responder grants agents their hive's terminal subject, and the dollar survives systemd";
|
|
ok =
|
|
let
|
|
exec =
|
|
natsOldPath.containers.swarm-nats.config.systemd.services.swarm-nats-auth.serviceConfig.ExecStart;
|
|
in
|
|
lib.hasInfix "--agent-publish-subject " exec && lib.hasInfix "$$SWARM.term.{hive}.>" exec;
|
|
}
|
|
{
|
|
# Second grant, same escaping trap, asserted separately: the two
|
|
# subject families are independent features (terminal rows and the
|
|
# turn-state header) and dropping either should fail as its own arm
|
|
# rather than being masked by the other still being present.
|
|
#
|
|
# Flag and argument are matched as one infix rather than as two
|
|
# independent `hasInfix` calls: the responder takes the flag
|
|
# repeatedly, so the thing worth pinning is that THIS subject is the
|
|
# argument of one of them, which two separate presence checks would
|
|
# both pass on while the subject sat under some other flag entirely.
|
|
name = "the responder grants agents their hive's agent-state subject too";
|
|
ok =
|
|
let
|
|
exec =
|
|
natsOldPath.containers.swarm-nats.config.systemd.services.swarm-nats-auth.serviceConfig.ExecStart;
|
|
in
|
|
lib.hasInfix "--agent-publish-subject '$$SWARM.agent-state.{hive}.>'" exec;
|
|
}
|
|
{
|
|
# The per-agent grant: the same two streams keyed on the agent alone,
|
|
# with the same doubled dollar, and the role the store writes for it.
|
|
name = "the responder grants a verified agent its own hive-free subjects";
|
|
ok =
|
|
let
|
|
exec = (responderOf natsOldPath).serviceConfig.ExecStart;
|
|
in
|
|
lib.hasInfix "--agent-token-publish-subject '$$SWARM.term.{agent}'" exec
|
|
&& lib.hasInfix "--agent-token-publish-subject '$$SWARM.agent-state.{agent}'" exec
|
|
&& lib.hasInfix "--store-cert-role swarm-nats-auth" exec;
|
|
}
|
|
{
|
|
# Its own arm, like the two above: the icon write is a separate feature
|
|
# and dropping it must not hide behind the streams still being granted.
|
|
name = "the responder grants a verified agent its own icon key";
|
|
ok =
|
|
let
|
|
exec = (responderOf natsOldPath).serviceConfig.ExecStart;
|
|
in
|
|
lib.hasInfix "--agent-token-publish-subject '$$KV.agent-icons.{agent}'" exec;
|
|
}
|
|
{
|
|
# The whole per-agent grant, compared as a list rather than by infix: a
|
|
# wider subject added beside these (`$$JS.API.>`, another stream's name)
|
|
# would pass every presence check above.
|
|
name = "a verified agent's grant is exactly its own subjects and its subagent stream";
|
|
ok =
|
|
let
|
|
args = lib.splitString " " (responderOf natsOldPath).serviceConfig.ExecStart;
|
|
granted = lib.concatLists (
|
|
lib.imap0 (
|
|
i: a:
|
|
lib.optional (a == "--agent-token-publish-subject" && i + 1 < lib.length args) (
|
|
lib.elemAt args (i + 1)
|
|
)
|
|
) args
|
|
);
|
|
in
|
|
granted == [
|
|
"'$$SWARM.term.{agent}'"
|
|
"'$$SWARM.agent-state.{agent}'"
|
|
"'$$KV.agent-icons.{agent}'"
|
|
"'$$SWARM.term.{agent}.sub.>'"
|
|
"'$$JS.API.STREAM.CREATE.term-sub-{agent}'"
|
|
"'$$JS.API.STREAM.INFO.term-sub-{agent}'"
|
|
];
|
|
}
|
|
{
|
|
# Each credential by `LoadCredential`, and the environment naming where
|
|
# the unit sees it: a DynamicUser cannot read the copies directly.
|
|
name = "a responder with a store identity loads it and is pointed at the store";
|
|
ok =
|
|
let
|
|
u = responderOf natsWithStoreIdentity;
|
|
creds = u.serviceConfig.LoadCredential;
|
|
in
|
|
lib.elem "bao-client.pem:/var/lib/swarm-nats-auth/bao-client.pem" creds
|
|
&& lib.elem "bao-client-key.pem:/var/lib/swarm-nats-auth/bao-client-key.pem" creds
|
|
&& u.environment.BAO_CLIENT_CERT == "/run/credentials/swarm-nats-auth.service/bao-client.pem"
|
|
&& u.environment.BAO_CLIENT_KEY == "/run/credentials/swarm-nats-auth.service/bao-client-key.pem"
|
|
&& lib.hasPrefix "https://" u.environment.BAO_ADDR;
|
|
}
|
|
{
|
|
# An absent leaf must not stop the responder, which would deny every
|
|
# client: it is delivered empty, and the three credentials the responder
|
|
# cannot run without are delivered too.
|
|
name = "the copy unit delivers the store identity, and an absent one as an empty file";
|
|
ok =
|
|
let
|
|
s = natsWithStoreIdentity.systemd.services.swarm-nats-auth-secrets.script;
|
|
in
|
|
lib.hasInfix "install -m 0400 /etc/pki/nats-auth.pem " s
|
|
&& lib.hasInfix "install -m 0400 /etc/pki/nats-auth-key.pem " s
|
|
&& lib.hasInfix "install -m 0400 /dev/null " s
|
|
&& lib.hasInfix "oidc-client.secret" s;
|
|
}
|
|
{
|
|
# The control: no identity, no store wiring, and the responder's
|
|
# credentials are exactly the three it cannot run without.
|
|
name = "a responder with no store identity is not pointed at a store";
|
|
ok =
|
|
let
|
|
u = responderOf natsOldPath;
|
|
in
|
|
!(u.environment ? BAO_ADDR)
|
|
&& !(u.environment ? BAO_CLIENT_CERT)
|
|
&& lib.length u.serviceConfig.LoadCredential == 3;
|
|
}
|
|
{
|
|
# On the store's host the glue pairs the responder with a leaf of its
|
|
# own, never the queue's TLS-issuing one or the hive's.
|
|
name = "on the store's host the responder presents its own leaf";
|
|
ok =
|
|
let
|
|
n = natsOnStoreHost.services.hyperhive.deploy.nats;
|
|
b = natsOnStoreHost.services.hyperhive.deploy.bao;
|
|
in
|
|
n.authBaoClientCertFile == "/var/lib/swarm-bao-pki/nats-auth.pem"
|
|
&& n.authBaoClientKeyFile == "/var/lib/swarm-bao-pki/nats-auth-key.pem"
|
|
&& n.authBaoClientCertFile != n.baoClientCertFile
|
|
&& n.authBaoClientCertFile != b.clientCertFile;
|
|
}
|
|
{
|
|
# Not a rename test. `hostClientSecretDir` is `readOnly`, so the fixture
|
|
# cannot define it; what can break is a reader left pointing at the
|
|
# namespace it moved out of. Five modules read this through an
|
|
# `autheliaCfg` alias, where a path-shaped grep does not see it.
|
|
name = "a consumer of authelia's host client-secret dir renders it from the deploy namespace";
|
|
ok = lib.hasInfix "/var/lib/nixos-containers/swarm-authelia/var/lib/authelia-swarm/oidc-clients/" autheliaOldPath.systemd.services.swarm-nats-auth-secrets.script;
|
|
}
|
|
{
|
|
# authelia exits at startup on `users: {}`, so an empty database, and a
|
|
# file holding exactly that document, must be replaced by the disabled
|
|
# placeholder rather than left for authelia to read.
|
|
name = "an empty authelia users database is seeded with the disabled placeholder";
|
|
ok =
|
|
let
|
|
s =
|
|
autheliaColocated.containers.swarm-authelia.config.systemd.services.authelia-swarm-secrets.script;
|
|
in
|
|
lib.hasInfix ''if [ ! -s "$users" ] || [ "$(cat "$users")" = "users: {}" ]; then'' s
|
|
&& lib.hasInfix "-authelia-users-placeholder.yml \"$users\"" s
|
|
&& !(lib.hasInfix ''echo "users: {}"'' s);
|
|
}
|
|
{
|
|
# Config NAMES the IdP; it never computes where the IdP is. Both arms
|
|
# matter together: the address is the swarm's name on a hive that runs
|
|
# nothing, and it is the SAME string on the hive that serves the vhost —
|
|
# so a re-introduced co-location branch shows up as the two disagreeing
|
|
# rather than as a value that merely looks plausible on one of them.
|
|
name = "the swarm IdP address is its domain on every hive, co-located or not";
|
|
ok =
|
|
autheliaNotColocated.services.hyperhive.swarm.authelia.url
|
|
== "https://${autheliaNotColocated.services.hyperhive.swarm.authelia.domain}"
|
|
&&
|
|
autheliaColocated.services.hyperhive.swarm.authelia.url
|
|
== autheliaNotColocated.services.hyperhive.swarm.authelia.url;
|
|
}
|
|
{
|
|
# …and the name it resolves through is a DOMAIN, not a host this config
|
|
# picked. `127.0.0.1` is what the old default rendered on the IdP host,
|
|
# the bridge address is what the queue's defaults still render, and
|
|
# neither is an address a client may be handed: the domain is allowed to
|
|
# resolve differently in different places, which is the whole property.
|
|
name = "the swarm IdP address names no host address";
|
|
ok =
|
|
let
|
|
url = autheliaNotColocated.services.hyperhive.swarm.authelia.url;
|
|
bridgeIp = autheliaNotColocated.services.hyperhive.network.bridgeIp;
|
|
in
|
|
!(lib.hasInfix "127.0.0.1" url) && !(lib.hasInfix "localhost" url) && !(lib.hasInfix bridgeIp url);
|
|
}
|
|
];
|
|
in
|
|
runGroup "nats-authelia" cases
|