waitForVisibleCandidate can report a failed document response instead of
a bare selector timeout, but it attached the listener itself, and its
first call comes after loginToStandaloneNextcloud's goto has already
consumed both the redirect and the response. Playwright delivers only
events that arrive after attachment, so the detector was structurally
blind to the one navigation it exists for.
Attach it before the goto. trackServerErrors is idempotent through its
WeakMap, so the later call is a no-op and the existing evaluation in the
wait loop is untouched.
A 500 login page now fails in ~130ms naming the endpoint, rather than
after 60s of waiting for selectors that cannot appear on an error page --
which is what pointed the recent OIDC investigation at the proxy.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
libcurl refuses a .onion host by name (RFC 7686, error 6) before it
consults /etc/hosts, extra_hosts or DNS, so the platform's DNS+iptables
onion egress cannot serve a server-side backchannel at all. Measured:
curl --noproxy '*' -> exit 6
curl --resolve auth.<onion>:80:127.0.0.1 … -> exit 6
--resolve fills curl's own cache strictly earlier than getaddrinfo and
is still refused, which makes sys-svc-container's onion_oidc_socks
template -- extra_hosts, despite its name -- a no-op for every libcurl
client. Only a configured proxy makes curl skip the check.
Since 9d91fbea8 emptied proxy.config.php.j2 there is no such proxy, while
svc-net-tor's exclusive/primary defaults replace Keycloak's clearnet
domain and KC_HOSTNAME pins the issuer to the onion. Nextcloud's
oidc_login discovery call therefore dies inside PHP, /apps/oidc_login/oidc
returns 500, and oidc_login_auto_redirect sends /login straight there.
Restoring the old socks5h://host.docker.internal:9050 alone would make it
worse: tor runs as a single manager-pinned replica publishing SOCKS with
mode: host, so only the replica on that node could connect and a
deterministic failure would become a 2-in-3 flake. In swarm, though, tor
is NOT host-networked -- that branch is compose-only -- and it already
binds SocksPort 0.0.0.0, so the shared overlay is a usable path from
every node.
Give svc-net-tor a shared_net overlay with alias 'tor' and dial it by
service name. Attachment is gated by a new consumer kind, onion_sso: the
consumer's services.sso.enabled AND the provider's services.tor.enabled.
Both are group-membership derived, so the conjunction is exactly "the
issuer is an onion" and stays false in clearnet deployments -- which
matters because the service registry is not filtered to deployed roles.
The existing kinds could not express it: keying on sso alone would also
hold without tor, and keying on tor would miss Nextcloud's variant 2,
which disables its own onion yet still needs the client path.
tor_socks_proxy keeps the swarm/compose split in one place so the six
roles sharing this defect need not restate it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
meta/volumes.yml already declares the force_ssl override as a bind for
both the mastodon and sidekiq services, read-only, gated on
`not tls.enabled`. compose.yml.j2 appended the identical source:target:ro
triple to _extra_volumes under MASTODON_HTTP_ONLY_ENABLED -- the same
predicate, spelled differently.
container_volumes concatenates extra_volumes onto the declarative set
without dedup, so both entries reached the rendered service. Compose
tolerates a repeated identical mount; Swarm rejects it with "duplicate
mount point", which is why this only ever failed in the swarm matrix.
Cut the manual append rather than the declaration: the declarative entry
covers both services, while the template block only emitted it for the
one it was rendering. MASTODON_CONTAINER_FORCE_SSL_OVERRIDE_PATH had no
other reader and goes with it. The neighbouring OIDC discovery append
stays -- meta/volumes.yml declares no OIDC entry, so the template is its
only source.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The wrapper calls the image entrypoint purely for its side effect --
install and migrate -- and then starts its own server, so it passed
/bin/true as the command to mean "do the init, start nothing". But
upstream gates that init on the command name:
if [[ "${1-}" == apache2* ]] || [ "${1-}" = 'php-fpm' ]; then
start
applyConfigEnv
fi
exec "$@"
/bin/true matches neither arm. The whole block was skipped and
exec /bin/true returned 0, so a complete no-op looked like a successful
init. No install ran, data/config.php was never written, and the failure
surfaced two steps later as the misleading "seed_config.php execution
failed".
Pass 'apache2 -v' instead: it matches apache2*, so start runs, and the
trailing exec "$@" only prints the version banner and exits 0 -- hence
the added >/dev/null, which keeps that banner out of the wrapper's
stdout.
Fail loudly when data/config.php is still missing afterwards, so a
regression of this shape is reported at its source, and surface the
throwable from $writer->save() with class, message, file and line
instead of a bare fatal.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The three svc-db-* credential tests each hand-rolled the same transport
gate in their test.env.j2:
(lookup('config', application_id, 'services.tor.enabled') | bool
and lookup('config', application_id, 'services.<entity>.exposed') | bool)
Three copies of one predicate, each naming its own entity key, and each
free to drift from the onion mapping that actually decides reachability.
Extract it into plugins/lookup/tor_reachable.py, split the same way as
its sibling tor_ports.py: a plain is_reachable() for callers holding the
merged applications view, plus a thin LookupModule resolving it through
lookup('applications'). Because collect_exposed_ports() scans
services.values(), the entity name disappears from the templates
entirely -- they collapse to
TOR_ENABLED={{ lookup('tor_reachable', application_id) | lower }}
Rework the integration guard accordingly: it now asserts that every
svc-db-* template delegates to the shared lookup, and drives its
base/variant agreement table through the real is_reachable() instead of
re-implementing the predicate. Add unit coverage for the new lookup.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The swarm DR stage aborted with "no backup unit installed on <mgr> or
<nfs-server>" on all five workspace distros. routine/backup/base.sh triggers
svc-bkp-volume-2-local / svc-bkp-secrets-2-local on the manager and
svc-bkp-nfs-2-local on the export host; all three trigger_units calls returned
rc=2 (no unit matches), so _triggered stayed 0.
The units were never installed because the roles were never in the closure.
utils/tests/swarm/README.md pins the contract: derive_includes pulls
svc-bkp-volume-2-local in via the `container_backup` consumer and
svc-bkp-nfs-2-local via `nfs_backup`. derive_includes._disabled_provider_roles
maps those same keys back to those roles and `found -= disabled_roles` prunes
them, so any caller that lists them in `disable` silently removes what the
drill then requires.
scripts/tests/environment/12_swarm.sh and 13_roundtrip.sh both do exactly that:
`variable_services <app> node nfs-server` keeps three keys and disables every
other group-conditional one, which sweeps up container_backup and nfs_backup as
collateral. Reproduced offline byte-identically to the CI line:
$ python3 -m cli.meta.roles.applications.dynamic_services \
--exclude mariadb node nfs-server
asset,...,container_backup,...,nfs_backup,...
Filtering in those two callers would leave `make swarm-zombie app=X disable=...`
and any future caller exposed, so the filter goes to the single entry point every
swarm drill passes through: the CI matrix reaches routine/00_one.sh via
distros.sh, and swarm-zombie reaches the same script through act. Both `disable`
consumers -- the provision-time services_disabler and derive_includes in the
matrix subprocess -- read the env var this script re-exports.
Measured before/after on the derive_includes closure:
APP_ID=svc-db-mariadb -> {} / {svc-bkp-secrets-2-local, svc-bkp-volume-2-local}
APP_ID=svc-storage-nfs-server-> {} / {svc-bkp-nfs-2-local}
node and nfs-server join the set for the same reason db87a1a1c added them to the
callers: they build the lab cluster itself.
Token matching is exact, so nfs_backup2 and mynode survive; `IFS=', ' read -a`
avoids the pathname expansion an unquoted split would have allowed. The CI swarm
workflow passes no `disable` at all, so the filter is a no-op there.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sys.modules is consulted before sys.meta_path, so everything site pulls
in from .pth files -- setuptools' _distutils_hack and 24 editable-install
finders on a dev host -- resolved straight out of the cache and never
reached the Blocker. An overlay with a smuggled 'import _distutils_hack'
passed the guard before this change and fails after it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
LAM is not served at APP_BASE_URL: / redirects to /lam/, whose body is a
bare meta refresh to templates/login.php, and /login, /admin/ and /admin
all 404 at the vhost root. The shared helper samples for a password field
on that interstitial and never reaches the form -- CI traces show no form
submission at all and LAM's access log records no POST.
Behind that, LAM binds the DN from its Admins list with
LDAP.BIND_CREDENTIAL while the helper types the users.administrator
secret of a different directory entry. That half is configurable via
ADMIN_NATIVE_PASSWORD; the entry point is not, so the opt-out stands and
the README records both.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The role overrides only the mariadb version, not its image. Mirror
discovery skips any service entry missing either key, so
mirror/docker.io/mariadb:11.4 was never pushed and the swarm deploy died
pulling it with 'manifest unknown' after ten retries.
Naming the image makes the ref discoverable; the nocheck keeps the
version bot off a pin it must not move on its own.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The role README arrived from main crediting the maintainer without a
link, while the branch added the lint that requires one credit form
everywhere. Neither side was wrong alone; only the merge produced the
violation.
Two declarations appeared to govern reachability and only one did. A
client reaches a provider when the provider declares an mcp_client
consumer in meta/networks.yml, or when the client already carries that
provider as a service for an unrelated reason. Admission alone attaches
nothing.
Measured rather than reasoned: a Hermes deploy fails at its own probe
against the Prometheus adapter because the two join disjoint networks,
and Open WebUI reaches Baserow only through an unrelated service
declaration.
A deploy log carries provisioning secrets in argv and environment dumps,
and reading one by line range puts them in the transcript verbatim. The
rule requires extracting the named field instead, states that a secret
denylist is not protection because it is case-sensitive and secrets also
ride inside URL paths, and records that no_log does not help locally
because MASK_CREDENTIALS_IN_LOGS is disabled there.
The requirement assumed Hermes would use its own OIDC support. The
deployed version serves a bearer API rather than a browser dashboard, so
that support does not apply and the agent sits behind the auth proxy
like the other two. The amendment keeps the original decision visible
and names both sides: the API server key alone used to be the whole
boundary, and a client presenting only a bearer no longer reaches the
agent through its public vhost.
The agent drives MCP clients, so holders of its application-scoped mcp
role must reach it, which completes the set of three client roles whose
gate now admits that group. The README follows the renamed assert task.
Three roles reach their MCP work from a manager-ops file rather than
from the role entry point, and the rename missed them, leaving them
including task files that no longer exist.
The agent drives MCP clients that reach providers with the credentials
the deployment issued, so an unauthenticated surface hands that reach to
anyone. sso was declared off behind a suppression; it is now group-gated
like every other role, admitting the administrator and mcp groups.
The API path and the health endpoint stay open, because clients
authenticate there with their own token and a gate would break both. A
Playwright spec drives the redirect through Keycloak and skips itself
when the service is off.
The store is written on the stack host, which keeps it single-writer,
but the users lookup that reads it runs on the controller. In compose
those are the same machine, so nothing showed. In swarm a provider wrote
its token to the stack host while a client resolved it on the controller
in the same run and found nothing.
The mirror runs under the same changed condition as the write, so an
unchanged store still touches nothing.
The provisioner fell back to the declared connections only while the
instance had none. Once it had one, the declared set was ignored
entirely, so a newly declared provider was dropped without a word and
the grant set never grew. Declared and existing entries are merged now,
and url, path, key and auth_type are refreshed on an entry already
present, so a provider that moves or rotates its secret is updated
rather than frozen.
The final check compared counts, which a swap would have passed. It
compares identities and names whatever the write lost.
The reconciler hard-coded "sse" while meta/mcp.yml declared the same
fact, so the declaration decided which providers may admit the role and
the constant decided which ones it accepts. They disagreed, and only a
runtime error settled it. The transport now arrives as an environment
value and its absence aborts, because without it every provider reads as
incompatible and the registry converges to empty without complaining.
Registration now carries its response into login, so an instance that
refused to create the account says so instead of failing later as a bare
"User Not Found". The mcp group joins the SSO gate's allowed groups.
The reconciler addresses a client with tasks_from: utils/mcp.yml, which
only works when every role offers its MCP work at the same path. The
files were named per role, and on the two agents the work sat inline in
the entry point, where nothing but a full role run could reach it.
The filter docstring pointed at the pre-rename path and now names the
file that exists.
Each of these guards a failure the work already hit. An adapter path
declared in two files drifts silently and surfaces as a 404 on the first
tool call. A provider borrowing the administrator token hands every
client that account's rights, and the read-back never matched the store,
so every deploy minted a fresh secret. A task that both carries a secret
and produces printed output puts it in the deploy log verbatim.
Provider probes, consumer transports, the schema and variant coverage
are checked the same way.
An mcp grant lets its holder drive clients that reach admitted providers
with the credentials the deployment issued, not the caller's own, so the
role is declared per application and never spans them.
The unit test pins that boundary directly: a scoped grant reaches only
the named application, an unscoped one reaches nothing, and an ordinary
role still spans everything. The Keycloak realm builder, the LDAP entry
builder and the Open WebUI reconciler all resolve membership through the
one module this covers.
The reconciler loaded a client role without telling it which application
it is, so the compose handler resolved a foreign instance directory at
flush time and the guard rejected it. The include now binds
application_id and domain the way the app loader does.
It also reconciled clients the inventory declares but the deployment
never carried, whose instance directory does not exist at all. Only
clients present on the stack host are reconciled now, which still covers
a client deployed by an earlier run.
Each provider builds its own sidecar from the shared Dockerfile and
supplies the base image itself, so renaming the build argument had to
reach all ten call sites. Without this the argument is undefined, the
FROM line resolves to a bare image with no digest, and no adapter builds.
The metadata named infinito/mcp-adapter:1.0.0 with an all-zero digest,
an image that does not exist, while the build actually used
python:3.13-slim. Declaration and artifact disagreed, so the declaration
proved nothing. Both now name the real base image and pin it by digest,
which a tag cannot guarantee across a rebuild.
The adapter base moves from the provider's service version to
mcp.adapter.digest, alongside the tool specification and its checksum.
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.
An inventory re-initialization mints a new virtual key while the proxy
database keeps the previous one under the same alias. Aliases are unique,
so creating the new key was refused and the constructor aborted after ten
identical retries. The alias is now released before the key is created,
and the release tolerates finding nothing.
The script moves out of the task body into files/ensure_keys.py, with the
port passed as an environment variable instead of templated into the
source, so it is plain Python and covered by unit tests.
Being an MCP client was enough to join a provider's overlay, so a single
admission anywhere reached every provider that opened one. Attachment now
requires both that the role speaks as a client and that the provider
lists it in mcp.allowed_consumers.
consumer.kind accepts a list so one overlay can carry both its
service-flag consumers and its MCP clients, which is what the vector
store needs.
Diagnosing a failing app meant reading its container logs, and the only
route to them was raw docker. The target mirrors compose-app-exec and
goes through the same container wrapper, so the log route is no wider
than the exec route already is.
A client rendered an endpoint for every provider the inventory knew,
including providers this run never deploys, so it carried connections
nothing was serving. Discovery now intersects with
application_closure(deployment.whitelist), the same set the service
loader preloads from. An unresolvable closure filters nothing, because
dropping every provider would look exactly like a deployment without
any.
credential_missing stays non-fatal in the app pass, where a provider may
still be waiting to write its secret, and becomes fatal at the
reconciliation stage, where every provider has already run.
Ten provider roles served an MCP surface that other roles consume, but
were never declared as shared services, so the registry did not know
them. Declaring them pulls in the recursion contract every registry
service owes: seven roles move their entry tasks into tasks/00_core.yml
behind the run_once flag, so the service loader cannot include them a
second time.
pretix already had a run_once block, but set the flag last; the flag now
comes first, before the body it protects.
The reconciler now runs in the server stage between the app pass and the
e2e pass, so every client sees the full provider set instead of only the
providers that happened to deploy before it. Handlers are flushed while
the clients are still bound, and the strict flag is released once the
reconciliation is over.
A structure lint pins both edges of that window so the include cannot
drift back into the app pass or past the e2e pass.
Every MCP role keeps its task tree under tasks/utils/ without numeric
prefixes, so a caller can reach any of them through include_role
tasks_from. utils/roles/mapping.py holds that path as the single source.
Ansible resolves a relative include against the including file's own
directory when that file was reached by path, but against tasks/ when it
was reached through tasks_from. The same file therefore loaded from its
own role and failed from a caller using tasks_from. Every nested include
now names {{ role_path }}/tasks/utils/..., which holds under both bases,
and a lint walks each tasks_from entrypoint transitively to keep it so.
The exec-gating lint learns the same form, otherwise it loses the include
chain and reports inherited IS_STACK_HOST gates as missing.
The expression resolving a provider's stored token appeared eighteen
times. plugins/lookup/mcp_credential.py replaces it, and mcp_probe_owner
disappears from eight roles: it was only ever a step towards the bearer
and no probe read it.
The Hermes and OpenClaw probes stop aborting the play while it is still
running. Roles deploy in dependency order, so a client probing during its
own role can run before a provider exists at all, and failing there
prevents that provider from ever deploying. The verdict moves to the
reconciliation pass, gated on MCP_RECONCILE_STRICT, which the reconciler
sets and group_vars declares so the readers need no default.
The sys-svc-mcp-reconcile role is added here; wiring it into a stage is a
separate change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
gino0631/nextcloud-metadata has no appstore release for server major
34, so `occ app:install metadata` reports the app as incompatible and
the role skips enable/configure — it was the only addon of ~50 to do
so. The spec asserts the opposite: OC.appswebroots must carry the
metadata key and the Files page must load apps/metadata/*, both true
only for an installed and enabled app.
`required` is the second half of the Playwright gate — addon_env_flags
emits <ADDON>_ADDON_ENABLED only when enabled AND required — so
flipping it lets skipUnlessAddonEnabled skip instead of asserting a
surface that cannot exist. `enabled` stays true: the install is still
attempted every run, and a compatible release only needs `required`
flipped back. Deploy behaviour is untouched; the abort condition reads
config.mandatory, not required. Same lever as integration_suitecrm.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The external app's admin UI became a Vue app in v9.0.1 — the only
release declaring min/max-version 34, and the one CI installs. Its
template is now just <div id="external-vue"></div>, so the two hard
assertions on #external and #add_external_site (v8.0.1 markup) could
only time out. Match the mount point and the NcButton label instead;
both stay sharp, since an unmounted #external-vue has zero height and
the button exists only once the component ran.
Drop #app-content from the shared shell candidates as well: NC34 emits
that id in apps/settings/templates/help.php alone, a route no spec
visits. Dead alternatives inside an OR-list are how the login breakage
stayed invisible — #header-start__appmenu sat there long after Vue
started replacing its mount node.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nextcloud 34 moved the app links into the waffle popover, so
`a[href*="/apps/files"]` is absent until the menu is opened, and
`#header-start__appmenu` is replaced by `nav.app-menu` on mount in
33 and 34 alike. Both post-login markers were therefore gone and
every spec died in `loginToStandaloneNextcloud`, although the login
itself succeeded (POST /login 303, GET /apps/dashboard/ 200).
Match on the header chrome and the dashboard root instead — present
in both versions, absent from the guest layout, so the post-logout
assertion in test-login-biber-ldap.js stays sharp.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The sandbox bind-mounts /dev/null over the deny-write entries in
.claude/, so .claude/loop.md is a character device (1,3) on the host.
Those nested mounts do not propagate into the debian lint container
that gets the repo bind-mounted at /opt/src/infinito, so
markdownlint-cli2 sees the dirent while globbing and then dies on
open(): ENOENT. That failed lint-markdown and with it the pre-push
gate.
Both .agents and .claude are gitignored in full -- git ls-files
lists only .claude/.gitignore and .claude/settings.json, neither of
them markdown -- so widening the two existing per-subdir ignores to
the directories drops no linted content.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 28cc9a6752)
Every lint-* target depends on install-lint, which bootstraps the toolchain on
a bare interpreter. Reading the pins through utils.cache.yaml pulled PyYAML
into that path, so the whole lint stage died with 'ModuleNotFoundError: No
module named yaml' before a single linter ran.
pinned.py now reads the file through utils.cache.files.read_text and parses
its two-level shape itself; that import chain is stdlib-only. One test blocks
PyYAML via a meta_path finder in a subprocess to reproduce the bootstrap
condition, a second cross-checks the hand parser against PyYAML so the two
readings cannot drift apart.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 286dd32f30)
mkstemp creates its file 0600 and Path.replace carries that mode into the target, so every freshly rendered Corefile was owner-only while coredns runs as nonroot. The container exits with 'open /Corefile: permission denied', name resolution dies for the whole stack, and deploys then fail at whatever they try to fetch first.
Long-running stacks hid it: coredns reads its config once at startup, so a container created before a re-render keeps working. Only new worktree stacks broke.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit efc281e327)
The sandbox bind-mounts /dev/null over the deny-write entries in
.claude/, so .claude/loop.md is a character device (1,3) on the host.
Those nested mounts do not propagate into the debian lint container
that gets the repo bind-mounted at /opt/src/infinito, so
markdownlint-cli2 sees the dirent while globbing and then dies on
open(): ENOENT. That failed lint-markdown and with it the pre-push
gate.
Both .agents and .claude are gitignored in full -- git ls-files
lists only .claude/.gitignore and .claude/settings.json, neither of
them markdown -- so widening the two existing per-subdir ignores to
the directories drops no linted content.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every lint-* target depends on install-lint, which bootstraps the toolchain on
a bare interpreter. Reading the pins through utils.cache.yaml pulled PyYAML
into that path, so the whole lint stage died with 'ModuleNotFoundError: No
module named yaml' before a single linter ran.
pinned.py now reads the file through utils.cache.files.read_text and parses
its two-level shape itself; that import chain is stdlib-only. One test blocks
PyYAML via a meta_path finder in a subprocess to reproduce the bootstrap
condition, a second cross-checks the hand parser against PyYAML so the two
readings cannot drift apart.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
mkstemp creates its file 0600 and Path.replace carries that mode into the target, so every freshly rendered Corefile was owner-only while coredns runs as nonroot. The container exits with 'open /Corefile: permission denied', name resolution dies for the whole stack, and deploys then fail at whatever they try to fetch first.
Long-running stacks hid it: coredns reads its config once at startup, so a container created before a re-render keeps working. Only new worktree stacks broke.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>