Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions scripts/guardian/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ async function main(): Promise<void> {
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('')

Expand Down Expand Up @@ -77,6 +77,9 @@ async function main(): Promise<void> {
...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,
Expand All @@ -87,7 +90,12 @@ async function main(): Promise<void> {
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,
})

Expand Down
15 changes: 12 additions & 3 deletions scripts/guardian/shared.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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' },
})
})

Expand All @@ -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', () => {
Expand Down
20 changes: 13 additions & 7 deletions scripts/guardian/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) ────────────────────────────
Expand All @@ -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
Expand All @@ -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

Expand All @@ -64,6 +66,7 @@ const ENV_KEYS: Record<PortName, string> = {
web: 'OPENALICE_WEB_PORT',
mcp: 'OPENALICE_MCP_PORT',
uta: 'OPENALICE_UTA_PORT',
ui: 'OPENALICE_UI_PORT',
}

function parsePort(raw: unknown, origin: string): number {
Expand Down Expand Up @@ -94,7 +97,7 @@ export async function readPortsFile(userDataHome: string): Promise<Partial<Recor
throw new Error(`[guardian] ${filePath} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`)
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error(`[guardian] ${filePath} must be a JSON object like {"web":47331,"mcp":47332,"uta":47333}`)
throw new Error(`[guardian] ${filePath} must be a JSON object like {"web":47331,"mcp":47332,"uta":47333,"ui":5173}`)
}
const out: Partial<Record<PortName, number>> = {}
for (const name of Object.keys(PORT_DEFAULTS) as PortName[]) {
Expand All @@ -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<GuardianPorts> {
const claim = async (name: PortName, choice: PortChoice, probeStart: number): Promise<number> => {
Expand All @@ -141,7 +146,8 @@ export async function planPorts(cfg: PortConfig): Promise<GuardianPorts> {
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 {
Expand Down
4 changes: 2 additions & 2 deletions src/webui/workspaces-ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
24 changes: 15 additions & 9 deletions src/workspaces/config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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')
})
})

Expand All @@ -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,
Expand Down
19 changes: 13 additions & 6 deletions src/workspaces/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<uiPort>` / `127.0.0.1:<uiPort>` — 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}`,
];
Expand All @@ -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);
Expand Down
26 changes: 24 additions & 2 deletions ui/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand Down
Loading