From 3aa284673875998146a3130f8fa73a3040fd4112 Mon Sep 17 00:00:00 2001 From: Catfish-75 Date: Wed, 3 Jun 2026 07:52:03 +0300 Subject: [PATCH] Fix Windows browse daemon cold start --- browse/src/cli.ts | 12 ++++- browse/src/server.ts | 2 +- browse/src/terminal-agent-control.ts | 76 ++++++++++++++++++++++++---- browse/test/config.test.ts | 37 ++++++++++++++ 4 files changed, 115 insertions(+), 12 deletions(-) diff --git a/browse/src/cli.ts b/browse/src/cli.ts index d705dc2ea..95005837e 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -239,7 +239,17 @@ async function startServer(extraEnv?: Record): Promise Promise) | null = null; // silently weakened by misconfiguration. function sanitizeAuthToken(raw: string | undefined): string | null { if (!raw) return null; - const stripped = raw.replace(/[\s ​-‍]/g, ''); + const stripped = raw.replace(/[\s\u00a0\u200b-\u200d\ufeff]/g, ''); if (stripped.length < 16) return null; return stripped; } diff --git a/browse/src/terminal-agent-control.ts b/browse/src/terminal-agent-control.ts index 094ba668f..6a777243d 100644 --- a/browse/src/terminal-agent-control.ts +++ b/browse/src/terminal-agent-control.ts @@ -19,6 +19,54 @@ import * as path from 'path'; import { safeUnlink, safeKill, isProcessAlive } from './error-handling'; import { writeSecureFile, mkdirSecure } from './file-permissions'; +export interface BunSpawnCommand { + command: string; + argsPrefix: string[]; +} + +function envPath(env: Record): string { + return env.PATH || env.Path || env.path || ''; +} + +/** + * Resolve a Bun executable suitable for a detached child spawn. + * + * On Windows, npm installs expose `bun` as a POSIX shell shim plus `bun.cmd`. + * Node/Bun process spawning does not reliably execute those shims directly, + * so prefer the real `node_modules/bun/bin/bun.exe` beside them. + */ +export function resolveBunSpawnCommand( + env: Record = process.env, + platform: NodeJS.Platform = process.platform, + execPath: string = process.execPath, +): BunSpawnCommand | null { + if (platform !== 'win32') return { command: 'bun', argsPrefix: [] }; + + if (execPath && path.basename(execPath).toLowerCase() === 'bun.exe' && fs.existsSync(execPath)) { + return { command: execPath, argsPrefix: [] }; + } + + const dirs = envPath(env).split(path.delimiter).filter(Boolean); + for (const dir of dirs) { + const candidates = [ + path.join(dir, 'bun.exe'), + path.join(dir, 'node_modules', 'bun', 'bin', 'bun.exe'), + ]; + for (const candidate of candidates) { + if (fs.existsSync(candidate)) return { command: candidate, argsPrefix: [] }; + } + + for (const shim of ['bun.cmd', 'bun.bat']) { + const shimPath = path.join(dir, shim); + if (fs.existsSync(shimPath)) { + return { command: 'cmd.exe', argsPrefix: ['/d', '/s', '/c', `"${shimPath}"`] }; + } + } + } + + return null; +} + /** * Locate the terminal-agent script on disk. In dev (cli.ts running via * `bun run`), it lives next to this file in browse/src. In a compiled @@ -68,16 +116,24 @@ export function spawnTerminalAgent(opts: { } const script = opts.scriptPath || resolveTerminalAgentScript(); if (!script || !fs.existsSync(script)) return null; - const proc = (Bun as any).spawn(['bun', 'run', script], { - cwd: opts.cwd || process.cwd(), - env: { - ...process.env, - BROWSE_STATE_FILE: opts.stateFile, - BROWSE_SERVER_PORT: String(opts.serverPort), - ...(opts.extraEnv || {}), - }, - stdio: ['ignore', 'ignore', 'ignore'], - }); + const bun = resolveBunSpawnCommand(); + if (!bun) return null; + let proc: any; + try { + proc = (Bun as any).spawn([bun.command, ...bun.argsPrefix, 'run', script], { + cwd: opts.cwd || process.cwd(), + env: { + ...process.env, + BROWSE_STATE_FILE: opts.stateFile, + BROWSE_SERVER_PORT: String(opts.serverPort), + ...(opts.extraEnv || {}), + }, + stdio: ['ignore', 'ignore', 'ignore'], + }); + } catch (err: any) { + if (err?.code === 'ENOENT' || err?.code === 'EACCES') return null; + throw err; + } proc.unref?.(); return proc.pid ?? null; } diff --git a/browse/test/config.test.ts b/browse/test/config.test.ts index 98ac29571..8fc9f8db3 100644 --- a/browse/test/config.test.ts +++ b/browse/test/config.test.ts @@ -1,5 +1,6 @@ import { describe, test, expect } from 'bun:test'; import { resolveConfig, ensureStateDir, readVersionHash, getGitRoot, getRemoteSlug, resolveGstackHome, resolveChromiumProfile, cleanSingletonLocks } from '../src/config'; +import { resolveBunSpawnCommand } from '../src/terminal-agent-control'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; @@ -342,6 +343,42 @@ describe('cli command dispatch', () => { expect(cliSource).toContain('Starting server in headed mode'); expect(cliSource).toContain('Starting server with proxy'); }); + + test('Windows launcher uses Bun.spawnSync for compiled CLI compatibility', () => { + expect(cliSource).toContain("Bun.spawnSync(['node', '-e', launcherCode]"); + expect(cliSource).not.toContain("spawnSync('node', ['-e', launcherCode]"); + }); +}); + +describe('server source portability', () => { + const serverSource = fs.readFileSync(path.resolve(__dirname, '../src/server.ts'), 'utf-8'); + + test('auth-token whitespace regex uses ASCII escapes for Node bundle safety', () => { + expect(serverSource).toContain('raw.replace(/[\\s\\u00a0\\u200b-\\u200d\\ufeff]/g'); + expect(serverSource).not.toContain('raw.replace(/[\\s '); + }); +}); + +describe('terminal agent spawn command', () => { + test('Windows npm bun shim resolves to real bun.exe', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-bun-shim-')); + const bunExe = path.join(tmpDir, 'node_modules', 'bun', 'bin', 'bun.exe'); + fs.mkdirSync(path.dirname(bunExe), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, 'bun.cmd'), '@echo off\n'); + fs.writeFileSync(bunExe, ''); + + try { + const result = resolveBunSpawnCommand({ PATH: tmpDir }, 'win32', 'C:\\Windows\\System32\\node.exe'); + expect(result).toEqual({ command: bunExe, argsPrefix: [] }); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + test('Windows missing bun is non-fatal for terminal-agent spawn', () => { + const result = resolveBunSpawnCommand({ PATH: '' }, 'win32', 'C:\\Windows\\System32\\node.exe'); + expect(result).toBeNull(); + }); }); describe('write command dispatch', () => {