Fix: load .env from the CLI entrypoint instead of at module import - #433
Open
AmaadMartin wants to merge 2 commits into
Open
Fix: load .env from the CLI entrypoint instead of at module import#433AmaadMartin wants to merge 2 commits into
AmaadMartin wants to merge 2 commits into
Conversation
added 2 commits
July 31, 2026 20:17
Importing dev/src/cli/cli.ts ran dotenv.config({quiet: true}) during module
evaluation, so merely importing the module read ${cwd}/.env off disk and
merged it into process.env. Move the call to the first statement of the
cli_entrypoint try block, where the shipped adk binary still loads .env
before any command action runs, with the same options and precedence.
Adds a regression case that writes a .env holding a sentinel into a temp dir, points process.cwd() at it, resets the module registry and re-imports cli.js, then asserts the sentinel never lands in process.env. A companion case runs dotenv.config() over the same fixture to prove the fixture is real and that the entrypoint's call still loads it.
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
N/A — no public issue is tracking this.
Problem:
dev/src/cli/cli.tscalleddotenv.config({quiet: true})at module scope, between the import block and the first declaration. Anything that imports the module therefore reads${process.cwd()}/.envoff disk and merges it intoprocess.envas a side effect of module evaluation.dev/test/cli/cli_test.tsimportscreateProgramfrom that module, so every run of theunit:devvitest project performed that read, withprocess.cwd()being the repo root.That makes the suite non-hermetic in a way the usual remedies cannot fix:
.envis.gitignored (so invisible ingit status) and absent on CI runners, so a contributor who never exportedDATABASE_URLstill gets it, and CI never sees what they see.process.envinsetupFiles, inbeforeAll, or withvi.stubEnvinbeforeEachis strictly too late — by the time the first hook executes the values are already merged. I verified this by addingbeforeEach(() => { vi.stubEnv('DATABASE_URL', ''); })tocli_test.tswith a repo-root.env: the value was still present at import time.The leak is not theoretical for this file.
dev/src/cli/cli.tsreads the injected value directly ingetSessionServiceFromOptions:so with
DATABASE_URLin a repo-root.env, everyweb/api_server/run/deploycase incli_test.tsthat does not pass--session_service_uriresolves a session service from the developer's database URI instead of the intendedmemory://default.Solution: relocate the call — delete
import dotenv from 'dotenv'anddotenv.config({quiet: true})fromdev/src/cli/cli.ts, and makedotenv.config({quiet: true})the first statement inside the existingtryblock ofdev/src/cli_entrypoint.ts(thebintarget,"adk": "./dist/esm/cli_entrypoint.js"). The side effect is moved, not removed:import '…/cli/cli.js'${cwd}/.env, mutatesprocess.envcreateProgram()adk <cmd>(bin)${cwd}/.envbefore any action${cwd}/.envbefore any actionWhy this is the right fix rather than a test-only workaround:
adk-pythonalready does it this way. The Python SDK never callsload_dotenvat module scope; the only invocation lives insideload_dotenv_for_agent(...)insrc/google/adk/cli/utils/envs.py, called from command bodies and loaders (cli_tools_click.py,cli.py,agent_loader.py,service_registry.py,fast_api.py). Importinggoogle.adk.cli.cli_tools_clickreads no.env. The TypeScript SDK was the outlier.vi.mock('dotenv', …)in the tests. It works mechanically (vi.mockis hoisted above imports) but leaves the filesystem side effect in shipped code and has to be repeated in every test file that ever transitively importscli.js.createProgram(). That closes the import-time hole but not the leak —cli_test.tscallscreateProgram()inbeforeEach, so the.envwould be read once per test case instead of once per file.setupFilesmodule that scrubsprocess.env. Cannot work for this bug (hooks run after module evaluation). It is worthwhile for the separate ambient-shell-variable problem, which is not folded in here.Scope — this is the only occurrence. A sweep for module-scope
dotenv.config()across the repo found exactly one:dev/src/cli/cli.ts. The two apparent hits indev/src/cli/cli_create.tsare inside theAGENT_TEMPLATEtemplate literal — they are the text of the sampleagent.tsthatadk createscaffolds, where a module-scopedotenv.config()is correct and intended, so they are deliberately untouched. The ~15dotenv.config({path: envPath})calls undertests/e2e/**pass an explicit path, belong to thee2eproject, and are out of scope.Ordering safety. Under ESM,
import {createProgram} from './cli/cli.js'is hoisted, so the wholecli.tsmodule graph now evaluates beforedotenv.config()runs. This is safe because the only code whose evaluation moves relative to thedotenvcall iscli.ts's own module body (its imports were already evaluated before the call in the old layout), and that body reads noprocess.env: it isLOG_LEVEL_MAP, plain function declarations, and theArgument/Optionconstants, whose defaults are string literals andprocess.cwd(). The one env-sensitive default,getTempDir(...)(which callsos.tmpdir()and so readsTMPDIR), is evaluated insidecreateProgram(), which the new entrypoint calls afterdotenv.config().grep -n "process\.env" dev/src/cli/cli.tsreturns a single hit, insidegetSessionServiceFromOptions.Behaviour claims, grounded.
dotenvresolved in this workspace is 17.4.2 (node -p "require('dotenv/package.json').version").node_modules/dotenv/lib/main.js:241computespath.resolve(process.cwd(), '.env')at call time, and:372defaultsoverridetofalse. A missing.envis not an exception —config()returns{parsed, error}— so relocating the call introduces no new failure mode on machines with no.env.{quiet: true}is carried over verbatim, so no v17 tip banner appears.No breaking change. CLI users invoke
dist/esm/cli_entrypoint.jsvia theadkbin, which still loads.envbefore any command action with the same options and precedence. Library users import@google/adk-devtools, whose entry (dev/src/index.ts) exports onlyAdkApiClientandAdkApiServerand never re-exportedcli.ts, so it never triggered the load. The only consumers ofdev/src/cli/cli.jsin the repo aredev/src/cli_entrypoint.tsanddev/test/cli/cli_test.ts; both are handled. Generated agent scaffolds are untouched.Collision check (required).
gh pr list --repo AmaadMartin/adk-js --state open --limit 100plus a filtered pass over all open PRs fordotenv|env|entrypoint|cli, thengh pr diff --name-onlyon the plausible neighbours. No open PR lands this change. The adjacent ones are test-only and attack a different source of pollution (the ambient shell, not a.envfile), which the plan for this fix explicitly keeps separate: #348 (dev/test/cli/cli_test.tsonly — scrubs an ambientDATABASE_URLviavi.stubEnv), #281 and #302 (hermeticity helpers undercore/test/tests/), #369 (consolidates thetests/e2edotenv loader, explicit-path calls only). None removes the module-scope call, and as explained above astubEnv/setupFilesscrub cannot fix the import-time read. This branch is cut frommainrather than stacked because its hunks are disjoint from #348's (imports block and a new trailingdescribevs. the mock factory andbeforeEach), and stacking on a test-only PR that may not merge would pointlessly couple a two-line source fix to it.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.
One new
describe('module import side effects')block was added todev/test/cli/cli_test.ts; no existing test in that file was modified or deleted. It writes a.envholdingADK_DOTENV_IMPORT_SENTINEL=leakedinto afs.mkdtempdirectory, pointsprocess.cwd()at it withvi.spyOn(notprocess.chdir, which the repo uses nowhere and which is unavailable in worker threads), callsvi.resetModules()so the dynamicimport('../../src/cli/cli.js')actually re-evaluates rather than hitting the registry entry created by the file's static import, and asserts the sentinel never reachesprocess.env. The sentinel name is one no production code reads, so the test pins the mechanism rather than piggy-backing onDATABASE_URL(which would be flaky for anyone who genuinely exports it). TheafterEachdeletes the sentinel and removes the temp dir on every path; theprocess.cwdspy is restored by the file's existingafterEach(() => vi.restoreAllMocks()).A second case in the same block calls
dotenv.config({quiet: true})over the same fixture and asserts the sentinel does appear. That is the positive control: without it the first assertion could pass for the wrong reason (a mistyped fixture filename), and it also pins the behaviour the relocated entrypoint call relies on.Proof each test can fail (mutation runs).
dev/src/cli/cli.tshunk (restoredimport dotenv from 'dotenv'and the module-scopedotenv.config({quiet: true})) and re-rannpx vitest run --project unit:dev dev/test/cli/cli_test.ts:.envto.env.wrong-nameto check the positive control is not vacuous:Both mutations were reverted and the suite re-run green.
Commands run (targeted only; the full repo suite was not run):
Note on
ts:check: the repo currently has a large number of pre-existingtsc --noEmiterrors in test files unrelated to this change (this is what PRs #370 / #408 / #421 are about). None of them are in the three files this PR touches —git-grepping the fullts:checkoutput forcli/cli.ts,cli_entrypoint, orcli_testreturns nothing, before or after.Also verified the stated postcondition directly: with
printf 'DATABASE_URL=bogus://nope\n' > .envat the repo root,npx vitest run --project unit:devstill reports14 passed / 225 passed, andnode -e "import('./dev/dist/esm/cli/cli.js').then(() => console.log(process.env.DATABASE_URL))"printsundefined— importing the built module no longer injects the file's value.Coverage. The change in
dev/src/cli/cli.tsis a pure deletion, so there are no new lines to cover there; measured coverage of that file under the targeted run is unchanged at 95.3% statements / 73.17% branches. The two added lines indev/src/cli_entrypoint.tsare not unit-tested, deliberately: that file runscreateProgram().parse(process.argv)at module scope, so importing it from a test would execute the CLI. Restructuring it into an exportedmain()behind animport.meta.urlguard just to reach a coverage number would be scope creep on a two-line fix. Those two lines are covered by the manual E2E below instead.Manual End-to-End (E2E) Tests:
Run against the real built binary (
npm run buildfirst, sodev/dist/esm/cli_entrypoint.jsreflects the change), from a scratch directory withDATABASE_URLnot exported in the shell (echo "${DATABASE_URL-unset}"printsunset):.envstill honoured by the shipped CLI — withprintf 'DATABASE_URL=bogus://nope\n' > .env:The URI from the file reached
getSessionServiceFromOptions, which is the proof the entrypoint'sdotenv.config()ran before the command action. (This doubles as an error-path check: the unsupported-scheme throw fromgetSessionServiceFromUriis reported exactly as before.)Explicit flag still wins over the
.envvalue (precedence unchanged) — same.env:An exported shell variable still beats the
.enventry (override: falsepreserved) — same.env:No new stdout/stderr noise in any run — no dotenv v17 "injected env (N) from .env // tip: …" banner appeared, confirming
{quiet: true}survived the move.Scratch directories and the temporary repo-root
.envwere deleted afterwards.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.