From 7e7fdbd30db2d626df9e2aaad75125147b4f6b09 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:55:16 -0300 Subject: [PATCH] fix(electron): launch peer-stamping server-ws.mjs entrypoint to avoid 403 LOCAL_ONLY (#3386) (#3665) Closes #3386 --- CHANGELOG.md | 1 + electron/lib/resolveServerEntry.js | 26 ++++++++ electron/main.js | 7 +- electron/package.json | 1 + .../electron-resolve-server-entry.test.ts | 64 +++++++++++++++++++ 5 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 electron/lib/resolveServerEntry.js create mode 100644 tests/unit/electron-resolve-server-entry.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d0e22a679..626e0e8082 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ ### 🔧 Bug Fixes +- **Electron desktop**: launch the peer-stamping `server-ws.mjs` entrypoint so local-only routes (AgentBridge, MCP, services) no longer return 403 LOCAL_ONLY (#3386) - **Provider Topology**: stop flagging healthy providers as errored based on stale historical failures; use current request status (#3619) - **OpenCode Free**: fetch the live model catalog from the provider's `modelsUrl` for the no-auth model picker instead of serving a stale hardcoded list (#3611) - **Hermes Agent**: honour the `HERMES_HOME` env var when writing/reading the agent config instead of always using `~/.hermes` (#3628). Introduced `getHermesHome()` / `getHermesConfigPath()` helpers (read at call-time) and routed all four hardcoded callsites through them so OmniRoute's config lands in the same directory that the Hermes PowerShell installer configures on Windows. diff --git a/electron/lib/resolveServerEntry.js b/electron/lib/resolveServerEntry.js new file mode 100644 index 0000000000..836cf22acf --- /dev/null +++ b/electron/lib/resolveServerEntry.js @@ -0,0 +1,26 @@ +/** + * resolveServerEntry.js — pure helper for selecting the Next.js server entrypoint. + * + * The WS-aware wrapper `server-ws.mjs` installs the trusted peer-IP stamp + * (peer-stamp.mjs) that the authz middleware needs to allow loopback/LAN access + * to LOCAL_ONLY routes (AgentBridge, MCP, services, etc.). Without it every + * LOCAL_ONLY request returns 403. + * + * We prefer `server-ws.mjs` when it exists — mirroring run-standalone.mjs — and + * fall back to the bare `server.js` only when the wrapper is absent (e.g. an + * older build or a stripped bundle). + * + * Extracted as a pure helper so it can be unit-tested without importing the full + * Electron main process (which requires the Electron binary). + * + * @param {string} serverDir - directory that contains server.js / server-ws.mjs + * @param {Function} existsSyncFn - injectable fs.existsSync (for unit tests) + * @returns {string} filename ("server-ws.mjs" or "server.js") + */ +function resolveServerEntry(serverDir, existsSyncFn) { + const path = require("path"); + const wsEntry = path.join(serverDir, "server-ws.mjs"); + return existsSyncFn(wsEntry) ? "server-ws.mjs" : "server.js"; +} + +module.exports = { resolveServerEntry }; diff --git a/electron/main.js b/electron/main.js index a439157eb4..b9e9ff486f 100644 --- a/electron/main.js +++ b/electron/main.js @@ -35,6 +35,7 @@ const { autoUpdater } = require("electron-updater"); const { hasEncryptedCredentials } = require("./sqlite-inspection"); const { loginManager } = require("./loginManager"); const { killProcessTree } = require("./processTree"); +const { resolveServerEntry } = require("./lib/resolveServerEntry"); // ── Single Instance Lock ─────────────────────────────────── const gotTheLock = app.requestSingleInstanceLock(); @@ -525,7 +526,11 @@ function startNextServer() { return; } - const serverScript = path.join(NEXT_SERVER_PATH, "server.js"); + // Prefer server-ws.mjs (peer-stamp wrapper) when present; fall back to server.js. + // Without server-ws.mjs every LOCAL_ONLY route (AgentBridge, MCP, services, …) returns + // 403 because the authz middleware can't verify the trusted loopback peer-stamp. (#3386) + const serverEntryName = resolveServerEntry(NEXT_SERVER_PATH, fs.existsSync.bind(fs)); + const serverScript = path.join(NEXT_SERVER_PATH, serverEntryName); if (!fs.existsSync(serverScript)) { console.error("[Electron] Server script not found:", serverScript); sendToRenderer("server-status", { status: "error", port: serverPort }); diff --git a/electron/package.json b/electron/package.json index 17d5c0a5fa..888f249bd0 100644 --- a/electron/package.json +++ b/electron/package.json @@ -54,6 +54,7 @@ "loginManager.js", "processTree.js", "sqlite-inspection.js", + "lib/resolveServerEntry.js", "package.json", "node_modules/**/*" ], diff --git a/tests/unit/electron-resolve-server-entry.test.ts b/tests/unit/electron-resolve-server-entry.test.ts new file mode 100644 index 0000000000..eb90286400 --- /dev/null +++ b/tests/unit/electron-resolve-server-entry.test.ts @@ -0,0 +1,64 @@ +/** + * Tests for electron/lib/resolveServerEntry.js (#3386) + * + * The Electron main process must launch `server-ws.mjs` (the peer-stamp wrapper) + * instead of bare `server.js` so that LOCAL_ONLY routes (AgentBridge, MCP, services) + * pass the loopback authz check. This helper is unit-tested here with an injectable + * existsSync so no filesystem or Electron binary is needed. + * + * TDD sequence: + * BEFORE the fix: main.js hardcodes "server.js" — the helper did not exist yet. + * AFTER the fix: main.js delegates to resolveServerEntry(); tests verify both branches. + */ + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { join } from "node:path"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); + +const { resolveServerEntry } = require("../../electron/lib/resolveServerEntry"); + +describe("resolveServerEntry (#3386 — Electron 403 LOCAL_ONLY fix)", () => { + const FAKE_SERVER_DIR = "/fake/standalone"; + + it("returns 'server-ws.mjs' when it exists in the server directory", () => { + // existsSync returns true for the server-ws.mjs path, false for anything else + const existsSyncFn = (p: string) => p === join(FAKE_SERVER_DIR, "server-ws.mjs"); + const result = resolveServerEntry(FAKE_SERVER_DIR, existsSyncFn); + assert.equal(result, "server-ws.mjs", "should prefer server-ws.mjs when present"); + }); + + it("falls back to 'server.js' when server-ws.mjs is absent", () => { + // existsSync always returns false — simulates a build without the WS wrapper + const existsSyncFn = (_p: string) => false; + const result = resolveServerEntry(FAKE_SERVER_DIR, existsSyncFn); + assert.equal(result, "server.js", "should fall back to server.js when server-ws.mjs is absent"); + }); + + it("only checks for server-ws.mjs inside the given serverDir, not a parent dir", () => { + const checked: string[] = []; + const existsSyncFn = (p: string) => { + checked.push(p); + return false; + }; + resolveServerEntry(FAKE_SERVER_DIR, existsSyncFn); + assert.equal(checked.length, 1, "should only call existsSync once"); + assert.ok( + checked[0].startsWith(FAKE_SERVER_DIR), + `checked path "${checked[0]}" should be inside serverDir "${FAKE_SERVER_DIR}"` + ); + assert.ok( + checked[0].endsWith("server-ws.mjs"), + `checked path "${checked[0]}" should end with "server-ws.mjs"` + ); + }); + + it("returns a plain filename (no directory component) in both branches", () => { + const withWs = resolveServerEntry(FAKE_SERVER_DIR, () => true); + const withoutWs = resolveServerEntry(FAKE_SERVER_DIR, () => false); + assert.ok(!withWs.includes("/") && !withWs.includes("\\"), "server-ws.mjs result must be a bare filename"); + assert.ok(!withoutWs.includes("/") && !withoutWs.includes("\\"), "server.js result must be a bare filename"); + }); +});