From b72b2fe4d4d882c1aeb7cc616a239c46dbe0c642 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Wed, 6 May 2026 11:39:34 +0800 Subject: [PATCH 1/4] feat(desktop): implement sidecar server process Move server initialization to a utility process (sidecar) for better isolation and resource management. This decouples the server lifecycle from the main Electron process, improving stability and startup control. --- packages/desktop/electron.vite.config.ts | 2 +- packages/desktop/src/main/env.d.ts | 10 ++ packages/desktop/src/main/index.ts | 65 ++++++----- packages/desktop/src/main/server.ts | 112 ++++++++++++++----- packages/desktop/src/main/sidecar.ts | 133 +++++++++++++++++++++++ 5 files changed, 264 insertions(+), 58 deletions(-) create mode 100644 packages/desktop/src/main/sidecar.ts diff --git a/packages/desktop/electron.vite.config.ts b/packages/desktop/electron.vite.config.ts index a352e03fdd4..52aa699ff60 100644 --- a/packages/desktop/electron.vite.config.ts +++ b/packages/desktop/electron.vite.config.ts @@ -37,7 +37,7 @@ export default defineConfig({ }, build: { rollupOptions: { - input: { index: "src/main/index.ts" }, + input: { index: "src/main/index.ts", sidecar: "src/main/sidecar.ts" }, }, externalizeDeps: { include: [nodePtyPkg] }, }, diff --git a/packages/desktop/src/main/env.d.ts b/packages/desktop/src/main/env.d.ts index 1de56e1c904..0a715610d42 100644 --- a/packages/desktop/src/main/env.d.ts +++ b/packages/desktop/src/main/env.d.ts @@ -5,6 +5,16 @@ interface ImportMetaEnv { interface ImportMeta { readonly env: ImportMetaEnv } + +declare namespace NodeJS { + interface Process { + readonly parentPort?: { + postMessage(message: unknown): void + on(event: "message", listener: (event: { data: unknown; ports: unknown[] }) => void): void + } + } +} + declare module "virtual:opencode-server" { export namespace Server { export const listen: typeof import("../../../opencode/dist/types/src/node").Server.listen diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index a1eba8b98db..0f23e50b42c 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -5,7 +5,7 @@ import * as http from "node:http" import { createServer } from "node:net" import { homedir } from "node:os" import { join } from "node:path" -import { getCACertificates, setDefaultCACertificates } from "node:tls" +import * as tls from "node:tls" import type { Event } from "electron" import { app, BrowserWindow, dialog } from "electron" import pkg from "electron-updater" @@ -36,6 +36,11 @@ app.setAppUserModelId(appId) app.setPath("userData", join(app.getPath("appData"), appId)) const { autoUpdater } = pkg +type NodeTlsWithSystemCertificates = typeof tls & { + getCACertificates: (type: "default" | "system") => string[] + setDefaultCACertificates: (certificates: string[]) => void +} + import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types" import { checkAppExists, resolveAppPath, wslPath } from "./apps" import { CHANNEL, UPDATER_ENABLED } from "./constants" @@ -43,7 +48,14 @@ import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigratio import { initLogging } from "./logging" import { parseMarkdown } from "./markdown" import { createMenu } from "./menu" -import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server" +import { + getDefaultServerUrl, + getWslConfig, + setDefaultServerUrl, + setWslConfig, + spawnLocalServer, + type SidecarListener, +} from "./server" import { createLoadingWindow, createMainWindow, @@ -51,15 +63,13 @@ import { setBackgroundColor, setDockIcon, } from "./windows" -import { drizzle } from "drizzle-orm/node-sqlite/driver" -import type { Server } from "virtual:opencode-server" import { migrate } from "./migrate" const initEmitter = new EventEmitter() let initStep: InitStep = { phase: "server_waiting" } let mainWindow: BrowserWindow | null = null -let server: Server.Listener | null = null +let server: SidecarListener | null = null const loadingComplete = defer() const pendingDeepLinks: string[] = [] @@ -129,7 +139,10 @@ function setupApp() { function useSystemCertificates() { try { - setDefaultCACertificates([...new Set([...getCACertificates("default"), ...getCACertificates("system")])]) + const nodeTls = tls as NodeTlsWithSystemCertificates + nodeTls.setDefaultCACertificates([ + ...new Set([...nodeTls.getCACertificates("default"), ...nodeTls.getCACertificates("system")]), + ]) } catch (error) { logger.warn("failed to load system certificates", error) } @@ -164,7 +177,6 @@ function setInitStep(step: InitStep) { async function initialize() { const needsMigration = !sqliteFileExists() - const sqliteDone = needsMigration ? defer() : undefined let overlay: BrowserWindow | null = null const port = await getSidecarPort() @@ -179,31 +191,26 @@ async function initialize() { setInitStep({ phase: "sqlite_waiting" }) if (overlay) sendSqliteMigrationProgress(overlay, progress) if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress) - if (progress.type === "Done") sqliteDone?.resolve() }) - if (needsMigration) { - const { Database, JsonMigration } = await import("virtual:opencode-server") - await JsonMigration.run(drizzle({ client: Database.Client().$client }), { - progress: (event: { current: number; total: number }) => { - const percent = Math.round(event.current / event.total) * 100 - initEmitter.emit("sqlite", { type: "InProgress", value: percent }) - }, - }) - initEmitter.emit("sqlite", { type: "Done" }) - - sqliteDone?.resolve() - } - - if (needsMigration) { - await sqliteDone?.promise - } - logger.log("spawning sidecar", { url }) - const { listener, health } = await spawnLocalServer(hostname, port, password, () => { - ensureLoopbackNoProxy() - useEnvProxy() - }) + const { listener, health } = await spawnLocalServer( + hostname, + port, + password, + () => { + ensureLoopbackNoProxy() + useEnvProxy() + }, + { + needsMigration, + userDataPath: app.getPath("userData"), + onSqliteProgress: (progress) => initEmitter.emit("sqlite", progress), + onStdout: (message) => logger.log("sidecar stdout", { message }), + onStderr: (message) => logger.warn("sidecar stderr", { message }), + onExit: (code) => logger.warn("sidecar exited", { code }), + }, + ) server = listener serverReady.resolve({ url, diff --git a/packages/desktop/src/main/server.ts b/packages/desktop/src/main/server.ts index fab09eb1b16..e80c0bddbbb 100644 --- a/packages/desktop/src/main/server.ts +++ b/packages/desktop/src/main/server.ts @@ -1,12 +1,31 @@ -import { app } from "electron" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" +import { utilityProcess } from "electron" import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants" -import { getUserShell, loadShellEnv } from "./shell-env" import { getStore } from "./store" +import type { SqliteMigrationProgress } from "../preload/types" export type WslConfig = { enabled: boolean } export type HealthCheck = { wait: Promise } +type SidecarMessage = + | { type: "sqlite"; progress: SqliteMigrationProgress } + | { type: "ready" } + | { type: "stopped" } + | { type: "error"; error: { message: string; stack?: string } } + +export type SidecarListener = { stop: () => void } + +type SpawnLocalServerOptions = { + needsMigration: boolean + userDataPath: string + onSqliteProgress?: (progress: SqliteMigrationProgress) => void + onStdout?: (message: string) => void + onStderr?: (message: string) => void + onExit?: (code: number) => void +} + export function getDefaultServerUrl(): string | null { const value = getStore().get(DEFAULT_SERVER_URL_KEY) return typeof value === "string" ? value : null @@ -30,19 +49,62 @@ export function setWslConfig(config: WslConfig) { getStore().set(WSL_ENABLED_KEY, config.enabled) } -export async function spawnLocalServer(hostname: string, port: number, password: string, configureEnv?: () => void) { - prepareServerEnv(password) +export async function spawnLocalServer( + hostname: string, + port: number, + password: string, + configureEnv: () => void, + options: SpawnLocalServerOptions, +) { configureEnv?.() - const { Log, Server } = await import("virtual:opencode-server") - await Log.init({ level: "WARN" }) - const listener = await Server.listen({ - port, - hostname, - username: "opencode", - password, - cors: ["oc://renderer"], + const child = utilityProcess.fork(join(dirname(fileURLToPath(import.meta.url)), "sidecar.js"), [], { + cwd: process.cwd(), + env: process.env, + serviceName: "opencode server", + stdio: "pipe", }) + child.stdout?.on("data", (chunk: Buffer) => options.onStdout?.(chunk.toString("utf8").trimEnd())) + child.stderr?.on("data", (chunk: Buffer) => options.onStderr?.(chunk.toString("utf8").trimEnd())) + + await new Promise((resolve, reject) => { + const onMessage = (message: SidecarMessage) => { + if (message.type === "sqlite") { + options.onSqliteProgress?.(message.progress) + return + } + if (message.type === "ready") { + cleanup() + resolve() + return + } + if (message.type === "error") { + cleanup() + reject(Object.assign(new Error(message.error.message), { stack: message.error.stack })) + } + } + const onExit = (code: number) => { + cleanup() + reject(new Error(`Sidecar exited before ready with code ${code}`)) + } + const cleanup = () => { + child.off("message", onMessage) + child.off("exit", onExit) + } + + child.on("message", onMessage) + child.on("exit", onExit) + child.postMessage({ + type: "start", + hostname, + port, + password, + userDataPath: options.userDataPath, + needsMigration: options.needsMigration, + }) + }) + child.on("exit", (code: number) => options.onExit?.(code)) + const wait = (async () => { const url = `http://${hostname}:${port}` @@ -56,23 +118,17 @@ export async function spawnLocalServer(hostname: string, port: number, password: await ready() })() - return { listener, health: { wait } } -} - -function prepareServerEnv(password: string) { - const shell = process.platform === "win32" ? null : getUserShell() - const shellEnv = shell ? (loadShellEnv(shell) ?? {}) : {} - const env = { - ...process.env, - ...shellEnv, - OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true", - OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", - OPENCODE_CLIENT: "desktop", - OPENCODE_SERVER_USERNAME: "opencode", - OPENCODE_SERVER_PASSWORD: password, - XDG_STATE_HOME: app.getPath("userData"), + return { + listener: { + stop: () => { + child.postMessage({ type: "stop" }) + setTimeout(() => { + if (child.pid) child.kill() + }, 2_000).unref() + }, + }, + health: { wait }, } - Object.assign(process.env, env) } export async function checkHealth(url: string, password?: string | null): Promise { diff --git a/packages/desktop/src/main/sidecar.ts b/packages/desktop/src/main/sidecar.ts new file mode 100644 index 00000000000..2a4ddcd819d --- /dev/null +++ b/packages/desktop/src/main/sidecar.ts @@ -0,0 +1,133 @@ +import { drizzle } from "drizzle-orm/node-sqlite/driver" + +import { getUserShell, loadShellEnv } from "./shell-env" + +type StartCommand = { + type: "start" + hostname: string + port: number + password: string + userDataPath: string + needsMigration: boolean +} + +type StopCommand = { type: "stop" } +type SidecarCommand = StartCommand | StopCommand + +type SidecarMessage = + | { type: "sqlite"; progress: { type: "InProgress"; value: number } | { type: "Done" } } + | { type: "ready" } + | { type: "stopped" } + | { type: "error"; error: { message: string; stack?: string } } + +type ParentPort = { + postMessage(message: SidecarMessage): void + on(event: "message", listener: (event: { data: unknown }) => void): void +} + +type Listener = { + stop(close?: boolean): void | Promise +} + +const parentPort = getParentPort() +let listener: Listener | undefined + +parentPort.on("message", (event) => { + const command = parseCommand(event.data) + if (!command) return + if (command.type === "stop") { + void stop() + return + } + void start(command) +}) + +async function start(command: StartCommand) { + try { + prepareServerEnv(command.password, command.userDataPath) + const { Database, JsonMigration, Log, Server } = await import("virtual:opencode-server") + await Log.init({ level: "WARN" }) + + if (command.needsMigration) { + await JsonMigration.run(drizzle({ client: Database.Client().$client }), { + progress: (event: { current: number; total: number }) => { + parentPort.postMessage({ + type: "sqlite", + progress: { + type: "InProgress", + value: event.total === 0 ? 100 : Math.round((event.current / event.total) * 100), + }, + }) + }, + }) + parentPort.postMessage({ type: "sqlite", progress: { type: "Done" } }) + } + + listener = await Server.listen({ + port: command.port, + hostname: command.hostname, + username: "opencode", + password: command.password, + cors: ["oc://renderer"], + }) + parentPort.postMessage({ type: "ready" }) + } catch (error) { + parentPort.postMessage({ type: "error", error: serializeError(error) }) + } +} + +async function stop() { + try { + await listener?.stop() + } finally { + listener = undefined + parentPort.postMessage({ type: "stopped" }) + setImmediate(() => process.exit(0)) + } +} + +function prepareServerEnv(password: string, userDataPath: string) { + const shell = process.platform === "win32" ? null : getUserShell() + const shellEnv = shell ? (loadShellEnv(shell) ?? {}) : {} + Object.assign(process.env, { + ...process.env, + ...shellEnv, + OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true", + OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", + OPENCODE_CLIENT: "desktop", + OPENCODE_SERVER_USERNAME: "opencode", + OPENCODE_SERVER_PASSWORD: password, + XDG_STATE_HOME: userDataPath, + }) +} + +function parseCommand(value: unknown): SidecarCommand | undefined { + if (!value || typeof value !== "object") return + const command = value as Partial + if (command.type === "stop") return { type: "stop" } + if (command.type !== "start") return + if (typeof command.hostname !== "string") return + if (typeof command.port !== "number") return + if (typeof command.password !== "string") return + if (typeof command.userDataPath !== "string") return + if (typeof command.needsMigration !== "boolean") return + return { + type: "start", + hostname: command.hostname, + port: command.port, + password: command.password, + userDataPath: command.userDataPath, + needsMigration: command.needsMigration, + } +} + +function serializeError(error: unknown) { + if (error instanceof Error) return { message: error.message, stack: error.stack } + return { message: String(error) } +} + +function getParentPort() { + const port = process.parentPort as ParentPort | undefined + if (!port) throw new Error("Sidecar parent port unavailable") + return port +} From 0047f87fabe0aa66a4f0b96de584443a81de3a8f Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 6 May 2026 14:07:48 +1000 Subject: [PATCH 2/4] fix(desktop): harden sidecar lifecycle --- packages/desktop/src/main/index.ts | 21 ++--- packages/desktop/src/main/ipc.ts | 2 +- packages/desktop/src/main/server.ts | 118 +++++++++++++++++++++++---- packages/desktop/src/main/sidecar.ts | 33 ++++++++ 4 files changed, 148 insertions(+), 26 deletions(-) diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 0f23e50b42c..e41324df6a8 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -113,17 +113,16 @@ function setupApp() { }) app.on("before-quit", () => { - killSidecar() + void killSidecar() }) app.on("will-quit", () => { - killSidecar() + void killSidecar() }) for (const signal of ["SIGINT", "SIGTERM"] as const) { process.on(signal, () => { - killSidecar() - app.exit(0) + void killSidecar().finally(() => app.exit(0)) }) } @@ -260,9 +259,10 @@ function wireMenu() { }, reload: () => mainWindow?.reload(), relaunch: () => { - killSidecar() - app.relaunch() - app.exit(0) + void killSidecar().finally(() => { + app.relaunch() + app.exit(0) + }) }, }) } @@ -301,10 +301,11 @@ registerIpcHandlers({ setBackgroundColor: (color) => setBackgroundColor(color), }) -function killSidecar() { +async function killSidecar() { if (!server) return - server.stop() + const current = server server = null + await current.stop() } function ensureLoopbackNoProxy() { @@ -425,7 +426,7 @@ async function installUpdate() { logger.log("installing downloaded update", { version: downloadedUpdateVersion, }) - killSidecar() + await killSidecar() autoUpdater.quitAndInstall() } diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index 1c4af0eb60a..dbcd4239dc2 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -19,7 +19,7 @@ const pickerFilters = (ext?: string[]) => { } type Deps = { - killSidecar: () => void + killSidecar: () => Promise | void awaitInitialization: (sendStep: (step: InitStep) => void) => Promise getWindowConfig: () => Promise | WindowConfig consumeInitialDeepLinks: () => Promise | string[] diff --git a/packages/desktop/src/main/server.ts b/packages/desktop/src/main/server.ts index e80c0bddbbb..4738693231d 100644 --- a/packages/desktop/src/main/server.ts +++ b/packages/desktop/src/main/server.ts @@ -1,6 +1,7 @@ import { dirname, join } from "node:path" import { fileURLToPath } from "node:url" -import { utilityProcess } from "electron" +import { app, utilityProcess } from "electron" +import type { Details } from "electron" import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants" import { getStore } from "./store" import type { SqliteMigrationProgress } from "../preload/types" @@ -15,7 +16,11 @@ type SidecarMessage = | { type: "stopped" } | { type: "error"; error: { message: string; stack?: string } } -export type SidecarListener = { stop: () => void } +export type SidecarListener = { stop: () => Promise } + +const SIDECAR_SERVICE_NAME = "opencode server" +const SIDECAR_START_STALL_TIMEOUT = 60_000 +const SIDECAR_STOP_TIMEOUT = 6_000 type SpawnLocalServerOptions = { needsMigration: boolean @@ -57,43 +62,80 @@ export async function spawnLocalServer( options: SpawnLocalServerOptions, ) { configureEnv?.() - const child = utilityProcess.fork(join(dirname(fileURLToPath(import.meta.url)), "sidecar.js"), [], { + const sidecar = join(dirname(fileURLToPath(import.meta.url)), "sidecar.js") + const child = utilityProcess.fork(sidecar, [], { cwd: process.cwd(), - env: process.env, - serviceName: "opencode server", + env: createSidecarEnv(), + serviceName: SIDECAR_SERVICE_NAME, stdio: "pipe", }) + let exited = false + const exit = defer() + + const onProcessGone = (_event: unknown, details: Details) => { + if (details.type !== "Utility" || details.name !== SIDECAR_SERVICE_NAME) return + options.onStderr?.(`utility process gone reason=${details.reason} exitCode=${details.exitCode}`) + } + + app.on("child-process-gone", onProcessGone) + child.once("exit", (code) => { + exited = true + app.off("child-process-gone", onProcessGone) + options.onExit?.(code) + exit.resolve(code) + }) + child.on("error", (error) => options.onStderr?.(`utility process error: ${serializeError(error).message}`)) child.stdout?.on("data", (chunk: Buffer) => options.onStdout?.(chunk.toString("utf8").trimEnd())) child.stderr?.on("data", (chunk: Buffer) => options.onStderr?.(chunk.toString("utf8").trimEnd())) await new Promise((resolve, reject) => { + let done = false + let timeout: NodeJS.Timeout + + const fail = (error: Error) => { + if (done) return + done = true + cleanup() + reject(error) + } + + const refreshTimeout = () => { + clearTimeout(timeout) + timeout = setTimeout(() => { + fail(new Error(`Sidecar did not become ready within ${SIDECAR_START_STALL_TIMEOUT}ms: ${sidecar}`)) + }, SIDECAR_START_STALL_TIMEOUT) + } + const onMessage = (message: SidecarMessage) => { if (message.type === "sqlite") { + refreshTimeout() options.onSqliteProgress?.(message.progress) return } if (message.type === "ready") { + if (done) return + done = true cleanup() resolve() return } if (message.type === "error") { - cleanup() - reject(Object.assign(new Error(message.error.message), { stack: message.error.stack })) + fail(Object.assign(new Error(message.error.message), { stack: message.error.stack })) } } const onExit = (code: number) => { - cleanup() - reject(new Error(`Sidecar exited before ready with code ${code}`)) + fail(new Error(`Sidecar exited before ready with code ${code}`)) } const cleanup = () => { + clearTimeout(timeout) child.off("message", onMessage) child.off("exit", onExit) } child.on("message", onMessage) child.on("exit", onExit) + refreshTimeout() child.postMessage({ type: "start", hostname, @@ -102,29 +144,47 @@ export async function spawnLocalServer( userDataPath: options.userDataPath, needsMigration: options.needsMigration, }) + }).catch((error) => { + if (!exited) child.kill() + throw error }) - child.on("exit", (code: number) => options.onExit?.(code)) const wait = (async () => { const url = `http://${hostname}:${port}` + let healthy = false + const gone = exit.promise.then((code) => { + if (healthy) return + throw new Error(`Sidecar exited before health check passed with code ${code}`) + }) const ready = async () => { while (true) { await new Promise((resolve) => setTimeout(resolve, 100)) - if (await checkHealth(url, password)) return + if (await checkHealth(url, password)) { + healthy = true + return + } } } - await ready() + await Promise.race([ready(), gone]) })() + let stopping: Promise | undefined + return { listener: { stop: () => { + if (stopping) return stopping + if (exited) return Promise.resolve() child.postMessage({ type: "stop" }) - setTimeout(() => { - if (child.pid) child.kill() - }, 2_000).unref() + stopping = Promise.race([ + exit.promise.then(() => undefined), + delay(SIDECAR_STOP_TIMEOUT).then(() => { + if (!exited) child.kill() + }), + ]) + return stopping }, }, health: { wait }, @@ -156,3 +216,31 @@ export async function checkHealth(url: string, password?: string | null): Promis return false } } + +function createSidecarEnv(): Record { + const env = Object.fromEntries( + Object.entries(process.env).flatMap(([key, value]) => (value === undefined ? [] : [[key, String(value)]])), + ) + delete env.DEBUG + if (process.platform === "linux") delete env.LD_PRELOAD + return env +} + +function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function serializeError(error: unknown) { + if (error instanceof Error) return { message: error.message, stack: error.stack } + return { message: String(error) } +} + +function defer() { + let resolve!: (value: T) => void + let reject!: (error: Error) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} diff --git a/packages/desktop/src/main/sidecar.ts b/packages/desktop/src/main/sidecar.ts index 2a4ddcd819d..c592884c99c 100644 --- a/packages/desktop/src/main/sidecar.ts +++ b/packages/desktop/src/main/sidecar.ts @@ -1,7 +1,18 @@ import { drizzle } from "drizzle-orm/node-sqlite/driver" +import * as http from "node:http" +import * as tls from "node:tls" import { getUserShell, loadShellEnv } from "./shell-env" +type NodeHttpWithEnvProxy = typeof http & { + setGlobalProxyFromEnv: () => void +} + +type NodeTlsWithSystemCertificates = typeof tls & { + getCACertificates: (type: "default" | "system") => string[] + setDefaultCACertificates: (certificates: string[]) => void +} + type StartCommand = { type: "start" hostname: string @@ -45,6 +56,8 @@ parentPort.on("message", (event) => { async function start(command: StartCommand) { try { prepareServerEnv(command.password, command.userDataPath) + useSystemCertificates() + useEnvProxy() const { Database, JsonMigration, Log, Server } = await import("virtual:opencode-server") await Log.init({ level: "WARN" }) @@ -73,6 +86,7 @@ async function start(command: StartCommand) { parentPort.postMessage({ type: "ready" }) } catch (error) { parentPort.postMessage({ type: "error", error: serializeError(error) }) + setImmediate(() => process.exit(1)) } } @@ -101,6 +115,25 @@ function prepareServerEnv(password: string, userDataPath: string) { }) } +function useSystemCertificates() { + try { + const nodeTls = tls as NodeTlsWithSystemCertificates + nodeTls.setDefaultCACertificates([ + ...new Set([...nodeTls.getCACertificates("default"), ...nodeTls.getCACertificates("system")]), + ]) + } catch (error) { + console.warn("failed to load system certificates", error) + } +} + +function useEnvProxy() { + try { + ;(http as NodeHttpWithEnvProxy).setGlobalProxyFromEnv() + } catch (error) { + console.warn("failed to load proxy environment", error) + } +} + function parseCommand(value: unknown): SidecarCommand | undefined { if (!value || typeof value !== "object") return const command = value as Partial From 0bd7c18353dd568f1f1265c58c8e0a29cd44c20c Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 6 May 2026 14:20:04 +1000 Subject: [PATCH 3/4] fix(desktop): preserve sidecar loopback proxy bypass --- packages/desktop/src/main/sidecar.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/desktop/src/main/sidecar.ts b/packages/desktop/src/main/sidecar.ts index c592884c99c..7a71c1e0f66 100644 --- a/packages/desktop/src/main/sidecar.ts +++ b/packages/desktop/src/main/sidecar.ts @@ -56,6 +56,7 @@ parentPort.on("message", (event) => { async function start(command: StartCommand) { try { prepareServerEnv(command.password, command.userDataPath) + ensureLoopbackNoProxy() useSystemCertificates() useEnvProxy() const { Database, JsonMigration, Log, Server } = await import("virtual:opencode-server") @@ -115,6 +116,26 @@ function prepareServerEnv(password: string, userDataPath: string) { }) } +function ensureLoopbackNoProxy() { + const loopback = ["127.0.0.1", "localhost", "::1"] + const upsert = (key: string) => { + const items = (process.env[key] ?? "") + .split(",") + .map((value: string) => value.trim()) + .filter((value: string) => Boolean(value)) + + for (const host of loopback) { + if (items.some((value: string) => value.toLowerCase() === host)) continue + items.push(host) + } + + process.env[key] = items.join(",") + } + + upsert("NO_PROXY") + upsert("no_proxy") +} + function useSystemCertificates() { try { const nodeTls = tls as NodeTlsWithSystemCertificates From b969387f8d3ec9b02c1721ac841d3385c9a189c1 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Wed, 6 May 2026 14:16:25 +0800 Subject: [PATCH 4/4] cleanup --- packages/desktop/src/main/env.d.ts | 9 --------- packages/desktop/src/main/index.ts | 12 ++---------- 2 files changed, 2 insertions(+), 19 deletions(-) diff --git a/packages/desktop/src/main/env.d.ts b/packages/desktop/src/main/env.d.ts index 0a715610d42..eee21e48cb1 100644 --- a/packages/desktop/src/main/env.d.ts +++ b/packages/desktop/src/main/env.d.ts @@ -6,15 +6,6 @@ interface ImportMeta { readonly env: ImportMetaEnv } -declare namespace NodeJS { - interface Process { - readonly parentPort?: { - postMessage(message: unknown): void - on(event: "message", listener: (event: { data: unknown; ports: unknown[] }) => void): void - } - } -} - declare module "virtual:opencode-server" { export namespace Server { export const listen: typeof import("../../../opencode/dist/types/src/node").Server.listen diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index e41324df6a8..9582133ac95 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -5,7 +5,7 @@ import * as http from "node:http" import { createServer } from "node:net" import { homedir } from "node:os" import { join } from "node:path" -import * as tls from "node:tls" +import { getCACertificates, setDefaultCACertificates } from "node:tls" import type { Event } from "electron" import { app, BrowserWindow, dialog } from "electron" import pkg from "electron-updater" @@ -36,11 +36,6 @@ app.setAppUserModelId(appId) app.setPath("userData", join(app.getPath("appData"), appId)) const { autoUpdater } = pkg -type NodeTlsWithSystemCertificates = typeof tls & { - getCACertificates: (type: "default" | "system") => string[] - setDefaultCACertificates: (certificates: string[]) => void -} - import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types" import { checkAppExists, resolveAppPath, wslPath } from "./apps" import { CHANNEL, UPDATER_ENABLED } from "./constants" @@ -138,10 +133,7 @@ function setupApp() { function useSystemCertificates() { try { - const nodeTls = tls as NodeTlsWithSystemCertificates - nodeTls.setDefaultCACertificates([ - ...new Set([...nodeTls.getCACertificates("default"), ...nodeTls.getCACertificates("system")]), - ]) + setDefaultCACertificates([...new Set([...getCACertificates("default"), ...getCACertificates("system")])]) } catch (error) { logger.warn("failed to load system certificates", error) }