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
7 changes: 4 additions & 3 deletions packages/desktop/apps/electron/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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/)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -284,11 +307,9 @@ export async function bootstrapServer<TSessionManager, THandlerDeps>(
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,
Expand Down
79 changes: 79 additions & 0 deletions packages/desktop/packages/server/src/__tests__/smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -94,6 +95,61 @@ async function spawnTestServer(extraEnv?: Record<string, string>): Promise<Spawn
})
}

async function readStream(stream: ReadableStream<Uint8Array> | null): Promise<string> {
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<string, string>): 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<typeof setTimeout> | undefined
const exitCode = await Promise.race([
proc.exited,
new Promise<never>((_, 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<WebSocket> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url)
Expand Down Expand Up @@ -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)
Expand Down
15 changes: 12 additions & 3 deletions packages/desktop/packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -132,7 +142,7 @@ let webuiNodeHandler: ReturnType<typeof nodeHttpAdapter> | 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({
Expand Down Expand Up @@ -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 },
Expand Down
Loading