Skip to content

fix(cli): ready banner prints the reachable origin, not the bound port - #10819

Merged
os-elon merged 2 commits into
mainfrom
claude/issue-10646-banner-external-base-url
Aug 21, 2026
Merged

fix(cli): ready banner prints the reachable origin, not the bound port#10819
os-elon merged 2 commits into
mainfrom
claude/issue-10646-banner-external-base-url

Conversation

@os-elon

@os-elonos-elon commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Fixes#10646

The os serve / os dev ready banner built its API, Console and MCP links from http://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, PORT stands 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 :3000 with no ports: 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 resolved OS_AUTH_URL to http://localhost. The banner printed the container-internal address anyway:

 API: http://localhost:3000/
Console: http://localhost:3000/_console/
MCP: http://localhost:3000/api/v1/mcp
connect an AI client (Claude Code, Cursor, ...) - skill: http://localhost:3000/api/v1/mcp/skill

Following the Console link failed outright; after moving the deployment to a domain the banner still said localhost:3000; and the MCP: 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. resolveAuthBaseUrl reads process.env and 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 whose baseOrigin is 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:

OS_AUTH_URL => BETTER_AUTH_URL (legacy) => OS_BASE_URL => http://localhost:PORT

The legacy name sits in the middle (AUTH_BASE_URL_ENV_NAMES in serve.ts, and readEnvWithDeprecation reads 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 only null/undefined) or carries no scheme. The banner then prints the paths bare:

 API: /
Console: /_console/
MCP: /api/v1/mcp
connect an AI client (Claude Code, Cursor, ...) - skill: /api/v1/mcp/skill
paths only — this deployment's external base URL could not be resolved;
set OS_AUTH_URL to its public origin (e.g. https://app.example.com)

"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:3000 was 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:PORT on the port actually bound (past any dev auto-shift 3000 => 3001), so os dev keeps its clickable Console link. There is a test for exactly this, because over-reach here would cost more than the bug.

Structural half:ServerReadyOptions.port is replaced by a required externalBaseOrigin: 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 test144 files, 1588 tests, all passing.
  • Gate families derived from the real change set with 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-measure ratchet, run over a fully built closure — @objectstack/cli carries a TEST_DEBT entry, 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):

Test Files 2 failed | 3 passed (5)
Tests 14 failed | 25 passed (39)

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 — no dist/ 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

  • No gate was weakened: no threshold moved, no baseline raised, nothing skipped, quarantined or retry-wrapped.
  • Nothing under content/docs/releases/**; the changeset is the release-notes input.

Generated by Claude Code

#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
@github-actions

github-actionsBot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 6 documentable anchor(s).

25 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 5f2e54cc66330cbc53a17f6e3746acdfcdc14704.

5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 45 of 221 client-bound route-ledger rows — the other 176 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 5f2e54cc66330cbc53a17f6e3746acdfcdc14704packageMentionDocs.

Which tree this was computed on

This run read content/docs from ef7b563c3ca590a237a2f50e6c2c41174f6a6d31 — the merge of head 90f1a89d3fa40d4c065ea932b95649fe4cf2c05b into base 5f2e54cc66330cbc53a17f6e3746acdfcdc14704, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 5f2e54cc66330cbc53a17f6e3746acdfcdc14704 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-elon
os-elon marked this pull request as ready for review August 21, 2026 14:34
@os-elon
os-elon added this pull request to the merge queueAug 21, 2026
Merged via the queue into main with commit 22f6629Aug 21, 2026
32 checks passed
@os-elon
os-elon deleted the claude/issue-10646-banner-external-base-url branch August 21, 2026 14:50
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 32492931366 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Console Pin Gate — 失败步骤: Build the Console SPA at the pinned objectui SHA

    ✗ Build failed in 5.90s
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

跨 PR 相同签名(24h,按失败测试文件聚合):

  • ⚠️本次没有可用的聚合签名(日志里没有能解析出测试文件名的 FAIL 行)—— 这不是「没有同签名的其他 PR」,是这一轮没测到。跨 PR 聚合本次不可用,请手工比对其他 PR 的同类评论。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 18 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

cli: server-ready banner prints container-internal http://localhost:<port> URLs, ignoring resolved OS_AUTH_URL / OS_BASE_URL

2 participants

@os-elon@claude