From a6fe14a8d60ae5bfd83529b36bead34b8b8c3348 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Sun, 9 Aug 2026 00:02:49 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20OpenCode=20?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=97=B6=E5=90=AF=E5=8A=A8=E4=B8=8E=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E6=89=93=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../20260808-opencode-serve-error-b71c.md | 74 ++ README.md | 4 +- electron-builder.yml | 1 + electron/api/route-utils.ts | 18 +- electron/api/routes/opencode.ts | 2 +- electron/api/server.ts | 2 +- electron/main/index.ts | 13 +- electron/opencode/manager.ts | 623 ++++++++++++-- electron/opencode/playwright-mcp.ts | 22 +- electron/utils/python-runtime.ts | 84 +- package.json | 13 +- pnpm-lock.yaml | 6 +- scripts/bundle-opencode.mjs | 90 +- scripts/verify-packaged-electron-runtime.mjs | 133 ++- src/stores/opencode.ts | 2 +- tests/unit/opencode-manager.test.ts | 769 +++++++++++++++++- tests/unit/opencode-routes.test.ts | 19 + tests/unit/playwright-mcp.test.ts | 10 + tests/unit/python-runtime.test.ts | 63 +- tests/unit/route-utils.test.ts | 53 ++ 20 files changed, 1847 insertions(+), 154 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260808-opencode-serve-error-b71c.md create mode 100644 tests/unit/playwright-mcp.test.ts create mode 100644 tests/unit/route-utils.test.ts diff --git a/.project-docs/30-worklog/tasks/20260808-opencode-serve-error-b71c.md b/.project-docs/30-worklog/tasks/20260808-opencode-serve-error-b71c.md new file mode 100644 index 0000000..6513fbe --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260808-opencode-serve-error-b71c.md @@ -0,0 +1,74 @@ +# Task: Fix installed OpenCode ServeError and package runtime locally + +## Identity + +- Task ID: 20260808-opencode-serve-error-b71c +- Mode: Feature +- Branch: main +- Worktree: D:\Datas\OthersProjects\makelore +- Base commit: d092132d86b8366c948a76cc659a787bb10a65a3 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Diagnose and fix the installed Makelore 1.0.0 OpenCode runtime startup failure on Windows in the current 2.0.0 source tree. +- Verify the packaged-runtime requirement that OpenCode and its required runtime dependencies resolve from the Makelore installation directory. +- Correlate Makelore/OpenCode logs, live process and TCP state, an isolated command-line reproduction, and the repository runtime-manager implementation. +- Add regression coverage for an unhealthy occupied preferred port and for process-exit/port-release ordering. +- Make packaged builds fail verification if OpenCode or Playwright MCP cannot execute from installation-local resources; do not introduce a system `PATH`, npm, or npx fallback. +- Do not terminate the user's Makelore, project preview, or background development processes, and do not mutate user runtime data. + +## Intent And Constraints + +- Keep confirmed facts separate from likely mechanisms and unverified assumptions. +- Do not expose provider tokens or other secrets while inspecting runtime diagnostics. +- Preserve the product boundary: Electron Main owns OpenCode startup, runtime paths, configuration, and lifecycle. +- Treat the installed 1.0.0 artifact and the 2.0.0 repository as distinct builds; verify shared lifecycle behavior against the installed minified Main bundle before attributing repository code. + +## Outcome + +- Confirmed that the installed runtime is `D:\Tools\泥土\niancode\Makelore\resources\opencode-ai\bin\opencode.exe` (`opencode-ai` 1.18.9, Windows x64 baseline) and that it runs successfully from the Unicode installation path without external Node or Bun on `PATH`. +- Confirmed that the incident is not a missing executable or Unicode-path failure. The old artifact already launched OpenCode from its installation tree, but its packaging contract still allowed an `npx` MCP fallback, a packaged-Python override, and a system/missing uv path. +- Reconstructed the failure: the old runtime PID 33188 stopped at 20:54:45 local time, provider-triggered restart reported that port 4096 had not been released at 20:54:48, and retries at 20:55:02 and 20:55:05 exited with `Unexpected error / ServeError`. +- Confirmed live that `127.0.0.1:4096` remains in `LISTEN` with recorded owner PID 33188 even though that PID is absent from Process, CIM, and tasklist enumeration. TCP connects but the endpoint does not answer HTTP, so it is not a healthy attachable OpenCode server. +- Reproduced the exact `Unexpected error / ServeError` and exit code 1 by occupying an isolated port before launching the same installed binary; the same binary and Makelore XDG data start successfully on a free isolated port. +- Confirmed in both repository source and the installed minified Main bundle that the child `exit` handler clears `proc` and publishes `stopped` before `stop()` has established port release. A release timeout then leaves the manager in a stopped/no-proc state, and a later start retries the fixed occupied port. +- A background `node.exe dev-server.js` process created through OpenCode at 20:13:04 remains alive after its parent disappeared. Inherited socket ownership is a high-confidence explanation for the ghost listener, but handle inheritance was not directly proven and remains an inference. +- Changed the manager to probe the preferred port before launch, use OpenCode's `--port=0` fallback for an unhealthy occupied listener, parse and publish the actual listening URL/port, and use the actual port for health, stop, restart, API, CORS, and renderer status. +- Changed stop/restart ordering so a managed child is not reported stopped until both process exit and port release are established. If an inherited ghost listener remains, restart records the release failure and starts a replacement on an OS-selected loopback port. +- Serialized start/stop/restart in FIFO order, made queued stops cancel older starts, isolated late callbacks by runtime generation, and retained every known unreleased port across generations. This prevents late exits, startup timeouts, or a dynamic-port replacement from losing ownership of an older ghost listener. +- Removed the production `npx` Playwright MCP fallback, made packaged Python and uv resolve from `resources`, injects their absolute paths at the front of the runtime environment, and makes missing packaged resources fail closed. The inherited project tool `PATH` remains available after the managed directories. +- Pinned `opencode-ai` to 1.18.9, staged only the selected self-contained executable, removed duplicate Python/OpenCode payloads, and extended packaged-artifact verification across OpenCode, MCP, Python, and uv. +- Made the Windows packaging path stage OpenCode explicitly for win32/x64 and added PE Machine checks for Makelore, OpenCode, Python, and uv so an ARM64 build host cannot silently produce a mixed-architecture x64 installer. +- Built `release\Makelore-2.0.0-win-x64.exe`; the installed 1.0.0 client was intentionally not overwritten while it or its project preview may be active. + +## Verification + +- The regression tests first failed on fixed-port startup and premature `stopped`, then passed after the implementation. Final focused run: 5 files / 147 tests passed, including FIFO lifecycle ordering, start cancellation, late/stale exits, attached-process termination failure, and cross-generation ghost ports. +- `pnpm run typecheck`: passed. +- Scoped ESLint on every changed TypeScript/JavaScript file: passed. +- `pnpm run build:vite`: passed. +- `pnpm install --frozen-lockfile`: passed. +- `pnpm run bundle:opencode`: passed; staged OpenCode reports exactly 1.18.9 with a sanitized system-only `PATH`. +- Windows packaging via `node scripts/run-electron-builder.mjs --win --publish never`: passed. +- Packaged-artifact verifier: passed for Electron-as-Node MCP execution, OpenCode version/hash/path, isolated Python module origins, and local uv 0.10.0. +- New unpacked layout contains `resources\opencode-ai\bin\opencode.exe`, `resources\python`, and `resources\bin\uv.exe`; it has no nested OpenCode `node_modules`, duplicate Python tree, or unpacked OpenCode architecture copies. +- Real packaged-runtime smoke test used isolated XDG directories, listened on OS-selected port 60604, listed sessions, and created a session successfully; the process stopped and the port was confirmed released. +- Installer: 219,859,508 bytes; SHA-256 `95E4A644E5BCB584D4C9EB6127F00626CA0AB096FC4E36DB7126CE715E46599C`. +- Full repository lint remains red only in pre-existing unrelated `MainLayout.tsx` and `chat-command-dialogs.test.tsx` findings. Full tests remain red in 19 unrelated environment/baseline cases (`zip` unavailable, missing test fixture, project-progress baseline, and image-canvas auth baseline); all changed focused suites pass. + +## Follow-ups + +- Install the newly built package only after saving work and fully exiting the old Makelore client. The fix no longer requires clearing the ghost 4096 listener, but a reboot remains the cleanest way to remove that inherited Windows listener if it persists. +- The verified locality contract covers Makelore's built-in OpenCode runtime chain: OpenCode, Electron-as-Node Playwright MCP code, Python, and uv. User-selected browsers and arbitrary tools launched by a project or Agent remain explicit external prerequisites and are not claimed as bundled runtime dependencies. +- Only Windows x64 packaging was built and smoke-tested in this task. Cross-platform and architecture-specific staging should receive separate artifact verification before making an equivalent macOS or Linux claim. + +## Promotion Candidates + +- Target: canonical OpenCode runtime lifecycle architecture and verification guidance. + - Proposal: define `stopped` as both process-exited and managed-port-released; require a Windows ghost-listener regression and packaged-runtime path verification for every release. + - Evidence: installed-client timeline, live TCP/process mismatch, exact occupied-port reproduction, and the matching manager exit/stop ordering. + - Future impact: prevents provider changes or runtime restarts from permanently wedging AI programming on fixed port 4096. + - Semantic conflicts: none identified. + - Human confirmation required: no; the user requested the runtime fix and local packaging behavior. diff --git a/README.md b/README.md index 5edee8f..d71181f 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Makelore 是一个面向软件与视觉创作的 AI 桌面工作台。当前版 ## 当前产品状态 - 桌面技术栈:Electron、React 19、Vite、TypeScript、Zustand、Tailwind CSS。 -- AI 编程运行时:Electron Main 管理项目内声明的 `opencode-ai` 依赖,Renderer 不直接启动或调用运行时。 +- AI 编程运行时:Electron Main 只启动安装目录 `resources/opencode-ai/bin/` 中固定版本的 OpenCode;Renderer 不直接启动或调用运行时。首选回环端口被不健康进程占用时,Main 会改用系统分配的临时端口,并把实际 URL 贯穿到所有运行时请求。 - 共享开发浏览器:AI 编程右侧提供项目级浏览器,用户与 Agent 查看并调试同一实时页面、Console 和 Network,支持本地与公网开发地址。 - 真机预览:AI 编程项目空间可读取当前服务端构建状态;可运行版本生成同源 HTTPS 二维码,供手机直接测试触控、屏幕和移动网络环境。 - 后端边界:Renderer 通过 Main 所有的 Host API 访问认证、模型、同步、更新、语音、图像与运行时能力。 @@ -61,7 +61,7 @@ pnpm run package:win pnpm run package:linux ``` -各平台打包脚本会先准备目标平台所需的 Python 与运行时资源,产物写入忽略的 `release/` 目录。 +Windows 打包脚本会先准备目标架构所需的 Python、uv 与 OpenCode 运行时资源,产物写入忽略的 `release/` 目录。Windows 正式包中的 OpenCode、Playwright MCP、Python 和 uv 均从安装目录解析;缺少本地资源时启动或产物验证会直接失败,不会回退到系统 Python、npm 或 npx 下载。Git、项目编译器和用户选择的浏览器仍属于项目/系统工具,不属于内置 OpenCode 运行时。macOS 与 Linux 的双架构产物需要分别完成对应架构的 staging 与产物验证后再发布。 ## 代码结构 diff --git a/electron-builder.yml b/electron-builder.yml index 895777e..50fe870 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -21,6 +21,7 @@ extraResources: - "!icons/*.md" - "!icons/*.svg" - "!bin/**" + - "!python/**" - "!screenshot/**" # opencode runtime bundled by scripts/bundle-opencode.mjs. - from: build/opencode-ai/ diff --git a/electron/api/route-utils.ts b/electron/api/route-utils.ts index 4fe1e22..4c63505 100644 --- a/electron/api/route-utils.ts +++ b/electron/api/route-utils.ts @@ -8,8 +8,6 @@ import { PORTS } from '../utils/config'; const ALLOWED_ORIGINS = new Set([ `http://127.0.0.1:${PORTS.NIANCODE_DEV}`, `http://localhost:${PORTS.NIANCODE_DEV}`, - `http://127.0.0.1:${PORTS.OPENCODE_RUNTIME}`, - `http://localhost:${PORTS.OPENCODE_RUNTIME}`, ]); export async function parseJsonBody(req: IncomingMessage): Promise { @@ -43,11 +41,23 @@ export function requireJsonContentType(req: IncomingMessage): boolean { return ct.includes('application/json'); } -export function setCorsHeaders(res: ServerResponse, origin?: string): void { +export function setCorsHeaders( + res: ServerResponse, + origin?: string, + runtimeUrl?: string, +): void { // Only reflect the Origin header back if it is in the allow-list. // Omitting the header for unknown origins causes the browser to block // the response — this is the intended behavior for untrusted callers. - if (origin && ALLOWED_ORIGINS.has(origin)) { + let runtimeOrigin: string | null = null; + if (runtimeUrl) { + try { + runtimeOrigin = new URL(runtimeUrl).origin; + } catch { + runtimeOrigin = null; + } + } + if (origin && (ALLOWED_ORIGINS.has(origin) || origin === runtimeOrigin)) { res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Vary', 'Origin'); } diff --git a/electron/api/routes/opencode.ts b/electron/api/routes/opencode.ts index 6888c2a..7561915 100644 --- a/electron/api/routes/opencode.ts +++ b/electron/api/routes/opencode.ts @@ -1770,7 +1770,7 @@ export async function handleOpencodeRoutes( if (url.pathname === '/api/opencode/stop' && req.method === 'POST') { try { await ctx.opencodeManager.stop(); - sendJson(res, 200, { success: true }); + sendJson(res, 200, { success: true, status: ctx.opencodeManager.getStatus() }); } catch (error) { sendJson(res, 500, { success: false, error: String(error) }); } diff --git a/electron/api/server.ts b/electron/api/server.ts index 96d1534..5481e6d 100644 --- a/electron/api/server.ts +++ b/electron/api/server.ts @@ -76,7 +76,7 @@ export function startHostApiServer(ctx: HostApiContext, port = getPort('NIANCODE // Set origin-aware CORS headers early so every response // (including error responses) carries them consistently. const origin = req.headers.origin; - setCorsHeaders(res, origin); + setCorsHeaders(res, origin, ctx.opencodeManager.getStatus().url); // CORS preflight — respond before auth so browsers can negotiate. if (req.method === 'OPTIONS') { diff --git a/electron/main/index.ts b/electron/main/index.ts index 31552e6..a72b11d 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -30,7 +30,7 @@ import { registerZoomShortcuts } from './zoom-shortcuts'; import { appUpdater, registerUpdateHandlers } from './updater'; import { logger } from '../utils/logger'; import { warmupNetworkOptimization } from '../utils/uv-env'; -import { resolvePythonRuntime } from '../utils/python-runtime'; +import { resolvePythonRuntime, resolveUvRuntime } from '../utils/python-runtime'; import { initTelemetry } from '../utils/telemetry'; import { getOrCreateAgentGatewaySessionClientId, @@ -578,6 +578,13 @@ if (gotTheLock) { arch: process.arch, configuredPath: process.env.NIANCODE_PYTHON_PATH, }); + const uvRuntime = resolveUvRuntime({ + isPackaged: app.isPackaged, + resourcesPath: process.resourcesPath, + appPath: app.getAppPath(), + platform: process.platform, + arch: process.arch, + }); const bundledSuperpowersDir = resolveBundledSuperpowersDir({ isPackaged: app.isPackaged, resourcesPath: process.resourcesPath, @@ -594,13 +601,15 @@ if (gotTheLock) { appPath: app.getAppPath(), }); opencodeManager = new OpencodeManager({ - port: 4096, + port: getPort('OPENCODE_RUNTIME'), binPath: opencodePaths.binPath, + preflightPreferredPort: true, userDataDir: app.getPath('userData'), bundledSuperpowersDir, bundledCourseSkillsDir, bundledAgentBrowserPluginPath, pythonRuntime, + uvRuntime, runtimeConfigProvider: async () => { const runtime = await buildOpencodeRuntimeConfigFromNianCodeProviders({ mcpServers: { diff --git a/electron/opencode/manager.ts b/electron/opencode/manager.ts index 036e021..434ddf1 100644 --- a/electron/opencode/manager.ts +++ b/electron/opencode/manager.ts @@ -1,5 +1,6 @@ import { EventEmitter } from 'node:events'; import { mkdirSync } from 'node:fs'; +import { createServer } from 'node:net'; import { join } from 'node:path'; import { execFile, @@ -15,8 +16,9 @@ import { } from './superpowers'; import { logger } from '../utils/logger'; import { - prependPythonToPath, + prependManagedRuntimesToPath, type PythonRuntime, + type UvRuntime, } from '../utils/python-runtime'; import { promisify } from 'node:util'; @@ -166,10 +168,12 @@ export interface OpencodeManagerOptions { bundledCourseSkillsDir?: string; bundledAgentBrowserPluginPath?: string; pythonRuntime?: PythonRuntime; + uvRuntime?: UvRuntime; spawn?: SpawnFn; configProvider?: ConfigProvider; runtimeConfigProvider?: RuntimeConfigProvider; startupTimeoutMs?: number; + preflightPreferredPort?: boolean; findPortOwner?: FindPortOwner; killProcess?: KillProcess; } @@ -178,12 +182,108 @@ const execFileAsync = promisify(execFile); const ATTACHED_SERVER_STOP_TIMEOUT_MS = 2_000; const ATTACHED_SERVER_STOP_POLL_MS = 100; +class OpencodePortReleaseError extends Error { + constructor(readonly port: number) { + super(`Timed out waiting for opencode port ${port} to be released`); + this.name = 'OpencodePortReleaseError'; + } +} + +class OpencodeStartCancelledError extends Error { + constructor() { + super('opencode startup was stopped'); + this.name = 'OpencodeStartCancelledError'; + } +} + +type RuntimeStartAttempt = { + generation: number; + requestSequence: number; + controller: AbortController; +}; + +function waitForAbortable(promise: Promise, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(new OpencodeStartCancelledError()); + + return new Promise((resolve, reject) => { + const onAbort = () => reject(new OpencodeStartCancelledError()); + signal.addEventListener('abort', onAbort, { once: true }); + void promise.then( + (value) => { + signal.removeEventListener('abort', onAbort); + resolve(value); + }, + (error) => { + signal.removeEventListener('abort', onAbort); + reject(error); + }, + ); + }); +} + function sleep(ms: number): Promise { return new Promise((resolve) => { setTimeout(resolve, ms); }); } +function isLoopbackPortAvailable(port: number): Promise { + if (!Number.isInteger(port) || port <= 0) return Promise.resolve(true); + + return new Promise((resolve) => { + const server = createServer(); + let settled = false; + const finish = (available: boolean) => { + if (settled) return; + settled = true; + if (server.listening) { + server.close(() => resolve(available)); + } else { + resolve(available); + } + }; + server.once('error', () => finish(false)); + server.listen({ host: '127.0.0.1', port, exclusive: true }, () => finish(true)); + }); +} + +function parseRuntimePort(url: string): number | null { + try { + const port = Number(new URL(url).port); + return Number.isInteger(port) && port > 0 && port <= 65_535 ? port : null; + } catch { + return null; + } +} + +function mergeProcessEnvironment( + overrides: Record, + platform: NodeJS.Platform = process.platform, +): Record { + const environment: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (typeof value === 'string') environment[key] = value; + } + + if (platform === 'win32') { + const inheritedPath = Object.entries(environment).find(([key]) => key.toLowerCase() === 'path'); + for (const key of Object.keys(environment)) { + if (key.toLowerCase() === 'path') delete environment[key]; + } + if (inheritedPath) environment[inheritedPath[0]] = inheritedPath[1]; + } + + for (const [key, value] of Object.entries(overrides)) { + if (platform === 'win32' && key.toLowerCase() === 'path') { + for (const existingKey of Object.keys(environment)) { + if (existingKey.toLowerCase() === 'path') delete environment[existingKey]; + } + } + environment[key] = value; + } + return environment; +} + function normalizeComparablePath(value: string | undefined): string | null { const trimmed = value?.trim(); if (!trimmed) return null; @@ -297,7 +397,16 @@ export class OpencodeManager extends EventEmitter { private readonly killProcess: KillProcess; private readonly startupTimeoutMs: number; private proc: ChildProcess | null = null; - private startPromise: Promise | null = null; + private lifecycleSequence = 0; + private cancelStartsBeforeSequence = 0; + private runtimeGeneration = 0; + private activeRuntimeGeneration: number | null = null; + private activeStartAttempt: RuntimeStartAttempt | null = null; + private readonly trackedUnreleasedPorts = new Set(); + private lifecycleBusy = false; + private readonly lifecycleQueue: Array<() => void> = []; + private exitCleanupPromise: Promise | null = null; + private readonly ignoredExitProcesses = new WeakSet(); private status: OpencodeStatus; constructor(private readonly options: OpencodeManagerOptions) { @@ -320,40 +429,126 @@ export class OpencodeManager extends EventEmitter { return userDataDir ? getManagedOpencodeConfigDir(userDataDir) : null; } - async start(): Promise { - if (this.status.state === 'running') return this.getStatus(); - if (this.startPromise) return await this.startPromise; + private enqueueLifecycle(operation: () => Promise): Promise { + return new Promise((resolve, reject) => { + const run = () => { + this.lifecycleBusy = true; + const finish = () => { + this.lifecycleBusy = false; + this.lifecycleQueue.shift()?.(); + }; + let result: Promise; + try { + result = operation(); + } catch (error) { + reject(error); + finish(); + return; + } + void result.then( + (value) => { + resolve(value); + finish(); + }, + (error) => { + reject(error); + finish(); + }, + ); + }; - this.startPromise = this.startRuntime(); - try { - return await this.startPromise; - } finally { - this.startPromise = null; - } + if (this.lifecycleBusy) { + this.lifecycleQueue.push(run); + } else { + run(); + } + }); + } + + async start(): Promise { + const requestSequence = ++this.lifecycleSequence; + return await this.enqueueLifecycle(async () => { + this.assertStartAllowed(requestSequence); + if (this.status.state === 'running') return this.getStatus(); + if (this.proc) await this.stopRuntime(); + return await this.startRuntime(requestSequence); + }); } async stop(): Promise { + const requestSequence = ++this.lifecycleSequence; + this.cancelEarlierStarts(requestSequence); + await this.enqueueLifecycle(async () => { + await this.stopRuntime(); + }); + } + + private async stopRuntime(): Promise { const proc = this.proc; - if (proc) { - if (!proc.kill()) { - throw new Error(`Failed to stop opencode process ${proc.pid ?? 'unknown'}`); - } - if (typeof proc.pid === 'number') { - const released = await this.waitForPortOwnerToRelease(proc.pid); - if (!released) { - throw new Error(`Timed out waiting for opencode port ${this.options.port} to be released`); + const generation = this.activeRuntimeGeneration; + const port = this.status.port > 0 ? this.status.port : this.options.port; + try { + if (proc) { + const exitPromise = this.waitForProcessExit(proc); + this.ignoredExitProcesses.add(proc); + if (!proc.kill()) { + this.ignoredExitProcesses.delete(proc); + throw new Error(`Failed to stop opencode process ${proc.pid ?? 'unknown'}`); } + if (typeof proc.pid === 'number') { + const [exited, released] = await Promise.all([ + exitPromise, + this.waitForPortToBeReleased(port), + ]); + this.recordPortReleaseOutcome(port, exited, released); + if (!exited) { + throw new Error(`Timed out waiting for opencode process ${proc.pid} to exit`); + } + if (!released) { + throw new OpencodePortReleaseError(port); + } + } + } else { + await this.stopAttachedServerIfManaged(port); } - } else { - await this.stopAttachedServerIfManaged(); + + await this.verifyTrackedPortsReleased(); + + if (this.proc === proc) { + this.proc = null; + } + if (this.activeRuntimeGeneration === generation) { + this.activeRuntimeGeneration = null; + } + this.setStatus({ state: 'stopped', port: this.options.port }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const errorPort = error instanceof OpencodePortReleaseError ? error.port : port; + this.setStatus({ + state: 'error', + port: errorPort, + ...(typeof proc?.pid === 'number' ? { pid: proc.pid } : {}), + error: message, + }); + throw error; } - this.proc = null; - this.setStatus({ state: 'stopped', port: this.options.port }); } async restart(): Promise { - await this.stop(); - return await this.start(); + const requestSequence = ++this.lifecycleSequence; + this.cancelEarlierStarts(requestSequence); + return await this.enqueueLifecycle(async () => { + try { + await this.stopRuntime(); + } catch (error) { + if (!(error instanceof OpencodePortReleaseError)) throw error; + logger.warn('[opencode-runtime] Restarting on another loopback port after release timeout', { + port: error.port, + }); + } + this.assertStartAllowed(requestSequence); + return await this.startRuntime(requestSequence); + }); } async checkHealth(): Promise<{ ok: boolean; status: OpencodeStatus }> { @@ -363,24 +558,45 @@ export class OpencodeManager extends EventEmitter { }; } - private async startRuntime(): Promise { - this.setStatus({ state: 'starting', port: this.options.port }); + private async startRuntime( + requestSequence: number, + requestedPort?: number, + allowPortFallback = true, + ): Promise { + const attempt = this.beginStartAttempt(requestSequence); + try { + let runtimePort = requestedPort ?? this.options.port; + if ( + requestedPort === undefined + && (this.options.preflightPreferredPort || this.options.findPortOwner) + ) { + const preferredPort = await waitForAbortable( + this.inspectPreferredPort(), + attempt.controller.signal, + ); + if (preferredPort.occupied && !preferredPort.existingServer) { + runtimePort = 0; + this.logPortFallback(); + } + } + this.setStatus({ state: 'starting', port: runtimePort }); const providedRuntimeConfig = this.resolveRuntimeConfig(); const runtimeConfig = providedRuntimeConfig && typeof (providedRuntimeConfig as Promise).then === 'function' - ? await providedRuntimeConfig + ? await waitForAbortable(providedRuntimeConfig, attempt.controller.signal) : providedRuntimeConfig; - const runtimeArgs = ['serve', '--hostname=127.0.0.1', `--port=${this.options.port}`]; + this.assertStartAllowed(requestSequence); + if (attempt.controller.signal.aborted) throw new OpencodeStartCancelledError(); + const runtimeArgs = ['serve', '--hostname=127.0.0.1', `--port=${runtimePort}`]; const runtimeEnv = this.resolveRuntimeEnv(runtimeConfig.env); const spawnCommand = this.resolveSpawnCommand(runtimeArgs, runtimeEnv); logger.info('[opencode-runtime] Starting runtime with provider config', { - port: this.options.port, + port: runtimePort, command: spawnCommand.command, args: spawnCommand.args, ...summarizeRuntimeConfigForDiagnostics(runtimeConfig.config, runtimeConfig.env), }); const childEnv = { - ...process.env, ...spawnCommand.env, OPENCODE_CONFIG_CONTENT: JSON.stringify(runtimeConfig.config), }; @@ -399,6 +615,8 @@ export class OpencodeManager extends EventEmitter { return await new Promise((resolve, reject) => { let settled = false; let stderr = ''; + let stdout = ''; + let abortStartup = () => undefined; const appendStderr = (message: string) => { stderr = `${stderr}${message}`; if (stderr.length > 8_000) { @@ -409,31 +627,68 @@ export class OpencodeManager extends EventEmitter { const detail = stderr.trim(); return detail ? `${message}\n${detail}` : message; }; - const finish = (callback: () => void) => { - if (settled) return; + const claimSettlement = (): boolean => { + if (settled) return false; settled = true; clearTimeout(timer); + attempt.controller.signal.removeEventListener('abort', abortStartup); + return true; + }; + const finish = (callback: () => void) => { + if (!claimSettlement()) return; callback(); }; + const terminateStartup = async () => { + this.ignoredExitProcesses.add(proc); + const exitPromise = this.waitForProcessExit(proc); + proc.kill(); + const [exited, released] = await Promise.all([ + typeof proc.pid === 'number' ? exitPromise : Promise.resolve(true), + this.waitForPortToBeReleased(runtimePort), + ]); + if (exited && this.proc === proc) this.proc = null; + if (this.isActiveGeneration(attempt.generation)) { + this.recordPortReleaseOutcome(runtimePort, exited, released); + } + }; const timer = setTimeout(() => { - finish(() => { - const error = formatStartupError(`Timed out waiting for opencode server after ${this.startupTimeoutMs}ms`); - this.setStatus({ state: 'error', port: this.options.port, error }); - proc.kill(); - reject(new Error(error)); - }); + if (!claimSettlement()) return; + const error = formatStartupError(`Timed out waiting for opencode server after ${this.startupTimeoutMs}ms`); + if (this.isActiveGeneration(attempt.generation)) { + this.setStatus({ state: 'error', port: runtimePort, error }); + } + void terminateStartup().then( + () => reject(new Error(error)), + reject, + ); }, this.startupTimeoutMs); + abortStartup = () => { + if (!claimSettlement()) return; + void terminateStartup().then( + () => reject(new OpencodeStartCancelledError()), + reject, + ); + }; + attempt.controller.signal.addEventListener('abort', abortStartup, { once: true }); + if (attempt.controller.signal.aborted) abortStartup(); + proc.stdout?.on('data', (chunk: Buffer) => { - const text = chunk.toString(); - const match = text.match(/opencode server listening\s+on\s+(https?:\/\/[^\s]+)/); + if (!this.isActiveGeneration(attempt.generation) || this.proc !== proc) return; + stdout = `${stdout}${chunk.toString()}`; + if (stdout.length > 8_000) { + stdout = stdout.slice(-8_000); + } + const match = stdout.match(/opencode server listening\s+on\s+(https?:\/\/[^\s]+)/); if (!match) return; + const listeningPort = parseRuntimePort(match[1]); + if (!listeningPort) return; finish(() => { this.setStatus({ state: 'running', - port: this.options.port, + port: listeningPort, url: match[1], pid: proc.pid, startedAt: Date.now(), @@ -443,6 +698,7 @@ export class OpencodeManager extends EventEmitter { }); proc.stderr?.on('data', (chunk: Buffer) => { + if (!this.isActiveGeneration(attempt.generation)) return; const message = chunk.toString(); appendStderr(message); this.emit('stderr', message); @@ -450,23 +706,36 @@ export class OpencodeManager extends EventEmitter { proc.on('error', (error) => { finish(() => { - this.proc = null; - this.setStatus({ state: 'error', port: this.options.port, error: error.message }); + if (this.proc === proc) this.proc = null; + this.ignoredExitProcesses.add(proc); + if (this.isActiveGeneration(attempt.generation)) { + this.setStatus({ state: 'error', port: runtimePort, error: error.message }); + } reject(error); }); }); proc.on('exit', (code) => { - this.proc = null; + if (this.proc === proc) this.proc = null; if (!settled) { clearTimeout(timer); - void this.findExistingServer().then((existingServer) => { + if (this.ignoredExitProcesses.has(proc)) { + finish(() => reject(new Error('opencode startup was stopped'))); + return; + } + + void this.inspectRuntimePort(runtimePort, true).then((portState) => { + if (!this.isActiveGeneration(attempt.generation)) { + finish(() => reject(new OpencodeStartCancelledError())); + return; + } + const existingServer = portState.existingServer; if (existingServer) { finish(() => { logger.warn( '[opencode-runtime] Attached to an existing server; newly generated provider config may not be active', { - port: this.options.port, + port: runtimePort, url: existingServer.url, }, ); @@ -476,30 +745,135 @@ export class OpencodeManager extends EventEmitter { return; } + if ( + allowPortFallback + && runtimePort === this.options.port + && portState.occupied + ) { + if (settled) return; + settled = true; + clearTimeout(timer); + this.logPortFallback(); + void this.startRuntime(requestSequence, 0, false).then(resolve, reject); + return; + } + finish(() => { const error = formatStartupError(code == null ? 'opencode process exited' : `Exited with code ${code}`); - this.setStatus({ state: 'error', port: this.options.port, error }); + if (this.isActiveGeneration(attempt.generation)) { + this.setStatus({ state: 'error', port: runtimePort, error }); + } reject(new Error(error)); }); }); return; } - if (this.status.state !== 'stopped') { - this.setStatus({ - state: 'stopped', - port: this.options.port, - error: code == null ? undefined : `Exited with code ${code}`, - }); - } + if (this.ignoredExitProcesses.has(proc)) return; + this.beginUnexpectedExitCleanup( + proc, + code, + this.isActiveGeneration(attempt.generation) ? this.status.port : runtimePort, + attempt.generation, + ); }); }); + } catch (error) { + if ( + !(error instanceof OpencodeStartCancelledError) + && this.isActiveGeneration(attempt.generation) + && this.status.state === 'starting' + ) { + this.setStatus({ + state: 'error', + port: this.status.port, + error: error instanceof Error ? error.message : String(error), + }); + } + throw error; + } finally { + if (this.activeStartAttempt === attempt) { + this.activeStartAttempt = null; + } + } } - private async findExistingServer(): Promise { + private beginStartAttempt(requestSequence: number): RuntimeStartAttempt { + this.assertStartAllowed(requestSequence); + const attempt = { + generation: ++this.runtimeGeneration, + requestSequence, + controller: new AbortController(), + }; + this.activeRuntimeGeneration = attempt.generation; + this.activeStartAttempt = attempt; + return attempt; + } + + private cancelEarlierStarts(requestSequence: number): void { + this.cancelStartsBeforeSequence = Math.max( + this.cancelStartsBeforeSequence, + requestSequence, + ); + if ( + this.activeStartAttempt + && this.activeStartAttempt.requestSequence < requestSequence + ) { + this.activeStartAttempt.controller.abort(); + } + } + + private assertStartAllowed(requestSequence: number): void { + if (requestSequence < this.cancelStartsBeforeSequence) { + throw new OpencodeStartCancelledError(); + } + } + + private isActiveGeneration(generation: number): boolean { + return this.activeRuntimeGeneration === generation; + } + + private async inspectPreferredPort(): Promise<{ + occupied: boolean; + existingServer: OpencodeStatus | null; + }> { + return await this.inspectRuntimePort(this.options.port); + } + + private async inspectRuntimePort(port: number, checkHealthFirst = false): Promise<{ + occupied: boolean; + existingServer: OpencodeStatus | null; + }> { + if (port <= 0) return { occupied: false, existingServer: null }; + + if (checkHealthFirst) { + const existingServer = await this.findExistingServer(port); + if (existingServer) return { occupied: true, existingServer }; + } + + if (!this.options.findPortOwner) { + const available = await isLoopbackPortAvailable(port); + if (available) return { occupied: false, existingServer: null }; + return { + occupied: true, + existingServer: await this.findExistingServer(port), + }; + } + + if (!checkHealthFirst) { + const existingServer = await this.findExistingServer(port); + if (existingServer) return { occupied: true, existingServer }; + } + return { + occupied: Boolean(await this.findPortOwner(port)), + existingServer: null, + }; + } + + private async findExistingServer(port: number): Promise { if (typeof globalThis.fetch !== 'function') return null; - const url = `http://127.0.0.1:${this.options.port}`; + const url = `http://127.0.0.1:${port}`; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), Math.min(this.startupTimeoutMs, 1_000)); @@ -515,7 +889,7 @@ export class OpencodeManager extends EventEmitter { return { state: 'running', - port: this.options.port, + port, url, startedAt: Date.now(), }; @@ -526,42 +900,143 @@ export class OpencodeManager extends EventEmitter { } } - private async stopAttachedServerIfManaged(): Promise { - const owner = await this.findPortOwner(this.options.port); + private async stopAttachedServerIfManaged(port: number): Promise { + const owner = await this.findPortOwner(port); if (!owner || !isManagedOpencodePortOwner(owner, this.options.binPath)) { return; } if (!this.killProcess(owner.pid)) { + this.trackedUnreleasedPorts.add(port); throw new Error(`Failed to stop attached opencode process ${owner.pid}`); } logger.warn('[opencode-runtime] Stopped attached bundled opencode server on managed port', { - port: this.options.port, + port, pid: owner.pid, }); - const released = await this.waitForPortOwnerToRelease(owner.pid); + const released = await this.waitForPortToBeReleased(port); if (!released) { - throw new Error(`Timed out waiting for opencode port ${this.options.port} to be released`); + this.trackedUnreleasedPorts.add(port); + throw new OpencodePortReleaseError(port); } + this.trackedUnreleasedPorts.delete(port); } - private async waitForPortOwnerToRelease(pid: number): Promise { + private async waitForPortToBeReleased(port: number): Promise { + if (port <= 0) return true; const deadline = Date.now() + ATTACHED_SERVER_STOP_TIMEOUT_MS; while (Date.now() < deadline) { - const owner = await this.findPortOwner(this.options.port); - if (!owner || owner.pid !== pid) return true; + const released = await this.isPortReleased(port); + if (released) return true; await sleep(ATTACHED_SERVER_STOP_POLL_MS); } return false; } + private async isPortReleased(port: number): Promise { + if (port <= 0) return true; + return this.options.findPortOwner + ? !await this.findPortOwner(port) + : await isLoopbackPortAvailable(port); + } + + private async verifyTrackedPortsReleased(): Promise { + for (const port of this.trackedUnreleasedPorts) { + if (await this.isPortReleased(port)) { + this.trackedUnreleasedPorts.delete(port); + continue; + } + throw new OpencodePortReleaseError(port); + } + } + + private recordPortReleaseOutcome( + port: number, + processExited: boolean, + portReleased: boolean, + ): void { + if (port <= 0) return; + if (processExited && portReleased) { + this.trackedUnreleasedPorts.delete(port); + return; + } + this.trackedUnreleasedPorts.add(port); + } + + private waitForProcessExit(proc: ChildProcess): Promise { + if (typeof proc.exitCode === 'number' || proc.signalCode != null) { + return Promise.resolve(true); + } + + return new Promise((resolve) => { + const onExit = () => { + clearTimeout(timer); + resolve(true); + }; + const timer = setTimeout(() => { + proc.off('exit', onExit); + resolve(false); + }, ATTACHED_SERVER_STOP_TIMEOUT_MS); + proc.once('exit', onExit); + }); + } + + private beginUnexpectedExitCleanup( + proc: ChildProcess, + code: number | null, + port: number, + generation: number, + ): void { + const cleanupPromise = this.enqueueLifecycle(async () => { + const released = typeof proc.pid !== 'number' + || await this.waitForPortToBeReleased(port); + if (!this.isActiveGeneration(generation)) { + if (!released && port > 0) this.trackedUnreleasedPorts.add(port); + return; + } + this.recordPortReleaseOutcome(port, true, released); + if (released) { + this.activeRuntimeGeneration = null; + this.setStatus({ + state: 'stopped', + port: this.options.port, + error: code == null ? undefined : `Exited with code ${code}`, + }); + return; + } + + const error = new OpencodePortReleaseError(port).message; + this.setStatus({ + state: 'error', + port, + ...(typeof proc.pid === 'number' ? { pid: proc.pid } : {}), + error, + }); + }); + this.exitCleanupPromise = cleanupPromise; + void cleanupPromise.finally(() => { + if (this.exitCleanupPromise === cleanupPromise) { + this.exitCleanupPromise = null; + } + }); + } + + private logPortFallback(): void { + logger.warn('[opencode-runtime] Preferred port is occupied by an unhealthy listener; using an ephemeral port', { + preferredPort: this.options.port, + }); + } + private resolveRuntimeEnv(runtimeEnv: Record): Record { + const inheritedEnvironment = mergeProcessEnvironment(runtimeEnv); const userDataDir = this.options.userDataDir?.trim(); if (!userDataDir) { - return this.options.pythonRuntime - ? prependPythonToPath(runtimeEnv, this.options.pythonRuntime) - : runtimeEnv; + return prependManagedRuntimesToPath( + inheritedEnvironment, + this.options.pythonRuntime, + this.options.uvRuntime, + ); } const baseDir = join(userDataDir, 'opencode'); @@ -587,7 +1062,7 @@ export class OpencodeManager extends EventEmitter { sourcePath: this.options.bundledAgentBrowserPluginPath, }); const environment = { - ...runtimeEnv, + ...inheritedEnvironment, OPENCODE_CONFIG_DIR: managedConfigDir, // Makelore exposes its managed and project-owned Skills explicitly. Avoid // rescanning unrelated user-wide ~/.claude and ~/.agents Skill libraries @@ -598,9 +1073,11 @@ export class OpencodeManager extends EventEmitter { XDG_DATA_HOME: dataHome, XDG_CACHE_HOME: cacheHome, }; - return this.options.pythonRuntime - ? prependPythonToPath(environment, this.options.pythonRuntime) - : environment; + return prependManagedRuntimesToPath( + environment, + this.options.pythonRuntime, + this.options.uvRuntime, + ); } private setStatus(next: OpencodeStatus): void { diff --git a/electron/opencode/playwright-mcp.ts b/electron/opencode/playwright-mcp.ts index c0fb571..e01757f 100644 --- a/electron/opencode/playwright-mcp.ts +++ b/electron/opencode/playwright-mcp.ts @@ -6,19 +6,14 @@ const moduleRequire = createRequire(import.meta.url); export const PLAYWRIGHT_MCP_SERVER_ID = 'playwright'; export const PLAYWRIGHT_MCP_PACKAGE = '@playwright/mcp'; -export const PLAYWRIGHT_MCP_VERSION = '0.0.78'; -function fallbackPlaywrightMcpServer(): OpencodeMcpServerEntry { - return { - type: 'local', - command: ['npx', '-y', `${PLAYWRIGHT_MCP_PACKAGE}@${PLAYWRIGHT_MCP_VERSION}`], - enabled: true, - }; -} +type ResolvePackageJson = () => string; -export function resolvePlaywrightMcpServer(): OpencodeMcpServerEntry { +export function resolvePlaywrightMcpServer( + resolvePackageJson: ResolvePackageJson = () => moduleRequire.resolve(`${PLAYWRIGHT_MCP_PACKAGE}/package.json`), +): OpencodeMcpServerEntry { try { - const packageJsonPath = moduleRequire.resolve(`${PLAYWRIGHT_MCP_PACKAGE}/package.json`); + const packageJsonPath = resolvePackageJson(); return { type: 'local', command: [process.execPath, join(dirname(packageJsonPath), 'cli.js')], @@ -27,7 +22,10 @@ export function resolvePlaywrightMcpServer(): OpencodeMcpServerEntry { ELECTRON_RUN_AS_NODE: '1', }, }; - } catch { - return fallbackPlaywrightMcpServer(); + } catch (error) { + throw new Error( + `Bundled ${PLAYWRIGHT_MCP_PACKAGE} is missing; Makelore does not download runtime dependencies with npx`, + { cause: error }, + ); } } diff --git a/electron/utils/python-runtime.ts b/electron/utils/python-runtime.ts index 106b735..7365075 100644 --- a/electron/utils/python-runtime.ts +++ b/electron/utils/python-runtime.ts @@ -1,5 +1,5 @@ import { existsSync } from 'node:fs'; -import { delimiter, dirname, isAbsolute, join, resolve } from 'node:path'; +import { dirname, isAbsolute, join, resolve } from 'node:path'; export type PythonRuntimeSource = 'configured' | 'bundled' | 'system'; @@ -9,6 +9,12 @@ export type PythonRuntime = { source: PythonRuntimeSource; }; +export type UvRuntime = { + executable: string; + binDir: string; + source: 'bundled' | 'system'; +}; + export type PythonRuntimeOptions = { isPackaged: boolean; resourcesPath: string; @@ -18,6 +24,8 @@ export type PythonRuntimeOptions = { configuredPath?: string; }; +export type UvRuntimeOptions = Omit; + function configuredRuntime(configuredPath: string | undefined, appPath: string): PythonRuntime | null { const trimmed = configuredPath?.trim(); if (!trimmed) return null; @@ -33,7 +41,9 @@ function bundledExecutable(resourcesPath: string, platform: NodeJS.Platform): st } export function resolvePythonRuntime(options: PythonRuntimeOptions): PythonRuntime { - const configured = configuredRuntime(options.configuredPath, options.appPath); + const configured = options.isPackaged + ? null + : configuredRuntime(options.configuredPath, options.appPath); if (configured) return configured; const executable = bundledExecutable(options.resourcesPath, options.platform); @@ -52,17 +62,71 @@ export function resolvePythonRuntime(options: PythonRuntimeOptions): PythonRunti }; } +function uvExecutableName(platform: NodeJS.Platform): string { + return platform === 'win32' ? 'uv.exe' : 'uv'; +} + +export function resolveUvRuntime(options: UvRuntimeOptions): UvRuntime { + const executable = options.isPackaged + ? join(options.resourcesPath, 'bin', uvExecutableName(options.platform)) + : join( + options.appPath, + 'resources', + 'bin', + `${options.platform}-${options.arch}`, + uvExecutableName(options.platform), + ); + if (existsSync(executable)) { + return { executable, binDir: dirname(executable), source: 'bundled' }; + } + + if (options.isPackaged) { + throw new Error(`Bundled uv runtime is missing: ${executable}`); + } + + return { executable: 'uv', binDir: '', source: 'system' }; +} + +function prependRuntimeBin( + environment: Record, + binDir: string, + platform: NodeJS.Platform, +): Record { + if (!binDir) return environment; + const result = { ...environment }; + const existingKey = Object.keys(result).find((key) => key.toLowerCase() === 'path'); + const pathKey = platform === 'win32' && existingKey ? existingKey : 'PATH'; + const existingPath = result[pathKey]; + const separator = platform === 'win32' ? ';' : ':'; + result[pathKey] = existingPath ? `${binDir}${separator}${existingPath}` : binDir; + return result; +} + export function prependPythonToPath( environment: Record, runtime: PythonRuntime, platform: NodeJS.Platform = process.platform, ): Record { - const result = { ...environment, NIANCODE_PYTHON_PATH: runtime.executable }; - if (!runtime.binDir) return result; - - const existingKey = Object.keys(result).find((key) => key.toLowerCase() === 'path'); - const pathKey = platform === 'win32' && existingKey ? existingKey : 'PATH'; - const existingPath = result[pathKey]; - result[pathKey] = existingPath ? `${runtime.binDir}${delimiter}${existingPath}` : runtime.binDir; - return result; + return prependRuntimeBin( + { ...environment, NIANCODE_PYTHON_PATH: runtime.executable }, + runtime.binDir, + platform, + ); +} + +export function prependManagedRuntimesToPath( + environment: Record, + pythonRuntime?: PythonRuntime, + uvRuntime?: UvRuntime, + platform: NodeJS.Platform = process.platform, +): Record { + const withPython = pythonRuntime + ? prependPythonToPath(environment, pythonRuntime, platform) + : { ...environment }; + if (!uvRuntime) return withPython; + return prependRuntimeBin( + { ...withPython, NIANCODE_UV_PATH: uvRuntime.executable }, + uvRuntime.binDir, + platform, + ); } diff --git a/package.json b/package.json index 3a9a466..99a2936 100644 --- a/package.json +++ b/package.json @@ -61,11 +61,12 @@ "prep:win-binaries": "pnpm run uv:download:win && pnpm run node:download:win", "icons": "zx scripts/generate-icons.mjs", "package": "pnpm run build:vite && node scripts/bundle-opencode.mjs", - "package:mac": "pnpm run python:download:mac && pnpm run package && node scripts/run-electron-builder.mjs --mac --publish never", - "package:mac:local": "pnpm run python:download:mac && SKIP_PREINSTALLED_SKILLS=1 pnpm run package && node scripts/run-electron-builder.mjs --mac --publish never", - "package:win": "pnpm run python:download:win && pnpm run package && node scripts/run-electron-builder.mjs --win --publish never", - "package:linux": "pnpm run python:download:linux && pnpm run package && node scripts/run-electron-builder.mjs --linux --publish never", - "release": "pnpm run python:download && pnpm run package && node scripts/run-electron-builder.mjs --publish always", + "package:stage:win-x64": "pnpm run build:vite && node scripts/bundle-opencode.mjs --platform=win32 --arch=x64", + "package:mac": "pnpm run python:download:mac && pnpm run uv:download:mac && pnpm run package && node scripts/run-electron-builder.mjs --mac --publish never", + "package:mac:local": "pnpm run python:download:mac && pnpm run uv:download:mac && SKIP_PREINSTALLED_SKILLS=1 pnpm run package && node scripts/run-electron-builder.mjs --mac --publish never", + "package:win": "pnpm run python:download:win && pnpm run uv:download:win && pnpm run package:stage:win-x64 && node scripts/run-electron-builder.mjs --win --publish never", + "package:linux": "pnpm run python:download:linux && pnpm run uv:download:linux && pnpm run package && node scripts/run-electron-builder.mjs --linux --publish never", + "release": "pnpm run python:download && pnpm run uv:download && pnpm run package && node scripts/run-electron-builder.mjs --publish always", "preversion": "node scripts/pre-version-fetch-tags.mjs", "version": "node scripts/assert-release-version.mjs", "version:patch": "pnpm version patch", @@ -95,7 +96,6 @@ "ms": "^2.1.3", "msgpackr": "2.0.5", "node-machine-id": "^1.1.12", - "opencode-ai": "latest", "pdfjs-dist": "^5.7.284", "posthog-node": "^5.28.0", "qrcode.react": "4.2.0", @@ -116,6 +116,7 @@ "@homebridge/ciao": "^1.3.7", "@larksuiteoapi/node-sdk": "^1.61.1", "@opencode-ai/plugin": "1.18.9", + "opencode-ai": "1.18.9", "@playwright/test": "^1.56.1", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6542ec8..0fa4f45 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,9 +62,6 @@ importers: node-machine-id: specifier: ^1.1.12 version: 1.1.12 - opencode-ai: - specifier: latest - version: 1.18.9 pdfjs-dist: specifier: ^5.7.284 version: 5.7.284 @@ -120,6 +117,9 @@ importers: '@opencode-ai/plugin': specifier: 1.18.9 version: 1.18.9 + opencode-ai: + specifier: 1.18.9 + version: 1.18.9 '@playwright/test': specifier: ^1.56.1 version: 1.59.0 diff --git a/scripts/bundle-opencode.mjs b/scripts/bundle-opencode.mjs index 413ab29..ab41a1d 100644 --- a/scripts/bundle-opencode.mjs +++ b/scripts/bundle-opencode.mjs @@ -2,6 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; +import { spawnSync } from 'node:child_process'; import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; @@ -9,12 +10,33 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const root = path.resolve(__dirname, '..'); const require = createRequire(import.meta.url); const outputDir = path.join(root, 'build', 'opencode-ai'); +const rootPackageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); + +const targetArgs = process.argv.slice(2); +for (const arg of targetArgs) { + if (!/^--(?:platform|arch)=\S+$/.test(arg)) { + throw new Error(`Unknown bundle target option: ${arg}`); + } +} + +function targetOption(name, fallback) { + const prefix = `--${name}=`; + const option = targetArgs.find((arg) => arg.startsWith(prefix)); + return option ? option.slice(prefix.length) : fallback; +} + +const targetPlatform = targetOption('platform', process.platform); +const targetArch = targetOption('arch', process.arch); function fail(message) { console.error(`[bundle-opencode] ${message}`); process.exit(1); } +if (targetPlatform !== process.platform) { + fail(`Cross-OS OpenCode staging is not supported: host ${process.platform}, target ${targetPlatform}.`); +} + function resolvePackageRoot(packageName) { try { return path.dirname(require.resolve(`${packageName}/package.json`, { @@ -32,6 +54,13 @@ if (!packageRoot) { const packageJsonPath = path.join(packageRoot, 'package.json'); const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); +const declaredVersion = rootPackageJson.devDependencies?.['opencode-ai'] + ?? rootPackageJson.dependencies?.['opencode-ai']; +if (declaredVersion !== packageJson.version) { + fail( + `The opencode-ai dependency must be pinned exactly to the bundled version. Declared ${declaredVersion ?? 'missing'}, installed ${packageJson.version ?? 'unknown'}.`, + ); +} const packageRequire = createRequire(packageJsonPath); const bin = packageJson.bin; const hasOpencodeBin = typeof bin === 'string' @@ -53,11 +82,6 @@ fs.cpSync(packageRoot, outputDir, { }, }); -const runtimeDependencies = { - ...(packageJson.dependencies && typeof packageJson.dependencies === 'object' ? packageJson.dependencies : {}), - ...(packageJson.optionalDependencies && typeof packageJson.optionalDependencies === 'object' ? packageJson.optionalDependencies : {}), -}; - const platformMap = { darwin: 'darwin', linux: 'linux', @@ -69,30 +93,9 @@ const archMap = { arm: 'arm', }; -function copyRuntimeDependency(packageName) { - let dependencyPackageJsonPath; - try { - dependencyPackageJsonPath = packageRequire.resolve(`${packageName}/package.json`); - } catch { - console.warn(`[bundle-opencode] optional dependency not installed: ${packageName}`); - return false; - } - - const dependencyRoot = path.dirname(dependencyPackageJsonPath); - const outputPackageDir = path.join(outputDir, 'node_modules', ...packageName.split('/')); - fs.rmSync(outputPackageDir, { recursive: true, force: true }); - fs.mkdirSync(path.dirname(outputPackageDir), { recursive: true }); - fs.cpSync(dependencyRoot, outputPackageDir, { - recursive: true, - dereference: true, - filter: (src) => !src.includes(`${path.sep}.git${path.sep}`), - }); - return true; -} - function getNativePackageCandidates() { - const platform = platformMap[process.platform] ?? process.platform; - const arch = archMap[process.arch] ?? process.arch; + const platform = platformMap[targetPlatform] ?? targetPlatform; + const arch = archMap[targetArch] ?? targetArch; const base = `opencode-${platform}-${arch}`; if (arch === 'x64') { @@ -103,7 +106,7 @@ function getNativePackageCandidates() { } function bundleNativeLauncher() { - const binaryName = process.platform === 'win32' ? 'opencode.exe' : 'opencode'; + const binaryName = targetPlatform === 'win32' ? 'opencode.exe' : 'opencode'; const outputBinary = path.join(outputDir, 'bin', binaryName); for (const packageName of getNativePackageCandidates()) { @@ -126,12 +129,35 @@ function bundleNativeLauncher() { } fail( - `Missing native opencode binary for ${process.platform}/${process.arch}. Tried ${getNativePackageCandidates().join(', ')}.`, + `Missing native opencode binary for ${targetPlatform}/${targetArch}. Tried ${getNativePackageCandidates().join(', ')}.`, ); } -const copiedDependencies = Object.keys(runtimeDependencies).filter(copyRuntimeDependency); const nativeLauncher = bundleNativeLauncher(); +const verificationEnv = { + ...process.env, + PATH: process.platform === 'win32' + ? [process.env.SystemRoot && path.join(process.env.SystemRoot, 'System32'), process.env.SystemRoot] + .filter(Boolean) + .join(path.delimiter) + : '/usr/bin:/bin', +}; +delete verificationEnv.NODE_PATH; +delete verificationEnv.BUN_INSTALL; +const versionProbe = spawnSync(nativeLauncher.outputBinary, ['--version'], { + cwd: outputDir, + encoding: 'utf8', + env: verificationEnv, +}); +if (versionProbe.error || versionProbe.status !== 0) { + fail( + `Bundled native launcher failed its self-contained version probe: ${versionProbe.error?.message ?? versionProbe.stderr ?? versionProbe.stdout}`, + ); +} +const reportedVersion = `${versionProbe.stdout ?? ''}${versionProbe.stderr ?? ''}`.trim(); +if (reportedVersion !== packageJson.version) { + fail(`Bundled native launcher reported ${reportedVersion || 'no version'}, expected ${packageJson.version}.`); +} console.log( - `[bundle-opencode] bundled opencode-ai@${packageJson.version ?? 'unknown'} with ${copiedDependencies.length} runtime dependency package(s) and native launcher ${nativeLauncher.packageName} -> ${path.relative(root, outputDir)}`, + `[bundle-opencode] bundled and verified self-contained opencode-ai@${packageJson.version} launcher ${nativeLauncher.packageName} -> ${path.relative(root, outputDir)}`, ); diff --git a/scripts/verify-packaged-electron-runtime.mjs b/scripts/verify-packaged-electron-runtime.mjs index 1a44814..d4b163d 100644 --- a/scripts/verify-packaged-electron-runtime.mjs +++ b/scripts/verify-packaged-electron-runtime.mjs @@ -3,8 +3,11 @@ import { spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { + closeSync, createReadStream, existsSync, + openSync, + readSync, readFileSync, statSync, writeFileSync, @@ -81,6 +84,37 @@ function assertEqual(actual, expected, label) { } } +function assertPeMachine(filePath, expectedMachine, label) { + const handle = openSync(filePath, 'r'); + try { + const dosHeader = Buffer.alloc(64); + if (readSync(handle, dosHeader, 0, dosHeader.length, 0) !== dosHeader.length) { + throw new Error(`${label}: truncated DOS header`); + } + if (dosHeader.toString('ascii', 0, 2) !== 'MZ') { + throw new Error(`${label}: missing MZ signature`); + } + + const peOffset = dosHeader.readUInt32LE(0x3c); + const coffHeader = Buffer.alloc(6); + if (readSync(handle, coffHeader, 0, coffHeader.length, peOffset) !== coffHeader.length) { + throw new Error(`${label}: truncated PE header`); + } + if (coffHeader.readUInt32LE(0) !== 0x00004550) { + throw new Error(`${label}: missing PE signature`); + } + + const machine = coffHeader.readUInt16LE(4); + if (machine !== expectedMachine) { + throw new Error( + `${label}: expected PE machine 0x${expectedMachine.toString(16)}, got 0x${machine.toString(16)}`, + ); + } + } finally { + closeSync(handle); + } +} + function isPathInside(parentPath, candidatePath) { const relativePath = relative(resolve(parentPath), resolve(candidatePath)); return relativePath !== '' @@ -106,12 +140,78 @@ const expectedNode = option('--expected-node', '24.15.0'); const expectedCommit = option('--expected-commit', null); const expectedBuildId = option('--expected-build-id', null); const manifestPath = option('--manifest', null); -const appAsarPath = join(dirname(appExecutable), 'resources', 'app.asar'); -const appAsarUnpackedPath = join(dirname(appExecutable), 'resources', 'app.asar.unpacked'); -for (const filePath of [appExecutable, installerPath]) { +const resourcesDir = join(dirname(appExecutable), 'resources'); +const appAsarPath = join(resourcesDir, 'app.asar'); +const appAsarUnpackedPath = join(resourcesDir, 'app.asar.unpacked'); +const opencodeRuntimeDir = join(resourcesDir, 'opencode-ai'); +const opencodePackagePath = join(opencodeRuntimeDir, 'package.json'); +const opencodeExecutable = join(opencodeRuntimeDir, 'bin', 'opencode.exe'); +const pythonRuntimeDir = join(resourcesDir, 'python'); +const pythonExecutable = join(pythonRuntimeDir, 'python.exe'); +const toolsBinDir = join(resourcesDir, 'bin'); +const uvExecutable = join(toolsBinDir, 'uv.exe'); +for (const filePath of [ + appExecutable, + installerPath, + appAsarPath, + opencodePackagePath, + opencodeExecutable, + pythonExecutable, + uvExecutable, +]) { if (!existsSync(filePath)) throw new Error(`Missing artifact: ${filePath}`); } +const PE_MACHINE_AMD64 = 0x8664; +for (const [label, executable] of [ + ['Makelore executable', appExecutable], + ['OpenCode executable', opencodeExecutable], + ['Python executable', pythonExecutable], + ['uv executable', uvExecutable], +]) { + assertPeMachine(executable, PE_MACHINE_AMD64, label); +} + +const declaredOpencodeVersion = packageJson.devDependencies?.['opencode-ai'] + ?? packageJson.dependencies?.['opencode-ai']; +const packagedOpencode = JSON.parse(readFileSync(opencodePackagePath, 'utf8')); +assertEqual(packagedOpencode.version, declaredOpencodeVersion, 'packaged OpenCode version'); + +const systemRoot = process.env.SystemRoot ?? process.env.WINDIR ?? 'C:\\Windows'; +const installLocalProbeEnv = { + ...process.env, + PATH: [join(systemRoot, 'System32'), systemRoot].join(';'), +}; +for (const key of ['NODE_PATH', 'BUN_INSTALL', 'OPENCODE_BIN', 'OPENCODE_PATH', 'NIANCODE_PYTHON_PATH', 'NIANCODE_UV_PATH']) { + delete installLocalProbeEnv[key]; +} +const opencodeVersion = run(opencodeExecutable, ['--version'], { + cwd: opencodeRuntimeDir, + env: installLocalProbeEnv, +}); +assertEqual(opencodeVersion, declaredOpencodeVersion, 'OpenCode executable version'); +const uvVersion = run(uvExecutable, ['--version'], { + cwd: toolsBinDir, + env: installLocalProbeEnv, +}); +if (!/^uv\s+\d+\.\d+\.\d+/.test(uvVersion)) { + throw new Error(`Invalid bundled uv version output: ${uvVersion}`); +} +const pythonProbe = JSON.parse(run(pythonExecutable, [ + '-I', + '-c', + 'import json,pip,sqlite3,ssl,sys; print(json.dumps({"executable":sys.executable,"pip":pip.__file__,"sqlite3":sqlite3.__file__,"ssl":ssl.__file__}))', +], { + cwd: pythonRuntimeDir, + env: installLocalProbeEnv, +})); +assertEqual(resolve(pythonProbe.executable), resolve(pythonExecutable), 'Python executable path'); +for (const [moduleName, modulePath] of Object.entries(pythonProbe).filter(([name]) => name !== 'executable')) { + if (typeof modulePath !== 'string' || !isPathInside(pythonRuntimeDir, modulePath)) { + throw new Error(`Python ${moduleName} was not loaded from the installation: ${modulePath}`); + } +} + const verificationHead = run('git', ['rev-parse', 'HEAD']); const gitStatus = run('git', ['status', '--porcelain']); if (gitStatus && !args.includes('--allow-dirty')) { @@ -127,6 +227,8 @@ const appRequire = createRequire(join(dirname(process.execPath), 'resources', 'a const packagedPackage = appRequire('./package.json'); const msgpackResolved = appRequire.resolve('msgpackr'); const canvasResolved = appRequire.resolve('@napi-rs/canvas'); +const playwrightPackageResolved = appRequire.resolve('@playwright/mcp/package.json'); +const playwrightCliResolved = join(dirname(playwrightPackageResolved), 'cli.js'); const { pack, unpack } = appRequire('msgpackr'); const { createCanvas } = appRequire('@napi-rs/canvas'); const packedValue = unpack(pack({ ok: true, text: '\u5362\u6b22' })); @@ -164,6 +266,8 @@ console.log('NIANCODE_ARTIFACT_PROBE=' + JSON.stringify({ }, msgpackResolved, canvasResolved, + playwrightPackageResolved, + playwrightCliResolved, nativeModules, packedValue, canvas: [canvas.width, canvas.height], @@ -207,6 +311,16 @@ if (!isPathInside(appAsarPath, probe.msgpackResolved)) { if (!isPathInside(appAsarPath, probe.canvasResolved)) { throw new Error(`@napi-rs/canvas was not resolved from app.asar: ${probe.canvasResolved}`); } +if (!isPathInside(appAsarPath, probe.playwrightPackageResolved)) { + throw new Error(`@playwright/mcp was not resolved from app.asar: ${probe.playwrightPackageResolved}`); +} +if (!isPathInside(appAsarPath, probe.playwrightCliResolved)) { + throw new Error(`@playwright/mcp CLI was not resolved from app.asar: ${probe.playwrightCliResolved}`); +} +run(appExecutable, [probe.playwrightCliResolved, '--help'], { + cwd: dirname(appExecutable), + env: { ...installLocalProbeEnv, ELECTRON_RUN_AS_NODE: '1' }, +}); const msgpackNative = probe.nativeModules.find(({ modulePath, physicalPath, exists }) => ( exists && isPathInside(appAsarPath, modulePath) @@ -245,6 +359,19 @@ const evidence = { installerBytes, installerSha256, runtime: { electron: probe.electron, node: probe.node }, + installLocalRuntimes: { + opencode: { + executable: opencodeExecutable, + version: opencodeVersion, + sha256: await sha256(opencodeExecutable), + }, + playwrightMcp: { + packageJson: probe.playwrightPackageResolved, + cli: probe.playwrightCliResolved, + }, + python: pythonProbe, + uv: { executable: uvExecutable, version: uvVersion }, + }, nativeModules: { msgpackr: probe.msgpackResolved, canvas: probe.canvasResolved, diff --git a/src/stores/opencode.ts b/src/stores/opencode.ts index e0d8438..ac9fede 100644 --- a/src/stores/opencode.ts +++ b/src/stores/opencode.ts @@ -2826,8 +2826,8 @@ export const useOpencodeStore = create((set, get) => ({ async stop() { closeActiveSessionEventSource(); await runRuntimeAction('/api/opencode/stop', set, { - ...get().status, state: 'stopped', + port: get().status.port, }); set({ health: null, diff --git a/tests/unit/opencode-manager.test.ts b/tests/unit/opencode-manager.test.ts index 5a792ce..ddda87e 100644 --- a/tests/unit/opencode-manager.test.ts +++ b/tests/unit/opencode-manager.test.ts @@ -1,4 +1,6 @@ import { EventEmitter } from 'node:events'; +import { createServer as createHttpServer } from 'node:http'; +import type { AddressInfo } from 'node:net'; import { existsSync, mkdirSync, @@ -134,6 +136,82 @@ describe('OpencodeManager', () => { expect(manager.getStatus()).not.toBe(status); }); + it('falls back to an ephemeral port when the preferred port has an unhealthy listener', async () => { + const { children, calls, spawn } = createSpawnHarness(); + const preferredPort = 4340; + const actualPort = 54721; + const findPortOwner = vi.fn(async () => ({ pid: 33188 })); + const fetch = vi.fn(async () => new Response(null, { status: 503 })); + vi.stubGlobal('fetch', fetch); + const manager = new OpencodeManager({ + port: preferredPort, + binPath: 'C:\\NianCode\\opencode.exe', + spawn, + findPortOwner, + }); + + const startPromise = manager.start(); + await vi.waitFor(() => expect(children).toHaveLength(1)); + children[0].stdout.emit( + 'data', + Buffer.from(`opencode server listening on http://127.0.0.1:${actualPort}\n`), + ); + const status = await startPromise; + + expect(calls[0].args).toEqual([ + 'serve', + '--hostname=127.0.0.1', + '--port=0', + ]); + expect(status).toMatchObject({ + state: 'running', + port: actualPort, + url: `http://127.0.0.1:${actualPort}`, + pid: 4242, + }); + expect(manager.getStatus()).toEqual(status); + }); + + it('uses the production bind probe when an unhealthy listener occupies the preferred port', async () => { + const listener = createHttpServer((_request, response) => { + response.statusCode = 503; + response.end(); + }); + await new Promise((resolve, reject) => { + listener.once('error', reject); + listener.listen({ host: '127.0.0.1', port: 0 }, () => resolve()); + }); + const preferredPort = (listener.address() as AddressInfo).port; + const actualPort = 54723; + try { + const { children, calls, spawn } = createSpawnHarness(); + const manager = new OpencodeManager({ + port: preferredPort, + binPath: 'C:\\NianCode\\opencode.exe', + preflightPreferredPort: true, + spawn, + }); + + const startPromise = manager.start(); + await vi.waitFor(() => expect(children).toHaveLength(1)); + children[0].stdout.emit( + 'data', + Buffer.from(`opencode server listening on http://127.0.0.1:${actualPort}\n`), + ); + + await expect(startPromise).resolves.toMatchObject({ + state: 'running', + port: actualPort, + }); + expect(calls[0].args).toContain('--port=0'); + } finally { + listener.closeAllConnections(); + await new Promise((resolve, reject) => { + listener.close((error) => error ? reject(error) : resolve()); + }); + } + }); + it('passes configProvider JSON through OPENCODE_CONFIG_CONTENT', async () => { const { children, calls, spawn } = createSpawnHarness(); const config = { provider: { anthropic: { models: ['claude'] } } }; @@ -234,6 +312,7 @@ describe('OpencodeManager', () => { const { children, calls, spawn } = createSpawnHarness(); const userDataDir = mkdtempSync(join(tmpdir(), 'niancode-opencode-python-')); const pythonExecutable = join(userDataDir, 'bundled-python', 'python.exe'); + const uvExecutable = join(userDataDir, 'bundled-bin', 'uv.exe'); const originalPath = process.env.PATH ?? ''; try { const manager = new OpencodeManager({ @@ -245,6 +324,11 @@ describe('OpencodeManager', () => { binDir: dirname(pythonExecutable), source: 'bundled', }, + uvRuntime: { + executable: uvExecutable, + binDir: dirname(uvExecutable), + source: 'bundled', + }, spawn, }); @@ -252,8 +336,16 @@ describe('OpencodeManager', () => { children[0].stdout.emit('data', Buffer.from('opencode server listening on http://127.0.0.1:4325\n')); await startPromise; - expect(calls[0].options.env).toMatchObject({ NIANCODE_PYTHON_PATH: pythonExecutable }); - expect(String(calls[0].options.env?.PATH).split(delimiter)[0]).toBe(dirname(pythonExecutable)); + expect(calls[0].options.env).toMatchObject({ + NIANCODE_PYTHON_PATH: pythonExecutable, + NIANCODE_UV_PATH: uvExecutable, + }); + const childPath = String(calls[0].options.env?.PATH); + expect(childPath.split(delimiter).slice(0, 2)).toEqual([ + dirname(uvExecutable), + dirname(pythonExecutable), + ]); + expect(childPath.split(delimiter).slice(2).join(delimiter)).toBe(originalPath); expect(process.env.PATH ?? '').toBe(originalPath); } finally { rmSync(userDataDir, { recursive: true, force: true }); @@ -407,6 +499,7 @@ describe('OpencodeManager', () => { }); const startPromise = manager.start(); + await vi.waitFor(() => expect(children).toHaveLength(1)); children[0].stderr.emit('data', Buffer.from('Failed to start server. Is port 4334 in use?\n')); children[0].emit('exit', 1); await expect(startPromise).resolves.toMatchObject({ @@ -459,6 +552,7 @@ describe('OpencodeManager', () => { }); const startPromise = manager.start(); + await vi.waitFor(() => expect(children).toHaveLength(1)); children[0].stderr.emit('data', Buffer.from('Failed to start server. Is port 4331 in use?\n')); children[0].emit('exit', 1); await expect(startPromise).resolves.toMatchObject({ @@ -507,6 +601,7 @@ describe('OpencodeManager', () => { }); const startPromise = manager.start(); + await vi.waitFor(() => expect(children).toHaveLength(1)); children[0].stderr.emit('data', Buffer.from('Failed to start server. Is port 4332 in use?\n')); children[0].emit('exit', 1); await startPromise; @@ -518,6 +613,50 @@ describe('OpencodeManager', () => { expect(manager.getStatus()).toEqual({ state: 'stopped', port: 4332 }); }); + it('retains an attached managed port when terminating its process fails', async () => { + const { children, spawn } = createSpawnHarness(); + const port = 4336; + const attachedOwner = { + pid: 5150, + executablePath: 'C:\\NianCode\\opencode.exe', + commandLine: `C:\\NianCode\\opencode.exe serve --port=${port}`, + }; + const findPortOwner = vi.fn() + .mockResolvedValueOnce(attachedOwner) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ pid: attachedOwner.pid }); + const killProcess = vi.fn(() => false); + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ healthy: true }), { + headers: { 'content-type': 'application/json' }, + status: 200, + }))); + const manager = new OpencodeManager({ + port, + binPath: 'C:\\NianCode\\opencode.exe', + spawn, + findPortOwner, + killProcess, + }); + + const startPromise = manager.start(); + await vi.waitFor(() => expect(children).toHaveLength(1)); + children[0].emit('exit', 1); + await startPromise; + + const emittedStates: string[] = []; + manager.on('status', (status: { state: string }) => emittedStates.push(status.state)); + await expect(manager.stop()).rejects.toThrow( + `Failed to stop attached opencode process ${attachedOwner.pid}`, + ); + await expect(manager.stop()).rejects.toThrow( + `Timed out waiting for opencode port ${port} to be released`, + ); + + expect(killProcess).toHaveBeenCalledOnce(); + expect(emittedStates).not.toContain('stopped'); + expect(manager.getStatus()).toMatchObject({ state: 'error', port }); + }); + it('isolates opencode config, data, and cache under the app user data directory', async () => { const userDataDir = mkdtempSync(join(tmpdir(), 'niancode-opencode-manager-')); try { @@ -972,6 +1111,42 @@ describe('OpencodeManager', () => { }); }); + it('reports runtime configuration failures instead of remaining starting', async () => { + const manager = new OpencodeManager({ + port: 4351, + binPath: '/opt/opencode', + runtimeConfigProvider: async () => { + throw new Error('provider config unavailable'); + }, + }); + + await expect(manager.start()).rejects.toThrow('provider config unavailable'); + expect(manager.getStatus()).toMatchObject({ + state: 'error', + port: 4351, + error: 'provider config unavailable', + }); + }); + + it('reports synchronous spawn failures instead of remaining starting', async () => { + const { spawn } = createSpawnHarness(); + spawn.mockImplementationOnce(() => { + throw new Error('spawn failed synchronously'); + }); + const manager = new OpencodeManager({ + port: 4352, + binPath: '/opt/opencode', + spawn, + }); + + await expect(manager.start()).rejects.toThrow('spawn failed synchronously'); + expect(manager.getStatus()).toMatchObject({ + state: 'error', + port: 4352, + error: 'spawn failed synchronously', + }); + }); + it('kills the process on stop and marks the runtime stopped', async () => { const { children, spawn } = createSpawnHarness(); const manager = new OpencodeManager({ @@ -987,9 +1162,597 @@ describe('OpencodeManager', () => { ); await startPromise; - await manager.stop(); + const stopPromise = manager.stop(); + children[0].emit('exit', 0); + await stopPromise; expect(children[0].kill).toHaveBeenCalledOnce(); expect(manager.getStatus()).toEqual({ state: 'stopped', port: 4323 }); }); + + it('rejects stop without reporting stopped when the child exits before its port is released', async () => { + const { children, spawn } = createSpawnHarness(); + const port = 4341; + let portHeld = false; + const findPortOwner = vi.fn(async () => portHeld + ? { pid: 4242, executablePath: '/opt/opencode' } + : null); + const manager = new OpencodeManager({ + port, + binPath: '/opt/opencode', + spawn, + findPortOwner, + }); + + const startPromise = manager.start(); + await vi.waitFor(() => expect(children).toHaveLength(1)); + children[0].stdout.emit( + 'data', + Buffer.from(`opencode server listening on http://127.0.0.1:${port}\n`), + ); + await startPromise; + + const emittedStates: string[] = []; + manager.on('status', (status: { state: string }) => emittedStates.push(status.state)); + portHeld = true; + vi.useFakeTimers(); + try { + const stopPromise = manager.stop(); + const stopRejection = expect(stopPromise).rejects.toThrow( + `Timed out waiting for opencode port ${port} to be released`, + ); + + children[0].emit('exit', 0); + const reportedStoppedBeforeTimeout = emittedStates.includes('stopped') + || manager.getStatus().state === 'stopped'; + + await vi.runAllTimersAsync(); + await stopRejection; + + expect(reportedStoppedBeforeTimeout).toBe(false); + expect(emittedStates).not.toContain('stopped'); + expect(manager.getStatus()).toMatchObject({ + state: 'error', + port, + error: `Timed out waiting for opencode port ${port} to be released`, + }); + } finally { + vi.useRealTimers(); + } + }); + + it('preserves an unexpected-exit port release failure for a later stop', async () => { + const { children, spawn } = createSpawnHarness(); + const port = 4354; + let portHeld = false; + const findPortOwner = vi.fn(async () => portHeld ? { pid: 33188 } : null); + const manager = new OpencodeManager({ + port, + binPath: '/opt/opencode', + findPortOwner, + spawn, + }); + + const startPromise = manager.start(); + await vi.waitFor(() => expect(children).toHaveLength(1)); + children[0].stdout.emit( + 'data', + Buffer.from(`opencode server listening on http://127.0.0.1:${port}\n`), + ); + await startPromise; + + const emittedStates: string[] = []; + manager.on('status', (status: { state: string }) => emittedStates.push(status.state)); + portHeld = true; + vi.useFakeTimers(); + try { + children[0].emit('exit', 1); + await vi.advanceTimersByTimeAsync(2_000); + expect(manager.getStatus()).toMatchObject({ + state: 'error', + port, + error: `Timed out waiting for opencode port ${port} to be released`, + }); + + await expect(manager.stop()).rejects.toThrow( + `Timed out waiting for opencode port ${port} to be released`, + ); + expect(emittedStates).not.toContain('stopped'); + expect(manager.getStatus().state).toBe('error'); + } finally { + vi.useRealTimers(); + } + }); + + it('preserves a startup-timeout port release failure for a later stop', async () => { + const listener = createHttpServer(); + await new Promise((resolve, reject) => { + listener.once('error', reject); + listener.listen({ host: '127.0.0.1', port: 0 }, () => resolve()); + }); + const port = (listener.address() as AddressInfo).port; + try { + const { children, spawn } = createSpawnHarness(); + const manager = new OpencodeManager({ + port, + binPath: '/opt/opencode', + startupTimeoutMs: 10, + spawn, + }); + + const startPromise = manager.start(); + const startRejection = expect(startPromise).rejects.toThrow( + 'Timed out waiting for opencode server after 10ms', + ); + children[0].kill.mockImplementationOnce(() => { + setImmediate(() => children[0].emit('exit', 1)); + return true; + }); + + await startRejection; + await expect(manager.stop()).rejects.toThrow( + `Timed out waiting for opencode port ${port} to be released`, + ); + expect(manager.getStatus()).toMatchObject({ + state: 'error', + port, + error: `Timed out waiting for opencode port ${port} to be released`, + }); + } finally { + listener.closeAllConnections(); + await new Promise((resolve, reject) => { + listener.close((error) => error ? reject(error) : resolve()); + }); + } + }); + + it('does not overwrite a timed-out start process that failed to exit', async () => { + const { children, spawn } = createSpawnHarness(); + const manager = new OpencodeManager({ + port: 4355, + binPath: '/opt/opencode', + startupTimeoutMs: 10, + spawn, + }); + + await expect(manager.start()).rejects.toThrow( + 'Timed out waiting for opencode server after 10ms', + ); + expect(children).toHaveLength(1); + + const retry = manager.start(); + expect(children).toHaveLength(1); + await expect(retry).rejects.toThrow( + `Timed out waiting for opencode process ${children[0].pid} to exit`, + ); + expect(spawn).toHaveBeenCalledOnce(); + expect(manager.getStatus().state).toBe('error'); + }, 10_000); + + it('retains a timed-out port across a late old-process exit and dynamic retry', async () => { + const { children, calls, spawn } = createSpawnHarness(); + const preferredPort = 4356; + const actualPort = 54724; + let oldPortHeld = false; + const findPortOwner = vi.fn(async (port: number) => ( + oldPortHeld && port === preferredPort ? { pid: 33188 } : null + )); + vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 503 }))); + const manager = new OpencodeManager({ + port: preferredPort, + binPath: '/opt/opencode', + findPortOwner, + startupTimeoutMs: 200, + spawn, + }); + + const firstStart = manager.start(); + const firstRejection = expect(firstStart).rejects.toThrow( + 'Timed out waiting for opencode server after 200ms', + ); + await vi.waitFor(() => expect(children).toHaveLength(1)); + oldPortHeld = true; + await firstRejection; + + children[0].emit('exit', 1); + const retry = manager.start(); + await vi.waitFor(() => expect(children).toHaveLength(2)); + expect(calls[1].args).toContain('--port=0'); + children[1].stdout.emit( + 'data', + Buffer.from(`opencode server listening on http://127.0.0.1:${actualPort}\n`), + ); + await retry; + + const stop = manager.stop(); + const stopRejection = expect(stop).rejects.toThrow( + `Timed out waiting for opencode port ${preferredPort} to be released`, + ); + children[1].emit('exit', 0); + await stopRejection; + + expect(spawn).toHaveBeenCalledTimes(2); + expect(manager.getStatus()).toMatchObject({ + state: 'error', + port: preferredPort, + }); + }, 10_000); + + it('restarts on an ephemeral port after the old process exits but keeps its port occupied', async () => { + const { children, calls, spawn } = createSpawnHarness(); + const preferredPort = 4342; + const actualPort = 54722; + let portHeld = false; + const findPortOwner = vi.fn(async (port: number) => ( + portHeld && port === preferredPort ? { pid: 4242 } : null + )); + vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 503 }))); + const manager = new OpencodeManager({ + port: preferredPort, + binPath: 'C:\\NianCode\\opencode.exe', + spawn, + findPortOwner, + }); + + const startPromise = manager.start(); + await vi.waitFor(() => expect(children).toHaveLength(1)); + children[0].stdout.emit( + 'data', + Buffer.from(`opencode server listening on http://127.0.0.1:${preferredPort}\n`), + ); + await startPromise; + + const emittedStates: string[] = []; + manager.on('status', (status: { state: string }) => emittedStates.push(status.state)); + portHeld = true; + vi.useFakeTimers(); + try { + const restartPromise = manager.restart(); + children[0].emit('exit', 0); + await vi.advanceTimersByTimeAsync(2_000); + + expect(children).toHaveLength(2); + expect(calls[1].args).toEqual([ + 'serve', + '--hostname=127.0.0.1', + '--port=0', + ]); + children[1].stdout.emit( + 'data', + Buffer.from(`opencode server listening on http://127.0.0.1:${actualPort}\n`), + ); + + await expect(restartPromise).resolves.toMatchObject({ + state: 'running', + port: actualPort, + url: `http://127.0.0.1:${actualPort}`, + }); + + const stopPromise = manager.stop(); + const stopRejection = expect(stopPromise).rejects.toThrow( + `Timed out waiting for opencode port ${preferredPort} to be released`, + ); + children[1].emit('exit', 0); + await stopRejection; + + expect(emittedStates).not.toContain('stopped'); + expect(manager.getStatus()).toMatchObject({ + state: 'error', + port: preferredPort, + }); + } finally { + vi.useRealTimers(); + } + }); + + it('cancels an in-flight start before the child is spawned', async () => { + const { children, spawn } = createSpawnHarness(); + let resolveRuntimeConfig!: (value: { config: Record; env: Record }) => void; + const runtimeConfig = new Promise<{ config: Record; env: Record }>((resolve) => { + resolveRuntimeConfig = resolve; + }); + const manager = new OpencodeManager({ + port: 4343, + binPath: '/opt/opencode', + runtimeConfigProvider: () => runtimeConfig, + spawn, + }); + + const startPromise = manager.start(); + const startRejection = expect(startPromise).rejects.toThrow('opencode startup was stopped'); + await vi.waitFor(() => expect(manager.getStatus().state).toBe('starting')); + const stopPromise = manager.stop(); + + await startRejection; + await stopPromise; + resolveRuntimeConfig({ config: {}, env: {} }); + await new Promise((resolve) => setImmediate(resolve)); + + expect(manager.getStatus()).toEqual({ state: 'stopped', port: 4343 }); + expect(children).toHaveLength(0); + expect(spawn).not.toHaveBeenCalled(); + }); + + it('kills a spawned start immediately when stop is requested before listening', async () => { + const { children, spawn } = createSpawnHarness(); + const manager = new OpencodeManager({ + port: 4348, + binPath: '/opt/opencode', + startupTimeoutMs: 10_000, + spawn, + }); + + const startPromise = manager.start(); + const startRejection = expect(startPromise).rejects.toThrow('opencode startup was stopped'); + expect(children).toHaveLength(1); + + const stopPromise = manager.stop(); + expect(children[0].kill).toHaveBeenCalledOnce(); + children[0].emit('exit', 0); + + await startRejection; + await stopPromise; + expect(manager.getStatus()).toEqual({ state: 'stopped', port: 4348 }); + }); + + it('does not report stopped when a cancelled start leaves its port occupied', async () => { + const { children, spawn } = createSpawnHarness(); + const port = 4353; + let portHeld = false; + const findPortOwner = vi.fn(async () => portHeld ? { pid: 33188 } : null); + vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 503 }))); + const manager = new OpencodeManager({ + port, + binPath: '/opt/opencode', + findPortOwner, + startupTimeoutMs: 10_000, + spawn, + }); + + const startPromise = manager.start(); + const startRejection = expect(startPromise).rejects.toThrow('opencode startup was stopped'); + await vi.waitFor(() => expect(children).toHaveLength(1)); + const emittedStates: string[] = []; + manager.on('status', (status: { state: string }) => emittedStates.push(status.state)); + portHeld = true; + vi.useFakeTimers(); + try { + const stopPromise = manager.stop(); + const stopRejection = expect(stopPromise).rejects.toThrow( + `Timed out waiting for opencode port ${port} to be released`, + ); + expect(children[0].kill).toHaveBeenCalledOnce(); + children[0].emit('exit', 0); + + await vi.advanceTimersByTimeAsync(2_000); + await startRejection; + await stopRejection; + + expect(emittedStates).not.toContain('stopped'); + expect(manager.getStatus()).toMatchObject({ + state: 'error', + port, + error: `Timed out waiting for opencode port ${port} to be released`, + }); + } finally { + vi.useRealTimers(); + } + }); + + it('recognizes the listening URL when stdout splits it across chunks', async () => { + const { children, spawn } = createSpawnHarness(); + const manager = new OpencodeManager({ + port: 4344, + binPath: '/opt/opencode', + startupTimeoutMs: 100, + spawn, + }); + + const startPromise = manager.start(); + children[0].stdout.emit('data', Buffer.from('opencode server listening ')); + children[0].stdout.emit('data', Buffer.from('on http://127.0.0.1:4344\n')); + + await expect(startPromise).resolves.toMatchObject({ + state: 'running', + port: 4344, + url: 'http://127.0.0.1:4344', + }); + }); + + it('preserves start-stop-start FIFO order instead of reusing the first start', async () => { + const { children, spawn } = createSpawnHarness(); + let resolveRuntimeConfig!: (value: { config: Record; env: Record }) => void; + const runtimeConfig = new Promise<{ config: Record; env: Record }>((resolve) => { + resolveRuntimeConfig = resolve; + }); + const manager = new OpencodeManager({ + port: 4345, + binPath: '/opt/opencode', + runtimeConfigProvider: () => runtimeConfig, + spawn, + }); + + const firstStart = manager.start(); + const firstStartRejection = expect(firstStart).rejects.toThrow('opencode startup was stopped'); + await vi.waitFor(() => expect(manager.getStatus().state).toBe('starting')); + const middleStop = manager.stop(); + const finalStart = manager.start(); + + resolveRuntimeConfig({ config: {}, env: {} }); + await firstStartRejection; + await middleStop; + await vi.waitFor(() => expect(children).toHaveLength(1)); + children[0].stdout.emit( + 'data', + Buffer.from('opencode server listening on http://127.0.0.1:4345\n'), + ); + await finalStart; + + expect(spawn).toHaveBeenCalledOnce(); + expect(manager.getStatus()).toMatchObject({ state: 'running', port: 4345 }); + }); + + it('preserves stop-restart-stop FIFO order instead of reusing the first stop', async () => { + const { children, spawn } = createSpawnHarness(); + const manager = new OpencodeManager({ + port: 4346, + binPath: '/opt/opencode', + spawn, + }); + + const initialStart = manager.start(); + children[0].stdout.emit( + 'data', + Buffer.from('opencode server listening on http://127.0.0.1:4346\n'), + ); + await initialStart; + + const firstStop = manager.stop(); + const middleRestart = manager.restart(); + const restartRejection = expect(middleRestart).rejects.toThrow('opencode startup was stopped'); + const finalStop = manager.stop(); + children[0].emit('exit', 0); + await firstStop; + await restartRejection; + await finalStop; + + expect(spawn).toHaveBeenCalledOnce(); + expect(manager.getStatus()).toEqual({ state: 'stopped', port: 4346 }); + }); + + it('cancels every queued start requested before stop', async () => { + const { children, spawn } = createSpawnHarness(); + let resolveRuntimeConfig!: (value: { config: Record; env: Record }) => void; + const runtimeConfig = new Promise<{ config: Record; env: Record }>((resolve) => { + resolveRuntimeConfig = resolve; + }); + const manager = new OpencodeManager({ + port: 4349, + binPath: '/opt/opencode', + runtimeConfigProvider: () => runtimeConfig, + spawn, + }); + + const firstStart = manager.start(); + const firstRejection = expect(firstStart).rejects.toThrow('opencode startup was stopped'); + await vi.waitFor(() => expect(manager.getStatus().state).toBe('starting')); + const secondStart = manager.start(); + const secondRejection = expect(secondStart).rejects.toThrow('opencode startup was stopped'); + const stop = manager.stop(); + + await firstRejection; + await secondRejection; + await stop; + resolveRuntimeConfig({ config: {}, env: {} }); + await new Promise((resolve) => setImmediate(resolve)); + + expect(spawn).not.toHaveBeenCalled(); + expect(children).toHaveLength(0); + expect(manager.getStatus()).toEqual({ state: 'stopped', port: 4349 }); + }); + + it('cancels older queued starts but lets restart launch its own generation', async () => { + const { children, spawn } = createSpawnHarness(); + let resolveFirstConfig!: (value: { config: Record; env: Record }) => void; + const firstConfig = new Promise<{ config: Record; env: Record }>((resolve) => { + resolveFirstConfig = resolve; + }); + let configCalls = 0; + const manager = new OpencodeManager({ + port: 4350, + binPath: '/opt/opencode', + runtimeConfigProvider: () => { + configCalls += 1; + return configCalls === 1 ? firstConfig : { config: {}, env: {} }; + }, + spawn, + }); + + const firstStart = manager.start(); + const firstRejection = expect(firstStart).rejects.toThrow('opencode startup was stopped'); + await vi.waitFor(() => expect(manager.getStatus().state).toBe('starting')); + const secondStart = manager.start(); + const secondRejection = expect(secondStart).rejects.toThrow('opencode startup was stopped'); + const restart = manager.restart(); + + await firstRejection; + await secondRejection; + await vi.waitFor(() => expect(children).toHaveLength(1)); + children[0].stdout.emit( + 'data', + Buffer.from('opencode server listening on http://127.0.0.1:4350\n'), + ); + await restart; + resolveFirstConfig({ config: {}, env: {} }); + + expect(spawn).toHaveBeenCalledOnce(); + expect(manager.getStatus()).toMatchObject({ state: 'running', port: 4350 }); + }); + + it('does not let an old process exit cleanup overwrite a restarted generation', async () => { + const { children, calls, spawn } = createSpawnHarness(); + const preferredPort = 4347; + const actualPort = 54725; + let oldPortHeld = false; + const findPortOwner = vi.fn(async (port: number) => ( + oldPortHeld && port === preferredPort ? { pid: 33188 } : null + )); + vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 503 }))); + const manager = new OpencodeManager({ + port: preferredPort, + binPath: '/opt/opencode', + findPortOwner, + spawn, + }); + + const initialStart = manager.start(); + await vi.waitFor(() => expect(children).toHaveLength(1)); + children[0].stdout.emit( + 'data', + Buffer.from(`opencode server listening on http://127.0.0.1:${preferredPort}\n`), + ); + await initialStart; + + vi.useFakeTimers(); + try { + const noOpStart = manager.start(); + const restart = manager.restart(); + oldPortHeld = true; + children[0].emit('exit', 0); + await noOpStart; + + await vi.waitFor(() => expect(children).toHaveLength(2)); + expect(calls[1].args).toContain('--port=0'); + children[1].stdout.emit( + 'data', + Buffer.from(`opencode server listening on http://127.0.0.1:${actualPort}\n`), + ); + await restart; + await vi.advanceTimersByTimeAsync(2_000); + + expect(manager.getStatus()).toMatchObject({ + state: 'running', + port: actualPort, + pid: children[1].pid, + }); + + const emittedStates: string[] = []; + manager.on('status', (status: { state: string }) => emittedStates.push(status.state)); + const stop = manager.stop(); + const stopRejection = expect(stop).rejects.toThrow( + `Timed out waiting for opencode port ${preferredPort} to be released`, + ); + children[1].emit('exit', 0); + await stopRejection; + + expect(spawn).toHaveBeenCalledTimes(2); + expect(emittedStates).not.toContain('stopped'); + expect(manager.getStatus()).toMatchObject({ + state: 'error', + port: preferredPort, + }); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/tests/unit/opencode-routes.test.ts b/tests/unit/opencode-routes.test.ts index b68fb71..f476a8b 100644 --- a/tests/unit/opencode-routes.test.ts +++ b/tests/unit/opencode-routes.test.ts @@ -205,6 +205,25 @@ describe('opencode host api routes', () => { }); }); + it('returns the authoritative runtime status after stopping', async () => { + const response = createResponse(); + const status = { state: 'stopped', port: 4096 } as const; + const stop = vi.fn(async () => undefined); + const getStatus = vi.fn(() => status); + + const handled = await handleOpencodeRoutes( + createRequest('POST'), + response.res, + new URL('http://127.0.0.1/api/opencode/stop'), + { opencodeManager: { stop, getStatus } } as never, + ); + + expect(handled).toBe(true); + expect(stop).toHaveBeenCalledOnce(); + expect(getStatus).toHaveBeenCalledOnce(); + expect(response.json()).toEqual({ success: true, status }); + }); + it('reports health through the manager', async () => { const response = createResponse(); const checkHealth = vi.fn(async () => ({ diff --git a/tests/unit/playwright-mcp.test.ts b/tests/unit/playwright-mcp.test.ts new file mode 100644 index 0000000..eeb6647 --- /dev/null +++ b/tests/unit/playwright-mcp.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest'; +import { resolvePlaywrightMcpServer } from '@electron/opencode/playwright-mcp'; + +describe('Playwright MCP runtime resolution', () => { + it('fails closed instead of falling back to npx when the local package is missing', () => { + expect(() => resolvePlaywrightMcpServer(() => { + throw new Error('missing package'); + })).toThrow('Bundled @playwright/mcp is missing'); + }); +}); diff --git a/tests/unit/python-runtime.test.ts b/tests/unit/python-runtime.test.ts index a6825a6..6ea1eed 100644 --- a/tests/unit/python-runtime.test.ts +++ b/tests/unit/python-runtime.test.ts @@ -2,7 +2,12 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { delimiter, dirname, join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import { prependPythonToPath, resolvePythonRuntime } from '@electron/utils/python-runtime'; +import { + prependManagedRuntimesToPath, + prependPythonToPath, + resolvePythonRuntime, + resolveUvRuntime, +} from '@electron/utils/python-runtime'; const temporaryDirectories: string[] = []; @@ -54,6 +59,21 @@ describe('managed Python runtime resolution', () => { })).toEqual({ executable, binDir: dirname(executable), source: 'bundled' }); }); + it('does not allow a packaged build to override Python outside the installation', async () => { + const resourcesPath = await temporaryDirectory('niancode-python-packaged-local-'); + const executable = await createFile(join(resourcesPath, 'python', 'python.exe')); + const configuredPath = await createFile(join(resourcesPath, 'external', 'python.exe')); + + expect(resolvePythonRuntime({ + isPackaged: true, + resourcesPath, + appPath: resourcesPath, + platform: 'win32', + arch: 'x64', + configuredPath, + })).toEqual({ executable, binDir: dirname(executable), source: 'bundled' }); + }); + it('resolves the packaged Unix interpreter', async () => { const resourcesPath = await temporaryDirectory('niancode-python-packaged-unix-'); const executable = await createFile(join(resourcesPath, 'python', 'bin', 'python3')); @@ -110,4 +130,45 @@ describe('managed Python runtime resolution', () => { expect(result.Path).toBe(`${runtime.binDir}${delimiter}existing-bin`); expect(result).not.toHaveProperty('PATH'); }); + + it('resolves packaged uv and prepends both managed runtime directories', async () => { + const resourcesPath = await temporaryDirectory('niancode-uv-packaged-'); + const uvExecutable = await createFile(join(resourcesPath, 'bin', 'uv.exe')); + const pythonExecutable = await createFile(join(resourcesPath, 'python', 'python.exe')); + const uvRuntime = resolveUvRuntime({ + isPackaged: true, + resourcesPath, + appPath: resourcesPath, + platform: 'win32', + arch: 'x64', + }); + + expect(uvRuntime).toEqual({ + executable: uvExecutable, + binDir: dirname(uvExecutable), + source: 'bundled', + }); + expect(prependManagedRuntimesToPath( + { Path: 'C:\\Windows\\System32' }, + { executable: pythonExecutable, binDir: dirname(pythonExecutable), source: 'bundled' }, + uvRuntime, + 'win32', + )).toEqual({ + Path: `${dirname(uvExecutable)}${delimiter}${dirname(pythonExecutable)}${delimiter}C:\\Windows\\System32`, + NIANCODE_PYTHON_PATH: pythonExecutable, + NIANCODE_UV_PATH: uvExecutable, + }); + }); + + it('fails closed when packaged uv is missing', async () => { + const resourcesPath = await temporaryDirectory('niancode-uv-packaged-missing-'); + + expect(() => resolveUvRuntime({ + isPackaged: true, + resourcesPath, + appPath: resourcesPath, + platform: 'win32', + arch: 'x64', + })).toThrow(`Bundled uv runtime is missing: ${join(resourcesPath, 'bin', 'uv.exe')}`); + }); }); diff --git a/tests/unit/route-utils.test.ts b/tests/unit/route-utils.test.ts new file mode 100644 index 0000000..0e5cee0 --- /dev/null +++ b/tests/unit/route-utils.test.ts @@ -0,0 +1,53 @@ +import type { ServerResponse } from 'node:http'; +import { describe, expect, it, vi } from 'vitest'; +import { setCorsHeaders } from '@electron/api/route-utils'; + +describe('Host API CORS', () => { + it('allows only the current dynamic OpenCode runtime origin', () => { + const setHeader = vi.fn(); + const response = { setHeader } as unknown as ServerResponse; + + setCorsHeaders( + response, + 'http://127.0.0.1:54721', + 'http://127.0.0.1:54721', + ); + + expect(setHeader).toHaveBeenCalledWith( + 'Access-Control-Allow-Origin', + 'http://127.0.0.1:54721', + ); + }); + + it('does not allow another loopback port when the runtime uses a dynamic port', () => { + const setHeader = vi.fn(); + const response = { setHeader } as unknown as ServerResponse; + + setCorsHeaders( + response, + 'http://127.0.0.1:54722', + 'http://127.0.0.1:54721', + ); + + expect(setHeader).not.toHaveBeenCalledWith( + 'Access-Control-Allow-Origin', + expect.anything(), + ); + }); + + it('does not retain the preferred runtime port after falling back dynamically', () => { + const setHeader = vi.fn(); + const response = { setHeader } as unknown as ServerResponse; + + setCorsHeaders( + response, + 'http://127.0.0.1:4096', + 'http://127.0.0.1:54721', + ); + + expect(setHeader).not.toHaveBeenCalledWith( + 'Access-Control-Allow-Origin', + expect.anything(), + ); + }); +});