Repository navigation
fix(cli): build \os dev\'s MCP connect hint from the resolved origin, not the listen socket - #16812
Conversation
… 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
📓 Docs Drift CheckThis PR changes 1 package(s): 30 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 22 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 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
|
Accepted by the Gate ① — CI36 raw check runs → 33 after latest-per-name collapse: 30 success, 3 skipped, 0 red, 0 pending; Gate ② — clause ②, verified on CONTENT, not spelling
⭐ The one thing that could have rebutted ⇒ the Acceptance — the card's three, read in the DIFF
⭐ The judgement call beyond the card — accepted, and whyWhen the chain yields no parseable origin (set-but-empty ⇒ 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 Serial — re-measured at accept time, ⛔ not carried over from dispatch17 open PRs read, 0 empty file lists, 526 distinct files. ⭐ The ablation is why this acceptance is cheapTwo legs with different predicted signatures, and both matched. Reverting the origin to the listen socket reddened the three Docs-drift advisory — answered without widening the PRThe 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:
⇒ 0 of 34 pages edited; the PR is still 3 files. Deferred, ⛔ not filed on the delivery's wordThree observations were declined and argued (a now-readerless Landing: marked ready and routed to the merge queue. ⛔ Not merged outside the queue; ⛔ no governed surface in this diff. Generated by Claude Code |
…rocess, and the canonical origin follows the listener (objectstack-ai#17725) Fixes objectstack-ai#16804 **objectstack dev** `--cert` <path> `--key` <path> 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 <path> --key <path>:开发者自带证书,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:<port> 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>
Fixes #16734
Clause-②: no
os devprinted 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 throughresolveAuthBaseUrl—OS_AUTH_URL→ legacyBETTER_AUTH_URL→OS_BASE_URL→ thehttp://localhost:PORTtail. The🤖 MCP server — connect a coding agentblock inpackages/cli/src/commands/dev.tsbuilt its base from the serve child'sobjectstack:listeningurl, which is the socket the child BOUND, by construction.OS_AUTH_URLnever entered that expression.That block's
Connectline is a command the reader pastes, so the wrong origin is not cosmetic:claude mcp addregisters 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 exportedprintMcpConnectHint({ boundPort, name })that resolves its origin throughresolveAuthBaseUrl, the same functionserve's banner call site uses, with the port the child ACTUALLY bound. The precedence chain is not restated anywhere indev.objectstack:listeninghandler now passesboundPort: actualand nothing else; thebaseexpression built frommsg.urlis gone.baseOrigin === null— a set-but-emptyOS_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"; aclaude mcp addline 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.tsis untouched: it is the reference implementation, and nothing in it needed to change. TheEndpoint/Skill/Connectwording is unchanged.start.tsalready carries the rule this PR applies todev, in its own words: "⛔ Do not reintroduce an address here fed from the child'sipcobjectstack:listeningmessage".devwas the one command still doing it.Anchors, re-derived on the current base
The card's line numbers were written against
5e53d73d; base here isdd2184ae25.dd2184ae25dev.ts:504-505— thebaseexpressiondev.ts:500-501dev.ts:503-508— the printed blockdev.ts:503-508format.ts:816— the➜ MCP:rowformat.ts:816验收备注
1. With
OS_AUTH_URLset, all three block lines and the➜ MCP:row agreeAsserted on both rows of one captured boot output, never on the block alone: each case installs one buffer over
console.error(the banner) andconsole.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 4001underOS_AUTH_URL=https://localhost:4443asserts the banner row,Endpoint,SkillandConnectall carryhttps://localhost:4443, thatlocalhost:4001appears 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, thenOS_BASE_URL).2. With
OS_AUTH_URLunset, the block still prints the LISTEN originThis falls out of the resolver rather than from a second fallback: with none of the three variables set,
resolveAuthBaseUrl's own built-in tail answershttp://localhost:PORTfor 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
3000appears nowhere in the capture; and an ephemeral bound port (45064), the--port 0shape.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
resolveAuthBaseUrlandformatUnusableAuthBaseUrlDiagnosticare untouched — the diff adds an import of the first and changes no line ofserve.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, andnever-consulted.example.comfrom a setOS_BASE_URLappears 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.ts6 passed,serve-bound-port-publication.test.ts21 passed,test/vitest-tiers-partition.test.ts22 passed, and the new file 11 passed — 85 of 85 in one run atb466fc5d90.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 withgit checkout HEAD --and re-proven (git diff HEADempty, blob equal to the HEAD blob). Both legs ran with atrap ... EXIT INT TERMrestore.8bb27f19db→a2980ca600OS_AUTH_URL-set cases and both unusable-value cases red; the three unset cases stayed green, as they mustresolveAuthBaseUrl(3000)instead of the bound port8bb27f19db→56d7bfd6e4Restore both times: blob back to
8bb27f19db,git diff HEADempty. 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.jsto source insidepackages/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
b466fc5d90unless 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 withpackages/cli is not built (./dist/index.js is absent)— a stated prerequisite, not a finding; both green afterpnpm --filter @objectstack/cli buildand in the full re-run above.pnpm --filter @objectstack/cli typecheck— exit 0 (tsc --noEmitpluscheck:test-typecheck, the test-layer debt ledger held).pnpm lint(eslint . --no-inline-config, the whole repo, no narrowing) — exit 0, 1m22s.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 arePREREQUISITE 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.check-changeset-fixed,check:authz-resolver,check:error-code-casing,check:filter-alias-parity,check:scaffold-emission-policy— all exit 0.check:dts-closureneeded a second look: exit 0 during the sweep, exit 1 after the unit tier had run, naming 7 packages with JS-onlydist/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-bytesexit 0, plus a direct control-character scan over both edited files (grep -naP) — no hits.STALE TREEagainstorigin/main78bc4ad587, naming.github/workflows/publish-smoke.ymlandscripts/pm/check-skill-line-ratchet.mjs. Re-deriving aftergit fetch origin mainproduced a byte-identical command list. Neither can add a family for these paths — publish-smoke isworkflow_run/schedule-driven with no PR path filter, and the skill ratchet's population isskills/**, 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.tswas 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:
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
8652f200e599and said outright that "That checkout carried uncommitted changes, so the commit above does not fully identify what was read"; this run reportshead b466fc5d90ec,diffBase dd2184ae25,dirty: false, samesinceRef. The anchors areprintMcpConnectHint(symbol),os dev(command),/api/v1/mcpand/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.mdxlines 75 and 468 — "theos devbanner prints the MCP endpoint, the agent-skill URL, and a ready-to-pasteclaude mcp addcommand") 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:
os dev(the command name, which many pages mention in passing) — no flag, no default, no output contract ofos devchanged;/api/v1/mcpor/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;printMcpConnectHint— a symbol introduced by this PR, so no page can be naming it.30 rows is what
@objectstack/cliplus 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.mdxandcontent/docs/getting-started/build-with-claude-code.mdxare the two pages that print aclaude mcp addline:http://localhost:3000/api/v1/mcplabelled "local dev server" andhttps://your-deployment.example.com/api/v1/mcplabelled "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 whatOS_AUTH_URLresolves 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 informat.ts, which this diff does not touch. Still true.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.urlnow has no reader inpackages/cli/src:devwas its last consumer, andstartdeclines it on purpose. The field is still published and still pinned byserve-bound-port-publication.test.ts, and the sameurlis written to the runtime state file where it is the right value. Dead-ish code, not a defect. Carrier: the next PR touchingpublishBoundPort/ListeningMessage.🤖 Generated with Claude Code
https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
Generated by Claude Code