mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-21 04:10:47 +02:00
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:
@@ -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
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.`
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user