From c7d1df445fa4e127b0498daad81ecee62a05b4bd Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 21 May 2026 00:17:16 -0700 Subject: [PATCH] Clean up bootstrap allowed origins --- .env.example | 4 +-- README.md | 3 ++- server/bootstrap.ts | 40 ++++++++++++++++------------- test/server/ws-edge-cases.test.ts | 2 +- test/unit/server/bootstrap.test.ts | 41 ++++++++++++++++++++---------- 5 files changed, 56 insertions(+), 34 deletions(-) diff --git a/.env.example b/.env.example index 5ebfdd802..24972483c 100644 --- a/.env.example +++ b/.env.example @@ -28,11 +28,11 @@ PORT=3001 # ALLOWED_ORIGINS is auto-managed by NetworkManager based on bind host and LAN IPs. # Do not edit manually — use EXTRA_ALLOWED_ORIGINS for custom additions. -# ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3001 +# ALLOWED_ORIGINS=http://localhost:3001,http://127.0.0.1:3001 # Extra origins to allow (for reverse proxies, custom domains, etc.). # These are preserved across NetworkManager reconfigurations. -# EXTRA_ALLOWED_ORIGINS=https://mysite.com,http://192.168.1.50:8080 +# EXTRA_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3002,https://mysite.com,http://192.168.1.50:8080 # Maximum concurrent WebSocket connections (default: 50) # MAX_CONNECTIONS=50 diff --git a/README.md b/README.md index c9431ea8f..971170370 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,8 @@ npm run serve # Production build and run |----------|----------|-------------| | `AUTH_TOKEN` | Auto | Authentication token (auto-generated on first run, min 16 chars) | | `PORT` | No | Server port (default: 3001) | -| `ALLOWED_ORIGINS` | No | Comma-separated allowed CORS origins (auto-detected from LAN) | +| `ALLOWED_ORIGINS` | No | Auto-managed CORS origins for the active server bind host and LAN IPs | +| `EXTRA_ALLOWED_ORIGINS` | No | Comma-separated custom CORS origins preserved across runtime origin rebuilds | | `CLAUDE_HOME` | No | Path to Claude config directory (default: `~/.claude`) | | `CODEX_HOME` | No | Path to Codex config directory (default: `~/.codex`) | | `WINDOWS_SHELL` | No | Windows shell: `wsl` (default), `cmd`, or `powershell` | diff --git a/server/bootstrap.ts b/server/bootstrap.ts index 650ad95bf..e7db9e2da 100644 --- a/server/bootstrap.ts +++ b/server/bootstrap.ts @@ -213,27 +213,31 @@ export function generateAuthToken(): string { return crypto.randomBytes(32).toString('hex') } +const BOOTSTRAP_PROD_PORT = 3001 +const BOOTSTRAP_DEV_PORTS = [5173, 3002] as const + +function buildOriginsForPorts(lanIps: string[], ports: readonly number[]): string { + const hosts = ['localhost', '127.0.0.1', ...lanIps] + return hosts + .flatMap(host => ports.map(port => `http://${host}:${port}`)) + .join(',') +} + /** - * Build ALLOWED_ORIGINS string from localhost + LAN IPs. - * Includes dev ports (5173, 3002) and production port (3001). + * Build the bootstrap ALLOWED_ORIGINS value. + * + * NetworkManager owns this variable at runtime, so bootstrap only seeds the + * production server port. Dev-server additions belong in EXTRA_ALLOWED_ORIGINS. */ export function buildAllowedOrigins(lanIps: string[]): string { - const origins: string[] = [ - 'http://localhost:5173', - 'http://localhost:3001', - 'http://localhost:3002', - 'http://127.0.0.1:5173', - 'http://127.0.0.1:3001', - 'http://127.0.0.1:3002', - ] - - for (const ip of lanIps) { - origins.push(`http://${ip}:5173`) - origins.push(`http://${ip}:3001`) - origins.push(`http://${ip}:3002`) - } + return buildOriginsForPorts(lanIps, [BOOTSTRAP_PROD_PORT]) +} - return origins.join(',') +/** + * Build bootstrap EXTRA_ALLOWED_ORIGINS for development companion ports. + */ +export function buildExtraAllowedOrigins(lanIps: string[]): string { + return buildOriginsForPorts(lanIps, BOOTSTRAP_DEV_PORTS) } /** @@ -317,6 +321,7 @@ export function ensureEnvFile(envPath: string): BootstrapResult { const configHost = readConfigHost() const lanIps = configHost === '0.0.0.0' ? detectLanIps() : [] const origins = buildAllowedOrigins(lanIps) + const extraOrigins = buildExtraAllowedOrigins(lanIps) if (!exists) { // Create new .env file @@ -324,6 +329,7 @@ export function ensureEnvFile(envPath: string): BootstrapResult { '# Auto-generated by Freshell first-run setup', `AUTH_TOKEN=${token}`, `ALLOWED_ORIGINS=${origins}`, + `EXTRA_ALLOWED_ORIGINS=${extraOrigins}`, 'PORT=3001', '', ].join('\n') diff --git a/test/server/ws-edge-cases.test.ts b/test/server/ws-edge-cases.test.ts index 40fbfaf06..a9bbb354d 100644 --- a/test/server/ws-edge-cases.test.ts +++ b/test/server/ws-edge-cases.test.ts @@ -1893,7 +1893,7 @@ describe('WebSocket edge cases', () => { // Our test infrastructure connects from 127.0.0.1, so any connection we make // tests the loopback bypass. The key is that it succeeds despite not being - // in ALLOWED_ORIGINS (which only has localhost:5173 and localhost:3001 by default). + // in ALLOWED_ORIGINS (which only has localhost:3001 by default). const { ws, close } = await createAuthenticatedConnection() // If we got here, the loopback connection was accepted diff --git a/test/unit/server/bootstrap.test.ts b/test/unit/server/bootstrap.test.ts index c91e402d0..f9b3af0c0 100644 --- a/test/unit/server/bootstrap.test.ts +++ b/test/unit/server/bootstrap.test.ts @@ -223,34 +223,34 @@ Ethernet adapter vEthernet (WSL): }) describe('buildAllowedOrigins', () => { - it('includes localhost origins for dev and prod ports', () => { + it('includes localhost origins for the production port only', () => { const origins = buildAllowedOrigins([]) - expect(origins).toContain('http://localhost:5173') expect(origins).toContain('http://localhost:3001') - expect(origins).toContain('http://localhost:3002') - expect(origins).toContain('http://127.0.0.1:5173') expect(origins).toContain('http://127.0.0.1:3001') - expect(origins).toContain('http://127.0.0.1:3002') + expect(origins).not.toContain('http://localhost:5173') + expect(origins).not.toContain('http://localhost:3002') + expect(origins).not.toContain('http://127.0.0.1:5173') + expect(origins).not.toContain('http://127.0.0.1:3002') }) - it('includes LAN IP origins for all ports', () => { + it('includes LAN IP origins for the production port only', () => { const origins = buildAllowedOrigins(['192.168.1.100']) - expect(origins).toContain('http://192.168.1.100:5173') expect(origins).toContain('http://192.168.1.100:3001') - expect(origins).toContain('http://192.168.1.100:3002') + expect(origins).not.toContain('http://192.168.1.100:5173') + expect(origins).not.toContain('http://192.168.1.100:3002') }) it('includes multiple LAN IPs', () => { const origins = buildAllowedOrigins(['192.168.1.100', '10.0.0.5']) - expect(origins).toContain('http://192.168.1.100:5173') expect(origins).toContain('http://192.168.1.100:3001') - expect(origins).toContain('http://192.168.1.100:3002') - expect(origins).toContain('http://10.0.0.5:5173') expect(origins).toContain('http://10.0.0.5:3001') - expect(origins).toContain('http://10.0.0.5:3002') + expect(origins).not.toContain('http://192.168.1.100:5173') + expect(origins).not.toContain('http://192.168.1.100:3002') + expect(origins).not.toContain('http://10.0.0.5:5173') + expect(origins).not.toContain('http://10.0.0.5:3002') }) it('returns comma-separated string format', () => { @@ -511,7 +511,7 @@ Ethernet adapter vEthernet (WSL): expect(writtenContent).toContain('192.168.1.100') }) - it('generated .env includes only localhost origins when config host is 127.0.0.1', () => { + it('generated .env puts dev server origins in EXTRA_ALLOWED_ORIGINS when config host is 127.0.0.1', () => { vi.mocked(os.homedir).mockReturnValue('/home/testuser') vi.mocked(fs.existsSync).mockReturnValue(false) // readConfigHost throws (no config.json) → defaults to 127.0.0.1 @@ -526,7 +526,22 @@ Ethernet adapter vEthernet (WSL): ensureEnvFile(mockEnvPath) expect(writtenContent).toContain('ALLOWED_ORIGINS=') + expect(writtenContent).toContain('EXTRA_ALLOWED_ORIGINS=') expect(writtenContent).toContain('localhost') + const allowedOrigins = writtenContent + .split('\n') + .find(line => line.startsWith('ALLOWED_ORIGINS=')) ?? '' + const extraAllowedOrigins = writtenContent + .split('\n') + .find(line => line.startsWith('EXTRA_ALLOWED_ORIGINS=')) ?? '' + expect(allowedOrigins).toContain('http://localhost:3001') + expect(allowedOrigins).toContain('http://127.0.0.1:3001') + expect(allowedOrigins).not.toContain('http://localhost:5173') + expect(allowedOrigins).not.toContain('http://localhost:3002') + expect(extraAllowedOrigins).toContain('http://localhost:5173') + expect(extraAllowedOrigins).toContain('http://localhost:3002') + expect(extraAllowedOrigins).toContain('http://127.0.0.1:5173') + expect(extraAllowedOrigins).toContain('http://127.0.0.1:3002') expect(writtenContent).not.toContain('192.168.1.100') })