feat(shared): public capability document (GET /capabilities) - #1284
ren-jentic wants to merge 7 commits into
Conversation
9b50a61 to
6ea7f85
Compare
Review lap 1/7 — public attack surface & document semanticsStarting a multi-lap adversarial review across the epic #1280 set (#1281, #1283, #1284) plus #1286. Each lap runs several independent lenses; findings are cumulative and I won't re-report across laps. This PR was reviewed in a fully-wired worktree at Verdict: BLOCK. 1 blocker, 4 majors. The blocker is a live authentication bypass, so it goes first. BLOCKER — merging this reverts main's
|
| this branch | origin/main |
|
|---|---|---|
| gate | if not ctx.config.auth.local_login.enabled: (local_login.py:94) |
if not ...local_login.enabled or ctx.config.auth.idp.enabled: (:119) |
| token verified | purpose="state" (:249) |
purpose="login" (:281) |
purpose="state" is the same token /authorize hands the browser inside the IdP redirect URL. So with auth.idp.enabled=true and auth.local_login.enabled=true, replaying that state into GET /login reaches the first-party password form. Executed:
GET /authorize -> 302 Location host: accounts.google.com /o/oauth2/v2/auth
REPLAY GET /login?ls=<that same state> -> 200
password field present: True
main returns 404 for the same request. Main's identity-gate ladder (resolve_identity_gate) and its fatal-missing-iat check are also reverted by this branch's older flow.py.
This also falsifies the PR body's own claim that the document publishes the "effective" offer because "the /authorize flow only reaches the form when no IdP is configured, IdP always wins" — on this branch it does not, so auth.methods.local_login would advertise a method whose reachability is the bug.
Compounding it: tests/arch/test_web_layer.py:204-211 adds an exemption whose comment justifies the route as "gated by config (auth.local_login.enabled → 404), the signed authorize state, a single-use CSRF nonce" — that is a description of the weaker gate, and uv run pytest tests/arch is 298 passed on the vulnerable code. The arch suite offers no protection here.
Fix: rebase onto origin/main and drop 33a8e386, a3b30b34 and c829e6e6 entirely; keep only fc24cfda, 12704a68 and 6ea7f850. Do not hand-merge — main's local_login.py and flow.py are strictly stronger. Then restate the arch exemption against main's two-sided gate.
That reduces this PR to its actual contribution, which is much smaller than the 48 files / +5,385 GitHub shows, and makes the rest of it reviewable on its merits.
urls.broker is gated on a self-declared hint that defaults permissive
capabilities.py:227-228 gates the server.mcp.broker_url fallback on server.backend == "local", justified as "the single-box topology where that internal URL is, by construction, the same origin the client already reached". Two problems:
server.backenddefaults to"local", and/instanceitself documents it as "a hint, not an authorization signal" (instance_identity.py:53). So the guard is keyed on the one field an operator is least likely to have set correctly.- "By construction the same origin" is false. Measured with the default config and a public canonical URL:
canonical_base_url = https://jentic.example.com | backend = local
published urls.broker = 'http://127.0.0.1:8100'
Any operator who deploys split but forgets backend: remote publishes the internal hop on an unauthenticated endpoint.
Also measured on the sanitisation path: userinfo is stripped correctly, but a credential in a query string survives — advertised_broker_url = https://pub.example.com/broker?token=s3cret#frag is published verbatim — and file:///etc/passwd and schemeless broker.internal:8100 are published unvalidated.
Fix: derive the gate from something structural rather than the hint — publish the fallback only when mcp.broker_url's host matches canonical_base_url's host (or both are loopback), else null. Validate advertised_broker_url as http(s) and drop query/fragment in _advertised_broker_url.
On a split deployment the document affirmatively lies about sibling surfaces
enabled_apps is this process's surfaces, not the deployment's — app_factory.py:402-404 says so ("for a standalone surface it is that one surface's name") and control/web/app.py:66 passes {"control"}. But the field docs describe deployment scope: agent_dcr is "available whenever the auth surface is mounted", authorization_server_metadata is "null when the auth surface is not mounted on this deployment". Executed against one config across three surfaces:
surface ['control'] -> surfaces=['control'] asm=None agent_dcr=False service_accounts=False
surface ['auth'] -> surfaces=['auth'] asm='/.well-known/...' agent_dcr=True service_accounts=True
A client hitting the control surface of a split deployment is told agent DCR and service accounts do not exist. For a login picker, false is worse than absent — it hides working methods rather than falling back to a probe, which is precisely the failure mode this document exists to prevent.
Fix: rename to surfaces_on_this_process, or make these bool | None with None = "unknown from this surface", and scope the docstrings to the process.
The contributor seam has no isolation on an unauthenticated route
capabilities.py:59-61 states the endpoint "is public and must stay cheap and DB-free", but resolve_capabilities calls contributors inline with no guard. Measured, one registered contributor each time:
contributor raises -> RuntimeError propagates (500 on a public route)
returns None -> AttributeError
non-serialisable value -> PydanticSerializationError
non-str key -> ValidationError
sync-blocking (1.2s sleep) -> 1.20s wall on the public route (blocks the event loop)
case variant 'MCP' -> features={'mcp': False, 'MCP': 'PWNED'}
The collision guard is exact-key only, so MCP shadows the built-in mcp for any case-insensitive client — which weakens the "a downstream package can never rewrite OSS semantics" claim. And because the handler is async def and calls contributors inline, a sync contributor blocks the whole event loop: an unauthenticated DoS reachable through a supported extension point.
Fix: per-contributor try/except Exception (log and skip, never fail the document); reject non-str keys and case-insensitive collisions; probe each value with model_dump_json or drop it; and either offload via run_in_threadpool or reject blocking contributors at registration.
Verified clean — worth recording
The security posture of the new code is otherwise good, and several things I went looking for are genuinely not there:
- No version leak, direct or by shape.
__version__is absent from the body; there is no build, commit or dependency field;capabilities_versionis a hand-bumped shape integer, not a build id;/system/versionstays authenticated. - No topology or tenancy leak beyond
urls.broker— nothing about tenants, whether an admin exists, whether setup is complete, internal hostnames, compose service names or licensed features. What/capabilitiesemits about identity is a strict subset of/instanceminusinstance_idandhost: it publishes less than an endpoint that already exists. - The broker opt-out is real (
broker routes with 'capab': []), standalone surfaces do mount it, andbackend: "LOCAL"is rejected by validation — so a casing typo cannot fall into the permissive branch. A remote backend never leaks the hop whenadvertisedis unset, and the advertised key works on any backend. - All six generated artifacts byte-match what the generators produce (generated into
/tmp; no writingmaketarget was run):control.openapi.yaml,ui/openapi.json,config/config-schema.json,endpoints.md,endpoints.json, plus a clean Go build with the regenerated config struct. - The route exemption is narrow — per-
operation_idinPUBLIC_OPERATION_IDS, andassert_classification_is_soundfails if any route renders public without being declared.getCapabilitiesclassifies aspublic=True, authenticated=False, required_scopes=[], no BearerAuth,Discoverytag. The DCR 404-when-disabled door is untouched by this PR. - The golden pin is a real pin — it asserts full
resp.json()equality, so any field's semantic change fails it. 25 unit tests + 298 arch tests pass; mypy and ruff clean on all five new/changed modules.
One process note: gh reports no prior review comments, reviews or inline comments on this PR, so nothing above was suppressed as already-resolved. Also worth flagging for all four PRs in this set — every green check ran against the shared base 2333432a, not against a merge with today's main, so green does not currently mean mergeable or still-green. Lap 6 will materialise the merges and re-run the gates.
Lap 1 of 7 · lens: public attack surface & document semantics · head 6ea7f850 · reviewing #1281, #1283, #1284, #1286 as a set
Review lap 2/7 — merge topology · contract irreversibility · gate adversary · authzFour independent lenses this lap. Findings are cumulative and I don't re-raise across laps. Verdict: BLOCK (unchanged). Lap 2 adds 1 blocker and 6 majors, and — more importantly — corrects the unblocking advice I gave in lap 1. Correction to lap 1: this cannot be rebased. It has to be recut.I told you to "rebase onto All six are I was also wrong that And the revert is wider than the
The branch sits at base-level approval logic while main has ~3.4× more, so a branch-side resolution also reverts #1264's approval-in-flow work. Revised fix: don't rebase. Cut a fresh branch from Worth stating plainly: the epic's premise that #1279 "can land any time" is falsified. Because this branch predates #1276's merge, it is the least mergeable of the four, not the most. BLOCKER — the arch gate this PR leans on is a text scan that a comment defeatsLap 1 noted the exemption text was stale (A-12). The gate underneath is weaker than that suggests. has_auth = any(indicator in source for indicator in auth_indicators)That is a substring scan over raw file text, and the indicator tuple includes the bare word Fix: classify from the app, not the text — assert over The gate never reaches
|
Review lap 3/7 — per-request log amplification · missing boot warnings · cachingThree lenses this lap. Findings are cumulative; I don't re-raise across laps. Verdict: BLOCK (unchanged — lap 1's revert blocker and lap 2's unenforced-version blocker stand). Lap 3 adds one major and one minor, both on the operability of the new public document. The only new log line is a static condition re-emitted on every unauthenticated requestThe collision warning is a property of the import-time contributor registry, but it's evaluated inside Probe with 5 unauthenticated Two problems. It's an unauthenticated remote log-volume amplifier — anyone who can reach the endpoint can drive warning volume in a deployment with a colliding contributor. And it makes the warning worthless as a signal: a condition that fires on every request is noise precisely when you need to read it. Meanwhile the events that should be logged aren't:
The repo already has the precedent for exactly this class of boot warning — Fix: validate contributors once in A deterministic, unauthenticated, every-client-at-startup document ships with no caching headers (minor)Per-request compute is emphatically not the problem — I measured It's also inconsistent within this very epic: #1283 ships Fix: return a Verified clean this lap
Lap 3 of 7 · lenses: observability & day-2 · concurrency & lifecycle · head |
Review lap 4/7 — compound risk · mutation adversaryTwo lenses this lap: compound-risk analysis (do individually-accepted decisions combine into something worse?) and per-claim mutation testing. Findings are cumulative; I don't re-raise across laps. Verdict: BLOCK. One new blocker, from combining three findings that were each merely "major" alone. Balanced against that, this PR has the best-tested surface in the entire stack — details at the end, and they're genuinely earned. BLOCKER — the contributor seam lets unauthenticated traffic disable the SSRF controlThree register items combine here: the contributor seam has no isolation on a public route (lap 1), the seam receives the whole app First, registration validates nothing: capabilities.py:73-81
def register_capability_contributor(contributor: CapabilityContributor) -> None:
_capability_contributors.append(contributor)I confirmed it accepts Second — and this is the part I verified directly — So a contributor invoked by one unauthenticated The realistic worst case isn't a malicious package — it's a well-meaning downstream extension with a bug, or a compromised transitive dependency, turning the public discovery document into a security-control switch. Fix (four cheap parts):
Part 3 is the one that actually closes this; the rest are defence in depth. The document tells clients local login is off on exactly the config where this branch serves a password formCombining lap 1's revert blocker with the The "effective offer" reasoning at Fix: derive The lowest-privilege availability risk in the stack, with nothing in its pathRanking every availability risk across the four PRs by privilege × ease × impact, this one wins on privilege — zero. Measured: Path inventory: One reassuring bound: Single highest-value mitigation: Aggregate disclosure: "no version leak" is true here and false one hop awayLap 1 verified this document leaks no version, which stands. But the aggregate defeats the stated ASVS posture: unauthenticated Two things become inferable only in combination: Minimal withholding set: drop Verified clean this lap — this PR's tests are the strongest in the stackCredit where due; the mutation sweep was unusually decisive here.
Lap 4 of 7 · lenses: defence-in-depth & compound risk · mutation adversary · head |
Lap 5 — integrator/consumer experience + docs-vs-code truthVerdict: MAJOR (L-05) — the document advertises three auth methods but publishes no endpoint for any, and points DCR clients at the wrong AS metadata
To act on any of those flags a client must hardcode The sharpest edge: Fix: publish MAJOR (K-03) — the PR body's stated scope hides two new unauthenticated routes and an 842-line auth rewriteThe body states:
Both halves are false, and the PR's own regenerated And The body's "Changes" list never mentions MINOR (K-06) — "Stacked on #1285" is false in both directionsThe body says "Stacked on #1285 (per the epic's merge order)". The branch is not stacked on #1285 — it branches from the shared base A maintainer reading this expects "merge #1285, rebase, done"; the reality is 16 conflicts and a required recut. Fix: replace with "duplicates #1285's already-merged local-login work; must be recut from (This also corrects my own Lap 2 note that "no PR body declares an ordering dependency" — this one does, and it is wrong.) MINOR (L-06) —
|
Lap 6 — merge-result verification (+ Lap 5 addendum)Verdict: Every prior lap reviewed this branch in isolation. This lap materialised the merge into today's First, a correction in your favour (M-03) — A-12 is not yours to fixI previously attributed the stale arch exemption in The BLOCKER (M-02) — the recut inventory has grown ~6.5× since I measured itMain gained three more auth commits since lap 2, all touching True conflict set vs today's main: 20 files / 131 hunks / 4 add/add. The inventory a resolver must handle:
A-01 has changed shape — it is no longer silent, but the blast radius is larger. Because
So a The good news: I simulated the recut and it is fully green. Recut from MAJOR (M-01) — a stack collision where "take one side" silently deletes documented public API
#1281 adds The trap is what doesn't conflict. (This supersedes my lap-2 note that the Fix: land #1281 first, resolve all three files as an explicit union in the recut, and add an arch assertion that every Lap 5 addendum (N-04) — the
|
Lap 7 (final) — merge-readiness triage · verdict:
|
branch local_login.py |
main local_login.py |
|
|---|---|---|
| gate | :94 if not ctx.config.auth.local_login.enabled: |
:145 if not ...local_login.enabled or ...idp.enabled: |
| size | 435 lines | 740 lines |
purpose= |
['state'] |
['login', 'session'] |
idp.enabled in gate |
False | True |
A --theirs resolution reproduces the live IdP bypass main already fixed. The recut is the fix; I'm listing it separately because it's the consequence to check after resolving, not just a conflict count.
BLOCKER I-01 — an unauthenticated route mutates process-global config
Re-executed live with TestClient, route dependencies []:
register_capability_contributor(hostile) # accepted, zero validation
GET /capabilities → 200
features = {'mcp': False, 'acme_pwned': True}
dns_pinning_enabled = True → False
local_login.enabled = False → True # visible in the next response: {'enabled': True}
The registration body is literally _capability_contributors.append(contributor) — no callable check, no arity check. And a raising contributor propagates RuntimeError straight out: no isolation, so one bad contributor 500s a public endpoint.
Four fixes, in order of importance:
- Pass a read-only projection (backend,
canonical_base_url, mcp-enabled) instead of livectx. This is the actual fix — the seam handing out a mutable god-object is the root cause; timeouts only bound the damage. - Wrap the call:
try: extra = await asyncio.wait_for(asyncio.to_thread(contributor, ctx), 0.25) except Exception: _log.warning(...); continue— closes I-01's crash arm plus A-09 and I-05 in one edit. register_capability_contributor— addif not callable(contributor): raise TypeError(...)and an arity check, matching the two comparable registries in the repo.- Move the collision
_log.warningout ofresolve_capabilitiesto registration time (H-04) — as written it's an unauthenticated remote log amplifier, re-emitted per request.
MAJOR L-05 / N-04 / A-07 — the document sends clients to the wrong door
From the same live run:
urls = {'broker': 'http://127.0.0.1:8100',
'authorization_server_metadata': '/.well-known/oauth-authorization-server'}
canonical_base_url = ''
The AS-metadata URL is relative, and agent_dcr, service_accounts, local_login are all enabled: true with no endpoint published for any of them. Worse, the two DCR doors disagree: discovery.py:70 (root) advertises registration_endpoint: {issuer}/register — the agent door — while :177 (/mcp) advertises /oauth-clients, which is what oauth_client_dcr actually means. A client following urls.authorization_server_metadata registers at the wrong endpoint.
And urls.broker leaks http://127.0.0.1:8100 on a default backend to unauthenticated callers.
Fix: return None for urls.broker unless advertised_broker_url is set (A-07 + I-04); make authorization_server_metadata absolute when canonical_base_url is set; publish an endpoint for every advertised auth method, or stop advertising it.
MAJOR M-01 — resolve testing/ as a union, not a pick
git merge-tree ff8736d3 6ea7f850 conflicts in testing/__init__.py, testing/compliance.py, test_compliance_oss.py; __all__ differs by exactly one symbol per side. docs/development/extending-jentic-one.md auto-merges while documenting both symbols (:174, :191), and grep -rln "jentic_one.testing" tests/arch/ returns nothing — so a one-sided resolution deletes documented public API with no gate failing. Land #1281 first and take the explicit union.
Landing path
Must-fix now: the recut · I-01 (read-only projection + isolation + registration validation) · the testing/ union.
Fast-follow: B-01/B-02 versioning, C-03 governed_hosts flag, G-04 ETag, L-05/N-04 URL completeness.
Not yours: E-01/E-02/E-03 arch-gate erosion (the "all non-health routes require auth" gate is a bypassable substring scan that doesn't cover shared/web/) — that's main's problem too, but this PR is the first to depend on it, so it's worth an issue. Likewise A-12, which lap 6 re-attributed to #1285 on main, not to you.
Merge order: #1281 → #1286 → #1283 (+ regen) → #1284 (recut). Last, because of the conflict breadth and because #1283 must exist for features.governed_hosts to be truthful.
The recut is mechanical and already validated at 3682 green. After it, I-01 is the one real design change — and it's the right one to make before a public unauthenticated endpoint ships.
Deployment self-description for one-URL client onboarding: which sign-in methods exist (idp / local_login / oauth_client_dcr / agent_dcr / service_accounts - the login-picker contract), where the broker is (urls.broker, closing the discovery half of #1249), which surfaces are mounted, and which optional features are on. Generalises the GET /auth/idp 'public capability hint' into the pattern MCP authorization (RFC 9728), Matrix /capabilities, and GitLab /metadata converged on. Minimal by design (ASVS fingerprinting posture): no exact version string. Mounted next to /instance in both app factories; the broker data plane opts out exactly as it does for /instance. Downstream packages extend features.* via register_capability_contributor (same import-time registry posture as set_claim_token_minter; additive only - collisions with built-ins are logged and dropped), with a BaseCapabilityContributorComplianceTest base in jentic_one.testing. New config key server.advertised_broker_url (default '' -> falls back to server.mcp.broker_url, correct for the local install topology) for split deployments whose internal broker URL is not client-reachable. Purely additive - no existing route, schema, or config key changes meaning. local_login is pinned false until #1276 lands its config key. Closes #1279 Co-authored-by: Cursor <cursoragent@cursor.com>
…o local backend Two review fixes from the epic #1280 review, enabled by restacking this branch onto #1285 (the local-login feature): - auth.methods.local_login now publishes the *effective* offer: auth.local_login.enabled AND no external IdP. The /authorize flow only falls through to the login form when no IdP is configured (IdP always wins, no mixed mode), so a raw config echo would advertise a sign-in method that is never reachable. - urls.broker falls back from server.advertised_broker_url to server.mcp.broker_url only when server.backend == 'local' (single-box topology, where the hop URL is by construction the origin the client already reached). On a remote/split backend the hop URL is topology-private (compose service name, internal listener): publishing it on an unauthenticated endpoint was both a routing lie and an internal-topology leak. Remote deployments now get null until the operator sets the advertised key. Regenerated: control OpenAPI, ui/openapi.json + generated client, endpoints reference, CLI vendored spec + client. Co-authored-by: Cursor <cursoragent@cursor.com>
Review follow-up for GET /capabilities (#1284), on the branch recut from main that drops the duplicate #1282/#1285 commits. - Contributor seam: contributors receive a frozen CapabilityView instead of the live Context (no reach into mutable security config from behind an unauthenticated route); registration validates callables, arity and duplicates and logs the registrant; each contribution is isolated (exception/non-mapping/non-str-key/non-bool-value logged and dropped, never a 500); collisions are case-insensitive; contributors run once at app build, never on the request path. - features is dict[str, bool] (Go client emits map[string]bool). - urls.broker publishes only an explicitly set, validated http(s) server.advertised_broker_url with userinfo/query/fragment stripped; the internal server.mcp.broker_url hop is never published (the backend=local fallback keyed on a self-declared hint is gone). - urls block grows authorize/token/agent_registration/ oauth_client_registration plus the /mcp-scoped RFC 8414 and RFC 9728 metadata documents, all absolute whenever a base URL is known — a DCR client is pointed at /oauth-clients' issuer document, not /register's. - Field docs scope mount-derived flags to the answering process (split deployments: false means "not served here", not "does not exist"). - Deterministic body ships ETag + Cache-Control and honours If-None-Match (304); boot logs one capabilities_resolved line and warns on a loopback broker URL beside a public canonical URL. - jentic_one.testing keeps the #1281 and capability compliance bases side by side (explicit union); shape-pin test ties CAPABILITIES_VERSION to the document's field-pointer set. - Regenerated OpenAPI/UI/CLI clients, endpoints reference, config schema. Co-authored-by: Cursor <cursoragent@cursor.com>
6ea7f85 to
90bea78
Compare
Co-authored-by: Cursor <cursoragent@cursor.com> # Conflicts: # .secrets.baseline # cli/client/generated/control/client.go # cli/client/generated/control/required.gen.go # docs/reference/endpoints.md
…lsResponse.token
The 1H sweep flags the capability document's OAuth token-endpoint URL
(RFC 8414 token_endpoint) as secret-shaped. It is a routable address,
not a credential, but the allowlist was keyed on bare property names,
so exempting it would have exempted every 'token' field in every
schema. Entries may now be schema-qualified ('Schema.prop.path'); the
exemption is scoped to CapabilitiesUrlsResponse.token only.
Co-authored-by: Cursor <cursoragent@cursor.com>
Review response — all seven laps triaged; branch merged with today's
|
| Finding | Disposition |
|---|---|
A-01 / C-01 / M-02 — branch carried a pre-review copy of #1282/#1285 that reverted main's two-sided /login gate; recut required |
Done — recut. The branch is based on main past #1281/#1285/#1286; no auth/ delta remains. The --theirs trap is gone because the conflicting auth/ files no longer exist on this branch. |
I-01 — unauthenticated route reached a contributor seam holding the live mutable Context |
Done. Contributors receive a frozen CapabilityView (backend, canonical URL, surfaces, mcp flag — nothing writable) and run once at app build, never on the request path. Registration validates callable/arity/duplicates and logs the registrant; each contribution is isolated (try/except, str→bool validation, case-insensitive first-writer-wins). A hostile or broken contributor can no longer touch config, 500 the route, or block the event loop. |
M-01 — testing/ union with #1281 |
Done. #1281 is merged and the branch is based past it; BaseUnregisteredUrlHandlerComplianceTest and BaseCapabilityContributorComplianceTest both present in __all__. |
Majors
| Finding | Disposition |
|---|---|
A-07/I-04 — urls.broker fallback gated on the self-declared backend hint; query-string credential survived |
Done. No fallback to server.mcp.broker_url on any backend — only the explicit server.advertised_broker_url is published, validated http(s)-with-host, userinfo/query/fragment stripped, invalid → null + boot warning. |
| L-05/N-04/A-07 — advertised methods with no endpoints; relative AS-metadata URL; wrong DCR door | Done. urls now publishes authorize, token, agent_registration, oauth_client_registration, and (gated on server.mcp.oauth.enabled) authorization_server_metadata_mcp + protected_resource_metadata; all absolute whenever a base URL is known (canonical URL, else the request origin). The mcp-vs-root registration_endpoint split is documented on the fields. |
| Split-deployment misreporting | Done. Field docs scoped to "the process answering this request"; surfaces documents that a sibling tier may serve what's absent here. |
B-01/B-02 — unenforced capabilities_version |
Done. test_capabilities_version_is_tied_to_the_document_shape pins the version to the sorted field-pointer set; the contract is additive (bump only on remove/rename/retype). |
| H-04 — per-request collision warning (unauthenticated log amplifier) + silent registration | Done. Request path is log-silent; registration and router build emit one-shot capability_contributor_registered / capabilities_resolved, plus the loopback-broker boot warning. |
| G-04 — no caching headers | Done. Strong ETag, If-None-Match → 304, Cache-Control: public, max-age=60. |
L-06 — Go Features map[string]interface{} |
Done. features is dict[str, bool] end-to-end; Go client emits map[string]bool. |
| K-03/K-06 — PR body scope claims | Done. Body rewritten for the recut branch; the stacking claim is gone. |
Deferred / not this PR
- C-03
features.governed_hosts— deferred exactly as lap 7 prescribed ("feat(registry): identity-scoped GET /governed-hosts digest endpoint #1283 must exist for it to be truthful"); feat(registry): identity-scoped GET /governed-hosts digest endpoint #1283 is still open. Fast-follow once it lands. - E-01/E-02/E-03 arch-gate erosion — re-attributed to
mainby the review; now filed as arch: the public-route auth gate is a substring scan that misses shared/web/ and accepts undocumented public declarations #1375 so it isn't lost. - A-12 stale arch exemption — re-attributed to feat(auth): local-account login form on the /authorize flow #1285 on
main(lap 6, M-03).
Today's push (49be1b4a)
- Merged current
main(post-feat!: remove toolkits — direct agent-credential bindings (theme 5, phases 0-6a) #1370 toolkit removal). All four conflicts were generated artifacts, resolved by regenerating from merged source (make openapi/endpoints/config-schema/config-reference,cli make generate-config/generate-api, UI codegen, secrets baseline). One real semantic touch: main's feat(instance): expose the MCP broker URL via /instance and both MCP UIs #1338 added an/instancecall site for the old private_sanitized_url_partsname — repointed to the publicsanitized_url_partsthis branch exports./capabilitieskeeps its stricter no-fallback posture forurls.broker, unchanged. - Fixed the red Go CLI lint & test check: the 1H sensitive-annotation sweep flagged
CapabilitiesUrlsResponse.token(the RFC 8414 token-endpoint URL, not a credential). Rather than exempting everytokenfield, the allowlist now accepts schema-qualified entries and the exemption is scoped toCapabilitiesUrlsResponse.tokenonly.
Verification on the merged head: make lint clean (ruff + mypy, 1278 files); tests/arch 320 passed; tests/unit 3710 passed with one failure — test_oneshot_cache_is_keyed_on_source_identity_not_path from #1286, which fails identically on a pristine origin/main checkout on macOS (/dev/fd semantics; green on Linux CI) — pre-existing, unrelated; GOWORK=off go build ./... && go test ./... fully green.
Co-authored-by: Cursor <cursoragent@cursor.com> # Conflicts: # .secrets.baseline
…ument #1283's GET /governed-hosts is the surface built for the same driving consumer as this document (the native gate), so a client must be able to branch on the flag instead of probing the route by 404 — which cannot distinguish 'unsupported' from 'wrong path'. Mount-derived and process-scoped like the agent_dcr/service_accounts flags: true iff the registry surface is mounted on the answering process. Golden pin and generated artifacts extended. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Addendum to the review response above: #1283 merged while this was in flight, so the one deferred item (C-03) is now in rather than fast-follow — The PR is now MERGEABLE against |
Summary
Implements #1279 (part of epic #1280): one public, unauthenticated
GET /capabilitiesdocument so a client connecting to an arbitrary deployment (native desktop app, CLI against a remote server, SPA, MCP client) can discover — from one URL — which sign-in methods exist, where the broker is, which surfaces are mounted, and which optional features are on. Generalises the in-treeGET /auth/idp"public capability hint" into the pattern MCP authorization (RFC 9728), Matrix/capabilities, and GitLab/metadataconverged on. Also closes the discovery half of #1249 (urls.broker).Based directly on
main(which already contains #1282/#1285): the branch was recut after review so it carries only the capabilities work —git diff main -- src/jentic_one/auth/is empty. The document publishes the livelocal_logincapability rather than a hardcoded placeholder.What's in here
shared/web/capabilities.py: response models,resolve_capabilities(config-only, DB-free), andget_capabilities_router(enabled_apps)— mounted next to the instance router in both app factories; the broker data plane opts out exactly as it does for/instance.auth.methods):idp(mirrors/auth/idp),local_login(the effective offer:auth.local_login.enabledAND no external IdP — the/authorizeflow only reaches the form when no IdP is configured, "IdP always wins", so a raw config echo would advertise an unreachable method),oauth_client_dcr(server.mcp.oauth.enabled+approval: auto|manualfromauto_approve_clients),agent_dcr/service_accounts(present iff the auth surface is mounted). Surface-derived flags are documented as describing the process answering this request.urls:brokercomes from the new config keyserver.advertised_broker_url(default""→ published asnull). There is no fallback toserver.mcp.broker_urlon any backend: the hop URL is topology-private (compose service name, internal listener), so publishing it on an unauthenticated endpoint would be wrong for routing and an internal-topology leak. The value is validated http(s)-with-host and stripped of userinfo/query/fragment before publishing; invalid values are dropped with a boot warning. The document also publishes the other public doors —authorize,token,agent_registration,oauth_client_registration, and (gated onserver.mcp.oauth.enabled)authorization_server_metadata_mcp+protected_resource_metadata— absolute whenever a base URL is known.register_capability_contributor— same process-global import-time registry posture asset_claim_token_minter. Contributors receive a frozenCapabilityView(never the liveContext) and run once at app build time, never on the request path. Registration validates the contributor (callable, arity, duplicates) and logs the registrant; each contribution is isolated (try/except+ str→bool key/value validation). Contributions extendfeatures.*additively; collisions with built-ins (or earlier contributors) are logged and dropped, so a downstream package can never rewrite OSS semantics (open question 5). Seam-table row indocs/development/extending-jentic-one.md+BaseCapabilityContributorComplianceTestinjentic_one.testing.If-None-Match→ 304,Cache-Control: public, max-age=60.CAPABILITIES_VERSIONis tied to the sorted field-pointer set by a shape-pin test; the contract is additive (bumps only on remove/rename/retype; clients branch on key presence).capability_contributor_registered,capabilities_resolved, loopback-broker warning).getCapabilitiesinPUBLIC_OPERATION_IDS; tagged Discovery;featuresis typeddict[str, bool]end-to-end (Go client emitsFeatures map[string]bool) and ships two built-ins:mcp(server.mcp.enabled) andgoverned_hosts(true iff the registry surface — which serves feat(registry): identity-scoped GET /governed-hosts digest endpoint #1283'sGET /governed-hosts— is mounted on the answering process); regeneratedmake openapi/make endpoints/make config-schema/cli make generate-config/ UI codegen artifacts all included.Default-preserving guarantee
Purely additive: one new public route + one new config key (default
""publishesurls.broker: null). No existing route, schema, or config key changes meaning. Clients treat the document as additive and fall back to today's probes when absent.Risk & rollback
server.advertised_broker_urlbecomes an ignored (still schema-valid) key. No migrations, no data.Tests
tests/unit/shared/test_capabilities.py(35 tests): byte-shape golden pin of the default body (the RFC 8414 golden pattern); per-flag toggles (auth.idp.enabled,auth.local_login.enabledincl. IdP-wins yield,server.mcp.enabled,server.mcp.oauth.enabled+auto_approve_clients, surface composition); broker-URL validation/stripping and no-fallback gating; contributor seam isolation, registration validation, case-shadowing, build-time freeze; ETag/304; version shape pin; standalone-surface mount; no/capabilitieson the broker app; no version string in the body; schema-visible + public (no BearerAuth) + Discovery tag. Plus the compliance-harness self-test intests/unit/testing/test_compliance_oss.py.make lint(ruff + mypy, 1239 files),make test-unit(3418 passed),make test-arch(300 passed),make detect-secretsgreen; full Go CLI build+test green with the regenerated client (GOWORK=off go build ./... && go test ./...).Closes #1279
Made with Cursor