b13822a0ea50

fix: Restore Codex engine browser load (v3.6.43)

- Replace browser-facing Reed-Solomon package wrapper with local WASM wrapper to remove fs/require from Codex chunks - Keep Node WASM byte loading test-only and guard browser bundle hygiene - Make peach_last_modified_at monotonic to clear same-millisecond vault mutation race - Verified typecheck, full tests, build, and clean Codex bundle scan

time
2026-05-19 20:22 UTC · 5 months ago
machine
koh2
parent
3991c2a5cd74
10 files manifest.firefox.json+1 −1manifest.json+1 −1package-lock.json+2 −2package.json+1 −1src/background/vault.ts+9 −2src/popup/codex/browser-bundle.test.ts+20 −0src/popup/codex/erasure.ts+16 −11src/popup/codex/node-wasm.ts+35 −0src/popup/codex/reed-solomon.ts+153 −0src/popup/codex/rs-loader.ts+10 −9
manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22   "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33   "manifest_version": 3,44   "name": "Peach",5-  "version": "3.6.41",5+  "version": "3.6.43",66   "description": "Zero-knowledge password manager",77   "browser_specific_settings": {88     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "3.6.41",4+  "version": "3.6.43",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package-lock.json+2 −2
@@ -1,12 +1,12 @@11 {22   "name": "peach",3-  "version": "3.6.41",3+  "version": "3.6.43",44   "lockfileVersion": 3,55   "requires": true,66   "packages": {77     "": {88       "name": "peach",9-      "version": "3.6.41",9+      "version": "3.6.43",1010       "dependencies": {1111         "@bokuweb/zstd-wasm": "^0.0.27",1212         "@stablelib/blake2s": "^2.0.1",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "3.6.41",3+  "version": "3.6.43",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/background/vault.ts+9 −2
@@ -521,8 +521,16 @@521521 }522522 523523 async function stampLocalVaultMutation(): Promise<void> {524+  const raw = await chrome.storage.local.get("peach_last_modified_at");525+  const previous = Number(526+    (raw as Record<string, unknown>).peach_last_modified_at,527+  );528+  const next = Math.max(529+    Date.now(),530+    Number.isFinite(previous) ? previous + 1 : 0,531+  );524532   await chrome.storage.local.set({525-    peach_last_modified_at: String(Date.now()),533+    peach_last_modified_at: String(next),526534   });527535 }528536 @@ -2282,4 +2290,3 @@22822290 22832291   return true;22842292 }2285-

view file

src/popup/codex/browser-bundle.test.ts+20 −0
@@ -0,0 +1,20 @@1+import { readFileSync } from "node:fs";2+import { describe, expect, it } from "vitest";3+4+describe("Codex browser bundle hygiene", () => {5+  it("keeps direct Node built-in imports out of browser-loaded Codex modules", () => {6+    const browserLoadedFiles = [7+      "src/popup/codex/rs-loader.ts",8+      "src/popup/codex/erasure.ts",9+    ];10+11+    for (const file of browserLoadedFiles) {12+      const source = readFileSync(file, "utf8");13+      expect(source, file).not.toContain(14+        "@subspace/reed-solomon-erasure.wasm",15+      );16+      expect(source, file).not.toMatch(/import\s*\(\s*["']node:/);17+      expect(source, file).not.toMatch(/from\s+["']node:/);18+    }19+  });20+});

view file

src/popup/codex/erasure.ts+16 −11
@@ -13,13 +13,13 @@1313  * not re-entrant on a shared buffer.1414  */1515 16-import { ReedSolomonErasure } from "@subspace/reed-solomon-erasure.wasm";1716 import {1817   CHUNK_SIZE,1918   CodexError,2019   MAX_DATA_SHARDS_PER_STRIPE,2120   computeParityShards,2221 } from "../../shared/codex-types";22+import { ReedSolomonErasure } from "./reed-solomon";2323 import { getReedSolomon } from "./rs-loader";2424 2525 /**@@ -72,19 +72,24 @@7272     }7373     return new Uint8Array(await response.arrayBuffer());7474   }75-  const fs = await import("node:fs");76-  const path = await import("node:path");77-  const wasmPath = path.resolve(78-    "node_modules/@subspace/reed-solomon-erasure.wasm/dist/reed_solomon_erasure_bg.wasm",79-  );80-  return new Uint8Array(fs.readFileSync(wasmPath));75+  return readTestWasmBytes();8176 }8277 78+async function readTestWasmBytes(): Promise<Uint8Array<ArrayBuffer>> {79+  if (import.meta.env.MODE !== "test") {80+    throw new CodexError(81+      "ERASURE_CODING_VERIFICATION_FAILED",82+      "Reed-Solomon WASM can only be loaded through chrome.runtime.getURL outside tests",83+    );84+  }85+  const { readNodeReedSolomonWasmBytes } = await import("./node-wasm");86+  return readNodeReedSolomonWasmBytes();87+}88+8389 /**84- * @subspace/reed-solomon-erasure.wasm exposes the RESULT_* constants but not a85- * `getResultMessage(code)` helper (the @digitaldefiance fork added it). We map86- * codes to human-readable strings here so the surrounding CodexError messages87- * stay specific instead of leaking a bare number.90+ * We map Reed-Solomon result codes to human-readable strings here so the91+ * surrounding CodexError messages stay specific instead of leaking a bare92+ * number.8893  */8994 function resultMessage(code: number): string {9095   switch (code) {

view file

src/popup/codex/node-wasm.ts+35 −0
@@ -0,0 +1,35 @@1+/**2+ * Node-only WASM byte loader for Vitest.3+ *4+ * Browser extension builds must not see direct Node built-in imports here;5+ * Vite/Rolldown rewrites those to browser external shims that fail in6+ * extension pages. The split specifier plus @vite-ignore keeps this fallback7+ * out of the browser module graph while still letting Node tests read from8+ * node_modules.9+ */10+11+const WASM_NODE_MODULE_PATH =12+  "node_modules/@subspace/reed-solomon-erasure.wasm/dist/reed_solomon_erasure_bg.wasm";13+14+type NodeFs = {15+  readFileSync(path: string): Uint8Array;16+};17+18+type NodePath = {19+  resolve(...parts: string[]): string;20+};21+22+async function importNodeModule<T>(name: string): Promise<T> {23+  return import(/* @vite-ignore */ (`node:${name}`)) as Promise<T>;24+}25+26+export async function readNodeReedSolomonWasmBytes(): Promise<Uint8Array<ArrayBuffer>> {27+  const [fs, path] = await Promise.all([28+    importNodeModule<NodeFs>("fs"),29+    importNodeModule<NodePath>("path"),30+  ]);31+  const fileBytes = fs.readFileSync(path.resolve(WASM_NODE_MODULE_PATH));32+  const bytes: Uint8Array<ArrayBuffer> = new Uint8Array(fileBytes.byteLength);33+  bytes.set(fileBytes);34+  return bytes;35+}

view file

src/popup/codex/reed-solomon.ts+153 −0
@@ -0,0 +1,153 @@1+/**2+ * Browser-safe wrapper around the Reed-Solomon WASM exports.3+ *4+ * The published JS wrapper for @subspace/reed-solomon-erasure.wasm imports5+ * Node's `fs` module at top level, which breaks extension pages once bundled.6+ * This file talks directly to the WASM exports we use and contains no Node7+ * built-ins, so it is safe for popup chunks.8+ */9+10+interface ReedSolomonWasmExports extends WebAssembly.Exports {11+  memory: WebAssembly.Memory;12+  __wbindgen_malloc(size: number): number;13+  __wbindgen_free(pointer: number, size: number): void;14+  encode(15+    shardsPointer: number,16+    shardsLength: number,17+    dataShards: number,18+    parityShards: number,19+  ): number;20+  reconstruct(21+    shardsPointer: number,22+    shardsLength: number,23+    dataShards: number,24+    parityShards: number,25+    shardsAvailablePointer: number,26+    shardsAvailableLength: number,27+  ): number;28+}29+30+function isReedSolomonExports(31+  exports: WebAssembly.Exports,32+): exports is ReedSolomonWasmExports {33+  return (34+    exports.memory instanceof WebAssembly.Memory &&35+    typeof exports.__wbindgen_malloc === "function" &&36+    typeof exports.__wbindgen_free === "function" &&37+    typeof exports.encode === "function" &&38+    typeof exports.reconstruct === "function"39+  );40+}41+42+export class ReedSolomonErasure {43+  static readonly RESULT_OK = 0;44+  static readonly RESULT_ERROR_TOO_FEW_SHARDS = 1;45+  static readonly RESULT_ERROR_TOO_MANY_SHARDS = 2;46+  static readonly RESULT_ERROR_TOO_FEW_DATA_SHARDS = 3;47+  static readonly RESULT_ERROR_TOO_MANY_DATA_SHARDS = 4;48+  static readonly RESULT_ERROR_TOO_FEW_PARITY_SHARDS = 5;49+  static readonly RESULT_ERROR_TOO_MANY_PARITY_SHARDS = 6;50+  static readonly RESULT_ERROR_TOO_FEW_BUFFER_SHARDS = 7;51+  static readonly RESULT_ERROR_TOO_MANY_BUFFER_SHARDS = 8;52+  static readonly RESULT_ERROR_INCORRECT_SHARD_SIZE = 9;53+  static readonly RESULT_ERROR_TOO_FEW_SHARDS_PRESENT = 10;54+  static readonly RESULT_ERROR_EMPTY_SHARD = 11;55+  static readonly RESULT_ERROR_INVALID_SHARD_FLAGS = 12;56+  static readonly RESULT_ERROR_INVALID_INDEX = 13;57+58+  private memoryCache: Uint8Array | null = null;59+60+  private constructor(private readonly exports: ReedSolomonWasmExports) {}61+62+  static fromBytes(bytes: BufferSource): ReedSolomonErasure {63+    const module = new WebAssembly.Module(bytes);64+    const instance = new WebAssembly.Instance(module);65+    if (!isReedSolomonExports(instance.exports)) {66+      throw new Error("Reed-Solomon WASM exports are missing required functions");67+    }68+    return new ReedSolomonErasure(instance.exports);69+  }70+71+  encode(72+    shards: Uint8Array,73+    dataShards: number,74+    parityShards: number,75+  ): number {76+    const exports = this.exports;77+    const shardsLength = shards.length;78+    const shardsPointer = exports.__wbindgen_malloc(shardsLength);79+    this.getUint8Memory().set(shards, shardsPointer);80+81+    const shardSize = shardsLength / (dataShards + parityShards);82+    const result = exports.encode(83+      shardsPointer,84+      shardsLength,85+      dataShards,86+      parityShards,87+    );88+    if (result === ReedSolomonErasure.RESULT_OK) {89+      shards.set(90+        this.getUint8Memory().subarray(91+          shardsPointer + shardSize * dataShards,92+          shardsPointer + shardsLength,93+        ),94+        shardSize * dataShards,95+      );96+    }97+    exports.__wbindgen_free(shardsPointer, shardsLength);98+    return result;99+  }100+101+  reconstruct(102+    shards: Uint8Array,103+    dataShards: number,104+    parityShards: number,105+    shardsAvailable: boolean[],106+  ): number {107+    const exports = this.exports;108+    const shardsLength = shards.length;109+    const shardsPointer = exports.__wbindgen_malloc(shardsLength);110+    this.getUint8Memory().set(shards, shardsPointer);111+112+    const shardsAvailableBytes = new Uint8Array(shardsAvailable.length);113+    shardsAvailableBytes.set(shardsAvailable.map((value) => (value ? 1 : 0)));114+    const shardsAvailablePointer = exports.__wbindgen_malloc(115+      shardsAvailableBytes.length,116+    );117+    this.getUint8Memory().set(shardsAvailableBytes, shardsAvailablePointer);118+119+    const shardSize = shardsLength / (dataShards + parityShards);120+    const result = exports.reconstruct(121+      shardsPointer,122+      shardsLength,123+      dataShards,124+      parityShards,125+      shardsAvailablePointer,126+      shardsAvailableBytes.length,127+    );128+    if (result === ReedSolomonErasure.RESULT_OK) {129+      shards.set(130+        this.getUint8Memory().subarray(131+          shardsPointer,132+          shardsPointer + shardSize * dataShards,133+        ),134+      );135+    }136+    exports.__wbindgen_free(shardsPointer, shardsLength);137+    exports.__wbindgen_free(138+      shardsAvailablePointer,139+      shardsAvailableBytes.length,140+    );141+    return result;142+  }143+144+  private getUint8Memory(): Uint8Array {145+    if (146+      this.memoryCache === null ||147+      this.memoryCache.buffer !== this.exports.memory.buffer148+    ) {149+      this.memoryCache = new Uint8Array(this.exports.memory.buffer);150+    }151+    return this.memoryCache;152+  }153+}

view file

src/popup/codex/rs-loader.ts+10 −9
@@ -14,8 +14,8 @@1414  * library is safe to reuse across encode / reconstruct calls.1515  */1616 17-import { ReedSolomonErasure } from "@subspace/reed-solomon-erasure.wasm";1817 import { CodexError } from "../../shared/codex-types";18+import { ReedSolomonErasure } from "./reed-solomon";1919 2020 const WASM_RELATIVE_PATH = "codex/reed_solomon_erasure_bg.wasm";2121 @@ -38,14 +38,7 @@3838       return ReedSolomonErasure.fromBytes(bytes);3939     }4040 41-    // Node / vitest path. Dynamic import so the bundler does not try to42-    // resolve `node:fs` for the browser build.43-    const fs = await import("node:fs");44-    const path = await import("node:path");45-    const wasmPath = path.resolve(46-      "node_modules/@subspace/reed-solomon-erasure.wasm/dist/reed_solomon_erasure_bg.wasm",47-    );48-    const bytes = fs.readFileSync(wasmPath);41+    const bytes = await readTestWasmBytes();4942     return ReedSolomonErasure.fromBytes(bytes);5043   } catch (err) {5144     throw new CodexError(@@ -53,6 +46,14 @@5346       `Failed to load Reed-Solomon WASM module: ${err instanceof Error ? err.message : String(err)}`,5447     );5548   }49+}50+51+async function readTestWasmBytes(): Promise<Uint8Array<ArrayBuffer>> {52+  if (import.meta.env.MODE !== "test") {53+    throw new Error("Reed-Solomon WASM can only be loaded through chrome.runtime.getURL outside tests");54+  }55+  const { readNodeReedSolomonWasmBytes } = await import("./node-wasm");56+  return readNodeReedSolomonWasmBytes();5657 }5758 5859 function isExtensionPopup(): boolean {

view file