fix(electron): launch peer-stamping server-ws.mjs entrypoint to avoid 403 LOCAL_ONLY (#3386) (#3665)

Closes #3386
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-11 12:55:16 -03:00
committed by GitHub
parent 47155fa3a0
commit 7e7fdbd30d
5 changed files with 98 additions and 1 deletions

View File

@@ -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.

View File

@@ -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 };

View File

@@ -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 });

View File

@@ -54,6 +54,7 @@
"loginManager.js",
"processTree.js",
"sqlite-inspection.js",
"lib/resolveServerEntry.js",
"package.json",
"node_modules/**/*"
],

View File

@@ -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");
});
});