diff --git a/packages/desktop/apps/electron/src/main/index.ts b/packages/desktop/apps/electron/src/main/index.ts index 598e5c07688..23764e55bfc 100644 --- a/packages/desktop/apps/electron/src/main/index.ts +++ b/packages/desktop/apps/electron/src/main/index.ts @@ -88,7 +88,7 @@ import { registerCoreRpcHandlers, cleanupSessionFileWatchForClient } from '@craf import type { PlatformServices } from '../runtime/platform' import { createElectronPlatform } from './platform' import type { HandlerDeps } from './handlers/handler-deps' -import { bootstrapServer, releaseServerLock } from '@craft-agent/server-core/bootstrap' +import { bootstrapServer, releaseServerLock, parseServerPort } from '@craft-agent/server-core/bootstrap' import { createMessagingBootstrap, type MessagingBootstrapHandle } from '@craft-agent/messaging-gateway' import { getCredentialManager } from '@craft-agent/shared/credentials' import { initModelRefreshService, getModelRefreshService, setFetcherPlatform } from '@craft-agent/server-core/model-fetchers' @@ -589,8 +589,9 @@ app.whenReady().then(async () => { : randomUUID() const rpcHost = process.env.CRAFT_RPC_HOST ?? (serverModeEnabled ? '0.0.0.0' : '127.0.0.1') - const rpcPort = process.env.CRAFT_RPC_PORT - ? parseInt(process.env.CRAFT_RPC_PORT, 10) + const envRpcPort = process.env.CRAFT_RPC_PORT + const rpcPort = envRpcPort + ? parseServerPort('CRAFT_RPC_PORT', envRpcPort, 9100) : (serverModeEnabled ? embeddedServerConfig.port : 0) // Load TLS certificates if configured diff --git a/packages/desktop/packages/server-core/src/bootstrap/__tests__/server-port.test.ts b/packages/desktop/packages/server-core/src/bootstrap/__tests__/server-port.test.ts new file mode 100644 index 00000000000..4c0b2e90cac --- /dev/null +++ b/packages/desktop/packages/server-core/src/bootstrap/__tests__/server-port.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'bun:test' +import { parseServerPort } from '../headless-start' + +describe('parseServerPort', () => { + it('uses the default when the value is undefined', () => { + expect(parseServerPort('CRAFT_RPC_PORT', undefined, 9100)).toBe(9100) + }) + + it('accepts whole decimal ports', () => { + expect(parseServerPort('CRAFT_RPC_PORT', '0', 9100)).toBe(0) + expect(parseServerPort('CRAFT_RPC_PORT', '9100', 0)).toBe(9100) + expect(parseServerPort('CRAFT_RPC_PORT', '65535', 0)).toBe(65535) + expect(parseServerPort('CRAFT_RPC_PORT', ' 3000 ', 0)).toBe(3000) + }) + + it('accepts integer numeric ports from bootstrap options', () => { + expect(parseServerPort('rpcPort', 0, 9100)).toBe(0) + expect(parseServerPort('rpcPort', 3000, 0)).toBe(3000) + }) + + it('rejects partially parsed port strings', () => { + expect(() => parseServerPort('CRAFT_RPC_PORT', '9100abc', 9100)).toThrow(/Invalid CRAFT_RPC_PORT/) + expect(() => parseServerPort('CRAFT_RPC_PORT', '3000.5', 9100)).toThrow(/Invalid CRAFT_RPC_PORT/) + expect(() => parseServerPort('CRAFT_RPC_PORT', '1e3', 9100)).toThrow(/Invalid CRAFT_RPC_PORT/) + }) + + it('rejects out-of-range or non-integer ports', () => { + expect(() => parseServerPort('CRAFT_RPC_PORT', '-1', 9100)).toThrow(/Invalid CRAFT_RPC_PORT/) + expect(() => parseServerPort('CRAFT_RPC_PORT', '65536', 9100)).toThrow(/Invalid CRAFT_RPC_PORT/) + expect(() => parseServerPort('rpcPort', 3000.5, 9100)).toThrow(/Invalid rpcPort/) + }) +}) diff --git a/packages/desktop/packages/server-core/src/bootstrap/headless-start.ts b/packages/desktop/packages/server-core/src/bootstrap/headless-start.ts index 6e777820ce9..a052a5b27ee 100644 --- a/packages/desktop/packages/server-core/src/bootstrap/headless-start.ts +++ b/packages/desktop/packages/server-core/src/bootstrap/headless-start.ts @@ -109,6 +109,29 @@ export function generateServerToken(): string { return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('') } +const PORT_VALUE_PATTERN = /^\d+$/ + +export function parseServerPort(name: string, value: string | number | undefined, defaultPort: number): number { + const raw = value ?? defaultPort + let port: number + + if (typeof raw === 'number') { + port = raw + } else { + const trimmed = raw.trim() + if (!PORT_VALUE_PATTERN.test(trimmed)) { + throw new Error(`Invalid ${name}: expected an integer port between 0 and 65535, got ${JSON.stringify(raw)}`) + } + port = Number(trimmed) + } + + if (!Number.isInteger(port) || port < 0 || port > 65535) { + throw new Error(`Invalid ${name}: expected an integer port between 0 and 65535, got ${String(raw)}`) + } + + return port +} + // --------------------------------------------------------------------------- // Startup lock file // --------------------------------------------------------------------------- @@ -284,11 +307,9 @@ export async function bootstrapServer( const sessionManager = options.createSessionManager() const rpcHost = options.rpcHost ?? process.env.CRAFT_RPC_HOST ?? '127.0.0.1' - const rpcPortRaw = options.rpcPort ?? parseInt(process.env.CRAFT_RPC_PORT ?? '9100', 10) - if (!Number.isFinite(rpcPortRaw) || rpcPortRaw < 0 || rpcPortRaw > 65535) { - throw new Error(`Invalid RPC port: ${rpcPortRaw}`) - } - const rpcPort = Math.trunc(rpcPortRaw) + const rpcPort = options.rpcPort != null + ? parseServerPort('rpcPort', options.rpcPort, 9100) + : parseServerPort('CRAFT_RPC_PORT', process.env.CRAFT_RPC_PORT, 9100) const wsServer = new WsRpcServer({ host: rpcHost, diff --git a/packages/desktop/packages/server/src/__tests__/smoke.test.ts b/packages/desktop/packages/server/src/__tests__/smoke.test.ts index 4f8822ed05f..74db32e1f03 100644 --- a/packages/desktop/packages/server/src/__tests__/smoke.test.ts +++ b/packages/desktop/packages/server/src/__tests__/smoke.test.ts @@ -10,6 +10,7 @@ import { describe, it, expect, afterEach } from 'bun:test' import { join } from 'node:path' +import { tmpdir } from 'node:os' import type { Subprocess } from 'bun' import WebSocket from 'ws' @@ -94,6 +95,61 @@ async function spawnTestServer(extraEnv?: Record): Promise | null): Promise { + if (!stream) return '' + + const reader = stream.getReader() + const decoder = new TextDecoder() + let output = '' + + while (true) { + const { done, value } = await reader.read() + if (done) break + output += decoder.decode(value, { stream: true }) + } + + output += decoder.decode() + return output +} + +async function runServerExpectingStartupFailure(extraEnv: Record): Promise<{ + exitCode: number | null + output: string +}> { + const token = crypto.randomUUID() + crypto.randomUUID() + const proc = Bun.spawn(['bun', 'run', SERVER_ENTRY], { + env: { + ...process.env, + ...extraEnv, + CRAFT_SERVER_TOKEN: token, + CRAFT_RPC_HOST: '127.0.0.1', + CRAFT_SERVER_LOCK_FILE: join(tmpdir(), `qwen-code-server-test-${crypto.randomUUID()}.lock`), + }, + stdout: 'pipe', + stderr: 'pipe', + }) + + let timeout: ReturnType | undefined + const exitCode = await Promise.race([ + proc.exited, + new Promise((_, reject) => { + timeout = setTimeout(() => { + proc.kill() + reject(new Error('Server did not exit after invalid startup config')) + }, STARTUP_TIMEOUT) + }), + ]).finally(() => { + if (timeout) clearTimeout(timeout) + }) + + const [stdout, stderr] = await Promise.all([ + readStream(proc.stdout), + readStream(proc.stderr), + ]) + + return { exitCode, output: `${stdout}\n${stderr}` } +} + function connectWs(url: string, token: string): Promise { return new Promise((resolve, reject) => { const ws = new WebSocket(url) @@ -163,6 +219,29 @@ describe('headless server smoke test', () => { expect(exitCode).not.toBe(0) }, TEST_TIMEOUT) + it('rejects partially parsed RPC ports at startup', async () => { + const result = await runServerExpectingStartupFailure({ + CRAFT_RPC_PORT: '9100abc', + CRAFT_HEALTH_PORT: '0', + }) + + expect(result.exitCode).not.toBe(0) + expect(result.output).toContain('Invalid CRAFT_RPC_PORT') + expect(result.output).not.toContain('CRAFT_SERVER_URL=') + }, TEST_TIMEOUT) + + it('rejects partially parsed health ports before starting', async () => { + const result = await runServerExpectingStartupFailure({ + CRAFT_RPC_PORT: '0', + CRAFT_HEALTH_PORT: '3000abc', + }) + + expect(result.exitCode).not.toBe(0) + expect(result.output).toContain('Invalid CRAFT_HEALTH_PORT') + expect(result.output).not.toContain('Qwen Code server listening') + expect(result.output).not.toContain('CRAFT_SERVER_URL=') + }, TEST_TIMEOUT) + it('shuts down cleanly on SIGTERM', async () => { server = await spawnTestServer() const ws = await connectWs(server.url, server.token) diff --git a/packages/desktop/packages/server/src/index.ts b/packages/desktop/packages/server/src/index.ts index c1182613f78..b6e5225587a 100755 --- a/packages/desktop/packages/server/src/index.ts +++ b/packages/desktop/packages/server/src/index.ts @@ -30,7 +30,7 @@ import { homedir } from 'node:os' import { readFileSync, existsSync } from 'node:fs' import { version as packageVersion } from '../package.json' import { enableDebug } from '@craft-agent/shared/utils/debug' -import { bootstrapServer, startHealthHttpServer, generateServerToken } from '@craft-agent/server-core/bootstrap' +import { bootstrapServer, startHealthHttpServer, generateServerToken, parseServerPort } from '@craft-agent/server-core/bootstrap' import { validateSession, createWebuiHandler, nodeHttpAdapter } from '@craft-agent/server-core/webui' import type { WebuiHandler } from '@craft-agent/server-core/webui' import { getCredentialManager } from '@craft-agent/shared/credentials' @@ -90,6 +90,15 @@ function parseOptionalWebSocketUrl(name: string, value: string | undefined): str } } +function parseServerPortOrExit(name: string, value: string | undefined, defaultPort: number): number { + try { + return parseServerPort(name, value, defaultPort) + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) + } +} + // In dev (monorepo), bundled assets root is the repo root (4 levels up from this file). // In packaged mode, use CRAFT_BUNDLED_ASSETS_ROOT env or cwd. const bundledAssetsRoot = process.env.CRAFT_BUNDLED_ASSETS_ROOT @@ -117,6 +126,7 @@ const webuiEnabled = webuiDir && existsSync(webuiDir) const webuiSecureCookies = parseOptionalBooleanEnv('CRAFT_WEBUI_SECURE_COOKIE', process.env.CRAFT_WEBUI_SECURE_COOKIE) const webuiWsUrl = parseOptionalWebSocketUrl('CRAFT_WEBUI_WS_URL', process.env.CRAFT_WEBUI_WS_URL) const serverToken = process.env.CRAFT_SERVER_TOKEN +const healthPort = parseServerPortOrExit('CRAFT_HEALTH_PORT', process.env.CRAFT_HEALTH_PORT, 0) // --------------------------------------------------------------------------- // Create WebUI handler early so it can be embedded in the WsRpcServer. @@ -132,7 +142,7 @@ let webuiNodeHandler: ReturnType | undefined let healthCheckFn: (() => { status: string }) | null = null if (webuiEnabled && serverToken) { - const rpcPort = parseInt(process.env.CRAFT_RPC_PORT ?? '9100', 10) + const rpcPort = parseServerPortOrExit('CRAFT_RPC_PORT', process.env.CRAFT_RPC_PORT, 9100) const rpcProtocol = tls ? 'wss' as const : 'ws' as const webuiHandler = createWebuiHandler({ @@ -296,7 +306,6 @@ if (webuiHandler) { } // Start HTTP health endpoint if CRAFT_HEALTH_PORT is set -const healthPort = parseInt(process.env.CRAFT_HEALTH_PORT ?? '0', 10) const healthServer = await startHealthHttpServer({ port: healthPort, deps: { sessionManager: instance.sessionManager },