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:
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.
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 thing —
electron 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.
- 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.
- When that probe fails: convert
preload.cjs → preload.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.
- Record the resolution on ADR-0034's constraint set so the next author does not re-derive it.
Acceptance Criteria
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)
Context
Operator direction (2026-07-26): the harness runs Neo dev mode with real ESM and no bundler, and
harness/preload.cjsis 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) setssandbox: trueon every harness window, and:377does the same for the credential-prompt window. Electron cannot load an ESM preload in a sandboxed renderer.Verified two ways rather than asserted:
Empirically, on this host. Renamed the preload to
.mjs, converted its singlerequire('electron')to animport, repointedmain.mjs+electron-builder.yml, and rannpm run smokeon Electron 43.1.0:The same experiment with
sandbox: falsesucceeded (passed: true,sharedHeapEvidence: true) — so the blocker is the sandbox, not the extension, and not our code.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:
electronitself, 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..mjscontainingrequire(). Electron would load it, because it treats sandboxed preloads as CJS regardless of extension. Rejected: Node treats.mjsas ESM unconditionally, so this breakstest/playwright/unit/harness/preload.spec.mjs(which reads the source throughvm) 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
apps/agentos/index.html:11loadssrc/MicroLoader.mjsas a nativetype="module"script andneo-config.jsonsets"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 doingcontextBridge.exposeInMainWorld('neoShell', …)plus boot/first-paint diagnostics. The ESM-purity concern is already satisfied everywhere it applies..cjsis therefore the honest marker: this file is CommonJS by force, and the extension says so.supportFetchAPI: trueis already set on the privilegedapp://scheme (main.mjs:107-109) andprotocol.handle('app', …)is already wired, so the fleet bridge could move fromcontextBridge+ IPC tofetch('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.
sandbox: truefails to load. It documents the constraint executably and goes red the moment Electron adds support — no polling, no doc-scraping, no calendar.preload.cjs→preload.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'svm.runInNewContextloader, which cannot execute ESM source as-is.Acceptance Criteria
sandbox: truefails to load on the pinned Electron version, with the failure mode (Cannot use import statement outside a module) named in the assertion..cjs" gets the answer without archaeology.cd harness && npm run smokeandnpm run smoke:brainstill pass; no change tosandbox, to any render behaviour, or to the preload's contract.Out of Scope
sandbox: false— rejected above; an ADR-0034 amendment if ever revisited.fetchoverapp://to delete the preload entirely — the genuinely interesting option, and its own decision.productWitnessPassed/ adapter-state work in #16033 (separate leaf, same files).Avoided Traps
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)