Skip to content

feat(security): forward PF inference window console to main + dev devtools#296

Merged
graydawnc merged 14 commits into
mainfrom
feat/pf-05-dev-fixes
May 22, 2026
Merged

feat(security): forward PF inference window console to main + dev devtools#296
graydawnc merged 14 commits into
mainfrom
feat/pf-05-dev-fixes

Conversation

@graydawnc
Copy link
Copy Markdown
Collaborator

@graydawnc graydawnc commented May 22, 2026

PR 5/7 — make-it-work debug session + Effect/OTel observability + DetectorsChip + ScanBanner rescan progress

The chunky one (14 commits). After PR 4 wired the callout + activation, real-world dev surfaced a handful of inference loading + protocol + provider-coordination bugs. Same PR also adds the DetectorsChip + rescan progress in ScanBanner so the user sees what scanner is actually doing and how far along the burst is.

Themed as "make it actually run + surface what's happening" — fixes interleaved with the visibility additions because each fix taught us what to surface.

Subsystems touched

graph TB
  subgraph DevLoad["Inference window loading in dev"]
    A1["3fb8cdd: @fs/abs-path URL → Vite serves<br/>HTML outside the renderer root"]
    A2["5b23a85: pf-model:// URL parsing<br/>+ stream large files"]
    A3["f9538df: self-host ORT WASM<br/>+ correct MIME + skip empty analyze"]
  end

  subgraph Observability["Observability"]
    B1["3546854: Effect-ify pf-runtime /<br/>pf-coordinator / sync; OTel spans<br/>for download / runtime.start /<br/>handshake / analyze"]
    B2["26e882a: forward inference window<br/>console → main stdout + dev devtools"]
    B3["90bd11a: gate devtools behind<br/>SPOOL_PF_DEVTOOLS env"]
  end

  subgraph Coordination["Provider coordination"]
    C1["18be661: gate notifyPfOnline on<br/>runtime ACTUALLY reaching ready<br/>not just spawn-resolved"]
    C2["af4ae23: drop matches without<br/>offsets + diagnostic mapping log"]
    C3["b8f9c55: re-locate matches via<br/>indexOf when tokenizer lacks<br/>offsets fallback"]
  end

  subgraph UI["UI surfaces (meta row)"]
    D1["020d8af: DetectorsChip in Security<br/>page meta row — chip per active<br/>provider + live runtime label"]
    D2["1d81500: ScanBanner shows rescan<br/>progress, not the opaque profile str"]
    D3["d9ac7de: chip typography matches<br/>surrounding meta"]
    D4["9388339: chip sized as inline meta<br/>not buttons"]
  end

  subgraph CoreScan["Core scan listing"]
    E1["813fe3c: exclude info-tier kinds<br/>from per-session listFindings —<br/>keeps high-tier visible on page 1"]
  end
Loading

Critical fixes explained

3fb8cdd — inference window actually loadable in dev

flowchart LR
  subgraph Before
    direction TB
    M["main: win.loadURL"] -->|"/../inference/pf-inference.html"| Vite
    Vite -->|"URL parser normalises to /inference/..."| SPA["SPA fallback"]
    SPA -->|"serves wrong index html"| M
    SPA -->|"Sidebar undefined errors"| Boom["💥"]
  end

  subgraph After
    M2["main: win.loadURL"] -->|"@fs/abs-path/pf-inference.html"| Vite2
    Vite2 -->|"server.fs.allow includes packages/app/src/inference"| Hidden["hidden window<br/>loads correctly"]
    Hidden --> PfReady["pf:ready"]
  end
Loading

Vite roots the renderer at src/renderer/; our HTML lives at src/inference/pf-inference.html (outside). A relative /../inference/... URL hits Vite's SPA fallback, silently serving the main renderer's index.html into the hidden window. Fix: Vite's @fs/<abs-path> escape hatch.

18be661 — wait for actual ready

// Before: pfRuntime.start() resolves on spawn → notifyPfOnline immediately
// → currentProfile drifts to regex@1,pf@1.5b-q4.r2, but actual model
//    load failed inside transformers.js → analyze round-trips to a
//    dead window that returns []. User sees regex-only findings tagged
//    with a profile string that LIES about what scanned them.
//
// After: check getState().status === 'ready' before notifyPfOnline.
//        Failures route to notifyPfOffline; profile stays regex@1.

3546854 — Effect-ify + OTel

The hand-rolled Promise chain in pf-runtime / pf-coordinator / syncPfRuntime never wrote into the OTel pipeline. Failure mode: "PF feels broken but I don't know where it broke." After this commit every span has structured attributes (pf.download.outcome, pf.runtime.status, pf.runtime.kind, pf.runtime.error) and lands in the same exporter as the scan worker.

813fe3c — info-tier exclusion in listFindings

A session with 800+ absolute-path findings + a handful of api-key would have the api-keys pushed off page 1 by detected_at DESC. New excludeInfo filter drops info-tier kinds at the SQL layer; ignored when caller explicitly asks for an info kind.

What's in this PR

  • 9 inference window / protocol / Effect-ify / observability fixes — see commit messages individually
  • 4 UI commits adding DetectorsChip + ScanBanner rescan progress + typography polish
  • 1 core/repo.ts change — excludeInfo filter

Test plan

  • App suite: 273/273 at branch tip
  • Manual: enable PF cold → window loads in dev → ready handshake → first analyze succeeds → DetectorsChip shows "Privacy Filter · WebGPU · Apple M2 Pro"
  • Manual: kill inference window mid-scan → ScanError surfaces, session re-enqueues → next backfill cleans up

Builds on PR 4 (callout). Followed by PR 6 (class-mapping precision restrict).

🤖 Generated with Claude Code

@graydawnc graydawnc force-pushed the feat/pf-04-callout branch from ceb9d61 to b3dc3af Compare May 22, 2026 06:30
@graydawnc graydawnc force-pushed the feat/pf-05-dev-fixes branch from 13244fc to b4023b1 Compare May 22, 2026 06:30
@graydawnc graydawnc marked this pull request as ready for review May 22, 2026 06:44
@graydawnc graydawnc force-pushed the feat/pf-05-dev-fixes branch from b4023b1 to 81fdae2 Compare May 22, 2026 06:52
@graydawnc graydawnc force-pushed the feat/pf-04-callout branch 2 times, most recently from 32625ca to 631ce7b Compare May 22, 2026 07:04
@graydawnc graydawnc force-pushed the feat/pf-05-dev-fixes branch from 81fdae2 to 7643a64 Compare May 22, 2026 07:04
@graydawnc graydawnc force-pushed the feat/pf-04-callout branch from 631ce7b to b852b84 Compare May 22, 2026 07:13
@graydawnc graydawnc force-pushed the feat/pf-05-dev-fixes branch 2 times, most recently from 7212acb to 16ce429 Compare May 22, 2026 07:19
@graydawnc graydawnc force-pushed the feat/pf-04-callout branch from b852b84 to 0a5ae69 Compare May 22, 2026 07:19
Base automatically changed from feat/pf-04-callout to main May 22, 2026 07:24
graydawnc added 11 commits May 22, 2026 15:25
…tools

The hidden inference renderer used to swallow its own console.log /
console.error / uncaught exceptions, which made silent transformers.js
or ONNX Runtime Web failures indistinguishable from a stuck handshake
on main's side — pfRuntime.start() resolved after the 90 s timeout
without surfacing why.

- Listen to webContents 'console-message' + 'render-process-gone' on
  defaultSpawnWindow, prefix log lines with [pf-inference] + level tag
  so they're greppable in main's stdout / OTel pipeline
- In dev (ELECTRON_RENDERER_URL set), openDevTools({mode:'detach'})
  on the inference window so model-load errors are immediately
  inspectable. Production builds skip this entirely
…eady

pfRuntime.start() resolves even when the handshake failed — the hidden
inference window might be up but transformers.js / ONNX crashed
loading the model, leaving state.status='failed'. syncPfRuntime used
to call scanWorker.notifyPfOnline() unconditionally after start(),
which:

  - drifted the scan profile to `regex@4,pf@1.5b-q4`, claiming PF
    participated when it actually didn't
  - made every analyze IPC round-trip to a dead host that returned []
    via the catchAll, producing regex-only findings tagged with a
    profile string that lies about what scanned them
  - made the symptom invisible from main's logs (no failure surface)

Now check `pfRuntime.getState().status === 'ready'` before notifying
the worker. Failure path logs the state + error and calls
notifyPfOffline so worker's pfOnline stays false and the profile
correctly reflects regex@4 only.

The pf-coordinator's failed state already surfaces via the Settings
runtime badge + the new Security-page DetectorsChip — users see
"Privacy Filter · failed" instead of a phantom-active claim.
A session with 800+ absolute-path findings (info-tier, default
hidden) and a handful of env-var / api-key findings (high-tier,
the actual leak) would render with no inline findings on the
Security page. SessionCard fetched the first 200 rows ordered by
detected_at DESC, the latest scan put all the absolute-paths in
that window, and the client-side `filter(!INFO_SEVERITY_KINDS)`
had nothing left to show.

- listFindings: new `excludeInfo: boolean` filter that drops
  info-tier kinds (absolute-path / ip / internal-host) at the SQL
  layer. Ignored when the caller explicitly includes an info kind
  via `kind` / `kinds`, so the "pin info kind" filter pill still
  works.
- SessionCard.load: pass `excludeInfo: true` by default; flip back
  to false only when the user has pinned an info kind. The
  existing client-side filter stays as defense-in-depth.

Pre-existing bug, surfaced while debugging PF rescans — independent
of the PF stack but committed here for convenience.
Surface which detectors are actively producing findings, with live
status — not just the opaque `regex@4,pf@1.5b-q4` profile string.
Before this, the only way to tell whether Privacy Filter was
actually contributing (vs. crashed and silently returning []) was
to dig into Settings → Security and check the runtime badge.

- new DetectorsChip component, renders inline pills:
  - "Pattern matching v4" — always shown
  - "Privacy Filter · WebGPU / WASM (CPU) / starting… / failed" —
    only when the profile string includes pf@
- polls pfGetRuntimeInfo at 3 s while pf is in profile so a
  loading → ready / loading → failed transition shows without a
  page refresh
- wires into the existing meta row after the activeKinds filter
  block. ScanBanner / ScanResultBanner sit above it as before;
  scanStatus is already available

Now a failing inference window (model load throw, GPU adapter
rejection, missing CSP) shows "Privacy Filter · failed" right next
to the findings count — same surface where the rest of the
detector summary lives.
Three independent bugs that combined to make pfRuntime.start()
silently fail before reaching pf:ready in dev mode. The bug 2-a
console forwarder added in an earlier commit is what finally
surfaced them — until then they looked like a stuck handshake.

1. Wrong dev URL → main app rendered inside the hidden window
   Vite roots the renderer at src/renderer/; pf-inference.html
   lives at src/inference/. The previous loadURL traversal
   `${rendererBase}/../inference/pf-inference.html` got
   URL-normalised down to `/inference/...`, which Vite's SPA
   fallback served as the main index.html. Result: the hidden
   window mounted the entire main app, blew up with `window.spool`
   undefined in <Sidebar> / <LibraryLanding>, and never reached
   the inference entry. Use Vite's `@fs/<abs-path>` escape hatch
   to address the source file directly + whitelist
   `src/inference/` in `server.fs.allow` so the dev server
   actually serves it.

2. pf-model:// fetch CORS-blocked across origins
   Document origin is http://localhost in dev / file:// in prod,
   protocol origin is pf-model:// — different by definition.
   Chromium's CORS layer blocks cross-origin fetch by default for
   privileged schemes. Set `corsEnabled: true` on the privileged
   scheme registration. The handler only serves files under
   pfModelsRoot() with path-traversal refused → no widened
   surface, just CORS unlock.

3. CSP didn't allow Vite's HMR WebSocket
   `connect-src 'self'` matches http://localhost but not the
   `ws://localhost` HMR socket Vite injects. Surfaced as console
   noise + a permanently-pending HMR client. Add `ws: wss:` to
   connect-src; the inference page never opens a WebSocket itself
   in prod, so the only thing that can actually use this is the
   Vite client.
Two follow-on bugs from the dev-load fix:

1. Chromium's URL parser, with our `standard: true` privileged scheme,
   treats the first path segment after `://` as a HOST. transformers.js
   fetches `pf-model:///openai-privacy-filter-q4/config.json`; Electron
   normalises the `///` and reports it back as
   host=`openai-privacy-filter-q4`, pathname=`/config.json`. The
   handler used pathname only, resolved to
   `<userData>/models/config.json`, returned ENOENT. Combine host + path
   before joining. Also URL-decode so paths with spaces
   ("Application Support") resolve correctly.

2. `net.fetch(pathToFileURL(target))` was hitting `ERR_FILE_NOT_FOUND`
   for the same reason + a separate encoding quirk. Switched to
   `createReadStream` + a Web Streams `Response`, which keeps the
   stream behaviour (917 MB onnx_data should never buffer in memory)
   while sidestepping the encoded-file-URL issues entirely.
Until now the Privacy Filter download + activation flow was all
hand-rolled Promises. When the inference window silently failed it
took ages to find out because nothing wrote into the OTel pipeline
that already collects every scan + sync span. This commit closes that
gap by routing the load-bearing PF paths through Effect.withSpan +
Effect.annotateCurrentSpan, wired to main's runWithObservability.

- pf-runtime.ts: makePfRuntime accepts a `run` dep (defaults to bare
  `Effect.runPromise` for tests; main injects `runWithObservability`).
  start() / stop() / analyze() each run their bodies inside an
  Effect block with a span name:
    pf.runtime.start  — annotated with pf.status / pf.runtime /
                        pf.adapter / pf.error
    pf.runtime.stop
    pf.analyze        — annotated with text_len + pf.matches
- pf-coordinator.ts: startDownload moves into Effect.tryPromise +
  Effect.ensuring (replaces the try/finally); wrapped with
    pf.coordinator.download
  annotated with total_bytes + file count + pf.download.outcome
  (installed / cancelled / failed)
- main/index.ts: syncPfRuntime is now a Promise wrapper around an
  Effect.gen body with span `pf.sync_runtime`. Annotations: pf.enabled,
  pf.runtime.status, pf.runtime.kind, pf.runtime.error, pf.notified
  (online / offline). Uses Effect.logError for the failed-handshake
  message so it carries fiber id + structured fields. Effect.ensuring
  clears pfActivationPending on every exit path (success and fail).

Tests unchanged — `run` is optional, defaults to Effect.runPromise.
Full app suite 273/273. Restart with this code shows:
  [pf.runtime.start]  (2.68s)  pf.status=failed pf.error=…
  [pf.sync_runtime]   (3.19s)  pf.enabled=true pf.notified=offline …
exactly what diagnosing PF startup issues should feel like.
ONNX Runtime Web defaults to fetching its WASM + JS-glue runtime
from cdn.jsdelivr.net. Incompatible with both Privacy Filter's
on-device guarantee and our strict CSP. Pin it to a local source
served through the existing pf-model:// protocol:

- pf-model-protocol.ts: route `pf-model:///ort/<file>` to
  onnxruntime-web/dist/. Resolution hops through @huggingface/transformers's
  scope (createRequire chain) because onnxruntime-web is a transitive
  dep — pnpm doesn't expose it directly in main's resolution scope.
- MIME table extended with .mjs / .js / .wasm — Chromium refuses to
  dynamic-import() a response unless the Content-Type is a recognised
  JS module type. Without this, the bytes arrived but ORT's
  asyncify.mjs / jsep.mjs were rejected at the module boundary.
- pf-inference.ts: env.backends.onnx.wasm.wasmPaths = 'pf-model:///ort/'
- pf-inference.html CSP: pf-model: added to script-src + worker-src
  (ORT's WASM glue is loaded both as script-elem and as Worker
  source). connect-src + default-src already had it.
- scan-worker-thread.ts pfProvider: short-circuit empty / whitespace
  text. GatherBlockQuantized in the model's embed_tokens layer fails
  with "Invalid dispatch group size (0, 1, 1)" on zero-token input;
  cheaper to skip the IPC entirely.

End-to-end status: with these changes pf:ready handshake completes
in ~2.5 s, scan worker is notified online, profile drifts to
regex@4,pf@1.5b-q4, and pf.analyze spans start landing for real
text. Outstanding: investigate the GatherBlockQuantized error on
non-empty inputs (may be tokenizer / quantization mismatch in the
q4 build — separate diagnosis).
Two follow-ons from end-to-end PF inference debugging:

1. SQLITE_CONSTRAINT_NOTNULL on insertFindings
   findings.start_offset and findings.end_offset are NOT NULL.
   transformers.js's `aggregation_strategy: 'simple'` returns
   `start: undefined` / `end: undefined` for some matches when the
   tokenizer can't anchor them to source offsets. Filter those out
   in pf-inference.ts BEFORE crossing the IPC — a match without a
   span is useless in Spool anyway (can't highlight, purge, or hover-
   reveal). Stops every PF-containing scan from rolling back with
   db-failed.

2. Diagnostic console line in pfProvider
   Added one-shot `[pf provider] raw=N mapped=M kinds=...` line so
   the suppression behaviour of class-mapping is visible without
   reading the source. Already paid off — confirms we see
   "raw=2 mapped=1 kinds=person,secret" (person → person-name kept,
   secret suppressed because no overlapping regex hit).
   Leaving in for now; will move into a span attribute on the next
   sweep if we keep it.
…file

Replaces \`regex@4,pf@1.5b-q4\` in the ScanBanner with a human-readable
count of the active rescan batch. The version string was dev jargon
that other products universally hide from users — VS Code / Spotlight /
1Password Watchtower / Tailscale all show only status + progress
during active operations, never engine versions. The DetectorsChip
in the meta row above already surfaces the same info as friendly
labels ("Pattern matching v4" · "Privacy Filter · WebGPU"), so the
banner was double-stating it in raw form.

- ScanBanner: profile chip → "23 of 145 rescans" (en) / "已重扫 23 / 145"
  (zh). Uses the existing burst-scoped backfillTotal high-water mark
  (PR #271 — stable, doesn't rewind on tab switch). Explicit "rescans"
  / "重扫" verb signals this is the active batch, NOT total sessions
  in the library — pre-empts the natural "this number doesn't match
  the sidebar's session count" question without needing a tooltip.
- i18n: settings.security.scanning_progress key for en + zh-CN +
  zh-TW + ja/ko/de/fr placeholder (English).
The meta row uses font-mono 11px for the "{N} 项风险 · {N} 项信息" text
but the chip's label was sans 11px — Geist Sans at the same px is
optically larger than Geist Mono, so the chips read as a different
"size class" than the row they sit in. Switch the entire chip to
font-mono so labels + version meta + neighbour text all render at
the same visual scale.
graydawnc added 3 commits May 22, 2026 15:25
Chips at h-5 + 11px text were closer to button-sized than to the
"180 项风险 · 8342 项信息" meta-row neighbours. Drop one notch each:
h-[18px], text-[10px], icon size 9, gap-[3px], border-radius 4px.
Still legible (10px Geist Mono is the same scale as other meta-row
inline tokens elsewhere in the app), but feels embedded in the row
instead of floating above it.
Was opening devtools on every dev start since PR 5c — useful while
the inference window was a black box, but the console-message
forwarder already pipes [pf-inference LOG/WARN/ERR] lines into
main's stdout, so the detached devtools window is just visual
clutter unless we're actively debugging this surface.

Opt in with `SPOOL_PF_DEVTOOLS=1 pnpm dev` when needed.
…offsets

transformers.js with BPE tokenizers occasionally returns matches with
`start: undefined / end: undefined` even when aggregation_strategy is
'simple'. Hard-filtering those out earlier (PR …7b08fa7) silenced the
SQLITE_CONSTRAINT_NOTNULL crash but also dropped every PF match —
post-restart we saw 2000+ pf.analyze calls with 0 surviving matches.

Better fallback: when offsets are missing, locate the matched word
in the source text via indexOf advancing a cursor so duplicates land
at distinct positions (matches arrive in document order). Drop only
when even indexOf can't find the word — should be rare since the
word came from the tokenizer reading the same source.

Keeps the NOT NULL safety while not throwing away real findings.
@graydawnc graydawnc force-pushed the feat/pf-05-dev-fixes branch from 16ce429 to f5ed169 Compare May 22, 2026 07:26
@graydawnc graydawnc added this pull request to the merge queue May 22, 2026
Merged via the queue into main with commit e17cf54 May 22, 2026
4 checks passed
@graydawnc graydawnc deleted the feat/pf-05-dev-fixes branch May 22, 2026 07:31
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.

1 participant