The contract check ran as an Ansible task in the middle of every provider deploy, and the two client checks ran the same way. A test that is part of the deploy cannot be run without deploying, cannot be re-run on its own, and reports its verdict as a deploy failure. Twenty-seven task files lose 491 lines to this.
It is now what it always was. Each provider ships files/test/test.sh, templates/test.env.j2 and meta/tests.yml, and test-e2e-cli stages one shared harness beside it: run.sh drives contract.sh in a throwaway container on the provider's own network, then isolation.sh for adapter-fronted providers. The read probe each provider offers moved from the calling task into meta/mcp.yml next to the allowlist it must satisfy, and the schema now rejects a read probe naming a tool outside that allowlist.
The bearer the test presents is the one a client resolves through mcp.credential, via the new mcp_client_bearer lookup over resolve_credential, which moved to utils.roles.applications.mcp so it is the same function mcp_servers renders every client connection with. The probe presented whatever the calling role passed, which for the fifteen adapter-fronted providers is a different secret than any client holds.
Sidecar isolation is read from the engine rather than measured inside the container, so it no longer needs a delegation to the node holding the task: attachment is compared by network name, which drops the gwbridge special case, and a swarm worker says it cannot measure instead of skipping silently.
Retrying is bounded by wall clock against MCP_DEADLINE_SECONDS, which scales through the same lookup('timeout') as the harness budget it has to stay under. A flag that is neither true nor false aborts rather than reading as off.
mcp-provider-probe becomes mcp-provider-cli-test: an enabled provider must ship the test rather than include the task. mcp-probe-bearer becomes mcp-adapter-bearer, tying ADAPTER_BEARER to the declared credential key now that no probe include carries it. A new integration test renders the shared env template for every provider through Ansible's own templar, so a later change to it breaks visibly instead of poisoning twenty deploys quietly.
Hermes and OpenClaw keep their client-side proof as CLI tests of their own.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
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.
The merged branch moved every role-local script out of files/ into a folder
named for its language (files/python/, files/php/, files/shell/), but the
moves lost their staging and never landed. HEAD still carried the old paths
while the worktree had the new ones.
Move the 24 remaining scripts and update the six places that resolve them:
the mcp-adapter instance/isolation/probe tasks, the litellm key task, the
adapter README and the endpoint-path lint. Every changed line in those
files is a path.
Also drop roles/sys-svc-compose/files/swarm/stack_ready.sh, a stale
duplicate of the copy already tracked under files/shell/swarm/.
The added comments are the branch's own `nocheck: mirrored-unit-test`
directives, which state why a script whose measurement only exists inside
its container cannot have a mirrored unit test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
834 commits from the Tor onion-service branch against 299 of ours, from merge
base 7b646bdece: 127 conflicted files, 3506 files changed.
The two sides are largely orthogonal. Theirs adds svc-net-tor and a `tor` service
across the role tree, plus two sweeping refactors: shipped scripts sorted by
language (files/ruby/, files/python/, files/shell/, scripts/lint/*.sh) and the
unit suite moved under tests/unit/python/. Ours is requirement 036, the native AI
gateway, plus the MCP role work. Most conflicts were both sides appending to the
same service map, resolved as unions.
Resolutions that were not unions:
Credentials moved namespace. Theirs renamed meta/schema.yml to meta/secrets.yml
and re-addressed credentials as secrets.credentials.<name>. Git carried our
litellm_api_key blocks through the rename, but every consumer we added still read
'credentials.<name>', which no longer resolves. All 45 call sites are migrated.
plugins/filter/container_volumes.py is deleted by theirs and relocated to
plugins/lookup/container_volumes.py, where it carries the same tmpfs defect our
f85996b43 had just fixed: a hard-coded {"size": ...} that silently drops a per
mount `mode`. The fix is re-applied in the relocated file, as that commit's merge
note anticipated.
compose.yml keeps only INFINITO_PLAYWRIGHT_REPORTS_BASE_DIR. Theirs also forwards
INFINITO_PLAYWRIGHT_STAGE_BASE_DIR; that line is deliberately dropped, per the
analysis recorded in 45804e9ad -- STAGE has a drift-proof role-vars fallback,
while forwarding it promotes a setdefault-pinnable .env value above that SPOT.
tasks/stages/01_constructor.yml takes their include, ./tasks/utils/setup/group.yml,
because their refactor removed tasks/groups/*-roles.yml, leaving our include
target nonexistent. Our task name is kept, since svc-virt is in the loop.
Makefile swarm-zombie is a union: their validate plus teardown, then our
lab_subnet cleanup, which their side dropped while leaving lab_subnet.sh as the
only orphaned reference in the file.
01_bootstrap.sh keeps our deterministic --ip pinning, which distinguishes an
already-attached node from a real failure, over their `|| true`.
Their language-sorted layout orphaned references our side still held: zammad's
two scripts/apply/*.rb, jenkins' java-with-ca-trust.sh, and the notify topics
their handlers renamed to hyphenated form. All corrected.
roles/sys-svc-compose/files/shell/swarm/stack_ready.sh gains our latched-update
block: paused, rollback_paused, rollback_started and rollback_completed exit 2
rather than polling a state that cannot resolve without another deploy.
config_from_run is unioned to (title, logged, jobs); the production caller in
trigger/__main__.py now passes jobs, without which our suite suppression would
have been inert.
READMEs are regenerated rather than hand-merged: 15 LiteLLM rows and 91 Tor rows
now derive from the merged metadata, which is itself evidence the meta merge is
coherent.
Verified: 5161 unit tests pass. The single failure, test_query.py TestSortSpec,
also fails on fork/feature/svc-net-tor at b51d2f9919, measured in a baseline
worktree -- so this merge introduces no new unit-test failure. Two modules are
excluded from that run because `baudolo`, a dependency their branch introduces,
is not installed on this host.
Not verified: no deploy has run against the merged tree, and make quality has not
been run end to end here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
gitea-mcp holds no credential and expects a token per request, so anything
on the provider's network reached it directly and skipped the adapter's
allowlist and limits. A private internal network fixes that, but the
isolation guard admitted exactly one subnet and rejected the adapter for
holding a second, reading a narrower attachment as a wider one.
Teach the guard the concept instead of working around it: a provider may
declare adapter.upstream_network, and the guard resolves that network and
adds its subnet to the expected set. Providers without one are unchanged.
web-app-gitea is the first consumer: the sidecar now joins the private net
and nothing else, bridge under compose and overlay under swarm, while gitea
and the adapter join it alongside their own attachments.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The probe compared the served tools against the allowlist and nothing else,
so a vendor growing the tool surface produced the same failure text as a
provider serving the wrong set. The August image bumps therefore read as
contract violations, which sent them to the wrong reader: the contract was
fine, the recorded surface had gone stale.
Compare against upstream_serves first and report what appeared and what
disappeared, then compare against the allowlist. Two failures, two audiences.
Both transports carried the identical comparison, so the check lived twice;
it is now one function.
A provider that records no differing surface, or records it as dynamic,
passes an empty list and is judged on policy alone. Without that, nextcloud's
runtime-composed surface would report drift on every deploy.
Resolving the value needs its own task: within one set_fact a variable is not
guaranteed to see another.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Counting interfaces held only while a sidecar reached its provider alone.
Once it also reaches an admitted client, a count cannot tell that second
attachment from an unrelated network someone joined it to. The probe now
compares each interface address against the declared subnets and rejects
both an address outside the set and a declared subnet with no address,
so an admitted client that cannot reach the sidecar fails loudly too.
In swarm the runtime attaches every task to docker_gwbridge for ingress
and egress. That subnet joins the expected set, because otherwise the
check rejects every sidecar there while proving nothing.
The probe was left behind when the mcp block moved out of services, so every
lookup it makes resolves against a path that no longer exists and the contract
check fails before it reaches a provider.
It survived the sweep because the check for dangling references filtered paths
containing mcp-adapter, meant to spare the genuine services.mcp-adapter service
entry, which excluded this role's whole directory by name.
A user definition that declares no password falls back to
{{ 42 | strong_password }}. That is a template, not a value: it is re-rendered
at every use site, so the task that creates an account and the task that later
authenticates as it read two different secrets. The account exists and the
login fails. Inventory creation now decides the value once, after the roles are
resolved and next to the role credentials that were already generated there.
The scope follows the deployment: only roles resolved into this inventory
contribute users, an existing password is never overwritten, and a second run
writes nothing.
Building it duplicated what the package already carried three to seven times
over: an ensure-map helper defined three times, the same fifteen-line
round-trip load repeated seven times, and the vault encrypt-and-reparse dance
twice. They now live in one module, the round-trip counterpart to the existing
read-only yaml_io. host_vars drops from 213 to 130 lines, and its two override
entry points share the merge they used to copy. Behaviour is unchanged, and
that is measured rather than asserted: the committed versions and the new ones
were driven against the same host_vars and produced byte-identical output for
all six writers, comment placement and vault block layout included.
The adapter probes move into their own folder because a shared file-name prefix
reads as a folder in disguise, and the lint that enforces it scans the tracked
file list, so the repository was failing on it. A test that was defined at
module level instead of inside its class, and therefore never ran, is indented
back in.
One audited implementation, instantiated once per provider application rather
than deployed as a single process holding every application's credential. Each
instance joins only its provider's network, carries only that provider's
least-privileged credential and serves a distinct endpoint.
Every decision lives in the policy layer, which validates the contract at
startup rather than at first call, so an instance missing a limit, an
allowlist or the schema hash never reaches a client. The server itself only
moves bytes over three JSON-RPC methods, with no stream state to get wrong.
Its upstream header is configurable because only half the providers accept an
RFC 6750 bearer.
The contract probe runs at deploy time rather than in Playwright, because it
needs the provider's real credential and a Playwright trace would capture it.
It proves, over both transports, that an unauthenticated caller is refused,
that a wrongly authenticated one is refused, that the handshake completes,
that the served tool list is exactly the contracted one, and that one
deterministic read answers without an error. Steps one and two are what
separate a guarded endpoint from one that merely happens to be unreachable.
The isolation probe counts the sidecar's network interfaces from inside it.
Reachability cannot be used here: an unknown name does not fail to resolve in
this environment, it falls through to a wildcard resolver that answers every
name with one reachable address.
The role is inert until a provider includes it; those inclusions follow.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>