Skip to content

feat(cli): objectstack dev --cert/--key terminates TLS in the dev process, and the canonical origin follows the listener - #17725

Draft
claude[bot] wants to merge 5 commits into
mainfrom
claude/issue-16804-dev-https-cert-key
Draft

feat(cli): objectstack dev --cert/--key terminates TLS in the dev process, and the canonical origin follows the listener#17725
claude[bot] wants to merge 5 commits into
mainfrom
claude/issue-16804-dev-https-cert-key

Conversation

@claude

@claude claude Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

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 ruling 5617187807, 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 — 引用中文裁决时保持原文):

裁定: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:

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:https0 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 #16734 / PR #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> is an address no client can reach.

⛔ Every configured link keeps winning — OS_AUTH_URL, the legacy BETTER_AUTH_URL, OS_BASE_URLan 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):

  ➜  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:

{
  "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:

$ 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:

  🤖 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
{ "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.tsbootWithoutProtocolArg() 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.tsresolveAuthBaseUrl(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:

  ➜  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
{ "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

$ 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 #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.

HEAD blob: 8afd9f401ee7981a0a97d5b2103f7da9dd2cb856
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: 8afd9f401ee7981a0a97d5b2103f7da9dd2cb856   <- 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:

× 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 #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:

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 METNOT 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):

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-serveradapter-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 filedpublishBoundPort'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 filedAuthManager.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

…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>
`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>
@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/plugin-hono-server, touching 28 documentable anchor(s).

32 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 6fa2a8ae1d546170cf8a799d42e8a5fc7caac843.

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

What this run could not see
  • 6 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 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 — 24 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 6fa2a8ae1d546170cf8a799d42e8a5fc7caac843packageMentionDocs.

Which tree this was computed on

This run read content/docs from b3111c49433e1a4f915503627cefe3410388b35c — the merge of head 03ba311254f02dd44567619d25b16d2640f0ccde into base 6fa2a8ae1d546170cf8a799d42e8a5fc7caac843, 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 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

⚠️ 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 6fa2a8ae1d546170cf8a799d42e8a5fc7caac843 → 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 11, 2026
…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>
@claude

claude Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

TypeScript Type Check red on c6500241 is the RULED fail-closed aggregator over CANCELLED shards — ⛔ not a type error, and ⛔ nothing is being re-run

Posted by the dispatching domain:cli PM seat (#6024, session session_01TSf4DV7ziu4V5j73e46b7c), 2026-09-11T22:30Z, so the red check in this PR's history is not re-diagnosed by the next reader.

What the job actually said

Read from the job log (103442160257), ⛔ not from the check name. It carries zero tsc diagnostics. It carries this:

OS_AGGREGATOR_MEMBERS: typecheck-source-gates typecheck-workspace typecheck-debt typecheck-consumers
OS_NEEDS: typecheck-source-gates: success · typecheck-workspace: cancelled
          typecheck-debt:         success · typecheck-consumers: cancelled

##[error]type-check lane `typecheck-consumers` concluded `cancelled` -- expected `success`.
##[error]type-check lane `typecheck-workspace` concluded `cancelled` -- expected `success`.
check-typecheck-lanes: FAILED (2 problem(s) above).

TypeScript Type Check is an aggregator over four lanes. Two concluded cancelled and it refuses to report green over a lane whose verdict it could not read. Test Core went red the same way over its own cancelled shards (Test Core (2/6)(5/6)).

Why the shards were cancelled, and why that makes this not this PR's defect

They were cancelled by this PR's own newer push: the failure sits on head c6500241, which 03ba3112 superseded. A red conclusion of cancelled is a supersession, ⛔ not a discrepancy with a green run elsewhere.

⛔ And the aggregator's behaviour is ruled, not a bug: #3668's wiring was rewritten (#6082 counts shard attestations), the maintainer refused to whitelist lifecycle values on 2026-08-07, and #16157 measured the opposite defect — a cancelled shard turning a required check green over untested packages. ⇒ ⛔ never filed and ⛔ never "fixed". Reporting failure over an unread lane is the posture the repo chose on purpose.

State on the current head, read at 2026-09-11T22:30Z

Head 03ba3112 (5 commits, +1258/−26, draft): 16 success · 13 in progress · 2 skipped · 0 red. Type Check · source gates is already success; Type Check · workspace, · consumer gates and · debt ledger are still in_progress, so the aggregator's verdict on this head has not been computed yet.

What is and is not being done

  • No re-run. A re-run is for confirming a failure whose cause is unmeasured; this cause is measured, and the resolution is the current head's own run, already in flight.
  • No empty commit, ⛔ no close-and-reopen, ⛔ no test skipped or quarantined.
  • No push from this seat — 「PM 永不写代码」. The diagnosis went to the delivering dev, which is alive and still pushing, so a real failure is fixed at its source rather than patched over here.
  • If a Type Check · * lane concludes failure (⛔ not cancelled) on 03ba3112 or later, that is real and in scope — and it must be judged after collapsing check runs latest-per-name, because a failure can be superseded by another failure carrying a different diagnosis, not only by a cancellation.

This PR stays watched until it is green and mergeable or closed.

domain:cli execution PM seat · #6024 · session session_01TSf4DV7ziu4V5j73e46b7c · 2026-09-11T22:30Z


Generated by Claude Code

@claude

claude Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

Contract review

Head reviewed: 03ba311254f02dd44567619d25b16d2640f0ccde. Reviewed at default judgment tier (standing ruling 5612096863); ⛔ CONTRACT_REVIEW_TIER is not this lane's. Every row below is a reading from that ref or from command output, ⛔ not from the delivering report.

Reviewed-by: domain:cli execution PM seat (#6024), session session_01TSf4DV7ziu4V5j73e46b7c, 2026-09-11T22:55Z

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

Lint & Repo Gates concluded success at 2026-09-11T22:51:27Z. Collapsed latest-per-name on this head: 30 success · 4 skipped · 0 red · 0 running. mergeable true.

The authorized widening, item by item

the ruling said the diff does reading
--cert <path> --key <path>, ⛔ not --https cert: devTlsCertFlag(), key: devTlsKeyFlag() at dev.ts:154-155 a bare 'https' flag name returns 0, against a positive control of 69 for port in the same file
canonical origin derived from the listener resolveAuthBaseUrl(port, boundProtocol: ListenerProtocol = 'http'), tail ?? \${boundProtocol}://localhost:${port}` (serve.ts:5725-:5731`) the hardcoded http:// tail the ruling named is gone, derived instead
OS_AUTH_URL only as an override that tail sits after OS_AUTH_URL / BETTER_AUTH_URL / OS_BASE_URL in the same ?? chain configured values still win, http ones included
existing callers unmoved both widened exports take the new parameter with a default (publishBoundPort(…, boundProtocol = 'http') at :559) additive; no existing call site changes meaning
a new plugin option tls?: HttpsListenerMaterial (hono-plugin.ts:48) optional member

The ruling's hard floor — ⛔ no CA generation, ⛔ no trust-store prose — HOLDS, and the count that looked like a violation was not one

A probe for selfSigned|self-signed|generateCert|createCertificate|openssl|mkcert|certificate authority|\bCA\b over the seven changed source and docs files returned 0 for five of them and 2 + 2 for dev-tls-contract.ts and content/docs/deployment/cli.mdx. ⛔ A count is not a reading, so both were read in context:

  • dev-tls-contract.ts:17-:22 is the prohibition itself: 「⛔ Nothing here generates a certificate or a CA, and nothing here tells a developer how to install one into a system trust store」, with the ruling's security-statement rationale and 「The trust store is the developer's own business」. All three trust-store mentions in the file are that same negative statement.
  • cli.mdx's two hits are openssl rand -hex 32 in the --auth-secret examples at :161 and :372 — and they are pre-existing: the count is 2 on origin/main and 2 on this head, so this PR neither added nor touched them.

Dedicated trust-store probe (trust store|truststore|keychain|add-trusted-cert|update-ca-certificates|trust it|install the ca): 0 in both changed docs pages and 0 in dev.ts.

Scope, fences and the transfer red line

13 files. Intersection with PR #17454's six lane files and PR #17718's two: NONE. packages/spec edits: NONE ⇒ the transfer red line was not tripped. content/docs/releases/: NONE. plugin-hono-server is in this lane, so the declared growth into the adapter is in-lane, ⛔ not a cross-domain PR. content/docs/deployment/cli.mdx is free because #17722 merged — the merge releases a fence, ⛔ not the arm.

⭐ The docs half is more than the flags: the manual inputs-vs-emitter pass corrected environment-variables.mdx, which stated OS_AUTH_URL's default as http://localhost:<port> unconditionally — made conditionally false by this diff, and listed in no drift row, exactly as the advisory's own caveat predicts.

⛔ The one item I will not clear: the flag pair is user-reachable on os serve, and the ruling named objectstack dev

Measured, ⛔ not inferred. Enumerating all 62 non-test command files from this ref, devTlsCertFlag()/devTlsKeyFlag() appear in exactly two: commands/dev.ts and commands/serve.ts. The reason is in the code at serve.ts:977-:979:

#16804 — developer-supplied TLS, declared through the shared contract so dev (which spawns this command) and serve cannot drift on the flag names, the prose, or what half a pair means.

dev spawns serve, so the listening process is serve and the pair must be accepted there for the ruled feature to work at all. The ruling itself points into that file, naming resolveAuthBaseUrl's fallback tail — which lives in serve.ts. The mechanism is therefore entailed, and the alternative (an undeclared flag that works but is not declared) is the very defect class this lane files cards about.

⚠️ But entailment of the mechanism is not authorization of the surface: os serve is the production serve path, and the adjudication weighed a dev-time mode (its analysis speaks of 本地开发、演示与录屏). So the delivered public surface is two commands where the ruling named one, and TLS on the production serve path is a capability the maintainer has not weighed.

Against the confidence gate for acting without the maintainer: ① the four axes point the same way and ③ nothing overturns an existing ruling, but ② fails — this is 公开面扩张, an explicit item on the manual floor. ⇒ ⛔ this seat does not self-adjudicate it, ⛔ does not clear needs:contract-review, and ⛔ does not flip the PR ready.

What is needed: one answer from the maintainer — either the ruled dev-time mode covers the pair being declared on the process that actually listens (in which case this lands as-is), or the serve declaration is to be trimmed or hidden (in which case the trim is a new dispatch, ⛔ not a push from this seat). It goes into the round report as one line with that recommendation: land as-is, because the mechanism is entailed and a working-but-undeclared flag would be strictly worse.

⛔ Nothing else in this PR is blocked by that question, and ⛔ the gate stays hung on both carriers until it is answered — a one-sided removal is indistinguishable from 「never hung」.

domain:cli execution PM seat · #6024 · session session_01TSf4DV7ziu4V5j73e46b7c · 2026-09-11T22:55Z


Generated by Claude Code

This was referenced Sep 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

objectstack dev has no https mode, so the advertised OAuth path for interactive MCP clients cannot be tried against a local dev server

1 participant