Feat: port mtls_utils and send Agent Registry requests over mTLS - #608
Open
AmaadMartin wants to merge 6 commits into
Open
Feat: port mtls_utils and send Agent Registry requests over mTLS#608AmaadMartin wants to merge 6 commits into
AmaadMartin wants to merge 6 commits into
Conversation
added 6 commits
August 3, 2026 19:39
Ports adk-python's src/google/adk/utils/_mtls_utils.py to TypeScript so that JS callers of Google Cloud REST APIs can honour certificate-based access policies. The module resolves the GOOGLE_API_USE_CLIENT_CERTIFICATE and GOOGLE_API_USE_MTLS_ENDPOINT settings, rewrites *.googleapis.com hosts to their .mtls.googleapis.com variants, and builds an undici dispatcher that presents the application-default client certificate to the global fetch. Certificate loading fails open: any missing or malformed configuration logs a warning and degrades to a plain non-mTLS request.
…e is configured AgentRegistry.makeRequest() now resolves the client certificate once per instance, attaches a certificate-presenting dispatcher to its fetch calls, and targets agentregistry.mtls.googleapis.com when GOOGLE_API_USE_MTLS_ENDPOINT says so. The in-flight promise is memoized rather than its result, so concurrent first calls share a single certificate load. With no environment configuration the request is byte-for-byte what it was: the plain host and a fetch init with no dispatcher property.
Adds an integration test that generates a throwaway CA plus server and client certificates with openssl, starts a node:https server with requestCert, and asserts that a fetch made through the dispatcher built by createMtlsDispatcher() is seen by the server with the expected client common name -- and that no peer certificate arrives when the feature is disabled. The suite skips cleanly when openssl is not on PATH.
With GOOGLE_API_USE_MTLS_ENDPOINT=always a failed certificate load still targets the mTLS host (parity with adk-python), so the fallback is a request without a client certificate rather than a request to the plain host.
Adds an assertion that MtlsEndpointSetting still serialises to auto/always/ never, since those strings are read from a shared environment variable and must not drift from the Python implementation. Also normalises the module doc comment to plain ASCII punctuation.
Review feedback: three of the module's exports had exactly one caller each and existed only so a unit test could reach them, and the NEVER opt-out was encoded twice -- once in shouldUseMtlsEndpoint and again, unreachably, inside effectiveGoogleapisEndpoint, which was only ever called behind that gate. - Merge shouldUseMtlsEndpoint, isNonMtlsGoogleapisEndpoint and effectiveGoogleapisEndpoint into effectiveGoogleapisEndpoint(url, hasClientCert), which reads the setting once. The caller in AgentRegistry is now a single expression, and the single-use hostnameOf helper is gone. - Inline useClientCertEffective, a bare wrapper over getBooleanEnvVar, into createMtlsDispatcher. - Inline the single-producer, single-consumer ClientCertificate interface. - Drop the outer .catch() in resolveMtlsTransport: createMtlsDispatcher catches everything after its env check and cannot reject, so the second fail-open handler was dead code with a second warning string for the same event. The two tests pinning it are removed with it. - Correct the FetchInit comment. The alias is not about importability -- it exists because eslint's no-undef rejects the bare RequestInit global, which npm run lint still confirms. No behaviour change: same precedence rules, same rewrite, same wire strings.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Please ensure you have read the contribution guide before creating a pull request.
Link to Issue or Description of Change
Closes: #issue_number
Related: #issue_number
Problem: adk-python has a shared mTLS utility (
src/google/adk/utils/_mtls_utils.py) that every Google Cloud REST caller consults before talking to a*.googleapis.comendpoint: it decides whether to present a client certificate, attaches it to the HTTP session, and rewrites the endpoint to*.mtls.googleapis.comso certificate-bound access tokens are honored. adk-js has no equivalent — the one Google Cloud REST caller,core/src/integrations/agent_registry/agent_registry.ts, always hitshttps://agentregistry.googleapis.com/v1alphawith a barefetch. Users in organizations that enforce certificate-based access cannot use the adk-js Agent Registry client at all, and users who do have a device certificate silently get non-certificate-bound tokens.Solution: port the module to
core/src/utils/mtls_utils.tsand adopt it inAgentRegistry.New module
core/src/utils/mtls_utils.ts(Node-only, deliberately not added toindex.ts/common.ts, so the browser bundle is untouched):_mtls_utils.pyMtlsEndpointSetting(auto/always/never)MtlsEndpointGOOGLE_API_USE_MTLS_ENDPOINTeffectiveGoogleapisEndpoint(url, hasClientCert)effective_googleapis_endpoint()+is_non_mtls_googleapis_endpoint()+_should_use_mtls_endpoint()urlunchangedcreateMtlsDispatcher()configure_session_for_mtls()undefinedPython splits the endpoint decision across three functions because it has three separate callers for the pieces; adk-js has one, so the whole policy -- the
neveropt-out,always, andauto+ certificate -- is applied in a single function that reads the setting once.use_client_cert_effective()is not a separate export either: it was a bare wrapper over this repo's existinggetBooleanEnvVar, so it is inlined at its one call site.AgentRegistry.makeRequest()now resolves the certificate once per instance, attaches a certificate-presenting dispatcher when one exists, and targetsagentregistry.mtls.googleapis.comwhen the resolved endpoint says so.With no environment variables set, nothing changes: same URL, same fetch init (the
dispatcherkey is added conditionally, so it is absent entirely), and no filesystem access at all. All 52 pre-existing assertions incore/test/integrations/agent_registry_test.tspass unmodified.Collision check. Before starting I listed all 506 open PRs on this fork (
gh pr list --limit 1000) and diffed every plausibly adjacent one. No open PR createscore/src/utils/mtls_utils.tsor modifiescore/src/integrations/agent_registry/agent_registry.ts. The 7 PRs whose body mentions mTLS (#529, #578, #194, #462, #535, #537, #464) all touch other clients only. #334 touchescore/test/integrations/agent_registry_test.ts, which is why the new registry tests live in a new file rather than being appended to that one — no conflict either way.New dependency:
undici(the one genuinely reviewable item)Node's global
fetchcannot present a client certificate on its own and does not accept anode:https.Agent. The options were (a) anundiciAgentpassed as the non-standarddispatcherinit property, (b) rewriting callers ontonode:https, or (c) another HTTP client. This PR takes (a); (b) would fork the request path in two and leave every otherfetch-based caller uncovered.Verified empirically on this branch, Node v22.22.2 with the resolved undici 7.29.0, against a local
node:httpsserver started withrequestCert: true:fetch(url, {dispatcher: new Agent({connect: {ca}})})→ server sees no peer certificate;fetch(url, {dispatcher: new Agent({connect: {ca, cert, key}})})→ server seessubject.CN === 'probe-client'.That scenario is now a committed test (
tests/integration/mtls_dispatcher_test.ts), not just a one-off probe.Disclosures about the dependency, each checked against a source of truth rather than quoted from memory:
node_modules/undici/package.jsondeclares"engines": {"node": ">=20.18.1"}and"license": "MIT". This repo declares noenginesfield in eitherpackage.json, and.github/workflows/validation.yamlusesactions/setup-node@v6with no pinnednode-version, so there is no existing floor for this to contradict — but it is stated here rather than smuggled in.undiciis imported lazily (await import('undici')insidecreateMtlsDispatcher), so importing@google/adkdoes not load it and the module stays importable below undici's engine floor. This is the one deliberate inline import in the change.scripts/check_license.shonly checks source-file headers, so no allowlist change is needed.package-lock.jsonis touched only by this one real dependency addition (9 added lines,resolvedpointing atregistry.npmjs.org). No version-bump churn, noCHANGELOG.md.Deliberate scope decisions (nothing silently dropped)
Not ported from
_mtls_utils.py:get_api_endpoint(location, default_template, mtls_template)— its Python callers are the Secret Manager and Parameter Manager regional clients, which do not exist in adk-js. Porting it would ship a parameter with no reader.MtlsClientCerts— extracts the certificate to a temp directory for consumers that need on-disk paths (gRPC-style channels). Nothing in adk-js needs on-disk paths; the dispatcher takes the bytes directly.Adoption sites deliberately left for follow-ups, to keep this reviewable:
core/src/auth/oauth2/oauth2_utils.ts), which mirrorsoauth2_credential_util.pyand interacts with the existing SSRF guard;getConnectionUri(). Rewriting a URL to an mTLS host only helps if the transport that dials it can present the certificate, and neither the MCPStreamableHTTPConnectionParamstransport nor the A2A client is wired for a custom dispatcher today. Shipping the rewrite alone would move traffic to an mTLS host with no certificate on it, sogetConnectionUri()is untouched.Cross-language parity notes (where JS and Python conventions conflict)
auto/always/never), the.mtls.googleapis.comhost form, the snake_casecertificate_config.jsonkeys, and the precedence rules (always> cert presence;neveralways wins) all match Python exactly.getBooleanEnvVar(), which accepts'true'or'1'(case-insensitive), whereas Python accepts only'true'. This is a deliberate superset: reusing the repo helper is the right call for process-internal parsing, and it is a widening, not a behaviour change for anyone.google.auth.transport.mtls. The Nodegoogle-auth-libraryv10 exports no mTLS helper, so this mirrors the resolution order used by itscertificatesubjecttokensupplier:GOOGLE_API_CERTIFICATE_CONFIG, elseCLOUDSDK_CONFIG, else%APPDATA%\gcloudon Windows /$HOME/.config/gcloudelsewhere, thencertificate_config.json→cert_configs.workload.{cert_path,key_path}.configure_session_for_mtls()returnsFalseon any certificate problem;createMtlsDispatcher()returnsundefinedand logs onelogger.warn. It never throws. Certificate and key bytes never reach a log, an error message, or disk (there is a test asserting exactly that).Notes for the reviewer
git diff fork/main -U0 | grep -E '@ts-expect-error|@ts-ignore|eslint-disable|as any|: any'returns nothing. Worth noting because the obvious way to type thedispatcherinit hits a real obstacle:dispatcheris not on the DOMRequestInit, and eslint'sno-undeffires on the bare type nameRequestInit(the existing workaround atdev/src/server/adk_api_client.ts:264is an// eslint-disable-next-line no-undef). Instead of copying that,FetchInitWithDispatcherextendsNonNullable<Parameters<typeof fetch>[1]>— derived from the globalfetchitself, so there is no free type identifier forno-undefto trip on and no suppression. undici's ownRequestInitwas tried first and is not assignable to the globalfetch(bodydiffers:AsyncIterable<Uint8Array>is not a DOMBodyInit).mtls_utils.ts215,agent_registry.ts+58); the rest is tests. It is also one logical checkpoint — splitting it would make part 1 a module with no reader, and a stacked base would stop CI from running on the later parts.console.log, no narration comments, no internal references; the diff and all four commit messages were grepped for both.Testing Plan
Please describe the tests that you ran to verify your changes. This is required for all PRs that are not small documentation or typo fixes.
Unit Tests:
[x] I have added or updated unit tests for my change.
[x] All unit tests pass locally.
Commands run on the exact pushed commit:
Coverage of the new code (
vitest --coverage.include, v8 provider):agent_registry.tsreports 98.88% lines / 94.16% branches; every uncovered line is pre-existing (getAuthHeaders'sclient.credentials.access_tokenfallback, lines 143-147), untouched by this change. All branches added here are covered.Proof that each test can fail. Every new test was run against mutated source; the mutations and their exact failure messages:
NEVERearly-return ineffectiveGoogleapisEndpointleaves the url unchanged when the setting is "never"expected 'https://oauth2.mtls.googleapis.com/to…' to be 'https://oauth2.googleapis.com/token'url.includes('googleapis.com')classifies https://evil-googleapis.com.attacker.test/x as falseandclassifies https://googleapis.com/token as falseexpected true to be falseresolveMtlsTransport()per requestloads the certificate once across sequential requestsand…across concurrent first requestsexpected "spy" to be called 1 times, but got 2 timesdispatcher,instead of...(dispatcher ? {dispatcher} : {}))uses the plain host with no dispatcher when no certificate is availableexpected { method: 'GET', …(2) } to not have property "dispatcher"loads the certificate once across concurrent first requestsexpected "spy" to be called 1 times, but got 2 timesMutation (g) exists because inlining
useClientCertEffectivemoved its assertions ontocreateMtlsDispatcher; it confirms thetrue/1parsing is still pinned there. Mutation (e) is the interesting one: it is invisible to the sequential test and caught only by the concurrent one, which is why both exist. Mutation (f) covers the cross-language contract: those three strings are read from a shared environment variable and must not drift from Python.Error paths are exercised, not just the happy path.
mtls_utils_test.tscovers: config file missing, config not valid JSON,cert_configs.workloadabsent,cert_pathmissing,key_pathmissing, a PEM file that fails to read, a rejection that is not anError, and a dispatcher constructor that throws — each assertingundefinedplus exactly onelogger.warn, and one asserting the warning contains neither the certificate nor the key bytes. It also asserts that with the feature disabled the filesystem is never touched.agent_registry_mtls_test.tscoverscreateMtlsDispatcher()rejecting (with anErrorand with a non-Error) and proves the request still succeeds against the plain host.Manual End-to-End (E2E) Tests:
Please provide instructions on how to manually test your changes, including any necessary setup or configuration.
tests/integration/mtls_dispatcher_test.tsis the automated version of the manual check: it generates a throwaway CA plus server and client certificates withopensslinto anfs.mkdtempdirectory (deleted inafterAll; no PEM fixture is committed,secretlintscans**/*), starts anode:httpsserver withrequestCert: true, and asserts the server seesCN=adk-test-clientfor afetchmade through the dispatcher, and no peer certificate when the feature is off. ItskipIfs cleanly whenopensslis not onPATH, so a runner without it does not fail -- though in practice CI'swindows-latestrunner does have it and the test genuinely ran and passed there (✓ integration tests/integration/mtls_dispatcher_test.ts (2 tests) 990ms), alongsideubuntu-latestandmacos-latest. Because the throwaway CA cannot be trusted at runtime (NODE_EXTRA_CA_CERTSis only read at process start — verified) andcreateMtlsDispatcherdeliberately exposes nocaoption, that file setsNODE_TLS_REJECT_UNAUTHORIZED=0inbeforeAlland restores it inafterAll; the assertions are about the client certificate, and vitest isolates the file in its own worker.On a workstation with a real provisioned device certificate:
Confirm from the process's network activity that the request goes to
agentregistry.mtls.googleapis.com; re-run withGOOGLE_API_USE_MTLS_ENDPOINT=neverand confirm it returns toagentregistry.googleapis.com; then unset both variables and confirm the plain-host behaviour is untouched.Checklist
[x] I have read the CONTRIBUTING.md document.
[x] I have performed a self-review of my own code.
[x] I have commented my code, particularly in hard-to-understand areas.
[x] I have added tests that prove my fix is effective or that my feature works.
[x] New and existing unit tests pass locally with my changes.