Skip to content

fix(instances): size the automatic warm-set cap by registered crews, not connected ones - #8573

Merged
chenmingwei23 merged 1 commit into
mainfrom
fix/warm-set-cap-registered-count
Sep 5, 2026
Merged

fix(instances): size the automatic warm-set cap by registered crews, not connected ones#8573
chenmingwei23 merged 1 commit into
mainfrom
fix/warm-set-cap-registered-count

Conversation

@iamwhatever

@iamwhatever iamwhatever commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Problem / Motivation

Exactly one remote crew pane sits at "loading" forever. The tunnel is up the whole
time, the other crews are fine, and which crew is stuck changes on every
gateway restart — so it reads as a flaky SSH link rather than as a setting doing
what it was told.

Why it matters

The user has no way to attribute it. Nothing is red: the tunnel reports
connected, the registry looks right, and the only symptom is a spinner in one
pane. The natural response is to go hunting through SSH config, tokens and
frame-ancestors — which is exactly what happened here — while the actual cause is
a cap that came back one short. And because the victim moves each restart, every
"fix" appears to work once.

What changed (motivation → approach → change)

Symptom → one pane never becomes ready, victim rotates per restart.

Root cause → the automatic warm-set cap (instances.warm_set_cap = 0, the
shipped default) was resolved from the number of crews connected at the instant
GET /api/instances was served
:

connected = sum(1 for i in items if (i.get("status") or {}).get("state") == "connected")

That made the cap a race against tunnel startup. With four crews configured and
the fourth still connecting when the dashboard polled, the served cap came back
3, and InstancesViewport's K-cap effect dutifully evicted the LRU pane to
honour it.

Eviction is indistinguishable from a disconnect at the pane: the iframe is
unmounted, the token re-minted, and the remote SPA cold-boots on the next click.
So one crew looks broken, and which one depended on connection order — hence a
victim that moves on every restart.

Change → count registered crews instead (len(items) from the registry).
That is a function of configuration rather than of live state, so:

  • it cannot race tunnel startup — a crew that is still connecting already has its
    slot;
  • it widens by itself when a crew is added, so nobody has to remember to raise the
    cap alongside. Forgetting that is precisely what reintroduces the eviction.

WARM_SET_CAP_AUTO_CEILING (8) still bounds automatic mode, so a large fleet still
evicts rather than mounting an unbounded number of dashboard SPAs in one renderer.
An explicit cap >= 1 is still honoured verbatim, including a value below the
registered count — a deliberately tight cap is the only knob that bounds renderer
cost, and silently widening it would defeat the operator's own trade.

The setting's help text moves off "connected" onto "configured" in the same commit,
and config-baseline.json — the committed snapshot of the config schema, which
test_config_baseline.py asserts byte-identical against its generator — is
regenerated to match. That one generated line is the whole of the baseline's diff.

docs/system-specs/modules/instances.md moves with the code: §2's warm-set
paragraph, the instances.warm_set_cap config-table row and the LRU-eviction
troubleshooting row all described the cap as tracking the live connected count,
which this change makes untrue. Each now says registered, and each states the
automatic ceiling explicitly rather than promising that no configured crew is ever
evicted — past WARM_SET_CAP_AUTO_CEILING (8) one is.

The instrumentation half

The same commit adds a permanent journal of crew-pane load outcomes, because this
class of bug only reproduces across a restart and a restart is slow:

  • website/electron/frame-load-log.js (new) — hooks did-start-navigation /
    did-frame-navigate / did-fail-load / console-message on the dashboard
    webContents, so a subframe that never commits, or commits a non-2xx, leaves a
    line carrying the status or the net error code.
  • website/src/lib/paneLog.ts (new) plus ten call sites in
    InstancesViewport.tsx — a greppable one-line [pane] journal (mount, ready,
    unmount, re-mint, evict) written into the same gateway-launch.log, so the
    renderer's view and the frame's view interleave on one timeline.

Tokens are redacted (token=<redacted>) and the journal is unconditional — no
debug flag, because the failure is not reproducible on demand.

The [pane] prefix is a marker, not a capability. The crew panes are
cross-origin iframes of the dashboard's webContents, so console-message fires
for their documents too: a prefix test on its own would let a compromised remote
gateway print entries into gateway-launch.log, forging the very record that says
whether its pane was ever requested. The forwarder gates the allowlist on frame
identity instead, and identity takes two checks — Electron's console-message
details carry the emitting WebFrameMain, so it requires both:

  1. parent === null — the top of the frame tree, a relationship Chromium owns and a
    nested page cannot claim.
  2. frame.origin equal to the URL the window was loaded with (backendUrl, passed
    in at the single call site).

Neither alone is sufficient, because position is not identity: a cross-origin pane
that gets a user click on a target="_top" link can navigate the top-level window,
and the remote document then has parent === null too. sourceId is deliberately
not used for either check: any script can rewrite it with a //# sourceURL=
comment. Both fail closed, so a runtime that supplies no frame — or a caller that
configures no origin — loses the INFO-level journal rather than trusting an
unverifiable claim.

A pane's console errors are still journaled — a framing refusal inside the pane
is exactly the diagnosis — but attributed (renderer console [error] (untrusted frame http://127.0.0.1:PORT): …) so a reader can tell pane-controlled text from the
dashboard's own. Every field the emitting document controls is escaped before it is
written: gateway-launch.log is read by tailing it, so a raw newline would let a
pane forge whole entries, and length is capped so one enormous message cannot
scroll the lines around it out of the tail.

The volume is bounded, and the bound is aggregate — because a pane drives more than
one path.
The repeat counter is keyed by message text, and text is what a
compromised pane chooses: varying it defeats a per-message cap, and clearing the key
map on overflow restarts the counting rather than holding the line. A pane can just as
easily loop its own frame's navigations — every did-start-navigation /
did-frame-navigate / did-fail-load is another unconditional line — so a limit on
the console path alone is one the pane walks around by switching paths. Every emission
therefore funnels through one writer (record), and every line attributable to an
untrusted frame, console or navigation alike, is charged against a single
per-attachment budget (UNTRUSTED_LOG_BUDGET = 100), independent of what the text
says. It is charged only for lines actually written, and the last line it admits names
the budget so the silence after it reads as the cap rather than as the pane
recovering. A navigation carries only isMainFrame as a trust signal (the positional
events pass no frame or origin), so a subframe navigation is the pane's and is
budgeted while a top-frame navigation is the dashboard's own handful and is not.
Trusted and untrusted console repeat counters stay separate maps, so pane volume
cannot evict the dashboard's own. The trade is explicit: past the budget a genuinely
broken pane stops explaining itself, which is acceptable because the first lines are
the diagnosis — a framing refusal repeats, it does not evolve — while an unbounded log
is a disk-exhaustion path on the user's machine.

A boolean presence flag is not a credential. paneLog's redaction matches key
names as a substring so authToken and session_secret are caught, which also
matched the hasToken flag in remint-empty and warm-declined — redacting the one
bit those lines exist to record while protecting nothing. Booleans are now exempt;
every other type under a secret-looking key is still replaced.

While wiring it up, the repo's own shell-contract drift guard caught a real
packaging bug: frame-load-log.js was missing from build.files in
website/electron/package.json, so the DMG would have shipped without the module
and window creation would have crashed. Fixed in the same commit.

website/electron/package.json adds no dependency. The only edit is one entry
in the build.files allowlist — no dependencies / devDependencies change, no
lockfile change, no new third-party code. electron-builder ships an explicit
per-file allowlist, which is why an unlisted local module works from source and is
simply absent from the DMG.

The i18n exemption

The journal's redaction sentinels — <redacted>, <empty>, <unserializable>,
?token=<redacted>, ?<query> — are log tokens, and the i18n:check gate reports
untranslated literals on added lines at zero tolerance. Translating one would make
gateway-launch.log unsearchable in the exact incident it exists for, and a locale
that renamed <redacted> would read as if the token had been printed. So they are
exempted rather than wrapped, via two fully anchored words.exclude shapes in
website/eslint.i18n.config.js:

String.raw`^<[a-z]+>$`
String.raw`^\?(?:[a-z_]+=)?<[a-z]+>$`

Both are deliberately separate from the existing ^[?&][a-z_]+=[a-z0-9]+$
server-contract shape: that class admits no angle bracket, and widening it to reach
these would let a bracket into a pattern whose whole tightness argument is that it
carries only [a-z0-9_=]. The anchors are what keep prose reportable — Enter <name> here and ?label=Save changes still fail the lint, and
website/src/test/i18nLintExemptions.test.ts pins both directions.

Tests

  • test/test_warm_set_cap.py — rewritten for the registered-count semantics.
    New TestAdmitsEveryRegisteredCrew class pins the regression directly: a crew
    still connecting does not shrink the cap, adding a crew widens it, and a
    registered-but-never-connected crew still gets a slot.

  • test/test_instances.py
    test_automatic_cap_covers_every_registered_crew_not_just_connected_ones
    (3 registered / 2 connected ⇒ cap 3, the exact shape that used to serve 2) and
    test_adding_a_crew_widens_the_served_cap (1 → 2 with no config edit).

  • website/src/lib/paneLog.test.ts (new, 16 cases) — the one-line format,
    credential redaction (including a wrapped key name like authToken, and both
    directions of the boolean exemption: hasToken=false survives, hasToken: 'abc'
    is still redacted), dropped undefined fields, and the cross-origin /
    about:blank / no-element branches of frameDocumentState.

  • website/electron/test/frame-load-log.test.js — 45 cases: started-vs-committed
    navigations, same-document SPA navigations staying silent, the [pane] journal's
    higher repeat cap (a re-mint loop is the finding), both console-message shapes,
    and a frame-load-log console trust boundary block that runs each attack rather
    than describing it:

    • a nested pane frame emitting [pane] pane-ready id=nobita status=200 produces no
      line at all, and neither does a remote document that navigated the top-level
      window
      ({ parent: null, origin: <remote> }) — the target="_top" case;
    • normalizeTrustedOrigin reduces the loaded URL to Chromium's serialization, and a
      same-prefix impostor (http://localhost:54760) is refused, pinning that the
      comparison is equality and not startsWith;
    • an unverifiable frame, a throwing parent, a throwing origin, and an
      unconfigured trusted origin all drop the journal;
    • UNTRUSTED_LOG_BUDGET + 50 distinct pane errors produce exactly
      UNTRUSTED_LOG_BUDGET lines with the last naming the budget; suppressed repeats
      do not consume it; a spent budget does not silence the dashboard's own errors; and
      overflowing the untrusted key map does not reset the trusted counters;
    • a subframe navigation flood (did-fail-load with isMainFrame=false,
      UNTRUSTED_LOG_BUDGET + 50 times) is bounded by the same budget, not just console
      output; console and navigation share one aggregate budget (70 + 70 across the
      two paths still totals UNTRUSTED_LOG_BUDGET, not 2×); and the dashboard's own
      top-frame navigations (isMainFrame=true) are never budgeted;
    • a pane error still lands but carries untrusted frame <origin>, and
      boom\nframe navigated (subframe) status=200 … stays one record with its newline
      escaped.
  • website/src/test/i18nLintExemptions.test.ts — 4 cases for the two new
    exemption shapes, in the file's existing both-directions style: quiet on the bare
    sentinels and on the query forms, still reporting Enter <name> here /
    Token <redacted> (a placeholder inside a sentence is copy) and still reporting
    ?label=Save changes (the query shapes did not widen into "anything after a ?").
    Whole file: 47 passed.

Local runs: test_warm_set_cap.py + test_instances.py +
test_config_superseded_defaults.py358 passed, 2 skipped, 1 failed. The
failure is unrelated: TestForwarderPidHints::test_connect_persists_forwarder_identity
asserts a non-empty process_start_time(), which shells out to ps -o lstart= on
macOS — blocked in my sandbox, so it returns "". Electron node --test (whole
package suite): 1662 passed, 1 skipped, 0 failed. vitest (paneLog +
i18nLintExemptions): 63 passed.

Manual verification

Partial, and stated plainly rather than claimed:

  • The cap resolution is covered end-to-end at the handler level by the two new
    test_instances.py cases, which exercise the real registry and the real
    api_instances_list.
  • Not yet verified on a packaged build. The desktop app runs the gateway from
    its bundled backend-dist, so confirming the served integer in the UI (Settings
    → Instances, "up to N instances stay warm") needs a make desktop +
    reinstall. The reporting user is doing that run.

Screenshots / video

Why no screenshot: the frontend diff adds logging call sites only — no
component, layout, string or style change, so no pixel differs.

Related Issues

no linked issue: found while diagnosing a stuck pane in a live session, not filed
first.

Pattern harvest

Rule candidate: review-prompt

Pattern: deriving a budget from live state that the thing being budgeted is
still racing to enter.
The cap counted connections while connections were still
being established, so it was systematically low during exactly the window it
governed. The general shape to flag: a limit computed from an observed runtime
count, applied to a set whose membership is declared — the two converge
eventually, and the gap is a silent, order-dependent failure. Prefer the declared
count (configuration) and bound it separately, rather than sampling the live one.

Checklist

  • At most two commits (one is the norm), with a Conventional Commits title (feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)
  • Existing tests pass and new tests added for new functionality
  • Self-review completed; code follows project style guidelines
  • Documentation updated (if applicable) — the config help text, the
    constants.py doc comments, the superseded-default display string, the
    instances module spec (§2, the config table and the eviction
    troubleshooting row) and the committed config-baseline.json snapshot all
    moved off "connected" onto "registered", each naming the automatic ceiling
  • No secrets, credentials, or internal references in the diff

@iamwhatever
iamwhatever requested a review from a team September 4, 2026 22:17
@iamwhatever
iamwhatever requested a review from a team as a code owner September 4, 2026 22:17
@iamwhatever
iamwhatever requested a review from cixuuz September 4, 2026 22:17
@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Sep 4, 2026
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

UX Review (Fable 5) — ✅ PASS

UX-level review of 9beb358be85d4f6792e0abe9479a2c5c92dcc711 — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

UX-Verdict: PASS

Backend-only cap fix plus logging; the user-visible outcome is one less phantom disconnect, and the updated settings copy honestly describes the new behavior.

Suggestions

  • Unify the term: superseded_defaults.py displays "0 (automatic: as many as are registered)" while the sections.py help says "configured" / "crews you added" — a user comparing the two surfaces meets two names for one rule; pick "configured" (user vocabulary) for both.
  • The help text's "up to an internal ceiling … Past that ceiling eviction resumes" never names the number (8), so an operator with 9 crews hits exactly the unattributable cold-boot this PR exists to kill, with the answer hidden in docs; append "(currently 8)" or interpolate WARM_SET_CAP_AUTO_CEILING into the help string.

[UX-REVIEWED] 9beb358

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — ✅ PASS

Design-level review of 9beb358be85d4f6792e0abe9479a2c5c92dcc711 — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

Design-Verdict: PASS

Root cause fixed at the right layer: the cap now derives from declared configuration, not from live state it was racing, and every doc, help text, and baseline moves in the same commit.

The instrumentation half is proportionate to the failure class it targets (only reproduces across slow restarts, previously left zero evidence anywhere): it is a self-contained module plus logging-only call sites, fully reversible, and its untrusted-frame handling — fail-closed frame identity, aggregate write budget, newline/length sanitization, token redaction — treats pane output as the external-content boundary it is rather than opening a new one. The residual case (eviction past the auto ceiling of 8, or under an explicit tight cap, still reads as a disconnect at the pane) is disclosed in the docs and now leaves log evidence; making an evicted pane visually distinct from a disconnect is legitimate follow-up work outside this PR's scope.

[DESIGN-REVIEWED] 9beb358

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5) — 🟡 CONCERNS

Premise-level review of 9beb358be85d4f6792e0abe9479a2c5c92dcc711 — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

All verification done — the contract's counts are in hand. Here is the review.

First-Principles-Verdict: CONCERNS

The cap fix is cause-level and self-sufficient; four-fifths of the diff is a permanent, always-on logging surface riding along in a fix.

What this change ships

Intent: stop one random crew pane from being silently evicted (and looking broken) whenever the dashboard polls before every tunnel finishes connecting — a FIX.

  1. Automatic warm-set cap now counts configured crews, not currently-connected ones — justified, cause-level
  2. Help text, spec, troubleshooting row, superseded-default display all move to "registered" — justified (same-commit spec sync, mandated)
  3. Desktop app journals every pane navigation/failure into gateway-launch.log — rides along, declared
  4. Dashboard emits a greppable [pane] lifecycle journal (~15 call sites) — rides along, declared
  5. The journal is always on, no debug flag — rides along, declared
  6. Pane-forgeable lines are identity-gated, budgeted, escaped, token-redacted — justified (untrusted-content boundary)
  7. Silent catches on re-mint/warm paths now leave a journal line — rides along, declared
  8. Two i18n lint exemptions for log sentinels like <redacted> — justified (CI gate)

Watch

  • The fix (items 1–2, plus tests) removes the reported defect alone; items 3–5 and 7 are ~1,600 of the ~2,000 diff lines under a fix: title. Their harm is named — this incident's misdiagnosis, and "the state is gone by the time anyone enables a flag" — so they are declared riders, not unjustified surface.
  • The new general forwarder leaves 2 hand-rolled siblings on the same job: website/electron/mochi/panelWindow.js:259-276 and mochi/avatarWindow.js:208 (grep: console-message under website/electron/, 3 non-test hits), the first with weaker redaction (split("?")[0]). Accepted-and-deferred: converting them changes mochi behavior.

Subtractions

  • Defer the instrumentation half (website/electron/frame-load-log.js, website/src/lib/paneLog.ts and its InstancesViewport call sites, the eslint exemptions) to its own change; the cap fix merges standalone and the permanent log surface gets examined as what it is.

[FIRST-PRINCIPLES-REVIEWED] 9beb358

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed 9beb358be85d4f6792e0abe9479a2c5c92dcc711 — this comment is updated in place on each push.

Review details

I've completed my analysis.

Candidate 1frame-load-log.js navigation formatters (formatFrameNavigate, formatFrameFailLoad, formatFrameStartNavigation) embed safeUrl(url) without routing it through sanitizeLogText, unlike the console path.

Falsifying it: the claim requires (a) a URL reaching did-start-navigation / did-frame-navigate / did-fail-load that carries a raw \n (or other C0 control) in its non-query portion. But the URLs delivered to these Chromium navigation events are canonicalized GURL serializations: per URL parsing, tab/LF/CR are stripped from the input, and remaining C0 controls in the path are percent-encoded before the event fires. So a raw newline does not survive into url, and errorDescription is a fixed Chromium net-error string, not attacker-chosen. I cannot re-derive a concrete input (a) that occurs in practice — the candidate's own author rated this "low" for exactly this reason, and I reach the same conclusion below 80. The [pane]-allowlisted console lines that a pane can influence are separately run through sanitizeLogText(message) in formatConsoleMessage, so the sink the log-forgery test guards is defended. Dropped.

No other item in the diff grounds to the three-part bar (the warm_set_cap connected→registered change is a tested pure-function rename with matching docs; paneLog's SECRET_KEYS redaction and safePaneUrl token stripping hold, and pane-emitted text is sanitized at the electron sink).

No findings.

[OPUS-REVIEWED] 9beb358

Verdict parsed from the review's SHA-scoped output markers for commit 9beb358be85d4f6792e0abe9479a2c5c92dcc711.

False positive or not applicable? A repository writer can comment:
/ai-review override fable 9beb358be85d4f6792e0abe9479a2c5c92dcc711: <one-sentence reason>

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ no blocking findings

GPT 5.6 completed its review of 9beb358be85d4f6792e0abe9479a2c5c92dcc711 and found no blocking issues.

This comment is updated in place on each push.

Review details

FINDING -- website/electron/frame-load-log.js:389 -- remote target="_top" navigations satisfy "isMainFrame === true", bypassing the log budget and allowing unbounded log growth -> Fix: trust only main-frame URLs matching dashboardOrigin.
[GPT-REVIEWED] 9beb358

False positive or not applicable? A repository writer can comment:
/ai-review override gpt 9beb358be85d4f6792e0abe9479a2c5c92dcc711: <one-sentence reason>

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Sep 4, 2026
@iamwhatever
iamwhatever force-pushed the fix/warm-set-cap-registered-count branch from fd94ccf to 0e0cdbc Compare September 5, 2026 00:28
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Sep 5, 2026
@iamwhatever
iamwhatever force-pushed the fix/warm-set-cap-registered-count branch from 0e0cdbc to 01a629c Compare September 5, 2026 00:31
@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Sep 5, 2026
@iamwhatever
iamwhatever force-pushed the fix/warm-set-cap-registered-count branch from 01a629c to c7eb0a8 Compare September 5, 2026 00:53
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Sep 5, 2026
@iamwhatever

iamwhatever commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator Author
  • span=585bf939dc3e docs/system-specs/modules/instances.md:200 — module spec still described the cap as the live connected count — fixed in c7eb0a801c1abd180660e631e8afd76581e3694a

this PR changes the automatic warm-set cap from the live connected count to the registered count everywhere except the module spec, which still says "the cap is resolved per request from the live connected count" (lines 200–210)

Legitimate and in scope: the spec is the reference for this module, and leaving it
on the old semantics makes it contradict the code the same commit ships. This is
completeness of the change, not new surface.

Three places carried the stale claim, and all three are now updated:

  1. §2 Warm set (~line 199) — "resolves the cap from the live connected count … so
    a crew the operator connected is never evicted" now resolves it from how many
    crews are REGISTERED, and states why registered rather than connected: a live
    count races tunnel startup, so the cap landed one short and the victim rotated
    per restart.
  2. The config-table row for instances.warm_set_cap (~line 266) — 0 now "tracks
    how many crews are registered, so up to an internal ceiling no configured crew
    is evicted".
  3. The LRU-eviction troubleshooting row (~line 844) — previously said only an
    explicit cap can now be below the connected count; it now names both ways a cap
    can fall short: an explicit value, or a fleet past
    WARM_SET_CAP_AUTO_CEILING.

Every one of the three also states the automatic ceiling explicitly rather than
promising that no configured crew is ever evicted — the sibling gpt finding on
constants.py caught the same over-claim in the doc comments and help text, and
both are answered the same way.

Gates re-run after the edit: docs-lint.sh, scrub-lint.sh --no-history,
check_feature_map.py, check_harness_parity.py, check_brand_name.py,
check_changelog_history.py, check_focus_cue.py,
check_testpaths_coverage.py — all rc=0.

@iamwhatever

iamwhatever commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator Author
  • span=ace2463464c3 src/kiro_crew/instances/constants.py:22 — "no crew … is ever evicted" contradicts the automatic ceiling — fixed in c7eb0a801c1abd180660e631e8afd76581e3694a

"no crew ... is ever evicted" contradicts the automatic ceiling: with nine registered crews the cap is eight and one is evicted -> Fix: qualify the changed comments and help text with "up to the automatic ceiling".

Legitimate, and the arithmetic is exactly right: WARM_SET_CAP_AUTO_CEILING = 8,
so at nine registered crews resolve_warm_set_cap(0, 9) returns 8 and the LRU
pane is evicted. The unqualified "ever" was mine — the pre-change comment made that
claim about connected crews, and I carried the absolute wording across to
registered without re-checking it against the clamp defined two definitions
below.

Fixed in both places the finding names:

  1. constants.py — the AUTO block now reads "as many as are registered, up to
    WARM_SET_CAP_AUTO_CEILING", says "below that ceiling no crew the operator
    configured is evicted", and points at the ceiling's own comment for what
    happens past it.
  2. sections.py help text — "so up to an internal ceiling no crew you added is
    evicted … Past that ceiling eviction resumes", replacing the shape that made
    the absolute promise first and mentioned the ceiling only afterwards.

The help string is snapshotted, so config-baseline.json was regenerated
(scripts/generate_config_baseline.py, 486 entries) and
test/test_config_baseline.py::TestCommittedBaselineParity passes — a stale
snapshot would have failed CI on the byte-identical assertion.

Gates re-run: black --check, isort --check-only, flake8, and
pytest test/test_config_baseline.py test/test_warm_set_cap.py test/test_config_superseded_defaults.py
— all rc=0. No behaviour changed; the clamp itself was already correct and is
covered by test_warm_set_cap.py.

@iamwhatever

iamwhatever commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator Author
  • span=e38bfbe7d82d website/electron/frame-load-log.js:112 — "Electron 43 emits (event, details), so list[0] drops every [pane] record" — rebutted: in Electron 43 list[0] is the details object, and the proposed list[1] is the deprecated numeric level

Electron 43 emits (event, details), so "list[0]" drops every [pane] console record and the positional navigation handler logs invalid details -> Fix: read list[1] and destructure the navigation details object.

Taken seriously rather than waved off, because if it held, the whole
instrumentation half of this PR would silently record nothing. It does not hold.
The premise — that console-message arrives as (event, details) — is not this
Electron's signature.

Version, from the pinned dependency actually installed:
website/electron/package.json declares "electron": "^43.2.0", and
node_modules/electron/package.json resolves to 43.2.0.

WebContents#console-message in that version's own electron.d.ts:

on(event: 'console-message', listener: (details: Event<WebContentsConsoleMessageEventParams>,
                                        /** The log level, from 0 to 3 … @deprecated */
                                        level: number,
                                        /** The actual console message @deprecated */
                                        message: string,
                                        /** … @deprecated */
                                        line: number,
                                        /** @deprecated */
                                        sourceId: string) => void): this;

Argument 0 is the details object — it carries message, level, sourceId,
lineNumber — and arguments 1–4 are the legacy positional payload, still
delivered, marked @deprecated. There is no separate leading event.

So normalizeConsoleMessage is correct as written: it inspects argument 0, takes
the modern shape when that argument has a string message (which is what Electron
43 delivers), and falls back to args[1..4] only when it does not — the legacy
form. [pane] records are not dropped. Applying the suggested fix would introduce
the bug: list[1] in Electron 43 is level, a number, so
typeof first.message === "string" would never match and the allowlist prefix test
would run against a numeric level.

Same for the navigation handlers. did-start-navigation has the identical shape —
details first, then the deprecated positional args this code reads:

on(event: 'did-start-navigation', listener: (details: Event<WebContentsDidStartNavigationEventParams>,
                                             /** @deprecated */ url: string,
                                             /** @deprecated */ isInPlace: boolean,
                                             /** @deprecated */ isMainFrame: boolean,
                                             /** @deprecated */ frameProcessId: number,
                                             /** @deprecated */ frameRoutingId: number) => void): this;

(_event, url, isInPlace, isMainFrame) therefore binds real values, not
undefined. did-frame-navigate and did-fail-load were never changed at all —
both are declared (event: Event, …) with no @deprecated on the payload — so
they are positional by definition.

One thing the finding is right about in spirit, recorded rather than acted on: the
navigation handlers depend on arguments Electron has marked deprecated, so a future
major that removes them would leave url undefined and the lines would read
frame navigation STARTED (main) with no URL. console-message is already immune —
it reads both shapes, which is exactly why the dual-shape branch exists. ^43.2.0
cannot resolve past 43.x, so this is a next-major-upgrade task rather than a defect
on the pinned version; hardening the two navigation handlers against a signature
this repo cannot receive would be speculative and untested code. Happy to open it
as a tracked follow-up for the Electron 44 bump if that is preferred.

Verified locally at head c7eb0a801c1abd180660e631e8afd76581e3694a:
node --test test/frame-load-log.test.js — 26 tests, 26 passed, covering both
console-message shapes (details object and legacy positional), the
started-vs-committed navigation pair, same-document SPA navigations staying silent,
and the [pane] higher repeat cap. Whole website/electron/test/ suite: 1369
tests, 0 failures.

@iamwhatever

iamwhatever commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator Author
  • span=12d5425f994e website/src/apps/mochi/src/renderer/SettingsPanel.tsx:851 — "touched MCP failure copy still bypasses ErrorNotice" — rebutted: this PR does not touch that file, or any MCP copy

touched MCP failure copy still bypasses ErrorNotice

The finding's premise is that this PR touched the copy. It did not. The file
exists in the tree, but it is not in this diff, so there is no touched copy for the
ErrorNotice rule to attach to.

Evidence at head c7eb0a801c1abd180660e631e8afd76581e3694a (the same 19-file set
as the reviewed head 01a629c1c):

$ git diff --name-only origin/main...HEAD | grep -icE 'mochi|SettingsPanel'
0
$ git diff origin/main...HEAD | grep -c 'ErrorNotice'
0

The complete diff is 19 files: config-baseline.json,
docs/system-specs/modules/instances.md, src/kiro_crew/config/sections.py,
src/kiro_crew/config/superseded_defaults.py,
src/kiro_crew/dashboard/handlers_instances.py,
src/kiro_crew/instances/constants.py, src/kiro_crew/instances/warm_set.py,
test/test_instances.py, test/test_warm_set_cap.py,
website/electron/frame-load-log.js, website/electron/package.json,
website/electron/test/frame-load-log.test.js,
website/electron/test/window-lifecycle.test.js,
website/electron/window-lifecycle.js, website/eslint.i18n.config.js,
website/src/components/InstancesViewport.tsx, website/src/lib/paneLog.test.ts,
website/src/lib/paneLog.ts, website/src/test/i18nLintExemptions.test.ts.

No path under website/src/apps/, no MCP failure copy, and no error-surface
component anywhere in the set. The only frontend strings this PR adds are the
[pane] log sentinels in website/src/lib/paneLog.ts, which are written to
gateway-launch.log and never rendered — that is why they are lint exemptions
rather than translated user copy.

Not dismissed as merely out of scope: if SettingsPanel.tsx:851 genuinely
bypasses ErrorNotice on main, that is a real defect worth fixing — but it is a
pre-existing one in a file this PR never opens, so the fix belongs in its own
change where it can be reviewed against that component's own surface. Folding it
in here would put an unrelated app's error handling inside a warm-set-cap fix.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running and removed readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention labels Sep 5, 2026
@iamwhatever
iamwhatever force-pushed the fix/warm-set-cap-registered-count branch from 89e6617 to d111944 Compare September 5, 2026 02:32
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Sep 5, 2026
@iamwhatever

Copy link
Copy Markdown
Collaborator Author
  • span=5e2da036f1be website/electron/frame-load-log.js:190 — "cross-origin panes can forge the host pane journal" — fixed in d1119446e042

Compromised remote pane -> console.info("[pane] ...") -> unverified console-message handler -> forged entries in gateway-launch.log -> Fix: accept pane-prefixed messages only from the trusted top-level dashboard origin/frame, and escape line breaks.

Legitimate, and the reachability is not hypothetical — the call site says so in as
many words. window-lifecycle.js:953 attaches this to the dashboard's own
webContents, and the comment two lines above it reads "The remote-crew panes are
iframes of THIS webContents." console-message fires for every frame in that
tree, so a pane's console output arrived at the same handler as the dashboard's,
and the only thing standing between it and the log file was a startsWith on text
the pane chooses. A compromised remote gateway could therefore write the very
record that decides the diagnosis this instrumentation exists for — "did the frame
ever navigate, and with what result" — and it got the INFO-level exemption and the
higher PANE_REPEAT_LIMIT along with it.

Fixed on both axes the finding names.

  1. Frame identity, not text. normalizeConsoleMessage now carries the
    emitting frame out of the details object, and isTopFrameMessage gates the
    [pane] allowlist on frame.parent === null — true only for the top-level
    document, a frame-tree relationship Chromium owns and a page cannot assert.
    WebFrameMain#parent is documented as "null if frame is the top frame in
    the frame hierarchy" in the pinned electron@43.2.0 typings, and frame is a
    declared member of WebContentsConsoleMessageEventParams ("Frame that logged
    the message"), so this reads a signal Electron already supplies rather than
    inferring one.
  2. Escaping. sanitizeLogText escapes CR/LF, replaces the remaining C0
    controls (an ESC sequence can rewrite what a terminal reader sees), and caps
    length. It is applied to message, sourceId and origin — every field the
    emitting document controls.

Two choices worth stating, because they are where this could have gone wrong.

  1. sourceId is not a trust signal and is deliberately not used as one. It
    would have been the easy origin check, and it is forgeable from inside a pane:
    it is the script URL as reported to devtools, which any script can rewrite with
    a //# sourceURL= comment, and a cross-origin <script src> reports the
    remote URL while executing in the including document's origin. Frame identity
    has neither weakness.
  2. The check fails closed. A runtime that supplies no frame — Electron < 35,
    where the first argument is a bare event — loses the INFO-level pane journal
    rather than trusting an unverifiable claim. That is a diagnostics regression on
    a version this repo cannot install (^43.2.0 always supplies frame), which is
    the right way round for a trust decision.

A pane's console errors are still journaled: a framing refusal or a refused
fetch inside the pane is exactly the diagnosis, and dropping them would trade one
bug for another. They are now attributed — renderer console [error] (untrusted frame http://localhost:7778): ..., using WebFrameMain#origin, Chromium's
serialized security origin rather than anything the page prints — so a reader can
tell pane-controlled text from the dashboard's own. Property access on a destroyed
frame throws in Electron, and a throw here would take out the whole console
handler, so both frame reads are guarded.

One thing the finding did not raise, in the same class and fixed with it: the
repeat counter is a Map keyed by pane-chosen message text, so a pane could grow
it for the life of the main process by emitting endless distinct errors. It now has
a key ceiling (REPEAT_KEYS_MAX = 500, dropped wholesale on overflow, which
restarts counting rather than leaking).

website/src/lib/paneLog.ts gained the matching note: the prefix is a marker, not
a capability, so a future caller moved into an iframe or a worker will stop
reaching the log file.

Verified at head d1119446e042: node --test test/frame-load-log.test.js — 35
tests, 35 passed, up from 26. The new cases are the attack itself (a pane frame
emitting [pane] pane-ready id=nobita status=200 produces no line at all), the
fail-closed path (unverifiable frame, journal dropped), the pane error that still
lands but carries untrusted frame <origin>, newline forgery (boom\nframe navigated (subframe) status=200 ... stays one line and keeps the text as \n),
control-character neutralization, truncation, and the repeat-key ceiling. Whole
website/electron suite: 1637 tests, 1636 passed, 1 skipped, 0 failures.
vitest run src/lib/paneLog.test.ts 15/15, scripts/scrub-lint.sh --no-history
rc=0. Rebased onto 92fa93428 in the same push; config-baseline.json
regenerated with no diff.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Sep 5, 2026
@iamwhatever
iamwhatever force-pushed the fix/warm-set-cap-registered-count branch from d111944 to a8203c2 Compare September 5, 2026 05:59
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Sep 5, 2026
@iamwhatever

Copy link
Copy Markdown
Collaborator Author

GPT 5.6 disposition — top-level remote navigation forging trusted pane records

  • FIXED (span=5e2da036f1be) — website/electron/frame-load-log.js:306, "top-level remote navigation can forge trusted pane records": legitimate, and the fix is an origin equality check paired with the position check.

The finding is right and I had the reasoning wrong. My previous round gated the
[pane] allowlist on frame.parent === null alone and argued that Chromium owns
that relationship so a page cannot claim it. That is true and beside the point:
parent === null is a position in the frame tree, not an identity. A cross-origin
pane that gets a user click on a target="_top" link navigates the top-level
window
, and the remote document that lands there satisfies parent === null too —
inheriting both the INFO-severity exemption and the higher PANE_REPEAT_LIMIT, which
is exactly the forgery the gate exists to stop.

What changed:

  1. isTopFrameMessage(frame) became isTrustedFrameMessage(frame, trustedOrigin)
    and now requires both halves: frame.parent === null and
    frameOriginOf(frame) === trustedOrigin. Neither alone is sufficient and the
    docstring says so, naming this attack.
  2. normalizeTrustedOrigin(value) reduces a full URL or a bare origin to the RFC 6454
    serialization WebFrameMain#origin reports, so the comparison is string equality
    on canonical values rather than a prefix or substring test. An unparseable value
    yields "".
  3. attachFrameLoadLogging(contents, log, trustedOrigin) takes the origin, and
    window-lifecycle.js passes backendUrl — the same value the window is loaded
    with at mainWindow.webContents.loadURL(\${backendUrl}?token=…`)`. There is one
    call site, so there is one origin.
  4. Every unverifiable input fails closed: no configured origin, no frame (Electron
    < 35), an absent origin, or a parent/origin getter that throws because the
    frame was destroyed. The cost of failing closed is the INFO-level journal; the cost
    of failing open is a forgeable diagnosis.

sourceId is still deliberately unused for this — it is rewritable with
//# sourceURL=, and a cross-origin <script src> reports the remote URL while
executing in the including document's origin.

Tests, in frame-load-log console trust boundary:

  1. hijackedTopFrame() ({ parent: null, origin: "http://localhost:7778" }) emitting
    [pane] pane-ready id=nobita status=200 produces no line — the target="_top"
    case, run rather than described.
  2. A same-prefix impostor http://localhost:54760 is refused, pinning that the check
    is equality and not startsWith.
  3. A throwing parent, a throwing origin, an absent origin, and an unconfigured
    trusted origin each drop the journal while errors still flow (attributed).
  4. window-lifecycle.test.js pins the third argument at the call site, so the wiring
    cannot silently revert to the position-only gate.

Whole electron package suite: 1657 passed, 1 skipped, 0 failed.

@iamwhatever

Copy link
Copy Markdown
Collaborator Author

GPT 5.6 disposition — distinct console errors bypassing the logging bound

  • FIXED (span=5e2da036f1be) — website/electron/frame-load-log.js:310, "distinct console errors bypass the logging bound": legitimate, and my own test documented the hole.

The finding is correct. if (repeats.size >= REPEAT_KEYS_MAX && !repeats.has(entry.message)) repeats.clear();
bounds the map, not the log: on overflow every counter resets, so a pane that
emits 500 distinct messages and then repeats gets its count restarted and can append
to gateway-launch.log forever. My own test asserted exactly that as if it were the
desired behaviour — assert.equal(lines.length, REPEAT_KEYS_MAX + 50, "every distinct error is still logged once"). The deeper problem is structural: a bound keyed by
attacker-chosen text is walk-around-able by choosing different text, so no tuning of
REPEAT_KEYS_MAX or CONSOLE_REPEAT_LIMIT fixes it.

What changed:

  1. UNTRUSTED_LOG_BUDGET = 100 — one aggregate budget for the life of the attachment,
    deliberately independent of what the text says, which is the only shape of
    limit a chooser of the key cannot walk around. Per-attachment rather than
    per-frame, because frame identity is itself pane-influenced once a pane can reload
    itself into a fresh frame.
  2. Charged only for lines that are actually written — after the severity filter and
    after the repeat cap — so suppressed repeats do not consume it and the "budget
    reached" line is always the line it marks.
  3. formatConsoleMessage gained a budgetExhausted tail naming the budget, so the
    silence that follows reads as the cap rather than as the pane going quiet — the
    opposite conclusion for whoever is diagnosing it.
  4. The repeat map is now split by trust (trustedRepeats / untrustedRepeats), so
    pane-chosen text cannot overflow the shared map and restart the dashboard's own
    counters as collateral.

The trade is explicit rather than hidden: past the budget a genuinely broken pane
stops explaining itself. That is acceptable because the first lines are the
diagnosis — a framing refusal or a refused fetch repeats, it does not evolve — while
an unbounded synchronous append is a disk-exhaustion path on the user's own machine.

Tests, all four running the attack rather than the happy path:

  1. UNTRUSTED_LOG_BUDGET + 50 distinct pane errors produce exactly
    UNTRUSTED_LOG_BUDGET lines, with the last carrying untrusted-frame log budget of 100 reached and the first carrying no notice.
  2. 400 identical pane errors consume 3 lines and no budget: a subsequent 100
    distinct errors still land, proving the charge is per written line.
  3. A fully spent pane budget does not silence the dashboard's own trusted error.
  4. Overflowing the untrusted key map does not reset the trusted counter — it keeps its
    first hit and still stops at CONSOLE_REPEAT_LIMIT.

The rewritten bounds the repeat counter case now states in a comment why the trusted
path is capped at the map and not at the log.

@iamwhatever

Copy link
Copy Markdown
Collaborator Author

Opus disposition — SECRET_KEYS.test(key) redacting the hasToken presence flag

  • FIXED (span=a24f4c443529) — website/src/lib/paneLog.ts, the substring key match redacting a boolean presence flag: advisory, correct, and worth fixing because the redacted bit is the whole finding of the lines that carry it.

Confirmed against the code. const SECRET_KEYS = /token|secret|password|cookie/i is
applied as SECRET_KEYS.test(key), and hasToken contains token, so
paneLog('remint-empty', { hasToken: false }) printed hasToken=<redacted>. Same for
both warm-declined call sites in InstancesViewport.tsx. Those else-branches are
reached exactly when a mint or warm returned nothing, which is the case the journal
exists to explain — and <redacted> there tells the reader nothing while protecting
nothing, since a boolean cannot hold a credential.

The substring match itself is not the bug and stays: it is what catches authToken,
session_secret and tokenValue, and what stops a caller who passes a whole
connection object from leaking through a key the list did not anticipate. So the fix
narrows on the value, not the key:

function isSecretValue(key, value) {
  return SECRET_KEYS.test(key) && typeof value !== 'boolean'
}

boolean is the only exemption, and the docstring says why widening it past boolean
would trade a real credential for a nicer log. Two new assertions in
paneLog.test.ts pin both directions: hasToken=false survives verbatim with no
<redacted> anywhere in the line, while { hasToken: 'abc' } is still redacted — and
a wrapped key (authToken: 'zzz') is still caught, so the substring behaviour is
pinned too rather than left to drift.

vitest (paneLog.test.ts): 16 passed.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Sep 5, 2026
@iamwhatever
iamwhatever force-pushed the fix/warm-set-cap-registered-count branch from a8203c2 to 454f066 Compare September 5, 2026 06:25
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Sep 5, 2026
@iamwhatever

Copy link
Copy Markdown
Collaborator Author

GPT 5.6 disposition — untrusted frame navigation bypassing the logging bound (restructure round)

  • FIXED (span=5e2da036f1be) — website/electron/frame-load-log.js:347, "untrusted frame navigation bypasses the logging bound": legitimate, and this is the third round on this span, so it is fixed by restructuring around the invariant rather than patching the one handler.

The finding is right. Last round I added UNTRUSTED_LOG_BUDGET but scoped it to the
console-message handler; the three navigation handlers (did-start-navigation,
did-frame-navigate, did-fail-load) still called log() unconditionally. A pane
drives its own frame's navigations — a reload or redirect loop is one line per event —
so that path was an unbounded append the budget did not cover. A bound on one emission
path is one the emitter walks around by switching paths.

Because this is the same span three rounds running, I stopped patching the symptom and
named the invariant instead:

Every line attributable to an untrusted frame — console or navigation — passes
through one writer and is charged against one aggregate per-attachment budget.

Made structural:

  1. One writer record(line, trusted) is now the ONLY path to log(). Trusted lines
    (the dashboard's own top frame) are unbudgeted; every untrusted line is charged
    against the single untrustedLogged counter, and the last line it admits carries
    the budget notice. No handler can be added that bypasses the budget, because there
    is no other way to reach log.
  2. All three navigation handlers and the console handler route through record.
    Console and navigation now share one budget, not one each.
  3. Trust for a navigation is isMainFrame. did-frame-navigate / did-fail-load
    carry only isMainFrame + frameProcessId/frameRoutingId — no WebFrameMain, no
    origin (verified against electron.d.ts, Electron 43.2.0), so it is the only
    signal available and I use it uniformly across the three. This is sound where the
    [pane] allowlist needed the stronger origin check: a top-frame navigation being
    unbudgeted grants no allowlist and forges no record — the only thing at stake is
    log volume — and a subframe (the pane, the repeatable loop surface) is always
    budgeted. A target="_top" hijack yields at most the single hijacking navigation
    as trusted, after which the dashboard is gone entirely; it is not a repeatable
    in-app flooding surface the way subframe reloads are.

The budget stays charged only for lines actually written (suppressed repeats do not
consume it), so the notice always marks the real last line.

Tests added to frame-load-log console trust boundary:

  1. UNTRUSTED_LOG_BUDGET + 50 subframe did-fail-load events yield exactly
    UNTRUSTED_LOG_BUDGET lines with the budget notice — the navigation path is now
    bounded.
  2. 70 subframe navigations + 70 pane console errors total UNTRUSTED_LOG_BUDGET, not
    2× — one aggregate budget across both paths.
  3. UNTRUSTED_LOG_BUDGET + 50 top-frame (isMainFrame=true) navigations are all
    logged and unbudgeted — the dashboard's own loads are not throttled.

frame-load-log suite 45 passed; whole electron package suite 1662 passed, 1 skipped,
0 failed.

If a fourth round lands on this span I will treat the trust-per-path model itself as
the thing to revisit rather than add another case, but I believe the invariant above
now closes the class: there is one writer, one budget, and one signal per event.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Sep 5, 2026
…not connected ones

The automatic cap (instances.warm_set_cap = 0) was resolved from the number of
crews CONNECTED at the instant GET /api/instances was served. That made it a
race against tunnel startup: with four crews configured and the fourth still
connecting when the dashboard polled, the served cap came back 3, and the
viewport dutifully evicted an LRU pane to honour it.

Eviction is indistinguishable from a disconnect at the pane -- the iframe is
unmounted, the token re-minted, and the remote SPA cold-boots on the next click
-- so the user saw exactly one crew stuck loading forever. Which crew depended
on which tunnel finished last, so the victim moved on every restart and read as
a random failure rather than as a cap.

Count registered crews instead. That is a function of configuration, not of
live state, so it cannot race, and it widens by itself when a crew is added --
nobody has to remember to raise the cap alongside. WARM_SET_CAP_AUTO_CEILING
(8) still bounds it, and an explicit cap >= 1 is still honoured verbatim.

Also journals crew-pane load outcomes, because this class of bug only
reproduces across a restart and left no evidence anywhere. A pane that never
becomes a live document shows only "loading pane": the remote gateway
configures no aiohttp access_log, the main window hooked no frame load events,
and a packaged app has no devtools console to open, so the one question that
decides the diagnosis -- did the frame ever navigate, and with what result --
had no answer.

Two halves, joined in gateway-launch.log:

- frame-load-log.js hooks did-start-navigation, did-frame-navigate and
  did-fail-load on the dashboard's webContents. Together they separate the
  three failures that look identical on screen: no start line at all means the
  frame was never pointed anywhere; a start with no commit means the request
  went out and never came back; a commit with status=403 means the remote
  refused the token.

- src/lib/paneLog.ts journals the renderer's own view of the pane lifecycle
  (mint, warm, ready, timeout, retry, postMessage delivery). Its lines carry a
  [pane] prefix that the forwarder honours, so they interleave with the Chromium
  frame events they explain.

The [pane] prefix is a marker, not a capability. The crew panes are cross-origin
iframes OF the dashboard's webContents, so console-message fires for their
documents too, and a prefix test alone would let a compromised remote gateway
print entries into gateway-launch.log -- forging the very record that says
whether its pane was ever requested, and buying the higher repeat cap and the
exemption from the error-severity filter along with it. The forwarder therefore
gates the prefix on frame identity, and identity takes two checks rather than
one: Electron's console-message details carry the emitting WebFrameMain, so the
forwarder requires BOTH parent === null (the top of the frame tree, a
relationship Chromium owns and a nested page cannot claim) AND that the frame's
origin equal the URL the window was loaded with. Position alone is not identity
-- a cross-origin pane that gets a user click on a target="_top" link can
navigate the top-level window, and the remote document then has parent === null
too. sourceId is deliberately not used for either check -- any script can rewrite
it with a //# sourceURL= comment. Both fail closed, so a runtime that supplies
no frame, or a caller that configures no origin, loses the INFO-level journal
rather than trusting an unverifiable claim.

A pane's console ERRORS are still journaled, since a framing refusal inside the
pane is exactly the diagnosis, but they are attributed to the emitting origin so
a reader can tell pane-controlled text apart from the dashboard's own. All of
that text -- message, sourceId, origin -- is escaped before it is written:
gateway-launch.log is read by tailing it, so a raw newline would let a pane
forge whole entries, and length is capped so one enormous message cannot scroll
the lines around it out of the tail.

The volume is bounded, and the bound is aggregate rather than per-path because a
pane drives more than one path. The repeat counter is keyed by message TEXT, and
text is what a compromised pane chooses: varying it defeats a per-message cap, and
clearing the key map on overflow restarts the counting rather than holding the
line. A pane can just as easily loop its own frame's NAVIGATIONS -- every
did-start-navigation / did-frame-navigate / did-fail-load is another unconditional
line -- so a limit on the console path alone is one the pane walks around by
switching paths. Every emission therefore funnels through one writer (record), and
every line attributable to an untrusted frame, console or navigation alike, is
charged against a single per-attachment budget (UNTRUSTED_LOG_BUDGET = 100),
independent of what the text says. It is charged only for lines actually written,
and the last line it admits names the budget so the silence after it reads as the
cap rather than as the pane recovering. A navigation carries only isMainFrame as a
trust signal -- the positional events pass no frame or origin -- so a subframe
navigation is the pane's and is budgeted, while a top-frame navigation is the
dashboard's own handful and is not. Trusted and untrusted console repeat counters
stay separate maps, so pane volume cannot evict the dashboard's own.

Both redact ?token= while still recording that a token was present -- a token
the remote rejected is the failure worth diagnosing; its value never is.
Console output is otherwise filtered to errors, because renderer warnings
arrive by the hundred per paint and would bury the load lines. The journal is
always on rather than debug-gated: the failure is intermittent and can vanish
on a restart, so the next occurrence has to leave a record without anyone
having enabled anything beforehand.

Also adds frame-load-log.js to build.files. electron-builder ships an explicit
per-file allowlist, so an unlisted module works from source and is simply
absent from the DMG -- the shell-contract drift guard caught this.

The journal's redaction sentinels needed an i18n exemption. `<redacted>`,
`<empty>`, `<unserializable>`, `?token=<redacted>` and `?<query>` are log tokens
whose reader is whoever greps gateway-launch.log, so translating one would make
the journal unsearchable in the incident it exists for -- and a renamed
`<redacted>` would read as if the token had been printed. Two anchored shapes in
eslint.i18n.config.js, both narrow enough that a placeholder inside a sentence
stays reportable copy, with cases in i18nLintExemptions.test.ts pinning both
directions.
@iamwhatever
iamwhatever force-pushed the fix/warm-set-cap-registered-count branch from 454f066 to 9beb358 Compare September 5, 2026 06:52
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: passed Eligible automated validation passed for the current revision and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Sep 5, 2026
@chenmingwei23
chenmingwei23 merged commit e76341d into main Sep 5, 2026
66 checks passed
@chenmingwei23
chenmingwei23 deleted the fix/warm-set-cap-registered-count branch September 5, 2026 07:42
@github-actions github-actions Bot removed the readiness: passed Eligible automated validation passed for the current revision label Sep 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants