mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
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 <noreply@anthropic.com> * 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 <adammartin0087@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
9 files changed
+159
-15
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'");
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user