Fix: replace winston with a console logger in core to unbreak the web build - #656
Closed
AmaadMartin wants to merge 8 commits into
Closed
Fix: replace winston with a console logger in core to unbreak the web build#656AmaadMartin wants to merge 8 commits into
AmaadMartin wants to merge 8 commits into
Conversation
Replaces the Math.random() UUID fallback with crypto.getRandomValues(), and throws rather than silently degrading to a non-cryptographic generator when no secure source exists. crypto.randomUUID() is secure-context-only, so it is absent on plain-HTTP origins even where crypto is present; getRandomValues() carries no such restriction and is used as the fallback. Callers making security decisions on this value (the OAuth2 state parameter in AuthHandler, and session identifiers minted by the session services) can no longer be handed a predictable UUID. Fallback applies RFC 4122 section 4.4 version and variant bits and zero-pads every byte. Tests pin the randomUUID branch discriminatingly, cover the getRandomValues fallback deterministically, and assert the throw.
* Fix unsafe A2A peer-supplied transferToAgent metadata createAdkEventFromMetadata() restored the transferToAgent action field directly from A2A metadata supplied by a remote A2A peer, with no filtering. A malicious or compromised remote agent could set this metadata on any Message, Task, TaskStatusUpdateEvent, or TaskArtifactUpdateEvent it sends back, forcing the local orchestrator (llm_agent.ts, which reads functionResponseEvent.actions.transferToAgent) to redirect execution to a different agent within its own configured multi-agent tree. This mirrors the fix already merged in google/adk-python (0ba7d3cba7004c0fcc0a05f1f4cfb6ea78e38f91), which explicitly excludes transfer_to_agent from the set of fields a remote peer may set. Existing test event_converter_utils_test.ts (line ~203) previously asserted the vulnerable behavior as correct; that expectation needs updating alongside this fix. * Invert transferToAgent assertion for unrestored peer metadata Per review feedback on this PR: the existing test at event_converter_utils_test.ts:203 asserted the old (vulnerable) behavior -- that a peer-supplied adk_transfer_to_agent value gets restored into event.actions.transferToAgent. Since the fix now drops that field, this assertion needs to change for CI to go green. Inverted rather than deleted: now asserts transferToAgent is undefined, so a future change that re-restores it from peer metadata fails this test instead of the test simply going silent. The adk_transfer_to_agent value stays in the fixture at :186, and the outbound toA2AMessage assertion at :92 is untouched -- only the inbound restoration assertion changes. * Restore peer-supplied action fields through an explicit allowlist Per optional review feedback: mirrors the structural approach google/adk-python used for the same fix (0ba7d3cba700, PEER_SETTABLE_ACTION_FIELDS frozenset) instead of the implicit 'escalate survives because it's the one line left standing' shape. Functionally identical today -- escalate and transferToAgent are the only two action fields A2AMetadataKeys exposes, and transferToAgent is already excluded -- but a future action field is now unsafe-by-default: adding it to the candidate object alone does nothing until it's also added to PEER_SETTABLE_ACTION_FIELDS, rather than silently being restored because no one remembered to exclude it.
…emp (#600) * refactor(core): create the unsafe executor's temp directory with mkdtemp createTempScriptFile built its directory name from Date.now() and Math.random(), then created it with fs.mkdir({recursive: true}), which does not fail on a path that already exists. A predictable name plus a non-exclusive create means a directory pre-created by another local user is adopted rather than rejected, and it keeps that user's permissions. fs.mkdtemp names the directory itself, creates it exclusively, and does so at mode 0o700. Dropping the fixed 'adk_js_unsafe_code_executor' parent in favour of it as a prefix also removes the one directory the executor never cleaned up, since fs.rm only ever removed the leaf. This is hardening rather than a fix for a reachable weakness: the component executes untrusted code locally with no sandbox by design, so a predictable temporary path is a weaker primitive than what it already grants. It should still not be the weak link. * refactor(core): address review nits on the mkdtemp change Drop the archaeology from the comment above mkdtemp: it described the implementation being replaced rather than the one being read, and the PR description already carries that history. In the temp-directory test, assert stderr before parsing stdout. If either run fails, JSON.parse throws "Unexpected end of JSON input" and swallows the stderr that would explain why.
…ck (#603) materializeFiles guarded against escaping its target directory with `fullPath.startsWith(resolvedBaseDir)`, a path-separator-unaware prefix match: a relative name like `../<dir>-evil/x` resolves outside the target directory but still starts with the same string, so it passed the check. Since the resulting directories are created with `fs.mkdir(..., {recursive: true})`, this let a caller-supplied file name write outside the intended directory as long as its resolved sibling path happened to share the base directory's name as a prefix. materializeFiles is reachable from run_skill_script_tool / run_skill_inline_script_tool with output files from a configured CodeExecutor, including AgentEngineSandboxCodeExecutor, which decodes the file name from the remote sandbox's own execution-result metadata; a caller with no other privileges beyond choosing that file name could reach a directory outside the one materializeFiles was scoped to. Require a path-separator boundary (or exact equality) instead of a bare prefix match, matching the containment check already used in FileArtifactService's assertInsideRoot. Add a regression test for the specific gap: an escape into a sibling directory that shares a name prefix with the target directory, which the existing `../escape.txt`-style test does not exercise (mkdtemp's random suffix means that case never collides with a real sibling name).
* fix(artifacts): isolate in-memory composite keys * test(artifacts): cover storage key boundaries * refactor(artifacts): encode in-memory key segments
… build core/src/utils/logger.ts imported winston, and the web build is a transpile-only esbuild pass, so the specifier was copied verbatim into dist/web/utils/logger.js. Winston transitively pulls in os, fs, util, zlib and http, so any bundler targeting the browser failed to resolve the published web artifact. SimpleLogger only used winston for a printf template; the level filter was already done by hand on an instance field. Rewriting it on console keeps the line layout, the default INFO level and the public export surface unchanged, and lets the winston dependency be dropped from core. Collapsing the four level methods into log() also fixes a latent crash: log() passed the numeric level as a winston level name, which made logform's colorizer throw. dev/ keeps its own winston logger, so node_modules/winston and the dev lockfile entry are untouched.
Adds a new SimpleLogger describe block alongside the existing setLogger block, which is left untouched. Covers level routing to the matching console method, the level filter in both directions, the INFO default, argument joining, formatLogLine, and log() no longer throwing.
…h a lookup Review follow-up. formatLogLine was an exported module-level helper with a single production caller; the format it existed to make testable is already pinned end to end by the full-line regex assertions on the console spies, so the seam bought no coverage. Inlined it and the single-use LOG_LABEL constant into log(). The exhaustive four-case switch becomes a CONSOLE_METHOD lookup. The console method is still resolved at call time, so vi.spyOn(console, ...) still intercepts, and the lookup is strictly stricter than the switch it replaces: the switch had no default branch and so accepted a newly added LogLevel silently, while indexing the lookup with an uncovered member is a compile error. The test that exercised formatLogLine directly is re-pointed at the public API rather than dropped, keeping the case count at 15.
AmaadMartin
force-pushed
the
fix/core-drop-winston-console-logger
branch
from
August 4, 2026 21:41
8ef347f to
188f2ae
Compare
Owner
Author
|
Closing: Option A was rejected upstream. This was ported to google#617, which the maintainer (kalenkevich) closed 13 minutes later with:
So dropping the winston dependency is off the table. Issue google#611 will instead be addressed via Option B (platform-split the logger: winston stays in a Node-only module, the web build gets a console implementation wired through the existing Branch left intact — the console-logger implementation written here is directly reusable as the web-side implementation under Option B. Do not re-port this PR. |
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
Fixes part of: Web build leaks Node-only deps: the async_hooks shim is gated behind --bundle (never applied), and winston has no shim google/adk-js#611 (the
winstonhalf; thenode:async_hookshalf stays open — see below)Related: Tracking: @google/adk cannot be bundled for the browser (5 build-level defects) google/adk-js#607
Problem:
@google/adkadvertises a browser build ("browser": "./dist/web/index_web.js"), but that artifact ships a literalimport * as winston from 'winston'.core/build.jsis a transpile-only esbuild pass (bundle: false,packages: 'external'), so module specifiers are copied through verbatim intocore/dist/web/utils/logger.js, and winston transitively drags inos,fs,util,zlibandhttp. Any bundler targeting the browser fails to resolve them.A build-layer alias is not available:
core/build.js:53gates the existingnode:async_hooksalias behindplatform === 'browser' && bundle, and that gate is load-bearing — esbuild rejectsaliasoutright withoutbundle(✘ [ERROR] Cannot use "alias" without "bundle"). That approach was already attempted and corrected on the issue thread, so it is not re-attempted here.Solution: Option A, as chosen by the repo owner on google#611 — drop the dependency at the source rather than patch the build for one platform.
SimpleLoggeronly used winston for two things: a printf template, and a "level filter" that was already a pass-through (the real filter is the instance fieldprivate logLevel, and every method early-returned before touching winston). Rewriting it onconsoleremoves 49 lines and adds 29 inlogger.ts, and letswinstonleavecore's dependencies entirely.core/src/utils/logger.tsnow imports nothing at all — nowinston, nonode:*builtin, noprocessreference. The four level methods collapse into a singlelog(), which builds the line inline and dispatches through a smallCONSOLE_METHODlookup. Theconsolemethod is resolved at call time (never captured into a module-level constant), sovi.spyOn(console, ...)still intercepts it. That lookup replaced an exhaustive four-caseswitchand is strictly stricter than it: theswitchhad nodefaultbranch and so accepted a newly addedLogLevelsilently, whereas indexing the lookup with an uncovered member is a compile error — verified by temporarily adding a fifth member:Nothing is added to
common.tsorindex.ts, so the public export surface is byte-for-byte unchanged.Collision check.
gh pr list --repo AmaadMartin/adk-js --state open --limit 1000(555 open PRs) was scanned for adjacent work. Two open PRs touchcore/src/utils/logger.ts: #432 ("deduplicate the winston logger into one level-gated implementation") and #431 ("add a getLogLevel() accessor"). Neither lands this change — #432 keeps winston incoreand generalises it into a sharedWinstonLogger, which is the opposite of the approved Option A, and #431 only adds an accessor. This PR is therefore branched frommainrather than stacked: stacking on #432 would mean reverting most of it, and both are unmerged fork branches. If #432 lands first this file will need a manual conflict resolution, and Option A should win. No other open PR touchescore/package.json's winston entry.Behaviour differences (all four intentional, per the plan's breaking-change analysis):
winstonis no longer a dependency of@google/adk. This is the point of the change. A consumer that relied on winston being installed transitively via@google/adkmust now declare it.@google/adk-devtoolsdeclares it itself, so it is unaffected.\x1b[3Xm...\x1b[39m. The replacement emits plain text. ANSI escapes render as literal garbage in a browser devtools console — the environment this change exists to support — and thedevCLI keeps its own colourised winston logger for terminal output. The line's text layout is unchanged:LEVEL: [ADK] <ISO-8601> <message>, with the samenew Date().toISOString()timestamp winston's defaultformat.timestamp()produced.warnanderrornow go to stderr. winston'sConsoletransport put all four levels on stdout (itsstderrLevelsset is empty for these custom levels).console.warn/console.errorwrite to stderr in Node and light up the corresponding devtools level in a browser.logger.log(level, ...)no longer throws. It previously passed the numeric level ('0'..'3') as a winston level name, which is not registered; winston logged[winston] Unknown logger level: 1and logform's colorizer then threwTypeError: colors[Colorizer.allColors[lookup]] is not a function. No existing test covered that path. Collapsing the four methods into onelog()fixes it incidentally, and there is now a regression test for it.Not changed: message text, level-filter semantics (
this.logLevel > level), theLogLevel.INFOdefault, theSimpleLogger/NoOpLoggerclass names (existing tests assertconstructor.name), the public export surface, and all 41core/srccall sites — no call-site edits.Scope kept tight, deliberately. No env-var/config knob for the log level, no
typeof console !== 'undefined'guard, no ANSI support, no change tocore/build.js,index_web.ts,common.ts,index.ts, or anything underdev/. No suppressions of any kind were added — noany,@ts-expect-error,eslint-disableor coverage pragma appears in this diff (eslint.config.jshas nono-consolerule, soconsole.*inside the logger lints clean without one).node:async_hooks— reported, not fixed. Removing winston does not remove the second leak, and this PR deliberately does not attempt it. Verified on the built artifact:It is pulled in by
core/src/utils/client_labels.ts:7, which is reachable from the web entry point viaindex_web.ts→export * from './common.js'→core/src/common.ts:282(export {getClientLabels, runWithClientLabel} from './utils/client_labels.js').core/src/utils/async_hooks_shim.tsexists and is correct but is only wired throughbuildOptions.alias, which esbuild refuses withoutbundle, so it is dead in every published artifact. That is a separate task; google#611 should stay open for it.Lockfile. The only
package-lock.jsonchange is the single"winston": "^3.19.0",line inside the"core"workspace'sdependenciesblock (1 file changed, 0 insertions(+), 1 deletion(-)). The"dev"workspace block and thenode_modules/winstonentry are untouched, becausedevstill depends on winston.npm ciwas used (nevernpm install) and passes, which is itself the proof the hand-edit is correct and complete;grep -c us-npm.pkg.dev package-lock.json→0, andprettierstill resolves to 3.8.4.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.
Seven tests were added as a new top-level
describe('SimpleLogger', ...)block in the existingcore/test/utils/logger_test.ts. Nothing in the existingdescribe('setLogger', ...)block was edited, weakened, skipped or deleted — it passes unchanged.Coverage of
core/src/utils/logger.ts: 100% branch, 89.87% statements. Every uncovered statement (lines 129, 130, 132, 133, 138, 139, 141, 142) is inside the pre-existing deprecatedloggerconst wrapper at the bottom of the file, which this change does not touch. All new code — every branch ofSimpleLogger— is 100% line- and branch-covered. The single-file run also reports a function-threshold miss (78.26% vs 88%), which is the same untouched wrapper's six methods measured against a global threshold applied to a one-file selection; it is not a real gate and does not appear in the repo-wide run.Proving each test can fail. Two techniques were needed, because the winston baseline never touches
consoleat all and would pass negative assertions vacuously.Against the unfixed source (
git checkout main -- core/src/utils/logger.ts, re-run, restore) — all 7 new tests fail, the 8 pre-existing ones still pass:Note that test 6 reproduces the real crash described above, and that test 2 fails on its positive half — a "not called" assertion alone would have passed vacuously here, which is why both halves are asserted in one test.
By mutating the new source (each mutation reverted afterwards):
if (this.logLevel > level) { return; }fromSimpleLogger.log× suppresses a message below the configured level → expected "debug" to not be called at all, but actually been called 1 times;× defaults to INFO → expected "debug" to not be called at all, but actually been called 1 times— 2 failed | 13 passedprivate logLevel: LogLevel = LogLevel.DEBUG× defaults to INFO → expected "debug" to not be called at all, but actually been called 1 times— 1 failed | 14 passedmessages.join(',')instead ofjoin(' ')× joins arguments with a single space → expected "info" to be called with arguments: [ StringMatching{…} ]— 1 failed | 14 passed[LogLevel.WARN]at'info'in theCONSOLE_METHODlookup× suppresses a message below the configured level → expected "warn" to be called 1 times, but got 0 times;× routes each level to its matching console method → expected "info" to be called 1 times, but got 2 times;× formats the full line for a warning → expected "warn" to be called with arguments: [ StringMatching{…} ]— 3 failed | 12 passed[ADK]label from the line template× emits a message at the configured level;× routes each level to its matching console method → expected "debug" to be called with arguments: [ StringContaining "DEBUG: [ADK] " ];× joins arguments with a single space;× formats the full line for a warning— 4 failed | 11 passedCollateral check. The default level is
INFO, so core's logger output now flows throughconsole.info/warn/errorin-process. The fourdevtest files that spy on those methods were run to confirm the extra calls break nothing:Manual End-to-End (E2E) Tests:
Please provide instructions on how to manually test your changes, including any necessary setup or configuration.
The build artifact is the end-to-end test for this fix — no mocks, the real published-shape output:
The issue's bundler repro was also run against the built web entry point. Locally
winstonitself still resolves (thedevworkspace keeps it innode_modules, so esbuild walks into it rather than reportingCould not resolve "winston"the way a published-package consumer would); the measurable effect is that winston's transitive Node-builtin imports disappear from the unresolved set — 345 → 325 unresolved import sites:utilosfshttpspathhttpzlibNo module disappears from the list entirely, because every one of those builtins is also reached through other dependencies and through the
node:async_hookschain that this PR reports rather than fixes.Other gates, run locally on the exact commit pushed:
npm run build(exit 0),npx eslint core/src/utils/logger.ts core/test/utils/logger_test.ts(clean),npm run format:check(clean, repo-wide),npm run docs:check(exit 0),scripts/check_license.sh(clean),npx secretlint(clean).npm run ts:checkis not run by CI and is dirty onmainwith 281 pre-existingdist/types-vs-srcidentity errors in unrelated test files; that count is unchanged by this PR and zero of those errors are in either file touched here.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.