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
+20 -3
View File
@@ -36,6 +36,10 @@ import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import type { RuntimeAPIs } from '@/lib/api/types';
import { TooltipProvider } from '@/components/ui/tooltip';
const CLI_MISSING_ERROR_REGEX =
/ENOENT|spawn\s+opencode|Unable\s+to\s+locate\s+the\s+opencode\s+CLI|OpenCode\s+CLI\s+not\s+found|opencode(\.exe)?\s+not\s+found|opencode(\.exe)?:\s*command\s+not\s+found|not\s+recognized\s+as\s+an\s+internal\s+or\s+external\s+command|env:\s*['"]?(node|bun)['"]?:\s*No\s+such\s+file\s+or\s+directory|(node|bun):\s*No\s+such\s+file\s+or\s+directory/i;
const CLI_ONBOARDING_HEALTH_POLL_MS = 1500;
const AboutDialogWrapper: React.FC = () => {
const { isAboutDialogOpen, setAboutDialogOpen } = useUIStore();
return (
@@ -237,20 +241,33 @@ function App({ apis }: AppProps) {
let cancelled = false;
const run = async () => {
const res = await fetch('/health', { method: 'GET' }).catch(() => null);
if (!res || !res.ok) return;
const data = (await res.json().catch(() => null)) as null | { openCodeRunning?: unknown; lastOpenCodeError?: unknown };
if (!res || !res.ok || cancelled) return;
const data = (await res.json().catch(() => null)) as null | {
openCodeRunning?: unknown;
isOpenCodeReady?: unknown;
opencodeBinaryResolved?: unknown;
lastOpenCodeError?: unknown;
};
if (!data || cancelled) return;
const openCodeRunning = data.openCodeRunning === true;
const isOpenCodeReady = data.isOpenCodeReady === true;
const resolvedBinary = typeof data.opencodeBinaryResolved === 'string' ? data.opencodeBinaryResolved.trim() : '';
const hasResolvedBinary = resolvedBinary.length > 0;
const err = typeof data.lastOpenCodeError === 'string' ? data.lastOpenCodeError : '';
const cliMissing =
!openCodeRunning &&
/ENOENT|spawn\s+opencode|Unable\s+to\s+locate\s+the\s+opencode\s+CLI|OpenCode\s+CLI\s+not\s+found|opencode(\.exe)?\s+not\s+found|env:\s*(node|bun):\s*No\s+such\s+file\s+or\s+directory|(node|bun):\s*No\s+such\s+file\s+or\s+directory/i.test(err);
(CLI_MISSING_ERROR_REGEX.test(err) || (!hasResolvedBinary && !isOpenCodeReady));
setShowCliOnboarding(cliMissing);
};
void run();
const interval = window.setInterval(() => {
void run();
}, CLI_ONBOARDING_HEALTH_POLL_MS);
return () => {
cancelled = true;
window.clearInterval(interval);
};
}, []);
@@ -8,6 +8,10 @@ import { copyTextToClipboard } from '@/lib/clipboard';
const INSTALL_COMMAND = 'curl -fsSL https://opencode.ai/install | bash';
const POLL_INTERVAL_MS = 3000;
const DOCS_URL = 'https://opencode.ai/docs';
const WINDOWS_WSL_DOCS_URL = 'https://opencode.ai/docs/windows-wsl';
type OnboardingPlatform = 'macos' | 'linux' | 'windows' | 'unknown';
type OnboardingScreenProps = {
onCliAvailable?: () => void;
@@ -42,6 +46,7 @@ export function OnboardingScreen({ onCliAvailable }: OnboardingScreenProps) {
const [isDesktopApp, setIsDesktopApp] = React.useState(false);
const [isRetrying, setIsRetrying] = React.useState(false);
const [opencodeBinary, setOpencodeBinary] = React.useState('');
const [platform, setPlatform] = React.useState<OnboardingPlatform>('unknown');
React.useEffect(() => {
const timer = setTimeout(() => setShowHint(true), HINT_DELAY_MS);
@@ -52,6 +57,28 @@ export function OnboardingScreen({ onCliAvailable }: OnboardingScreenProps) {
setIsDesktopApp(isDesktopShell());
}, []);
React.useEffect(() => {
if (typeof navigator === 'undefined') {
setPlatform('unknown');
return;
}
const ua = navigator.userAgent || '';
if (/Windows/i.test(ua)) {
setPlatform('windows');
return;
}
if (/Macintosh|Mac OS X/i.test(ua)) {
setPlatform('macos');
return;
}
if (/Linux/i.test(ua)) {
setPlatform('linux');
return;
}
setPlatform('unknown');
}, []);
React.useEffect(() => {
let cancelled = false;
void (async () => {
@@ -170,6 +197,14 @@ export function OnboardingScreen({ onCliAvailable }: OnboardingScreenProps) {
return () => clearInterval(interval);
}, [checkCliAvailability, onCliAvailable]);
const docsUrl = platform === 'windows' ? WINDOWS_WSL_DOCS_URL : DOCS_URL;
const binaryPlaceholder =
platform === 'windows'
? 'C:\\Users\\you\\AppData\\Roaming\\npm\\opencode.cmd'
: platform === 'linux'
? '/home/you/.bun/bin/opencode'
: '/Users/you/.bun/bin/opencode';
return (
<div
className="h-full flex items-center justify-center bg-transparent p-8 relative cursor-default select-none"
@@ -194,6 +229,17 @@ export function OnboardingScreen({ onCliAvailable }: OnboardingScreenProps) {
</p>
</div>
{platform === 'windows' && (
<div className="mx-auto max-w-2xl rounded-lg border border-border bg-background/50 p-4 text-left">
<div className="text-sm text-foreground">Windows setup (WSL recommended)</div>
<ol className="mt-2 list-decimal space-y-1 pl-5 text-sm text-muted-foreground">
<li>Install WSL (if needed) with <code className="text-foreground/80">wsl --install</code> in PowerShell.</li>
<li>Run the install command below inside your WSL terminal.</li>
<li>If OpenChamber does not detect OpenCode automatically, set the binary path below.</li>
</ol>
</div>
)}
<div className="flex justify-center">
<div className="bg-background/60 backdrop-blur-sm border border-border rounded-lg px-5 py-3 font-mono text-sm w-fit">
{copied ? (
@@ -208,12 +254,12 @@ export function OnboardingScreen({ onCliAvailable }: OnboardingScreenProps) {
</div>
<a
href="https://opencode.ai/docs"
href={docsUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1 justify-center"
>
View documentation
{platform === 'windows' ? 'View Windows + WSL documentation' : 'View documentation'}
<RiExternalLinkLine className="h-3 w-3" />
</a>
@@ -239,7 +285,7 @@ export function OnboardingScreen({ onCliAvailable }: OnboardingScreenProps) {
<Input
value={opencodeBinary}
onChange={(e) => setOpencodeBinary(e.target.value)}
placeholder="/Users/you/.bun/bin/opencode"
placeholder={binaryPlaceholder}
disabled={isRetrying}
className="flex-1 font-mono text-xs"
/>
@@ -259,24 +305,35 @@ export function OnboardingScreen({ onCliAvailable }: OnboardingScreenProps) {
Apply
</Button>
</div>
<div className="text-xs text-muted-foreground/70">
Saves to <code className="text-foreground/70">~/.config/openchamber/settings.json</code> and reloads OpenCode configuration.
</div>
<div className="text-xs text-muted-foreground/70">Saves to OpenChamber settings and reloads OpenCode configuration.</div>
</div>
</div>
</div>
{showHint && (
<div className="absolute bottom-8 left-0 right-0 text-center space-y-1">
<p className="text-sm text-muted-foreground/70">
Already installed? Make sure <code className="text-foreground/70">opencode</code> is in your PATH
</p>
<p className="text-sm text-muted-foreground/70">
or set <code className="text-foreground/70">OPENCODE_BINARY</code> environment variable.
</p>
<p className="text-sm text-muted-foreground/70">
If you see <code className="text-foreground/70">env: node: No such file or directory</code> or <code className="text-foreground/70">env: bun: No such file or directory</code>, install that runtime or ensure it is on PATH.
</p>
{platform === 'windows' ? (
<>
<p className="text-sm text-muted-foreground/70">
On Windows, install and run OpenCode in WSL for best compatibility.
</p>
<p className="text-sm text-muted-foreground/70">
If detection fails, set a native path (<code className="text-foreground/70">opencode.cmd</code>/<code className="text-foreground/70">opencode.exe</code>), <code className="text-foreground/70">wsl.exe</code>, or <code className="text-foreground/70">wsl:/usr/local/bin/opencode</code>.
</p>
</>
) : (
<>
<p className="text-sm text-muted-foreground/70">
Already installed? Make sure <code className="text-foreground/70">opencode</code> is in your PATH
</p>
<p className="text-sm text-muted-foreground/70">
or set <code className="text-foreground/70">OPENCODE_BINARY</code> environment variable.
</p>
<p className="text-sm text-muted-foreground/70">
If you see <code className="text-foreground/70">env: node: No such file or directory</code> or <code className="text-foreground/70">env: bun: No such file or directory</code>, install that runtime or ensure it is on PATH.
</p>
</>
)}
</div>
)}
</div>
+40
View File
@@ -166,9 +166,49 @@ const normalizeOrigin = (raw: string): string | null => {
}
};
const parseUrl = (raw: string): URL | null => {
const trimmed = raw.trim();
if (!trimmed) return null;
try {
return new URL(trimmed);
} catch {
try {
return new URL(trimmed.endsWith('/') ? trimmed : `${trimmed}/`);
} catch {
return null;
}
}
};
const normalizeHost = (rawHost: string): string => rawHost.replace(/^\[|\]$/g, '').toLowerCase();
const isLoopbackHost = (host: string): boolean => {
const normalized = normalizeHost(host);
return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1';
};
export const isDesktopLocalOriginActive = (): boolean => {
if (typeof window === 'undefined') return false;
const local = typeof window.__OPENCHAMBER_LOCAL_ORIGIN__ === 'string' ? window.__OPENCHAMBER_LOCAL_ORIGIN__ : '';
const localUrl = parseUrl(local);
const currentUrl = parseUrl(window.location.origin);
if (localUrl && currentUrl) {
if (localUrl.origin === currentUrl.origin) {
return true;
}
const localPort = localUrl.port || (localUrl.protocol === 'https:' ? '443' : '80');
const currentPort = currentUrl.port || (currentUrl.protocol === 'https:' ? '443' : '80');
return (
localUrl.protocol === currentUrl.protocol &&
localPort === currentPort &&
isLoopbackHost(localUrl.hostname) &&
isLoopbackHost(currentUrl.hostname)
);
}
const localOrigin = normalizeOrigin(local);
const currentOrigin = normalizeOrigin(window.location.origin) || window.location.origin;
return Boolean(localOrigin && currentOrigin && localOrigin === currentOrigin);
+11
View File
@@ -108,6 +108,16 @@ function isExecutable(filePath: string): boolean {
}
}
function shouldUseWindowsShell(binary: string): boolean {
if (process.platform !== 'win32') return false;
const trimmed = (binary || '').trim();
if (!trimmed) return true;
const ext = path.extname(trimmed).toLowerCase();
if (ext === '.cmd' || ext === '.bat') return true;
// Bare command names often resolve to .cmd shims via PATHEXT.
return !ext && !trimmed.includes('\\') && !trimmed.includes('/');
}
function appendToPath(dir: string) {
const trimmed = (dir || '').trim();
if (!trimmed) return;
@@ -349,6 +359,7 @@ async function spawnManagedOpenCodeServer(
cwd: workingDirectory,
env: { ...process.env },
stdio: ['ignore', 'pipe', 'pipe'],
shell: shouldUseWindowsShell(binary),
});
const url = await new Promise<string>((resolve, reject) => {
+2 -2
View File
@@ -4,7 +4,7 @@ import path from 'path';
import fs from 'fs';
import net from 'net';
import { spawn, spawnSync } from 'child_process';
import { fileURLToPath } from 'url';
import { fileURLToPath, pathToFileURL } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -625,7 +625,7 @@ const commands = {
return;
}
const { startWebUiServer } = await import(serverPath);
const { startWebUiServer } = await import(pathToFileURL(serverPath).href);
await startWebUiServer({
port: options.port,
attachSignals: true,
+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,
});