import { execCli } from '@main/utils/childProcess'; import { getAppDataPath } from '@main/utils/pathDecoder'; import { collectRuntimePathBinaryCandidates, isAbsoluteExistingFile, RUNTIME_PATH_SHELL_ENV_TIMEOUT_MS, } from '@main/utils/runtimePathBinaryResolver'; import { safeSendToRenderer } from '@main/utils/safeWebContentsSend'; import { getShellPreferredHome, resolveInteractiveShellEnvBestEffort } from '@main/utils/shellEnv'; import { getErrorMessage } from '@shared/utils/errorHandling'; import { createLogger } from '@shared/utils/logger'; import { createHash, randomUUID } from 'crypto'; import { promises as fsp, readdirSync, readFileSync } from 'fs'; import path from 'path'; import { gunzipSync } from 'zlib'; import type { OpenCodeRuntimeInstallProgress, OpenCodeRuntimeStatus } from '@shared/types'; import type { BrowserWindow } from 'electron'; const logger = createLogger('OpenCodeRuntimeInstallerService'); const CHANNEL = 'openCodeRuntime:progress'; const ROOT_PACKAGE_NAME = 'opencode-ai'; const NPM_REGISTRY_BASE_URL = 'https://registry.npmjs.org'; const CURRENT_MANIFEST_SCHEMA_VERSION = 1; const MAX_TARBALL_BYTES = 250 * 1024 * 1024; const MAX_BINARY_BYTES = 350 * 1024 * 1024; const FETCH_TIMEOUT_MS = 60_000; const VERSION_TIMEOUT_MS = 10_000; interface NpmPackageMetadata { name?: string; version?: string; dist?: { tarball?: string; integrity?: string; }; optionalDependencies?: Record; } interface OpenCodeRuntimeManifest { schemaVersion: 1; version: string; platformPackage: string; binaryPath: string; integrity: string; installedAt: string; } export interface PlatformCandidate { packageName: string; reason: string; } function getRuntimeRootPath(): string { return path.join(getAppDataPath(), 'runtimes', 'opencode'); } function getCurrentManifestPath(): string { return path.join(getRuntimeRootPath(), 'current.json'); } function parseManifest(value: unknown): OpenCodeRuntimeManifest | null { if (!value || typeof value !== 'object' || Array.isArray(value)) { return null; } const manifest = value as Partial; if ( manifest.schemaVersion !== CURRENT_MANIFEST_SCHEMA_VERSION || typeof manifest.version !== 'string' || typeof manifest.platformPackage !== 'string' || typeof manifest.binaryPath !== 'string' || typeof manifest.integrity !== 'string' || typeof manifest.installedAt !== 'string' ) { return null; } return manifest as OpenCodeRuntimeManifest; } function readCurrentManifestSync(): OpenCodeRuntimeManifest | null { try { const raw = readFileSync(getCurrentManifestPath(), 'utf8'); return parseManifest(JSON.parse(raw)); } catch { return null; } } export function resolveAppManagedOpenCodeRuntimeBinaryPath(): string | null { const manifest = readCurrentManifestSync(); return isAbsoluteExistingFile(manifest?.binaryPath) ? manifest.binaryPath : null; } export async function resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath(): Promise { const binaryPath = resolveAppManagedOpenCodeRuntimeBinaryPath(); if (!binaryPath) { return null; } try { await execCli(binaryPath, ['--version'], { timeout: VERSION_TIMEOUT_MS, windowsHide: true, }); return binaryPath; } catch { return null; } } function getExecutableName(): string { return process.platform === 'win32' ? 'opencode.exe' : 'opencode'; } function getPathExecutableNames(): string[] { return process.platform === 'win32' ? ['opencode.exe', 'opencode.cmd', 'opencode.bat', 'opencode'] : ['opencode']; } function collectPathOpenCodeBinaryCandidates( additionalEnvSources: (NodeJS.ProcessEnv | null | undefined)[] = [], options: { includeFallbackPathEntries?: boolean } = {} ): string[] { return collectRuntimePathBinaryCandidates({ executableNames: getPathExecutableNames(), additionalEnvSources, includeFallbackPathEntries: options.includeFallbackPathEntries, extraCandidates: collectNvmOpenCodeBinaryCandidates(), }); } function collectNvmOpenCodeBinaryCandidates(): string[] { if (process.platform === 'win32') { const appdata = process.env.APPDATA; if (!appdata) { return []; } return collectVersionedOpenCodeBinaryCandidates(path.join(appdata, 'nvm')); } return collectVersionedOpenCodeBinaryCandidates( path.join(getShellPreferredHome(), '.nvm', 'versions', 'node'), 'bin' ); } function collectVersionedOpenCodeBinaryCandidates(rootPath: string, binSegment = ''): string[] { let versions: string[]; try { versions = readdirSync(rootPath); } catch { return []; } return versions .toSorted((left, right) => right.localeCompare(left, undefined, { numeric: true })) .flatMap((version) => { const versionPath = binSegment ? path.join(rootPath, version, binSegment) : path.join(rootPath, version); return getPathExecutableNames().map((executableName) => path.join(versionPath, executableName) ); }); } type OpenCodeBinaryVersionProbe = | { ok: true; version: string | null } | { ok: false; error: string }; type VerifiedOpenCodeBinaryProbe = | { ok: true; binaryPath: string; version: string | null } | { ok: false; firstFailure: { binaryPath: string; error: string } | null }; async function probeOpenCodeBinaryVersion(binaryPath: string): Promise { try { const { stdout } = await execCli(binaryPath, ['--version'], { timeout: VERSION_TIMEOUT_MS, windowsHide: true, }); return { ok: true, version: stdout.trim() || null }; } catch (error) { return { ok: false, error: getErrorMessage(error) }; } } function normalizeBinaryCandidateForCompare(binaryPath: string): string { const normalized = path.resolve(binaryPath); return process.platform === 'win32' ? normalized.toLowerCase() : normalized; } async function probeFirstWorkingOpenCodeBinaryCandidate( candidates: string[], seen: Set, firstFailure: { binaryPath: string; error: string } | null ): Promise { let nextFirstFailure = firstFailure; for (const binaryPath of candidates) { const normalized = normalizeBinaryCandidateForCompare(binaryPath); if (seen.has(normalized)) { continue; } seen.add(normalized); const version = await probeOpenCodeBinaryVersion(binaryPath); if (version.ok) { return { ok: true, binaryPath, version: version.version }; } nextFirstFailure ??= { binaryPath, error: version.error }; } return { ok: false, firstFailure: nextFirstFailure }; } interface OpenCodeRuntimeBinaryResolveOptions { shellEnvTimeoutMs?: number; includeShellEnv?: boolean; } async function probeFirstWorkingPathOpenCodeBinary( options: OpenCodeRuntimeBinaryResolveOptions = {} ): Promise { const seenCandidates = new Set(); let firstFailure: { binaryPath: string; error: string } | null = null; const cachedProbe = await probeFirstWorkingOpenCodeBinaryCandidate( collectPathOpenCodeBinaryCandidates([], { includeFallbackPathEntries: false, }), seenCandidates, firstFailure ); if (cachedProbe.ok) { return cachedProbe; } firstFailure = cachedProbe.firstFailure; if (options.includeShellEnv === false) { return probeFirstWorkingOpenCodeBinaryCandidate( collectPathOpenCodeBinaryCandidates([], { includeFallbackPathEntries: true, }), seenCandidates, firstFailure ); } const shellEnv = await resolveInteractiveShellEnvBestEffort({ timeoutMs: options.shellEnvTimeoutMs ?? RUNTIME_PATH_SHELL_ENV_TIMEOUT_MS, fallbackEnv: process.env, }); const shellProbe = await probeFirstWorkingOpenCodeBinaryCandidate( collectPathOpenCodeBinaryCandidates([shellEnv], { includeFallbackPathEntries: false, }), seenCandidates, firstFailure ); if (shellProbe.ok) { return shellProbe; } firstFailure = shellProbe.firstFailure; return probeFirstWorkingOpenCodeBinaryCandidate( collectPathOpenCodeBinaryCandidates([shellEnv]), seenCandidates, firstFailure ); } async function resolveVerifiedPathOpenCodeBinaryPath( options: OpenCodeRuntimeBinaryResolveOptions = {} ): Promise { const result = await probeFirstWorkingPathOpenCodeBinary(options); return result.ok ? result.binaryPath : null; } export async function resolveVerifiedOpenCodeRuntimeBinaryPath( options: OpenCodeRuntimeBinaryResolveOptions = {} ): Promise { return ( (await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath()) ?? (await resolveVerifiedPathOpenCodeBinaryPath(options)) ); } function isLinuxMuslRuntime(): boolean { if (process.platform !== 'linux') { return false; } const report = typeof process.report?.getReport === 'function' ? (process.report.getReport() as { header?: { glibcVersionRuntime?: string } }) : null; const header = report?.header; return !header?.glibcVersionRuntime; } export function getOpenCodeRuntimePlatformCandidates( platform: NodeJS.Platform = process.platform, arch: string = process.arch, musl: boolean = isLinuxMuslRuntime() ): PlatformCandidate[] { if (platform === 'darwin') { if (arch === 'arm64') return [{ packageName: 'opencode-darwin-arm64', reason: 'macOS arm64' }]; if (arch === 'x64') { return [ { packageName: 'opencode-darwin-x64', reason: 'macOS x64' }, { packageName: 'opencode-darwin-x64-baseline', reason: 'macOS x64 baseline fallback' }, ]; } } if (platform === 'linux') { if (arch === 'arm64') { return musl ? [ { packageName: 'opencode-linux-arm64-musl', reason: 'Linux arm64 musl' }, { packageName: 'opencode-linux-arm64', reason: 'Linux arm64 glibc fallback' }, ] : [ { packageName: 'opencode-linux-arm64', reason: 'Linux arm64 glibc' }, { packageName: 'opencode-linux-arm64-musl', reason: 'Linux arm64 musl fallback' }, ]; } if (arch === 'x64') { return musl ? [ { packageName: 'opencode-linux-x64-musl', reason: 'Linux x64 musl' }, { packageName: 'opencode-linux-x64-baseline-musl', reason: 'Linux x64 musl baseline fallback', }, { packageName: 'opencode-linux-x64', reason: 'Linux x64 glibc fallback' }, ] : [ { packageName: 'opencode-linux-x64', reason: 'Linux x64 glibc' }, { packageName: 'opencode-linux-x64-baseline', reason: 'Linux x64 baseline fallback' }, { packageName: 'opencode-linux-x64-musl', reason: 'Linux x64 musl fallback' }, ]; } } if (platform === 'win32') { if (arch === 'arm64') return [{ packageName: 'opencode-windows-arm64', reason: 'Windows arm64' }]; if (arch === 'x64') { return [ { packageName: 'opencode-windows-x64', reason: 'Windows x64' }, { packageName: 'opencode-windows-x64-baseline', reason: 'Windows x64 baseline fallback' }, ]; } } throw new Error(`OpenCode app install is not supported on ${platform}/${arch}`); } async function fetchText(url: string): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); try { const response = await fetch(url, { signal: controller.signal }); if (!response.ok) { throw new Error(`HTTP ${response.status} from ${url}`); } return await response.text(); } finally { clearTimeout(timer); } } async function fetchPackageMetadata( packageName: string, version = 'latest' ): Promise { const url = `${NPM_REGISTRY_BASE_URL}/${encodeURIComponent(packageName)}/${encodeURIComponent(version)}`; const raw = await fetchText(url); const parsed = JSON.parse(raw) as NpmPackageMetadata; if (!parsed.version || !parsed.dist?.tarball || !parsed.dist.integrity) { throw new Error(`Invalid npm metadata for ${packageName}@${version}`); } return parsed; } export function verifyOpenCodeRuntimePackageIntegrity(buffer: Buffer, integrity: string): void { const match = /^sha512-([A-Za-z0-9+/=]+)$/.exec(integrity.trim()); if (!match) { throw new Error('OpenCode package integrity is missing sha512 metadata'); } const actual = createHash('sha512').update(buffer).digest('base64'); if (actual !== match[1]) { throw new Error('OpenCode package integrity check failed'); } } async function downloadTarball( url: string, onProgress: (progress: OpenCodeRuntimeInstallProgress) => void ): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); try { const response = await fetch(url, { signal: controller.signal }); if (!response.ok || !response.body) { throw new Error(`Failed to download OpenCode package: HTTP ${response.status}`); } const totalHeader = response.headers.get('content-length'); const totalBytes = totalHeader ? Number.parseInt(totalHeader, 10) : undefined; if (totalBytes && totalBytes > MAX_TARBALL_BYTES) { throw new Error('OpenCode package is unexpectedly large'); } const chunks: Buffer[] = []; let downloadedBytes = 0; const reader = response.body.getReader(); while (true) { const { done, value } = await reader.read(); if (done) { break; } const chunk = Buffer.from(value); downloadedBytes += chunk.length; if (downloadedBytes > MAX_TARBALL_BYTES) { throw new Error('OpenCode package exceeded the maximum allowed download size'); } chunks.push(chunk); onProgress({ phase: 'downloading', downloadedBytes, totalBytes, percent: totalBytes ? Math.min(100, Math.round((downloadedBytes / totalBytes) * 100)) : undefined, detail: totalBytes ? `Downloading OpenCode ${Math.round((downloadedBytes / totalBytes) * 100)}%` : 'Downloading OpenCode...', }); } return Buffer.concat(chunks, downloadedBytes); } finally { clearTimeout(timer); } } function readTarString(buffer: Buffer, start: number, length: number): string { const end = buffer.indexOf(0, start); const safeEnd = end >= start && end < start + length ? end : start + length; return buffer.toString('utf8', start, safeEnd).trim(); } function readTarSize(buffer: Buffer, offset: number): number { const raw = readTarString(buffer, offset + 124, 12) .replace(/\0/g, '') .trim(); const size = Number.parseInt(raw || '0', 8); if (!Number.isFinite(size) || size < 0) { throw new Error('Invalid OpenCode package tar entry size'); } return size; } function assertSafeTarPath(name: string): void { if ( !name || name.startsWith('/') || name.startsWith('\\') || name.includes('..') || name.includes('\\') ) { throw new Error(`Unsafe OpenCode package tar entry: ${name}`); } } export function extractOpenCodeRuntimeBinaryFromTarball(tarball: Buffer): Buffer { const tar = gunzipSync(tarball, { maxOutputLength: MAX_BINARY_BYTES + 1024 * 1024 }); const targetName = `package/bin/${getExecutableName()}`; let offset = 0; while (offset + 512 <= tar.length) { const name = readTarString(tar, offset, 100); if (!name) { break; } const prefix = readTarString(tar, offset + 345, 155); const fullName = prefix ? `${prefix}/${name}` : name; assertSafeTarPath(fullName); const typeFlag = readTarString(tar, offset + 156, 1); const size = readTarSize(tar, offset); const dataStart = offset + 512; const dataEnd = dataStart + size; if (dataEnd > tar.length) { throw new Error('OpenCode package tar entry exceeds archive bounds'); } if ((typeFlag === '0' || typeFlag === '') && fullName === targetName) { if (size <= 0 || size > MAX_BINARY_BYTES) { throw new Error('OpenCode binary size is invalid'); } return tar.subarray(dataStart, dataEnd); } offset = dataStart + Math.ceil(size / 512) * 512; } throw new Error(`OpenCode package did not contain ${targetName}`); } async function readCurrentManifest(): Promise { try { const raw = await fsp.readFile(getCurrentManifestPath(), 'utf8'); return parseManifest(JSON.parse(raw)); } catch { return null; } } export class OpenCodeRuntimeInstallerService { private mainWindow: BrowserWindow | null = null; private installPromise: Promise | null = null; private latestStatus: OpenCodeRuntimeStatus | null = null; setMainWindow(win: BrowserWindow | null): void { this.mainWindow = win; } invalidateStatusCache(): void { this.latestStatus = null; } async getStatus(): Promise { if (this.installPromise && this.latestStatus) { return this.latestStatus; } const appManagedStatus = await this.getAppManagedStatus(); if (appManagedStatus.installed) { this.latestStatus = appManagedStatus; return appManagedStatus; } const pathStatus = await this.getPathStatus(); const status = pathStatus.installed || appManagedStatus.source !== 'app-managed' || appManagedStatus.state !== 'failed' ? pathStatus : appManagedStatus; this.latestStatus = status; return status; } async install(): Promise { if (this.installPromise) { return this.installPromise; } this.installPromise = this.installInternal().finally(() => { this.installPromise = null; }); return this.installPromise; } private publish(status: OpenCodeRuntimeStatus): void { this.latestStatus = status; safeSendToRenderer(this.mainWindow, CHANNEL, status); } private publishProgress(progress: OpenCodeRuntimeInstallProgress): void { this.publish({ installed: false, source: 'missing', state: progress.phase, progress, }); } private async getAppManagedStatus(): Promise { const manifest = await readCurrentManifest(); if (!isAbsoluteExistingFile(manifest?.binaryPath)) { return { installed: false, source: 'missing', state: 'idle' }; } try { const { stdout } = await execCli(manifest.binaryPath, ['--version'], { timeout: VERSION_TIMEOUT_MS, windowsHide: true, }); return { installed: true, binaryPath: manifest.binaryPath, version: stdout.trim() || manifest.version, source: 'app-managed', state: 'ready', }; } catch (error) { return { installed: false, binaryPath: manifest.binaryPath, version: manifest.version, source: 'app-managed', state: 'failed', error: getErrorMessage(error), }; } } private async getPathStatus(): Promise { const result = await probeFirstWorkingPathOpenCodeBinary(); if (result.ok) { return { installed: true, binaryPath: result.binaryPath, version: result.version ?? undefined, source: 'path', state: 'ready', }; } if (!result.firstFailure) { return { installed: false, source: 'missing', state: 'idle' }; } return { installed: false, binaryPath: result.firstFailure.binaryPath, source: 'path', state: 'failed', error: result.firstFailure.error, }; } private async installInternal(): Promise { try { this.publishProgress({ phase: 'checking', detail: 'Resolving latest OpenCode package...' }); const rootMetadata = await fetchPackageMetadata(ROOT_PACKAGE_NAME); const candidates = getOpenCodeRuntimePlatformCandidates(); const optionalDependencies = rootMetadata.optionalDependencies ?? {}; const selected = candidates.find((candidate) => optionalDependencies[candidate.packageName]); if (!selected) { throw new Error( `No OpenCode binary package is available for ${process.platform}/${process.arch}` ); } const platformVersion = optionalDependencies[selected.packageName] ?? rootMetadata.version!; const normalizedVersion = platformVersion.replace(/^[~^]/, ''); const platformMetadata = await fetchPackageMetadata(selected.packageName, normalizedVersion); this.publishProgress({ phase: 'downloading', detail: `Downloading ${selected.packageName}@${platformMetadata.version}...`, }); const tarball = await downloadTarball(platformMetadata.dist!.tarball!, (progress) => { this.publishProgress(progress); }); verifyOpenCodeRuntimePackageIntegrity(tarball, platformMetadata.dist!.integrity!); this.publishProgress({ phase: 'installing', detail: 'Extracting OpenCode binary...' }); const binary = extractOpenCodeRuntimeBinaryFromTarball(tarball); const runtimeRoot = getRuntimeRootPath(); const tempDir = path.join(runtimeRoot, `installing-${process.pid}-${randomUUID()}`); const versionDir = path.join( runtimeRoot, 'versions', platformMetadata.version!, selected.packageName ); const binaryPath = path.join(versionDir, getExecutableName()); await fsp.rm(tempDir, { recursive: true, force: true }); await fsp.mkdir(tempDir, { recursive: true }); const tempBinaryPath = path.join(tempDir, getExecutableName()); await fsp.writeFile(tempBinaryPath, binary); if (process.platform !== 'win32') { // Required so the downloaded OpenCode platform binary can be spawned. // eslint-disable-next-line sonarjs/file-permissions -- app-managed CLI binary must be executable await fsp.chmod(tempBinaryPath, 0o755); } this.publishProgress({ phase: 'installing', detail: 'Verifying OpenCode binary...' }); const { stdout } = await execCli(tempBinaryPath, ['--version'], { timeout: VERSION_TIMEOUT_MS, windowsHide: true, }); await fsp.rm(versionDir, { recursive: true, force: true }); await fsp.mkdir(path.dirname(versionDir), { recursive: true }); await fsp.rename(tempDir, versionDir); const manifest: OpenCodeRuntimeManifest = { schemaVersion: CURRENT_MANIFEST_SCHEMA_VERSION, version: stdout.trim() || platformMetadata.version!, platformPackage: selected.packageName, binaryPath, integrity: platformMetadata.dist!.integrity!, installedAt: new Date().toISOString(), }; await fsp.writeFile( getCurrentManifestPath(), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8' ); const status: OpenCodeRuntimeStatus = { installed: true, binaryPath, version: manifest.version, source: 'app-managed', state: 'ready', progress: { phase: 'ready', percent: 100, detail: `Installed OpenCode ${manifest.version}`, }, }; this.publish(status); return status; } catch (error) { const status: OpenCodeRuntimeStatus = { installed: false, source: 'missing', state: 'failed', error: getErrorMessage(error), progress: { phase: 'failed', detail: getErrorMessage(error), }, }; logger.error('Failed to install OpenCode runtime:', status.error); this.publish(status); return status; } } }