Skip to content

Commit 4935c66

Browse files
fix(cli,plugin-auth): one boot line per warning class, each printed once, and the loopback OAuth notice at info (#22097)
Fixes #22073 Clause-②: no ## What changes Three boot-output changes, as the triage ruling on the card (comment `6038562452`) directs. Log text and log levels only: no accept set, answer or public signature moves. 1. **One line per warning class.** `printAutomationSummary` (`packages/cli/src/utils/format.ts`) groups the engine's binding audit by (trigger type, reason) and prints one line per class with its flows listed. The class's short text is the reason's **first sentence**, derived by `leadSentence()`. It is never a second, hand-written wording: the engine owns every reason sentence (`describeUnboundReason`, and `scheduledWorkDisabledReason` for the deployment policy). A reason that is already one sentence (a missing trigger, a binding failure, a declined subflow) prints whole, remedy included. Distinct reasons stay distinct lines. 2. **Each warning prints once.** `printAutomationSummary` now returns the identifying text of every logger record it restated, at the moment it prints its own line. `printBootDiagnostics` withholds exactly those and counts them in its header: `⚠ Boot diagnostics — 3 warnings logged during startup (2 more already listed above):`. The shadowed-flow bootstrap restatement is covered by the same rule. The pull-time `Flow name collision` record still replays, because it says which rule armed the body. 3. **Loopback OAuth notice at `info`.** In `packages/plugins/plugin-auth/src/auth-plugin.ts`, `OAuth is served UNENCRYPTED: …` logs at `info` when the issuer's host is loopback and stays `warn` for private and link-local hosts. The sentence is the same and is still emitted on every accepted plain-HTTP boot, so D1 of ruling batch #210 item 5 ("eligibility decides WHICH sentence, never WHETHER") holds across both levels. Where the long text now prints: at `--log-level debug` (or `info`) the boot-quiet window never opens. The live stream then carries `@objectstack/service-automation`'s per-flow `[Automation] flow 'NAME' declares a 'TYPE' trigger but is NOT bound — it will never auto-launch. REASON` warning, with the whole reason. When any class line was shortened, one dim line under the classes says so. ### Measured, before → after (H1) `examples/app-todo` has 2 package-authored `schedule` flows. Booted with `os dev --fresh --no-watch`, scheduled work off (the default), default log level: | | before (`3d918850`) | after (this branch) | | --- | --- | --- | | lines carrying the schedule `NOT bound` sentence | **4** (2 banner + 2 *Boot diagnostics*) | **1** | | `OAuth is served UNENCRYPTED` lines (localhost) | 1 (`WARN`, in *Boot diagnostics*) | 0 | | *Boot diagnostics* records shown | 6 | 3 (+2 counted as listed above) | The after banner line: ```text ⚠ 2 flows declare a 'schedule' trigger but are NOT bound — disabled by deployment policy — package-authored scheduled work is off on this deployment (OS_AUTOMATION_SCHEDULED_WORK_ENABLED is unset or not truthy), so no time trigger arms and no packaged `defineJob` is scheduled: task_reminder, overdue_escalation reasons cut to their first sentence — --log-level debug prints each flow's full reason ``` The second producer was the one the PM named: the banner's `a.unbound` loop, plus *Boot diagnostics* replaying `service-automation`'s `kernel:bootstrapped` audit warning (`plugin.ts:1533`). There was no third copy. By construction, the hotcrm shape (8 flows) goes from 16 lines to 1 class line plus 1 hint line. hotcrm itself was not booted here. ### How a new boot warning stays single (H7) *Boot diagnostics* withholds only a record that a banner section **handed back as restated when it printed its own line**. Today the only such section is the automation summary, and only for the service-automation audit records it summarises. - A new `logger.warn`, such as the branding-asset warning #22071 adds, is restated by no banner section. It is never matched, so it replays once. - A banner line that is not also a logger record has nothing to withhold, so it prints once. - The match can fail only toward printing. A reworded producer or an escaped character in a JSON-format record is printed, never dropped, so the failure mode is a doubled line, never a lost diagnostic. - A banner section that starts restating a logger record must hand that record back, or it doubles. That contract is documented on `BootDiagnosticsReplayOptions.restatedAbove`. - The failed-boot and migrate-and-exit paths print no banner and pass nothing, so they replay every record. ## PM readings, measured - **H1**: reproduced with the counts above. The two producers are the ones named. - **H2**: the CLI does both jobs, and `service-automation` is untouched. Hosts that boot the kernel without the CLI banner read the service's warning as their only notice: `packages/runtime/src/domains/automation.ts` (the runtime composition every adapter host uses), `packages/verify/src/harness.ts`, and the plugin's own comment at `plugin.ts:1490`–`:1492` ("The warn matters for embedded hosts and tests"). Lowering that warning, or grouping it, would change what those hosts see. No `@objectstack/service-automation` changeset. - **H3**: classes are keyed on the whole reason, not its short form. The deployment-policy reason is one class per trigger type. A real binding failure, a missing trigger and a declined subflow each keep their own line. The short-text derivation and the long-text location are described above. - **H4**: no other published origin feeds the decision. `getAuthIssuer()`, `getMcpResourceUrl()` and `isMcpOAuthEnabled()` all derive from the one `getCanonicalOrigin()`, so "every published origin is loopback" reads as "the issuer's host is loopback". **Partly falsified:** the transport rule has no separable loopback predicate to reuse. `isOAuthEligibleBaseUrl` checks `localhost` / `*.localhost` inline (`auth-manager.ts:558`), and `isPrivateOrLoopbackHostLiteral` checks `127.0.0.0/8` and `::1` in the same function as the private ranges. Extracting one means editing `auth-manager.ts`, which is outside this card's declared file surface. So the notice's module-local `isLoopbackIssuer()` composes the rule's own pieces: the same `localhost` / `*.localhost` names, the `127.0.0.0/8` block judged by the same ADR-0069 D5 matcher (`ipMatchesRange`, already exported), and `[::1]`. It adds no new host regex, and it reads the WHATWG-canonical hostname as the rule does. It is asked only of an issuer the rule already accepted. Agreement is pinned by the loopback / private table below. Extracting one shared predicate is left as an open question in the report. - **H5**: two assertions in `mcp-oauth-plaintext-notice.test.ts` flip, both for the ruling's reason. `fires on a loopback deployment too — plain HTTP is plain HTTP` (localhost: 1 `warn`) is replaced by `logs the sentence at info, never warn, on a LOOPBACK deployment`, over `localhost`, `app.localhost`, `127.0.0.1`, `127.0.0.2`, `127.1` and `[::1]`, each 0 `warn` and 1 `info` naming the issuer. `every plain-HTTP boot gets exactly one of the two sentences` now counts across both levels; its localhost row would otherwise read 0. The control stays `warn`: `is emitted at warn on a private address` (`172.16.0.1`), plus a new `keeps the warning on a PRIVATE or LINK-LOCAL deployment` over `10.0.0.5`, `172.16.0.1`, `192.168.1.10`, `169.254.10.20`, `[fd00::1]` and `[fe80::1]`. - **H6**: `packages/cli/src/commands/doctor.ts:289` is unchanged. Text that quoted the old lines moved with them, outside `content/docs/**`: - `docs/qa/platform-checklist/areas/platform-core.json`: the `platform-core.boot-health` acceptance `verify` quoted the per-flow banner line. Revision 6 → 7, with a history entry. - `docs/qa/platform-checklist/areas/ai.json`: `ai.mcp-oauth-private-host-transport` expected the accepted-transport line "exactly 1 on (c)" (loopback) at the default level. It now names the level per boot, and boot (c) runs at `--log-level info`. Revision 2 → 3, with a history entry. `pnpm check:platform-checklist` is green. - No `content/docs/**` page needed an edit. `automation/flows.mdx:2380` and `deployment/environment-variables.mdx:91` say such flows are "listed by … the CLI startup summary as disabled by deployment policy", which is still true. `releases/v17/17-5.mdx` is release-owned and untouched. `packages/spec/scripts/publish-smoke-boot-failure.test.ts` holds verbatim historical specimens of the old header with no restated records, which are still valid. - **H7**: covered in the section above. ## Tests (at `de6b4a08`) - New `packages/cli/src/utils/format.boot-warning-classes.test.ts`, unit tier, 11 tests. The first block boots the **real** `AutomationServicePlugin` on a `LiteKernel` with 8 `schedule` flows and scheduled work off. It captures stdout through the same `BootLogCapture` `serve` uses, collects through `collectAutomationSummary`, and prints the real banner. Pins: - exactly **one** line carries `a 'schedule' trigger`, and it names all 8 flows; - every flow is named on exactly **one** line, so no warning appears twice; - the long explanation is absent at the default level. The premise is asserted first: the producer warned once per flow into the capture. Formatter legs cover distinct classes, the first-sentence cut, the hint only when shortened, a JSON-format record, a record the banner did not restate (never withheld), the shadowed restatement withheld while the collision record is kept, and the no-banner path replaying everything. - `pnpm --filter @objectstack/cli exec vitest run --project unit`: **260 files, 3797 passed**. `pnpm --filter @objectstack/cli typecheck`: exit 0. The new file is in `tsconfig.json`'s program (`--listFilesOnly`), and `check:test-typecheck` is OK. The `integration` tier is declared to CI: the diff touches no spawn entry, no integration-tier file and no driver. - `pnpm --filter @objectstack/plugin-auth test`: **126 files, 2623 passed, 10 skipped**. `pnpm --filter @objectstack/plugin-auth typecheck`: exit 0. - **Ablations.** Each mutation went through `scripts/ablation-replace.mjs` against the committed fix, with the anchor hit verified on disk and the restore proven as blob == HEAD with an empty `git diff HEAD`. The subjects resolve from source, so no rebuild was needed. - **A**: drop the unbound claim. 5 red: both real-boot pins, plus the three formatter print-once legs that withhold unbound records. - **B**: key classes by flow. 2 red: the one-schedule-line pin and the classes leg. - **C**: never log at `info`. 6 red: every loopback spelling. - **D**: treat every host as loopback. 10 red: every private and link-local control. - **Gates.** `node scripts/pm/dispatch-gates.mjs --ran` reports **72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN** at `de6b4a08`. The derivation added `pnpm --filter @objectstack/lint run check:doc-formula-expressions` and `pnpm check:platform-checklist` to the PM's list. `check:dual-build-cjs-loads` and `check:i18n-coverage` first exited 3 (PREREQUISITE NOT MET, unbuilt workspace packages). After `pnpm turbo run build`, they and the other dist-reading gates were re-run, all exit 0. Full `pnpm lint` exits 0 at `de6b4a08`. Narrowed eslint (`--no-inline-config --format json`) over the 4 changed TS files: 0 errors, 0 warnings. The other 3 changed files are outside eslint's population ("no matching configuration"), and the config enables no type-aware linting (`eslint.config.mjs:327`). ## Acceptance notes (not filed) - *Boot diagnostics* still pairs the ready line `⚠ Server is ready — DEGRADED: missing core services: …` with the kernel's `System started with degraded capabilities …` record. This is deliberately left: it is the ready-line status, not a banner-list entry, and `serve-ready-degraded-boot.e2e.test.ts` asserts the kernel sentence in the same output as its premise. - A flow declined onto a disabled packaged subflow also has a bind-time engine `warn` (`Flow 'NAME' is registered but NOT armed on trigger 'TYPE' — …`). That is a different record from the bootstrap audit, so it still replays beside the banner's class line. The case is rare, and the record carries the subflow detail. - The service's own bootstrap sentence reads "is NOT bound — it will never auto-launch. disabled by deployment policy — … This is not a binding failure" for an embedded host. That is a wording observation, unchanged here (H2). --- _Generated by [Claude Code](https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c28f317 commit 4935c66

7 files changed

Lines changed: 641 additions & 30 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/cli": patch
3+
"@objectstack/plugin-auth": patch
4+
---
5+
6+
The startup banner prints one line per warning class, each warning appears once, and a localhost boot no longer warns that OAuth is unencrypted.
7+
8+
Clause-②: no
9+
10+
- **One line per class.** Flows that declare a trigger but are not bound are grouped by trigger type and reason, with the flows listed: `⚠ 8 flows declare a 'schedule' trigger but are NOT bound — disabled by deployment policy — … (OS_AUTOMATION_SCHEDULED_WORK_ENABLED is unset or not truthy), so no time trigger arms …: flow_a, flow_b, …`. Before, the full reason (up to ~650 characters) printed once per flow. The banner now shows the reason's first sentence; `--log-level debug` still streams each flow's full reason. A real binding failure, or a missing trigger, keeps its own line, worded as before.
11+
- **Printed once.** *Boot diagnostics* no longer repeats the automation plugin's per-flow `… is NOT bound` and shadowed-flow warnings, which the banner's `Flows:` list already shows. Its header counts them instead: `⚠ Boot diagnostics — 5 warnings logged during startup (8 more already listed above):`. Every other boot warning replays exactly as before. A boot that fails before the banner still replays all of them.
12+
- **OAuth on loopback.** `OAuth is served UNENCRYPTED: …` is logged at `info` when the issuer's host is loopback (`localhost`, `*.localhost`, `127.0.0.0/8`, `::1`), so it is not shown at the default `warn` level. It stays `warn` on a private or link-local issuer. The sentence and the transport rule are unchanged.
13+
- ⛔ Nothing you author changes. Which flows bind, the scheduled-work switch, the transport rule, the service-automation warning an embedded host reads, and every public key, export and parameter are unchanged.

‎docs/qa/platform-checklist/areas/ai.json‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -690,7 +690,7 @@
690690
"title": "the MCP OAuth transport rule is judged on the DEPLOYMENT's own host: a private / link-local plain-HTTP deployment serves the OAuth track and logs the accepted-transport warning; a public plain-HTTP one is still refused, fail-closed, and logs its own public-plaintext warning",
691691
"since": "v17",
692692
"status": "active",
693-
"revision": 2,
693+
"revision": 3,
694694
"priority": "P1",
695695
"surface": "api",
696696
"fixtures": {
@@ -714,7 +714,7 @@
714714
"on boot (a), POST /api/v1/mcp with NO credentials; record the 401 and its WWW-Authenticate header, including whether a resource_metadata pointer is present",
715715
"if a second machine is available, drive a real MCP client from it against boot (a) over http:// and record whether the client completes the OAuth flow, refuses the scheme up front, or fails elsewhere — RECORD WHICH, because a client-side scheme refusal changes what this compatibility is worth",
716716
"boot (b) on a PUBLIC host spelling over plain http; capture the boot log and GET both protected-resource paths; record status and body — and record separately whether the public-plaintext warning `OAuth discovery is served over PUBLIC plain HTTP` appears and how many times",
717-
"boot (c) on loopback; capture the boot log and the same two GETs",
717+
"boot (c) on loopback with --log-level info (its accepted-transport line is logged at INFO there, so a default warn-level boot log does not carry it); capture the boot log and the same two GETs",
718718
"re-boot (a) with an https base URL (a self-signed terminator is enough — no client needs to trust it, only the deployment's own base URL has to be https); capture the boot log and record the ABSENCE of BOTH plain-HTTP warnings"
719719
],
720720
"acceptance": [
@@ -731,9 +731,9 @@
731731
"evidence": "the two 404s and the boot-log line"
732732
},
733733
{
734-
"clause": "eligibility decides WHICH plain-HTTP warning is emitted, never WHETHER one is: every plain-HTTP boot carries exactly one of the two, once, at mount. On a boot whose origin the transport rule ACCEPTS (a and c) it is the accepted-transport line `OAuth is served UNENCRYPTED`; on the PUBLIC plain-HTTP boot (b), whose OAuth track the same rule left dark, it is the distinct line `OAuth discovery is served over PUBLIC plain HTTP`, which states that the AS discovery surface is served over public plain HTTP, that the MCP OAuth track is disabled, and that TLS is the remedy. Neither line on the https re-boot. Each names the issuer URL. The ruled sentence 「OAuth 未加密:仅限可信内网」 is carried as the line's MEANING and lives verbatim in the code comment beside the call — ruling batch #210 item 5",
734+
"clause": "eligibility decides WHICH plain-HTTP warning is emitted, never WHETHER one is: every plain-HTTP boot carries exactly one of the two, once, at mount. On a boot whose origin the transport rule ACCEPTS (a and c) it is the accepted-transport line `OAuth is served UNENCRYPTED` — logged at WARN on (a), and at INFO on loopback (c), where nothing crosses a network; on the PUBLIC plain-HTTP boot (b), whose OAuth track the same rule left dark, it is the distinct line `OAuth discovery is served over PUBLIC plain HTTP`, which states that the AS discovery surface is served over public plain HTTP, that the MCP OAuth track is disabled, and that TLS is the remedy. Neither line on the https re-boot. Each names the issuer URL. The ruled sentence 「OAuth 未加密:仅限可信内网」 is carried as the line's MEANING and lives verbatim in the code comment beside the call — ruling batch #210 item 5",
735735
"oracle": "log",
736-
"verify": "grep the captured boot logs for the two ENGLISH anchors separately — `OAuth is served UNENCRYPTED`: exactly 1 on (a), exactly 1 on (c), 0 on (b), 0 on the https re-boot; `OAuth discovery is served over PUBLIC plain HTTP`: exactly 1 on (b), 0 on (a), 0 on (c), 0 on the https re-boot. Each matched line names the issuer, the origin followed by /api/v1/auth. ⛔ Grepping for 「OAuth 未加密:仅限可信内网」 now returns 0 on every boot: ruling batch #210 item 5 moved that sentence out of the emitted string into the code comment, so a run still greping it scores every boot as a miss",
736+
"verify": "grep the captured boot logs for the two ENGLISH anchors separately — `OAuth is served UNENCRYPTED`: exactly 1 on (a), at WARN; exactly 1 on (c), at INFO, in its --log-level info boot log (0 in a default warn-level one); 0 on (b), 0 on the https re-boot; `OAuth discovery is served over PUBLIC plain HTTP`: exactly 1 on (b), 0 on (a), 0 on (c), 0 on the https re-boot. Each matched line names the issuer, the origin followed by /api/v1/auth. ⛔ Grepping for 「OAuth 未加密:仅限可信内网」 now returns 0 on every boot: ruling batch #210 item 5 moved that sentence out of the emitted string into the code comment, so a run still greping it scores every boot as a miss",
737737
"evidence": "the matching log lines with their counts, and the two empty results (the public boot and the https re-boot)"
738738
},
739739
{
@@ -783,6 +783,12 @@
783783
"date": "2026-09-22",
784784
"change": "ruling batch #210 item 5 (B/B). D1: eligibility decides WHICH plain-HTTP warning is emitted, never WHETHER one is — boot (b) moves from asserting 0 warnings to asserting exactly 1 occurrence of a new, distinct public-plaintext line, because the .well-known discovery routes are mounted regardless of transport while the 'OAuth track is NOT live' line sits inside the MCP-surface condition, so a public plain-HTTP boot with that surface off used to emit nothing at all. D2: the emitted strings are English and the grep anchors move to them; the ruled Chinese sentence is carried as the line's meaning in the code comment beside the call. Steps, clauses and verifies moved together",
785785
"ref": "claude/issue-19571-plaintext-as-warning"
786+
},
787+
{
788+
"revision": 3,
789+
"date": "2026-10-07",
790+
"change": "the accepted-transport line `OAuth is served UNENCRYPTED` is logged at INFO when the issuer's host is loopback and stays WARN on a private or link-local one (triage ruling on #22073), so boot (c)'s line no longer shows at the default warn level. steps[5] boots (c) with --log-level info; acceptance[2]'s clause and verify name the level per boot. D1 is unchanged: the same sentence is still emitted on every accepted plain-HTTP boot",
791+
"ref": "#22073"
786792
}
787793
]
788794
}

‎docs/qa/platform-checklist/areas/platform-core.json‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"title": "Showcase boots clean: health + ready 200, no degraded startup banners, console + app metadata served",
1010
"since": "v15",
1111
"status": "active",
12-
"revision": 6,
12+
"revision": 7,
1313
"priority": "P0",
1414
"surface": "mixed",
1515
"preconditions": [
@@ -40,7 +40,7 @@
4040
{
4141
"clause": "the `Flows:` startup banner carries no ⚠ line of a MISAUTHORED class — a flow name claimed by several definitions, a flow targeting an unknown object, a trigger NOT bound for any reason other than deployment policy, or flows declared while the automation engine is not enabled — and no ERROR-level lines appear IN THE BOOT WINDOW — the window is part of the clause, because ordinary caller-error 4xx traffic also logs at ERROR",
4242
"oracle": "log",
43-
"verify": "read each ⚠ line under the Flows banner and classify it. ⚠️ A stock boot DOES print ⚠ lines that are not misauthoring: each package-authored scheduled flow is listed as `⚠ flow '<name>' declares a '<type>' trigger but is NOT bound — disabled by deployment policy …`, because package-authored scheduled work is off by default (QA run #21056 counted two on stock showcase). That is the posture working, so a bare grep for '⚠' reads a clean boot as a FAIL (the four misauthored classes are the four ⚠ shapes printAutomationSummary in packages/cli/src/utils/format.ts prints). Then grep for ERROR lines, SCOPED to the boot window — from process start to the first 200 on /api/v1/health (the same instant clause 0 records as time-to-healthy). ⚠️ A caller-error REFUSAL logs at ERROR level with a full stack BEFORE answering 4xx — measured on 17.1.0 with a `$fn` filter probe answering 400 INVALID_FILTER (#10257). So an unscoped grep over a log that also carries the run's own probe traffic reports ERROR lines for requests the platform refused CORRECTLY, and a clean boot reads as a boot failure. Cut the log at the health-green line before grepping, or capture the boot log to its own file before issuing the first request; seed rejections still count as failures (see #3415 — SeedLoader rejections were silent)",
43+
"verify": "read each ⚠ line under the Flows banner and classify it. ⚠️ A stock boot DOES print ⚠ lines that are not misauthoring: package-authored scheduled flows are listed on ONE line per trigger type and reason, `⚠ <n> flow(s) declare a '<type>' trigger but are NOT bound — disabled by deployment policy …: <names>`, because package-authored scheduled work is off by default (QA run #21056 counted two such flows on stock showcase; since #22073 they share one line, and Boot diagnostics no longer repeats them). That is the posture working, so a bare grep for '⚠' reads a clean boot as a FAIL (the four misauthored classes are the four ⚠ shapes printAutomationSummary in packages/cli/src/utils/format.ts prints). Then grep for ERROR lines, SCOPED to the boot window — from process start to the first 200 on /api/v1/health (the same instant clause 0 records as time-to-healthy). ⚠️ A caller-error REFUSAL logs at ERROR level with a full stack BEFORE answering 4xx — measured on 17.1.0 with a `$fn` filter probe answering 400 INVALID_FILTER (#10257). So an unscoped grep over a log that also carries the run's own probe traffic reports ERROR lines for requests the platform refused CORRECTLY, and a clean boot reads as a boot failure. Cut the log at the health-green line before grepping, or capture the boot log to its own file before issuing the first request; seed rejections still count as failures (see #3415 — SeedLoader rejections were silent)",
4444
"evidence": "the grepped log excerpt"
4545
},
4646
{
@@ -90,6 +90,12 @@
9090
"date": "2026-10-01",
9191
"change": "checklist-accuracy findings of QA run #21056. steps[3] asked for every SeedLoader line, but at the default warn level a healthy boot prints none: it now names the level-immune `Seeds:` banner row and the --log-level info line. acceptance[2]'s 'no ⚠' collided with the stock `NOT bound — disabled by deployment policy` lines for package-authored scheduled flows: the clause now names the misauthored classes it fails on",
9292
"ref": "#21060"
93+
},
94+
{
95+
"revision": 7,
96+
"date": "2026-10-07",
97+
"change": "acceptance[2].verify quoted the per-flow banner line `⚠ flow '<name>' declares a '<type>' trigger but is NOT bound — disabled by deployment policy …`. The banner now prints one line per (trigger type, reason) class with the flows listed, and Boot diagnostics no longer repeats the automation plugin's per-flow line, so the quoted shape moved with it. The clause is unchanged",
98+
"ref": "#22073"
9399
}
94100
]
95101
},

0 commit comments

Comments
 (0)