Fix Windows path/spawn regressions and session visibility (#552)

* Update OpenCode CLI detection

* fix(windows): handle cli file-url import and cmd shim spawn

Fixes #533 and #521.

* fix(windows): stabilize api path rewrite and session merge

Fixes #548.
This commit is contained in:
Bohdan Triapitsyn
2026-02-28 16:00:10 +02:00
committed by GitHub
parent 122a45a890
commit 6eb5d1afa9
6 changed files with 462 additions and 66 deletions
+317 -46
View File
@@ -3398,6 +3398,15 @@ const ENV_EFFECTIVE_PORT = ENV_CONFIGURED_OPENCODE_HOST?.port ?? ENV_CONFIGURED_
const ENV_SKIP_OPENCODE_START = process.env.OPENCODE_SKIP_START === 'true' ||
process.env.OPENCHAMBER_SKIP_OPENCODE_START === 'true';
const ENV_DESKTOP_NOTIFY = process.env.OPENCHAMBER_DESKTOP_NOTIFY === 'true';
const ENV_CONFIGURED_OPENCODE_WSL_DISTRO =
typeof process.env.OPENCODE_WSL_DISTRO === 'string' && process.env.OPENCODE_WSL_DISTRO.trim().length > 0
? process.env.OPENCODE_WSL_DISTRO.trim()
: (
typeof process.env.OPENCHAMBER_OPENCODE_WSL_DISTRO === 'string' &&
process.env.OPENCHAMBER_OPENCODE_WSL_DISTRO.trim().length > 0
? process.env.OPENCHAMBER_OPENCODE_WSL_DISTRO.trim()
: null
);
// OpenCode server authentication (Basic Auth with username "opencode")
@@ -3644,6 +3653,10 @@ let resolvedOpencodeBinary = null;
let resolvedOpencodeBinarySource = null;
let resolvedNodeBinary = null;
let resolvedBunBinary = null;
let useWslForOpencode = false;
let resolvedWslBinary = null;
let resolvedWslOpencodePath = null;
let resolvedWslDistro = null;
function isExecutable(filePath) {
try {
@@ -3682,6 +3695,136 @@ function searchPathFor(binaryName) {
return null;
}
function isWslExecutableValue(value) {
if (typeof value !== 'string') return false;
const trimmed = value.trim();
if (!trimmed) return false;
return /(^|[\\/])wsl(\.exe)?$/i.test(trimmed);
}
function clearWslOpencodeResolution() {
useWslForOpencode = false;
resolvedWslBinary = null;
resolvedWslOpencodePath = null;
resolvedWslDistro = null;
}
function resolveWslExecutablePath() {
if (process.platform !== 'win32') {
return null;
}
const explicit = [process.env.WSL_BINARY, process.env.OPENCHAMBER_WSL_BINARY]
.map((v) => (typeof v === 'string' ? v.trim() : ''))
.filter(Boolean);
for (const candidate of explicit) {
if (isExecutable(candidate)) {
return candidate;
}
}
try {
const result = spawnSync('where', ['wsl'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
if (result.status === 0) {
const lines = (result.stdout || '')
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const found = lines.find((line) => isExecutable(line));
if (found) {
return found;
}
}
} catch {
// ignore
}
const systemRoot = process.env.SystemRoot || 'C:\\Windows';
const fallback = path.join(systemRoot, 'System32', 'wsl.exe');
if (isExecutable(fallback)) {
return fallback;
}
return null;
}
function buildWslExecArgs(execArgs, distroOverride = null) {
const distro = typeof distroOverride === 'string' && distroOverride.trim().length > 0
? distroOverride.trim()
: ENV_CONFIGURED_OPENCODE_WSL_DISTRO;
const prefix = distro ? ['-d', distro] : [];
return [...prefix, '--exec', ...execArgs];
}
function probeWslForOpencode() {
if (process.platform !== 'win32') {
return null;
}
const wslBinary = resolveWslExecutablePath();
if (!wslBinary) {
return null;
}
try {
const result = spawnSync(
wslBinary,
buildWslExecArgs(['sh', '-lc', 'command -v opencode']),
{
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 6000,
},
);
if (result.status !== 0) {
return null;
}
const lines = (result.stdout || '')
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const found = lines[0] || '';
if (!found) {
return null;
}
return {
wslBinary,
opencodePath: found,
distro: ENV_CONFIGURED_OPENCODE_WSL_DISTRO,
};
} catch {
return null;
}
}
function applyWslOpencodeResolution({ wslBinary, opencodePath, source = 'wsl', distro = null } = {}) {
const resolvedWsl = wslBinary || resolveWslExecutablePath();
if (!resolvedWsl) {
return null;
}
useWslForOpencode = true;
resolvedWslBinary = resolvedWsl;
resolvedWslOpencodePath = typeof opencodePath === 'string' && opencodePath.trim().length > 0
? opencodePath.trim()
: 'opencode';
resolvedWslDistro = typeof distro === 'string' && distro.trim().length > 0 ? distro.trim() : ENV_CONFIGURED_OPENCODE_WSL_DISTRO;
resolvedOpencodeBinary = `wsl:${resolvedWslOpencodePath}`;
resolvedOpencodeBinarySource = source;
// Keep OPENCODE_BINARY empty in WSL mode to avoid native spawn attempts.
delete process.env.OPENCODE_BINARY;
return resolvedOpencodeBinary;
}
function resolveOpencodeCliPath() {
const explicit = [
process.env.OPENCODE_BINARY,
@@ -3694,6 +3837,7 @@ function resolveOpencodeCliPath() {
for (const candidate of explicit) {
if (isExecutable(candidate)) {
clearWslOpencodeResolution();
resolvedOpencodeBinarySource = 'env';
return candidate;
}
@@ -3701,6 +3845,7 @@ function resolveOpencodeCliPath() {
const resolvedFromPath = searchPathFor('opencode');
if (resolvedFromPath) {
clearWslOpencodeResolution();
resolvedOpencodeBinarySource = 'path';
return resolvedFromPath;
}
@@ -3739,6 +3884,7 @@ function resolveOpencodeCliPath() {
const fallbacks = process.platform === 'win32' ? winFallbacks : unixFallbacks;
for (const candidate of fallbacks) {
if (isExecutable(candidate)) {
clearWslOpencodeResolution();
resolvedOpencodeBinarySource = 'fallback';
return candidate;
}
@@ -3757,6 +3903,7 @@ function resolveOpencodeCliPath() {
.filter(Boolean);
const found = lines.find((line) => isExecutable(line));
if (found) {
clearWslOpencodeResolution();
resolvedOpencodeBinarySource = 'where';
return found;
}
@@ -3764,6 +3911,15 @@ function resolveOpencodeCliPath() {
} catch {
// ignore
}
const wsl = probeWslForOpencode();
if (wsl) {
return applyWslOpencodeResolution({
wslBinary: wsl.wslBinary,
opencodePath: wsl.opencodePath,
source: 'wsl',
distro: wsl.distro,
});
}
return null;
}
@@ -3778,6 +3934,7 @@ function resolveOpencodeCliPath() {
if (result.status === 0) {
const found = (result.stdout || '').trim().split(/\s+/).pop() || '';
if (found && isExecutable(found)) {
clearWslOpencodeResolution();
resolvedOpencodeBinarySource = 'shell';
return found;
}
@@ -4059,10 +4216,44 @@ async function applyOpencodeBinaryFromSettings() {
delete process.env.OPENCODE_BINARY;
resolvedOpencodeBinary = null;
resolvedOpencodeBinarySource = null;
clearWslOpencodeResolution();
return null;
}
const raw = typeof settings.opencodeBinary === 'string' ? settings.opencodeBinary.trim() : '';
const explicitWslPath = process.platform === 'win32' && typeof raw === 'string'
? raw.match(/^wsl:\s*(.+)$/i)
: null;
if (explicitWslPath && explicitWslPath[1] && explicitWslPath[1].trim().length > 0) {
const probe = probeWslForOpencode();
const applied = applyWslOpencodeResolution({
wslBinary: probe?.wslBinary || resolveWslExecutablePath(),
opencodePath: explicitWslPath[1].trim(),
source: 'settings-wsl-path',
distro: probe?.distro || ENV_CONFIGURED_OPENCODE_WSL_DISTRO,
});
if (applied) {
return applied;
}
}
if (process.platform === 'win32' && (isWslExecutableValue(raw) || isWslExecutableValue(normalized || ''))) {
const probe = probeWslForOpencode();
const applied = applyWslOpencodeResolution({
wslBinary: probe?.wslBinary || normalized || raw || null,
opencodePath: probe?.opencodePath || 'opencode',
source: 'settings-wsl',
distro: probe?.distro || ENV_CONFIGURED_OPENCODE_WSL_DISTRO,
});
if (applied) {
return applied;
}
}
if (normalized && isExecutable(normalized)) {
clearWslOpencodeResolution();
process.env.OPENCODE_BINARY = normalized;
prependToPath(path.dirname(normalized));
resolvedOpencodeBinary = normalized;
@@ -4071,7 +4262,6 @@ async function applyOpencodeBinaryFromSettings() {
return normalized;
}
const raw = typeof settings.opencodeBinary === 'string' ? settings.opencodeBinary.trim() : '';
if (raw) {
console.warn(`Configured settings.opencodeBinary is not executable: ${raw}`);
}
@@ -4084,12 +4274,16 @@ async function applyOpencodeBinaryFromSettings() {
function ensureOpencodeCliEnv() {
if (resolvedOpencodeBinary) {
if (useWslForOpencode) {
return resolvedOpencodeBinary;
}
ensureOpencodeShimRuntime(resolvedOpencodeBinary);
return resolvedOpencodeBinary;
}
const existing = typeof process.env.OPENCODE_BINARY === 'string' ? process.env.OPENCODE_BINARY.trim() : '';
if (existing && isExecutable(existing)) {
clearWslOpencodeResolution();
resolvedOpencodeBinary = existing;
resolvedOpencodeBinarySource = resolvedOpencodeBinarySource || 'env';
prependToPath(path.dirname(existing));
@@ -4099,6 +4293,13 @@ function ensureOpencodeCliEnv() {
const resolved = resolveOpencodeCliPath();
if (resolved) {
if (useWslForOpencode) {
resolvedOpencodeBinary = resolved;
resolvedOpencodeBinarySource = resolvedOpencodeBinarySource || 'wsl';
console.log(`Resolved opencode CLI via WSL: ${resolvedWslOpencodePath || 'opencode'}`);
return resolved;
}
process.env.OPENCODE_BINARY = resolved;
prependToPath(path.dirname(resolved));
ensureOpencodeShimRuntime(resolved);
@@ -4108,6 +4309,7 @@ function ensureOpencodeCliEnv() {
return resolved;
}
clearWslOpencodeResolution();
return null;
}
@@ -5171,12 +5373,34 @@ async function createManagedOpenCodeServerProcess({
env,
}) {
let binary = (process.env.OPENCODE_BINARY || 'opencode').trim() || 'opencode';
const args = ['serve', '--hostname', hostname, '--port', String(port)];
let args = ['serve', '--hostname', hostname, '--port', String(port)];
if (process.platform === 'win32' && useWslForOpencode) {
const wslBinary = resolvedWslBinary || resolveWslExecutablePath();
if (!wslBinary) {
throw new Error('WSL executable not found while attempting to launch OpenCode from WSL');
}
const wslOpencode = resolvedWslOpencodePath && resolvedWslOpencodePath.trim().length > 0
? resolvedWslOpencodePath.trim()
: 'opencode';
const serveHost = hostname === '127.0.0.1' ? '0.0.0.0' : hostname;
binary = wslBinary;
args = buildWslExecArgs([
wslOpencode,
'serve',
'--hostname',
serveHost,
'--port',
String(port),
], resolvedWslDistro);
}
// On Windows, Bun/Node cannot directly spawn shell wrapper scripts (#!/bin/sh).
// Detect if the resolved binary is a shim that wraps a Node/Bun script and
// resolve the actual target so we can spawn it with the correct interpreter.
if (process.platform === 'win32') {
if (process.platform === 'win32' && !useWslForOpencode) {
const interpreter = opencodeShimInterpreter(binary);
if (interpreter) {
// Binary itself has a node/bun shebang spawn via that interpreter.
@@ -5675,29 +5899,53 @@ function setupProxy(app) {
}
app.set('opencodeProxyConfigured', true);
// Windows path normalization: OpenCode CLI stores paths with backslashes in the DB,
// but the frontend sends forward slashes. Rewrite directory query params on Windows.
// Must run BEFORE all other /api middleware.
if (process.platform === 'win32') {
app.use('/api', (req, _res, next) => {
// Parse directory from the raw URL since Express query parsing may not be available
const rawUrl = req.originalUrl || req.url || '';
const dirMatch = rawUrl.match(/[?&]directory=([^&]*)/);
if (dirMatch) {
const decoded = decodeURIComponent(dirMatch[1]);
if (decoded.includes('/')) {
const fixed = decoded.replace(/\//g, '\\');
const fixedEncoded = encodeURIComponent(fixed);
const newUrl = rawUrl.replace(/([?&]directory=)[^&]*/, '$1' + fixedEncoded);
console.log(`[Win32PathFix] Rewrote directory: "${decoded}" → "${fixed}"`);
console.log(`[Win32PathFix] URL: "${rawUrl}" → "${newUrl}"`);
req.originalUrl = newUrl;
req.url = newUrl;
}
const stripApiPrefix = (rawUrl) => {
if (typeof rawUrl !== 'string' || !rawUrl) {
return '/';
}
if (rawUrl === '/api') {
return '/';
}
if (rawUrl.startsWith('/api/')) {
return rawUrl.slice(4);
}
return rawUrl;
};
// Keep route matching stable; only rewrite the proxied upstream path.
const rewriteWindowsDirectoryParam = (upstreamPath) => {
if (process.platform !== 'win32') {
return upstreamPath;
}
try {
const parsed = new URL(upstreamPath, 'http://openchamber.local');
const pathname = parsed.pathname || '/';
if (pathname === '/session' || pathname.startsWith('/session/')) {
return upstreamPath;
}
next();
});
}
const directory = parsed.searchParams.get('directory');
if (!directory || !directory.includes('/')) {
return upstreamPath;
}
const fixed = directory.replace(/\//g, '\\');
parsed.searchParams.set('directory', fixed);
const rewritten = `${parsed.pathname}${parsed.search}${parsed.hash}`;
if (rewritten !== upstreamPath) {
console.log(`[Win32PathFix] Rewrote directory: "${directory}" → "${fixed}"`);
console.log(`[Win32PathFix] URL: "${upstreamPath}" → "${rewritten}"`);
}
return rewritten;
} catch {
return upstreamPath;
}
};
const getUpstreamPathForRequest = (req) => {
const rawUrl = (typeof req.originalUrl === 'string' && req.originalUrl)
? req.originalUrl
: (typeof req.url === 'string' ? req.url : '/');
return rewriteWindowsDirectoryParam(stripApiPrefix(rawUrl));
};
app.use('/api', (req, res, next) => {
if (
@@ -5733,7 +5981,7 @@ function setupProxy(app) {
const forwardSseRequest = async (req, res) => {
const startedAt = Date.now();
const upstreamPath = req.originalUrl.replace(/^\/api/, '');
const upstreamPath = getUpstreamPathForRequest(req);
const targetUrl = buildOpenCodeUrl(upstreamPath, '');
const authHeaders = getOpenCodeAuthHeaders();
@@ -5970,7 +6218,7 @@ function setupProxy(app) {
const forwardGenericApiRequest = async (req, res) => {
try {
const upstreamPath = req.originalUrl.replace(/^\/api/, '');
const upstreamPath = getUpstreamPathForRequest(req);
const targetUrl = buildOpenCodeUrl(upstreamPath, '');
const headers = collectForwardHeaders(req);
const method = String(req.method || 'GET').toUpperCase();
@@ -6008,7 +6256,7 @@ function setupProxy(app) {
// This avoids edge-cases in generic proxy streaming for multi-file attachments.
app.post('/api/session/:sessionId/message', express.raw({ type: '*/*', limit: '50mb' }), async (req, res) => {
try {
const upstreamPath = req.originalUrl.replace(/^\/api/, '');
const upstreamPath = getUpstreamPathForRequest(req);
const targetUrl = buildOpenCodeUrl(upstreamPath, '');
const authHeaders = getOpenCodeAuthHeaders();
@@ -6067,7 +6315,8 @@ function setupProxy(app) {
signal: AbortSignal.timeout(10000),
};
const globalRes = await fetch(buildOpenCodeUrl('/session', ''), fetchOpts);
const globalSessions = globalRes.ok ? (await globalRes.json()) : [];
const globalPayload = globalRes.ok ? await globalRes.json().catch(() => []) : [];
const globalSessions = Array.isArray(globalPayload) ? globalPayload : [];
const settingsPath = path.join(os.homedir(), '.config', 'openchamber', 'settings.json');
let projectDirs = [];
@@ -6075,33 +6324,47 @@ function setupProxy(app) {
const settingsRaw = fs.readFileSync(settingsPath, 'utf8');
const settings = JSON.parse(settingsRaw);
projectDirs = (settings.projects || [])
.map(p => p.path)
.filter(p => typeof p === 'string' && p.length > 0);
.map((project) => (typeof project?.path === 'string' ? project.path.trim() : ''))
.filter(Boolean);
} catch {}
const seen = new Set(globalSessions.map(s => s.id));
const seen = new Set(
globalSessions
.map((session) => (session && typeof session.id === 'string' ? session.id : null))
.filter((id) => typeof id === 'string')
);
const extraSessions = [];
for (const dir of projectDirs) {
const backslashDir = dir.replace(/\//g, '\\');
const encoded = encodeURIComponent(backslashDir);
try {
const dirRes = await fetch(buildOpenCodeUrl(`/session?directory=${encoded}`, ''), fetchOpts);
if (dirRes.ok) {
const dirSessions = await dirRes.json();
if (Array.isArray(dirSessions)) {
for (const s of dirSessions) {
if (s && s.id && !seen.has(s.id)) {
seen.add(s.id);
extraSessions.push(s);
const candidates = Array.from(new Set([
dir,
dir.replace(/\\/g, '/'),
dir.replace(/\//g, '\\'),
]));
for (const candidateDir of candidates) {
const encoded = encodeURIComponent(candidateDir);
try {
const dirRes = await fetch(buildOpenCodeUrl(`/session?directory=${encoded}`, ''), fetchOpts);
if (dirRes.ok) {
const dirPayload = await dirRes.json().catch(() => []);
const dirSessions = Array.isArray(dirPayload) ? dirPayload : [];
for (const session of dirSessions) {
const id = session && typeof session.id === 'string' ? session.id : null;
if (id && !seen.has(id)) {
seen.add(id);
extraSessions.push(session);
}
}
}
}
} catch {}
} catch {}
}
}
const merged = [...globalSessions, ...extraSessions];
merged.sort((a, b) => (b.time_updated || 0) - (a.time_updated || 0));
merged.sort((a, b) => {
const aTime = a && typeof a.time_updated === 'number' ? a.time_updated : 0;
const bTime = b && typeof b.time_updated === 'number' ? b.time_updated : 0;
return bTime - aTime;
});
console.log(`[SessionMerge] ${globalSessions.length} global + ${extraSessions.length} extra = ${merged.length} total`);
return res.json(merged);
} catch (error) {
@@ -6310,6 +6573,10 @@ async function main(options = {}) {
opencodeBinaryResolved: resolvedOpencodeBinary || null,
opencodeBinarySource: resolvedOpencodeBinarySource || null,
opencodeShimInterpreter: resolvedOpencodeBinary ? opencodeShimInterpreter(resolvedOpencodeBinary) : null,
opencodeViaWsl: useWslForOpencode,
opencodeWslBinary: resolvedWslBinary || null,
opencodeWslPath: resolvedWslOpencodePath || null,
opencodeWslDistro: resolvedWslDistro || null,
nodeBinaryResolved: resolvedNodeBinary || null,
bunBinaryResolved: resolvedBunBinary || null,
});
@@ -7687,6 +7954,10 @@ async function main(options = {}) {
detectedNow,
detectedSourceNow,
shim,
viaWsl: useWslForOpencode,
wslBinary: resolvedWslBinary || null,
wslPath: resolvedWslOpencodePath || null,
wslDistro: resolvedWslDistro || null,
node: resolvedNodeBinary || null,
bun: resolvedBunBinary || null,
});