Skip to content
Open
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
4 changes: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
40 changes: 23 additions & 17 deletions server/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

/**
Expand Down Expand Up @@ -317,13 +321,15 @@ 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
const content = [
'# Auto-generated by Freshell first-run setup',
`AUTH_TOKEN=${token}`,
`ALLOWED_ORIGINS=${origins}`,
`EXTRA_ALLOWED_ORIGINS=${extraOrigins}`,
'PORT=3001',
'',
].join('\n')
Expand Down
2 changes: 1 addition & 1 deletion test/server/ws-edge-cases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 28 additions & 13 deletions test/unit/server/bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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
Expand All @@ -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')
})

Expand Down
Loading