Skip to content

Fix: replace winston with a console logger in core to unbreak the web build - #656

Closed
AmaadMartin wants to merge 8 commits into
mainfrom
fix/core-drop-winston-console-logger
Closed

Fix: replace winston with a console logger in core to unbreak the web build#656
AmaadMartin wants to merge 8 commits into
mainfrom
fix/core-drop-winston-console-logger

Conversation

@AmaadMartin

@AmaadMartin AmaadMartin commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Please ensure you have read the contribution guide before creating a pull request.

Link to Issue or Description of Change

  1. Link to an existing issue (if applicable):
    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 winston half; the node:async_hooks half stays open — see below)
    Related: Tracking: @google/adk cannot be bundled for the browser (5 build-level defects) google/adk-js#607
  2. Or, if no issue exists, describe the change:
    Problem: @google/adk advertises a browser build ("browser": "./dist/web/index_web.js"), but that artifact ships a literal import * as winston from 'winston'. core/build.js is a transpile-only esbuild pass (bundle: false, packages: 'external'), so module specifiers are copied through verbatim into core/dist/web/utils/logger.js, and winston transitively drags in os, fs, util, zlib and http. Any bundler targeting the browser fails to resolve them.

A build-layer alias is not available: core/build.js:53 gates the existing node:async_hooks alias behind platform === 'browser' && bundle, and that gate is load-bearing — esbuild rejects alias outright without bundle (✘ [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. SimpleLogger only used winston for two things: a printf template, and a "level filter" that was already a pass-through (the real filter is the instance field private logLevel, and every method early-returned before touching winston). Rewriting it on console removes 49 lines and adds 29 in logger.ts, and lets winston leave core's dependencies entirely.

core/src/utils/logger.ts now imports nothing at all — no winston, no node:* builtin, no process reference. The four level methods collapse into a single log(), which builds the line inline and dispatches through a small CONSOLE_METHOD lookup. The console method is resolved at call time (never captured into a module-level constant), so vi.spyOn(console, ...) still intercepts it. That lookup replaced an exhaustive four-case switch and is strictly stricter than it: the switch had no default branch and so accepted a newly added LogLevel silently, whereas indexing the lookup with an uncovered member is a compile error — verified by temporarily adding a fifth member:

core/src/utils/logger.ts(60,13): error TS7053: Element implicitly has an 'any' type because expression of type 'LogLevel'
  can't be used to index type '{ readonly 0: "debug"; readonly 1: "info"; readonly 2: "warn"; readonly 3: "error"; }'.
  Property '[LogLevel.FATAL]' does not exist on type '{ readonly 0: "debug"; ... }'.

Nothing is added to common.ts or index.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 touch core/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 in core and generalises it into a shared WinstonLogger, which is the opposite of the approved Option A, and #431 only adds an accessor. This PR is therefore branched from main rather 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 touches core/package.json's winston entry.

Behaviour differences (all four intentional, per the plan's breaking-change analysis):

  1. winston is 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/adk must now declare it. @google/adk-devtools declares it itself, so it is unaffected.
  2. ANSI colour codes are gone. winston wrapped the level token in \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 the dev CLI keeps its own colourised winston logger for terminal output. The line's text layout is unchanged: LEVEL: [ADK] <ISO-8601> <message>, with the same new Date().toISOString() timestamp winston's default format.timestamp() produced.
  3. warn and error now go to stderr. winston's Console transport put all four levels on stdout (its stderrLevels set is empty for these custom levels). console.warn/console.error write to stderr in Node and light up the corresponding devtools level in a browser.
  4. 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: 1 and logform's colorizer then threw TypeError: colors[Colorizer.allColors[lookup]] is not a function. No existing test covered that path. Collapsing the four methods into one log() fixes it incidentally, and there is now a regression test for it.

Not changed: message text, level-filter semantics (this.logLevel > level), the LogLevel.INFO default, the SimpleLogger/NoOpLogger class names (existing tests assert constructor.name), the public export surface, and all 41 core/src call 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 to core/build.js, index_web.ts, common.ts, index.ts, or anything under dev/. No suppressions of any kind were added — no any, @ts-expect-error, eslint-disable or coverage pragma appears in this diff (eslint.config.js has no no-console rule, so console.* 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:

$ grep -rn "winston" core/dist/web            # BEFORE: 9 hits
core/dist/web/utils/logger.js:8:import * as winston from "winston";
core/dist/web/utils/logger.js:19:    this.logger = winston.createLogger({
... (7 more)

$ grep -rn "winston" core/dist/web            # AFTER: no output
$ grep -rn "node:async_hooks" core/dist/web   # AFTER: still exactly 1 hit
core/dist/web/utils/client_labels.js:8:import { AsyncLocalStorage } from "node:async_hooks";

It is pulled in by core/src/utils/client_labels.ts:7, which is reachable from the web entry point via index_web.tsexport * from './common.js'core/src/common.ts:282 (export {getClientLabels, runWithClientLabel} from './utils/client_labels.js'). core/src/utils/async_hooks_shim.ts exists and is correct but is only wired through buildOptions.alias, which esbuild refuses without bundle, 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.json change is the single "winston": "^3.19.0", line inside the "core" workspace's dependencies block (1 file changed, 0 insertions(+), 1 deletion(-)). The "dev" workspace block and the node_modules/winston entry are untouched, because dev still depends on winston. npm ci was used (never npm install) and passes, which is itself the proof the hand-edit is correct and complete; grep -c us-npm.pkg.dev package-lock.json0, and prettier still 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 existing core/test/utils/logger_test.ts. Nothing in the existing describe('setLogger', ...) block was edited, weakened, skipped or deleted — it passes unchanged.

$ npx vitest run --project unit:core core/test/utils/logger_test.ts
 Test Files  1 passed (1)
      Tests  15 passed (15)          # 8 pre-existing + 7 new

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 deprecated logger const wrapper at the bottom of the file, which this change does not touch. All new code — every branch of SimpleLogger — 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 console at 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:

 × SimpleLogger > emits a message at the configured level
   → expected "info" to be called 1 times, but got 0 times
 × SimpleLogger > suppresses a message below the configured level
   → expected "warn" to be called 1 times, but got 0 times
 × SimpleLogger > defaults to INFO
   → expected "info" to be called 1 times, but got 0 times
 × SimpleLogger > routes each level to its matching console method
   → expected "debug" to be called 1 times, but got 0 times
 × SimpleLogger > joins arguments with a single space
   → expected "info" to be called with arguments: [ StringMatching{…} ]
 × SimpleLogger > log() emits without throwing
   → expected [Function] to not throw an error but 'TypeError: colors[Colorizer.allColors…' was thrown
     TypeError: colors[Colorizer.allColors[lookup]] is not a function
 × SimpleLogger > formats the full line for a warning
   → expected "warn" to be called with arguments: [ StringMatching{…} ]
 Tests  7 failed | 8 passed (15)

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):

Mutation Result
Delete if (this.logLevel > level) { return; } from SimpleLogger.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 times2 failed | 13 passed
private logLevel: LogLevel = LogLevel.DEBUG × defaults to INFO → expected "debug" to not be called at all, but actually been called 1 times1 failed | 14 passed
messages.join(',') instead of join(' ') × joins arguments with a single space → expected "info" to be called with arguments: [ StringMatching{…} ]1 failed | 14 passed
Point [LogLevel.WARN] at 'info' in the CONSOLE_METHOD lookup × 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
Drop the [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 warning4 failed | 11 passed

Collateral check. The default level is INFO, so core's logger output now flows through console.info/warn/error in-process. The four dev test files that spy on those methods were run to confirm the extra calls break nothing:

$ npx vitest run --project unit:dev dev/test/cli/cli_deploy_cloud_run_test.ts \
    dev/test/cli/cli_deploy_agent_engine_test.ts dev/test/utils/agent_loader_test.ts \
    dev/test/server/adk_api_server_test.ts
 Test Files  4 passed (4)
      Tests  116 passed (116)

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:

npm ci
cd core && npm run build     # note: no --bundle; this is exactly what ships
grep -rn "winston" dist/web            # BEFORE: 9 hits   AFTER: no output
grep -rn "node:async_hooks" dist/web   # BEFORE: 1 hit    AFTER: 1 hit (unchanged, see above)

The issue's bundler repro was also run against the built web entry point. Locally winston itself still resolves (the dev workspace keeps it in node_modules, so esbuild walks into it rather than reporting Could 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:

npx esbuild core/dist/web/index_web.js --bundle --platform=browser \
  --outfile=/dev/null --log-limit=0 2>&1 | grep "Could not resolve"
module before after
util 20 12
os 14 8
fs 25 23
https 14 13
path 13 12
http 7 6
zlib 3 2

No module disappears from the list entirely, because every one of those builtins is also reached through other dependencies and through the node:async_hooks chain 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:check is not run by CI and is dirty on main with 281 pre-existing dist/types-vs-src identity 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.

adilburaksen and others added 8 commits July 31, 2026 16:33
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
AmaadMartin force-pushed the fix/core-drop-winston-console-logger branch from 8ef347f to 188f2ae Compare August 4, 2026 21:41
@AmaadMartin

Copy link
Copy Markdown
Owner Author

Closing: Option A was rejected upstream.

This was ported to google#617, which the maintainer (kalenkevich) closed 13 minutes later with:

No, we need to keep winston! Please close this pr

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 setLogger seam), which keeps winston on Node as requested.

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.

@AmaadMartin AmaadMartin closed this Aug 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants