A client reaches the MCP endpoint by the provider's service name on the
container network, so the hop is plain HTTP. WordPress hands out an application
password only over SSL and, on an onion deployment, home_url() is
http://<onion>, so the gate stays shut: core never even attempts to validate the
credential and answers rest_not_logged_in. The client learns nothing about why.
Measured on a live deployment, same credential, same endpoint, the only
difference being the declaration of the scheme: 401 without it, 200 with it.
Deliberately wrong passwords produced the same rest_not_logged_in as correct
ones, which is what "never validated" looks like from outside.
The accommodation belongs on the provider's side, as 5e810c4a5a already settled
for Moodle: a probe that spoofs Host and X-Forwarded-Proto asserts the endpoint
works when spoofed and says nothing about how a real client reaches it. That is
exactly what happened here - the contract test sent the header and passed while
hermes and openclaw, which send only their Authorization, were refused in run
34699757369 on both compose and swarm.
WORDPRESS_INTERNAL_HOST is rendered from the same mcp.endpoint.service_key the
clients build their URL from, so both sides name one thing once. The filter
opens the gate for a request whose HTTP_HOST matches it, splits on ':' so a port
cannot defeat the match, and changes nothing when the variable is unset. Its
existing branches are untouched.
contract.py loses the X-Forwarded-Proto it used to send. The test now takes the
route a client takes, and can no longer be green while every client fails.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The mu-plugin header describes the split as 'the adapter authorises the transport, the callback authorises the operation'. The second half held, every ability carries infinito_mcp_may_read. The first did not: the adapter answered the initialize handshake to anyone, so the server and its protocol version were readable without a credential.
Measured in run 34653754175, the swarm WordPress#0: test-mcp-guest.js posts an anonymous initialize and expects at least 400, and the error context reads 'Expected: >= 400, Received: 200'. Its neighbour addons/mcp-adapter.spec.js posts an anonymous tools/list and passed, so the tool call was already refused and only the handshake was not.
The filter hangs on rest_pre_dispatch and refuses every route under the mcp namespace while the site cannot attribute the request. It leaves an already short-circuited response alone rather than overwriting it, scopes itself by route prefix so the rest of the REST API stays as open as it was, and reaches is_user_logged_in, which triggers determine_current_user and with it wp_validate_application_password, so a client presenting its application password passes. A WP_Error renders no jsonrpc key, which is the second thing the spec asserts.
The new unit test reads the guarded prefix out of the PHP and the endpoint out of the spec and requires the endpoint to sit under the prefix. Without it an adapter that moves namespace would leave the guard protecting nothing while both negative specs keep passing.
A positive spec was tried and reverted: handing the MCP credential to the Playwright env is forbidden by tests/lint/ansible/services/test_mcp_secret_not_in_playwright.py, because the trace travels in the CI artifact. No role probes its MCP endpoint with the credential at deploy time either, so the authenticated direction stays unproven.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The CLI contract test rejected WordPress in job 102705652296 of run
34421944328:
REJECTED server serves ['mcp-adapter-discover-abilities', 'mcp-adapter-execute-ability', 'mcp-adapter-get-ability-info'] but the contract declares ['infinito-get-post', 'infinito-list-categories', 'infinito-search-posts']
There are two layers. The mcp-adapter v0.5.0 default server lists only its
three meta-tools and reaches public abilities through discover and execute; it
takes its tool list from the mcp_adapter_default_server_config filter
(includes/Servers/DefaultServerFactory.php). And the three infinito abilities
were never registered at all: the mu-plugin hooked abilities_api_init and
passed no category, while WordPress 7.1.0 core fires only
wp_abilities_api_init, refuses wp_register_ability() outside it and requires a
category (src/wp-includes/abilities-api.php:290-297,
abilities-api/class-wp-ability.php:295-297).
The mu-plugin now registers an `infinito` category on
wp_abilities_api_categories_init, registers the abilities on
wp_abilities_api_init with that category, and sets the default server's tools
to exactly the three abilities, with empty resources and prompts, at
PHP_INT_MAX. With the meta-tools gone, execute-ability no longer reaches every
public ability any other plugin registers.
The abilities return objects ({posts}, {categories}, {post}), since MCP defines
structuredContent as a JSON object. get-post now serves published posts only,
as search-posts already did; before, it returned any published type, including
customize_changeset and wp_block entries.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Same shape as 31fa1e5540 for the Zammad apply scripts.
A mu-plugin is included by the WordPress loader, not imported: it exports
nothing, and defined( 'ABSPATH' ) || exit ends it outside a booted site. Its
content is three wp_register_ability calls behind the abilities_api_init hook,
whose callbacks stand on get_posts and current_user_can. A unit test would
mock all of it and prove nothing.
The suppression is structurally honest but leaves the three abilities, the
permission check among them, without automated coverage. A probe against the
running instance would be the real substitute and does not exist yet.
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>
mcp_servers hands every client the bare URL http://<service_key>:<port><path>,
which resolves only when the client shares a network with the provider. The
overlay declared modes: [swarm] with neither a topology nor a consumer kind, so
under compose _compute_attachments produced no attachment at all.
Nothing failed, which is why this survived deploys. The docker daemon ships
dns-search: [DOMAIN_PRIMARY], so the unresolvable service name was completed
into a public FQDN, the wildcard record answered with the ingress address, and
every MCP tool call for the Apache container went out over the internet and back
in through the reverse proxy. The calls succeeded; only the route was wrong.
The overlay now runs in both modes, creates shared_net, and admits mcp_client so
each role named in mcp.allowed_consumers is attached. aliases becomes
proxy_aliases, because the public-domain alias belongs on the proxy leg rather
than on the shared network.
auth changes from app_password to basic_auth: WordPress application passwords are
presented over HTTP Basic, and the adapter needs the transport, not the
credential's product name. web-app-openwebui leaves allowed_consumers.
The mu-plugin filters wp_is_application_passwords_available. Core gates them on
is_ssl(), which is false on the internal hop because TLS terminates at the proxy
and no X-Forwarded-Proto reaches wp-config.php, so the service account could not
authenticate. The filter reads the site's own canonical scheme, leaving the gate
closed on a genuinely plain-HTTP site.
The new lint fails any role declaring mcp.endpoint whose overlay has no topology
or does not admit mcp_client, with a nocheck opt-out in the networks file head.
This role is what motivated it.
Measured while writing this: from a throwaway container sharing a provider's
network, `nslookup <service>` returned <service>.infinito.example -> the ingress
address, while the service answered 401 immediately from inside its own
container. That is this failure mode observed live.
The same measurement bounds the claim. In that case the client already shared
the network AND the container alias was registered, and the search suffix still
won, so attachment may be necessary without being sufficient. The WordPress
endpoint has not been re-probed since this change, and the lint encodes
co-residency as the whole condition, which that observation puts in doubt.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A role-files-layout exemption frees a tree from files/<lang>/ because
its import paths are fixed from outside. The mirrored-test lint derived
its scope from that same directory, so every layout exemption silently
became a test exemption nobody ever argued for.
The language now comes from the file suffix. Test code is excluded by
scope rather than exempted: a test-* role and a files/playwright/ or
files/test/ tree are the tests, so demanding a test of them is circular.
The eleven files this newly reaches are framework-instantiated and carry
a stated reason each.
Both plugins ended their candidate chain with a hardcoded
/etc/ssl/certs/ca-certificates.crt. Its own docstring named the condition
that would make it useful — the OS bundle carries the Infinito CA only
once update-ca-certificates has run inside the container — and that
condition never occurs alone: with-ca-trust.sh installs the anchor and
exports CURL_CA_BUNDLE/CA_TRUST_CERT/SSL_CERT_FILE together, and
compose_ca.py sets the same three on every service it covers.
Where the CA override does not apply at all, which is the shipped default
since TLS_MODE is letsencrypt, the OS store holds no internal CA either,
so the fallback could not rescue an internal endpoint. For Joomla the
outcome is byte-identical: with no CURLOPT_CAINFO set, Debian libcurl
compiles in that very path. In swarm the inject runs --no-wrapper, so
update-ca-certificates never runs and the removed line pointed at a file
that provably lacks the CA.
WordPress now verifies against its own bundled Mozilla roots instead of
the image OS store when no variable is set; nothing in the repository
writes into that store for these two images.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every script a role ships now lives under `files/<language>/`, and the unit
suite is split the same way: `tests/unit/python/` beside new sibling trees for
the other languages. `tests/lint/filesystem/test_role_files_layout.py` keeps it
that way; directories whose shape is dictated from outside carry a `.nocheck`
naming the constraint (Docker build contexts, WordPress mu-plugins, the Joomla
extension, Odoo addons, Playwright suites, the `files/test/test.sh` harness
that `test-e2e-cli` resolves generically).
This cannot be applied in halves. Moving a file and repointing what names it
are the same change: 14 references resolve only at deploy time, so the tree is
green either before or after, never between.
Those 14 were found by static resolution over every reference class, not by the
test suite -- a full `make quality-high` was green while all of them were
broken:
* 7 `src:` values built from a role variable holding a bare filename
(svc-bkp-remote-2-local, decidim, espocrm, friendica, joomla, pixelfed x2).
The variable doubles as the destination name inside the container, so the
language segment belongs at the call site, not in the variable.
* `sys-service/tasks/04_base.yml` resolved every consumer's systemd script
through `first_found(..., errors='strict')` over `files/script.{sh,py}`.
18 roles ship exactly that and have no template fallback: the play would
have died on each.
* `sys-front-inj-javascript` fell back to `<role>/files/javascript.js`; the
interface now reads `files/javascript/javascript.js`.
* `sys-front-inj-logout/templates/head_sub.j2` built the cache-busting mtime
path through `path_absolute(... 'files', ...)`.
* 4 `ansible.builtin.script:` invocations naming a bare command
(pull.py, prune.sh, set_postgres_superuser_password.sh, ganesha-serve.sh).
* `dev-python/files/shell/install.sh` pointed at a sibling role's script.
Depth-dependent paths inside the moved trees were bumped accordingly: 59
`parents[N]` computations, 12 relative climbs, and the per-role recover CLI,
whose path `cli/administration/recover/recoverers.py` still assembled from the
pre-move location.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
3529 invalid comments in 528 files, grandfathered in while the linter
only saw the working-tree diff. Removal ran through the linter's own
collectors, so detection and deletion cannot drift: a comment whose
opener line is blank before it and whose closer line is blank after it
loses all its lines, anything else loses only the comment region, and a
line left holding a bare delimiter goes with it.
Five files keep their comments behind a nocheck marker instead. Four are
upstream configs whose inline docs are upstream's own (mautrix, systemd
journald, nextcloud-oidc-login, MediaWiki). In littlejs the
commented-out entries ARE the deactivation mechanism for an example
tile, so deleting them would delete configuration.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
daggerhart-openid-connect-generic calls wp_remote_post() against
http://auth.<onion>/.../token, which bottoms out in libcurl -- and libcurl
refuses a .onion host at name resolution (RFC 7686) before /etc/hosts,
extra_hosts or DNS is consulted. The result is cURL error 6, which WordPress
turns into WP_Error('http_request_failed') and a bounce to
/wp-login.php?login-error=http_request_failed. Five specs of WordPress 0 in run
31340965955 failed on that single cause, 60 occurrences in the log.
Only a configured proxy makes curl skip the check, and socks5h:// hands the name
to Tor. The mu-plugin hooks http_api_curl because http_request_args cannot
express a proxy and WP core's WP_HTTP_Proxy hardcodes CURLPROXY_HTTP, so SOCKS5
is reachable only on the raw handle. env.j2 sets WORDPRESS_OIDC_SOCKS_PROXY only
when the issuer is an onion, so a clearnet deployment never routes through Tor.
Deliberately not adding the onion_oidc_socks include: 238b8b41f established it
is a no-op for this client, since libcurl never reads /etc/hosts here.
Depends on 1bb31fba4 -- without it swarm renders socks5h://host.docker.internal,
which is unreachable from inside the overlay.
The plugin's behaviour is unexercised: syntax checked, never run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
WordPress serves MCP through two addons rather than a sidecar, because the
application already carries the extension point.
The mu-plugin is the entire tool surface. The MCP Adapter publishes an ability
only when it carries meta.mcp.public, so whatever is not registered in that file
cannot be reached over MCP no matter what else the installation has. Every
ability reads already-published content and carries its own permission
callback, which is not decoration: the adapter authorizes the transport, the
callback authorizes the operation, and dropping it would let any authenticated
caller reach the ability.
The second addon fetches the release asset rather than the tag's source
archive. The source archive unpacks as mcp-adapter-<version>, which no longer
answers to the slug the generic activation step uses, and it ships no vendor
directory.
Three Playwright specs cover what needs no credential: both addons active, the
abilities registered, and a guest served nothing. The mcp role joins the role
catalogue instead of replacing it.
The deploy died in the drill's second pass on `wp plugin install activitypub
--activate`: the first pass had installed it, the DR restore put the tree back,
and wp-cli refuses to unpack onto an occupied directory. `--force` would have
made that green by overwriting whatever the restore produced - on a branch about
NFS-backed volumes, the worst possible place to paper over a restore. The probe
keeps it red instead: `wp plugin is-installed` answers from `get_plugins()`
alone (verified in the pinned wp-cli phar: `Fetchers\Plugin::get()` iterates
`get_plugins()` and nothing else), so a tree WordPress cannot parse still
reaches the install and still fails on folder_exists.
Activation moves into its own step. Chained to `install --activate`, wp-cli sets
chained_command and skips the result check, so an activation failure returns 0
today.
The same install path now serves both call sites; the addon hook carried its own
copy, which is why one defect needed fixing twice.
mu-plugins follow the declaration the way discourse already does. The loop reads
`mechanism: mu_plugin` from meta/addons/ instead of spelling two filenames, which
is how `infinito-http-ca-trust` shipped undeclared while `infinito-oidc-rbac-mapper`
carried a `required: true` that nothing evaluated. Both install paths honour that
field now - a required addon aborts the play, an optional one warns and is
skipped - which is the first place in the repo to read it.
Three guards, because the loop replaced two guaranteed literals: an assert that
every required addon is claimed by some install path, a lint pairing each
vendored addon with its shipped file in both directions, and the spec the addon
contract demands.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Add tests/lint/repository/test_task_file_numbering.py: numbered task
files use exactly two leading digits, a numbered *_core.yml must be
00_core.yml, per directory the numbers are unique/consecutive and start
at 01 (00 with a core file), numbered includes appear in ascending
order, and shared helpers live in tasks/utils/ instead of the numbered
chain.
Apply it across every role: rename NN_core.yml to 00_core.yml with a
consecutive down-shift of the siblings, move letter-suffix children into
a subdirectory named after their parent task, relocate helpers included
from more than one place into tasks/utils/, and update every reference
(include_tasks, path_absolute, .ansible-lint-ignore, requirement docs).
Legacy narration comments in the touched files are deleted; the upstream
xwiki.properties reference block opts out via nocheck. The unused
MAILU_IP6_PUBLIC var (only ever named in now-removed comments) is dropped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move files that share a delimiter-bounded name prefix within a folder (threshold 2) into a subfolder named after the prefix, dropping the prefix from the filename. Covers plugins/filter (docker, generate, has, merge, get/all), env handler subpackages, utils/roles/entity, utils/tests/swarm/write, role SQL/scripts/templates, keycloak/mailu task-includes, shared tasks/utils/load, and CI scripts. Update every import, query_file/script/include_tasks/src/lookup target, compose bind mount, and python -m path to the new locations.
Extend the prefix-grouped-files lint to threshold 2 with dir-level .nocheck and file-head nocheck opt-outs, and skip groups whose shortest member's stem equals the shared prefix. Keep flat only where a loader forbids nesting: WordPress mu-plugins and the docs/requirements namespace. Ansible filter discovery is recursive, so nested filters stay Jinja-resolvable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The earlier mu-plugin pointed http_request_args['sslcertificates'] at the OS bundle (/etc/ssl/certs/ca-certificates.crt), which does NOT carry the internal root CA unless update-ca-certificates ran in the container — so the OIDC token exchange to the self-signed internal Keycloak still failed with 'cURL error 60: SSL certificate problem' in CI run 28044348006. Read the env-provided bundle first (CURL_CA_BUNDLE / CA_TRUST_CERT / SSL_CERT_FILE), which sys-svc-compose-ca injects into every container pointing at the bind-mounted /tmp/infinito/ca/root-ca.crt (compose_ca.py), falling back to the OS bundle.
Verified chain: daggerhart-openid-connect-generic issues its token/userinfo requests via wp_remote_post/wp_remote_get (the WP HTTP API), so the http_request_args filter applies and WP's curl uses CURLOPT_CAINFO = the internal root CA, which verifies the Keycloak cert.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The OIDC login redirected to Keycloak and back with a valid code, but the server-side token exchange (daggerhart-openid-connect-generic wp_remote_post to the self-signed internal Keycloak) failed with 'cURL error 60: SSL certificate problem: unable to get local issuer certificate' (60x in CI run 28018750880), so the user was never logged in and never redirected to /wp-admin. WordPress's HTTP API (WpOrg\Requests) pins CURLOPT_CAINFO to its own bundled wp-includes/certificates/ca-bundle.crt and ignores the OS trust store and the CURL_CA_BUNDLE/SSL_CERT_FILE env vars, so the earlier ca-certificates Dockerfile change had no effect.
Add a mu-plugin that filters http_request_args to set sslcertificates to the OS CA bundle (/etc/ssl/certs/ca-certificates.crt), which the compose CA-injection populates with the internal root CA, so wp_remote_* TLS verifies the internal cert. Not a green-wash: no assertion weakened, no_sslverify left false (verification stays ON), the test still requires a real OIDC round-trip to /wp-admin.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements requirements 005, 006, and 007.
005: per-application Keycloak `group-ldap-mapper`s anchor each app's
role groups under `/roles/<application_id>/<role_name>` (and, for
tenant-aware apps, `/roles/<application_id>/<tenant>/<role_name>`).
The flat-CN LDAP layout keeps each CN globally unique so Keycloak's
group-tree resolver never collides; per-app mappers use the LDAP
`description` as the visible Keycloak group name to keep the path
free of redundant prefixes. Migration purges legacy pre-005 entries
and forces a Keycloak `logout-all` so stale tokens drop pre-005
group paths.
006: per-service `<SERVICE>_SERVICE_ENABLED` env flags and the
`service-gating.js` helper let role specs `skipUnlessServiceEnabled`
cleanly when a shared service is missing or `SERVICES_DISABLED` is
set; `MODE_CI` markers (`INFINITO_MAKE_DEPLOY`, `GITHUB_ACTIONS`,
`ACT`) propagate through the deploy CLI into the test stage.
007: WordPress->Discourse round-trip Playwright scenario gated on the
`web-app-discourse` deployment.
WordPress Playwright runs 5/5 green on the single-site flow; the
multisite + Discourse scenarios are correctly skipped when their
service prerequisites are absent. The `rbac_group_path` lookup is the
single sanctioned producer for OIDC group paths and is wired into
every consumer (yourls, lam, minio, openwebui, pgadmin, phpldapadmin,
phpmyadmin, prometheus, xwiki).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Document the WordPress must-use plugins convention, the criteria for
adding code here (security/integration contract, must-always-load), and
the deploy path via 05_mu_plugins.yml, so future contributors understand
why this directory exists and what belongs in it.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Auto-provision one OpenLDAP group per role declared under each application's
`rbac.roles` in `config/main.yml`, gated on `group_names` so only apps
deployed on the host contribute. Keycloak syncs the groups from LDAP, emits
the `groups` claim via the shared RBAC client scope, and WordPress maps that
claim to a local role in a mu-plugin (auto-loaded, cannot be deactivated
from the admin UI). No direct LDAP bind from WordPress.
Non-obvious design choice: the `groups` client scope MUST be a *default*
client scope, not optional. When optional + `include.in.token.scope=false`,
Keycloak 26 drops the group-membership mapper from userinfo even when the
scope is explicitly requested, so the mu-plugin receives no `groups` and
falls back to `subscriber` for every user. ID/access tokens still carry
the claim, but WP consumes userinfo.
Playwright verifies the three-role cycle (subscriber/editor/administrator)
serially and idempotently per requirement 004: the add step runs via the
Keycloak admin UI (detects "already a member" via a disabled picker
checkbox and skips); teardown runs via the Keycloak Admin REST API. Only
memberships the test itself added are removed, so any pre-existing
membership is preserved. Each hop (super-admin, biber, WP-admin) runs in
its own browser context so WP session cookies and Keycloak SSO cookies
cannot leak.
Resolves: docs/requirements/004-generic-rbac-ldap-auto-provisioning.md
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>