feat(cli): objectstack dev --cert/--key terminates TLS in the dev process, and the canonical origin follows the listener - #17725
Conversation
…and derive the canonical origin from the listener `objectstack dev --cert <path> --key <path>` terminates TLS in the dev process itself, and every origin the boot advertises follows it: the two `/.well-known/*` documents, the CSRF allow-list, the ready banner's `API:` row, the `MCP server` connect hint and the runtime state file. The developer brings the certificate. Nothing here generates one, and nothing here says anything about installing one into a trust store. - `utils/dev-tls-contract.ts` — the one reader of the flag pair, shared by `dev` and the `serve` child it spawns, so the protocol the parent derives and the protocol the child binds come from one answer. Half a pair is refused by the door the operator typed at; an unreadable file is refused by the process that would have bound the socket, and never degraded to a plain-http listener. - `resolveAuthBaseUrl(port, boundProtocol)` — only the built-in default tail follows the listener. `OS_AUTH_URL` and the rest of the configured chain keep winning: they name where a deployment is reached, not what this process bound. The parameter defaults to `http`, so a tree with no TLS flags in play resolves byte-for-byte as before. - `publishBoundPort(..., boundProtocol)` — the socket's own address, which both the runtime state file and the `objectstack:listening` IPC message send a consumer to. - `HonoPluginOptions.tls` / `HonoHttpServer` — `@hono/node-server` takes a listener factory as an option, so the TLS arm is the same fetch handler and the same drain with one different server factory. Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude <noreply@anthropic.com>
…flag ablation - `adapter-tls-listener.test.ts` drives a real ephemeral TLS socket with a certificate the test mints into a tempdir, and drives the two legs against each other: the https listener refuses a plain-http request and the plain listener refuses a TLS one, so neither leg can pass by being broken. - `dev-tls-contract.test.ts` pins the three answers of the flag pair, the refusals, and — negatively — that no flag description and no line of the module mentions generating a certificate or trusting one, with an anti-vacuity case proving the same scan reads words that are there. - `dev-mcp-connect-hint-origin.test.ts` gains the https acceptance (banner row and all three hint lines, `OS_AUTH_URL` unset) and an ablation leg: the boot that passes no protocol at all and the boot that passes `http` must be byte-identical, and both must differ from the https leg. - `serve-auth-base-url-diagnostic.test.ts` pins that the listener protocol reaches the built-in tail and stops there — every configured value keeps winning, an `http://` one included. - `serve-bound-port-publication.test.ts` gains the same ablation for the state file and the IPC message, and its two source pins are updated to the spellings this change moved. Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude <noreply@anthropic.com>
`check:nul-bytes` caught a raw 0x1b that a scripted edit materialised out of the escape sequence while the test was asserting ABOUT that byte — the exact slip the gate's header says every occurrence in this repo came from. Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 32 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 24 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 b3111c49433e1a4f915503627cefe3410388b35c && git checkout b3111c49433e1a4f915503627cefe3410388b35c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6fa2a8ae1d546170cf8a799d42e8a5fc7caac843 03ba311254f02dd44567619d25b16d2640f0ccde && git checkout -B drift-repro 6fa2a8ae1d546170cf8a799d42e8a5fc7caac843 && git merge --no-ff 03ba311254f02dd44567619d25b16d2640f0ccde
node scripts/docs-audit/affected-docs.mjs --json 6fa2a8ae1d546170cf8a799d42e8a5fc7caac843
|
…AUTH_URL default row Two hand-written pages, both found by a manual pass rather than by the docs-drift list — which is structurally blind here: a page that states a rule by its INPUTS shares no identifier with the emitter that implements it. - `environment-variables.mdx`'s `OS_AUTH_URL` row gave its default as `http://localhost:<OS_PORT>` unconditionally. This change makes that conditionally false, so it ships with the correction, and the row now also says the variable wins whatever the listener speaks. - `cli.mdx`'s `os dev` flag table enumerates every flag, so two new public flags absent from it would advertise a smaller CLI than ships. Added, plus a short section on what the pair is for. ⛔ Zero trust-store prose on either page, and nothing about obtaining or trusting a certificate beyond saying that both are the developer's own. ⛔ `content/docs/releases/` untouched. Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude <noreply@anthropic.com>
|
Contract reviewHead reviewed: Reviewed-by: Verdict: the ruled surface PASSES. ⛔ The gate is NOT cleared, for one item that sits on the maintainer's floor — see the last section.Gates
The authorized widening, item by item
The ruling's hard floor — ⛔ no CA generation, ⛔ no trust-store prose — HOLDS, and the count that looked like a violation was not oneA probe for
Dedicated trust-store probe ( Scope, fences and the transfer red line13 files. Intersection with PR #17454's six lane files and PR #17718's two: NONE. ⭐ The docs half is more than the flags: the manual inputs-vs-emitter pass corrected ⛔ The one item I will not clear: the flag pair is user-reachable on
|
Fixes #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 ruling5617187807, batch #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 — 引用中文裁决时保持原文):
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 lastdescribereads both flag descriptions and the module's own source fortrust store/keychain/certutil/add-trusted-cert/self-signed/generated CAand fornode: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
Reading: XS.
@hono/node-server@2.1.1takes the listener factory as an option. Its exportedOptionstype is a union whose https arm is, verbatim fromnode_modules/@hono/node-server/dist/index.d.mts:So terminating TLS needs no bridging code at all — the same
fetchhandler, 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 holdresolveAuthBaseUrlhas a hardcodedhttp://fallback tailpackages/cli/src/commands/serve.ts:5634declared it;:5637was?? `http://localhost:${port}`packages/cli/srccontains zero TLS codehttps.createServer·createSecureServer·node:tls·node:https→ 0 each. Positive control from the same tree:createServer→ 3 hits (commands/serve.ts:255— anet.createServer()port probe — plusutils/port-contract.ts:52andserve-port-validation.test.ts:96, both in prose),from 'node:→ 313devdeclares no--cert/--keydev.ts+serve.ts;dev's flags are atcommands/dev.ts:128(static override flags). The onlycert-shaped hits inserve.tswere the word "certainly" at:410and/.well-known/*prose at:5036printMcpConnectHintalready resolves throughresolveAuthBaseUrl; not re-implemented herepackages/plugins/plugin-hono-server/src/adapter.tslikewise had zerohttpshits, against a same-file positive control of 8 forkey— 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 legacyBETTER_AUTH_URL,OS_BASE_URL— anhttp://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'sAPI:/MCP:rows, and the🤖 MCP serverblock.publishBoundPort(..., boundProtocol)is the socket's own address rather than the canonical origin, so it is ⛔ notOS_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 theos devparent learns the server from — and a hardcodedhttp://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_URLunset — all three surfaces give httpsnode 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):
GET /.well-known/oauth-protected-resourceover that real TLS socket:{ "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):{ "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:
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:
{ "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
httpto be identical while both differ fromhttps: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.errorandconsole.login 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 equalresolveAuthBaseUrl(port, 'http')and differ fromresolveAuthBaseUrl(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_URLstill winsOS_AUTH_URL=https://tunnel.example.comwith both TLS flags, port 38473:{ "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: anhttp://OS_AUTH_URLunder a TLS listener is not upgraded.Refusals — loud, and never degraded to plain http
CERT-PATH-PLACEHOLDER / KEY-PATH-PLACEHOLDER. The bytes the CLI actually prints are
angle-bracketed (
path to the certificateandpath to its private keyinside anglebrackets) and are pinned verbatim in
dev-tls-contract.test.ts; a bracketed span doesnot 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
--certasked 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.tsis the one reader of the pair, shared bydevand theservechild it spawns — the same judgement asport-contract.ts, for the same reason: before it, a value typed atdevwould have been refused one process later under the name of the channel it arrived on.devforwards the paths, never the bytes, so exactly one process reads the file and exactly one owns that refusal — and the parent'sboundProtocolcomes from the sameresolveDevTlsIntentcall that built the child's argv, so the scheme the hint prints and the scheme the child binds cannot part company (pinned: exactly oneresolveDevTlsIntent(call indev.ts).HonoPluginOptions.tlstakes 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.tsand their tests. The delivered diff grew intopackages/plugins/plugin-hono-server(adapter.ts,hono-plugin.ts, one new test) andpackages/cli/src/utils, becauseserve()— 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 #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 touchesplugin-hono-serverat all. ⛔ Nopackages/specedit:HonoPluginOptionsis declared locally inhono-plugin.ts, so the new option needed none (packages/specwas 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 exportedresolveAuthBaseUrl/publishBoundPortsignature (both additive defaults), and a new publicHonoPluginOptions.tls.Two pins this change moved, and why they are updated rather than deleted
serve-bound-port-publication.test.tsheld 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 fromboundPort(notport), the publish-site pin still asserts the seam is handed the resolved bound port, and the positive control for thenot.toContainnegatives still assertsportreaches 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'sand
publishBoundPort's; there are exactly two, and the script refuses to proceed on anyother count — flipped to
'https', which is precisely the "derived protocol leaked into thedefault" regression.
⭐ The first of those five is not one of this PR's tests — it is the pre-existing #10202
pin, which has guarded that fallback since before
--certexisted. A mutation that reddens apin 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 baregit checkout --, which restores themutation back out of the index); it is verified by
git diff HEADbeing empty and by blobequality, ⛔ never by an exit code; the
trap … EXIT INT TERMuses an absolute path resolvedfrom
git rev-parse --show-toplevel; and an empty or unresolvable blob hash is treated as aFAILURE rather than as "nothing to compare". No mutated file survives — the final tree is
clean at
03ba3112.Gates — every derived family measured, at
03ba3112The 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-derivedafter the docs commit (13 paths → 95 commands, 30 of them families only the two
content/docs/paths schedule). Reconciled back:95 / 95 green. Four needed a second pass, and each one is worth naming:
check:nul-bytes\u001binto a raw0x1bbyte indev-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 inc6500241; nowOK (scanned 8438 text file(s) … no raw ASCII control bytes), plus agrep -naPself-scan over every changed file returning emptycheck:dual-build-cjs-loadsPREREQUISITE 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) parsecheck:i18n-coverageos lintcould not loadapp-showcase's config against an unbuiltconnector-mcp, so nothing was compared. Re-run:OK (13 config(s), 621 baselined untranslated string(s), none new)check:type-check-debt--re-measureleg OOM'd — under my ownNODE_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 numberexit 3from any of those three isPREREQUISITE 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 anddispatch-gates.mjsdoes not name it. It completed over the whole repo at the final commit03ba3112(clean tree,git status --porcelainempty):The 10 changed
.tsfiles 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 singleeslint.config.mjsenables no type-aware linting (zeroparserOptions.project/projectServicematches — 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):pnpm --filter '@objectstack/plugin-hono-server^...' --filter '@objectstack/cli^...' buildVERDICT command-exit 0(held 453s)build+typecheckVERDICT command-exit 0plugin-hono-server—adapter-tls-listener+adapter-drain2 passedfiles, 8 passed testscli --project unit— the four pin files4 passedfiles, 102 passed testspackages/cli'sintegrationtier is declared to CI: no path in this diff is an integration-tier file, abin/entry ortest/helpers/serve-process.ts, so--project unitis what is owed locally.Acceptance notes
check:nul-bytescaught a real defect in this PR before it was pushed: a Python-driven edit materialised�into a raw0x1bbyte indev-tls-contract.test.ts. Fixed to the escape text; gate re-run green over 8438 files, plus agrep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]'self-scan over every changed file returning empty.publishBoundPort's url islocalhost-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.AuthManager.getCanonicalOrigin()'s own fallback is'http://localhost:3000'(auth-manager.ts:6088), reached only when nobaseUrlis configured at all. Everyos serve/os devpath 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 anAuthPluginwithout abaseUrl.维护者速读(草稿)
改了什么。
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, keptin prose because a PR body's footer block is not reliably preserved by the platform.
Generated by Claude Code