From 5349ceaf009adc25325e53da6615aa070db3bd08 Mon Sep 17 00:00:00 2001 From: Ame Date: Thu, 11 Jun 2026 18:10:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(guardian):=20own=20the=20Vite=20dev=20port?= =?UTF-8?q?=20=E2=80=94=20probe,=20strictPort,=20truthful=20banner,=20orig?= =?UTF-8?q?in=20allowlist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the UI port lived outside Guardian's port authority: dev.ts printed a hardcoded "5173 (Vite picks +1 if taken)" banner before Vite even spawned, Vite auto-incremented on its own, and the workspace WS origin allowlist kept trusting literal 5173 — so a drifted Vite port meant a lying banner plus an allowlist pointing at whatever unrelated app sat on 5173. - planPorts claims a fourth `ui` port (default 5173, probe upward; env OPENALICE_UI_PORT / ports.json `ui` key follow the same explicit fail-loud rule as the backend trio) - dev.ts banner prints the resolved port; injects OPENALICE_UI_PORT into both Vite and Alice - vite.config.ts binds exactly the injected port (strictPort) under the orchestrator; standalone keeps classic 5173 + auto-increment - buildDefaultOrigins derives UI origins from the injected port instead of hardcoded 5173, so the WS origin gate tracks the real frontend Verified end-to-end with 5173+5174 occupied: banner/Vite agree on 5175; WS upgrade with Origin 5175 → 101, stale 5173 → 403, foreign origin → 403, same-origin through the Vite proxy → 101. Co-Authored-By: Claude Fable 5 --- scripts/guardian/dev.ts | 12 ++++++++++-- scripts/guardian/shared.spec.ts | 15 ++++++++++++--- scripts/guardian/shared.ts | 20 +++++++++++++------- src/webui/workspaces-ws.ts | 4 ++-- src/workspaces/config.spec.ts | 24 +++++++++++++++--------- src/workspaces/config.ts | 19 +++++++++++++------ ui/vite.config.ts | 26 ++++++++++++++++++++++++-- 7 files changed, 89 insertions(+), 31 deletions(-) diff --git a/scripts/guardian/dev.ts b/scripts/guardian/dev.ts index 2e4ebe305..f84f1f8a9 100644 --- a/scripts/guardian/dev.ts +++ b/scripts/guardian/dev.ts @@ -38,7 +38,7 @@ async function main(): Promise { console.log(`[guardian] UTA → http://127.0.0.1:${ports.utaPort}`) console.log(`[guardian] Alice → http://localhost:${ports.webPort}`) console.log(`[guardian] MCP → http://localhost:${ports.mcpPort}/mcp`) - console.log(`[guardian] UI → http://localhost:5173 (Vite picks +1 if taken)`) + console.log(`[guardian] UI → http://localhost:${ports.uiPort}`) console.log(`[guardian] flag → ${flagPath}`) console.log('') @@ -77,6 +77,9 @@ async function main(): Promise { ...baseEnv, OPENALICE_WEB_PORT: String(ports.webPort), OPENALICE_MCP_PORT: String(ports.mcpPort), + // Where the UI actually lives — consumed by the workspace WS-origin + // allowlist (src/workspaces/config.ts buildDefaultOrigins). + OPENALICE_UI_PORT: String(ports.uiPort), OPENALICE_UTA_URL: utaUrl, }, prefixLogs: true, @@ -87,7 +90,12 @@ async function main(): Promise { name: 'vite', command: 'pnpm', args: ['--filter', 'open-alice-ui', 'dev'], - env: { ...baseEnv, OPENALICE_BACKEND_PORT: String(ports.webPort) }, + env: { + ...baseEnv, + OPENALICE_BACKEND_PORT: String(ports.webPort), + // Guardian is the port authority: Vite binds exactly this (strictPort). + OPENALICE_UI_PORT: String(ports.uiPort), + }, prefixLogs: true, }) diff --git a/scripts/guardian/shared.spec.ts b/scripts/guardian/shared.spec.ts index 877f80952..9b80ac6c7 100644 --- a/scripts/guardian/shared.spec.ts +++ b/scripts/guardian/shared.spec.ts @@ -30,9 +30,9 @@ describe('readPortsFile', () => { expect(await readPortsFile(home)).toEqual({ web: 12345 }) }) - it('reads all three ports', async () => { - await writePortsFile('{ "web": 18331, "mcp": 18332, "uta": 18333 }') - expect(await readPortsFile(home)).toEqual({ web: 18331, mcp: 18332, uta: 18333 }) + it('reads all four ports', async () => { + await writePortsFile('{ "web": 18331, "mcp": 18332, "uta": 18333, "ui": 15173 }') + expect(await readPortsFile(home)).toEqual({ web: 18331, mcp: 18332, uta: 18333, ui: 15173 }) }) it('fails loud on broken JSON instead of silently defaulting', async () => { @@ -59,6 +59,7 @@ describe('resolvePortConfig', () => { web: { value: 47331, source: 'default' }, mcp: { value: 47332, source: 'default' }, uta: { value: 47333, source: 'default' }, + ui: { value: 5173, source: 'default' }, }) }) @@ -70,6 +71,14 @@ describe('resolvePortConfig', () => { expect(cfg.web).toEqual({ value: 15000, source: 'env' }) expect(cfg.mcp).toEqual({ value: 12346, source: 'file' }) expect(cfg.uta).toEqual({ value: 47333, source: 'default' }) + expect(cfg.ui).toEqual({ value: 5173, source: 'default' }) + }) + + it('ui port follows the same precedence as the backend trio', () => { + expect(resolvePortConfig({ OPENALICE_UI_PORT: '15173' }, { ui: 16173 }).ui) + .toEqual({ value: 15173, source: 'env' }) + expect(resolvePortConfig({}, { ui: 16173 }).ui) + .toEqual({ value: 16173, source: 'file' }) }) it('empty-string env var is treated as unset', () => { diff --git a/scripts/guardian/shared.ts b/scripts/guardian/shared.ts index 163f12cb5..5afaf4e7e 100644 --- a/scripts/guardian/shared.ts +++ b/scripts/guardian/shared.ts @@ -28,6 +28,8 @@ export interface GuardianPorts { webPort: number mcpPort: number utaPort: number + /** Vite dev-server port — resolved by Guardian (dev only; prod has no Vite). */ + uiPort: number } // ── Port configuration (L1 → L2) ──────────────────────────── @@ -36,7 +38,7 @@ export interface GuardianPorts { // them into the children via env (see memory:port-architecture-3-layers). // User-facing configuration lives in L1 — `data/config/ports.json`: // -// { "web": 47331, "mcp": 47332, "uta": 47333 } (all keys optional) +// { "web": 47331, "mcp": 47332, "uta": 47333, "ui": 5173 } (all keys optional) // // Deliberately a data/config file and NOT a dotenv file: the data dir is the // one location every topology agrees on (dev repo, docker volume, Electron @@ -48,7 +50,7 @@ export interface GuardianPorts { // drifting off a value the user pinned would be worse than aborting. Only // unconfigured ports keep the probe-upward-from-default behavior. -const PORT_DEFAULTS = { web: 47331, mcp: 47332, uta: 47333 } as const +const PORT_DEFAULTS = { web: 47331, mcp: 47332, uta: 47333, ui: 5173 } as const export type PortName = keyof typeof PORT_DEFAULTS @@ -64,6 +66,7 @@ const ENV_KEYS: Record = { web: 'OPENALICE_WEB_PORT', mcp: 'OPENALICE_MCP_PORT', uta: 'OPENALICE_UTA_PORT', + ui: 'OPENALICE_UI_PORT', } function parsePort(raw: unknown, origin: string): number { @@ -94,7 +97,7 @@ export async function readPortsFile(userDataHome: string): Promise> = {} for (const name of Object.keys(PORT_DEFAULTS) as PortName[]) { @@ -118,14 +121,16 @@ export function resolvePortConfig( if (fromFile !== undefined) return { value: fromFile, source: 'file' } return { value: PORT_DEFAULTS[name], source: 'default' } } - return { web: pick('web'), mcp: pick('mcp'), uta: pick('uta') } + return { web: pick('web'), mcp: pick('mcp'), uta: pick('uta'), ui: pick('ui') } } /** * Turn a resolved PortConfig into bindable ports. Explicit (env/file) ports * assert-free and fail loud when taken; default ports probe upward (web from - * 47331, mcp from web+1, uta from max(47333, mcp+1)) — the historical - * collision-dodging behavior. + * 47331, mcp from web+1, uta from max(47333, mcp+1), ui from 5173) — the + * historical collision-dodging behavior. The ui port is resolved here too + * (not left to Vite's own auto-increment) so Guardian can print the real + * URL and inject the value into Alice for the WS-origin allowlist. */ export async function planPorts(cfg: PortConfig): Promise { const claim = async (name: PortName, choice: PortChoice, probeStart: number): Promise => { @@ -141,7 +146,8 @@ export async function planPorts(cfg: PortConfig): Promise { const webPort = await claim('web', cfg.web, PORT_DEFAULTS.web) const mcpPort = await claim('mcp', cfg.mcp, webPort + 1) const utaPort = await claim('uta', cfg.uta, Math.max(PORT_DEFAULTS.uta, mcpPort + 1)) - return { webPort, mcpPort, utaPort } + const uiPort = await claim('ui', cfg.ui, PORT_DEFAULTS.ui) + return { webPort, mcpPort, utaPort, uiPort } } export interface SpawnSpec { diff --git a/src/webui/workspaces-ws.ts b/src/webui/workspaces-ws.ts index b8613eea7..fc9e3788b 100644 --- a/src/webui/workspaces-ws.ts +++ b/src/webui/workspaces-ws.ts @@ -169,8 +169,8 @@ function isOriginAllowed(req: IncomingMessage, svc: WorkspaceService): boolean { * IP, a domain) works without configuration. A browser's Origin is not * forgeable from a foreign page, so this admits exactly the pages we * served ourselves. The static allowlist covers cross-origin topologies - * (Vite dev on 5173, future cloud demo) and stays env-extensible via - * WEB_TERMINAL_ALLOWED_ORIGINS. + * (the Vite dev port — Guardian-resolved, 5173 by default — and the future + * cloud demo) and stays env-extensible via WEB_TERMINAL_ALLOWED_ORIGINS. */ export function isWsOriginAllowed( origin: string | undefined, diff --git a/src/workspaces/config.spec.ts b/src/workspaces/config.spec.ts index 800c8a823..e610566c6 100644 --- a/src/workspaces/config.spec.ts +++ b/src/workspaces/config.spec.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest' import { buildDefaultOrigins, loadConfig } from './config.js' describe('buildDefaultOrigins', () => { - it('derives backend origin entries from webPort', () => { + it('derives backend origin entries from webPort, UI entries default to 5173', () => { expect(buildDefaultOrigins(4444)).toEqual([ 'http://localhost:5173', 'http://127.0.0.1:5173', @@ -11,14 +11,14 @@ describe('buildDefaultOrigins', () => { ]) }) - it('keeps the contributor-dev 5173 entries even when webPort changes', () => { - // 5173 is the structural contributor-dev convention (Vite default), - // not derived from webPort. It stays put no matter what backend port - // is used. - const a = buildDefaultOrigins(3002) - const b = buildDefaultOrigins(47331) - expect(a).toContain('http://localhost:5173') - expect(b).toContain('http://localhost:5173') + it('tracks the real Vite port when Guardian probed off 5173', () => { + // When 5173 was taken, Guardian resolves e.g. 5174 and injects it; the + // allowlist must follow the actual frontend, not the stale convention — + // a leftover 5173 entry would admit whatever unrelated app sits there. + const origins = buildDefaultOrigins(47331, 5174) + expect(origins).toContain('http://localhost:5174') + expect(origins).toContain('http://127.0.0.1:5174') + expect(origins).not.toContain('http://localhost:5173') }) }) @@ -30,6 +30,12 @@ describe('loadConfig (workspaces)', () => { expect(cfg.allowAnyOrigin).toBe(false) }) + it('derives the UI origin from OPENALICE_UI_PORT when Guardian injected it', () => { + const cfg = loadConfig({ webPort: 47331, env: { OPENALICE_UI_PORT: '5174' } }) + expect(cfg.allowedOrigins.has('http://localhost:5174')).toBe(true) + expect(cfg.allowedOrigins.has('http://localhost:5173')).toBe(false) + }) + it('respects WEB_TERMINAL_ALLOWED_ORIGINS env override', () => { const cfg = loadConfig({ webPort: 4444, diff --git a/src/workspaces/config.ts b/src/workspaces/config.ts index 91fd16500..0f1fc739a 100644 --- a/src/workspaces/config.ts +++ b/src/workspaces/config.ts @@ -47,17 +47,21 @@ const DEFAULT_BOOTSTRAP_TIMEOUT_MS = 60_000; * backend serves the UI bundle at the same origin, browser hits it * directly. Same-origin in practice but Origin header is still set * and CORS check fires. - * - `localhost:5173` / `127.0.0.1:5173` — contributor-dev: Vite dev - * server proxies API/WS to the backend; browser's origin is 5173. + * - `localhost:` / `127.0.0.1:` — contributor-dev: Vite + * dev server proxies API/WS to the backend; browser's origin is the + * Vite port. Guardian resolves it (probe from 5173) and injects + * `OPENALICE_UI_PORT` so the allowlist tracks the real frontend even + * when 5173 was taken; standalone Vite (no orchestrator) keeps the + * 5173 default. * * The cloud-demo topology (future https://app.openalice.io) is intentionally * NOT in the default list — that's an opt-in addition driven by config when * the cloud demo ships. */ -export function buildDefaultOrigins(webPort: number): string[] { +export function buildDefaultOrigins(webPort: number, uiPort = 5173): string[] { return [ - 'http://localhost:5173', - 'http://127.0.0.1:5173', + `http://localhost:${uiPort}`, + `http://127.0.0.1:${uiPort}`, `http://localhost:${webPort}`, `http://127.0.0.1:${webPort}`, ]; @@ -75,7 +79,10 @@ export function loadConfig(opts: LoadConfigOptions): ServerConfig { const command = parseCommand(env['WEB_TERMINAL_COMMAND']); - const originsRaw = (env['WEB_TERMINAL_ALLOWED_ORIGINS'] ?? buildDefaultOrigins(opts.webPort).join(',')) + // Guardian-injected Vite dev-server port; 5173 when running without the + // orchestrator (standalone Vite, prod — where no UI origin drift exists). + const uiPort = parseIntEnv(env['OPENALICE_UI_PORT'], 5173, 1, 65535); + const originsRaw = (env['WEB_TERMINAL_ALLOWED_ORIGINS'] ?? buildDefaultOrigins(opts.webPort, uiPort).join(',')) .split(',') .map((s) => s.trim()) .filter(Boolean); diff --git a/ui/vite.config.ts b/ui/vite.config.ts index ecc7d2840..541de06cb 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -40,13 +40,35 @@ function readBackendPort(): number { const backendPort = readBackendPort() +/** + * Resolve the dev-server port. Two modes: + * + * 1. `OPENALICE_UI_PORT` env — set by `scripts/guardian/dev.ts`, which + * already probed the port for availability. Bind exactly it + * (strictPort): if Vite silently drifted off the value Guardian + * printed and injected into the backend's WS-origin allowlist, the + * banner URL would lie and the workspace terminal would break. + * 2. standalone (`pnpm --filter open-alice-ui dev`, no orchestrator) — + * Vite's classic 5173 + auto-increment behavior, unchanged. + */ +function readUiPort(): { port: number; strictPort: boolean } { + const envPort = Number.parseInt(process.env['OPENALICE_UI_PORT'] ?? '', 10) + if (Number.isFinite(envPort) && envPort > 0 && envPort <= 65535) { + return { port: envPort, strictPort: true } + } + return { port: 5173, strictPort: false } +} + +const { port: uiPort, strictPort } = readUiPort() + export default defineConfig({ plugins: [react(), tailwindcss()], - // Dev server on port 5173 with API proxy to the backend. + // Dev server with API proxy to the backend. // Backend port is read from `data/config/connectors.json` (web.port) so // changing the backend port in one place propagates to Vite automatically. server: { - port: 5173, + port: uiPort, + strictPort, proxy: { '/api': { target: `http://localhost:${backendPort}`,