LearnNewsExamplesServices
Frontmatter
id16036
titleharness/preload.cjs stays CommonJS until Electron supports ESM in sandboxed preloads — self-notifying probe as the revalidation trigger
stateClosed
labels
ai
assigneesneo-gpt
createdAtJul 27, 2026, 1:24 AM
updatedAtJul 28, 2026, 10:00 PM
githubUrlhttps://github.com/neomjs/neo/issues/16036
authorneo-opus-vega
commentsCount1
parentIssue13377
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtJul 28, 2026, 10:00 PM

harness/preload.cjs stays CommonJS until Electron supports ESM in sandboxed preloads — self-notifying probe as the revalidation trigger

neo-opus-vega
neo-opus-vega commented on Jul 27, 2026, 1:24 AM

Context

Operator direction (2026-07-26): the harness runs Neo dev mode with real ESM and no bundler, and harness/preload.cjs is the one file that breaks that pattern. Operator asked for it gone, then yielded on the evidence below, with the explicit ask: fix it as soon as Electron supports it. This ticket exists so that "as soon as" is a mechanism rather than a memory.

Deliberately NOT milestoned — this is not v13.2 release scope and cannot be scheduled, since it is gated on an upstream capability. Recorded under #13377 (ADR-0034 §5, Electron shell; steward @neo-opus-vega) per the D#15209 hygiene rule that an unmilestoned leaf must be explicitly deferred rather than silently untracked.

The Problem

harness/main.mjs:115-124 (getSecureWebPreferences) sets sandbox: true on every harness window, and :377 does the same for the credential-prompt window. Electron cannot load an ESM preload in a sandboxed renderer.

Verified two ways rather than asserted:

  1. Empirically, on this host. Renamed the preload to .mjs, converted its single require('electron') to an import, repointed main.mjs + electron-builder.yml, and ran npm run smoke on Electron 43.1.0:

       HARNESS_PAGE error Unable to load preload script: …/harness/preload.mjs
    HARNESS_RUNTIME_FAILURE preload-error: Cannot use import statement outside a module

    The same experiment with sandbox: false succeeded (passed: true, sharedHeapEvidence: true) — so the blocker is the sandbox, not the extension, and not our code.

  2. Documented upstream, current. Electron ESM docs: "Sandboxed preload scripts can't use ESM imports", and sandboxed preloads "run as plain JavaScript without an ESM context." Post-28 work improved only unsandboxed ESM preloads (dynamic imports in 37–39, Node globals in 42/43). A search for an upstream tracking issue found none — so there is nothing to subscribe to.

Why the obvious workarounds are rejected:

  • A bundler. The docs prescribe one only when a sandboxed preload imports external modules. Ours imports exactly one thingelectron itself, which Electron provides. Zero external imports, therefore nothing to bundle, and adding a build step to one 157-line file would violate the zero-build dev-mode decision (main.mjs:61-63, operator 2026-07-10) for cosmetics.
  • .mjs containing require(). Electron would load it, because it treats sandboxed preloads as CJS regardless of extension. Rejected: Node treats .mjs as ESM unconditionally, so this breaks test/playwright/unit/harness/preload.spec.mjs (which reads the source through vm) and any tooling that opens the file — in exchange for a filename that misreports its own contents.
  • sandbox: false. Works today, and trades OS-level process sandboxing on the shell that owns credential custody (ADR-0034 §2.3.4–.6, E5 #15537) for a file extension. Security posture change, ADR-governed, and not worth it.

The Architectural Reality

  • The preload is not in the app's module graph. apps/agentos/index.html:11 loads src/MicroLoader.mjs as a native type="module" script and neo-config.json sets "environment": "development" — the app, workers, and whole graph are real ESM with no build step. The preload is a 157-line shim in a separate isolated world doing contextBridge.exposeInMainWorld('neoShell', …) plus boot/first-paint diagnostics. The ESM-purity concern is already satisfied everywhere it applies.
  • .cjs is therefore the honest marker: this file is CommonJS by force, and the extension says so.
  • A genuinely clean alternative exists and is deliberately out of scope here: supportFetchAPI: true is already set on the privileged app:// scheme (main.mjs:107-109) and protocol.handle('app', …) is already wired, so the fleet bridge could move from contextBridge + IPC to fetch('app://…') handled in main — deleting the preload's reason to exist rather than changing its language. That is an ADR-0034 amendment (credential ingress is deliberately preload-owned), not a refactor, and belongs in its own decision if pursued.

The Fix

Land a self-notifying probe instead of a reminder, then act when it fires.

  1. Add a harness test that asserts the current runtime constraint: an ESM preload under sandbox: true fails to load. It documents the constraint executably and goes red the moment Electron adds support — no polling, no doc-scraping, no calendar.
  2. When that probe fails: convert preload.cjspreload.mjs (one line: require('electron')import {contextBridge, ipcRenderer} from 'electron'), repoint the three reference sites (main.mjs:120, harness/electron-builder.yml:18, preload.spec.mjs:5), and adapt the spec's vm.runInNewContext loader, which cannot execute ESM source as-is.
  3. Record the resolution on ADR-0034's constraint set so the next author does not re-derive it.

Acceptance Criteria

  • A probe asserts that an ESM preload under sandbox: true fails to load on the pinned Electron version, with the failure mode (Cannot use import statement outside a module) named in the assertion.
  • The probe is self-notifying: it turns red when Electron gains support, and its failure message states that the conversion in this ticket is now unblocked — so the red is actionable rather than mysterious.
  • The probe does not weaken or skip on unexpected outcomes; an inconclusive result fails rather than passing quietly.
  • The constraint is stated in the harness README next to the preload reference, so a reader who wonders "why .cjs" gets the answer without archaeology.
  • cd harness && npm run smoke and npm run smoke:brain still pass; no change to sandbox, to any render behaviour, or to the preload's contract.
  • This ticket carries the conversion steps concretely enough that whoever sees the probe go red can execute it without re-doing the analysis.

Out of Scope

  • Setting sandbox: false — rejected above; an ADR-0034 amendment if ever revisited.
  • Introducing a bundler for the preload — rejected above.
  • Migrating the fleet bridge to fetch over app:// to delete the preload entirely — the genuinely interesting option, and its own decision.
  • The productWitnessPassed / adapter-state work in #16033 (separate leaf, same files).

Avoided Traps

  • A calendar reminder or a "revisit in 6 months" note. Nothing checks it, so it never fires — the same defect as a retirement trigger keyed to an event nobody observes.
  • Scraping the Electron docs sentence as the trigger. Brittle to rewording, and it tests prose rather than behaviour. The probe tests the runtime.
  • Asserting via source-text (expect(source).not.toContain('import')). That pins our file, not Electron's capability, and would stay green forever.

Decision Record impact

none — ADR-0034 §2.2 already owns the shell's empirically-verified constraint set; this adds an executable witness for one row and changes no authority.

Related: #13377 (epic, ADR-0034 §5) · #16033 (same files, separate leaf) · Electron ESM docs · Process Sandboxing

Authored by Vega (@neo-opus-vega, Claude Opus 5, Claude Code)

tobiu unassigned from @neo-opus-vega on Jul 28, 2026, 11:22 AM
tobiu referenced in commit 2d35528 - "test(harness): probe sandboxed ESM preload support (#16036) (#16108) on Jul 28, 2026, 10:00 PM
tobiu closed this issue on Jul 28, 2026, 10:00 PM