Repository navigation
fix(hono): on a createHonoApp host a HEAD is answered as its GET, so HEAD /api/v1/mcp/skill no longer answers 405 Allow: GET - #24286
Conversation
…from The catch-all handed dispatch() the raw `HEAD`, which no dispatcher domain serves, so a HEAD was not answered as its GET on this host (`HEAD /api/v1/mcp/skill` answered 405 with `Allow: GET`). The standalone server hands dispatch() `GET` for the same request. Only the verb moves: the forwarded request is still the client's HEAD, so the /approvals/act domain now reads HEAD off that request. Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude <noreply@anthropic.com>
…p, against the standalone server; changeset Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude <noreply@anthropic.com>
…eal result shape; type the hosted door's request Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 27 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d57274c54ada0ac0d9e70b89895b7b10dfa15919 && git checkout d57274c54ada0ac0d9e70b89895b7b10dfa15919
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 93b4acb57ec23c4e94a7047133ce612f89a13838 4f14775b4ce225820bebffab1217d7e479594202 && git checkout -B drift-repro 93b4acb57ec23c4e94a7047133ce612f89a13838 && git merge --no-ff 4f14775b4ce225820bebffab1217d7e479594202
node scripts/docs-audit/affected-docs.mjs --json 93b4acb57ec23c4e94a7047133ce612f89a13838
|
席位核对ACCEPT ·
Generated by Claude Code |
Fixes #24245
Clause-②: no
Basis for the Clause-② line: the fix removes a refusal the published HTTP contract already rules out.
IHttpServer's unmatched-request contract names@objectstack/http-conformanceas its validator, and that suite pins "answers HEAD from GET routes without a body" (packages/qa/http-conformance/src/adapter.test.ts:144), as RFC 9110 §9.3.2 does. No export moves. Every request whose answer changes is aHEADon acreateHonoApphost, and it now gets itsGETanswer's status and headers with no body. NoGETanswer moved (measured, below).What changed
packages/adapters/hono/src/index.ts(the home): a module-privatedispatchedMethod(method)mapsHEADtoGETand leaves every other verb as sent.${prefix}/*catch-all handsdispatch()dispatchedMethod(method). Before, it handed the rawc.req.method, which readsHEADeven though Hono routed the request asGET./auth/configbranch comparesdispatchedMethod(method). AHEADthere was forwarded to better-auth and answered404, whileplugin-auth'sGETmount answers it on the standalone server. This is the same defect class in the same file, and it is fixed under the bounded in-place exemption (same class, mechanical, file in this claim's surface, same gate family).{ request: c.req.raw }is still the client'sHEADrequest, as the standalone mounts hand theirs. Hono drops the body itself.packages/runtime/src/domains/approvals.ts: the/approvals/actdomain decides "is this aHEAD" from the forwarded request's own method (request?.method === 'HEAD'), not from the dispatcher's verb. This edit is required by the change above. Without it, the hostedHEADreached the approvals member as the rawHEADand answered405withAllow: GET, POST. Measured:hono-approvals-act.conformance.test.ts's HEAD pin went red with only the catch-all change, because the hosted door answered405where the self-hosted door answered200. The docblocks that said the two methods are always equal now say where they differ.@objectstack/honopatch,@objectstack/runtimepatch).Where the fix lives, and why (measured)
Measured in process on base
53ef75243, with aLiteKernel, fakeauth/mcp/analytics/protocolslots, and both doors:createHonoAppdriven throughapp.request, withHttpDispatcher.prototype.dispatchwrapped to record the verb it received and its answer;HonoServerPluginon a real socket withcreateDispatcherPlugin.The run covered 34 paths ×
GET/HEAD× anonymous/signed in.handleMcpSkillRequestanswers405+Allow: GETfor every method that is notGET(packages/runtime/src/domains/mcp.ts, themethod !== 'GET'branch).dispatch()receivedHEADon everyHEADrow.HEADrow exactly as itsGETrow. Its transport routes aHEADto theGETmount, and the mount handsdispatch()a literal'GET'.hono4.13.12hono-basere-dispatches aHEADasGETand wraps the answer innew Response(null, …). EveryHEADanswer throughapp.requesthad an empty body, at base and at head.Why the catch-all and not the
mcpdomain. The shape is not themcpdomain's. The dispatcher decides a verb by comparing it with'GET'in many places: a domain'smethodsfilter (/health,/ready), a domain's branches (m === 'GET'), the discovery root, and the share-link floor's public-GETexemption. Through the hosted door, 13 of 68HEADrows answered differently from theirGET, across 7 route families (table below). A domain-levelHEAD-as-GETwould close one of them and leave a dialect to repeat in every other domain. The catch-all is the one producer of the rawHEADon this host. Mapping it there gives both compositions the samedispatch()input: the verbGET, beside a request whose method isHEAD.The PM's two checks:
GETkeeps itsGETanswer forHEAD. For/keysand/actions/task/close, signed in, that is405+Allow: POST; anonymous,401 UNAUTHENTICATED. These answers are unchanged from base, on both doors. AHEADthere runs only the domain's refusal. NoGETis served on such a path.HEADrow gets the anonymousGETanswer. At base one row did not: the publicGET /share-links/:token/resolveanswered501(no service in this boot), and itsHEADanswered401.Side effects of
GETdispatch. A domain that forwards the request to a member still hands over the client'sHEAD. The/mcptransport was handedHEADon both doors, before and after. The/approvals/actmember is handed aGETbuilt by the domain, as before. A domain keyed on the dispatcher's verb now runs itsGETbranch for aHEAD, which is what the standalone server already does for the same request.HEADon the hosted door, base → head (noGETrow moved; standalone unchanged)GETHEADat baseHEADat head/mcp/skill200405,Allow: GET200/health,/ready200404 ROUTE_NOT_FOUND200/environments/env_m(scoped root)200404 ROUTE_NOT_FOUND200/analytics/meta200404(domainhandled: false)200/ai/agents200501200/data/account,/data/account/r1503(no data service)404(domainhandled: false)503/share-links/tok/resolve501(public)401501/auth/config200404200(unit pin)Other
GET-serving domains with the same shapeThe card asks for these by name. All of them reach
dispatch()through the same catch-all, so this change closes them too:/health,/ready(methods: ['GET']); the discovery root under/environments/:id;analytics(/analytics/meta);ai(/ai/agents);data(the reads);share-links(the public resolve floor); and the auth arm's/auth/config.=== 'GET':i18n(m !== 'GET'is not handled);automation(itsGETfaces);notifications;packages(itsGETfaces);security(suggested-bindings);share-links(its otherGETfaces);meta(the!method || method === 'GET'reads).Tests
packages/adapters/hono/src/hono-head-dispatched-as-get.test.ts(4 cases; the dispatcher is this package's stub):HEAD /mcp/skillreachesdispatch()asGETbeside the client'sHEADrequest, and its answer is theGETanswer's status and headers with an empty body. The fixture is the skill domain's real result shape.GET(405,Allow: POST,METHOD_NOT_ALLOWED,httpStatus405) gets the same status and headers onHEAD, with no body.GET POST PUT PATCH DELETE OPTIONS) reachesdispatch()as sent.HEAD /auth/configgets the public config's status and headers with no body, and better-auth is not asked.packages/qa/http-conformance/src/hono-head-as-get.conformance.test.ts(24 cases). It uses the realHttpDispatcherand the realcreateHonoApppipeline (app.request), beside the standalone server:GETstatus, pluscodeandhttpStatusfor a refusedGET;HEADstatus and headers equal to theGET's; emptyHEADbody; and theAllowvalue (POSTon the two405rows, absent otherwise).HEADstatus andAllowequal the standaloneHEAD's.HEAD /mcp/skillcarries the skill'scontent-type,content-dispositionandcache-controlon both doors./mcptransport is handedHEADon both doors.packages/runtime/src/domains/approvals-act.test.ts: new case. AHEADrequest dispatched asGETis still forwarded to the member as aGET, and is answered with no body.packages/runtime/src/mcp-skill-method-not-allowed.hono.integration.test.tsactions-every-verb.hono.integration.test.tskeys-every-verb.hono.integration.test.tsnotifications-every-verb.hono.integration.test.tsdispatcher-body-after-anonymous-floor.hono.integration.test.tsandhttp-dispatcher.mcp.test.ts(7 files, 291 tests).hono-approvals-act.conformance.test.ts(itsHEADpin holds both doors equal),hono-webhooks-redeliver.conformance.test.ts(HEADstays404) andhono-meta-item-read-gate.conformance.test.ts.@objectstack/honotest: 10 files, 211 tests, exit 0.typecheck: exit 0. Both at379b734a2.@objectstack/http-conformancetest: 16 files, 214 tests, exit 0.typecheck: exit 0. Both at379b734a2.@objectstack/runtimetest: 416 files, 6548 passed and 19 skipped, exit 0.typecheck: exit 0. Both at0a5693634; no runtime file changed after that commit.origin/mainat4f14775b4brought onlyscripts/pm/dispatch-gates*.One-time ablations (fix committed first,
scripts/ablation-replace.mjs, wrap mode, restore proven: blob equalsHEADandgit diff HEADis empty)dispatchedMethod. Mutatedreturn method === 'HEAD' ? 'GET' : method;toreturn method;. Anchor count 1 → 0; blob4f962dbb6cfd→aa3f8f87edab. The subject resolves from source in both suites (the hono package itself, and the http-conformance alias of@objectstack/honotosrc), so no build leg.GETpaths, anonymous-floor rows that matched at base, and the/mcpverb row.4f962dbb6cfd.if (request?.method === 'HEAD') {toif (method === 'HEAD') {. Blob67a8a4b44bf3→ab7e441de94c.approvals-act.test.ts: 1 failed, 12 passed (expected 'HEAD' to be 'GET').hono-approvals-act'sHEADpin was red, with the hosted door at405andAllow: GET, POST. The rebuiltdistthen went green.Gates
The gate list is derived by
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(no paths) at the final head4f14775b4. It gives 66 commands, the same list as at0a5693634and at379b734a2.4f14775b4: all exit 0. Each exit code was captured before any pipe. These include:pnpm check:dispatcher-error-vocabularypnpm check:error-status-conformance("every derivable runtime status is documented, and every documented status is reachable")pnpm check:route-envelopepnpm check:nul-bytes(OK, no raw control bytes)pnpm check:changeset-gate-self-testsnode scripts/check-changeset-no-major.mjs --base origin/mainpnpm check:cross-package-test-inputspnpm check:test-source-aliaspnpm check:type-check-coverageandpnpm check:type-check-debtpnpm check:dual-build-cjs-loads--ranreconciliation:66 derived famil(ies) accounted for, 66 run, 0 NOT-MEASURED (a DERIVED zero, all 66 recorded an exit code and none of them is 3).0a5693634: two commands exited 3 as prerequisite refusals, not measured failures. Both were re-run:check-plugin-teardown-shape --self-testneeded its pinned fixture commit, which was fetched.check:dual-build-cjs-loadsneeded every workspacedist.pnpm exec eslint --no-inline-configover the five changed source and test files, exit 0, no output. The repo-widepnpm lintis CI's.@objectstack/runtime'stest:repoproject (CI'sTest Core). The diff edits no test in that project.Acceptance notes
createHonoApphost,GET /api/v1/mcp/skillserves the skill as an SSE frame of a JSON string: the body isdata: "# skill"followed by a blank line, not the markdown. Thecontent-typeis doubled (text/markdown; charset=utf-8, text/markdown; charset=utf-8) andcache-controlreadsno-cache, no-store. The cause istoResponse'seventsstream arm inpackages/adapters/hono/src/index.ts: it SSE-frames every event unlessvercelDataStreamis set, and it merges its own capitalised headers with the domain's lowercase ones.packages/runtime/src/domains/mcp.tssays string events are "written verbatim" on every adapter. The standalone server serves the raw markdown withcache-control: no-store(PR fix(runtime): on the standalone server every /actions verb reaches the actions domain, so an anonymous caller gets 401, and no Allow lists a verb mounted only to refuse #24244's pin). This change does not touch it, and the new pins assert onlytoContainon those two headers and a non-emptyGETbody, so nothing pins the defect.dispatcher.handleAuth(path, method, …)still passes the raw verb. The auth domain does not read it (_method), so no answer differs, and it is unchanged.HEADon aGETwhose answer is aneventsstream still runs the stream's producer; Hono discards the body. The standalone server does the same for the same request, since it runs theGEThandler too. Noted, not changed.Generated by Claude Code