From 1439f5e8380330d31e08bcd9ade632d1a27d3d38 Mon Sep 17 00:00:00 2001 From: Issue Reproducer Date: Thu, 18 Jun 2026 15:00:44 +0000 Subject: [PATCH 01/10] reproduce(issue-1720): add reproduction script for brew opencode detection on macOS --- scripts/reproduce-issue-1720.mjs | 322 +++++++++++++++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 scripts/reproduce-issue-1720.mjs diff --git a/scripts/reproduce-issue-1720.mjs b/scripts/reproduce-issue-1720.mjs new file mode 100644 index 00000000..0844bbb8 --- /dev/null +++ b/scripts/reproduce-issue-1720.mjs @@ -0,0 +1,322 @@ +#!/usr/bin/env node +/** + * Reproduction script for issue #1720: + * Mac initial setup process does not detect opencode installed via brew. + * + * This script simulates the EXACT opencode binary detection chain that + * runs during initial setup (first launch) of the OpenChamber desktop app + * on macOS. + * + * Detection chain in packages/web/server/lib/opencode/env-runtime.js: + * Step 1: Check env vars (OPENCODE_BINARY, OPENCODE_PATH, etc.) + * Step 2: searchPathFor('opencode') — walks process.env.PATH + * Step 3: Hardcoded fallback paths (includes /opt/homebrew/bin/opencode, + * /usr/local/bin/opencode, etc.) + * Step 4: Shell probing — $SHELL -lic 'command -v opencode' + * (⚠️ NO TIMEOUT on spawnSync) + * + * On macOS, apps launched from the Dock/Finder inherit a minimal PATH: + * /usr/bin:/bin:/usr/sbin:/sbin + * This does NOT include any brew bin directories. + * + * The Electron main process (main.mjs) tries to augment PATH by probing + * the user's login shell with: + * spawnSync($SHELL, ['-il', '-c', 'env -0'], { timeout: 5000 }) + * + * If the shell startup is slow (>5s due to nvm, pyenv, etc.), this times + * out and PATH stays minimal. The server-side probing (in index.js:652) + * also calls spawnSync but with NO timeout at all — potentially hanging + * the module initialization indefinitely. + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); + +// For accurate reproduction, try to find the actual env-runtime.js +const ENV_RUNTIME_PATH = path.resolve( + path.dirname(__filename), + '..', + 'packages/web/server/lib/opencode/env-runtime.js' +); + +// ── Helpers ────────────────────────────────────────────────── + +function isExecutable(filePath) { + try { + const stat = fs.statSync(filePath); + if (!stat.isFile()) return false; + if (process.platform === 'win32') { + const ext = path.extname(filePath).toLowerCase(); + if (!ext) return true; + return ['.exe', '.cmd', '.bat', '.com'].includes(ext); + } + fs.accessSync(filePath, fs.constants.X_OK); + return true; + } catch { + return false; + } +} + +function searchPathFor(binaryName, envPath) { + const trimmed = typeof binaryName === 'string' ? binaryName.trim() : ''; + if (!trimmed) return null; + const current = typeof envPath === 'string' ? envPath : ''; + const parts = current.split(path.delimiter).filter(Boolean); + const candidateNames = [trimmed]; + for (const dir of parts) { + for (const candidateName of candidateNames) { + const candidate = path.join(dir, candidateName); + if (isExecutable(candidate)) return candidate; + } + } + return null; +} + +// ── Print section header ───────────────────────────────────── + +function section(title) { + console.log(`\n${'─'.repeat(60)}`); + console.log(` ${title}`); + console.log(`${'─'.repeat(60)}`); +} + +// ── Main reproduction ──────────────────────────────────────── + +async function main() { + console.log(`🔍 Issue #1720 Reproduction`); + console.log(` Platform: ${process.platform} (${process.arch})`); + console.log(` Node: ${process.version}`); + console.log(` env-runtime.js: ${fs.existsSync(ENV_RUNTIME_PATH) ? 'found' : 'not found'}`); + console.log(` Timestamp: ${new Date().toISOString()}`); + console.log(); + + // ── Step 0: Find actual opencode binary on system ───────── + + section('Step 0: Locate opencode on this system'); + + let whichPath = null; + try { + const r = spawnSync('which', ['opencode'], { encoding: 'utf8' }); + if (r.status === 0 && r.stdout.trim()) { + whichPath = r.stdout.trim(); + console.log(` 'which opencode' → ${whichPath}`); + console.log(` Is executable: ${isExecutable(whichPath)}`); + if (isExecutable(whichPath)) { + const realPath = fs.realpathSync(whichPath); + console.log(` Real path (resolved symlinks): ${realPath}`); + } + } else { + console.log(` 'which opencode' failed (status ${r.status})`); + } + } catch (err) { + console.log(` 'which opencode' error: ${err.message}`); + } + + // Also try 'command -v opencode' + try { + const r = spawnSync('sh', ['-c', 'command -v opencode'], { encoding: 'utf8' }); + if (r.status === 0 && r.stdout.trim()) { + console.log(` 'command -v opencode' → ${r.stdout.trim()}`); + } + } catch {} + + console.log(); + + // ── Step 1: Check brew prefix ───────────────────────────── + + section('Step 1: Determine brew installation path'); + + for (const cmd of ['/opt/homebrew/bin/brew', '/usr/local/bin/brew']) { + if (isExecutable(cmd)) { + try { + const r = spawnSync(cmd, ['--prefix'], { encoding: 'utf8' }); + if (r.status === 0) { + const prefix = r.stdout.trim(); + console.log(` Brew at ${cmd}, prefix: ${prefix}`); + console.log(` Expected opencode path: ${path.join(prefix, 'bin', 'opencode')}`); + console.log(` Exists & executable: ${isExecutable(path.join(prefix, 'bin', 'opencode'))}`); + } + } catch {} + } + } + + // Check if opencode exists at known brew paths + for (const candidate of ['/opt/homebrew/bin/opencode', '/usr/local/bin/opencode']) { + const marker = isExecutable(candidate) ? '✓ EXISTS' : '✗ NOT FOUND'; + console.log(` ${marker} ${candidate}`); + } + + // ── Step 2: Simulate Dock-launched environment ──────────── + + section('Step 2: Simulate macOS Dock/Finder-launched environment'); + + // On macOS, the Dock gives a minimal PATH + const DOCK_PATH = '/usr/bin:/bin:/usr/sbin:/sbin'; + const RUNNER_PATH = process.env.PATH || ''; + const isDockLike = RUNNER_PATH === DOCK_PATH; + + console.log(` Actual PATH ${isDockLike ? '=' : '≠'} Dock PATH`); + console.log(` Actual: ${RUNNER_PATH}`); + console.log(` Dock: ${DOCK_PATH}`); + console.log(` SHELL: ${process.env.SHELL || '(not set)'}`); + + // ── Step 3: Run the detection chain ─────────────────────── + + section('Step 3: Run opencode binary detection chain'); + + // 3a. Env vars (fresh install: none set) + console.log('\n 🔹 Step 3a: Environment variables'); + const envVars = ['OPENCODE_BINARY', 'OPENCODE_PATH', 'OPENCHAMBER_OPENCODE_PATH', 'OPENCHAMBER_OPENCODE_BIN']; + for (const v of envVars) { + console.log(` ${v}=${process.env[v] || '(not set)'}`); + } + + // 3b. PATH search + console.log('\n 🔹 Step 3b: PATH search (searchPathFor)'); + const envPath = process.env.PATH || DOCK_PATH; + const pathResult = searchPathFor('opencode', envPath); + if (pathResult) { + console.log(` ✓ Found: ${pathResult}`); + } else { + console.log(` ✗ Not found in PATH`); + console.log(` (PATH=${envPath})`); + } + + // 3c. Hardcoded fallbacks + console.log('\n 🔹 Step 3c: Hardcoded fallback paths'); + const home = os.homedir(); + const unixFallbacks = [ + path.join(home, '.opencode', 'bin', 'opencode'), + path.join(home, '.bun', 'bin', 'opencode'), + path.join(home, '.local', 'bin', 'opencode'), + path.join(home, 'bin', 'opencode'), + '/opt/homebrew/bin/opencode', + '/usr/local/bin/opencode', + '/usr/bin/opencode', + '/bin/opencode', + ]; + + let foundInFallbacks = false; + for (const candidate of unixFallbacks) { + const ok = isExecutable(candidate); + console.log(` ${ok ? '✓' : '✗'} ${candidate}`); + if (ok) foundInFallbacks = true; + } + + // 3d. Shell probing (last resort) + console.log('\n 🔹 Step 3d: Shell probing (last resort)'); + const shellCandidates = [ + process.env.SHELL || '', + '/bin/zsh', + '/bin/bash', + '/bin/sh', + ].filter(Boolean).filter((s) => isExecutable(s)); + + console.log(` Available shells: ${shellCandidates.length > 0 ? shellCandidates.join(', ') : 'NONE'}`); + + let shellFound = false; + for (const shell of shellCandidates) { + try { + console.log(` Probing: ${shell} -lic 'command -v opencode'`); + const result = spawnSync(shell, ['-lic', 'command -v opencode'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + timeout: 10000, + }); + if (result.status === 0) { + const found = (result.stdout || '').trim().split(/\s+/).pop() || ''; + if (found && isExecutable(found)) { + console.log(` ✓ Found: ${found}`); + shellFound = true; + break; + } + } + if (result.error) { + console.log(` ⚠ Error: ${result.error.message}`); + } else if (result.status !== 0) { + console.log(` ✗ Status ${result.status}`); + } + } catch (err) { + console.log(` ⚠ Exception: ${err.message}`); + } + } + + if (!shellFound) { + console.log(' ✗ Not found via shell probing'); + } + + // ── Summary ────────────────────────────────────────────── + + section('Root Cause Analysis'); + + const found = pathResult || foundInFallbacks || shellFound || (whichPath && isExecutable(whichPath)); + + if (found) { + console.log(` + ✅ The binary CAN be found by at least one detection method on + this system. However, this does not rule out the bug on all + macOS configurations. + + ⚠️ POTENTIAL ISSUES (macOS-specific): + + 1. SHELL PROBING HAS NO TIMEOUT (env-runtime.js:366): + The shell probing step at line 366 calls spawnSync without + a timeout. If the user's shell startup is slow (>5s due to + nvm, pyenv, etc.), this blocks indefinitely. + + 2. ELECTRON PROBE HAS 5s TIMEOUT (main.mjs:1023): + The Electron main process probes the shell with a 5-second + timeout. If the shell takes >5s, PATH is NOT augmented. + + 3. SERVER-SIDE SHELL PROBE HAS NO TIMEOUT (env-runtime.js:205): + The server's getLoginShellEnvSnapshot() also has no timeout, + potentially blocking module initialization at index.js:652. + + 4. FILE-SYSTEM PERMISSION ISSUE: + On macOS with Full Disk Protection, the Electron sandbox + may restrict access to files outside the app container. + + 5. BREW INSTALLATION PATH NOT COVERED: + If brew is installed at a custom prefix (not /opt/homebrew + or /usr/local), the hardcoded fallbacks won't match. +`); + } else { + console.log(` + ❌ The binary was NOT found by ANY detection method. This + confirms the root cause: the detection chain failed. + + The hardcoded fallback paths only cover: + - /opt/homebrew/bin/opencode (Apple Silicon standard) + - /usr/local/bin/opencode (Intel standard) + + If opencode is at a different location, it won't be found. +`); + } + + console.log(` Binary path (from 'which opencode'): ${whichPath || '(not found)'}`); + if (whichPath && !unixFallbacks.includes(whichPath)) { + console.log(` ⚠️ NOT in hardcoded fallbacks! + The detected path ${whichPath} is not among the hardcoded + fallback paths. This is likely the root cause.`); + } + + console.log(` + ────────────────────────────────────────────────────────── + Recommended fixes: + 1. Add a timeout to ALL spawnSync calls in the detection chain + (especially env-runtime.js lines 205, 366) + 2. Consider using 'which opencode' or 'command -v opencode' + as a standalone fallback with a short timeout + 3. The existing hardcoded paths DO cover standard brew paths, + so if the binary is at /opt/homebrew/bin/opencode or + /usr/local/bin/opencode, detection should work +`); +} + +main().catch(console.error); From 13caa698f3a00b13f7f69dc70fd6853cd06e15b3 Mon Sep 17 00:00:00 2001 From: Mayuresh Kadu <23300+mskadu@users.noreply.github.com> Date: Fri, 19 Jun 2026 22:14:25 +0100 Subject: [PATCH 02/10] server: add 5s timeout to all shell probes in env-runtime.js --- packages/web/server/lib/opencode/env-runtime.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/web/server/lib/opencode/env-runtime.js b/packages/web/server/lib/opencode/env-runtime.js index 998128d3..892fb1ba 100644 --- a/packages/web/server/lib/opencode/env-runtime.js +++ b/packages/web/server/lib/opencode/env-runtime.js @@ -4,6 +4,8 @@ import os from 'node:os'; import path from 'node:path'; import { mergePathValues } from './path-utils.js'; +const SHELL_PROBE_TIMEOUT_MS = 5_000; + export const createOpenCodeEnvRuntime = (deps) => { const { state, @@ -207,6 +209,7 @@ export const createOpenCodeEnvRuntime = (deps) => { stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 10 * 1024 * 1024, windowsHide: true, + timeout: SHELL_PROBE_TIMEOUT_MS, }); if (result.status !== 0) { @@ -367,6 +370,7 @@ export const createOpenCodeEnvRuntime = (deps) => { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, + timeout: SHELL_PROBE_TIMEOUT_MS, }); if (result.status === 0) { const found = (result.stdout || '').trim().split(/\s+/).pop() || ''; @@ -434,6 +438,7 @@ export const createOpenCodeEnvRuntime = (deps) => { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, + timeout: SHELL_PROBE_TIMEOUT_MS, }); if (result.status === 0) { const found = (result.stdout || '').trim().split(/\s+/).pop() || ''; @@ -515,6 +520,7 @@ export const createOpenCodeEnvRuntime = (deps) => { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, + timeout: SHELL_PROBE_TIMEOUT_MS, }); if (result.status === 0) { const found = (result.stdout || '').trim().split(/\s+/).pop() || ''; From 3d436d88e84dbc7de256d0216b776bd293090db8 Mon Sep 17 00:00:00 2001 From: Mayuresh Kadu <23300+mskadu@users.noreply.github.com> Date: Sat, 20 Jun 2026 09:05:24 +0100 Subject: [PATCH 03/10] server: add fast command -v path before login shell probing --- .../web/server/lib/opencode/env-runtime.js | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/packages/web/server/lib/opencode/env-runtime.js b/packages/web/server/lib/opencode/env-runtime.js index 892fb1ba..a9b919cf 100644 --- a/packages/web/server/lib/opencode/env-runtime.js +++ b/packages/web/server/lib/opencode/env-runtime.js @@ -362,6 +362,30 @@ export const createOpenCodeEnvRuntime = (deps) => { return null; } + // Fast path: 'command -v' via plain sh (no login shell, no .zshrc sourcing). + // This is much faster than the full login shell probe below and catches + // standard brew paths even when launched with minimal PATH. + if (process.platform !== 'win32') { + try { + const fastResult = runSpawnSync('/bin/sh', ['-c', 'command -v opencode'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + timeout: SHELL_PROBE_TIMEOUT_MS, + }); + if (fastResult.status === 0) { + const found = (fastResult.stdout || '').trim().split(/\s+/).pop() || ''; + if (found && isExecutable(found)) { + clearWslOpencodeResolution(); + state.resolvedOpencodeBinarySource = 'shell'; + return found; + } + } + } catch { + // Fall through to login shell probe + } + } + const shells = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean); for (const shell of shells) { if (!isExecutable(shell)) continue; @@ -430,6 +454,28 @@ export const createOpenCodeEnvRuntime = (deps) => { return null; } + // Fast path: 'command -v' via plain sh (no login shell, no .zshrc sourcing). + // This is much faster than the full login shell probe below and catches + // standard brew paths even when launched with minimal PATH. + if (process.platform !== 'win32') { + try { + const fastResult = runSpawnSync('/bin/sh', ['-c', 'command -v node'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + timeout: SHELL_PROBE_TIMEOUT_MS, + }); + if (fastResult.status === 0) { + const found = (fastResult.stdout || '').trim().split(/\s+/).pop() || ''; + if (found && isExecutable(found)) { + return found; + } + } + } catch { + // Fall through to login shell probe + } + } + const shells = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean); for (const shell of shells) { if (!isExecutable(shell)) continue; @@ -512,6 +558,28 @@ export const createOpenCodeEnvRuntime = (deps) => { return null; } + // Fast path: 'command -v' via plain sh (no login shell, no .zshrc sourcing). + // This is much faster than the full login shell probe below and catches + // standard brew paths even when launched with minimal PATH. + if (process.platform !== 'win32') { + try { + const fastResult = runSpawnSync('/bin/sh', ['-c', 'command -v bun'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + timeout: SHELL_PROBE_TIMEOUT_MS, + }); + if (fastResult.status === 0) { + const found = (fastResult.stdout || '').trim().split(/\s+/).pop() || ''; + if (found && isExecutable(found)) { + return found; + } + } + } catch { + // Fall through to login shell probe + } + } + const shells = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean); for (const shell of shells) { if (!isExecutable(shell)) continue; From d781c302e207be395b6a5c5c25c05adde8b44280 Mon Sep 17 00:00:00 2001 From: Mayuresh Kadu <23300+mskadu@users.noreply.github.com> Date: Sat, 20 Jun 2026 09:09:15 +0100 Subject: [PATCH 04/10] server+vsce: fix brew path coverage (TOOLCHAIN_SEGMENTS, fallback order) --- packages/vscode/src/opencode.ts | 2 +- packages/web/server/lib/opencode/path-utils.js | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/vscode/src/opencode.ts b/packages/vscode/src/opencode.ts index edcb47a9..a85ef453 100644 --- a/packages/vscode/src/opencode.ts +++ b/packages/vscode/src/opencode.ts @@ -317,8 +317,8 @@ function resolveOpencodeCliPath(): string | null { path.join(home, '.opencode', 'bin', 'opencode'), path.join(home, '.bun', 'bin', 'opencode'), path.join(home, '.local', 'bin', 'opencode'), - '/usr/local/bin/opencode', '/opt/homebrew/bin/opencode', + '/usr/local/bin/opencode', path.join(home, 'bin', 'opencode'), ]; diff --git a/packages/web/server/lib/opencode/path-utils.js b/packages/web/server/lib/opencode/path-utils.js index 4803593a..732c4921 100644 --- a/packages/web/server/lib/opencode/path-utils.js +++ b/packages/web/server/lib/opencode/path-utils.js @@ -12,6 +12,7 @@ const TOOLCHAIN_SEGMENTS = [ '/opt/pkg/', '/opt/pmk/', '/snap/', + '/usr/local/', ]; const TOOLCHAIN_BASENAMES = new Set([ From 390a7d0a66f00f9fa79860846f26f1d0b4cf1b14 Mon Sep 17 00:00:00 2001 From: Mayuresh Kadu <23300+mskadu@users.noreply.github.com> Date: Sat, 20 Jun 2026 09:11:18 +0100 Subject: [PATCH 05/10] scripts: update reproduction script with fix documentation --- scripts/reproduce-issue-1720.mjs | 95 +++++++++++++++++++++----------- 1 file changed, 64 insertions(+), 31 deletions(-) diff --git a/scripts/reproduce-issue-1720.mjs b/scripts/reproduce-issue-1720.mjs index 0844bbb8..603a52bf 100644 --- a/scripts/reproduce-issue-1720.mjs +++ b/scripts/reproduce-issue-1720.mjs @@ -10,10 +10,11 @@ * Detection chain in packages/web/server/lib/opencode/env-runtime.js: * Step 1: Check env vars (OPENCODE_BINARY, OPENCODE_PATH, etc.) * Step 2: searchPathFor('opencode') — walks process.env.PATH - * Step 3: Hardcoded fallback paths (includes /opt/homebrew/bin/opencode, + * Step 3a: Hardcoded fallback paths (includes /opt/homebrew/bin/opencode, * /usr/local/bin/opencode, etc.) + * Step 3b: Fast-path: /bin/sh -c 'command -v opencode' (with timeout) * Step 4: Shell probing — $SHELL -lic 'command -v opencode' - * (⚠️ NO TIMEOUT on spawnSync) + * (with timeout, fixed) * * On macOS, apps launched from the Dock/Finder inherit a minimal PATH: * /usr/bin:/bin:/usr/sbin:/sbin @@ -24,9 +25,9 @@ * spawnSync($SHELL, ['-il', '-c', 'env -0'], { timeout: 5000 }) * * If the shell startup is slow (>5s due to nvm, pyenv, etc.), this times - * out and PATH stays minimal. The server-side probing (in index.js:652) - * also calls spawnSync but with NO timeout at all — potentially hanging - * the module initialization indefinitely. + * out and PATH stays minimal. The fast-path (Step 3b) catches standard brew + * paths even with minimal PATH, and all shell probes now have a 5s timeout + * to prevent blocking startup indefinitely. */ import fs from 'node:fs'; @@ -208,6 +209,30 @@ async function main() { if (ok) foundInFallbacks = true; } + // 3c-fp. Fast-path (command -v via /bin/sh) + console.log('\n 🔹 Step 3c-fp: Fast-path (command -v via /bin/sh)'); + let fastPathFound = false; + try { + const fastResult = spawnSync('/bin/sh', ['-c', 'command -v opencode'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 5000, + }); + if (fastResult.status === 0) { + const found = (fastResult.stdout || '').trim().split(/\s+/).pop() || ''; + if (found && isExecutable(found)) { + console.log(` ✓ Found: ${found}`); + fastPathFound = true; + } else { + console.log(` ✗ Not found (${found || 'empty'})`); + } + } else { + console.log(` ✗ Status ${fastResult.status} (${fastResult.error?.message || ''})`); + } + } catch (err) { + console.log(` ⚠ Exception: ${err.message}`); + } + // 3d. Shell probing (last resort) console.log('\n 🔹 Step 3d: Shell probing (last resort)'); const shellCandidates = [ @@ -255,7 +280,7 @@ async function main() { section('Root Cause Analysis'); - const found = pathResult || foundInFallbacks || shellFound || (whichPath && isExecutable(whichPath)); + const found = pathResult || foundInFallbacks || fastPathFound || shellFound || (whichPath && isExecutable(whichPath)); if (found) { console.log(` @@ -263,28 +288,36 @@ async function main() { this system. However, this does not rule out the bug on all macOS configurations. - ⚠️ POTENTIAL ISSUES (macOS-specific): + 🔧 FIXES APPLIED (issue #1720): - 1. SHELL PROBING HAS NO TIMEOUT (env-runtime.js:366): - The shell probing step at line 366 calls spawnSync without - a timeout. If the user's shell startup is slow (>5s due to - nvm, pyenv, etc.), this blocks indefinitely. + 1. ✅ SHELL PROBING TIMEOUT (env-runtime.js): + All 4 shell probe spawnSync calls now have a 5-second + timeout. Prevents indefinite blocking on slow shells. - 2. ELECTRON PROBE HAS 5s TIMEOUT (main.mjs:1023): - The Electron main process probes the shell with a 5-second - timeout. If the shell takes >5s, PATH is NOT augmented. + 2. ✅ FAST-PATH PROBE (env-runtime.js): + Added /bin/sh -c 'command -v opencode' before the full + login shell probe. Catches brew binaries at ~10ms instead + of potentially seconds. (Standard brew paths are also + covered by hardcoded fallbacks.) - 3. SERVER-SIDE SHELL PROBE HAS NO TIMEOUT (env-runtime.js:205): - The server's getLoginShellEnvSnapshot() also has no timeout, - potentially blocking module initialization at index.js:652. + 3. ✅ TOOLCHAIN_SEGMENTS (path-utils.js): + Added '/usr/local/' to TOOLCHAIN_SEGMENTS so a PATH + containing /usr/local/bin is recognized as user-configured. - 4. FILE-SYSTEM PERMISSION ISSUE: - On macOS with Full Disk Protection, the Electron sandbox - may restrict access to files outside the app container. + 4. ✅ VS CODE FALLBACK ORDER (opencode.ts): + Brew fallback order now matches server: /opt/homebrew + (Apple Silicon) before /usr/local (Intel). - 5. BREW INSTALLATION PATH NOT COVERED: - If brew is installed at a custom prefix (not /opt/homebrew - or /usr/local), the hardcoded fallbacks won't match. + ⚠️ REMAINING ISSUES (not addressed by this fix): + + 1. FILE-SYSTEM PERMISSION ISSUE: + On macOS with Full Disk Protection, the Electron sandbox + may restrict access to files outside the app container. + + 2. CUSTOM BREW PREFIX: + If brew is installed at a custom prefix (not /opt/homebrew + or /usr/local), the hardcoded fallbacks won't match — but + the fast-path (command -v via sh) may still find it. `); } else { console.log(` @@ -308,14 +341,14 @@ async function main() { console.log(` ────────────────────────────────────────────────────────── - Recommended fixes: - 1. Add a timeout to ALL spawnSync calls in the detection chain - (especially env-runtime.js lines 205, 366) - 2. Consider using 'which opencode' or 'command -v opencode' - as a standalone fallback with a short timeout - 3. The existing hardcoded paths DO cover standard brew paths, - so if the binary is at /opt/homebrew/bin/opencode or - /usr/local/bin/opencode, detection should work + Fixes applied: + 1. ✅ All 4 shell probe spawnSync calls now have a 5s timeout + (SHELL_PROBE_TIMEOUT_MS, added at env-runtime.js module level) + 2. ✅ Fast-path via /bin/sh -c 'command -v opencode' added + before login shell probing in all 3 resolvers + 3. ✅ /usr/local/ added to TOOLCHAIN_SEGMENTS in path-utils.js + 4. ✅ Brew fallback order fixed in VS Code extension + (/opt/homebrew before /usr/local) `); } From df669a4ded4f4429d7115ed0adac63052f734a9b Mon Sep 17 00:00:00 2001 From: Mayuresh Kadu <23300+mskadu@users.noreply.github.com> Date: Sat, 20 Jun 2026 09:12:15 +0100 Subject: [PATCH 06/10] changelog: document brew opencode detection fix --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c18a394..17bd9515 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- Desktop: opencode installed via Homebrew on macOS is now found even when the app is launched from the Dock — shell probes won't block startup, a fast `command -v` catch catches brew paths without sourcing shell config, and brew path ordering is consistent across runtimes (issue #1720). + ## [1.13.2] - 2026-06-18 - Chat/Performance: long conversations and large session lists now stay smooth and responsive while a response is streaming (thanks to @bashrusakh). From 2cbbf3ab153b574a0fc2c8314779f7d345153405 Mon Sep 17 00:00:00 2001 From: Mayuresh Kadu <23300+mskadu@users.noreply.github.com> Date: Sat, 20 Jun 2026 09:16:49 +0100 Subject: [PATCH 07/10] review fixes: align reproduction script timeout, tighten comments --- .../web/server/lib/opencode/env-runtime.js | 21 +++++++++++-------- scripts/reproduce-issue-1720.mjs | 2 +- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/web/server/lib/opencode/env-runtime.js b/packages/web/server/lib/opencode/env-runtime.js index a9b919cf..1ee396c7 100644 --- a/packages/web/server/lib/opencode/env-runtime.js +++ b/packages/web/server/lib/opencode/env-runtime.js @@ -362,9 +362,10 @@ export const createOpenCodeEnvRuntime = (deps) => { return null; } - // Fast path: 'command -v' via plain sh (no login shell, no .zshrc sourcing). - // This is much faster than the full login shell probe below and catches - // standard brew paths even when launched with minimal PATH. +// Fast path: 'command -v' via plain sh (no login shell, no .zshrc sourcing). +// This is much faster than the full login shell probe below and catches +// brew paths when the Electron login shell env merge already augmented PATH +// or when /bin/sh has a broader default PATH than the process. if (process.platform !== 'win32') { try { const fastResult = runSpawnSync('/bin/sh', ['-c', 'command -v opencode'], { @@ -454,9 +455,10 @@ export const createOpenCodeEnvRuntime = (deps) => { return null; } - // Fast path: 'command -v' via plain sh (no login shell, no .zshrc sourcing). - // This is much faster than the full login shell probe below and catches - // standard brew paths even when launched with minimal PATH. +// Fast path: 'command -v' via plain sh (no login shell, no .zshrc sourcing). +// This is much faster than the full login shell probe below and catches +// brew paths when the Electron login shell env merge already augmented PATH +// or when /bin/sh has a broader default PATH than the process. if (process.platform !== 'win32') { try { const fastResult = runSpawnSync('/bin/sh', ['-c', 'command -v node'], { @@ -558,9 +560,10 @@ export const createOpenCodeEnvRuntime = (deps) => { return null; } - // Fast path: 'command -v' via plain sh (no login shell, no .zshrc sourcing). - // This is much faster than the full login shell probe below and catches - // standard brew paths even when launched with minimal PATH. +// Fast path: 'command -v' via plain sh (no login shell, no .zshrc sourcing). +// This is much faster than the full login shell probe below and catches +// brew paths when the Electron login shell env merge already augmented PATH +// or when /bin/sh has a broader default PATH than the process. if (process.platform !== 'win32') { try { const fastResult = runSpawnSync('/bin/sh', ['-c', 'command -v bun'], { diff --git a/scripts/reproduce-issue-1720.mjs b/scripts/reproduce-issue-1720.mjs index 603a52bf..e4c60002 100644 --- a/scripts/reproduce-issue-1720.mjs +++ b/scripts/reproduce-issue-1720.mjs @@ -252,7 +252,7 @@ async function main() { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, - timeout: 10000, + timeout: 5000, }); if (result.status === 0) { const found = (result.stdout || '').trim().split(/\s+/).pop() || ''; From 474ec6c0378aa0271010a157f04da2fc90a0373d Mon Sep 17 00:00:00 2001 From: Mayuresh Kadu <23300+mskadu@users.noreply.github.com> Date: Sat, 20 Jun 2026 09:35:59 +0100 Subject: [PATCH 08/10] gitignore: exclude internal planning docs from upstream --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index f69072c8..0a6e6dca 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,6 @@ data/ workspaces/ *.pid .worktrees/ + +# Internal superpower planning docs (not for upstream) +docs/superpowers/ From 3edc765ddd075e4b32e796129d43783f526d201b Mon Sep 17 00:00:00 2001 From: Mayuresh Kadu <23300+mskadu@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:14:41 +0100 Subject: [PATCH 09/10] fix: restore fast-path comment indentation, correct reproduce script claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address bot review findings: - Indent the three 'Fast path' comment blocks to match surrounding code - Reproduce script header no longer claims the fast path catches brew paths with a minimal PATH — the hardcoded fallbacks do that; the fast path only sees binaries already in the inherited PATH --- .../web/server/lib/opencode/env-runtime.js | 24 +++++++++---------- scripts/reproduce-issue-1720.mjs | 7 +++--- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/packages/web/server/lib/opencode/env-runtime.js b/packages/web/server/lib/opencode/env-runtime.js index 34043275..c14557bd 100644 --- a/packages/web/server/lib/opencode/env-runtime.js +++ b/packages/web/server/lib/opencode/env-runtime.js @@ -455,10 +455,10 @@ export const createOpenCodeEnvRuntime = (deps) => { return null; } -// Fast path: 'command -v' via plain sh (no login shell, no .zshrc sourcing). -// This is much faster than the full login shell probe below and catches -// brew paths when the Electron login shell env merge already augmented PATH -// or when /bin/sh has a broader default PATH than the process. + // Fast path: 'command -v' via plain sh (no login shell, no .zshrc sourcing). + // This is much faster than the full login shell probe below and catches + // brew paths when the Electron login shell env merge already augmented PATH + // or when /bin/sh has a broader default PATH than the process. if (process.platform !== 'win32') { try { const fastResult = runSpawnSync('/bin/sh', ['-c', 'command -v opencode'], { @@ -548,10 +548,10 @@ export const createOpenCodeEnvRuntime = (deps) => { return null; } -// Fast path: 'command -v' via plain sh (no login shell, no .zshrc sourcing). -// This is much faster than the full login shell probe below and catches -// brew paths when the Electron login shell env merge already augmented PATH -// or when /bin/sh has a broader default PATH than the process. + // Fast path: 'command -v' via plain sh (no login shell, no .zshrc sourcing). + // This is much faster than the full login shell probe below and catches + // brew paths when the Electron login shell env merge already augmented PATH + // or when /bin/sh has a broader default PATH than the process. if (process.platform !== 'win32') { try { const fastResult = runSpawnSync('/bin/sh', ['-c', 'command -v node'], { @@ -653,10 +653,10 @@ export const createOpenCodeEnvRuntime = (deps) => { return null; } -// Fast path: 'command -v' via plain sh (no login shell, no .zshrc sourcing). -// This is much faster than the full login shell probe below and catches -// brew paths when the Electron login shell env merge already augmented PATH -// or when /bin/sh has a broader default PATH than the process. + // Fast path: 'command -v' via plain sh (no login shell, no .zshrc sourcing). + // This is much faster than the full login shell probe below and catches + // brew paths when the Electron login shell env merge already augmented PATH + // or when /bin/sh has a broader default PATH than the process. if (process.platform !== 'win32') { try { const fastResult = runSpawnSync('/bin/sh', ['-c', 'command -v bun'], { diff --git a/scripts/reproduce-issue-1720.mjs b/scripts/reproduce-issue-1720.mjs index 7fff57a1..40be62bb 100644 --- a/scripts/reproduce-issue-1720.mjs +++ b/scripts/reproduce-issue-1720.mjs @@ -25,9 +25,10 @@ * spawnSync($SHELL, ['-il', '-c', 'env -0'], { timeout: 5000 }) * * If the shell startup is slow (>5s due to nvm, pyenv, etc.), this times - * out and PATH stays minimal. The fast-path (Step 3b) catches standard brew - * paths even with minimal PATH, and all shell probes now have a 5s timeout - * to prevent blocking startup indefinitely. + * out and PATH stays minimal. The hardcoded fallback paths catch standard + * brew locations, the fast-path (Step 3b) catches binaries already visible + * in the inherited PATH without sourcing shell config, and all shell probes + * now have a 5s timeout to prevent blocking startup indefinitely. */ import fs from 'node:fs'; From 3532cef049efca7b03ec219f25d715b5e3304d5f Mon Sep 17 00:00:00 2001 From: Mayuresh Kadu <23300+mskadu@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:16:18 +0100 Subject: [PATCH 10/10] changelog: fix 'catch catches' phrasing --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2670c9c..c0fcdfed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] -- Desktop: opencode installed via Homebrew on macOS is now found even when the app is launched from the Dock — shell probes won't block startup, a fast `command -v` catch catches brew paths without sourcing shell config, and brew path ordering is consistent across runtimes (issue #1720). +- Desktop: opencode installed via Homebrew on macOS is now found even when the app is launched from the Dock — shell probes won't block startup, a fast `command -v` probe catches brew paths without sourcing shell config, and brew path ordering is consistent across runtimes (issue #1720). - Chat: if OpenCode restarts while a response is still running, the chat now stops with an interrupted state and a notification to continue instead of hanging silently (thanks to @sum117). ## [1.19.0] - 2026-08-19