diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 2794747..4999157 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -20,7 +20,13 @@ import { resolveRoot } from "@prismshadow/penguin-core"; import { liveServerLock } from "@prismshadow/penguin-server/lock"; import { startEmbeddedServer, stopEmbeddedServer } from "./server-process.js"; import type { EmbeddedServer } from "./server-process.js"; -import { desktopLoginUrl, isAppUrl, MAX_SERVER_RESTARTS, restartDelayMs } from "./util.js"; +import { + desktopLoginUrl, + isAppUrl, + isLocalSurfaceUrl, + MAX_SERVER_RESTARTS, + restartDelayMs, +} from "./util.js"; app.setName("PenguinHarness"); @@ -55,12 +61,44 @@ function createWindow(url: string): void { win.on("closed", () => { win = null; }); - // Everything off the app origin (external links, Workspace previews on the 127.0.0.1 - // counterpart host) opens in the system browser; the window never leaves the app. + // "Open in a new tab" (Workspace HTML previews) is an app-origin link that mints a + // token and 302s to the preview origin — it needs the session cookie, so it must open + // in a window of this app; handing it to the system browser would land on a 401. + // Denying it outright (as this did at first) made the entry silently do nothing. + // Genuinely external links still go to the system browser. win.webContents.setWindowOpenHandler(({ url: target }) => { - if (!isAppUrl(target, appOrigin)) void shell.openExternal(target); + if (isLocalSurfaceUrl(target, appOrigin)) { + return { + action: "allow", + overrideBrowserWindowOptions: { + width: 1100, + height: 800, + autoHideMenuBar: true, + // Same hardening as the main window: the preview is Agent-written, untrusted + // HTML and must never get Node. + webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true }, + }, + }; + } + void shell.openExternal(target); return { action: "deny" }; }); + // The child lands on the preview origin after the redirect, so its policy is "stay + // within this instance's loopback surface, everything else to the system browser" — + // the main window's stricter app-origin-only rule would bounce the preview itself out. + win.webContents.on("did-create-window", (child) => { + child.webContents.setWindowOpenHandler(({ url: target }) => { + if (isLocalSurfaceUrl(target, appOrigin)) return { action: "allow" }; + void shell.openExternal(target); + return { action: "deny" }; + }); + child.webContents.on("will-navigate", (event, target) => { + if (!isLocalSurfaceUrl(target, appOrigin)) { + event.preventDefault(); + void shell.openExternal(target); + } + }); + }); win.webContents.on("will-navigate", (event, target) => { if (!isAppUrl(target, appOrigin)) { event.preventDefault(); diff --git a/packages/desktop/src/util.ts b/packages/desktop/src/util.ts index af348e9..f620097 100644 --- a/packages/desktop/src/util.ts +++ b/packages/desktop/src/util.ts @@ -39,6 +39,27 @@ export function isAppUrl(url: string, origin: string | null): boolean { } } +/** + * Whether a URL belongs to this instance's local surface: the app origin itself or its + * loopback counterpart on the same port, which is where Workspace previews are served + * (see design § "Workspace 文件预览"). Preview windows navigate freely within it; anything + * else is external and belongs in the system browser. + */ +export function isLocalSurfaceUrl(url: string, origin: string | null): boolean { + if (origin === null) return false; + let target: URL; + let app: URL; + try { + target = new URL(url); + app = new URL(origin); + } catch { + return false; + } + if (target.protocol !== app.protocol || target.port !== app.port) return false; + const loopback = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]); + return loopback.has(target.hostname) && loopback.has(app.hostname); +} + /** Max automatic server restarts before giving up with an error dialog. */ export const MAX_SERVER_RESTARTS = 3; diff --git a/packages/desktop/test/util.test.ts b/packages/desktop/test/util.test.ts index 182675a..bac748d 100644 --- a/packages/desktop/test/util.test.ts +++ b/packages/desktop/test/util.test.ts @@ -3,6 +3,7 @@ import { appOriginFor, desktopLoginUrl, isAppUrl, + isLocalSurfaceUrl, parsePortFile, restartDelayMs, } from "../src/util.js"; @@ -42,6 +43,23 @@ describe("isAppUrl", () => { }); }); +describe("isLocalSurfaceUrl", () => { + const origin = "http://localhost:7364"; + it("accepts the app origin and its loopback counterpart on the same port", () => { + // The counterpart is where Workspace previews are served: a preview window must be + // able to reach it, which the stricter app-origin rule would deny. + expect(isLocalSurfaceUrl("http://localhost:7364/chat", origin)).toBe(true); + expect(isLocalSurfaceUrl("http://127.0.0.1:7364/preview/tok/x.html", origin)).toBe(true); + }); + it("rejects other ports, other hosts, other schemes, and junk", () => { + expect(isLocalSurfaceUrl("http://127.0.0.1:7365/preview/x", origin)).toBe(false); + expect(isLocalSurfaceUrl("http://example.com:7364/", origin)).toBe(false); + expect(isLocalSurfaceUrl("https://localhost:7364/", origin)).toBe(false); + expect(isLocalSurfaceUrl("not a url", origin)).toBe(false); + expect(isLocalSurfaceUrl("http://localhost:7364/", null)).toBe(false); + }); +}); + describe("restartDelayMs", () => { it("doubles from 1s and caps at 8s", () => { expect([0, 1, 2, 3, 4].map(restartDelayMs)).toEqual([1000, 2000, 4000, 8000, 8000]); diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts index ab55c2b..575f884 100644 --- a/packages/server/src/config.ts +++ b/packages/server/src/config.ts @@ -20,6 +20,12 @@ export interface ServerConfig { root: string; /** HTTP listen address and port (defaults to 127.0.0.1:7364, deliberately avoiding common ports like 3000/8080). */ host: string; + /** + * Listen port. `0` asks the OS for an ephemeral port (the desktop shell always does), + * in which case the value is only a request: index.ts writes the ACTUAL bound port back + * here once listening, because preview URLs are built from the server's own port rather + * than the browser's (dev serves the SPA on a different port; see resolvePreviewTarget). + */ port: number; /** SQLite database path; ":memory:" for test injection. */ dbPath: string; diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 4df3257..db877d6 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -96,6 +96,12 @@ const server = serve({ fetch: app.fetch, hostname: config.host, port: config.por console.log(`Data root: ${config.root}`); console.log(`SQLite: ${config.dbPath}`); if (config.desktopToken !== null) console.log("Desktop mode: enabled"); + // PORT=0 asked for an ephemeral port: record the real one so everything derived from + // the server's own port is correct — Workspace preview URLs above all, which are built + // from the bind port on purpose (see resolvePreviewTarget) and would otherwise point at + // port 0 and fail to load. deps.config is this same object, so both route call sites + // (me.ts, sessions.ts) observe the update. + config.port = info.port; // The root exists by now (openDatabase created it), and the pre-start check found no // live owner — record ourselves as this root's server. acquireServerLock(config.root, { diff --git a/packages/server/src/services/preview-token.ts b/packages/server/src/services/preview-token.ts index f0cd186..59597a0 100644 --- a/packages/server/src/services/preview-token.ts +++ b/packages/server/src/services/preview-token.ts @@ -189,6 +189,12 @@ export function resolvePreviewTarget( const counterpart = loopbackCounterpart(requestHost); if (!counterpart) return null; if (!loopbackHostRoles(serverBind.host)) return null; + // Port 0 means "the listener has not reported its actual port yet" (PORT=0 picks an + // ephemeral one; index.ts writes it back once bound). Emitting `:0` would produce a URL + // no browser will load — Chromium rejects it outright with ERR_UNSAFE_PORT — so degrade + // to "no isolated preview" instead, which the UI already handles by falling back to the + // sandboxed same-origin preview. + if (serverBind.port === 0) return null; let protocol: string; try { diff --git a/packages/server/test/workspace-preview.test.ts b/packages/server/test/workspace-preview.test.ts index b42abde..3739f5a 100644 --- a/packages/server/test/workspace-preview.test.ts +++ b/packages/server/test/workspace-preview.test.ts @@ -96,6 +96,15 @@ describe("preview origin derivation", () => { }); }); + it("gives up while the bind port is still 0, rather than emitting an unloadable :0 URL", () => { + // PORT=0 (the desktop shell) before index.ts writes the actual port back: a `:0` URL + // is rejected by Chromium as ERR_UNSAFE_PORT, so previewIsolated reports false and the + // UI falls back to the sandboxed same-origin preview. + expect( + resolvePreviewTarget("http://localhost:0/x", "localhost:0", null, { ...bind, port: 0 }), + ).toBeNull(); + }); + it("gives up when the loopback counterpart is not reachable from the bind address", () => { expect( resolvePreviewTarget("http://localhost:7364/x", "localhost:7364", null, {