Merge codex/gstack-2 into gstack2-runtime-integration

Reconcile the four integrated v2 runtime implementations (unified execution
result contract, execution profiles, capability readiness, GitHub security)
with main's browser-provider hardening.

Conflict resolutions:
- runtimeContract() generator: keep new execution-result + doctor-capability
  paragraphs, adopt main's `[matching browser flags]` fallback wording;
  regenerate the six RUNTIME.md.
- package.json: keep the strict isolated test:gstack2 runner and marked 18.0.6
  security bump; adopt main's playwright-core alias.
- bun.lock: regenerated via bun install.
- release-hardening.test.ts: adopt main's browser-provider assertions
  (resolveServerLaunchTarget, --browser managed smoke loop).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sinabina
2026-07-21 13:15:03 -07:00
co-authored by Claude Opus 4.8
160 changed files with 4397 additions and 522 deletions
+34 -2
View File
@@ -44,6 +44,30 @@ export function isCustomChromium(): boolean {
return p.includes('GBrowser') || p.includes('gbrowser');
}
/**
* Return the explicitly selected Chromium executable for both headless and
* headed launches. Keeping this opt-in preserves the managed browser fallback
* while allowing the lightweight playwright-core adapter to reuse a system or
* host-managed Chrome without downloading Playwright's browser package.
*/
export function configuredChromiumExecutable(
env: NodeJS.ProcessEnv = process.env,
): string | undefined {
const value = env.GSTACK_CHROMIUM_PATH?.trim();
return value || undefined;
}
/** Installed-system Chromium is supported only for headless automation. */
export function assertHeadedBrowserProvider(
env: NodeJS.ProcessEnv = process.env,
): void {
if (env.GSTACK_BROWSER_PROVIDER === 'installed') {
throw new Error(
'Visible GStack Browser requires managed Chromium; installed Chrome-family browsers are headless-only',
);
}
}
/**
* Decide whether Playwright should request Chromium's sandbox.
*
@@ -358,9 +382,11 @@ export class BrowserManager {
// BROWSE_EXTENSIONS_DIR points to an unpacked Chrome extension directory.
// Extensions only work in headed mode, so we use an off-screen window.
const extensionsDir = process.env.BROWSE_EXTENSIONS_DIR;
if (extensionsDir) assertHeadedBrowserProvider();
const { STEALTH_LAUNCH_ARGS, buildGStackLaunchArgs } = await import('./stealth');
const launchArgs: string[] = [...STEALTH_LAUNCH_ARGS, ...buildGStackLaunchArgs()];
let useHeadless = true;
const executablePath = configuredChromiumExecutable();
// Docker/CI/root: Chromium sandbox requires unprivileged user namespaces which
// are typically disabled in containers and are never available for the root
@@ -387,7 +413,11 @@ export class BrowserManager {
this.browser = await chromium.launch({
headless: useHeadless,
...(useHeadless && managedHeadlessChannel() ? { channel: 'chromium' as const } : {}),
...(executablePath
? { executablePath }
: useHeadless && managedHeadlessChannel()
? { channel: 'chromium' as const }
: {}),
// On Windows, Chromium's sandbox fails when the server is spawned through
// the Bun→Node process chain (GitHub #276). Disable it — local daemon
// browsing user-specified URLs has marginal sandbox benefit. Also disabled
@@ -447,6 +477,7 @@ export class BrowserManager {
* every action Claude takes in real time.
*/
async launchHeaded(authToken?: string): Promise<void> {
assertHeadedBrowserProvider();
// Clear old state before repopulating
this.pages.clear();
this.tabSessions.clear();
@@ -515,7 +546,7 @@ export class BrowserManager {
// Support custom Chromium binary via GSTACK_CHROMIUM_PATH env var.
// Used by GStack Browser.app to point at the bundled Chromium.
const executablePath = process.env.GSTACK_CHROMIUM_PATH || undefined;
const executablePath = configuredChromiumExecutable();
// Rebrand Chromium → GStack Browser in macOS menu bar / Dock / Cmd+Tab.
// Patch the Chromium .app's Info.plist so macOS shows our name.
@@ -1557,6 +1588,7 @@ export class BrowserManager {
* If step 2 fails → return error, headless browser untouched
*/
async handoff(message: string): Promise<string> {
assertHeadedBrowserProvider();
if (this.connectionMode === 'headed' || this.isHeaded) {
return `HANDOFF: Already in headed mode at ${this.getCurrentUrl()}`;
}
+10 -5
View File
@@ -118,7 +118,7 @@ interface ServerState {
serverPath: string;
binaryVersion?: string;
mode?: 'launched' | 'headed';
/** Hash of (proxyUrl + headed flag), used by D2 daemon-mismatch check. */
/** Hash of proxy, headed mode, and browser-provider intent, used by daemon-mismatch checks. */
configHash?: string;
/** Xvfb child PID for cleanup on disconnect. */
xvfbPid?: number;
@@ -431,8 +431,8 @@ async function ensureServer(flags?: GlobalFlags): Promise<ServerState> {
// hint. No silent restart — that would drop tab state, cookies, and
// logged-in sessions without warning.
if (desiredHash && state.configHash && state.configHash !== desiredHash) {
console.error(`[browse] existing daemon has different config (proxy/headed mismatch).`);
console.error(`[browse] run 'browse disconnect' first to apply --proxy/--headed.`);
console.error(`[browse] existing daemon has different config (browser provider, proxy, or headed mode).`);
console.error(`[browse] run 'browse disconnect' first to apply the selected browser configuration.`);
process.exit(1);
}
// Same path: existing daemon is plain (no flags) but caller passes
@@ -782,7 +782,7 @@ export interface GlobalFlags {
proxyUrl: string | null;
/** Whether --headed was passed. */
headed: boolean;
/** Hash of (proxy + headed) for daemon-mismatch check. */
/** Hash of proxy, headed mode, and browser-provider intent for daemon-mismatch checks. */
configHash: string;
/** Redacted form of proxyUrl, safe for logs. */
redactedProxyUrl: string;
@@ -842,7 +842,12 @@ export function extractGlobalFlags(rawArgs: string[], env: NodeJS.ProcessEnv): G
args: out,
proxyUrl: canonicalProxyUrl,
headed,
configHash: computeConfigHash({ proxyUrl: canonicalProxyUrl, headed }),
configHash: computeConfigHash({
proxyUrl: canonicalProxyUrl,
headed,
browserProvider: env.GSTACK_BROWSER_PROVIDER,
browserExecutable: env.GSTACK_CHROMIUM_PATH,
}),
redactedProxyUrl: redactProxyUrl(canonicalProxyUrl),
};
}
+11 -2
View File
@@ -125,7 +125,7 @@ export function toUpstreamConfig(cfg: ParsedProxyConfig): UpstreamConfig {
}
/**
* Compute a stable hash of (proxyUrl + headed flag) for daemon-mismatch
* Compute a stable hash of proxy, headed mode, and browser-provider intent for daemon-mismatch
* detection (D2). The hash is deterministic across CLI invocations on the
* same machine and survives daemon restarts via the state file.
*
@@ -135,9 +135,18 @@ export function toUpstreamConfig(cfg: ParsedProxyConfig): UpstreamConfig {
export function computeConfigHash(opts: {
proxyUrl: string | null | undefined;
headed: boolean;
browserProvider?: string | null;
browserExecutable?: string | null;
}): string {
const proxyKey = canonicalizeProxyUrl(opts.proxyUrl);
const input = JSON.stringify({ proxy: proxyKey, headed: opts.headed });
const browserProvider = opts.browserProvider || null;
const browserExecutable = browserProvider === "installed" ? opts.browserExecutable || null : null;
const input = JSON.stringify({
proxy: proxyKey,
headed: opts.headed,
browserProvider,
browserExecutable,
});
return createHash('sha256').update(input).digest('hex').slice(0, 16);
}
+12 -5
View File
@@ -355,11 +355,18 @@ export async function handleWriteCommand(
}
} catch (err: any) {
// Enhanced error guidance: clicking <option> elements always fails (not visible / timeout)
const isOption = 'locator' in resolved
? await resolved.locator.evaluate(el => el.tagName === 'OPTION').catch(() => false)
: await target.locator(resolved.selector).evaluate(
el => el.tagName === 'OPTION'
).catch(() => false);
// Do not start a second auto-wait after the click has already timed out.
// Missing selectors used to spend 5s in click(), then block again in
// evaluate() until the outer client killed the command. count() is an
// immediate query and keeps the helpful option guidance only when one
// unique element actually exists.
const optionLocator = 'locator' in resolved
? resolved.locator
: target.locator(resolved.selector);
const optionCount = await optionLocator.count().catch(() => 0);
const isOption = optionCount === 1
? await optionLocator.evaluate(el => el.tagName === 'OPTION').catch(() => false)
: false;
if (isOption) {
throw new Error(
`Cannot click <option> elements. Use 'browse select <parent-select> <value>' instead of 'click' for dropdown options.`
+34 -1
View File
@@ -7,7 +7,7 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { startTestServer } from './test-server';
import { BrowserManager } from '../src/browser-manager';
import { BrowserManager, assertHeadedBrowserProvider, configuredChromiumExecutable } from '../src/browser-manager';
import { resolveServerScript } from '../src/cli';
import { handleReadCommand as _handleReadCommand, parseOutArgs, hasOutArg, resultToString } from '../src/read-commands';
import { handleWriteCommand as _handleWriteCommand } from '../src/write-commands';
@@ -23,6 +23,25 @@ const handleReadCommand = (cmd: string, args: string[], b: BrowserManager) =>
const handleWriteCommand = (cmd: string, args: string[], b: BrowserManager) =>
_handleWriteCommand(cmd, args, b.getActiveSession(), b);
describe('configuredChromiumExecutable', () => {
test('returns and trims an explicitly selected system browser', () => {
expect(configuredChromiumExecutable({
GSTACK_CHROMIUM_PATH: ' /Applications/Google Chrome.app/Contents/MacOS/Google Chrome ',
})).toBe('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome');
});
test('keeps the managed-browser path when no override is selected', () => {
expect(configuredChromiumExecutable({})).toBeUndefined();
expect(configuredChromiumExecutable({ GSTACK_CHROMIUM_PATH: ' ' })).toBeUndefined();
});
test('rejects headed launch when setup selected an installed system browser', () => {
expect(() => assertHeadedBrowserProvider({ GSTACK_BROWSER_PROVIDER: 'installed' }))
.toThrow('Visible GStack Browser requires managed Chromium');
expect(() => assertHeadedBrowserProvider({ GSTACK_BROWSER_PROVIDER: 'managed' })).not.toThrow();
});
});
// ─── Pure arg-parser + result-conversion unit tests (no browser) ───
describe('parseOutArgs / hasOutArg', () => {
test('--out <path> splits the flag from the positional', () => {
@@ -459,6 +478,20 @@ describe('Interaction', () => {
}
}, 15000);
test('click on a missing selector does not start a second locator wait', async () => {
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
const started = performance.now();
try {
await handleWriteCommand('click', ['#definitely-missing-regression-node'], bm);
expect(true).toBe(false); // Should not reach here
} catch (err: any) {
expect(err.message).toContain('#definitely-missing-regression-node');
}
// click() intentionally retains Playwright's 5s auto-wait. The regression
// was a second default locator wait that pushed the total beyond 8s.
expect(performance.now() - started).toBeLessThan(6500);
}, 8000);
test('hover works', async () => {
const result = await handleWriteCommand('hover', ['h1'], bm);
expect(result).toContain('Hovered');
@@ -92,6 +92,47 @@ describe('D2 daemon-mismatch refuse (CLI integration)', () => {
}
}, 15000);
test('refuses to reuse a same-version daemon from a different browser provider', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-provider-mismatch-'));
const stateFile = path.join(tmpDir, 'browse.json');
const fakeServer = await startFakeHealthServer('fake-token');
const { computeConfigHash } = await import('../src/proxy-config');
const managedHash = computeConfigHash({
proxyUrl: null,
headed: false,
browserProvider: 'managed',
});
fs.writeFileSync(stateFile, JSON.stringify({
pid: process.pid,
port: fakeServer.port,
token: 'fake-token',
startedAt: new Date().toISOString(),
serverPath: '',
mode: 'launched',
configHash: managedHash,
}, null, 2));
const cliEnv: Record<string, string> = {};
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined) cliEnv[key] = value;
}
cliEnv.BROWSE_STATE_FILE = stateFile;
cliEnv.GSTACK_BROWSER_PROVIDER = 'installed';
cliEnv.GSTACK_CHROMIUM_PATH = process.execPath;
try {
const result = await runCli(['status'], cliEnv);
expect(result.code).toBe(1);
expect(result.stderr).toContain('different config');
expect(result.stderr).toContain('browse disconnect');
} finally {
await fakeServer.close();
try { fs.unlinkSync(stateFile); } catch { /* ignore */ }
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}, 15000);
test('refuses when existing plain daemon meets a --proxy invocation', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-mismatch-plain-'));
const stateFile = path.join(tmpDir, 'browse.json');
+16
View File
@@ -186,4 +186,20 @@ describe('extractGlobalFlags', () => {
);
expect(a.configHash).not.toBe(b.configHash);
});
test('configHash changes with browser provider and installed executable', () => {
const managed = extractGlobalFlags(['goto', 'x'], {
GSTACK_BROWSER_PROVIDER: 'managed',
} as NodeJS.ProcessEnv);
const installedA = extractGlobalFlags(['goto', 'x'], {
GSTACK_BROWSER_PROVIDER: 'installed',
GSTACK_CHROMIUM_PATH: '/browser/a',
} as NodeJS.ProcessEnv);
const installedB = extractGlobalFlags(['goto', 'x'], {
GSTACK_BROWSER_PROVIDER: 'installed',
GSTACK_CHROMIUM_PATH: '/browser/b',
} as NodeJS.ProcessEnv);
expect(managed.configHash).not.toBe(installedA.configHash);
expect(installedA.configHash).not.toBe(installedB.configHash);
});
});