Uh oh!
There was an error while loading. Please reload this page.
fix(cli): ready banner prints the reachable origin, not the bound port - #10819
Conversation
#10646) The banner composed `http://localhost:<port>` from the port the process binds. That stops being the address a human can open the moment anything sits in front of it. On the EE 4.1.0 compose stack the app container `expose`s :3000 with no `ports:` mapping while Caddy publishes :80 and `OS_AUTH_URL` is already `http://localhost` — so the Console link failed outright and the `MCP:` line, which customers paste into an AI client, named an address that can never connect and never says so. The origin now comes from `resolveAuthBaseUrl`, the runtime's own chain (`OS_AUTH_URL` -> legacy `BETTER_AUTH_URL` -> `OS_BASE_URL` -> `http://localhost:<port>`), whose `baseOrigin` is also what is pushed onto the CSRF allow-list — so the banner cannot disagree with the origin the deployment actually trusts. Nothing about what the server listens on, binds to, or advertises to a client changed; this is printed text only. When the chain yields nothing usable (a set-but-empty variable, or a value with no scheme) the banner prints the paths with no origin in front of them, and names `OS_AUTH_URL` as the fix. A missing address sends the operator to look one up; a confident wrong one gets copied. `ServerReadyOptions.port` is replaced by a required `externalBaseOrigin: string | null`, so the banner can no longer compose an address from a port and a caller that forgets to resolve one is a compile error rather than a plausible-looking line of output. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r
📓 Docs Drift CheckThis PR changes 1 package(s): 25 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 23 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 ef7b563c3ca590a237a2f50e6c2c41174f6a6d31 && git checkout ef7b563c3ca590a237a2f50e6c2c41174f6a6d31
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5f2e54cc66330cbc53a17f6e3746acdfcdc14704 90f1a89d3fa40d4c065ea932b95649fe4cf2c05b && git checkout -B drift-repro 5f2e54cc66330cbc53a17f6e3746acdfcdc14704 && git merge --no-ff 90f1a89d3fa40d4c065ea932b95649fe4cf2c05b
node scripts/docs-audit/affected-docs.mjs --json 5f2e54cc66330cbc53a17f6e3746acdfcdc14704
|
Uh oh!
There was an error while loading. Please reload this page.
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 32492931366 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Fixes#10646
The
os serve/os devready banner built its API, Console and MCP links fromhttp://localhost:PORT, composed inside the banner from the port the process binds. That address stops being the one a human can open the moment anything sits in front of it.(Throughout this body,
PORTstands for the placeholder the code spells with angle brackets — GitHub's sanitizer strips that spelling even inside code fences, so it is written as a bare word here.)Measured (EE 4.1.0 published-image compose stack, moved from cloud#1507)
The app container
exposes:3000with noports:mapping — unreachable from the host, and less so still under--scale app=N— while the published entry point is Caddy on:80, and compose has already resolvedOS_AUTH_URLtohttp://localhost. The banner printed the container-internal address anyway:Following the Console link failed outright; after moving the deployment to a domain the banner still said
localhost:3000; and theMCP:line is the address customers paste into an AI client, where a wrong absolute URL never fails loudly — it just never connects.What changed
Only printed text. Nothing about what the server listens on, binds to, or advertises to a client is touched — no
listen, no port, no host binding, no value any client resolves.resolveAuthBaseUrlreadsprocess.envand the bound port and returns a string.The origin is the runtime's own answer, not a second one. The banner resolves it through
resolveAuthBaseUrl— the same function whosebaseOriginis pushed onto the CSRF allow-list a few hundred lines earlier in the same boot — so the banner and the origin the deployment actually trusts cannot drift apart.I re-derived that chain from the runtime rather than taking the ordering from the card, and it has four links, not three:
The legacy name sits in the middle (
AUTH_BASE_URL_ENV_NAMESinserve.ts, andreadEnvWithDeprecationreads it) and is exactly what a restated-from-memory chain drops — which is one reason this reads the chain instead of restating it.When no origin can be determined, no absolute URL is printed at all. The chain yields nothing usable when a variable is set-but-empty (
OS_AUTH_URL=stops the chain rather than falling through —??skips onlynull/undefined) or carries no scheme. The banner then prints the paths bare:"No absolute URL" is deliberately not "no line": the operator still learns MCP is mounted and at which path, and supplies the origin they actually use. A missing address sends them to look one up; a confident wrong one gets copied.
http://localhost:3000was never a neutral default here — it was the wrong answer that shipped.The local dev loop is unchanged. With nothing set, the tail of the chain is still
http://localhost:PORTon the port actually bound (past any dev auto-shift 3000 => 3001), soos devkeeps its clickable Console link. There is a test for exactly this, because over-reach here would cost more than the bug.Structural half:
ServerReadyOptions.portis replaced by a requiredexternalBaseOrigin: string | null. The banner no longer knows the port, so it cannot compose an address from one — the old defect is now a compile error rather than a plausible-looking line of output, and a caller that forgets to resolve an origin cannot silently fall back to localhost. Pinned by a@ts-expect-error.Deliberately not touched, per the card: the deploy README's step 5 and the compose template are evidence about what the resolved base should be, not files this card edits.
Verification
Every command below ran at
01e6ea87a7, which is the final commit and the head of this branch — the gate union was run against that exact tree (working tree clean, nothing committed after it).pnpm --filter @objectstack/cli typecheck— clean (script name echoed in the output, so this was not a zero-match no-op).pnpm --filter @objectstack/cli test— 144 files, 1588 tests, all passing.node scripts/pm/dispatch-gates.mjs(no hand-built path list), all green:check:nul-bytes,check:changeset-gate-self-tests,check:objectui-changeset,check:route-envelope,check:slot-lookup,check:test-source-alias,check:type-source-resolution,check:cross-package-test-inputs,check:query-options-erasure,check:engine-double-contract,check:where-matcher,check:type-check-coverage,check:type-check-debt(the--re-measureratchet, run over a fully built closure —@objectstack/clicarries aTEST_DEBTentry, so new test files can genuinely move it; it reported "33 ledger entries re-measured, none above its recorded number").Reverse verification
Predicted direction before running: red, on the two new files only.
From the committed fix, the banner decision was reverted to the shipped defect (
const base = 'http://localhost:3000', ignoring the resolved origin):The 14 failures are exactly the two new files. The 3 passing files are the pre-existing banner tests (
format.tenancy,format.seed-summary,format.config-artifact-row) — they stayed green through the reintroduced defect, which is the measurement showing they were blind to this class and why the new coverage is not redundant.Restore leg:
git checkout HEAD -- packages/cli/src/utils/format.ts, marker confirmed absent from the file, re-run 39/39 passing.Both legs resolve through relative in-package specifiers (
./format.js,./serve.js,../utils/format.js), which vitest loads as TypeScript source — nodist/on either leg. That is measured, not assumed: the mutation turned red with no rebuild, which is only possible if the tests read source.Notes
content/docs/releases/**; the changeset is the release-notes input.Generated by Claude Code