From 1b29e5dc5d8601b99669d3b5ee36b4ecb19b66c3 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sat, 24 Oct 2026 21:25:28 -0700 Subject: [PATCH] v1.91.193.0 fix(browse): spawn the real bun.exe for the terminal agent on Windows (#2295, port of #3357) (#3388) * fix(browse): spawn bun.exe directly for the terminal-agent on Windows Under the Node polyfill a bare 'bun' resolved (via cross-spawn) to npm's bun.cmd shim and ran as `cmd.exe /c bun run terminal-agent.ts`. windowsHide only hid cmd.exe, and the detached cmd.exe had no console, so bun.exe allocated a visible one that stole focus on every spawn. proc.pid was cmd.exe's, so the agent's startup check (record.pid === process.pid) never matched; it exited after 2s and the 60s watchdog respawned it (#2295). resolveBunExecutable() finds the real bun.exe (own execPath under bun, BUN_INSTALL, PATH, the npm-global node_modules layout, ~/.bun) and falls back to 'bun'. POSIX is unchanged. Co-Authored-By: Claude Opus 5.5 * refactor(browse): one Windows bun.exe lookup for the terminal agent and the native cookie-import worker (#2295) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): Windows terminal agent spawns bun.exe directly (#2295, port of #3357) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(browse): the terminal-agent spawn pin expects resolveBunExecutable() (#3357) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: Adam Martin Co-authored-by: Claude Opus 5.5 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 12 +++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- browse/src/bun-child-config.ts | 38 ++++++++++ browse/src/cookie-import-native.ts | 11 +-- browse/src/terminal-agent-control.ts | 20 ++++- browse/test/sidebar-tabs.test.ts | 2 +- browse/test/terminal-agent-bun-exe.test.ts | 85 ++++++++++++++++++++++ package.json | 2 +- 9 files changed, 159 insertions(+), 15 deletions(-) create mode 100644 browse/test/terminal-agent-bun-exe.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 51fd86b55..13271bf1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [1.91.193.0] - 2026-10-25 + +**Windows: the browse terminal agent stops respawning every 60 seconds and stops flashing a console when Bun came from npm (#2295; ported from #3357 by @adammartin0087).** The agent was spawned as a bare `bun`. With Bun installed through `npm install -g bun`, that name resolves to npm's `bun.cmd` shim and runs as `cmd.exe /c bun …`, so `windowsHide` hid only `cmd.exe` while `bun.exe` opened its own visible console, and the recorded pid was `cmd.exe`'s. The agent's startup check never matched its record, it exited after 2 seconds, and the watchdog respawned it. + +### Fixed +- **`browse/src/terminal-agent-control.ts`**: `spawnTerminalAgent` spawns `resolveBunExecutable()`, which on Windows is the real `bun.exe`: the running `bun.exe` itself, then `BUN_INSTALL\bin`, each absolute `PATH` entry (and, next to an npm `bun.cmd` shim, the `node_modules\bun\bin\bun.exe` it forwards to), then `USERPROFILE\.bun\bin`. With none found it falls back to `bun`, as before. macOS and Linux are unchanged. +- **`browse/src/bun-child-config.ts`**, **`browse/src/cookie-import-native.ts`**: that lookup is `findWindowsBunExe()`, and the Windows native cookie-import worker now uses it instead of its own copy, so it also finds an npm-global Bun. It lives in `bun-child-config.ts` because that file is already in the native import's source-bound qualification set (`NATIVE_CODE_INPUTS`). The edit changes the hash of `cookie-import-native.ts` and `bun-child-config.ts`; the qualification list is empty today, so no receipt is invalidated. + +### Tests +- `browse/test/terminal-agent-bun-exe.test.ts` (from the PR) covers POSIX, the running `bun.exe`, a compiled `browse.exe`, the npm shim, lookup order and the fallback, plus a source check that the spawn site never passes a bare `'bun'`. New: the shared lookup returns null when nothing is installed, finds the npm-global `bun.exe` for the cookie worker, and `cookie-import-native.ts` has no candidate list of its own. +- Not verified here: real Windows behaviour. The PR author tested on Windows 11; the `windows-free-tests` lane runs the new unit tests. + ## [1.91.192.0] - 2026-10-25 **Merge trains stop refusing correct heads and stop passing damaged CHANGELOGs.** This week's trains hit five defects in the ship-queue tooling. #3331's READY was invisible to the coordinator because its lane's origin was `https://git.capy.ai/...`. #3345's pin replaced the released `## [1.91.179.0]` heading, and three things then failed. The Version Gate passed the pin. `mergeChangelogs` copied 179's body into `[Unreleased]`. `verify --train` refused the correct hand-resolved head at 593e61c. Separately, the train slotted the /cso docs PR (#3337) ahead of the catalog PR it describes (#3343). Each defect now has a regression test built from the real case. diff --git a/VERSION b/VERSION index db41280e0..15112a52f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.192.0 +1.91.193.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 07dec8257..0fde9150f 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.192.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.193.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/browse/src/bun-child-config.ts b/browse/src/bun-child-config.ts index 045a97571..7c808c02b 100644 --- a/browse/src/bun-child-config.ts +++ b/browse/src/bun-child-config.ts @@ -33,3 +33,41 @@ export function emptyBunConfig(platform: NodeJS.Platform = process.platform): st emptyConfig = file; return file; } + +export interface BunExeLookup { + env?: NodeJS.ProcessEnv; + execPath?: string; + isBun?: boolean; + isFile?: (p: string) => boolean; +} + +/** + * The bun.exe a Windows parent spawns its bun children with, or null when + * none is installed: the running bun.exe itself, then BUN_INSTALL\bin, each + * absolute PATH entry (plus, beside npm's bun.cmd shim, the bun.exe it + * forwards to), then USERPROFILE\.bun\bin. The terminal agent and the native + * cookie-import worker (cookie-import-native.ts) both resolve through it; it + * lives here because this file is part of the native import's source-bound + * qualification (NATIVE_CODE_INPUTS), and terminal-agent-control.ts is not. + */ +export function findWindowsBunExe(opts: BunExeLookup = {}): string | null { + const win = path.win32; + const env = opts.env ?? process.env; + const execPath = opts.execPath ?? process.execPath; + const isBun = opts.isBun ?? !!process.versions.bun; + const isFile = opts.isFile ?? ((p: string) => { try { return fs.statSync(p).isFile(); } catch { return false; } }); + // A compiled browse.exe also reports versions.bun, so check the name too. + if (isBun && /^bun\.exe$/i.test(win.basename(execPath))) return execPath; + const dirs = (env.PATH || env.Path || '').split(';').filter(d => d && win.isAbsolute(d)); + const candidates = [ + ...(env.BUN_INSTALL ? [win.join(env.BUN_INSTALL, 'bin', 'bun.exe')] : []), + ...dirs.flatMap(d => [ + win.join(d, 'bun.exe'), + // npm global install: PATH holds only the bun.cmd shim; the real + // binary it forwards to lives under node_modules. + ...(isFile(win.join(d, 'bun.cmd')) ? [win.join(d, 'node_modules', 'bun', 'bin', 'bun.exe')] : []), + ]), + ...(env.USERPROFILE ? [win.join(env.USERPROFILE, '.bun', 'bin', 'bun.exe')] : []), + ]; + return candidates.find(isFile) ?? null; +} diff --git a/browse/src/cookie-import-native.ts b/browse/src/cookie-import-native.ts index 6fc0c41cb..7632300a6 100644 --- a/browse/src/cookie-import-native.ts +++ b/browse/src/cookie-import-native.ts @@ -4,7 +4,7 @@ import { createRequire } from 'node:module'; import { release } from 'node:os'; import path from 'node:path'; import { CookieImportError, normalizeCookieDomain, type PlaywrightCookie } from './cookie-import-browser'; -import { emptyBunConfig } from './bun-child-config'; +import { emptyBunConfig, findWindowsBunExe } from './bun-child-config'; import { hashNativeFile, nativeCodeHashes, nativeCodeMatches, readNativeQualifications, type NativeQualifiedBuild } from './cookie-import-native-integrity'; type BrowserName = 'Chrome' | 'Chromium' | 'Brave' | 'Edge'; @@ -107,14 +107,7 @@ export async function importNativeCookies(options: { } catch (error) { throw new CookieImportError('The native browser build could not be checked safely. Sign in manually in the gstack browser.', error instanceof Error && error.message === 'native_timeout' ? 'native_timeout' : 'native_unqualified'); } - const bunCandidates = [ - ...(process.env.BUN_INSTALL ? [path.join(process.env.BUN_INSTALL, 'bin', 'bun.exe')] : []), - ...(process.env.PATH || process.env.Path || '').split(path.delimiter).filter(directory => path.isAbsolute(directory)).map(directory => path.join(directory, 'bun.exe')), - ...(process.env.USERPROFILE ? [path.join(process.env.USERPROFILE, '.bun', 'bin', 'bun.exe')] : []), - ]; - const bunExecutable = bunCandidates.find(candidate => { - try { return statSync(candidate).isFile(); } catch { return false; } - }); + const bunExecutable = findWindowsBunExe(); if (!bunExecutable || !qualified.some(build => build.executableSha256 === executableSha256 && build.playwrightVersion === playwrightVersion)) { throw new CookieImportError('This browser build or supervisor runtime has not passed native qualification. Sign in manually in the gstack browser.', 'native_unqualified'); } diff --git a/browse/src/terminal-agent-control.ts b/browse/src/terminal-agent-control.ts index 1b7247838..bda034539 100644 --- a/browse/src/terminal-agent-control.ts +++ b/browse/src/terminal-agent-control.ts @@ -22,7 +22,7 @@ import { safeUnlink, isProcessAlive } from './error-handling'; import { restrictFilePermissions, mkdirSecure } from './file-permissions'; import { atomicWriteSync } from '../../lib/fs-atomic'; import { readPidCmdline, readPidStartTime } from './xvfb'; -import { emptyBunConfig } from './bun-child-config'; +import { emptyBunConfig, findWindowsBunExe, type BunExeLookup } from './bun-child-config'; function agentProcessInfo(pid: number): { startTime: string; commandLine: string } { if (!Number.isSafeInteger(pid) || pid <= 0) return { startTime: '', commandLine: '' }; @@ -149,6 +149,22 @@ export function bunChildFlags(): string[] { return ['--no-env-file', `--config=${emptyBunConfig()}`]; } +/** + * The bun executable to spawn the agent with. On Windows a bare 'bun' under + * the Node polyfill resolves (via cross-spawn) to npm's bun.cmd shim, run as + * `cmd.exe /c bun ...`. That breaks two ways: windowsHide only hides cmd.exe, + * and the detached cmd.exe has no console, so bun.exe allocates a fresh + * visible one (a window that steals focus on every spawn); and proc.pid is + * cmd.exe's, so the agent's startup check (record.pid === process.pid) never + * matches and it exits after 2s, which the 60s watchdog then respawns. + * Spawning bun.exe directly fixes both. Falls back to 'bun' when no .exe is + * found, which is the old behavior. + */ +export function resolveBunExecutable(opts: BunExeLookup & { platform?: string } = {}): string { + if ((opts.platform ?? process.platform) !== 'win32') return 'bun'; + return findWindowsBunExe(opts) ?? 'bun'; +} + export function resolveTerminalAgentScript(searchHints: { metaDir?: string; execPath?: string } = {}): string | null { const meta = searchHints.metaDir || __dirname; const exec = searchHints.execPath || process.execPath; @@ -208,7 +224,7 @@ export function spawnTerminalAgent(opts: { writeAgentRecord(stateDir, reservation); let proc: any; try { - proc = (Bun as any).spawn(['bun', 'run', ...bunChildFlags(), script, `--agent-gen=${gen}`], { + proc = (Bun as any).spawn([resolveBunExecutable(), 'run', ...bunChildFlags(), script, `--agent-gen=${gen}`], { cwd: opts.cwd || process.cwd(), env: { ...process.env, diff --git a/browse/test/sidebar-tabs.test.ts b/browse/test/sidebar-tabs.test.ts index a8ece8f68..3e0eed513 100644 --- a/browse/test/sidebar-tabs.test.ts +++ b/browse/test/sidebar-tabs.test.ts @@ -233,7 +233,7 @@ describe('cli.ts: sidebar-agent is no longer spawned', () => { 'utf-8', ); expect(CONTROL_SRC).toContain('terminal-agent.ts'); - expect(CONTROL_SRC).toMatch(/\.spawn\(\['bun',\s*'run',\s*\.\.\.bunChildFlags\(\),\s*script,\s*`--agent-gen=\$\{gen\}`\]/); + expect(CONTROL_SRC).toMatch(/\.spawn\(\[resolveBunExecutable\(\),\s*'run',\s*\.\.\.bunChildFlags\(\),\s*script,\s*`--agent-gen=\$\{gen\}`\]/); expect(CONTROL_SRC).toContain('BROWSE_OWNER_PID: String(opts.ownerPid)'); }); }); diff --git a/browse/test/terminal-agent-bun-exe.test.ts b/browse/test/terminal-agent-bun-exe.test.ts new file mode 100644 index 000000000..5be1b0abf --- /dev/null +++ b/browse/test/terminal-agent-bun-exe.test.ts @@ -0,0 +1,85 @@ +/** + * Windows terminal-agent console flash: under the Node polyfill a bare 'bun' + * resolved to npm's bun.cmd shim and ran as `cmd.exe /c bun ...`. The detached + * cmd.exe had no console, so bun.exe opened a visible one on every spawn, and + * proc.pid was cmd.exe's, so the agent failed its startup record check and + * exited, and the 60s watchdog respawned it. spawnTerminalAgent now spawns + * bun.exe directly. + */ +import { describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { findWindowsBunExe } from '../src/bun-child-config'; +import { resolveBunExecutable } from '../src/terminal-agent-control'; + +const files = (...paths: string[]) => (p: string) => paths.some(f => f.toLowerCase() === p.toLowerCase()); + +describe('resolveBunExecutable', () => { + test('POSIX keeps bare bun', () => { + expect(resolveBunExecutable({ platform: 'linux', isFile: () => true })).toBe('bun'); + expect(resolveBunExecutable({ platform: 'darwin', isFile: () => true })).toBe('bun'); + }); + + test('Windows under bun.exe reuses its own executable', () => { + expect(resolveBunExecutable({ + platform: 'win32', isBun: true, execPath: 'C:\\Tools\\bun.exe', env: {}, isFile: () => false, + })).toBe('C:\\Tools\\bun.exe'); + }); + + test('a compiled browse.exe is not mistaken for bun', () => { + expect(resolveBunExecutable({ + platform: 'win32', isBun: true, execPath: 'C:\\gstack\\browse\\dist\\browse.exe', + env: { PATH: 'C:\\bun\\bin' }, isFile: files('C:\\bun\\bin\\bun.exe'), + })).toBe('C:\\bun\\bin\\bun.exe'); + }); + + test('npm global install resolves through the bun.cmd shim to the real bun.exe', () => { + const npm = 'C:\\Users\\u\\AppData\\Roaming\\npm'; + expect(resolveBunExecutable({ + platform: 'win32', isBun: false, execPath: 'C:\\Program Files\\nodejs\\node.exe', + env: { PATH: `C:\\Windows\\System32;${npm}` }, + isFile: files(`${npm}\\bun.cmd`, `${npm}\\node_modules\\bun\\bin\\bun.exe`), + })).toBe(`${npm}\\node_modules\\bun\\bin\\bun.exe`); + }); + + test('BUN_INSTALL wins over PATH, and USERPROFILE\\.bun is the last resort', () => { + const isFile = files('C:\\bi\\bin\\bun.exe', 'C:\\p\\bun.exe', 'C:\\Users\\u\\.bun\\bin\\bun.exe'); + const base = { platform: 'win32', isBun: false, execPath: 'node.exe', isFile }; + expect(resolveBunExecutable({ ...base, env: { BUN_INSTALL: 'C:\\bi', PATH: 'C:\\p' } })).toBe('C:\\bi\\bin\\bun.exe'); + expect(resolveBunExecutable({ ...base, env: { PATH: 'C:\\p' } })).toBe('C:\\p\\bun.exe'); + expect(resolveBunExecutable({ ...base, env: { PATH: 'C:\\none', USERPROFILE: 'C:\\Users\\u' } })).toBe('C:\\Users\\u\\.bun\\bin\\bun.exe'); + }); + + test('falls back to bare bun when no bun.exe exists', () => { + expect(resolveBunExecutable({ + platform: 'win32', isBun: false, execPath: 'node.exe', env: { PATH: 'C:\\x;relative' }, isFile: () => false, + })).toBe('bun'); + }); + + test('spawnTerminalAgent spawns through the resolver, not a bare bun', () => { + const source = fs.readFileSync(path.resolve(import.meta.dir, '../src/terminal-agent-control.ts'), 'utf8'); + const spawnSite = source.slice(source.indexOf('export function spawnTerminalAgent')); + expect(spawnSite).toContain('spawn([resolveBunExecutable(), \'run\''); + expect(spawnSite).not.toMatch(/spawn\(\[\s*'bun'/); + }); +}); + +describe('findWindowsBunExe is the one bun.exe lookup', () => { + test('returns null, not a bare bun, when no bun.exe is installed', () => { + expect(findWindowsBunExe({ isBun: false, execPath: 'node.exe', env: { PATH: 'C:\\x;relative' }, isFile: () => false })).toBeNull(); + }); + + test('the native cookie-import worker finds an npm-global bun.exe through the same lookup', () => { + const npm = 'C:\\Users\\u\\AppData\\Roaming\\npm'; + expect(findWindowsBunExe({ + isBun: false, execPath: 'C:\\Program Files\\nodejs\\node.exe', env: { PATH: npm }, + isFile: files(`${npm}\\bun.cmd`, `${npm}\\node_modules\\bun\\bin\\bun.exe`), + })).toBe(`${npm}\\node_modules\\bun\\bin\\bun.exe`); + }); + + test('cookie-import-native has no bun.exe candidate list of its own', () => { + const source = fs.readFileSync(path.resolve(import.meta.dir, '../src/cookie-import-native.ts'), 'utf8'); + expect(source).toContain('const bunExecutable = findWindowsBunExe();'); + expect(source).not.toContain("'bun.exe'"); + }); +}); diff --git a/package.json b/package.json index eafbad88c..fc98e57f0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.192", + "version": "1.91.193", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module",