Files
mokosh/vite.config.ts
Mark f251297256 feat(04-02): Wave 1 — setimmediate polyfill replaced + generate-icons.cjs + deferred-items closure
Coherent 5-edit Wave 1 GREEN landing per Plan 04-02 Task 2; RED gate from
Task 1 (`tests/build/no-new-function-in-sw-chunk.test.ts` 1-hit assertion)
flips GREEN with 0 hits of `new Function` in any SW chunk
(`dist/assets/index.ts-*.js` glob).

## Threat T-04-02-01 mitigation (Elevation of Privilege — `new Function` literal)

Three layered mechanisms cooperate to drop the CSP-unsafe `new Function`
literal from the SW chunk while preserving JSZip's zip-assembly correctness
end-to-end (REVISION iter-2 WARNING 1 empirically pinned at UAT harness 33/33):

1. **Runtime polyfill prelude** at top-of-module of `src/background/index.ts`
   (BEFORE the first `import`): an inline `queueMicrotask`-based polyfill
   installs `globalThis.setImmediate` at SW boot. JSZip's pre-bundled
   `dist/jszip.min.js` IIFE guards its internal setimmediate polyfill behind
   `if(!s.setImmediate){...}`, so the upstream offending body never executes
   at runtime once our prelude has installed the safe fast-path.

2. **`vite-plugin-node-polyfills` `exclude: ['setimmediate']`** in vite.config.ts:
   prevents the plugin from injecting its node-stdlib-browser-aliased
   setimmediate polyfill into the chunk. NOTE: this alone is insufficient
   because JSZip's `dist/jszip.min.js` ships its OWN bundled-in setimmediate
   (via the package.json `"browser"` field that maps `./lib/index` →
   `./dist/jszip.min.js`); the plugin's `exclude` only filters the plugin's
   own contributions.

3. **`resolve.alias.setimmediate`** redirects bare-specifier `setimmediate`
   requires to `src/shared/setimmediate-stub.ts` (a 22-LOC TS module that
   installs the same `queueMicrotask`-based polyfill via side-effect import).
   This catches any future direct `import 'setimmediate'` consumer that
   bypasses the prelude.

4. **`stripSetimmediateNewFunction()` Rollup post-transform plugin** in
   vite.config.ts: surgically replaces the single occurrence of
   `(I=new Function(""+I))` with `(I=function(){})` in any output chunk
   that contains the JSZip-bundled setimmediate IIFE. The replacement is
   observably equivalent in our codepath (the parent `typeof I!="function"&&`
   guard means the body never runs when I is already a function — which is
   the only form JSZip ever uses — AND the runtime prelude makes the entire
   IIFE body unreachable regardless). Without this plugin, JSZip's
   pre-bundled distribution embeds the upstream setimmediate package's
   `setImmediate.js` verbatim inside its internal CJS module registry
   (slot 54), unreachable by Vite's resolve.alias or the polyfill plugin's
   exclude.

## Architecture decision log

**Option α (force JSZip unbundled `lib/index.js` via `resolve.alias.jszip`)
was attempted and reverted 2026-05-21** (between commits 630d40c and this).
Empirically broke UAT harness A30+ because the unbundled entry's transitive
readable-stream-browser browser-field mapping did not propagate correctly
through Vite's resolver — the async zip-write pipeline silently produced
an empty events.json. The post-transform plugin (Option β) is the
minimum-surface fix that preserves JSZip's runtime behavior verbatim while
satisfying the textual `new Function` count = 0 invariant.

## Verification

**Build / static gates:**
- `npm run build` exits 0; SW chunk `dist/assets/index.ts-DfBxWCT9.js`
  (378.92 kB) contains 0 occurrences of `new Function` (was 1 in pre-fix
  `index.ts-8LkXuqac.js`).
- `npx tsc --noEmit` exits 0.
- `grep -rn 'permissions.request' src/` returns 0 hits (Plan 04-02 ROADMAP
  SC #4 regression pin GREEN).
- `node generate-icons.cjs` exits 0; old `generate-icons.js` no longer
  exists (rename via `git mv` preserves history).
- `grep -c "exclude: \\['setimmediate'\\]" vite.config.ts` returns 1.
- `grep -c "queueMicrotask" src/background/index.ts` returns ≥1.
- `grep -c "Resolved in Phase 4 Plan 04-02" .planning/phases/01-stabilize-video-pipeline/deferred-items.md` returns ≥1.

**Test gates:**
- Focused: `npm test -- tests/build/no-new-function-in-sw-chunk.test.ts tests/build/dead-code-grep.test.ts --run` → 3/3 GREEN (Task 1's RED gate flipped GREEN).
- Full vitest: 183/183 GREEN on the clean run (180 baseline + 3 net new
  from Plan 04-02 Task 1's two new files). Pre-existing intermittent flakes
  per 04-01-SUMMARY Issues Encountered (blob-url-download / webm-remux /
  webm-playback ffmpeg dry-run) persist across SUMMARY runs and are owned
  by Plan 04-03.

**Pre-checkpoint bundle gates (per saved memory feedback-pre-checkpoint-bundle-gates.md):**
1. Tier-1 FORBIDDEN_HOOK_STRINGS: 13/13 tests GREEN; inventory unchanged at
   12 strings (Plan 04-02 added no harness hooks).
2. SW CSP-safety grep: `grep -rn 'new Function\\|eval(' dist/assets/` returns
   0 hits — polarity flipped from the pre-existing 1 documented exception
   (the setimmediate literal). T-04-02-01 mitigation pin lands.
3. Node-globals: `Buffer.copy / .isView / .length / .push / .shift / .slice
   / .write` in SW chunk (pre-existing JSZip internals; unchanged from
   04-01-SUMMARY).
4. DOM-globals: `document.createElement / .createTextNode / .documentElement
   / .F` + `window.Math / .console / .localStorage / .process` (pre-existing
   JSZip text encoder fallback paths; unchanged from 04-01-SUMMARY).
5. manifest.json: present, MV3, `name: __MSG_extName__` (chrome.i18n intact).

**Empirical UAT harness (REVISION iter-2 WARNING 1):**
- `HEADLESS=1 SKIP_PROD_REBUILD=0 npm run test:uat` → 33/33 assertions
  passed (verbatim `UAT harness: 33/33 assertions passed` in stdout).
  Confirms JSZip's full SAVE → zip pipeline (A24-A32 inclusive, exercising
  the in-memory MediaRecorder segments + base64 port wire + remux + zip
  assembly + chrome.downloads + events.json + meta.json + screenshot)
  operates correctly under the new bundle. The setimmediate polyfill
  replacement preserves zip-write behavior end-to-end at the empirical
  layer.

## Files

- **vite.config.ts**: imports `node:url` (fileURLToPath/URL) + `Plugin`
  type from vite; adds `nodePolyfills.exclude: ['setimmediate']`;
  adds `resolve.alias.setimmediate` → `src/shared/setimmediate-stub.ts`;
  adds `stripSetimmediateNewFunction()` Rollup post-transform plugin
  with full rationale comment.
- **src/background/index.ts**: 17-line top-of-module prelude inserted
  BEFORE the first `import { Logger } ...` line. Inline `queueMicrotask`-based
  setimmediate polyfill with typed widening cast (no `as any` per
  CLAUDE.md). Reversible by `git revert`.
- **src/shared/setimmediate-stub.ts** (NEW): 50-LOC TS module providing
  the same `queueMicrotask`-based polyfill via side-effect import.
  Documented as the resolve.alias target.
- **generate-icons.js → generate-icons.cjs**: `git mv` preserving history.
  Node 14+ treats `.cjs` as CJS regardless of `package.json` "type":
  "module" per https://nodejs.org/api/packages.html#determining-module-system.
  No code change; `require('fs')` + `require('path')` resolve cleanly.
  No other references to the old `.js` path elsewhere in the codebase
  outside the `.planning/` audit trail.
- **.planning/phases/01-stabilize-video-pipeline/deferred-items.md**:
  appended "Resolved in Phase 4 Plan 04-02" closure block citing this
  commit; details the 4-mechanism layered mitigation; documents the
  Option α attempt + reversion.

References:
  - .planning/phases/04-harden-clean-up-optional/04-RESEARCH.md §Q1
  - .planning/phases/04-harden-clean-up-optional/04-PATTERNS.md
    §vite.config.ts + §src/background/index.ts
  - Plan 04-02 threat model T-04-02-01 (Elevation of Privilege) +
    T-04-02-02 (DoS — JSZip fallback compatibility; verified by UAT 33/33)
  - node_modules/jszip/lib/utils.js:7 (upstream `require("setimmediate")`)
  - node_modules/setimmediate/setImmediate.js (upstream polyfill source)
  - Plan 01-12 Wave 7 deferred-items.md disclosure (Phase 5 → Phase 4 target)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:16:44 +02:00

171 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { fileURLToPath, URL } from 'node:url';
import { defineConfig, type Plugin } from 'vite';
import { crx } from '@crxjs/vite-plugin';
import { nodePolyfills } from 'vite-plugin-node-polyfills';
import manifest from './manifest.json';
/**
* Plan 04-02 CSP hardening (RESEARCH §Q1, Option β — refined #3).
*
* Surgically excises the `new Function("" + I)` CSP-unsafe literal from JSZip's
* pre-bundled `dist/jszip.min.js` after Vite has rolled it into the SW chunk.
*
* Context: JSZip's `package.json` `"browser"` field maps `./lib/index` →
* `./dist/jszip.min.js`, a pre-bundled CJS distribution that ships its own
* internal CommonJS module registry (including the `setimmediate` polyfill in
* slot 54). The pre-bundled form bypasses BOTH:
* - `vite-plugin-node-polyfills` `exclude: ['setimmediate']` (which only
* filters `node-stdlib-browser`-aliased polyfills);
* - Our `resolve.alias.setimmediate` (which only intercepts bare-specifier
* `require("setimmediate")` calls, not jszip's internal `r("setimmediate")`
* against its own slot table).
*
* Alternative considered (Option α — force JSZip's unbundled `lib/index.js`
* entry via `resolve.alias.jszip`): tested empirically 2026-05-21 against the
* UAT harness; broke A30+ assertions because JSZip's async-write pipeline
* (readable-stream-browser) was not transitively wired correctly through Vite's
* resolver. Reverted.
*
* The runtime IS already CSP-safe under the current bundle: the SW chunk's
* top-of-module `queueMicrotask`-based polyfill prelude lands BEFORE JSZip is
* imported, so the setimmediate IIFE's `if(!s.setImmediate){...}` guard skips
* the offending body at runtime. The `new Function` literal becomes statically
* present but runtime-unreachable. This plugin removes the static literal so
* the build-gate `tests/build/no-new-function-in-sw-chunk.test.ts` passes
* AND no future static-analysis auditor (or tightened MV3 CSP) flags it.
*
* Replacement: `(I=new Function(""+I))` → `(I=function(){})` — a no-op IIFE.
* Since the parent `typeof I!="function"&&` guard predicates the assignment on
* I being a non-function, and our prelude ensures the entire IIFE body never
* executes, the replacement is observably equivalent in our codepath.
*
* Why not the empty arrow `()=>{}`: keeps the same `function` keyword family
* as the surrounding code (mangler-friendly + bytecount-parity).
*
* Scope: applies ONLY to chunks with `setImmediate`-pattern content (a guarded
* check before the substring replacement avoids touching unrelated chunks).
*
* References:
* - .planning/phases/04-harden-clean-up-optional/04-RESEARCH.md §Q1
* - .planning/phases/04-harden-clean-up-optional/04-02-PLAN.md threat model
* T-04-02-01 (Elevation of Privilege — `new Function` literal)
* - node_modules/jszip/lib/utils.js:7 (the upstream `require("setimmediate")` site)
* - node_modules/setimmediate/setImmediate.js (the upstream polyfill source)
*/
function stripSetimmediateNewFunction(): Plugin {
// Exact substring from `setimmediate` package's setImmediate.js, present
// verbatim in JSZip's pre-bundled output. The replacement is byte-shorter
// (no `new Function("" + I)` constructor invocation); we accept the size
// delta in favor of pinning a unique substring for the find-and-replace.
// If this string changes in a future JSZip / setimmediate upgrade, the
// build-gate test catches the regression (count goes back to 1).
const NEEDLE = '(I=new Function(""+I))';
const REPLACEMENT = '(I=function(){})';
return {
name: 'plan-04-02-strip-setimmediate-new-function',
enforce: 'post',
generateBundle(_options, bundle) {
for (const fileName of Object.keys(bundle)) {
const chunk = bundle[fileName];
if (chunk.type !== 'chunk') continue;
if (!chunk.code.includes(NEEDLE)) continue;
chunk.code = chunk.code.split(NEEDLE).join(REPLACEMENT);
}
},
};
}
export default defineConfig({
plugins: [
crx({
manifest,
contentScripts: {
injectCss: false,
},
}),
nodePolyfills({
include: ['buffer'],
// Plan 04-02 CSP hardening (RESEARCH §Q1 + Plan 01-12 deferred-items flip):
// exclude the transitive `setimmediate` polyfill that vite-plugin-node-polyfills
// bundles alongside `buffer`. NOTE: this only filters node-stdlib-browser-aliased
// polyfills; JSZip's direct `require("setimmediate")` in its pre-bundled
// dist/jszip.min.js bypasses this exclusion. See stripSetimmediateNewFunction()
// plugin below for the post-bundle excision of the JSZip-side literal.
exclude: ['setimmediate'],
globals: {
Buffer: true,
global: false,
process: false,
},
protocolImports: false,
}),
// Plan 04-02 Wave 1 — post-bundle excision of the JSZip-internal
// setimmediate polyfill's `new Function("" + I)` literal. See the plugin
// definition + multi-paragraph rationale above (file top).
stripSetimmediateNewFunction(),
],
resolve: {
alias: {
ebml: 'ebml/lib/ebml.js',
// Plan 04-02 CSP hardening (RESEARCH §Q1, refined): redirect the
// upstream `setimmediate` npm package (which contains a CSP-unsafe
// `new Function("" + I)` fallback for the never-called string-form
// `setImmediate(string)`) to a local stub that installs a safe
// `queueMicrotask`-based polyfill. JSZip's `require("setimmediate")`
// at `node_modules/jszip/lib/utils.js:7` bypasses
// vite-plugin-node-polyfills' `exclude` option, so the alias is the
// canonical interception point. See src/shared/setimmediate-stub.ts
// for the polyfill source + threat-mitigation rationale (T-04-02-01).
// Pinned by tests/build/no-new-function-in-sw-chunk.test.ts (1 → 0).
setimmediate: fileURLToPath(new URL('./src/shared/setimmediate-stub.ts', import.meta.url)),
},
},
// `define` text-replaces token symbols at bundle time. We declare
// __MOKOSH_UAT__ as `false` in the production config so the gated
// hook-import branches in src/background/index.ts + src/offscreen/
// recorder.ts are static dead branches that Rollup tree-shakes. The
// test-only build (vite.test.config.ts) overrides this to `true`. We
// chose a dedicated token rather than gating on import.meta.env.MODE
// because vitest also uses MODE='test' by default — gating on MODE
// would activate the hooks during unit tests and overwrite the
// vi.fn() chrome.* mocks the existing 83-test baseline relies on.
// Reference: https://vite.dev/config/shared-options.html#define
define: {
__MOKOSH_UAT__: 'false',
// Plan 01-12 Wave 5 (RESEARCH §12 + D-09 spirit-satisfaction):
// Defensive token reserved for any future inline smoke-mode check.
// Currently the smoke harness lives entirely in `smoke.sh` outside
// Vite's input set — verified by `grep -rn 'smoke\|SMOKE\|data:text/html' src/`
// returning empty. Activated by setting the env var `VITE_DEV=1`
// before invoking `vite` / `vite build`; defaults to `false` so the
// gated branch (if any future plan adds one) is statically tree-
// shaken out of production. See scripts/README.md for the wider
// dev-script isolation invariant.
__VITE_DEV__: JSON.stringify(process.env.VITE_DEV === '1'),
},
build: {
// Plan 01-11: bump from default ES2020 to ES2022 so gated top-level
// await (`if (__MOKOSH_UAT__) { await import(...); }` in
// src/background/index.ts + src/offscreen/recorder.ts) compiles.
// The extension targets MV3 (Chrome ≥88); top-level await landed in
// Chrome 89 / Edge 89 / Firefox 89 / Safari 15 per MDN — comfortably
// inside the MV3 compatibility envelope.
// Reference: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Operators/await#browser_compatibility
// Reference: https://vite.dev/config/build-options.html#build-target
target: 'es2022',
rollupOptions: {
input: {
offscreen: 'src/offscreen/index.html',
// Plan 01-10 D-17-onboarding: welcome page bundled into both
// production and test builds. The page is reached via
// chrome.runtime.getURL('src/welcome/welcome.html') from the
// SW's openWelcomeIfFirstInstall helper; it requires the
// manifest's web_accessible_resources block to be navigable.
welcome: 'src/welcome/welcome.html',
},
},
},
});