Fix: reject a non-object Reasoning Engine raw body instead of crashing the dev server - #621
Open
AmaadMartin wants to merge 2 commits into
Open
Fix: reject a non-object Reasoning Engine raw body instead of crashing the dev server#621AmaadMartin wants to merge 2 commits into
AmaadMartin wants to merge 2 commits into
Conversation
added 2 commits
August 3, 2026 23:12
POST /api/reasoning_engine reads the socket itself when express.json() declines the request. That path parsed the raw body and passed the result straight to executeQuery, which dereferences `body.input`. A four-byte payload of `null` therefore threw inside an async EventEmitter listener: an unhandled rejection, fatal since Node 15, with no response written, so the dev server died and the client hung until its socket timed out. Narrow the parsed value to a non-null object before using it and log the discarded body at warn level. Typing the local as `object` also removes an `any` and its no-explicit-any suppression.
The line above already logs the raw body unconditionally, so the warn in the else arm differed only in severity, and a rejected body is already observable through the 400 the caller receives.
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
No public issue is associated with this report, so no issue number is cited.
Problem:
POST /api/reasoning_enginecan be killed by a four-byte request body.The handler in
dev/src/server/adk_api_server.tshas two paths. Whenexpress.json()declines a request (for example the doubled
Content-Type: application/json,application/jsonthat Agent Engine sends, which the existing test at
dev/test/server/adk_api_server_test.ts:1125already exercises), the handler reads the socket itself and parses the body by hand:
JSON.parsewas guarded against invalid JSON but not against valid non-object JSON.JSON.parse('null')returnsnull, and the first statement ofexecuteQueryisconst input = body.input || {};, so the handler dereferencesnull. Three things followfrom that one line:
TypeError: Cannot read properties of null (reading 'input').asynclistener passed toreq.on('end', ...).EventEmitterdiscards the promise a listener returns, so nothing catches it — an unhandled rejection,
which has been fatal since Node 15. The dev server process exits.
res.*call and sits outsideexecuteQuery's owntry/catch, sono response is ever written and the client blocks until its socket times out.
No authentication, no session and no valid app name are needed, so this is an unauthenticated
remote denial of service against the dev server rather than an error-handling nicety.
Only
nullis fatal. Reading a property off a number, string or boolean returnsundefined,so
5,"hi",trueand[]already produce the correct 400 today; they are normalized bythis change but were never crashes. (The originating report claimed bare numbers and strings
also threw — that is not correct, and no test here asserts it.)
Solution: narrow the parsed value to a non-null object before it reaches
executeQuery.A non-object body now means "no usable payload" and falls through to the pre-existing
validation response: 400
{"error":"appName is required in input"}— the same answer theendpoint already gives for
5,"hi",trueand unparseable input. This is behavioral paritywith
adk-python's FastAPI equivalent, which rejects a non-conforming body with a 400 ratherthan dying; the two endpoints have different error payload shapes and that difference is out of
scope.
Why it is shaped this way:
handler, not a
try/catchbolted around the symptom.typeof null === 'object', so a guard that checks onlytypeofdoes not fix the bug — see the second mutation below, which is caught."Invalid request body"would change an observable contract for a case that already returns the
appNamemessage,and is not needed to fix the crash.
warn, but the lineabove already logs
Received Reasoning Engine raw body: ${rawBody}unconditionally, so the onlydelta was severity — and the rejection is already observable through the 400. Dropped.
objectmakes the existing// eslint-disable-next-line @typescript-eslint/no-explicit-anyobsolete, so this is a netremoval of one
anyand one suppression. No suppression is added anywhere —git diff -U0over this branch matches zero
@ts-expect-error/@ts-ignore/eslint-disable/as anyadditions.
executeQuerykeeps its existinganyparameter. Retyping it means modelling the wholeReasoning Engine payload, which is a different change from a crash fix.
executeQuery. With the narrowing in place there is no reachablethrow left outside
executeQuery's owntry/catch— the property reads cannot throw on anon-null object (a truthy non-object
body.inputsuch as{"input": 5}yieldsundefinedonfurther reads), and everything past the
appNameguard is already inside atrythat responds 500. A catch-all would be unreachable, and therefore untestable code.Deliberately not included: consolidating
/run,/run_sseand/api/reasoning_engineinto ashared orchestration helper, and introducing a validated request model for the payload. Both are
defensible refactors, and both rewrite the exact lines this fix touches, which would bury a
five-line crash fix in a large diff.
Collision check (
gh pr list --repo AmaadMartin/adk-js --state open --limit 1000, all 379open PRs scanned, then
gh pr diff --name-onlyon every plausibly adjacent one):overlaps functionally. It rewrites the same block to call a new
parseReasoningEngineQuery()whose first line isisRecord(rawBody) ? rawBody : {}, which alsoremoves this crash as a side effect. I extracted that module from the PR head and ran it on Node
v22.22.2: for
null,"hello",5,trueand[]it returnsappName === undefinedwithoutthrowing, i.e. the same 400. It is not stacked on here for two reasons: it is part 3 of an
unmerged three-PR stack (Fix: type the searchFlights result without any (Part 1/3) #230 → Fix: omit conformance event fields by destructuring instead of any (Part 2/3) #231 → Fix: narrow the reasoning engine request body instead of typing it any (Part 3/3) #232, each based on the previous rather than on
main),so the crash fix only reaches
mainif all three land; and it expands scope with a new moduleand a new mandatory-
newMessage400. This PR is the standalone minimum that can land on its own.If Fix: narrow the reasoning engine request body instead of typing it any (Part 3/3) #232's stack lands first, this PR should be closed as redundant.
req.on('data', ...)lines in the same block but leaves the null dereference intact. Adjacent,not overlapping; the two diffs do not touch a common line.
AdkApiServer.stop()socket teardown) are unrelated despite matching on name.
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.
Two new cases in the existing
describe('Reasoning Engine', ...)block ofdev/test/server/adk_api_server_test.ts. No existing test was modified, removed, skipped orweakened — the five original Reasoning Engine tests are the regression signal that behavior for
well-formed bodies is unchanged, and all five still pass untouched.
Both use raw
fetchrather than the file'sHttpClienthelper, which cannot express theserequests: it hardcodes
Content-Type: application/json(routing to the already-parsed path) andits
body ? JSON.stringify(body) : undefinedternary drops a falsy payload, so it physicallycannot send the literal
null.should return 400 when the raw body is JSON null— the crash regression test.should return 400 when the raw body is a JSON primitive("hello") — required for branchcoverage: without it the
typeof parsed === 'object'operand never evaluates tofalse.Together with the pre-existing raw-body test (a well-formed object), all three states of the new
branch are exercised:
typeoftrue +!== nulltrue → assign;typeoftrue +!== nullfalse →skip;
typeoffalse → skip.Proof the tests can fail (mutation testing). Both mutations were run one at a time, with the
source restored from a pristine copy in between.
Mutation 1 — revert the fix hunk to the pre-fix
body = JSON.parse(rawBody);. Test 1 failsloudly:
The
fetchnever resolves (no response is written), so the teardown hook times out as well —the same two symptoms a real client sees.
Mutation 2 — keep the guard but drop the null operand (
if (typeof parsed === 'object')),which is the most plausible way to get this fix wrong since
typeof null === 'object'. Test 1fails identically:
Honest note on test 2: it is a coverage test, not a regression test.
"hello"does not throwon the unfixed code — it reaches the 400 by accident — so test 2 still passes under both
mutations. It is included because dropping it leaves the
typeofoperand's false arm unexecuted,and it pins the 400 contract for primitives against future refactors of this block.
Coverage. Scoped run over the changed file:
Every line and branch added by this change is covered. The only uncovered lines inside the block
are
874-875, the pre-existingJSON.parsesyntax-errorcatcharm, which this diff does nottouch and which was equally uncovered before the change.
Repo gates run locally on the pushed commit:
npm run build— pass.npm run lint— pass, clean.npm run format:check— "All matched files use Prettier code style!".npm run ts:check— 281 errors, all pre-existing incore/test, zero indev/and zerodelta introduced by this branch (
281on the base commit,281with the change; verified bystashing).
ts:checkis not a step in.github/workflows/validation.yaml.Manual End-to-End (E2E) Tests:
Please provide instructions on how to manually test your changes, including any necessary setup or configuration.
npm run buildat the repo root.Start the dev server against any agents directory (
adk api_server <agents_dir>).Send the four-byte payload that used to kill it:
Expect
HTTP/1.1 400 Bad Requestand{"error":"appName is required in input"}. Before thischange the request returns nothing at all and the server process exits.
Immediately re-issue a valid Reasoning Engine query and confirm it returns 200. This is the
half of the bug the status code alone does not demonstrate: the process survived.
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.
CI note. The first
run-tests (windows-latest)attempt failed on three unrelated timeouts —tests/e2e/tools/mcp/load_mcp_resource_e2e_test.ts, two cases intests/integration/app_loader/app_loader_test.ts, andtests/integration/build_setup/build_setup_test.ts.None of them exercise the dev HTTP server, and the unmodified base commit
b390217efails onwindows in the same way (on
tests/integration/adk_web/webui_test.ts). Re-running the job passed:run-tests, ubuntu-latest, macos-latest and windows-latest are all green on this commit.
Revision after complexity review. The
else { this.logger.warn(...) }arm was removed: line865 already logs the raw body unconditionally, so the warn added only severity, and the discarded
body is observable through the 400. The guard is now four lines shorter. Both mutations were
re-run against the shrunk code and still fail (
Unhandled Rejection TypeError: Cannot read properties of null (reading 'input')+Hook timed out in 10000ms), all 53 tests still pass, andthe new lines remain fully covered. The reviewer's optional braceless-
ifcollapse was not taken:every
ifin this 1060-line file uses braces, and nocurlyrule is configured, so the bracesmatch local style.