Skip to content

Commit 030ae89

Browse files
rcurrieclaude
andcommitted
0.1.1: answer the review — and stop shipping bytes we don't run
Two blocking Errors in the 0.1.0 review, both the same root cause: inline `eslint-disable` comments for an `obsidianmd/*` rule are rejected outright. The console logging they suppressed was only a Warning — the directives were the failure. Everything else in a very long report was a Warning or a Recommendation. Directives gone, and the logging with them rather than suppressed. The two dev-only `console.log`s moved onto the debug shim as `backendSpoof()` and `lastWorkerEnv()`, which is strictly better: obsidian-drive.mjs can assert on the values instead of a human reading them out of a console. The worker's warn/error bridge stays — a worker is its own console target, so without it a conversion that died inside the thread leaves nothing behind — but it is relaxed in eslint.config.mjs, where a reader can see it, not mid-file. Also dropped an unnecessary no-control-regex directive; the repository now has zero inline eslint directives. **The finding worth keeping: the review reads the bundle, not the intent.** main.js contained `require("fs")`, `require("path")` and `require("os")`, and the plugin was described to users as able to "read and write any file on the system". All three lived inside the onnxruntime emscripten glue, which was inlined *as a text string* and — since ORT fixed the renderer guard upstream around 1.24 — never used at all. Ninety-two kilobytes of dead text earned a filesystem-access warning on a public safety scorecard. The decision now happens in analyzeOrtGlue() at build time and the glue is inlined only when a patch is genuinely needed; the loud failure on an unrecognised epilogue stays, so a future ORT bump still can't reintroduce the bug quietly. main.js 4675571 → 4582504 bytes, and the only `require()` left in it is `require("obsidian")`. Encoding that string so a scanner wouldn't recognise it would also have "worked". It is obfuscation, which the developer policies prohibit, and the wrong instinct regardless: not shipping it is the fix. Also: pdf.js gets `isEvalSupported: false` on both the worker and renderer paths, so Type-4 shading functions are interpreted rather than compiled — a converter should not compile code out of the document it was handed. Priced against the ground-truth suite before keeping it: attention 8/8 checks on WebGPU, 376 s, identical to the recorded baseline. `authorUrl` now points at the org profile rather than this repository, and the release workflow attests its assets (actions/attest-build-provenance, with the id-token and attestations permissions that needs). Deliberately not fixed, and why: the "use window.setTimeout" and "avoid globalThis" warnings land on code that runs inside a Web Worker, where `window` does not exist — applying them would break the default conversion path. The hundreds of no-unsafe-*/no-explicit-any warnings land on untyped third-party surfaces (transformers.js env, ORT wasmPaths, pdf.js internals) across files including the Node CLI, which is not in the plugin bundle at all. getSettingDefinitions() is real UX value and deferred to 0.2.0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 6d802a8 commit 030ae89

13 files changed

Lines changed: 206 additions & 70 deletions

File tree

.github/workflows/release.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ on:
1616

1717
permissions:
1818
contents: write
19+
# Artifact attestations: `id-token` signs the provenance statement with the
20+
# run's OIDC identity, `attestations` writes it to the repository's store.
21+
# Without them a user has to take on faith that the main.js they downloaded
22+
# was built from this source — which, for a plugin that reads their documents,
23+
# is worth more than the two lines it costs.
24+
id-token: write
25+
attestations: write
1926

2027
jobs:
2128
release:
@@ -41,6 +48,17 @@ jobs:
4148
- run: npm run typecheck
4249
- run: npm run build
4350

51+
# Must run in the job that produced the files, against the exact bytes
52+
# that get attached below — an attestation over a rebuilt artifact proves
53+
# nothing about the one users download.
54+
- name: Attest the release assets
55+
uses: actions/attest-build-provenance@v4
56+
with:
57+
subject-path: |
58+
plugin/dist/main.js
59+
plugin/dist/manifest.json
60+
plugin/dist/styles.css
61+
4462
# Left as a draft on purpose, so the assets can be checked before anyone
4563
# can install them. When you publish it, leave "Set as a pre-release"
4664
# UNTICKED: GitHub excludes pre-releases from /releases/latest, and

PLAN.md

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -558,10 +558,18 @@ without the per-platform binaries. Keep it in the back pocket if a machine ever
558558
`plugin/` can no longer see it (and silently assumed a mobile plugin, which made Electron's
559559
`process` an undefined global). `plugin/check-manifest.mjs` asserts the submission rules
560560
directly and gates the release workflow.
561-
- [ ] **Submit.** Blocked on two things that are the author's to do: the repository is still
562-
**private**, and there is **no release yet** — the directory needs a tag equal to the manifest
563-
version carrying `main.js`, `manifest.json`, `styles.css`. Then community.obsidian.md; the
564-
`obsidian-releases` PR route is gone.
561+
- [x] **Submitted; 0.1.0 failed review; 0.1.1 answers it.** Two blocking Errors, both the same root
562+
cause: inline `eslint-disable` comments for an `obsidianmd/*` rule are rejected outright —
563+
the console logging they suppressed was only a Warning. Everything else in a very long report
564+
was a Warning or Recommendation. Fixed: directives gone (dev logs moved onto the probe, where
565+
the driver can assert on them instead of a human reading a console); `authorUrl` → the org
566+
profile; artifact attestations on the release assets.
567+
**The finding worth remembering: the review reads the bundle, not the intent.** `main.js`
568+
carried `require("fs")`/`path`/`os` and the plugin was reported to users as able to "read and
569+
write any file on the system" — from 92 KB of onnxruntime emscripten glue inlined *as a text
570+
string* and, since ORT fixed the guard upstream ~1.24, never used at all. Now decided at build
571+
time and inlined only when a patch is genuinely needed. Also `isEvalSupported: false` for
572+
pdf.js, so a document's Type-4 shading functions are interpreted rather than compiled.
565573
- [ ] **Windows and Linux remain untested.** The code no longer assumes WebGPU (probe → candidates →
566574
CPU fallback, all unit-tested against fake navigators), but nothing has run on either OS.
567575

engine-js/src/browser/pdf.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ export interface LoadPdfBrowserOptions {
141141
}
142142

143143
/**
144-
* Extra `getDocument` parameters needed when there is no document.
144+
* `getDocument` parameters for this host.
145145
*
146146
* Font handling matters as much as the canvas: with `disableFontFace` left at
147147
* its browser default, pdf.js registers `FontFace`s against `document.fonts`.
@@ -150,8 +150,22 @@ export interface LoadPdfBrowserOptions {
150150
* the one the fixture suite validated.
151151
*/
152152
function docParams(opts: LoadPdfBrowserOptions): Record<string, unknown> {
153-
if (typeof document !== "undefined") return {};
153+
/**
154+
* Never compile PDF-supplied code.
155+
*
156+
* pdf.js turns Type 4 (PostScript calculator) shading functions into
157+
* JavaScript with `new Function(src, …)` when it can, and falls back to an
158+
* interpreter when it can't. The speed only matters for gradient-heavy
159+
* artwork, and we rasterize once per page; a document conversion tool
160+
* compiling expressions out of the document it was handed is a bad trade at
161+
* any speed, and it is what Obsidian's review flags as dynamic code
162+
* execution. Applies on *both* paths — the renderer fallback runs the same
163+
* pdf.js as the worker.
164+
*/
165+
const shared = { isEvalSupported: false };
166+
if (typeof document !== "undefined") return shared;
154167
return {
168+
...shared,
155169
CanvasFactory: OffscreenCanvasFactory,
156170
FilterFactory: NoopFilterFactory,
157171
disableFontFace: true,

manifest.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
{
22
"id": "reflow",
33
"name": "Reflow",
4-
"version": "0.1.0",
4+
"version": "0.1.1",
55
"minAppVersion": "1.5.0",
66
"description": "Convert a PDF into clean, readable Markdown entirely on your device. Figures, tables, and math survive. No API key, no upload, no page limit.",
77
"author": "Ampdat",
8-
"authorUrl": "https://github.com/ampdat/reflow",
8+
"authorUrl": "https://github.com/ampdat",
99
"isDesktopOnly": true
1010
}

plugin/README.md

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,10 +114,44 @@ Obsidian's installer downloads **only** `main.js`, `manifest.json` and
114114
would quietly convert on the main thread forever. `worker.ts` is bundled to
115115
`build/worker.js` (scratch, gitignored) and inlined from there.
116116
2. **pdf.js's parsing worker is inlined too** ([`assets.ts`](assets.ts)), for the
117-
remote-code reason above. `main.js` is ~4.7 MB as a result: the main bundle,
117+
remote-code reason above. `main.js` is ~4.6 MB as a result: the main bundle,
118118
the worker bundle, and two copies of the pdf.js worker (the renderer path and
119119
the conversion worker each need one).
120120

121+
### What the review reads is the bundle, not your intent
122+
123+
Two findings in the 0.1.0 review came from bytes in `main.js` that the plugin
124+
never executes, and both are worth remembering because the instinct they invite
125+
is the wrong one.
126+
127+
**"Direct Filesystem Access — can read and write any file on the system."**
128+
`main.js` contained `require("fs")`, `require("path")` and `require("os")`. All
129+
of them lived inside the onnxruntime emscripten glue, which was inlined *as a
130+
text string* and — on a current dependency tree — never used at all, because
131+
the guard it patches has been fixed upstream since ORT ~1.24. Ninety-two
132+
kilobytes of dead text was enough to have the plugin described to users as
133+
something that reads their filesystem. The fix is in
134+
[`esbuild.config.mjs`](esbuild.config.mjs): decide at build time whether a patch
135+
is needed and inline the glue only then. Encoding the string so a scanner
136+
wouldn't recognise it would have "worked" and is exactly what the developer
137+
policies prohibit — obfuscation is a policy violation, not a workaround.
138+
139+
**Dynamic code execution.** pdf.js compiles Type 4 (PostScript calculator)
140+
shading functions with `new Function(src, …)`. `docParams()` in
141+
[`browser/pdf.ts`](../engine-js/src/browser/pdf.ts) now passes
142+
`isEvalSupported: false` on both the worker and renderer paths, so pdf.js uses
143+
its interpreter and never compiles code out of the document it was handed. Its
144+
own `new Function("")` capability probe still appears in the bundle, so a static
145+
scanner may still flag the pattern; the substance is that nothing in a converted
146+
PDF becomes executable code.
147+
148+
**Inline `eslint-disable` comments for `obsidianmd/*` rules are rejected
149+
outright** — that, not the logging they suppressed, is what failed 0.1.0. The
150+
console calls themselves are only a warning. Where a rule genuinely has to be
151+
relaxed (the worker's warn/error bridge, which is a worker's only route to the
152+
console), it is relaxed in [`eslint.config.mjs`](eslint.config.mjs) where it is
153+
visible, never mid-file.
154+
121155
### The manifest lives at the repository root
122156

123157
[`../manifest.json`](../manifest.json), not next to this source, because the

plugin/check-manifest.mjs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ const d = manifest.description ?? "";
4040
check(d.length <= 250, `description must be 250 characters or fewer (is ${d.length})`);
4141
check(d.endsWith("."), "description must end with a period");
4242
check(!/^(this is a |this plugin|a plugin)/i.test(d), "description must not start with 'This is a plugin'");
43-
// eslint-disable-next-line no-control-regex -- deliberately matching non-ASCII
4443
check(!/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/u.test(d), "description must not contain emoji");
4544

4645
check("fundingUrl" in manifest === false || !!manifest.fundingUrl, "remove fundingUrl if unused");

plugin/esbuild.config.mjs

Lines changed: 69 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -34,54 +34,79 @@ const dev = watch || process.argv.includes("--dev");
3434
//
3535
// Instead, inline a *patched* copy as text under `virtual:ort-glue`; ort-env.ts
3636
// serves it from a blob URL via `wasmPaths.mjs`. See plugin/ort-env.ts.
37+
//
38+
// The decision is made **here, at build time**, and the text is inlined only if
39+
// a patch is actually needed. It used to be inlined unconditionally and the
40+
// same question asked again at runtime, which cost more than the 92 KB: the
41+
// glue's own Node branch (`var fs = require("fs")`, `require("path")`,
42+
// `require("os")`) sat in main.js as dead string data, and Obsidian's review
43+
// reads the bundle, not the guard around it. The plugin was reported as
44+
// "Direct Filesystem Access ... can read and write any file on the system" on
45+
// the strength of text it never executes. Encoding the string to hide it would
46+
// be obfuscation, which the developer policies forbid outright — and would be
47+
// the wrong instinct anyway. Not shipping it is the honest fix.
3748
const ORT_GLUE = "node_modules/@huggingface/transformers/dist/ort-wasm-simd-threaded.jsep.mjs";
3849
const GLUE_EPILOGUE =
3950
"if (isNode) isPthread = (await import('worker_threads')).workerData === 'em-pthread';";
4051

52+
/**
53+
* Does the installed glue still need our patch, and if so, what does the
54+
* patched copy look like?
55+
*
56+
* Runs once per build rather than per bundle, so both the main and the worker
57+
* context agree — and so the answer can be `define`d into both.
58+
*/
59+
function analyzeOrtGlue() {
60+
const source = readFileSync(ORT_GLUE, "utf8");
61+
62+
// onnxruntime-web ~1.24-1.26 added the missing guard upstream, so on a
63+
// current transformers there is nothing to patch. Detect that rather than
64+
// assuming a version: `process?.type != "renderer"` in the epilogue.
65+
if (/isPthread\s*=\s*\(await import\(["']worker_threads["']\)\)/.test(source)) {
66+
const epilogue = source.slice(source.lastIndexOf("export default"));
67+
if (/process\??\.type\s*!=/.test(epilogue)) {
68+
console.log(" [ort-glue] upstream glue guards on process.type — nothing inlined");
69+
return { patched: false, source: "" };
70+
}
71+
}
72+
73+
if (!source.includes(GLUE_EPILOGUE)) {
74+
// Neither the known-broken epilogue nor the upstream fix: fail loudly
75+
// rather than shipping a glue we only think we understand.
76+
throw new Error(
77+
`[ort-glue] unrecognized node-detection epilogue in ${ORT_GLUE}. ` +
78+
`onnxruntime-web changed; re-check the patch in plugin/ort-env.ts.`,
79+
);
80+
}
81+
const patched = source.replace(GLUE_EPILOGUE, "// [reflow] node pthread bootstrap removed");
82+
// One `worker_threads` reference legitimately survives: the one inside the
83+
// module body, which *is* guarded by `"renderer" != process.type` and so is
84+
// unreachable here. Anything else means the patch missed something.
85+
const remaining = patched.split("worker_threads").length - 1;
86+
if (remaining !== 1 || !patched.includes('"renderer"!=process.type')) {
87+
throw new Error(
88+
`[ort-glue] expected exactly one renderer-guarded worker_threads reference ` +
89+
`after patching, found ${remaining} (guard present: ` +
90+
`${patched.includes('"renderer"!=process.type')})`,
91+
);
92+
}
93+
console.log(` [ort-glue] patched ${(patched.length / 1024).toFixed(1)} KB of emscripten glue`);
94+
return { patched: true, source: patched };
95+
}
96+
97+
const ortGlue = analyzeOrtGlue();
98+
4199
const inlinePatchedOrtGlue = {
42100
name: "inline-patched-ort-glue",
43101
setup(build) {
44102
build.onResolve({ filter: /^virtual:ort-glue$/ }, () => ({
45103
path: "virtual:ort-glue",
46104
namespace: "ort-glue",
47105
}));
48-
build.onLoad({ filter: /.*/, namespace: "ort-glue" }, () => {
49-
const source = readFileSync(ORT_GLUE, "utf8");
50-
51-
// onnxruntime-web ~1.24-1.26 added the missing guard upstream, so on a
52-
// current transformers there is nothing to patch. Detect that rather than
53-
// assuming a version: `process?.type != "renderer"` in the epilogue.
54-
if (/isPthread\s*=\s*\(await import\(["']worker_threads["']\)\)/.test(source)) {
55-
const epilogue = source.slice(source.lastIndexOf("export default"));
56-
if (/process\??\.type\s*!=/.test(epilogue)) {
57-
console.log(" [ort-glue] upstream glue already guards on process.type — no patch needed");
58-
return { contents: source, loader: "text" };
59-
}
60-
}
61-
62-
if (!source.includes(GLUE_EPILOGUE)) {
63-
// Neither the known-broken epilogue nor the upstream fix: fail loudly
64-
// rather than shipping a glue we only think we understand.
65-
throw new Error(
66-
`[ort-glue] unrecognized node-detection epilogue in ${ORT_GLUE}. ` +
67-
`onnxruntime-web changed; re-check the patch in plugin/ort-env.ts.`,
68-
);
69-
}
70-
const patched = source.replace(GLUE_EPILOGUE, "// [reflow] node pthread bootstrap removed");
71-
// One `worker_threads` reference legitimately survives: the one inside the
72-
// module body, which *is* guarded by `"renderer" != process.type` and so is
73-
// unreachable here. Anything else means the patch missed something.
74-
const remaining = patched.split("worker_threads").length - 1;
75-
if (remaining !== 1 || !patched.includes('"renderer"!=process.type')) {
76-
throw new Error(
77-
`[ort-glue] expected exactly one renderer-guarded worker_threads reference ` +
78-
`after patching, found ${remaining} (guard present: ` +
79-
`${patched.includes('"renderer"!=process.type')})`,
80-
);
81-
}
82-
console.log(` [ort-glue] patched ${(patched.length / 1024).toFixed(1)} KB of emscripten glue`);
83-
return { contents: patched, loader: "text" };
84-
});
106+
build.onLoad({ filter: /.*/, namespace: "ort-glue" }, () => ({
107+
contents: ortGlue.source,
108+
loader: "text",
109+
}));
85110
},
86111
};
87112

@@ -149,7 +174,13 @@ const shared = {
149174
// with --node-integration-in-worker, so `process.release.name === "node"` is
150175
// true there too and transformers.js would pick its cpu-only node backend.
151176
banner: { js: forceWebBackend },
152-
define: { __REFLOW_DEV__: String(dev) },
177+
define: {
178+
__REFLOW_DEV__: String(dev),
179+
// Whether `virtual:ort-glue` carries a patched copy at all. Asked at build
180+
// time so ort-env.ts doesn't have to re-derive it by regexing text that,
181+
// on a healthy dependency tree, isn't in the bundle.
182+
__REFLOW_ORT_GLUE_PATCHED__: String(ortGlue.patched),
183+
},
153184
sourcemap: watch ? "inline" : false,
154185
minify: !watch,
155186
logLevel: "info",

plugin/eslint.config.mjs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@ export default tseslint.config(
2020
languageOptions: {
2121
parserOptions: { project: "./tsconfig.json", tsconfigRootDir: import.meta.dirname },
2222
globals: {
23-
// Build-time constant, `define`d by esbuild (see virtual.d.ts).
23+
// Build-time constants, `define`d by esbuild (see virtual.d.ts).
2424
__REFLOW_DEV__: "readonly",
25+
__REFLOW_ORT_GLUE_PATCHED__: "readonly",
2526
// `manifest.json` lives at the repository root, because that is where
2627
// the community directory reads it from. This config's base path is
2728
// plugin/, so the obsidianmd rules can no longer find it and fall back
@@ -62,6 +63,15 @@ export default tseslint.config(
6263
"obsidianmd/rule-custom-message": "off",
6364
},
6465
},
66+
{
67+
// The bridge that carries worker-side warnings and errors to the renderer's
68+
// console (see the comment at the call site). Configured here rather than
69+
// with an inline `eslint-disable`, which Obsidian's review rejects outright
70+
// for obsidianmd/* rules — and rightly: a rule turned off in a config file
71+
// is visible, one turned off mid-file is not.
72+
files: ["worker-host.ts"],
73+
rules: { "obsidianmd/rule-custom-message": "off" },
74+
},
6575
{
6676
// probe.ts is development-only: release builds resolve it to probe-stub.ts
6777
// (see esbuild.config.mjs), so nothing here reaches an installed plugin.

plugin/main.ts

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,8 @@ export default class ReflowPlugin extends Plugin {
342342
private workerUrl: string | null = null;
343343
/** How the last conversion actually ran, for the probe and the log. */
344344
lastRunMode: "worker" | "renderer" | null = null;
345+
/** Environment the conversion worker reported on boot; read by the probe. */
346+
lastWorkerEnv: Record<string, unknown> | null = null;
345347
/** Compute-backend probe, run once per session (see `probeOnce`). */
346348
private deviceProbe: Promise<DeviceProbe> | null = null;
347349
/** Its resolved value, for the synchronous message path. */
@@ -363,19 +365,21 @@ export default class ReflowPlugin extends Plugin {
363365
*/
364366
private installDevProbe(): void {
365367
const w = window as unknown as { __reflow_spoofResult?: string };
366-
// eslint-disable-next-line obsidianmd/rule-custom-message -- dev build only
367-
console.log(
368-
`[reflow] backend spoof: ${w.__reflow_spoofResult} | ` +
369-
`navigator.gpu: ${typeof navigator !== "undefined" && "gpu" in navigator} | ` +
370-
`ort glue: ${ortConfig.strategy} (${ortConfig.glueBytes} B)`,
371-
);
372368

373369
installProbe({
374370
// The injected platform libs, so an ad-hoc probe can exercise pdf.js or
375371
// transformers.js directly (bisecting a stall to one of them) without a
376372
// plugin rebuild — same rationale as exposing `ort`.
377373
pdfjs,
378374
transformers,
375+
/**
376+
* Whether the esbuild banner's `process.release.name` rename took, read
377+
* back off the global it wrote. This used to be logged at load; it is
378+
* pulled rather than pushed now, which costs nothing (the plugin is
379+
* loaded long before anyone asks) and keeps a diagnostic that only
380+
* developers want out of every user's console.
381+
*/
382+
backendSpoof: () => w.__reflow_spoofResult ?? null,
379383
readBinary: async (path: string) => {
380384
const f = this.app.vault.getAbstractFileByPath(path);
381385
if (!(f instanceof TFile)) throw new Error(`not a file in the vault: ${path}`);
@@ -394,6 +398,15 @@ export default class ReflowPlugin extends Plugin {
394398
return this.settings.useWorker;
395399
},
396400
lastRunMode: () => this.lastRunMode,
401+
/**
402+
* What the conversion worker reported about its environment on boot —
403+
* WebGPU, OffscreenCanvas, whether the backend spoof took there too.
404+
*
405+
* Also formerly a `console.log`. Holding it is strictly more useful than
406+
* printing it: `tools/obsidian-drive.mjs` can assert on the values
407+
* instead of a human reading them out of a console.
408+
*/
409+
lastWorkerEnv: () => this.lastWorkerEnv,
397410
ortConfig,
398411
benchPage: async (path: string, opts?: Record<string, unknown>) => {
399412
const f = this.app.vault.getAbstractFileByPath(path);
@@ -679,8 +692,7 @@ export default class ReflowPlugin extends Plugin {
679692
signal,
680693
...hooks,
681694
onReady: (env) => {
682-
// eslint-disable-next-line obsidianmd/rule-custom-message -- dev build only
683-
if (__REFLOW_DEV__) console.log("[reflow] worker ready:", env);
695+
this.lastWorkerEnv = env;
684696
},
685697
});
686698
}

0 commit comments

Comments
 (0)