feat(security): forward PF inference window console to main + dev devtools#296
Merged
Conversation
This was referenced May 22, 2026
Collaborator
Author
This was referenced May 22, 2026
ceb9d61 to
b3dc3af
Compare
13244fc to
b4023b1
Compare
b4023b1 to
81fdae2
Compare
32625ca to
631ce7b
Compare
81fdae2 to
7643a64
Compare
631ce7b to
b852b84
Compare
7212acb to
16ce429
Compare
b852b84 to
0a5ae69
Compare
…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.
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.
16ce429 to
f5ed169
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

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"] endCritical fixes explained
3fb8cdd— inference window actually loadable in devflowchart 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"] endVite roots the renderer at
src/renderer/; our HTML lives atsrc/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 ready3546854— Effect-ify + OTelThe hand-rolled Promise chain in
pf-runtime/pf-coordinator/syncPfRuntimenever 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 listFindingsA session with 800+
absolute-pathfindings + a handful ofapi-keywould have the api-keys pushed off page 1 bydetected_at DESC. NewexcludeInfofilter drops info-tier kinds at the SQL layer; ignored when caller explicitly asks for an info kind.What's in this PR
excludeInfofilterTest plan
Builds on PR 4 (callout). Followed by PR 6 (class-mapping precision restrict).
🤖 Generated with Claude Code