Skip to content

fix(cli): build \os dev\'s MCP connect hint from the resolved origin, not the listen socket - #16812

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-16734-mcp-connect-hint-origin
Sep 8, 2026
Merged

os-project-manager merged 2 commits into
mainfrom
claude/issue-16734-mcp-connect-hint-origin

Conversation

@claude

@claude claude Bot commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #16734

Clause-②: no

os dev printed two MCP addresses in one boot output, from two origin resolvers, and only one of them knew the canonical origin. The ready banner's ➜ MCP: row (printServerReady, packages/cli/src/utils/format.ts) goes through resolveAuthBaseUrl — OS_AUTH_URL → legacy BETTER_AUTH_URL → OS_BASE_URL → the http://localhost:PORT tail. The 🤖 MCP server — connect a coding agent block in packages/cli/src/commands/dev.ts built its base from the serve child's objectstack:listening url, which is the socket the child BOUND, by construction. OS_AUTH_URL never entered that expression.

That block's Connect line is a command the reader pastes, so the wrong origin is not cosmetic: claude mcp add registers an entry against an address discovery never advertises and, behind a TLS proxy, nothing can reach — and the two rows disagreeing made the correct one look like the typo.

What changed

  • packages/cli/src/commands/dev.ts — the block moves into an exported printMcpConnectHint({ boundPort, name }) that resolves its origin through resolveAuthBaseUrl, the same function serve's banner call site uses, with the port the child ACTUALLY bound. The precedence chain is not restated anywhere in dev.
  • The call site inside the objectstack:listening handler now passes boundPort: actual and nothing else; the base expression built from msg.url is gone.
  • When the chain yields no parseable origin (baseOrigin === null — a set-but-empty OS_AUTH_URL=, or a value with no scheme) the block is omitted. The banner's documented rule for that case is "print the paths with no origin and name the variable that fixes it, never guess"; a claude mcp add line has no paths-only form, so the honest output is no block rather than reprinting, on the same screen, the exact address the banner just refused to print.
  • packages/cli/src/commands/dev-mcp-connect-hint-origin.test.ts — new, 11 cases.
  • .changeset/dev-mcp-connect-hint-origin.md — @objectstack/cli: patch.

packages/cli/src/utils/format.ts is untouched: it is the reference implementation, and nothing in it needed to change. The Endpoint / Skill / Connect wording is unchanged.

start.ts already carries the rule this PR applies to dev, in its own words: "⛔ Do not reintroduce an address here fed from the child's ipc objectstack:listening message". dev was the one command still doing it.

Anchors, re-derived on the current base

The card's line numbers were written against 5e53d73d; base here is dd2184ae25.

card found on dd2184ae25 rot
dev.ts:504-505 — the base expression dev.ts:500-501 4 lines
dev.ts:503-508 — the printed block dev.ts:503-508 0 lines
format.ts:816 — the ➜ MCP: row format.ts:816 0 lines

验收备注

1. With OS_AUTH_URL set, all three block lines and the ➜ MCP: row agree

Asserted on both rows of one captured boot output, never on the block alone: each case installs one buffer over console.error (the banner) and console.log (the hint), in call order, then drives both printers under one environment and one bound port. The card's own reason — "Two printers that disagree cannot be pinned by reading one of them."

The measured repro is a case verbatim: dev -p 4001 under OS_AUTH_URL=https://localhost:4443 asserts the banner row, Endpoint, Skill and Connect all carry https://localhost:4443, that localhost:4001 appears nowhere in the capture, and — independent of which rows were named — that the set of distinct MCP origins in the whole capture is exactly one. Two further cases carry the same assertion onto a public origin (with and without a port) and onto the rest of the chain (BETTER_AUTH_URL, then OS_BASE_URL).

2. With OS_AUTH_URL unset, the block still prints the LISTEN origin

This falls out of the resolver rather than from a second fallback: with none of the three variables set, resolveAuthBaseUrl's own built-in tail answers http://localhost:PORT for the port it is handed, and the hint is handed the port the child actually bound.

Three cases: an ordinary local boot on 3000; the auto-shifted port (3000 busy, bound 3001) which additionally asserts 3000 appears nowhere in the capture; and an ephemeral bound port (45064), the --port 0 shape.

That control is measured, not asserted — ablation leg 2 below is exactly the "reach for a canonical origin and ignore the bound port" fix, and these are the cases that catch it.

3. The precedence chain is reused, not reimplemented; the existing pins are unchanged and still green

resolveAuthBaseUrl and formatUnusableAuthBaseUrlDiagnostic are untouched — the diff adds an import of the first and changes no line of serve.ts. The set-but-empty behaviour keeps its existing owner; what this PR adds is a pin that both printers obey it (the banner prints paths only, the hint prints nothing, and never-consulted.example.com from a set OS_BASE_URL appears in neither).

packages/cli/src/commands/serve-auth-base-url-diagnostic.test.ts — 25 passed, unchanged file. Alongside it, serve-banner-external-base-url.test.ts 6 passed, serve-bound-port-publication.test.ts 21 passed, test/vitest-tiers-partition.test.ts 22 passed, and the new file 11 passed — 85 of 85 in one run at b466fc5d90.

Reverse verification — two legs, both restored byte-identical

Committed first, mutated on disk, proven on disk (occurrence counts for the removed text and the injected marker, plus git hash-object), run, then restored with git checkout HEAD -- and re-proven (git diff HEAD empty, blob equal to the HEAD blob). Both legs ran with a trap ... EXIT INT TERM restore.

leg mutation blob result
1 — the defect, restored origin taken from the listen socket again 8bb27f19db → a2980ca600 5 failed / 6 passed — the three OS_AUTH_URL-set cases and both unusable-value cases red; the three unset cases stayed green, as they must
2 — the port ignored resolveAuthBaseUrl(3000) instead of the bound port 8bb27f19db → 56d7bfd6e4 2 failed / 9 passed — only the shifted-port and ephemeral-port cases red; acceptance 1 stayed green

Restore both times: blob back to 8bb27f19db, git diff HEAD empty. Leg 1 shows the pin can see the reported defect; leg 2 shows acceptance 2 is a real control and not decoration. No permanent test file carries either mutation.

Resolution note: both printers and the test resolve ./dev.js / ./serve.js to source inside packages/cli (the unit project transforms TS directly), so neither leg is dist-mediated and no rebuild sits between the mutation and the reading.

Checks run

All at b466fc5d90 unless noted. Exit codes captured before any pipe.

  • pnpm --filter '@objectstack/cli^...' build — exit 0 (the dependency closure).
  • pnpm --filter @objectstack/cli exec vitest run --project unit — 186 files, 2555 passed + 6 expected-fail, exit 0. First attempt reported 2 suites failing to collect with packages/cli is not built (./dist/index.js is absent) — a stated prerequisite, not a finding; both green after pnpm --filter @objectstack/cli build and in the full re-run above.
  • pnpm --filter @objectstack/cli typecheck — exit 0 (tsc --noEmit plus check:test-typecheck, the test-layer debt ledger held).
  • pnpm lint (eslint . --no-inline-config, the whole repo, no narrowing) — exit 0, 1m22s.
  • Gate families derived from the real change set, not from a hand-written list: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, then reconciled with --ran: 57 derived, 57 run, 0 NOT-MEASURED, 0 UNRUN. 54 exit 0. The other three are PREREQUISITE NOT MET (exit 3, the gates' own distinct code) and say nothing in either direction — each needs a full workspace build, which is CI's run: check:dual-build-cjs-loads, check:i18n-coverage, check:type-check-debt. The first half of that last one, check:type-check-coverage, ran and passed.
  • The five gates the derivation flags as "roster under a directory one of your paths is in — the silence is not evidence in either direction" were run rather than read: check-changeset-fixed, check:authz-resolver, check:error-code-casing, check:filter-alias-parity, check:scaffold-emission-policy — all exit 0.
  • check:dts-closure needed a second look: exit 0 during the sweep, exit 1 after the unit tier had run, naming 7 packages with JS-only dist/ and no declarations — none of them in this diff, none of them downstream of it. Rebuilding exactly those 7 restored it to 63 packages swept, 155/155 declarations present, exit 0. Container tree state, not this change.
  • pnpm check:nul-bytes exit 0, plus a direct control-character scan over both edited files (grep -naP) — no hits.
  • Derivation staleness, declared: the tool warned STALE TREE against origin/main 78bc4ad587, naming .github/workflows/publish-smoke.yml and scripts/pm/check-skill-line-ratchet.mjs. Re-deriving after git fetch origin main produced a byte-identical command list. Neither can add a family for these paths — publish-smoke is workflow_run/schedule-driven with no PR path filter, and the skill ratchet's population is skills/**, which this diff does not touch.

Tier placement

Both tiers are derived by packages/cli/vitest-tiers.ts, and the new file lands in unit (it spawns nothing and boots no kernel). The integration tier's include list names no file this diff touches, so the integration tier is declared to CI. test/vitest-tiers-partition.test.ts was run and is green.

Docs drift advisory — answered, 0 pages edited

The bot's Docs Drift Check on this PR truncated its list at 15 rows, so it was re-derived locally rather than read off the comment:

node scripts/docs-audit/affected-docs.mjs --json 56ee7aef1d49d7583e0cb268dc6cbd1c34ee8fd0

34 docs, 4 anchors, 1 changed package. Provenance differs from the bot's on purpose and both readings are kept: the bot computed on merge commit 8652f200e599 and said outright that "That checkout carried uncommitted changes, so the commit above does not fully identify what was read"; this run reports head b466fc5d90ec, diffBase dd2184ae25, dirty: false, same sinceRef. The anchors are printMcpConnectHint (symbol), os dev (command), /api/v1/mcp and /api/v1/mcp/skill (routes).

The 4 release-owned pages — content/docs/releases/ v13, v14, v16, v17 — were not edited and are not this PR's to edit. Release notes are written centrally at release time; this PR's only input to them is its changeset. For the record, the two rows that actually describe this block (v16.mdx lines 75 and 468 — "the os dev banner prints the MCP endpoint, the agent-skill URL, and a ready-to-paste claude mcp add command") remain true after this change: all three lines still print, they simply carry the reachable origin now. Nothing to correct there even in a dedicated docs PR.

The other 30: verified unaffected, grouped by why. Every one of them was listed through a generic anchor, not through anything this diff altered:

  • listed via os dev (the command name, which many pages mention in passing) — no flag, no default, no output contract of os dev changed;
  • listed via /api/v1/mcp or /api/v1/mcp/skill — both are path literals this diff moves between two files and does not change; no route moved, no route was added or removed;
  • listed via printMcpConnectHint — a symbol introduced by this PR, so no page can be naming it.

30 rows is what @objectstack/cli plus the MCP route path always produces; the count is not evidence of breakage, and none of the 30 was edited.

Hand-read for the bot's declared blind spot — a page stating the rule by its INPUTS while the diff changed the EMITTER. This change does carry a rule (the connect hint is built from the resolved origin, not the listen socket), so content/ was grepped by hand for that rule's own vocabulary, whether or not the bot listed the page:

  • grep -rn "objectstack:listening|listen origin|bound socket" content/ — zero hits. No page anywhere states which origin the hint is built from, or mentions the listen socket or the two-row disagreement. There is no page for this rule to falsify.
  • content/docs/ai/connect-mcp.mdx and content/docs/getting-started/build-with-claude-code.mdx are the two pages that print a claude mcp add line: http://localhost:3000/api/v1/mcp labelled "local dev server" and https://your-deployment.example.com/api/v1/mcp labelled "a deployed instance". That is exactly what the fixed hint now emits in each case — the first is the resolver's unset tail, the second is what OS_AUTH_URL resolves to. The change moves the code toward these pages; before it, only the first form could ever be printed. Verified unaffected, unedited.
  • content/docs/deployment/cli.mdx:45 — "The boot banner also prints the app's MCP endpoint (/api/v1/mcp)". It names the PATH and no origin, and it is about the ready banner in format.ts, which this diff does not touch. Still true.
  • The one behaviour change that could falsify a promise is the block being omitted when no origin resolves. No page promises that block; the sentence above is about the banner's MCP: row, which is unconditional and unchanged.

Outcome: 0 of 34 pages needed a change, 0 were edited, and this stays a 3-file PR.

Out of scope, noted and not filed

  • ListeningMessage.url now has no reader in packages/cli/src: dev was its last consumer, and start declines it on purpose. The field is still published and still pinned by serve-bound-port-publication.test.ts, and the same url is written to the runtime state file where it is the right value. Dead-ish code, not a defect. Carrier: the next PR touching publishBoundPort / ListeningMessage.
  • No reproducible defect, contract violation, or metadata-authoring trap was found outside this card's scope, so nothing was filed.

🤖 Generated with Claude Code

https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8


Generated by Claude Code

… not the listen socket

`os dev` printed two MCP addresses in one boot output. The ready banner's
`MCP:` row goes through `resolveAuthBaseUrl` (`OS_AUTH_URL` -> legacy
`BETTER_AUTH_URL` -> `OS_BASE_URL` -> `http://localhost:<port>`); the
`MCP server - connect a coding agent` block built its base from the serve
child's `objectstack:listening` url, i.e. the socket the child bound. Under
`OS_AUTH_URL=https://localhost:4443` with `dev -p 4001` the two disagreed,
and the block's `Connect` line is a command the reader pastes: it registered
an MCP entry against an origin discovery never advertises and a TLS proxy
never exposes.

The hint now resolves through the same function `serve` calls, with the
port the child ACTUALLY bound - so the no-variable-set case still answers
`http://localhost:<boundPort>` (auto-shifted port included) from the
resolver's own tail rather than from a second fallback. When the chain
yields no parseable origin the block is omitted rather than reprinting the
address the banner just refused to print.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
… origin fix

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
@github-actions github-actions Bot added the size/m label Sep 8, 2026
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

30 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 56ee7aef1d49d7583e0cb268dc6cbd1c34ee8fd0.

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

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 22 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 56ee7aef1d49d7583e0cb268dc6cbd1c34ee8fd0 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 8652f200e599c4247aa1a160e3c43a7c27165fba — the merge of head b466fc5d90ec4621d2e5ced3bd9b004a92563389 into base 56ee7aef1d49d7583e0cb268dc6cbd1c34ee8fd0, 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 8652f200e599c4247aa1a160e3c43a7c27165fba && git checkout 8652f200e599c4247aa1a160e3c43a7c27165fba
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 56ee7aef1d49d7583e0cb268dc6cbd1c34ee8fd0 b466fc5d90ec4621d2e5ced3bd9b004a92563389 && git checkout -B drift-repro 56ee7aef1d49d7583e0cb268dc6cbd1c34ee8fd0 && git merge --no-ff b466fc5d90ec4621d2e5ced3bd9b004a92563389

node scripts/docs-audit/affected-docs.mjs --json 56ee7aef1d49d7583e0cb268dc6cbd1c34ee8fd0

⚠️ 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 56ee7aef1d49d7583e0cb268dc6cbd1c34ee8fd0 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 8, 2026

Copy link
Copy Markdown
Collaborator
VERDICT: ACCEPT
Implemented-by: `claude/issue-16734-mcp-connect-hint-origin`
Reviewed-by: `session_015QE8qk46e5CHJxyQEUjbf8`

Accepted by the domain:cli execution PM seat (#6024, R71). Re-measured at source — ⛔ a delivery report is not a reading.

Gate ① — CI

36 raw check runs → 33 after latest-per-name collapse: 30 success, 3 skipped, 0 red, 0 pending; mergeable_state=clean. ⚠️ Legacy commit statuses are not covered by the check-suite signal, so they were read separately: combined success (1 context, Vercel).

Gate ② — clause ②, verified on CONTENT, not spelling

--pair 16812 exit 0, and both declaration documents read independently (⛔ the gate's 「both carriers agree」 is about the label carriers — #16770): PR body → no, card claim 5580232384 → no.

⭐ The one thing that could have rebutted no is that printMcpConnectHint is newly exported. Checked directly rather than waved through:

@objectstack/cli exports : "." , "./console" , "./hook-body"     (all from dist/)
printMcpConnectHint      : commands/dev.ts ×3, its test ×4, and NOWHERE else
                           no index.ts re-exports it   (control: 7 hits in-tree, reader works)

⇒ the export reaches the sibling test file, not consumers. No published surface widens.

Acceptance — the card's three, read in the DIFF

  • Item 1 (both rows, one capture). describe('with OS_AUTH_URL set, all three hint lines and the banner row agree'), and the test drives printServerReady(...) alongside the hint so both printers land in one ordered buffer — the file's own words: "The banner row and each of the three block lines, from ONE capture." Leads with the literally reported case (dev -p 4001 under OS_AUTH_URL=https://localhost:4443), asserts localhost:4001 appears nowhere, and that the set of distinct MCP origins is exactly one. ⛔ That last assertion is what makes it a pin rather than a toContain that would pass on a mixed output.
  • Item 2 (negative control). Three unset cases: 3000, ⭐ the auto-shifted 3001 (also asserting 3000 appears nowhere), and an ephemeral 45064. This is the direction a hardcoded canonical origin destroys, and it is pinned.
  • Item 3 (reuse, not reimplement). resolveAuthBaseUrl is imported from ./serve.js — visible in the diff, with ⛔ dev declares no chain of its own written beside it. The set-but-empty OS_AUTH_URL pins in serve-auth-base-url-diagnostic.test.ts re-run unchanged (25 passed), and packages/cli/src/utils/format.ts is not in the diff — the "reference implementation, not a second thing to change" fence held.

⭐ The judgement call beyond the card — accepted, and why

When the chain yields no parseable origin (set-but-empty OS_AUTH_URL=, or a value with no scheme) the block is now omitted rather than falling back to the bound socket. The card did not ask for this, so it was examined rather than accepted:

⇒ It is entailed by item 1. If the banner refuses to print an origin, the only way for the block to agree is to print nothing — and a Connect command has no paths-only form the way the banner's paths do. Falling back to the socket would, in the function header's words, "reprint, on the same screen, the exact address the banner just refused to print" — recreating this card's defect in precisely the configuration most likely to be misconfigured. It is argued in the header, pinned by two tests, and changes no banner behaviour.

Serial — re-measured at accept time, ⛔ not carried over from dispatch

17 open PRs read, 0 empty file lists, 526 distinct files. commands/dev.ts → 0 other holders; utils/format.ts → 0; commands/serve.ts (newly imported from) → 0. ⭐ Positive control fired: scripts/engine-double-contract.pinned.json 3 holders, packages/objectql/src/engine.ts 2, packages/metadata-protocol/src/protocol.ts 2.

⭐ The ablation is why this acceptance is cheap

Two legs with different predicted signatures, and both matched. Reverting the origin to the listen socket reddened the three OS_AUTH_URL-set cases and both unusable-value cases while all three unset cases stayed green; separately hardcoding resolveAuthBaseUrl(3000) reddened only the shifted-port and ephemeral-port cases while item 1 stayed green. ⇒ Two independent failure modes, two distinct signatures — that asymmetry is what separates a real fix from a suite that would pass either way. On-disk mutation proven by occurrence counts and git hash-object; restore proven by blob equality and an empty git diff HEAD.

Docs-drift advisory — answered without widening the PR

The bot listed 30 pages and truncated at 15 rows. The delivery re-derived the list (34 docs, 4 anchors) instead of reading the truncated comment, and:

  • ⛔ the 4 release-owned pages were NOT edited — release notes are written centrally at release time; this PR's only input to them is its changeset. Verified from the file list: nothing under content/ is in this diff at all.
  • the other 30 were each listed through a generic anchor (the os dev command name, the two MCP path literals this diff moves but does not change, or the brand-new symbol no page can be naming) — all verified unaffected.
  • ⭐ the bot's declared blind spot was hand-read: grep -rn 'objectstack:listening|listen origin|bound socket' content/ → zero hits; no page anywhere states which origin the hint is built from. The two pages that print a claude mcp add line show http://localhost:3000/… for a local dev server and the deployment origin for a deployed instance — which is exactly what the fixed hint now emits in each case. ⇒ the change moves the code toward those pages; before it, only the local form could ever be printed.

⇒ 0 of 34 pages edited; the PR is still 3 files.

Deferred, ⛔ not filed on the delivery's word

Three observations were declined and argued (a now-readerless ListeningMessage.url, the double attribution footer, the docs bot's own precision limits). ⛔ This seat has not independently re-derived any of them, so ⛔ none is filed here — that is a separate act with its own measurement.

Landing: marked ready and routed to the merge queue. ⛔ Not merged outside the queue; ⛔ no governed surface in this diff.


Generated by Claude Code

@os-project-manager
os-project-manager marked this pull request as ready for review September 8, 2026 07:38
@os-project-manager
os-project-manager added this pull request to the merge queue Sep 8, 2026
Merged via the queue into main with commit 68fd85a Sep 8, 2026
38 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-16734-mcp-connect-hint-origin branch September 8, 2026 08:04
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…rocess, and the canonical origin follows the listener (objectstack-ai#17725)

Fixes objectstack-ai#16804

**objectstack dev** `--cert` &lt;path&gt; `--key` &lt;path&gt;
terminates TLS in the dev process itself, and every origin the boot
advertises follows the listener. This is director-seat ruling
[`5617187807`](objectstack-ai#16804 (comment)),
batch objectstack-ai#111 item 1, option **N (narrow)** — parts (1)+(2) of the card's
Ask in exactly that shape.

## ⛔ No CA generation, and no trust-store prose — 不生成 CA、不写信任库指引

The ruling, quoted verbatim (⛔ not paraphrased — 引用中文裁决时保持原文):

> **裁定**:objectstack dev --cert &lt;path&gt; --key
&lt;path&gt;:开发者自带证书,dev 进程内终止 TLS;同笔做 (2):规范 origin 自动为
`https://localhost:<port>`,两个 `.well-known/*` 文档随之广播 https,`OS_AUTH_URL`
只作覆盖(`resolveAuthBaseUrl` 的硬编码 `http://` 回落尾巴按 listener 协议派生)。⛔ 不生成自签
CA;⛔ 不打印、不文档化任何「把 CA 装进系统信任库」的指引——信任库是开发者自己的事。

So, in as many words: **this PR generates no certificate and no CA, and
it writes no instruction anywhere — not in code, not in `--help`, not in
a doc page, and not as a suggestion in this body — for installing a
certificate into a system trust store.** The trust store is the
developer's own business. The developer brings the certificate; the
feature's whole job is to *use* it. Option **F** (generated CA + trust
instructions) is refused on the ruling's security-statement ground;
option **X** (document the proxy recipe) is not delivered.

That refusal is **asserted, not merely promised**:
`dev-tls-contract.test.ts`'s last `describe` reads both flag
descriptions and the module's own source for `trust store` / `keychain`
/ `certutil` / `add-trusted-cert` / `self-signed` / `generated CA` and
for `node:crypto`, `generateKeyPairSync`, `X509Certificate`, and an
ANTI-VACUITY case proves the same scan finds words that *are* there — so
a future edit adding that prose cannot pass by having nothing to read.

## The confidence gap the ruling handed the implementing seat — MEASURED

> 置信缺口留给实施席先测:Hono 适配层接 Node TLS 的成本(`packages/cli/src` 今天零处 TLS 代码),超出
M 级停手回报。

**Reading: XS.** `@hono/node-server@2.1.1` takes the listener factory as
an *option*. Its exported `Options` type is a union whose https arm is,
verbatim from `node_modules/@hono/node-server/dist/index.d.mts`:

```ts
type createHttpsOptions = {
  serverOptions?: ServerOptions$2;              // node:https ServerOptions
  createServer?: typeof createServer$2;         // node:https createServer
};
```

So terminating TLS needs **no bridging code at all** — the same `fetch`
handler, the same route table, the same graceful drain, one different
server factory. Well under an M; no stop was warranted.

## Premise check — all four re-derived on this branch's base `6fa2a8ae`,
all four hold

| # | premise | re-derived here |
|:--|:--|:--|
| 1 | `resolveAuthBaseUrl` has a hardcoded `http://` fallback tail | ✓
`packages/cli/src/commands/serve.ts:5634` declared it; `:5637` was ``??
`http://localhost:${port}` `` |
| 2 | `packages/cli/src` contains **zero** TLS code | ✓
`https.createServer` · `createSecureServer` · `node:tls` · `node:https`
→ **0** each. Positive control from the same tree: `createServer` → 3
hits (`commands/serve.ts:255` — a `net.createServer()` port probe — plus
`utils/port-contract.ts:52` and `serve-port-validation.test.ts:96`, both
in prose), `from 'node:` → 313 |
| 3 | `dev` declares no `--cert` / `--key` | ✓ zero on `dev.ts` +
`serve.ts`; `dev`'s flags are at `commands/dev.ts:128` (`static override
flags`). The only `cert`-shaped hits in `serve.ts` were the word
"certainly" at `:410` and `/.well-known/*` prose at `:5036` |
| 4 | part (3) is already fixed by objectstack-ai#16734 / PR objectstack-ai#16812 | ✓
`printMcpConnectHint` already resolves through `resolveAuthBaseUrl`;
**not** re-implemented here |

`packages/plugins/plugin-hono-server/src/adapter.ts` likewise had
**zero** `https` hits, against a same-file positive control of 8 for
`key` — so the TLS arm is genuinely new rather than a second copy of
one.

## What follows the listener, and what deliberately does not

`resolveAuthBaseUrl(port, boundProtocol)` — **only the built-in default
tail moves.** That tail is the one link in the chain nobody configured:
it is the process describing its own socket, and once TLS terminates
in-process http://localhost:&lt;port&gt; is an address no client can
reach.

⛔ Every **configured** link keeps winning — `OS_AUTH_URL`, the legacy
`BETTER_AUTH_URL`, `OS_BASE_URL` — **an `http://` value included.** They
answer a different question: where the deployment is *reached*, which
behind a proxy or a tunnel has no relation to what this process bound. A
default has no standing to overrule an operator's deliberate statement
about a different hop, and a "helpful" scheme upgrade there would be a
bug wearing a feature's clothes.

Because the whole boot already routed through that one resolver, the
three acceptance surfaces follow with no second reader: the two
`/.well-known/*` documents (`AuthManager.getCanonicalOrigin()` ←
`AuthPlugin({ baseUrl })` ← `resolveAuthBaseUrl`), the CSRF allow-list,
the ready banner's `API:` / `MCP:` rows, and the `🤖 MCP server` block.

`publishBoundPort(..., boundProtocol)` is the **socket's own** address
rather than the canonical origin, so it is ⛔ *not*
`OS_AUTH_URL`-overridable: both of its consumers *open* that url — the
runtime state file is what an external supervisor dials, the IPC message
is what the `os dev` parent learns the server from — and a hardcoded
`http://` under a TLS listener hands both a machine-readable address
that answers a handshake error. This change is what makes that surface
false, so it ships with it.

## Acceptance — driven on a real boot of `examples/app-todo`, bytes
pasted

### ① `--cert`/`--key`, `OS_AUTH_URL` unset — all three surfaces give
https

`node packages/cli/bin/run.js dev --fresh -p 38471 --cert CERTPATH --key
KEYPATH`
(CERTPATH / KEYPATH are the two real file paths; spelled as words
because a
bracketed placeholder does not survive a GitHub body write):

```text
  ➜  API:       https://localhost:38471/
  ➜  MCP:       https://localhost:38471/api/v1/mcp
      connect an AI client (Claude Code, Cursor, …) · skill: https://localhost:38471/api/v1/mcp/skill

  🤖 MCP server — connect a coding agent:
     Endpoint  https://localhost:38471/api/v1/mcp
     Skill     https://localhost:38471/api/v1/mcp/skill
     Connect   claude mcp add --transport http app-todo https://localhost:38471/api/v1/mcp
     Disable   OS_MCP_SERVER_ENABLED=false
```

`GET /.well-known/oauth-protected-resource` over that real TLS socket:

```json
{
  "resource": "https://localhost:38471/api/v1/mcp",
  "authorization_servers": ["https://localhost:38471/api/v1/auth"],
  "scopes_supported": ["data:read", "data:write", "actions:execute", "offline_access"],
  "bearer_methods_supported": ["header"],
  "resource_name": "ObjectStack MCP"
}
```

`GET /.well-known/oauth-authorization-server` (url-valued fields):

```json
{
  "issuer": "https://localhost:38471/api/v1/auth",
  "authorization_endpoint": "https://localhost:38471/api/v1/auth/oauth2/authorize",
  "token_endpoint": "https://localhost:38471/api/v1/auth/oauth2/token",
  "jwks_uri": "https://localhost:38471/api/v1/auth/jwks",
  "registration_endpoint": "https://localhost:38471/api/v1/auth/oauth2/register"
}
```

And the listener really is TLS — plain http on that same port, with its
exit code captured **before** any pipe:

```text
$ curl -sS --noproxy '*' -m 10 http://localhost:38471/.well-known/oauth-protected-resource > log 2>&1; echo "EXIT=$?"
EXIT=52
curl: (52) Empty reply from server
```

The runtime state file names the socket, not a guess:
`{"pid":27532,"port":38471,"url":"https://localhost:38471",...}`.

### ② Without the flags — identical to today

Same command, flags removed, port 38472:

```text
  🤖 MCP server — connect a coding agent:
     Endpoint  http://localhost:38472/api/v1/mcp
     Skill     http://localhost:38472/api/v1/mcp/skill
     Connect   claude mcp add --transport http app-todo http://localhost:38472/api/v1/mcp
  ➜  API:       http://localhost:38472/
  ➜  MCP:       http://localhost:38472/api/v1/mcp
```
```json
{ "resource": "http://localhost:38472/api/v1/mcp",
  "authorization_servers": ["http://localhost:38472/api/v1/auth"] }
```
state file: `{"port":38472,"url":"http://localhost:38472",...}`

⭐ **Pinned as an ablation, not a claim** — three legs, each requiring
the omitted argument and an explicit `http` to be *identical* while both
differ from `https`:

- `dev-mcp-connect-hint-origin.test.ts` — `bootWithoutProtocolArg()`
reproduces this file's pre-change call expression character for
character and drives it beside today's call; the whole captured boot
buffer (banner + hint, `console.error` and `console.log` in call order)
must match byte for byte, over a plain port, dev's auto-shifted port and
an ephemeral one.
- `serve-auth-base-url-diagnostic.test.ts` — `resolveAuthBaseUrl(port)`
must equal `resolveAuthBaseUrl(port, 'http')` and differ from
`resolveAuthBaseUrl(port, 'https')`.
- `serve-bound-port-publication.test.ts` — the same, for the state-file
payload and the IPC message.
- `adapter-tls-listener.test.ts` — the http and https listeners are
constructed identically but for the fourth argument, and each
**refuses** the other's protocol, so neither leg can pass by being
broken in a convenient direction.

### ③ `OS_AUTH_URL` still wins

`OS_AUTH_URL=https://tunnel.example.com` **with** both TLS flags, port
38473:

```text
  ➜  API:       https://tunnel.example.com/
  ➜  MCP:       https://tunnel.example.com/api/v1/mcp
     Endpoint  https://tunnel.example.com/api/v1/mcp
     Connect   claude mcp add --transport http app-todo https://tunnel.example.com/api/v1/mcp
```
```json
{ "resource": "https://tunnel.example.com/api/v1/mcp",
  "authorization_servers": ["https://tunnel.example.com/api/v1/auth"] }
```

…while the state file still names the socket:
`{"port":38473,"url":"https://localhost:38473",...}` — the canonical
origin and the bound address answering their own questions, as designed.
The awkward direction is pinned too: an `http://` `OS_AUTH_URL` under a
TLS listener is **not** upgraded.

### Refusals — loud, and never degraded to plain http

```text
$ os dev --cert CERTPATH                                  # EXIT=1
  ✗ --cert was given without --key.
    TLS needs both halves: --cert CERT-PATH-PLACEHOLDER --key KEY-PATH-PLACEHOLDER.
    Drop both to serve plain http on this port.

$ os dev --cert /no/such/cert.pem --key /no/such/key.pem  # EXIT=1
  ✗ --cert could not be read: "/no/such/cert.pem"
    ENOENT: no such file or directory, open '/no/such/cert.pem'
    The path is resolved relative to the current working directory.
```

⚠️ In the first block the notice's two bracketed placeholders are
rendered here as
CERT-PATH-PLACEHOLDER / KEY-PATH-PLACEHOLDER. The bytes the CLI actually
prints are
angle-bracketed (`path to the certificate` and `path to its private key`
inside angle
brackets) and are pinned verbatim in `dev-tls-contract.test.ts`; a
bracketed span does
not survive a GitHub body write, so it is spelled out rather than
silently eaten.

⛔ There is deliberately no path from either refusal back to an http
listener: a developer who typed `--cert` asked for TLS, and answering
with the other protocol would surface first as a client-side handshake
error naming neither the flag nor the file. Prefer failing to falling
back.

## Shape of the change

`packages/cli/src/utils/dev-tls-contract.ts` is the **one** reader of
the pair, shared by `dev` and the `serve` child it spawns — the same
judgement as `port-contract.ts`, for the same reason: before it, a value
typed at `dev` would have been refused one process later under the name
of the channel it arrived on. `dev` forwards the **paths**, never the
bytes, so exactly one process reads the file and exactly one owns that
refusal — and the parent's `boundProtocol` comes from the same
`resolveDevTlsIntent` call that built the child's argv, so the scheme
the hint prints and the scheme the child binds cannot part company
(pinned: exactly one `resolveDevTlsIntent(` call in `dev.ts`).

`HonoPluginOptions.tls` takes PEM **bytes**, ⛔ not paths: whoever hands
the adapter TLS material is the layer that knows why it has it and can
name the flag the operator typed. A transport adapter taking paths would
own a second reader of the same file and would have to invent a refusal
from a filename.

## Scope — declared growth, and two fences confirmed clear

The dispatch's declared face was `commands/dev.ts`, `commands/serve.ts`
and their tests. The delivered diff **grew** into
`packages/plugins/plugin-hono-server` (`adapter.ts`, `hono-plugin.ts`,
one new test) and `packages/cli/src/utils`, because `serve()` — the only
place a listener is created — lives in the adapter, and putting TLS
anywhere else would have meant a second listener owner (Route & surface
ownership §1). Contract-first: the option belongs at the producer.

Checked rather than assumed: **no open PR holds any file in this diff.**
All 9 open PRs' file lists were read; PR objectstack-ai#17454's cli files are
`index.ts`, `commands/init.ts`,
`commands/migrate/{account-issuer,apply}.ts` — disjoint from every path
here — and no open PR touches `plugin-hono-server` at all. ⛔ No
`packages/spec` edit: `HonoPluginOptions` is declared locally in
`hono-plugin.ts`, so the new option needed none (`packages/spec` was
read only). ⛔ `content/docs/releases/` untouched.

`Clause-②` re-derived from the **delivered** diff: still **yes** — two
new public CLI flags on two commands, a new exported module, a widened
exported `resolveAuthBaseUrl` / `publishBoundPort` signature (both
additive defaults), and a new public `HonoPluginOptions.tls`.

## Two pins this change moved, and why they are updated rather than
deleted

`serve-bound-port-publication.test.ts` held three source-text pins that
went red on the first run — exactly their job. Each is updated to the
new spelling with its intent intact: the banner pin still asserts the
origin comes from `boundPort` (not `port`), the publish-site pin still
asserts the seam is handed the resolved bound port, and the positive
control for the `not.toContain` negatives still asserts `port` reaches
the transport under its own name — now by reading the construction site
rather than one formatting of it.

## The acceptance-2 pins CAN fail — proven by mutation, not by
inspection

An ablation leg only means something if it goes red when the thing it
guards moves. Driven
from the committed state, on disk, with the restore in a `trap`:

**Mutation.** Both `boundProtocol: ListenerProtocol = 'http'` defaults —
`resolveAuthBaseUrl`'s
and `publishBoundPort`'s; there are exactly two, and the script refuses
to proceed on any
other count — flipped to `'https'`, which is precisely the "derived
protocol leaked into the
default" regression.

```text
HEAD blob: 8afd9f4
before: old-spelling=2  new-spelling=0
after:  old-spelling=0  new-spelling=2      <- the edit REACHED THE DISK
mutated blob: 2ae1e42c1400fcb736763b5e18243554198f685a

MUTATED_EXIT=1      Test Files  2 failed (2)      Tests  5 failed | 48 passed (53)

restored blob: 8afd9f4   <- byte-identical to HEAD
git diff HEAD: empty;  git status --porcelain: []

RESTORED_EXIT=0     Test Files  2 passed (2)      Tests  53 passed (53)
```

⚠️ Observed direction: **turns red** — the ordinary one. The five that
failed:

```text
× resolveAuthBaseUrl — precedence (pre-existing behaviour, unchanged)
    > falls back to http://localhost:<port> when no variable is set
× resolveAuthBaseUrl — the listener protocol reaches the TAIL and stops there
    > ABLATION: the omitted argument and an explicit `http` are the same call
× without the flags the output is byte-for-byte what it was
    > the no-flag boot equals the boot that never passes a protocol at all
    > the two legs also agree on an auto-shifted port and an ephemeral one
    > and the legs DISCRIMINATE — the https leg differs from both
```

⭐ The first of those five is **not one of this PR's tests** — it is the
pre-existing objectstack-ai#10202
pin, which has guarded that fallback since before `--cert` existed. A
mutation that reddens a
pin written years before the feature is the cleanest available evidence
that acceptance 2 is
guarded by the tree and not merely by this PR's own assertions.

Discipline notes, since each is a way this measurement could have been
void: the restore is
`git checkout HEAD -- <absolute path>` (⛔ never bare `git checkout --`,
which restores the
mutation back out of the index); it is verified by `git diff HEAD` being
empty and by blob
equality, ⛔ never by an exit code; the `trap … EXIT INT TERM` uses an
absolute path resolved
from `git rev-parse --show-toplevel`; and an empty or unresolvable blob
hash is treated as a
FAILURE rather than as "nothing to compare". No mutated file survives —
the final tree is
clean at `03ba3112`.

## Gates — every derived family measured, at `03ba3112`

The set was **derived from the delivered diff**, not taken from the
dispatch's list:
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`, re-derived
after the docs commit (13 paths → **95** commands, 30 of them families
only the two
`content/docs/` paths schedule). Reconciled back:

```text
Run reconciliation — 95 derived, 95 run, 0 NOT-MEASURED, 0 UNRUN.
```

**95 / 95 green.** Four needed a second pass, and each one is worth
naming:

| family | first pass | why, and what closed it |
|:--|:--|:--|
| `check:nul-bytes` | **exit 1 — a real finding** | A scripted edit had
materialised `\u001b` into a raw `0x1b` byte in
`dev-tls-contract.test.ts:209`, in the very assertion that is *about*
that byte — the exact slip the gate's header says every occurrence in
this repo came from. Fixed to the escape text in `c6500241`; now `OK
(scanned 8438 text file(s) … no raw ASCII control bytes)`, plus a `grep
-naP` self-scan over every changed file returning empty |
| `check:dual-build-cjs-loads` | exit 3 | `PREREQUISITE NOT MET` —
unbuilt sibling packages, ⛔ not a pass and ⛔ not a red. Re-run after the
closure builds: `✓ 104 published require entry point(s) across 67
package(s) load; 620 emitted CommonJS file(s) parse` |
| `check:i18n-coverage` | exit 3 | Same class — `os lint` could not load
`app-showcase`'s config against an unbuilt `connector-mcp`, so *nothing
was compared*. Re-run: `OK (13 config(s), 621 baselined untranslated
string(s), none new)` |
| `check:type-check-debt` | exit 3 | The `--re-measure` leg OOM'd —
under **my own** `NODE_OPTIONS=--max-old-space-size=4096`, which is
*below* the 6144 MB CI-shaped ceiling the gate pins for itself. It
refused to record 0 rather than lying. Re-run at 8192: `OK — 5 ledger
entr(ies) re-measured in 98.2s, 55 raw tsc error(s) total, none above
its recorded number` |

⚠️ An `exit 3` from any of those three is `PREREQUISITE NOT MET` — **NOT
MEASURED**, in neither direction — so none of them was reported green
until it had actually run.

**`pnpm lint` — the full union, not a narrowing.** The lane adds it and
`dispatch-gates.mjs` does not name it. It completed over the whole repo
at the final commit `03ba3112` (clean tree, `git status --porcelain`
empty):

```text
node --stack-size=4000 node_modules/eslint/bin/eslint.js . --no-inline-config --format json
→ 6638 files linted · 0 errors · 0 warnings   (exit 0)
```

The 10 changed `.ts` files also lint clean on their own (`--format json`
→ 10 files, 0/0). Since the union ran, no invariance argument is owed;
for the record, this repo's single `eslint.config.mjs` enables **no**
type-aware linting (zero `parserOptions.project` / `projectService`
matches — the config says so itself at `:328`), so a diff here cannot
move an untouched file's verdict anyway.

**Build / typecheck / tests**, all through
`scripts/pm/os-verify-lock.sh` (one lock per container):

| run | verdict |
|:--|:--|
| `pnpm --filter '@objectstack/plugin-hono-server^...' --filter
'@objectstack/cli^...' build` | `VERDICT command-exit 0` (held 453s) |
| the two packages' own `build` + `typecheck` | `VERDICT command-exit 0`
|
| `plugin-hono-server` — `adapter-tls-listener` + `adapter-drain` | `2
passed` files, **8 passed** tests |
| `cli --project unit` — the four pin files | `4 passed` files, **102
passed** tests |

`packages/cli`'s `integration` tier is **declared to CI**: no path in
this diff is an integration-tier file, a `bin/` entry or
`test/helpers/serve-process.ts`, so `--project unit` is what is owed
locally.

## Acceptance notes

- `check:nul-bytes` caught a real defect in this PR before it was
pushed: a Python-driven edit materialised `�` into a raw `0x1b` byte in
`dev-tls-contract.test.ts`. Fixed to the escape text; gate re-run green
over 8438 files, plus a `grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]'`
self-scan over every changed file returning empty.
- **noted, not filed** — `publishBoundPort`'s url is
`localhost`-hardcoded on the host half as well as the scheme half. Under
a non-loopback bind that host is already wrong today, independently of
TLS; this change touches only the scheme, and the host is out of this
card's scope. Carrier: whoever next changes the bind host — no PR or
card currently in flight names it.
- **noted, not filed** — `AuthManager.getCanonicalOrigin()`'s own
fallback is `'http://localhost:3000'` (`auth-manager.ts:6088`), reached
only when no `baseUrl` is configured at all. Every `os serve` / `os dev`
path passes one, so this branch is unreachable from the CLI and no TLS
boot can hit it. Not a defect from any door this card opens. Carrier:
whoever composes an `AuthPlugin` without a `baseUrl`.

---

## 维护者速读(草稿)

**改了什么。** `objectstack dev --cert <证书> --key <私钥>` 两个新 flag:开发者自带证书,dev
进程自己终止 TLS。给了这两个 flag 之后,这次启动对外宣告的所有地址一律变成 `https://localhost:<端口>`——两个
`/.well-known/*` 发现文档、CSRF 白名单、就绪横幅的 `API:`/`MCP:` 行、`🤖 MCP server`
连接提示,以及外部监管进程会去拨的 runtime 状态文件。不给 flag 时逐字节和今天一样。

**为什么改。** 桌面端 MCP 客户端拒绝对非 https 地址发起 OAuth
登录,所以产品页面承诺的「交互式客户端自动弹浏览器登录」在本地开发服务器上根本演示不了。以前唯一的走法是镜头外手搭一页 openssl +
反向代理再手设 `OS_AUTH_URL`——每次演示、每次录屏、每次排查都要重来一遍。这一笔把那页准备工作删掉。

**风险与代价(含回滚)。** ⛔ 不生成任何证书或 CA,⛔ 也不在任何地方(代码、`--help`、文档、本 PR 正文)写「把 CA
装进系统信任库」的指引——信任库仍然是开发者自己的事,这条由测试反向断言把守,将来有人加这句话会红。dev 进程在给了 flag
时持有一份私钥,这是自带证书方案本来就有的性质,不新增。风险面很窄:不给 flag 时代码路径与今天完全相同,已由四处消融测试钉住。回滚 =
revert 本 PR,无数据迁移、无配置残留、无已发布键退役。已配置的 `OS_AUTH_URL` 一律优先(连 `http://`
的值也不被「升级」),所以任何现有部署的行为不动。

**席位意见。** _(留空,待维护者定稿)_

**你要做的。** 这是新公开 CLI 面(`Clause-②: yes`),已挂
`needs:contract-review`。请确认两点:① 两个 flag 的名字与描述文案;② 「只有回落尾巴跟随
listener、所有已配置值一律优先」这条边界是你要的。确认后按常规合并即可。

This pull request was authored by Claude Code in session
`session_01TSf4DV7ziu4V5j73e46b7c`; that sentence is the durable
attribution, kept
in prose because a PR body's footer block is not reliably preserved by
the platform.


---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants