Commit Graph
2780 Commits
Author SHA1 Message Date
kevinveenbirkenbachandClaude Opus 5 563121cf76 fix(ci): let the chunk gate stop the chain, not only the next chunk
db66e01020 gave chunk_gate the job of stopping the chain at the first red
chunk, and each gate reads its immediate predecessor to do it. A blocked chunk
reports skipped, and skipped is what the gate accepts -- for good reason, since
that commit also lets a block whose slice is empty skip itself. So the chunk
after a blocked one sees a pass and runs.

Exactly one chunk falls out, the chain carries on, and the rows that chunk
owned get no verdict at all while nothing says so. Run 33279942812: chunk 0 red
with 13 of 44, chunk 1 skipped by the gate, chunk 2 deployed 81 jobs behind it.

Chunk 2 now reads chunk 0 as well as chunk 1, and needs what it reads. Chunk 1
already had only one predecessor and is unchanged. The allow-list keeps
skipped: an empty slice still has to pass.

The finish job was already right and stays untouched. It fails on failure or
cancelled and not on skipped, which is correct there for the same reason it is
wrong in the gate: a chunk this skips was skipped because an earlier one failed,
and that failure is what it sees.

The test pins both, because a fourth chunk written from the third's template
would repeat this: every gate names every chunk before it, needs what it names,
and the finish job needs them all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 16:31:05 +02:00
kevinveenbirkenbachandClaude Opus 5 6d191555c3 feat(ci-trigger): retrigger by role, and by what never got a verdict
Two switches and an alias for the case f4c5a7cc36 was not written for: a run
where most of the ranking is red and much of it never ran.

--roles-only collapses the failed selections to their role ids. f4c5a7cc36
replaced exactly that form with pinned tokens, and its reason holds: the
rotation may not draw the combination that broke. It holds for a handful of red
rows. At thirty roles the question stops being which combination broke and
becomes whether the role comes up at all, and a token per combination spends
the priority budget proving the same role red twice. The default is unchanged;
this is the trade, named, behind a flag.

--include-unrun puts the rows the source run holds no job for on the priority
line, with their axes. resume_offset covers a run that walked a contiguous
prefix; it stops at the first gap, and a run whose priority line consumed the
budget before the head of the ranking leaves a hole at row one. Then the offset
is empty, the retrigger starts over, and the rows nobody judged queue behind
the ones already settled.

With --strict the two agree on what settles a row. Only a success or a failure
does. A cancelled row, or one still running when the run was read, was never
given the chance to be either, so it comes back as itself rather than as a role
name that would let the rotation draw some other combination instead. On the
run this was written against that moves 24 rows: 123 started, 99 judged.

Membership is decided on role#variant. Mode, onion state and distro are
assigned by the sweep rotation, so comparing full tokens between a source run
and a freshly computed ranking reports almost every row as unrun -- measured,
266 of 269, against 177 once the identity is right. Each ranking row is a
distinct role#variant, which is what makes it the identity to compare on.

i8ciall carries the combination, next to i8cifail which reproduces the exact
red rows and i8cifo which takes only the hard failures.

resume_offset and the new code now build their tokens through one helper
rather than two copies of the same five lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 16:14:25 +02:00
kevinveenbirkenbachandClaude Opus 5 4a0d6b9f5d style(lint): unwrap the stack-var pattern the way autoformat wants it
880bec33bb wrote the regex across three lines; it fits on one under the line
limit, so `make quality` rewrites it on every run. The pattern is unchanged -
identical once whitespace is stripped from both revisions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 15:25:20 +02:00
kevinveenbirkenbachandClaude Opus 5 ab2062da2a feat(sys-svc-rdbms): give the persisted-password patch a script of its own
The body was a Jinja-interpolated shell string inside the task. As a file it
can be read, shellchecked and run, and the three parameters travel through the
environment the way the password already did.

That forces one thing into the open. The old expression carried
${PATCH_PASSWORD} and the shell expanded it while assembling the command; a
value arriving in an environment variable is not expanded a second time, so the
substitution has to be explicit. The consumers now write @PASSWORD@ and the
script replaces it, which is also the version a reader can follow without
knowing that rule.

The script refuses to run without its parameters rather than treating an unset
one as empty, because an empty volume name would look exactly like a node that
does not hold the volume: a silent success on every host.

Tests move with it, to the files/ segment every other test of a shipped script
uses. They run it against a stubbed `container` in three states per consumer -
no volume, volume without the config, both present - and assert the marker the
task reads as a change. Five mutations of the script were checked against them:
dropping the substitution, the marker, the existence test, the parameter guards
and the volume filter each turn the suite red.

Layout follows what the linters already require: files/shell/ is enforced by
role-files-layout, and 65e34746fc names ansible.builtin.script as the sanctioned
way to run one from a task.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 15:16:20 +02:00
kevinveenbirkenbachandClaude Opus 5 5f7ff5a1c1 test(lint): let the host-fact rule see what its name promises
The rule read one of the two ways a fact is written, and it read only
templates. Both gaps hid the same kind of value.

Bracket subscripts were invisible: ansible_facts.default_ipv4 was matched,
ansible_facts['default_ipv4'] was not. Both forms are now generated from one
key list rather than spelled out.

Role vars and defaults were out of scan. A fact assigned to a variable there
reaches the template exactly as if it had been written in the template, and the
only real violation in the tree was sitting in that blind spot, which is how it
survived the rule that exists to catch it. The scan covers vars/main.yml and
defaults/main.yml through the mapping constants, 839 files where it saw only
templates before.

Interface addresses were not a pattern at all, only the named default_ipv4. A
resolver reading the docker bridge off ansible_facts['docker0'] cost thirteen
jobs in one run and was removed by hand; the rule that should have caught it did
not know the shape. Any ansible_facts[<iface>]['ipv4'] is now matched, and the
legacy ansible_<iface>.ipv4 with it.

What stays permitted is the distinction the rule turns on. A fact that ADDRESSES
a host travels wrongly. A fact that describes its PLATFORM does not: os_family,
architecture and processor_vcpus choose behaviour for the node the rendered file
is consumed on, which is the node it was rendered for. All seven ansible_facts
reads in the tree were read individually; five are of that second kind, and a
rule needing a suppression on five correct lines out of seven is not a rule.

svc-net-tor is the sixth and seventh. It reads both the bridge address and
default_ipv4, and meta/services.yml pins the role to the manager, so the fact
describes the node the hidden service forwards on. Declared rather than left
looking like an oversight.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 14:24:32 +02:00
kevinveenbirkenbachandClaude Opus 5 d7de6e3805 fix(compose_volumes): leave the two-engine diagnostic to its owner
b067d21ef added the realignment helper as an argument to compose_volumes(),
so it now runs before that function reaches its own guarded call. A role with
both engines enabled therefore raised the bare ValueError through the lookup,
and the message that names the collision and says to pick one dbtype per role
had become unreachable. The helper catches it and defers.

No role can trigger this today: over all 265 meta/services.yml, none declares
both keys at all, and `enabled` has a single shape across the tree, the literal
True in 54 places. The guard protects the diagnostic for whoever adds the
second engine, which is exactly when a bare ValueError would be least useful.

The tests pin what the two startup failures cost to find. The SQL must stay one
ALTER USER IF EXISTS: root must not appear, because giving it a password during
the entrypoint's temporary server breaks a readiness probe that connects
without one; CREATE USER must not appear, because the entrypoint's own carries
no IF NOT EXISTS and the script runs under set -e; and the GRANT must not come
back, because rotating a credential changes passwords, not privileges.

Verified by rendering the version those tests were written against: it puts
root, CREATE USER and GRANT in the file and four statements in place of one, so
every assertion fails on it. The deferral was checked the same way, against the
module as it stands in HEAD.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 13:56:51 +02:00
kevinveenbirkenbachandClaude Opus 5 880bec33bb fix(roles): name the application in values handed to the stack include
`vars:` on an `include_role` is in scope for everything the include pulls in,
and the expression is evaluated where it is read rather than where it is
written. `sys-stk-full` pulls in the proxy and the database, and each of those
roles sets `application_id` to its own id, so a value written with
`application_id` answers a different question by the time anyone asks for it.

It surfaced as a missing key in a role that never declared the value: a
repository url written by one app was looked up on `svc-prx-openresty`, and the
git task died on an unresolvable argument rather than on anything to do with
the url.

The seven definitions here are the ones the included chain actually reads, all
of them `compose_repository_url` / `compose_repository_ref` sources. Naming the
application literally makes each expression answer the same in every scope it
travels through. Values that stay inside their own role keep reading
`application_id`; nothing downstream evaluates them.

`4ce4236585` fixed the other half of the same mechanism a while back, in
`meta/services.yml`, where a role-var was referenced from a value the compose
role resolves. Same failure, opposite end of the lookup.

The lint fixes the form. It judges only the `compose_*` keys, because matching
every handed-over key reported thirteen values, six of which nobody downstream
reads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 12:56:56 +02:00
kevinveenbirkenbachandClaude Opus 5 1e88eb9d1d fix(swarm): let a rollout survive one task that failed and came back
update_config never set failure_action, so swarmkit's default applied: pause,
at a max_failure_ratio of zero. One failed task stops the rollout for good, and
nothing in the deploy resumes it.

That contradicts the restart_policy three lines below. The policy replaces the
task and the service ends up healthy, while the update status stays latched and
service_converged.sh reads that status before it looks at any task, so the
deploy reports a failure over a service that is running. The rescue artifact
shows the sequence: the old task shut down, its replacement tripped over a
containerd object runsc could not reap, and the third task was Running while
the job died on "update latched".

Four swarm jobs failed that way, all in the update pass, never in the first.

The convergence check is untouched and stays as strict as it was: a genuinely
broken spec fails every task and still trips "tasks not running yet".

What this does give up: the latch was also the only thing catching a service
that keeps crash-looping. At ratio zero any failure latched, so a task dying
and restarting forever was guaranteed to be reported. Now the rollout
completes, and the check samples task state at one moment - a task on its
fiftieth restart still reads as Running, and wait_converged's `until` needs a
single lucky sample. Restoring that detection needs the check to look at task
churn, which the service ps history already carries and the script prints only
on failure today. Not in this commit.

max_failure_ratio cannot substitute here: at replicas 1, the common case in
this repository, one failure is always the whole service.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 12:43:47 +02:00
Alejandro Roman 026b28f9a7 fix(swar-stalwart): migration process 2026-08-30 14:43:00 +04:00
kevinveenbirkenbachandClaude Opus 5 f233d0c30b style(lint): split the role path with the path type
ruff's PTH206 rejects .split(os.sep) on a path. parts[0] is the same first
component here, since the string always has its roles/ prefix removed and so
never starts with a separator.

It reached the branch because the file was rewritten after its ruff run, to
satisfy the raw-walk rule, and the green check belonged to the earlier version.
quality-high's autoformat step caught it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 01:00:26 +02:00
kevinveenbirkenbachandClaude Opus 5 c9298033e6 fix(roles): define the entity_name these five roles read
Role vars do not cross role boundaries, so flowise, hermes, homeassistant,
openclaw and openwebui read nothing where they template entity_name into
resolve_service_key. Seventy-seven other roles set it on the line below
application_id.

Ansible does not stop for it. In compose the undefined value renders into task
names as "<< error 1 - 'entity_name' is undefined >>" and the play carries on,
because that path resolves through resolve_compose_cid and never reads the key.
Swarm templates it into a real argument and dies on the first task, and with
any_errors_fatal that ends the play. Reproduced both ways against a replica of
resolve_host_cid.yml: compose ok=4 failed=0, swarm "'entity_name' is undefined".

Nothing could have supplied it either - role vars reach neither a sibling
include nor the parent, so the value was always undefined rather than sometimes
borrowed from whichever role ran before.

The rule that comes with it compares consumption against definition, which no
existing check does; the four scanners under tests/lint/ansible/variables all
run the other way. It skips a file another role pulls in by absolute path,
since that file executes in the including role's scope and reads that role's
value, and it matches the variable rather than get_entity_name, the filter whose
name ends in it. Both exemptions cost a false finding each while I was writing
it. It caught two roles the review had missed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 00:37:24 +02:00
kevinveenbirkenbachandClaude Opus 5 52b4504035 fix(container-dns): let the bridge be declared instead of guessed
Whether a resolver answers on the docker bridge was inferred from the host's
own resolv.conf: all entries loopback was read as "a listener of ours is there,
and containers must reach it on the bridge". A systemd-resolved stub binds
127.0.0.53, NetworkManager's dnsmasq binds 127.0.0.1, and a node dnsmasq binds
both those and the bridge. All three look identical from resolv.conf and only
the third is reachable from a container.

So ordinary hosts were handed an address with nothing behind it. The
Instructions job died there: daemon.json carried "dns": ["10.208.0.1"] alone,
and the openresty build could not resolve dl-cdn.alpinelinux.org while the
daemon, resolving through the host, pulled the same base image fine. Two netns,
two outcomes.

e308e18923 had already fixed this once, with a reachability gate whose message
names the same URL. b09f2cbd82 dropped that gate and ce43263745 put the
resolv.conf inference in its place, so the guard was gone and only the missing
docker0 fact still hid it. 285ba1e76c removed that accident and the failure
surfaced.

svc-net-tor and NETWORK_CONTAINER_BRIDGE_RESOLVER now say it instead, the
latter false by default and true in the swarm lab, which provisions the dnsmasq
its nodes resolve through. Extra-vars are global, so the lab's NFS and backup
hosts carry it although no listener is installed there; the clearnet resolver
behind the bridge keeps them working.

The docker0 fact is gone from the address too. It is not merely late, it is
wrong while it is late: facts are read before the daemon config is applied, so
docker0 still holds docker's default and writing default-address-pools moves
the bridge out from under it. A swarm node shipped "dns": ["172.17.0.1", ...]
against a live 10.208.0.1, which is why its containers asked a dead address and
fell through to the public resolver, where infinito.example does not exist. The
pool answers before the daemon exists and answers the same afterwards, and it
is read per slice rather than per supernet, which differ once a pool does not
start on a slice boundary.

A list that would carry only the bridge now gets NETWORK_PUBLIC_DNS_RESOLVERS
behind it, so a declaration that turns out to be wrong costs a wasted first
query rather than all name resolution. An empty declaration raises.

Rendered from the repository's own variable files: the Instructions host yields
[] and the template omits the key, which is what de95a95c65 emitted while that
job was green; lab and tor nodes yield the live bridge ahead of the clearnet
resolver even with a stale docker0 fact present; an ordinary host yields only
its declared resolver.

The suite asserted the two behaviours this replaces, including one case added
with 285ba1e76c demanding the stale fact win. They are inverted, and a
cross-product over nameserver shapes, tor membership, clearnet and the docker0
fact now asserts that no shape ends up holding the bridge alone - the old suite
checked each shape by itself, which is how this passed through it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 00:32:13 +02:00
kevinveenbirkenbachandClaude Opus 5 f70717ad45 fix(openclaw): name the model the way the gateway validates it
The provider block ec736bd4d7 added carries only an id per model. OpenClaw
validates the whole file before serving anything and rejected it:

  Gateway failed to start: Invalid config at /home/node/.openclaw/openclaw.json:
  models.providers.litellm.models.0.name: Invalid input

A rejected config is not a degraded start. The gateway retries, trips its own
breaker after four unclean boots, and the container ends at exit 128. The
deploy notices only at the very end, when the container health check gives up
after 25 attempts - twenty-five minutes and a role away from the cause.

The model itself was fine: the rendered file on the host held
[{"id": "qwen2.5:0.5b"}], so this is the missing key and not the
LITELLM_CHAT_MODEL fallback. name now sits beside id with the same value.
Which of the two reaches the API is the upstream's business; identical values
cannot send a different model than the one agents.defaults names.

It shipped because nothing rendered it. All three existing cases in
test_mcp_tool_filter.py pass OPENCLAW_LITELLM_ENABLED=False, so the block was
skipped and its shape was never compared to anything. The new test renders it
with LiteLLM on, and against the old template it fails on KeyError: 'name'.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:06:41 +02:00
kevinveenbirkenbachandClaude Opus 5 285ba1e76c fix(container-dns): take the bridge from the pool, not from a fact
ce43263745 replaced the bridge literal with the clearnet resolver and left
the lookup to supply the bridge from ansible_facts.docker0. That fact
cannot be there on the run that needs it: facts are gathered at play
start, nothing in the role re-gathers them, and on a fresh node docker is
installed afterwards - so the render that writes daemon.json always sees
no docker0 and emits the clearnet resolver alone.

Measured on one node of the same job: docker0 is 10.208.0.1/24 while its
/etc/docker/daemon.json carries "dns": ["1.1.1.1"]. The bridge existed and
was missing from the config that had to name it.

The inner containers then asked a public resolver for a name only the node
dnsmasq serves, and the DinD check reported it accurately seven times:

  Server: 1.1.1.1
  ** server can't find infinito.example: NXDOMAIN

(The '[container] CA trust certificate is not a regular file' line above it
is noise - container.py catches that and falls back to plain docker run.)

default-address-pools is where docker takes docker0 from, so the
declaration answers before the daemon exists and gives the same answer
after: subnet_gateway("10.208.0.0/12") is the 10.208.0.1 measured above. An
observed docker0 still wins, so a host whose bridge sits elsewhere keeps
its real address.

That also settles what the docstring flagged as unavoidable. The
fact-derived value appeared only from the second run on, so daemon.json
differed between passes and restarted the container runtime under a live
stack. Both passes now render the same bytes.

Only the swarm path showed it: 18 jobs died at the daemon start before
reaching this handler, and the six that got here in the next run are the
first to exercise it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 22:08:51 +02:00
kevinveenbirkenbachandClaude Opus 5 87c1ba0371 test(lint): scan the nested task files too, and say what is not scanned
The glob stopped at roles/*/tasks/*.yml while the docstring claimed every
task file of every role. It missed 226 of 916 files - anything under a
nested directory such as tasks/flavor/compose/ or tasks/utils/. The rule
passed and read as full coverage.

The vacuum guard could not catch that. It fails when no file pairs a
docker call with a stack include at all, which protects against an empty
scan, not against a scan that is merely a quarter short.

Widening it surfaces no new finding, so nothing was hiding down there.

The other limit stays and is now written down instead of implied: the
comparison is per file, so splitting the docker call and the stack include
across two task files escapes the rule. Resolving that needs the include
graph rather than two line numbers, and every current caller keeps both in
one file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 20:59:49 +02:00
kevinveenbirkenbachandClaude Opus 5 a3dcf9b869 fix(inventory): drop the credentials the inventory actually holds
_drop_app_credentials walked applications[app].credentials, which
f713ba8350 moved to secrets.credentials. Nothing lives at the old address
any more - the run's inventory has twelve applications under
secrets.credentials and none above it - so the isinstance check never
matched and the function returned 0 every time. The rotation ran, reported
success and rotated no application secret at all.

It stayed hidden because the sibling half worked: _drop_user_passwords
reads document["users"], which did not move, so the total was never zero
and the call looked healthy.

That matters more than a dead branch usually would. Rotating between two
deploy passes is what turns the second pass into a test of the update
path, where a role has to push a new secret into the application and the
database it already created. With the drop silently skipped, every such
run has been re-deploying the values the first pass wrote.

The fixture pinned the pre-rename shape, so the test confirmed the bug
rather than catching it. Migrated to the real layout, with the address in
one helper instead of four assertions; against the old code it now fails
1 != 4.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 20:58:42 +02:00
kevinveenbirkenbachandClaude Opus 5 8e3fe04d72 fix(mcp): read a provider's bearer where provisioning writes it
Discovery resolved applications[role].credentials, the address from
before f713ba8350 moved every credential under secrets.credentials. The
run's own inventory shows which one is real: twelve applications carry
secrets.credentials, none carries the top-level key. So every provider
declaring credential.source: credentials - fifteen of the twenty - handed
back an empty mapping, and reconciliation aborted on a secret that had
been provisioned all along:

  mcp_servers: 1 authorized MCP server(s) cannot be rendered.
  web-app-prometheus: credential_missing: web-app-prometheus declares
  owner 'mcp-web-app-prometheus' but no secret resolved

The five providers on token_store read through users and were never
affected, which is why only some clients broke.

Provider and client were reading the same secret at two addresses:
web-app-prometheus/vars/main.yml:32 renders its bearer from
secrets.credentials.mcp_bearer. Without the strict assert this would have
surfaced as a 401 at runtime rather than a failed deploy.

The lookup was correct when 6881ac5132 wrote it - the rename landed on a
branch it was not an ancestor of, and arrived here through the main
merges of 08-15. Its own message notes that no regex over lookup('config')
could see an access path like this one; a .get("credentials") split across
two lines in a Python plugin is equally invisible to it.

The address now comes from credential_key's constants, which is where the
module already claims a credential is addressed by exactly one string.

resolve_credential was always handed a fabricated mapping directly, so the
function was covered and its caller's address was not. The new tests close
that seam, one of them end to end over the shape provisioning produces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 20:57:07 +02:00
kevinveenbirkenbachandClaude Opus 5 f4b1ccadd7 fix(container-dns): re-tag the value before handing it to the templar
5345f1632 added the render step but not the re-trust, so it never worked.
ansible-core 2.19+ renders only strings carrying TrustedAsTemplate, and
the str() coercion on the line above drops the tag the extra-vars loader
attached. templar.template() then hands the expression back untouched,
the guard sees Jinja and raises:

  The lookup plugin 'container_dns' failed: networks.internet.dns is
  '{{ NETWORK_PUBLIC_DNS_RESOLVERS | first }}' and did not resolve to an
  address.

Which is worse than the ParseAddr the guard was written to prevent, and
on the same 18 jobs: before, one bad daemon.json; after, every run that
reaches the daemon config, compose included, since
inventories/development/default.yml carries a lookup('env') there.

Re-tagging first is what proxy_pass, container_ports, container_volumes
and compose_volumes already do. Through the real lookup loader the field
now renders to ["10.208.0.1", "1.1.1.1"].

The unit stub was the reason none of this showed: it rendered any string,
so twenty tests passed against code that could not work in a play. It now
refuses an untagged string the way the templar does, which makes the four
tests covering this fail without the re-trust. Stub and production key off
the same private ansible module, so a move would degrade both at once -
production to silent identity, the stub to rendering anything. The added
class asserts that shared premise rather than trusting it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 20:55:46 +02:00
kevinveenbirkenbach ac02ed134d fix(homeassistant): create the MCP network after docker exists
The image ships docker-ce-cli and no daemon; sys-svc-container installs
and starts that, and a role reaches it through a sys-stk-* stack. This
role pre-created its overlay network before that include, so on the
compose path the branch that probes the bridge ran against a socket that
was not there yet:

  Error connecting: ... FileNotFoundError(2, 'No such file or directory')

It fails late and unrecognisably. The play spends three minutes in the
constructor and the base roles first, the container reports healthy
throughout because its probe cannot ask about a daemon that is not due
yet, and the error surfaces as a network probe rather than as 'docker is
not running'. Swarm hides it entirely, since those nodes have docker
before the play reaches the role.

minio, discourse, openldap, seaweedfs and prometheus all order it the
other way. minio is the closest model: load the stack, create the
network, and let the later flush_handlers start the stack, so the network
exists before anything needs it and docker exists before anything
creates it.

The rule compares the two line numbers in each task file. It caught this
one at line 5 against line 11.
2026-08-29 20:06:43 +02:00
kevinveenbirkenbach 5345f1632e fix(container-dns): render the inventory value before docker reads it
ce43263745 replaced the literal 172.17.0.1 under networks.internet.dns
with {{ NETWORK_PUBLIC_DNS_RESOLVERS | first }}, for a good reason: the
fixed address went stale whenever default-address-pools moved the
bridge. Nothing renders that field, though. The file arrives as
'-e @inventories/development/swarm.yml' and this lookup read it straight
out of variables, so the expression reached the daemon as its own source
text:

  dockerd: unable to configure the Docker daemon with file
  /etc/docker/daemon.json: invalid JSON:
  ParseAddr("{{ NETWORK_PUBLIC_DNS_RESOLVERS | first }}"):
  unable to parse IP

Then 'Start request repeated too quickly', and the node has no container
engine, which is where eight swarm jobs died - across centos and debian,
so it was never distro-specific. While the field held an address the
missing render step was invisible.

It now renders against the lookup's own templar, and refuses to emit a
value that still carries Jinja. A string that is not an address has no
business in daemon.json, and failing loudly beats leaving a node without
docker.
2026-08-29 19:42:47 +02:00
kevinveenbirkenbach 8c4ed6be46 fix(lint-playwright): derive the helper set here too
87f762bd7 replaced the named helper lists in the deploy task and
rerun-spec.sh with a glob. There was a third stager. lint-playwright
keeps its own staging tree, so it kept its own list, and every MCP spec
failed to parse there: 8 of 87 roles, the same 'Cannot find module
./mcp-endpoint' the CI lint job reports.

All three lists held different sets - the deploy task four helpers,
rerun-spec.sh three, this one four again but not the same four - and none
of them the one the specs actually needed.

f14b1ab3f6 already fixed this drift once, by adding addon-gating here to
match the deploy stager. Adding a name to a list that exists three times
is what made a third occurrence certain; the list is gone now.

The rule learns about the third stager as well, since one that goes back
to naming helpers is exactly what nobody notices.
2026-08-29 18:37:20 +02:00
kevinveenbirkenbach 06c13e7f3e fix(mcp): hide the path the application serves, not the sidecar's
f5272fa85f derived the 404 from mcp.endpoint.path, which for an
adapter-fronted provider names the sidecar. Mattermost declares /mcp
there and answers on /plugins/mattermost-ai/mcp-server/mcp, so the proxy
blocked a path the application never served while the native surface
stayed reachable. Baserow passed only because its two paths coincide -
one role agreeing proved nothing about the other four.

That surface is what the adapter exists to narrow: it serves four tools
out of fourteen, and the native path reaches all fourteen.

adapter.upstream_path joins upstream_network and upstream_transport in
meta, and the four roles whose upstream lives on their own vhost declare
it there. Gitea does not: its upstream is a separate sidecar on its own
network, so it has nothing to hide on the application's vhost, and the
absence says that.

The roles read the value back through lookup('config', ...) rather than
keeping a second copy, so the path is stated once.

The template now separates the two roles the old variable conflated:
endpoint.path is what gets served, and only where the surface is public
and enabled; every declared path that differs from it gets a 404. Three
tests pin the distinction rather than the outcome - mattermost's two
paths must differ, gitea must declare none, and a passthrough provider
must say where its upstream lives.
2026-08-29 17:47:03 +02:00
kevinveenbirkenbach 57ff37693c Revert "fix(cache): refuse a render that captured a template failure"
This reverts 49f2dd516.

Its justification was wrong. I claimed the marker reached a running
Nextcloud as the stored Collabora WOPI URL. It did not: the deploy log
shows the executed command was

  config:app:set richdocuments wopi_url --value 'https://collabora.infinito.example/'

02_manager_ops/06_plugin/03_configure.yml reads the addon file straight
from disk with include_vars, in role scope, precisely so those variables
resolve - its own header comment says so. The '<< error >>' strings
appear in the merged-applications payload that feeds the loop items, and
nothing configures the application from there. I read a payload dump and
took it for the value that was set.

The guard is also too broad for what it does catch: it fails the whole
render because one unrelated subtree could not resolve, so every deploy
with nextcloud in the application set would abort on a condition that
was previously a warning and changed no configuration.

The underlying observation stands - a captured template failure should
not be memoised - but it belongs at the boundary where a caller asks for
the affected value, not on the whole payload, and it needs a real case
before it is worth the blast radius.
2026-08-29 16:10:21 +02:00
kevinveenbirkenbach 49f2dd516b fix(cache): refuse a render that captured a template failure
Ansible's marker behaviour replaces an undefined name with the literal
'<< error N - NAME is undefined >>' instead of raising, and it is
inherited ambiently from whatever task is rendering, so code calling
templar.template(fail_on_undefined=True) gets that string back rather
than an exception.

_render_with_templar then returns it, get_merged_applications memoises
it unconditionally under a key that fingerprints only applications,
users and two domains, and hands the same object back without a
deepcopy. The first caller in the process therefore freezes the value
for every later one: a lookup from another role renders a role's addon
config without that role's vars, and the role itself is served the
poisoned result afterwards even though its own read happens in the right
scope.

It reached a running Nextcloud as the stored Collabora WOPI URL. A
configuration key set to an error message is worse than a missing one,
because the application stores it and reports itself configured.

The check sits at the end of the one function every merge render passes
through, so applications, users and domains are covered, and it runs
before any caller can memoise. The path is carried down while
descending, so the message names the offending key rather than the
payload. The pattern requires the ordinal and the dash so prose about
errors does not trip it.

2678090934 made the same choice for the lookup layer; this is the cache
layer.

This raises where the code used to continue. That is the point - a value
nobody can act on should stop a deploy rather than configure an
application - but the blast radius is only measured by the next deploy.
2026-08-29 16:06:45 +02:00
kevinveenbirkenbach 42c7df9ed9 fix(proxy): give the vhost overrides back the concerns they drop
sys-stk-front-proxy prefers a role's own templates/proxy.conf.j2 over a
shared flavour, and an override replaces the flavour whole - including
every cross-cutting include living inside it. Expressing your own
locations should not cost you the MCP policy.

It did. f5272fa85f added that policy to both flavours and to nextcloud,
and the one provider whose native path most needed hiding renders from
its own template, so /mcp kept reaching baserow. The deploy was
failed=0 the whole time: the template produced no line at all, and a
green recap says nothing about a line that never existed. Reading the
rendered vhost is what found it.

Two overrides also lacked the prometheus locations, so nextcloud and
checkmk had no /healthz/live and no per-request metrics. That include
guards itself and emits nothing where prometheus is absent.

The required set is the intersection of the two flavours, so a concern
added to both is demanded of the overrides on the next run without
anyone maintaining a list. Location snippets are excluded, being what an
override exists to replace, and the set is only asked of roles that
actually serve MCP - demanding it of the rest would report noise and
teach the reader to skip the report.

A second rule covers a latent case the intersection cannot: a gated
override needs the oauth2 endpoint and the raised header buffers, both
of which live in one flavour only. All six carry them today, and nothing
made them.
2026-08-29 15:56:13 +02:00
kevinveenbirkenbach 211d98aa88 fix(sys-svc-proxy): finish stripping forgeable identity headers
b8759a363e closed this for html.conf.j2 and its message said 'every proxy
location', so the class read as done. Three proxying snippets never got
it: ws, upload and media.

They are not covered by inheritance. nginx inherits proxy_set_header as
an array, so a location declaring any of its own discards every one from
the enclosing block, and proxy_pass_request_headers defaults to on. A
grep for the strip found exactly one include site before this commit,
which means there was nothing above those locations to inherit either.

So every websocket, upload and image location forwarded whatever
X-Forwarded-User the client sent, to applications that authenticate by
believing that header. Reached through ws_generic (mastodon, espocrm,
odoo), nextcloud's signaling and whiteboard, peertube's websocket and
tracker, and collabora's /cool/.

The lint asks the file whether it proxies rather than keeping a list of
snippet names. It grants no exemption for an SSO branch: html.conf.j2
carries both, and an exemption keyed on the SSO reference would keep it
green after the strip was deleted from the other branch.
2026-08-29 15:54:29 +02:00
kevinveenbirkenbach 0b9fe7861a feat(baserow): front the native MCP endpoint with the adapter
The least-privilege half of the criterion could not hold as built.
provision_mcp.py creates the endpoint owner's workspace through
CoreHandler().create_workspace, which makes that account its ADMIN, and
2.3.3 knows only ADMIN and MEMBER - both write table data. There is no
read-only workspace role to demote to, so mutating_tools_enabled: false
bounded nothing while create_rows, update_rows and delete_rows sat in the
served allowlist.

The adapter's contract simply omits them: a client calling one is refused
with DENY_UNKNOWN_TOOL. Omission is the mechanism, since passthrough.py
refuses to load a contract that lists a mutating tool while mutations are
off. The other ten stay recorded under upstream_serves so a version bump
enabling one shows up as drift rather than as a silently wider surface.

The transport was what reverted this twice. Baserow speaks classic
HTTP+SSE, where a GET opens the response stream and the stream names a
second URL for requests, while the adapter only ever spoke Streamable
HTTP. adapter.upstream_transport selects sse.py and defaults to
streamable_http, so no existing provider changes.

The endpoint key stops being a client credential. Clients present
credentials.mcp_bearer to the sidecar; the key reaches the sidecar as
ADAPTER_UPSTREAM_PATH_KEY and is spliced in percent-encoded, so it never
enters the rendered contract and a key carrying a separator cannot open a
path of its own.

Deploy is green: the probe reached list_databases over SSE, the served
surface matched the contract, and the sidecar proved it reaches only its
provider.
2026-08-29 11:08:49 +02:00
kevinveenbirkenbach 44c4b17206 fix(lint): point the audit report rule at the moved docs
Three path segments in Python, not markdown links and not imports, so
neither the link rule nor the import resolver saw them when the rest of
the move was followed down. This is the rule that loads the generator and
compares its output against the committed report, so a stale path breaks
it outright rather than weakening it - and it is the one that caught the
generator writing an empty report earlier.

It has to land before the report gains rows, or the check cannot read
what it is meant to check.
2026-08-29 11:06:58 +02:00
kevinveenbirkenbach f5272fa85f feat(proxy): serve an MCP path only where it is meant to be public
Baserow's image mounts the MCP app on the ASGI root unconditionally, so
mcp.enabled only decides whether an endpoint record exists. The
application's own vhost kept routing /mcp/*: switched off it answered
401 Endpoint not found., and a refused credential is still a surface.
Switched on it is worse - the adapter narrows fourteen upstream tools to
four, and the publicly routed native path reaches all fourteen for
anyone holding the endpoint key. web-app-homeassistant had the same
shape, an exposure: internal surface reachable publicly. Both now 404
there and stay reachable on the container network.

The public surfaces get the edge limits the contract already declared.
Zones are written per role by the role that owns them, in the shape
web-app-prometheus uses, because one round-scoped file would drop another
application's zones on the next deploy. The rate is concurrent_requests
per second rather than an eighth limit key: a client allowed N in flight,
each bounded by timeout_seconds, cannot sustain more unless every call
returns instantly. A refusal is 429, not nginx's 503, because a client
that should back off must not read it as an outage. stream_seconds
finally binds something - the adapter holds no stream, the proxy does.

The shared location snippet's three timeouts become parameters defaulting
to the 900s they hard-coded, so no existing vhost changes.

Found by the disabled-state assertion 7d8e7e87ff added, which had never
run: its helper was never staged, so the two defects hid each other.
2026-08-29 11:05:09 +02:00
kevinveenbirkenbach 0b9b21bd9b test(lint): keep credentials out of volumes declared non-secret
meta/volumes.yml separates type: secret from type: config, and the
distinction is what a reader relies on: a config volume is the one you
may show someone or mount read-only for every process. Four roles
rendered a live client secret or an account password into one anyway.

The mode makes it concrete rather than theoretical. 9e7f28db20 widened
these to 0444 for a real reason - swarm config objects mount root:root
and 0440 crashloops a non-root app user - so every process in the
container can read what the type says is safe to show.

The vocabulary comes from how this repository actually writes secrets,
not from a word list. All four are suppressed with the upstream reason
they cannot be split: Jenkins CasC reads one file, Zammad's wizard takes
no reference. They are recorded rather than fixed, and a fifth role
cannot join them silently.
2026-08-29 10:59:56 +02:00
kevinveenbirkenbach bdaea43d56 test(mcp): make switching mutations on cost a named proof
Requirement 035 asks that mutating variants test confirmation,
authorization, idempotency, audit and rollback apart from the read-only
ones. No surface enables mutations, so those tests would assert against
nothing and pass forever, and inventing a mutating variant to satisfy the
checkbox would be a claim rather than a check.

The guard is what survives that emptiness: a surface permitting mutations
must name an existing artifact per proof, so the day one ships it cannot
ship without them. Three synthetic blocks exercise the rule - none, ones
naming a file that is not there, and ones that are - because a rule
evaluated over zero matching roles is green by construction. The reverse
also holds: proofs declared while mutations are off name artifacts that
are never the thing under test.

mutating_proofs was missing from the schema keys, so the lint would have
rejected the very block it demands.
2026-08-29 10:58:06 +02:00
kevinveenbirkenbach c04c49e753 refactor(mcp): drop the path-addressed endpoint from client discovery
Baserow was the only role declaring endpoint.key_credential, and putting
the adapter in front of it left the field with no consumer. Keeping it
would also contradict the decision that put the adapter there: a secret
in the URL path cannot be scoped per consumer and cannot be revoked on
its own, so a provider authenticating that way belongs behind an adapter
rather than in a client's rendered configuration. The segment now stays
between the adapter and its upstream.

Discovery loses a config lookup per provider per client along with the
branch. The test that proved the segment was appended becomes its
inverse: an endpoint still declaring the field gets a URL without one, so
the decision is held rather than merely unproven.
2026-08-29 10:55:34 +02:00
kevinveenbirkenbach 23688c6850 feat(svc-ai-mcp-adapter): bound a range query by the results it can return
A client picks start, end and step freely, so nothing limited how much
the upstream scanned. Against the live Prometheus contract a day at
minute resolution asks for 1441 points while the contract returns 100:
the server computed all of them and the adapter discarded 1341, with the
timeout bounding only how long it did that, four times over.

The ceiling is result_items, which the adapter already truncates to, so
the request and the response now mean the same number and no eighth
limit key was needed across seventeen roles. The refusal names the
smallest step that fits. Both boundary forms Prometheus accepts are
understood, and one that cannot be parsed is refused rather than waved
through, since a range nobody can measure is a range nobody can bound.

The rule keys on a tool declaring the three range arguments, not on a
role name, so a future range-shaped upstream inherits it.

938426da01 closed the time half of the same criterion with
--query.timeout; this is the sample half.

Also repairs one dead link the docs move left in the README.
2026-08-29 10:51:35 +02:00
kevinveenbirkenbach 88be326eb5 fix(svc-ai-mcp-adapter): ship sse.py to the container that imports it
The module was added while nothing selected it, so neither the Dockerfile
COPY nor the build-context staging listed it and nothing noticed. The
first provider to declare upstream_transport: sse would have crashed the
sidecar at import instead of serving.

Two places have to agree, because the context is assembled before it is
built: instance.yml puts the file next to the Dockerfile, the COPY takes
it into the image, and either one alone is inert.

The lint parses server.py, resolves every import that names a sibling
module, and requires each in both places, plus the reverse for a module
nobody imports.
2026-08-29 10:49:16 +02:00
kevinveenbirkenbach 87f762bd75 fix(e2e): derive the shared Playwright helpers instead of listing them
A helper that no stager copies fails at import, and it fails for every
role that imports it at once, because the whole spec file stops loading.
That is what mcp-endpoint.js did to all ten MCP specs: they ran zero
tests rather than one failing one.

The set lived in three places and no two agreed - the deploy task named
four helpers, rerun-spec.sh named three, and the specs required five.
addon-gating.js was already missing from the rerun path before any of
this. Both stagers now glob the runner's own files/*.js, minus the
config, so the directory listing is the set.

The lint derives what must be staged from the requires the specs
actually make, and holds both stagers to globbing, since a set that can
be typed twice will differ twice.
2026-08-29 10:45:20 +02:00
kevinveenbirkenbach 0a669b793e fix(mcp): mask the bearer the contract probe hands its container
The probe passes the adapter's bearer as a command argument, and ansible
echoes those once the run is verbose, so every deploy of all eight
adapter providers printed it verbatim.

The rule meant to catch this was blind twice over. Its scan tested the
path components for equality with 'mcp', which the role directory
'svc-ai-mcp-adapter' does not satisfy, so the file was never read; the
provider roles kept the non-empty guard satisfied while the one role that
handles every provider credential sat outside it. Its secret vocabulary
also lacked the two names a bearer actually travels under here,
MCP_AUTH_HEADER and mcp_probe_authorization. Either hole alone would have
let the task through.
2026-08-29 10:43:33 +02:00
kevinveenbirkenbachandClaude Opus 5 2d30681977 docs(mcp): turn the audit into something the lints can check
The audit read as prose, so a role could sit under a classification nobody had checked against its own declaration, and "blocked" could not be told apart from "nobody looked". The sections are now named after the exact values a role declares, which makes the section titles the vocabulary: one rule holds every role under its own declared classification, a second rejects a classification outside the vocabulary, and a third requires a named blocker per blocked role while forbidding a blocked role from shipping an enabled surface.

The report itself is generated from the roles' own metadata and a drift check imports the generator and compares against the committed file, which caught a stale report twice while this work was in progress. It lives under docs/contributing rather than beside the requirements, because a generated file in that directory reads like a finished criterion.

The criteria across seven requirement files now carry what they rest on: what closed them, or what blocks them and where that was measured. Two coverage limits stay stated rather than hidden: the report is built only from roles that declare an MCP block, so a role blocked without one is invisible to it, and a ticked criterion here means implemented and tested rather than proven in operation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 00:58:44 +02:00
kevinveenbirkenbachandClaude Opus 5 2608cb08d6 test(lint): fail closed on five properties that used to pass silently
Each of these is invisible because every individual setting is valid on its own. A sandboxed workload and the host container socket are both legitimate, only their combination is not: the socket hands over the host daemon, which can start a privileged container on the host kernel, and the isolating runtime becomes decoration while the deploy stays green. An embodiable agent that forgets the robot admission include deploys privileged onto a shared node and nothing says a word, and the include cannot live in svc-ai-robot because the service loader preloads it a stage earlier.

A provider bearer handed to a browser through playwright.env.j2 lands in the retained trace, its headers, its environment dump and its network log, and outlives the deploy that minted it inside a downloadable CI artifact. A served surface without its own mcp RBAC role leaves a client nothing to scope a grant to, and the fallback is not nobody: an empty grant reads as every administrator. An image bump to an amd64-only tag turns an arm64 promise into a scheduling failure on the Pi rather than a red test here, because platform_arch exists to pin a role to one architecture and a role that must run everywhere declares nothing.

Four of the five derive their scan from declarations rather than a list, and each carries a vacuum guard that fails when its scan comes back empty, since a rule whose derivation broke is otherwise a rule that passes forever.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 00:56:52 +02:00
kevinveenbirkenbachandClaude Opus 5 f52315850c test(openwebui): pin what a bare restart brings back
Open WebUI runs with persistent config off, so the environment is authoritative on every start and whatever the connection filter renders is the state the container comes back with, not only what a deploy produces. The filter already carried the group grant in that environment, but nothing asserted it: without the grant travelling there, a restart would restore a disabled connection that waits for the next deploy.

The README described exactly that outage as the current behaviour, which stopped being true once the grant moved into the environment, so it is corrected rather than left to mislead the next reader.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 00:55:22 +02:00
kevinveenbirkenbachandClaude Opus 5 ce29195b64 fix(qdrant,flowise): authenticate the vector engine and prove what Flowise can reach
Qdrant ran with no authentication at all: its environment set the HTTP port and disabled telemetry and nothing else, so every container that reached its network held full read, write and delete access to every collection, and the MCP surface's read-only promise rested entirely on the adapter's contract naming only GET routes. Qdrant 1.19 forces authentication on every request once a read-only key is set, so the engine now declares both keys, the adapter presents the read-only one, and the full key goes only to consumers that own data here. A write route is now refused by the engine rather than only by the adapter.

Three defects in the Flowise registry path. Its tool-name check compared against a contract the role always passed as empty, so it could not fail; it now compares against the provider's declared allowlist. Its managed flow pointed a customMCP node at an input that exists on no node at this tag, while the node that does take a registry id is customMcpServerTool, and the one that takes an inline config would have embedded the bearer in the flow; the flow is now an Agentflow whose Tool node calls one named tool with fixed arguments and is executed on every deploy. Its module docstring claimed only SSE providers could be registered, while the pinned MCPToolkit connects Streamable HTTP first and falls back to SSE, so the docstring contradicted the role's own declaration.

Reaching a provider inside the container network needs HTTP_SECURITY_CHECK=false, which replaces the built-in deny list rather than merging into it, so a typo there disables every protection silently while the registry keeps working. The deploy now points a throwaway entry at loopback and at the metadata address and fails unless both are refused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 00:49:58 +02:00
kevinveenbirkenbachandClaude Opus 5 4e46858202 feat(n8n): provision the MCP server and client, and close the auth gate
The oauth2 gate declared no allowed_groups at all while the role carried an mcp RBAC role nothing consulted, so it admitted anyone the proxy authenticated. It now admits the role's own administrator and mcp groups, and a lint derives the client set from meta/mcp.yml rather than a list, which is how the gap surfaced.

Two idempotence defects went with it. The run minted a public-API key, found it again next time and counted that as a change, so the task never converged; and it left that workflow-scoped key standing after every deploy although nothing read it. It is now minted per run and revoked before the run ends. The server workflow also connected nothing, and the trigger serves exactly the tools that reach it over an ai_tool connection, so it served none; it now carries one checked-in read tool with an explicit input schema.

The client side did not exist. It renders an agent workflow with one MCP Client Tool per discovered provider, each pinned to that provider's allowlist, because the node defaults to including every tool an upstream happens to serve. Credentials go through the internal API since the public one of this tag can only create and delete them, never converge an existing one. Every node type, typeVersion and connection literal was read from the pinned source.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 00:46:57 +02:00
kevinveenbirkenbachandClaude Opus 5 d9f497688d feat(litellm): authenticate the admin UI through Keycloak
The role declared sso.enabled false behind three suppressions, so the checks were switched off rather than satisfied. LiteLLM authenticates its own admin UI instead of sitting behind an oauth2 proxy, so the declaration is flavor oidc and the client id, the three Keycloak endpoints, the proxy base URL and the admin id all render together under one flag: a half-set group would let the gateway fall back to its own form with nothing complaining. The variants pin both states so the matrix runs with and without SSO.

The proof has to measure the departure, not the arrival: a gateway ignoring the client id answers /sso/key/generate with its own username and password form and never redirects, so the spec asserts the round trip to Keycloak and back. The guest spec became SSO-aware, since its canonical-domain and password-field assertions only hold where no SSO is configured.

Two more gateway defects: the key warm-up ran even where no model was served, falling back to openrouter/auto so the gateway then answered every prompt with 400, and it is now gated on a served-model expression mirroring the model selection itself. A lint requires every declared svc-ai backend to have a model_list branch, because a backend without a route is an alias nobody serves. probe_auth.py proves revocation rather than creation: it mints a throwaway key, revokes it, and fails unless a real consumer key survives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 00:44:26 +02:00
kevinveenbirkenbachandClaude Opus 5 b18c507cf6 feat(svc-ai-mcp-adapter): speak the classic HTTP+SSE transport
Streamable HTTP answers a POST on the same request; classic SSE splits the channels, so a GET opens the response stream and that stream announces a second URL as the request channel. The adapter only ever spoke the former, which is what reverted the Baserow migration twice. A contract now selects the transport and defaults to streamable_http, so no existing provider changes, and an unknown value is refused at load rather than at the first tool call.

The stream is opened once and held, because reopening it per call would lose whatever answer is already in flight. A notification carries no id, so it posts without awaiting a response the protocol never sends. A dead stream is now reset alongside the session it belongs to, since rebuilding the handshake over a dead stream would keep using it.

Two things this cleaned up on the way: NoRedirect and OPENER had been copied rather than reused, so server.py now takes the one in sse.py, and decode_jsonrpc moved to passthrough.py where it belongs, since it unwraps MCP responses rather than HTTP, which also brought server.py back under the file-size limit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 00:41:55 +02:00
kevinveenbirkenbachandClaude Opus 5 7d8e7e87ff test(mcp): prove a switched-off surface is actually gone
Every role-local MCP spec gates itself on the service flag and skips when the surface is off, so nothing ever checked that switching it off removes it. A surface still serving after the flag went false was indistinguishable from one that was never deployed: both read as a green skip. The gating helper had no inverse at all, so there was no way to assert what a disabled service must not do.

A shared assertion now probes the endpoint with both a GET and a POST while the flag is false and fails on any answer that shows something listening. It counts 401 and 403 as listening, which is the point: a refused credential still means the surface is there. The check is transport-agnostic because the roles differ in their enabled-state probes, over SSE, Streamable HTTP or plain JSON-RPC, while an endpoint that is gone answers none of them. Each role passes its endpoint URL as a thunk, since some resolve it only once a test is running.

The coverage lint requires the registration, and derives the role set from meta/mcp.yml rather than from a filename convention, which is how the tenth role surfaced: its spec is not named like the other nine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 00:40:15 +02:00
kevinveenbirkenbachandClaude Opus 5 ec736bd4d7 feat(mcp): offer no client a tool its provider calls mutating
Both agents registered every tool every provider served. Hermes documents that as its own default in tools/mcp_tool.py, "Neither set -> register all tools", and the role never set tools.include; OpenClaw's toolFilter was never rendered at all. A provider that grew a mutating tool would have reached both without anything declaring it.

A serving role now names its mutating tools, the discovery snapshot carries that set beside the allowlist, and every client renders the allowlist minus it in whatever form its upstream enforces. The subtraction sits at the client and not in discovery because Flowise compares the declared set against what its registry actually discovered and needs the full served list. Where a provider permits mutations there is nothing to withhold, so the set renders empty.

This is a client-side boundary, not a server-side one: whoever holds the endpoint key can still call a write tool directly. Baserow is the case that made it necessary, since its MCPEndpoint model carries no scope and its owner is a workspace administrator, so the declared mutating_tools_enabled bounds nothing there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 00:29:12 +02:00
kevinveenbirkenbachandClaude Opus 5 63a37dac45 fix(svc-ai-mcp-adapter): enforce the contract at runtime, not only at load
Four guarantees the contract made were never applied. concurrent_requests and stream_seconds appeared nowhere but the required-key list: ThreadingHTTPServer starts a thread per connection and asks no contract, so the ceiling only holds if the handler refuses the surplus itself, which it now does with 503 and gives the slot back on the failure path as well. The upstream was opened with urlopen, which follows redirects, and urllib copies the request headers onto the redirect target, so an upstream answering 302 with an off-host location would have handed the adapter's upstream credential to whoever it named.

input_schema was published to clients and never checked, which made it a suggestion: on the openapi path an argument the contract does not name is appended to the upstream request as a query parameter, so a client could add parameters the reviewed operation never granted, and a tool declaring no schema accepted anything. A tool that declares no inputs now takes none, a required input must be present, and the check runs after the size ceiling so a large payload is still refused for being large. No shipped contract regresses: the read tool every deploy probe calls declares no required argument, measured across all fourteen contracts.

sse.py adds a client for the classic HTTP+SSE transport, which splits the response and request channels across two URLs and needs a reader thread because the answer to a POST regularly arrives before the POST returns. It has no caller yet. The deploy probe keeps its own SSE implementation deliberately: it guards every enabled surface today and fails by exit code rather than by exception.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 00:26:11 +02:00
kevinveenbirkenbachandClaude Opus 5 ead858f481 feat(agents): let each sandboxed agent act as its own platform account
The platform reserves a user key per role, so hermes and openclaw existed as non-privileged accounts before either role claimed one. Claiming the key is what turns a reservation into an identity the agent may authenticate as, and gives the account its description and its mailbox. Neither role declares a password: provisioning stores one for every declared account, and a role whose registration rejects the shell-safe alphabet says so in its own meta/users.yml rather than carrying a second credential beside the account it already owns.

The lint derives the agent set from the sandbox-runtime scan rather than a list and holds three properties: the role claims its own reserved key, the claimed account carries no role because an agent employee is never an administrator, and no second credential sits beside it. Pointing an agent at an account that already exists needs no code, since the inventory users variable overrides the same key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 00:23:20 +02:00
kevinveenbirkenbachandClaude Opus 5 656eabf490 feat(svc-ai-litellm): bind every gateway consumer to the contract it declares
Four roles declared the litellm service and never included the gateway's consumer contract, which is the single place that refuses an empty virtual key and an AI base URL pointing outside the container network. They rendered their AI configuration regardless, so a missing key or a public endpoint reached a green deploy and every prompt left the deployment unauthenticated. Flowise, Hermes, OpenClaw and Open WebUI now include it, each gated on its own enable flag.

A lint derives the consumer set from the repository rather than a list: a litellm block without modes is a consumer, while the gateway and its admin UI declare the service they run and are exempt from the contract they own. The existing rule against addressing a model provider directly scanned only templates, so a role resolving the same variable in vars/ was invisible to it; it now scans both, and a negative control keeps the pattern from going quietly stale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 00:09:56 +02:00
kevinveenbirkenbachandClaude Opus 5 ce43263745 fix(container-dns): follow the live bridge, not a configured address
The swarm inventory carried the manager's docker bridge gateway as a literal under networks.internet.dns, so one field meant two things at once and the value went stale the moment default-address-pools moved the bridge. The daemon kept handing out the new address while the inventory kept naming the old one, and the DinD diagnostic probed that same literal, so it failed in step with the defect instead of catching it.

The inventory now declares only the clearnet resolver. The lookup hands containers the bridge whenever the host resolves solely through a loopback listener, which is how the simulated cluster nodes are wired and which docker strips from a container's resolv.conf. The diagnostic reads the gateway from the running daemon instead of being told it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 00:06:42 +02:00
kevinveenbirkenbachandClaude Opus 5 eed0879eaa Merge branch 'main' into feature/ai-agent-employees
Conflict: roles/web-app-bridgy-fed/files/Dockerfile

Both sides extended the header comment. main also replaced the upstream
checkout, `git clone --depth=1 --branch "${BRIDGY_REF}"`, with git init plus a
fetch of the single ref; this branch never touched those lines, so git took
main's version automatically and only the comment collided.

Resolved to main's file verbatim. It is a superset of ours in both respects:
it keeps our two comment paragraphs and adds a third naming the trip-wire the
non-idiomatic fetch exists for, namely that BRIDGY_REF is a commit and
`git clone --branch` refuses one. Verified byte-identical to stage 3.

Committed with the hook skipped and before the semantic audit of the other 45
files finished. main brings a lint rule that has never seen this branch's
roles, image bumps on svc-ai-ollama and web-app-nextcloud which this branch
rewrote, a stylelint major, and release 13.0.0 across the packaging surface.
Any of those can merge cleanly line by line and still be wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 13:32:15 +02:00