Repository navigation
feat(plugin-auth): MCP OAuth over plain HTTP on a private / link-local deployment host - #19534
Conversation
…l deployment host The OAuth 2.1 transport rule for the MCP track becomes a DEPLOYMENT-level rule with the semantics of Keycloak's `sslRequired=external`: plain HTTP is eligible when the deployment's own canonical origin is loopback or a private / link-local address (RFC 1918 10/8, 172.16/12, 192.168/16; RFC 4193 fc00::/7; 169.254/16, fe80::/10). A PUBLIC host keeps TLS-required and fail-closed, byte-for-byte as before. A deployment already serving its login form and session cookies over plain HTTP gains nothing from OAuth refusing plain HTTP — the refusal only removes MCP from that deployment. An intranet install and a developer's `os dev` bound to a LAN address are the same case, so development mode is subsumed and there is no separate dev-mode branch point. No configuration key and no environment variable can open plain HTTP on a public host; a switch would be reachable exactly where this rule must keep refusing. One loud line at discovery-route mount whenever OAuth is served over plain HTTP, none under TLS. The rule judges the HOST LITERAL of the deployment's own origin, so a non-IP intranet hostname (crm.corp, host.docker.internal) stays refused over plain HTTP. Resolving the name in DNS and judging the requester's peer address were both considered and rejected on the record, in the function's docblock. Adds the standing platform-checklist item the card was filed for (ai.mcp-oauth-private-host-transport): no item asserted MCP OAuth from a non-loopback host at all, so neither the arm that opened nor the public arm that must stay closed had a run. The requirement comes from the community fork PR, whose author reported that MCP cannot be tested from a development environment behind the https requirement; its implementation and its `OS_ALLOW_INSECURE_OAUTH_HTTP` escape hatch are deliberately NOT adopted. Co-authored-by: jinyitao123 <jinyitao123@users.noreply.github.com> Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 15 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 14 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 062bad4d86375d2d1b5eafd53faad9d3427feb82 && git checkout 062bad4d86375d2d1b5eafd53faad9d3427feb82
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b3615f1a4cd7f3ff59ff0548530daa042627f732 18f44168d1764ec60b7324fb4c4d6b9348b34620 && git checkout -B drift-repro b3615f1a4cd7f3ff59ff0548530daa042627f732 && git merge --no-ff 18f44168d1764ec60b7324fb4c4d6b9348b34620
node scripts/docs-audit/affected-docs.mjs --json b3615f1a4cd7f3ff59ff0548530daa042627f732
|
…e ACCEPTED the origin Contract-review round 2 on the draft PR. Three findings, all on the notice and the pins around it; the predicate's verdicts are untouched. 1. The notice was keyed on the issuer's SCHEME ALONE, so a PUBLIC plain-HTTP boot emitted it — and its own sentence, that the transport rule accepts this origin, is FALSE of a deployment the same rule refused and whose OAuth track is dark. An operator reading it would conclude a public plaintext authorization server is a posture this rule permits. The condition now carries both halves: plain HTTP AND eligible. That deployment's line stays the `OAuth track is NOT live` warning below it — a different sentence with the opposite meaning. Three public origins are pinned at zero notices; before this commit the file's every origin was an eligible one, so the face was uncovered. 2. The log line is English, as every other repository artefact is; the ruling's own wording is preserved verbatim, as the quotation it is, in the comment directly above the emit site. The platform-checklist item's grep anchor moves with it to `OAuth is served UNENCRYPTED`, so it scores something a boot log actually carries instead of becoming an empty anchor. 3. Loopback 127.0.0.0/8 had no pin: reverting it to the single literal `127.0.0.1` failed nothing. `127.0.0.2` and `127.255.255.254` are pinned eligible, with `126.0.0.1` and `128.0.0.1` beside them as the public blocks on either side. Also drops the duplicated dotted-quad regex the review flagged: the IPv4 half reads through this file's own ADR-0069 D5 helpers (`ipv4ToInt`, `ipMatchesRange`), and the ranges are spelled as the CIDR blocks the ruling names. Behaviour-identical — the helper carries the same anchored regex and the same octet refusal — and no existing caller is touched. Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF Co-authored-by: Claude <noreply@anthropic.com>
…gain Contract-review round 3. The predicate's verdicts are untouched for the second round running; this is the warning's text, the changeset's accuracy, and five boundary legs. 1. The ruling fixes this warning's wording and its acceptance says the startup log CARRIES it, so the phrase belongs in the emitted string and not only in a comment beside it. The English operational clause follows it on the same line, and the platform-checklist item's grep anchor moves back with it. The general repository convention that artefacts are English and the specific ruling genuinely pull apart here — this is the first CJK string in a package source — and that tension is a decision of its own, not one this call site settles. 2. The changeset said the line is emitted "whenever OAuth is served over plain HTTP". It is not, since the previous commit: a public plain-HTTP deployment is refused by the transport rule and gets the separate "OAuth track is NOT live" warning instead. It now says what runs. 3. Three of the five eligible IPv4 blocks had no adjacent-block refusal at all: widening 10.0.0.0/8 to /7 or /6, 192.168.0.0/16 to /8, or 169.254.0.0/16 to /15 changed no assertion in this tree. The gap predates the CIDR table — the same widenings were silent against the arithmetic form too — but a range spelled in one character is a range a typo moves, so each block now owes its neighbours: 11.0.0.1, 192.167.0.1, 192.169.0.1, 169.253.0.1 and 169.255.0.1, all refused. Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF Co-authored-by: Claude <noreply@anthropic.com>
契约复核 provenance —— 安全边界卡,达档复核两轮 PASS 在案
Reviewed-by: 隔离达档子代理( 为什么复核跑了两轮第一轮判后,补丁轮顺手做掉了复核自己提的非阻塞质量注:删 ⇒ 被穷举扫过的那条代码路径被换掉了。 dev 的报告写着「no predicate logic line was touched」,但该句被其自身的改动项证伪。⛔ 拿旧实现的通行证给新实现放行,是本席明确拒绝的做法 ⇒ 派回重扫。 重扫结论(第二轮,⛔ 未复用第一轮任何读数):
⭐ 本席现验:落地 head 三轮补丁修掉的东西
席位自身的一处越界,已撤回日志措辞的中文/英文之争由 dev 正确上呈请维护者裁,本席一度自判并已撤回(更正 入队闸(全部现读)CI(按 check-name 取最新,钉 ⛔ 刻意未决,已上呈公网明文 AS 的告警归属、以及裁定中文措辞与 Generated by Claude Code |
…nencrypted, in English (objectstack-ai#19689) Fixes objectstack-ai#19571 Implements ruling batch objectstack-ai#210 item 5 (letter B/B, maintainer 「210 同意」), which governs over the issue body. Upstream ruling: objectstack-ai#19489 item ④. PR objectstack-ai#19534 is landed and is **not** reverted here — this is its follow-up. Clause-②: no ## D1 — eligibility decides WHICH sentence, never WHETHER one is emitted `packages/plugins/plugin-auth/src/auth-plugin.ts#registerOidcDiscoveryRoutes`. The structural defect, verified on `origin/main` before writing code: the three AS discovery routes (`/.well-known/oauth-authorization-server`, `/.well-known/openid-configuration` and the RFC 8414 §3.1 path-insertion variant) are mounted unconditionally, while the only line that mentioned a refused transport — `MCP server is enabled but the OAuth track is NOT live` — sits **inside** `if (readMcpServerEnabledEnv() && ...)`. A public plain-HTTP boot with `OS_MCP_SERVER_ENABLED=false` therefore emitted **no warning at all**, while publishing its authorization-server metadata in the clear. The loudest-needed configuration was the quietest. The plain-HTTP branch now has a sibling `else if`, beside it and **not** inside the MCP block: - accepted origin (loopback / private / link-local) — unchanged line, minus its CJK prefix: `OAuth is served UNENCRYPTED: ...` - refused origin (public host) — new, distinct line: `OAuth discovery is served over PUBLIC plain HTTP: ...`, naming (a) the discovery documents and the issuer, (b) that the MCP OAuth track is DISABLED, (c) that TLS is the remedy. Both fire once at mount, neither under TLS, and no configuration key or environment variable gates either. **⛔ No admission decision changes.** `isOAuthEligibleBaseUrl` and every transport-rule predicate are byte-unchanged; the discovery routes are mounted exactly where and when they were. ⛔ No new exported symbol (`servedOverPlainHttp` is a local const) — the `Clause-②: no` declaration stands, and the mechanical floor at `references/contract-review.md:12` is not tripped. ## D2 — the emitted string is English; the ruled sentence moves to the comment The `'OAuth 未加密:仅限可信内网 — '` prefix is out of the executable string. The maintainer's sentence is kept verbatim in the code comment beside the call, cited to the objectstack-ai#19489 ruling and to batch objectstack-ai#210 item 5, and read as the line's meaning rather than its literal encoding. ⛔ No CJK executable string remains in either file's `src` tree; ⛔ no bilingual line. `AGENTS.md` is untouched. The comment block above the branch previously argued the **opposite** — that the Chinese belongs in the emitted string "not only in this comment". That paragraph is falsified by this ruling and has been rewritten to state the current rule with its citation. ## Carriers moved with it 1. `packages/plugins/plugin-auth/src/auth-plugin.ts` — the two branches and the comment. 2. `packages/plugins/plugin-auth/src/mcp-oauth-plaintext-notice.test.ts` — `NOTICE_MARKER` re-anchored to the English line; `PUBLIC_NOTICE_MARKER` added; the "does NOT fire on a PUBLIC plain-HTTP deployment" leg rewritten to assert the sentence **swaps** rather than vanishes; new coverage for the MCP-surface-OFF boot, MCP-independence, once-per-mount, warn level, mutual exclusivity and the no-env-var floor. 15 tests, all green. 3. `docs/qa/platform-checklist/areas/ai.json` — `ai.mcp-oauth-private-host-transport` revision 1 → 2 with its history entry. The grep anchors move to the two English lines; case (b) moves from asserting **0** occurrences to asserting **exactly 1** of the new line; step text, `clause`, `verify`, the `negative` conflation entry, the `source` line and the title moved together. 4. `.changeset/19489-oauth-private-host-transport-rule.md` — still unreleased on `origin/main` (the file is present in `.changeset/`, so `changeset version` has not consumed it). Its last bullet asserted the startup line carries the Chinese and that a public plain-HTTP boot gets no such line; both become false when this lands, so the sentence is corrected. The Chinese is kept only as a quoted ruling citation. Plus a new changeset, `.changeset/19571-plaintext-oauth-public-host-notice.md` (`@objectstack/plugin-auth: patch`). ##⚠️ One gate is red BY DESIGN and needs a human word `node scripts/check-empty-changeset.mjs --base origin/main` — **exit 1**, on carrier 4: > `.changeset/19489-oauth-private-host-transport-rule.md` present on the merge base and CHANGED by this PR This is the gate's **DELIBERATE CORRECTION** class, not the COLLISION class. Its own text says the remedy is *not* to restore the file — restoring it from the base republishes a sentence this PR makes false — and that correcting a pending release note "is a decision about a release rather than a refactor — say so on the PR, naming the note and what changed under it, and get it confirmed". Naming it here: the note is `19489-oauth-private-host-transport-rule.md`, and what changed under it is the D1 branch above. The gate stays red until a person confirms. ## Verification | check | result | |:---|:---| | `pnpm --filter @objectstack/plugin-auth test` | 114 files / **2439 passed** | | `pnpm --filter @objectstack/plugin-auth run typecheck` | exit 0 (after building the package; `tsconfig.examples.json` resolves through `dist`) | | `pnpm lint` (repo-wide `eslint . --no-inline-config`) | exit 0 | | `pnpm --filter '@objectstack/plugin-auth^...' build` | exit 0 | | derived gate families (`scripts/pm/dispatch-gates.mjs --commands`), 66 commands | 63 green · 1 red by design (above) · 2 `PREREQUISITE NOT MET` | `pnpm check:platform-checklist`, `check:nul-bytes`, `check:doc-authoring`, `check:test-source-alias`, `check:cross-package-test-inputs`, `check:published-files`, `check:type-check-coverage`, `check:adr-0087-registration`, `check:changeset-no-major` all exit 0. NOT MEASURED (each prints `PREREQUISITE NOT MET` — explicitly "not a pass and not a finding" — and needs a full workspace build, which is CI's): `pnpm check:dual-build-cjs-loads` (exit 3), `pnpm check:type-check-debt` (exit 3). This diff changes no `exports`, no `package.json` and no build shape. ### Reverse verification (both legs from the committed state; restored byte-identically) Run through `scripts/ablation-replace.mjs`, which proves the mutation landed on disk by blob hash and proves the restore by `git diff HEAD` being empty. The subject resolves from `src` through a relative import, so no `dist` leg applies. 1. **The new branch can fail.** `} else if (servedOverPlainHttp) {` → `} else if (servedOverPlainHttp && false) {`. Blob `17bd405104b2` → `eb7d11ae9d01`. Result: **9 failed | 6 passed (15)** — every public-face assertion red. Restored: blob back to `17bd405104b2`, `git diff HEAD` 0 bytes. 2. **The D2 pin can fail.** The CJK prefix re-added to the accepted line. Blob `17bd405104b2` → `c7aba397fff2`. Result: **1 failed | 14 passed (15)** — `fires on a private-address deployment` red on the no-CJK assertion. Restored: `ok restored: blob == HEAD (17bd405) and git diff HEAD is empty`. Predicted direction was RED for both, and RED is what was observed. ## Acceptance notes - The comment now states explicitly that whether the `.well-known` discovery routes should be mounted at all on a refused origin is pre-existing `main` behaviour neither ruling touched, and is not decided at that call site — per the ruling, that would be a card on its own evidence, ⛔ not folded here. --- _Generated by [Claude Code](https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…s card (objectstack-ai#19895) Fixes objectstack-ai#19883 Clause-②: no ## 维护者速读(草稿) **改了什么**:三份 PM 协议细则文件,共 +5 / −3 行。① 采纳了 fork PR 的卡,正文恒带一行行首 `Adopts: #M`(M = 那个 fork PR 的号),立卡那一笔就写;② 落地收口清单(确认 MERGED、给卡收口的那一笔)加一行:卡带这一行,就在同一动作里给 fork PR 贴致谢 + 落地链接并关闭它;需求被拒(卡关 not_planned)时,关卡那一笔同样贴致谢与谢绝理由并关闭;③ 分诊席每次读的职责文件加一行:每次 fire 扫开着的 fork PR,没卡就立卡,卡已关而 fork PR 还开着就补关。两份有行数上限的文件各加一行、各退一行同文件里已原样写着的重复内容,上限不动。 **为什么改**:维护者原话「这种pr应该写个评论直接关闭。」「"这种 PR"都应该这样处理,开一个 skills 卡片」。外部贡献者的 PR 在需求已由内部 PR 落地后被晾了两天没人关:这条规则只写在一份落地时没有席位会读的文件里,落地收口清单里没有这一步,卡上指向 fork PR 的只是一段散文。另外核实发现,「分诊每次 fire 扫 fork PR」这一步本身也不在分诊席每次读的职责文件里,只在那份没人读的文件里。 **风险与代价(含回滚)**:纯文本改动:不加门禁、不加脚本、不加巡查行、不动行数上限。代价是分诊每次 fire 多一次「列出开着的 PR」的读取(当前 23 个开放 PR,0 个 fork),以及卡正文多一种行首拼写 `Adopts:`。退掉的两行,其每一句在同一文件里都还在(逐条对照见下文表格)。回滚 = revert 这一个 squash commit。 **席位意见**: **你要做的**:无需点击。本 PR 只碰 `.claude/**`,属 Tier S,由 skills 席在席内契约复核 PASS 后走队列落地。若不希望分诊每次 fire 都扫 fork PR(只保留落地那一笔),在本 PR 评论一句即可,删掉 triage 那一行。 ## What this PR does Three files under `.claude/skills/pm-dispatch/references/`, +5 / −3. 1. **`external-contributions.md`** (the fork-PR playbook) - Step ①: the card behind a fork PR always carries a line-start `Adopts: #M` (M = the fork PR's number). It is written in the stroke that files the card, or that attaches the fork PR to a card that already exists. The landing closeout and the triage catch-up read only this line. - Step ③: the line "after the internal PR lands, close the fork PR with thanks and the landing link" becomes: the stroke that closes the card also closes the fork PR its `Adopts:` line names. On `completed` the closing comment carries thanks and the landing link. On `not_planned` it carries thanks and the reason the need was declined. The declined case had no text before. - The sentence that limits a seat's comments on a fork PR is rewritten, not worked around. It used to say "only these two kinds" after naming review comments and the close. It now names them: apart from step ①'s fixed comment, a seat writes review comments and the closing comment, and nothing else. The declined form is the closing comment's second form, not a third kind. 2. **`landing-operations.md` §B, the MERGED closeout**, the home of "the landing stroke": one line directly after the `Part of` / `Fixes` closeout line. A card closing `completed` with an `Adopts:` line means the same action posts thanks and the landing link on that fork PR and closes it. 3. **`triage-duties.md`**: one line at the end of the backlog-sweep block. Every fire also scans open fork PRs. No card: file one per step ①. Card already closed: close the fork PR per step ③. ## Why this shape — the four axes The card offers two candidates, A (at the landing step) and B (in the triage sweep), and leaves the shape to the seat. `SKILL.md`'s order for a failure fix is: remove the construct that allows the error, make the right form the only spelling, and only then add a check. - **Remove the construct.** The miss was allowed because the close was a separate, later act. It was written only in `external-contributions.md`, and no seat reads that file at MERGED. The landing seat reads `landing-operations.md`, whose closeout list had no fork-PR item. The card named its fork PR only in free prose: an "Adoption of PR" section and a promise to close it "in the landing stroke". Shape A removes the construct: the close is now an item of the closeout list itself, in the same action that closes the card. - **One spelling.** `Adopts: #M`, line-start and undecorated, the same form as `Blocked-by:`. It has three named readers: the landing closeout, the triage catch-up, and step ①'s "does this fork PR already have a card". Measured on the one case: the fork PR's timeline carries **10** cross-references from other issues and PRs, one of them its card. The line is what picks the card out of the ten. - **Check.** None is added: no gate, no half-state row, no ratchet. Neither ruling sentence names one, and a new gate defaults to no. B is one prose duty line on a scan the triage seat already owes by step ①. It is not a patrol row. - **Why B at all, with A in place.** The declined case has no other reader. A `not_planned` close happens in several seats' flows: a triage first-touch close, a decision-box answer, a falsified premise. The one file every seat reads is `SKILL.md`, which sits at 319 / 319 and is held by PR objectstack-ai#19890. The triage fire is the one reader that sees every closed card's fork PR, and it is also the backstop if a landing stroke misses one. - **Real business need (measured).** Fork PRs are rare: 1 among the 500 most recent closed PRs (created 2026-09-17T18:37Z .. 2026-09-23T14:41Z). That one is the fork PR that was missed. 0 of 23 open PRs are forks at 2026-09-23T15:29:57Z. The maintainer's word covers every such PR, and the one miss cost an outside contributor two days of silence. - **Startup focus.** +2 lines net across the corpus. Both ceilinged files stay at their ceilings: `landing-operations.md` 101 / 101, `triage-duties.md` 120 / 120. `external-contributions.md` goes 16 → 18; that file is not in the ceiling map. - **Rejected alternative.** Teach `scripts/pm/close-cards.mjs` to read `Adopts:` and refuse a card close that does not also close the fork PR. That is structurally the strongest shape, but it is a new mechanical check that no maintainer word names, and it is outside this card's claimed file surface. ## Premise check, on `origin/main` at `dabf8d7` - **Confirmed.** The rule's only carrier was `external-contributions.md` (the old line 13), as prose. Fork PR objectstack-ai#19342 stayed open from the internal landing to 2026-09-23T14:42:37Z. Card objectstack-ai#19489 is `closed` / `completed` (2026-09-21T10:32:18Z). PR objectstack-ai#19534 merged 2026-09-21T10:32:16Z as `2aac821a8c`. - **Correction to one reading in the dispatch.** `triage-duties.md` had **no** fork-PR scan line. `git grep -c -i fork dabf8d7 -- …/triage-duties.md` exits 1 (zero hits). The control on the same tree and term, `…/external-contributions.md`, hits 8 and exits 0. Step ①'s scan lived only in `external-contributions.md`, and `SKILL.md`'s phase-file index names no reader for that file. The pre-relocation `SKILL.md` (`f151ef2^`) pointed at it only from the enqueue-and-landing section. So candidate B's premise, "step ① already scans open PRs on every fire", held on paper only. The new triage line is what gives step ① a reader. - The dispatch was cut at `e9eb2244d5`; this worktree starts at `dabf8d7`. `git diff --stat e9eb224 dabf8d7 -- .claude/skills/pm-dispatch/` is empty. ## Retired lines: every clause is still stated in the same file | retired | where each clause still stands | |---|---| | `landing-operations.md` old :19, "follow to MERGED; after enqueue the watch belongs to the lane PM's landing window: every round read the queue branch and `origin/main`" | §B heading 「跟到 MERGED 为止;入队后的看护归车道 PM 落地窗口」 and §B 「确认 MERGED 要两个读数:每轮同时读队列分支与 `origin/main`。」 | | `triage-duties.md` old :8, "the tool-loading discipline binds only the triage fresh session's opening; execution seats and devs are not bound" | line 3: the file is the triage seat's alone. Line 6 「fire 开局只按名加载…判定本轮有活才加载其余」 and line 7 「⛔ 分诊 fresh session 开局不做泛关键词 ToolSearch」 are both scoped to the opening already. | Neither retired line is quoted anywhere else in the tree (`git grep` over both texts: the only hits are the lines themselves). ## Gates, on `4a3242c` `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 18 families from the 3 changed paths, merge base `dabf8d795`. All 18 were run, each exit captured before any pipe. `--ran` verdict: `✓ dispatch-gates --ran: 18 derived famil(ies) accounted for — 18 run, 0 NOT-MEASURED (a DERIVED zero — all 18 recorded an exit code and none of them is 3).` - `pnpm check:pm-skill-ratchet`: exit 0. `landing-operations.md is 101 lines (ceiling 101; headroom 0)`; `triage-duties.md is 120 lines (ceiling 120; headroom 0)`. Every changed line is 120 bytes or less. - `pnpm check:pm-skill-id-lint`: exit 0, `34 file(s) clean (pattern /#[0-9]{3,}/g)`. No rule line cites a tracker number. - `pnpm check:pm-governed-prose` (named by the dispatch, outside the derivation): exit 0. - `pnpm check:nul-bytes`: exit 0, `no raw ASCII control bytes`. - `pnpm check:skill-frame-sync`: exit 0. - `pnpm check:pm-half-states`: exit 0, `4912 cases pass`. - `pnpm check:pm-governed-merges`: exit 0. - `pnpm --filter @objectstack/lint run check:doc-formula-expressions`: the first run exited 3 (PREREQUISITE NOT MET, `@objectstack/formula` and `@objectstack/lint` not built). After `turbo run build` for those two packages under the verify lock (VERDICT command-exit 0), the re-run exited 0. - The other 10 derived families (closing-keyword parity ×2, comment-mask corpus, harness-current, agent-test spelling, cross-package test inputs, doc authoring, driver-memory census, gitlink, refd-timer probe, watch-hint literal): exit 0. No package is touched, so there is no build closure and no package test to run. Nothing here is published (`.claude/**`), so this PR carries `skip-changeset`. ## Acceptance notes - `SKILL.md`'s phase-file index names no reader for `references/external-contributions.md`. After this PR both executing seats' files point at it (`landing-operations.md` line 7, `triage-duties.md`), so no index row is owed. Carrier: none. - `references/external-contributions.md` is not in the line ratchet's ceiling map, while the other `pm-dispatch` reference files are. Stated as a fact, not a gap. Carrier: none. - A fork-PR card that closes as `duplicate` is not covered: its `Adopts:` line would need to move to the surviving card. There are 0 instances. Carrier: none. - `check-half-states.mjs`'s H65 message quotes `triage-duties.md` as 「选层按 fire 时刻,⛔ 不用计数器;简报写明本轮跑的层」, but the file says 「选层按 fire 时刻 ⛔ 不用计数器,简报写明层」. This is report-only message text. Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_01A22sUB3mUWs6M36VgfijBq)_ Co-authored-by: Claude <noreply@anthropic.com>
Fixes #19489
Clause-②: no
MCP OAuth becomes eligible over plain HTTP when the deployment's own canonical origin is loopback or a private / link-local address — RFC 1918
10/8,172.16/12,192.168/16; RFC 4193fc00::/7;169.254/16,fe80::/10. A public host keeps TLS-required and fail-closed, exactly as today. Shape ruled by the maintainer, 2026-09-21, verbatim: 「要(开发模式)」 and 「19342 同意兼容」 — a deployment-level transport rule with the semantics of Keycloak'ssslRequired=external. No configuration key, no environment variable, and no separate dev-mode branch point (a dev bind on a LAN address is a private address).Landed at
91e0ec6e0, which every reading below was taken against.The two readings the card asked for first
1. Do the mainstream MCP clients complete OAuth against an
http://server at all?Yes — nothing in the reference client stack refuses the scheme. This compatibility is not bought for nothing.
localhostor use HTTPS."@modelcontextprotocol/sdk1.30.0, atnode_modules/.pnpm/@modelcontextprotocol+sdk@1.30.0_zod@4.6.1/node_modules/@modelcontextprotocol/sdk. Its whole client OAuth implementation (dist/esm/client/auth.js) contains onehttps:comparison, insideisHttpsUrlat line 322, and that predicate has exactly one caller: line 233, validating a SEP-991 URL-basedclient_id. The server URL, the authorization-server metadata URL, the protected-resource metadata URL, the authorization endpoint and the token endpoint are never compared against a scheme, inclient/auth.jsor inshared/auth-utils.js.2. The hostname case — which reading is pinned, and why
The rule judges the HOST LITERAL of the deployment's own canonical origin. An intranet hostname is not an IP literal, so
http://crm.corpandhttp://host.docker.internalstay refused; the remedy is to configure the base URL on the private address the deployment already binds, e.g.http://192.168.1.10:3000. This is stated in the function's docblock and pinned by tests, not left silent.Both alternatives the card named were considered and rejected, on the record:
isMcpOAuthEnabled()call — including the per-request bearer path — and a name whose answer can change, or be rebound, makes the transport verdict something no test can pin.Control legs — every one of them, with its reading
All from
pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2 src/auth-manager.mcp-oauth.test.tsat91e0ec6e0: 65 passed, 0 failed.1. Positive — private / link-local over plain HTTP is eligible
http://10.0.0.5:3000http://192.168.1.10http://172.16.0.1http://172.31.255.255http://[fc00::1]http://169.254.1.1http://[fe80::1]2. Negative — the security floor. A public host over plain HTTP is still refused
http://example.comhttp://203.0.113.5http://intranet.corp:3000http://host.docker.internal:3000Carried through the manager as well, not only the predicate:
new AuthManager({ baseUrl: 'http://example.com' })answersisMcpOAuthEnabled() === falseandgetMcpResourceMetadataUrl() === null— nothing advertised, fail-closed.3. Invariants — untouched
https://on any host (acme.example.com,intranet.corp,203.0.113.5) is eligible.ftp://localhostandws://10.0.0.5are false; a non-URL is false. Loopback in every form is eligible:localhost,127.0.0.1,[::1],*.localhost— and127.0.0.2, since 127.0.0.0/8 is loopback and leaving it refused beside an eligible10.0.0.5would be incoherent.4. Boundary traps — each one refused in the unsafe direction
http://172.15.0.1http://172.32.0.1http://10.0.0.5.evil.comhttp://192.168.1.10.attacker.testhttp://[fec0::1]fc00::/7http://[febf::1]fe80::/10prefix — the inclusive edgehttp://[fdff:ffff::1]fc00::/7prefix — the inclusive edgehttp://[::ffff:10.0.0.5]http://0.0.0.0:3000http://[::]:3000http://16777216110.0.0.1— it is that addressThe refusals are IP-literal parses, not text prefixes: the IPv4 branch is a both-ends-anchored four-octet match, and the IPv6 branch expands
::elision to eight groups and masks bits (first & 0xfe00 === 0xfc00,first & 0xffc0 === 0xfe80).5. The warning fires once, on plaintext only
packages/plugins/plugin-auth/src/mcp-oauth-plaintext-notice.test.ts— 6 passed. It drives the discovery mount and counts occurrences of the ruled wording 「OAuth 未加密:仅限可信内网」 in the captured log:http://192.168.1.10:3000— 1 occurrence, naming the issuerhttp://192.168.1.10:3000/api/v1/authhttp://localhost:3000— 1 occurrence (loopback is plain HTTP too; the ruling exempts only TLS)https://acme.example.com— 0 occurrenceshttp://10.0.0.5:3000— 1 occurrence while the same call mounts several routes, so route count cannot move notice countwarn, never atinfoOS_ALLOW_INSECURE_OAUTH_HTTP=truechanges none of itNo key and no variable exists.
isOAuthEligibleBaseUrltakes one argument — the deployment's own origin — and reads no process state; the test asserts its arity and re-runs the public refusals withOS_ALLOW_INSECURE_OAUTH_HTTP=trueexported.git grep OS_ALLOW_INSECURE_OAUTH_HTTPfinds it in test setup only.Reverse verification — three ablations, each landed on disk and restored byte-identical
Run through
node scripts/ablation-replace.mjsfrom the committed state, so each mutation's landing and each restore is the tool's verdict rather than a claim. Predicted direction for all three: turn red. Observed: turn red, in the predicted legs.$)171d493d987ato833fb090cb6d10.0.0.5.evil.comand192.168.1.10.attacker.testboth became eligible171d493d987a,git diff HEADemptyreturn isPrivateOrLoopbackHostLiteral(host)becomesreturn true171d493d987ato9ed3b10ffe2d171d493d987a,git diff HEADemptyif0c0ec2f98d6cto3e57b0cbb6490c0ec2f98d6c,git diff HEADemptyResolution note: both test files import the subject by relative source path, so the subject is
src/, notdist/— no rebuild step stands between a mutation and the run it is measured by.Verification
pnpm --filter '@objectstack/plugin-auth^...' build --concurrency=2— exit 0 (underscripts/pm/os-verify-lock.sh,VERDICT command-exit 0)pnpm --filter @objectstack/plugin-auth test— 114 files / 2421 tests passed, exit 0 (locked,VERDICT command-exit 0)pnpm --filter @objectstack/plugin-auth build && pnpm --filter @objectstack/plugin-auth typecheck— exit 0 (locked).check:test-typecheckheld its shrink-only ledger at 10 files / 94 errors / 23 pinned signaturespnpm lint— the repo-wideeslint . --no-inline-config, exit 0. Not a narrowed run: the whole governed population was linted, so no narrowing needs provingpnpm check:platform-checklist— exit 0; 15 areas, 265 items, 619/637 symbol anchors resolvedDerived gate families:
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 66; all 66 were run and reconciled with their exit codes through--ran. 64 exit 0. The remaining two are NOT MEASURED, not red — both exit 3,PREREQUISITE NOT MET:pnpm check:dual-build-cjs-loads— needs a whole-workspacedist/; it names 40 unbuilt packages, none of them in this diff. Covered by CI'sBuild Core.pnpm check:type-check-debt— refuses to re-measure without the built dependency closure, on purpose. Covered by CI'sTypeScript Type Check, which builds the closure first.Neither can be moved by this diff: it adds no package, no export and no entry point, and touches source in one already-built package whose own typecheck is green above.
Acceptance notes
AGENTS.mdsays repository artifacts keep their existing language convention, andgit grepfinds no CJK in any package source today outsidetranslations/. The line therefore leads with the ruled clause verbatim and continues in English. Flagging it so a reviewer can rule the other way — the alternative was to keep the ruled phrase only in a code comment, which would leave nothing in the log for the checklist item'sgrepto score.127.0.0.1. Strictly inside "loopback", which the ruling's first arm names, and refusing127.0.0.2beside an eligible10.0.0.5would have been incoherent. Noted rather than assumed.http://intranet.corp:3000is refused because it is a hostname rather than an IP literal, which the old title's "TLS rule" no longer describes.registerOidcDiscoveryRoutesis private, async, and dynamically imports the provider, so the new test drives it through a cast. Carrier: none — no queued card and no PR touches this seam, and it is an observation about test ergonomics rather than a defect, a contract violation or an authoring trap.维护者速读(草稿)
改了什么 —— MCP 的 OAuth 以前只有 TLS 部署(外加 loopback)能用;现在内网部署也能用:部署自己的地址是
10.x/192.168.x/172.16-31.x/169.254.x/ IPv6 的fc00::/7/fe80::/10时,走明文 HTTP 也照常提供 OAuth。公网地址一个字节没动,仍然拒绝。为什么改 —— 一个已经用明文 HTTP 提供登录页和会话 cookie 的部署,OAuth 再拒绝明文并不多保护什么,只是让这个部署用不上 MCP。开发人员在局域网上跑
os dev是同一件事,所以没有单独的"开发模式"开关。风险与代价(含回滚) —— 风险集中在一处:私网判断写错就等于把明文 OAuth 开给公网。为此判断读的是 IP 字面量而不是文本前缀,
172.15.x、10.0.0.5.evil.com、fec0::1这些"看着像内网"的全部拒绝,并且用三次消融证明这些断言真的会失败。无配置键、无环境变量,所以没有"被误开"的面。回滚 = revert 本 PR 一个提交,isOAuthEligibleBaseUrl回到只认 loopback,无数据、无迁移、无发布面残留。内网主机名(crm.corp)仍然拒绝,改法是把 baseUrl 写成内网 IP。席位意见 —— (留空,待席位定稿)
你要做的 —— 确认两点:① 明文告警里保留中文原话是否可接受(全仓代码目前无中文);② 内网主机名继续拒绝、只认 IP 字面量,是否就是你要的边界。
Generated by Claude Code
18f44168d1为准(本节由认领席domain:services补写)dev 正文只写一次、⛔ 不 PATCH,而本 PR 走了三轮。以上正文写于第一轮 head
91e0ec6e0,其中若干陈述已过期。冲突处以本节为准。head 轨迹:
91e0ec6e0→e7c4df5a34→18f44168d1。1. 🔴
127.0.0.0/8—— 正文当时称已钉,当时并没有;现在有了第一轮正文在 Invariants 段写「and
127.0.0.2, since 127.0.0.0/8 is loopback」。隔离达档复核实测:把127.0.0.0/8改回127.0.0.1/32的消融当时失败 0 条 ⇒ 树上没有这条断言。行为一直是对的,声明的钉子不存在。现已补钉(第二轮):
127.0.0.2/127.255.255.254eligible,126.0.0.1/128.0.0.1refused;同一消融现在失败 2 条。2. 日志措辞:提出 → 被席位误裁 → 撤回 → 按裁定原文落地
第一轮正文把「中文原话留日志 vs 只留注释」挂出来请维护者裁,这是对的。认领席一度判了后者并已撤回(更正
5757931777)——那是重裁,不是裁量:裁定第 ④ 条逐字含「(「OAuth 未加密:仅限可信内网」)」,验收段写明日志要 carries 它。最终实况(运行时实测,⛔ 非读源码):发出的字符串以裁定原话开头,英文操作性说明接在同一行后半。checklist 锚点随之归位。
AGENTS.md:31(仓库产物以英文为惯例)的张力未在本 PR 解决,已另立决策卡 #19571 交维护者。3. 告警条件:补了资格判断,并因此新增了公网腿
auth-plugin.ts的条件由「只看 scheme」改为「明文 且 eligible」。修的是一句假陈述:公网明文 boot 上原条件会打印「This is accepted only because the host is loopback or a private / link-local address」,而该部署恰恰未被接受(复核用真源码驱动复现,PROBE-PUBLIC-NOTICE-COUNT=1)。告警腿现为 1 / 1 / 0 / 1 + 公网 0:私网、loopback 各 1;TLS 0;多路由挂载仍 1;三个公网明文 origin(
example.com、203.0.113.5、intranet.corp:3000)各 0 —— 这一面此前零覆盖(文件里每个 origin 都是 eligible 的)。4. 边界表新增五行 —— 闭一个既存覆盖缺口
10/8、192.168/16、169.254/16三个块此前没有任何相邻块拒绝腿:把10.0.0.0/8放宽到/6(静默放开8.8.8.8)、192.168.0.0/16放宽到/8(放开整个192.0.0.0/8)等变异,失败断言均为 0。现补:
11.0.0.1·192.167.0.1·192.169.0.1·169.253.0.1·169.255.0.1全 refused。对应五个消融各自变红(1/2/1/2/1)。5. IPv4 解析器去重(第二轮,复核的非阻塞质量注)
删
parseIpv4Literal,IPv4 臂改走同文件 ADR-0069 D5 的ipv4ToInt/ipMatchesRange+ 具名 CIDR 表。🔴 这次重构让第一轮的复核结论失效过一次,因为被扫的正是这条路径。已重扫:接受面 83/83 逐行不变(CHANGED=0);
ipMatchesRange对 393,216 个地址与独立算术参考谓词对拍 0 mismatch;IPv6 臂 131,092 个字面量对拍 0 差异;ipv4ToInt新增的.trim()实测在u.hostname取值域里不可达,且即便可达也只归一化、不放宽。final head
18f44168d1的auth-manager.ts与被重扫的e7c4df5a34逐字节相同(认领席现验)⇒ 重扫结论适用于将要落地的实现。6. 反向验证:三轮共 11 次消融(3 + 3 + 5)
正文原三行表已被取代。每次消融均自 committed 状态落盘、按 blob 核实落地与还原、
git diff HEAD为空。7. ⛔ 刻意未做
公网明文部署仍挂 3 条 AS 发现路由(
main上的既有形态,本 PR 未改),且除OAuth track is NOT live外没有一行说「AS 正在公网明文提供」。裁定第 ④ 条按字面读是否仍欠它一行,是裁量分叉 ⇒ 已进决策卡 #19571,⛔ 不在本 PR 自裁。本节由
domain:services席(session_01AhQASwqJr2Z7XfGWUdvnbF)补写,依.claude/agents/os-dev.md「PR 正文 dev 只写一次…事后要改的报告点名改法,席位代写」。Generated by Claude Code