mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-28 07:32:14 +02:00
v1.90.0.0 feat: make browser cookie imports explicit and safe (#2964)
* fix(browse): prepare reliable cookie import wave for validation * ci: sequence quality and behavior for validation branch * fix(browse): isolate Windows qualification and preserve native diagnostics * test(browse): cover cookie workflow quality and isolate Windows user paths * test(browse): trace native member startup and initialize fresh folders * fix(browse): keep Windows member stdin alive through EOF * fix(browse): latch native timeouts and compare contained Edge startup * test(browse): verify native version metadata and actual Windows argv * test(browse): qualify Dia import on isolated macOS CI * fix(browse): require picker origin for session mutations * fix(browse): bound credential reads through stream completion * test(browse): inspect owned Windows process arguments natively * test(evals): preserve passing coverage during cookie repair reruns * test(browse): isolate Dia qualification in a fresh macOS account * test(browse): pass bounded integer timeouts to native Mac probes * test(browse): distinguish Windows profile initialization from containment * test(browse): await descendant pipe readiness before parent exit * test(browse): initialize and restore isolated macOS Keychain state * test(browse): initialize Windows fixture folders before qualification * test(ci): pin the same Node runtime across Windows checks * test(browse): distinguish native macOS browser preflight stages * test(browse): isolate Windows descendant console lifetime * test(browse): preserve native receipts and identify fixture lock holders * test(browse): prepare dependency resolution before native Mac worker startup * test(ci): include lock and close checks in native diagnostics * test(browse): preserve native owner probe stages and subprocess deadlines * fix(browse): classify Chromium profile-in-use exit precisely * test(browse): retain Mac qualification evidence through cleanup failures * test(browse): bound Mac fixture paths and retire its owned user domain * test(browse): accept vanished fixture entries without weakening cleanup * test(browse): identify probe-created macOS user domains safely * test(browse): observe Mac user domains without targeting them first * test(browse): use passive fresh-user ownership throughout Mac qualification * test(browse): distinguish profile and registered-home Keychain lookups * test(browse): qualify Dia under one registered account home * test(browse): identify Dia startup and owned process-group failures * test(browse): classify bounded Dia startup diagnostics without leaking output * fix(test): preserve native Mac sandboxing and reap owned browser children * fix(browse): preserve Chromium sandboxing for native profile imports * test(browse): inspect signed Mach-O architecture without launching Xcode tools * test(browse): sample pending Dia startup and reap on all cleanup paths * test(browse): compare protected Dia launches in fresh Bun and Node accounts * test(browse): inspect isolated Mac GUI readiness without browser access * v1.90.0.0 fix: bind cookie picker actions to their document * test: validate cookie guards and fit nested launch fixtures * ci: configure the bundled Chromium sandbox helper * fix(browse): classify Playwright authentication timeouts * test: retain bounded Windows lifecycle diagnostics * test(cso): reuse bounded NTFS precision candidates * test(review): handle explicit preservation choices safely * test(browse): remove owned fixture directories with explicit primitives * test(review): distinguish descriptive reuse from edit commitments * test: admit only the approved unscored cookie workflow refusal * test: keep the Office Hours judge mock export-complete * fix: keep dependency-free CI planners independent of the model SDK * test: observe the exact holder after a native fixture unlink failure * fix: start seeded PTY observations at owned readiness * test: acquire identity-bound Windows deletion admission before profile resets * test: preserve qualified Git index bits without authorizing mutations
This commit is contained in:
@@ -0,0 +1,436 @@
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
import { copyFileSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { release, tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { nativeBrowserPaths, importNativeCookies } from '../src/cookie-import-native';
|
||||
import { nativeCookieEnvironment, NATIVE_COOKIE_NODE_SCRIPT, NATIVE_PROGRESS_PREFIX } from '../src/cookie-import-native-worker';
|
||||
import { parseNativeCookieDiagnostic } from '../src/cookie-import-native-job';
|
||||
import { hashNativeFile, nativeCodeHashes, nativeCodeMatches, NATIVE_CODE_INPUTS, NATIVE_QUALIFICATION_DATA, readNativeQualifications } from '../src/cookie-import-native-integrity';
|
||||
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'native-cookies-'));
|
||||
const env = { LOCALAPPDATA: 'C:\\fixture\\Local', PROGRAMFILES: 'C:\\Apps', 'PROGRAMFILES(X86)': 'C:\\Apps32' };
|
||||
const node = Bun.which('node');
|
||||
if (!node) throw new Error('Node is required for native-cookie transport tests');
|
||||
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
function isolatedEnv(): NodeJS.ProcessEnv {
|
||||
const local = path.join(root, 'AppData', 'Local');
|
||||
const roaming = path.join(root, 'AppData', 'Roaming');
|
||||
const temporary = path.join(local, 'Temp');
|
||||
for (const directory of [local, roaming, temporary]) mkdirSync(directory, { recursive: true });
|
||||
return {
|
||||
PATH: path.dirname(node!),
|
||||
HOME: root,
|
||||
USERPROFILE: root,
|
||||
LOCALAPPDATA: local,
|
||||
APPDATA: roaming,
|
||||
TEMP: temporary,
|
||||
TMP: temporary,
|
||||
...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function expectNativeProgress(stderr: string) {
|
||||
const lines = stderr.trim().split(/\r?\n/).filter(Boolean);
|
||||
expect(lines.length).toBeGreaterThan(0);
|
||||
return lines.map(line => {
|
||||
expect(line.startsWith(NATIVE_PROGRESS_PREFIX)).toBe(true);
|
||||
const record = JSON.parse(line.slice(NATIVE_PROGRESS_PREFIX.length));
|
||||
expect(parseNativeCookieDiagnostic(record)).toEqual(record);
|
||||
return record;
|
||||
});
|
||||
}
|
||||
|
||||
function adapter(options: object, extraEnv: object = {}) {
|
||||
const script = `
|
||||
const { importNativeCookies } = await import(${JSON.stringify(path.resolve(import.meta.dir, '../src/cookie-import-native.ts'))});
|
||||
const { CookieImportError } = await import(${JSON.stringify(path.resolve(import.meta.dir, '../src/cookie-import-browser.ts'))});
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
Object.defineProperty(process.versions, 'bun', { value: undefined });
|
||||
process.env.LOCALAPPDATA = ${JSON.stringify(env.LOCALAPPDATA)};
|
||||
Object.assign(process.env, ${JSON.stringify(extraEnv)});
|
||||
try { console.log(JSON.stringify({ cookies: await importNativeCookies(${JSON.stringify(options)}) })); }
|
||||
catch (error) { console.log(JSON.stringify({ code: error.code, message: error.message, typed: error instanceof CookieImportError })); }
|
||||
`;
|
||||
const result = spawnSync(process.execPath, ['--no-env-file', '--no-install', `--config=${process.platform === 'win32' ? 'NUL' : '/dev/null'}`, '-e', script], { env: isolatedEnv(), encoding: 'utf8', timeout: 10_000 });
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe('');
|
||||
return JSON.parse(result.stdout);
|
||||
}
|
||||
|
||||
const options = {
|
||||
browserName: 'Edge',
|
||||
userDataDir: 'C:\\fixture\\Local\\Microsoft\\Edge\\User Data',
|
||||
profile: 'Default',
|
||||
domains: ['example.test'],
|
||||
};
|
||||
|
||||
describe('native-cookie production admission', () => {
|
||||
test('the real member entry reports boot and decoded input before refusing an unowned job', () => {
|
||||
const result = spawnSync(process.execPath, ['--no-env-file', '--no-install', '--no-macros', `--config=${process.platform === 'win32' ? 'NUL' : '/dev/null'}`, path.resolve(import.meta.dir, '../src/cookie-import-native-worker.ts'), '--member-smoke'], {
|
||||
env: isolatedEnv(), input: JSON.stringify({ jobName: 'Local\\gstack-cookie-00000000-0000-0000-0000-000000000000' }), encoding: 'utf8', timeout: 10_000,
|
||||
});
|
||||
const progress = expectNativeProgress(result.stderr);
|
||||
expect(progress).toContainEqual({ stage: 'worker_boot', memberMode: true });
|
||||
expect(progress).toContainEqual({ stage: 'member_input' });
|
||||
expect(progress).toContainEqual({ stage: 'member_decoded' });
|
||||
expect(result.status).toBe(1);
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({ error: 'native_supervision_failed', diagnostic: { stage: 'job_open' } });
|
||||
});
|
||||
|
||||
test.each([['malformed', '{'], ['oversized', 'x'.repeat(1024 * 1024 + 1)]])('the real member rejects %s input before opening a job', (_name, input) => {
|
||||
const result = spawnSync(process.execPath, ['--no-env-file', '--no-install', '--no-macros', `--config=${process.platform === 'win32' ? 'NUL' : '/dev/null'}`, path.resolve(import.meta.dir, '../src/cookie-import-native-worker.ts'), '--member-smoke'], {
|
||||
env: isolatedEnv(), input, encoding: 'utf8', timeout: 10_000,
|
||||
});
|
||||
const progress = expectNativeProgress(result.stderr);
|
||||
expect(progress).toContainEqual({ stage: 'member_input' });
|
||||
expect(progress).not.toContainEqual({ stage: 'member_decoded' });
|
||||
expect(progress).not.toContainEqual({ stage: 'job_open' });
|
||||
expect(result.status).toBe(1);
|
||||
expect(JSON.parse(result.stdout)).toEqual({ error: 'native_supervision_failed', diagnostic: { stage: 'member_input' } });
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === 'win32')('qualification refuses non-Windows without issuing a receipt', () => {
|
||||
const result = spawnSync(process.execPath, ['--no-env-file', '--no-install', '--config=/dev/null', path.resolve(import.meta.dir, 'cookie-import-native-qualification.ts'), root], {
|
||||
env: isolatedEnv(), encoding: 'utf8', timeout: 10_000,
|
||||
});
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stdout).toBe('');
|
||||
expect(result.stderr).toContain('no cases ran and no qualification was issued');
|
||||
});
|
||||
|
||||
test('activation data is separate from the receipt code inputs', () => {
|
||||
expect(NATIVE_CODE_INPUTS).toContain('browse/src/cookie-import-native.ts');
|
||||
expect(NATIVE_CODE_INPUTS).toContain('browse/src/cookie-database.ts');
|
||||
expect(NATIVE_CODE_INPUTS).toContain('browse/src/cookie-import-native-worker.ts');
|
||||
expect(NATIVE_CODE_INPUTS).toContain('browse/scripts/build-node-server.sh');
|
||||
expect(NATIVE_CODE_INPUTS).toContain('browse/dist/server-node.mjs');
|
||||
expect(NATIVE_CODE_INPUTS).not.toContain(NATIVE_QUALIFICATION_DATA);
|
||||
});
|
||||
|
||||
test('empty selection never launches or widens to all cookies', async () => {
|
||||
expect(await importNativeCookies({ ...options, domains: [] })).toEqual([]);
|
||||
});
|
||||
|
||||
test('rejects unsupported production runtime', async () => {
|
||||
await expect(importNativeCookies(options)).rejects.toMatchObject({ code: 'not_supported' });
|
||||
});
|
||||
|
||||
test('rejects invalid domains before launching', async () => {
|
||||
for (const domain of ['https://example.test', 'example.test/path', '*', 'example.test\n--flag']) {
|
||||
await expect(importNativeCookies({ ...options, domains: [domain] })).rejects.toMatchObject({ code: 'invalid_domain' });
|
||||
}
|
||||
});
|
||||
|
||||
test('valid Chromium hostname forms reach runtime admission', async () => {
|
||||
for (const domain of ['service_name.example.test', '-service.example.test', '[::1]']) {
|
||||
await expect(importNativeCookies({ ...options, domains: [domain] })).rejects.toMatchObject({ code: 'not_supported' });
|
||||
}
|
||||
});
|
||||
|
||||
test('unqualified Windows extraction is closed with a typed safe error', () => {
|
||||
expect(adapter(options)).toMatchObject({ code: 'native_unqualified', typed: true });
|
||||
});
|
||||
|
||||
test('environment flags cannot activate unqualified extraction', () => {
|
||||
expect(adapter(options, { GSTACK_COOKIE_NATIVE_QUALIFY: '1', GSTACK_NATIVE_COOKIES: '1' })).toMatchObject({ code: 'native_unqualified', typed: true });
|
||||
});
|
||||
|
||||
test('Chrome default user-data policy also blocks numbered profiles', () => {
|
||||
const result = adapter({ ...options, browserName: 'Chrome', userDataDir: 'C:\\fixture\\Local\\Google\\Chrome\\User Data', profile: 'Profile 2' });
|
||||
expect(result.code).toBe('native_profile_unsupported');
|
||||
expect(result.message).toContain('Chrome 136');
|
||||
expect(result.message).toContain('both pipe and TCP');
|
||||
expect(result.message).toContain('sign in manually');
|
||||
});
|
||||
|
||||
test('does not substitute a different browser for an unsupported identity', () => {
|
||||
expect(adapter({ ...options, browserName: 'Dia' })).toMatchObject({ code: 'not_supported', typed: true });
|
||||
});
|
||||
|
||||
test('rejects mismatched and copied profile roots', () => {
|
||||
expect(adapter({ ...options, browserName: 'Brave' })).toMatchObject({ code: 'native_profile_unsupported' });
|
||||
expect(adapter({ ...options, userDataDir: 'C:\\copied-profile' })).toMatchObject({ code: 'native_profile_unsupported' });
|
||||
});
|
||||
|
||||
test('rejects profile traversal without echoing the supplied text', () => {
|
||||
const result = adapter({ ...options, profile: '../sensitive-sentinel' });
|
||||
expect(result.code).toBe('invalid_profile');
|
||||
expect(JSON.stringify(result)).not.toContain('sensitive-sentinel');
|
||||
});
|
||||
|
||||
test('maps every supported browser to only its own executable and data root', () => {
|
||||
const expected = [
|
||||
['Chrome', 'Google\\Chrome', 'chrome.exe'],
|
||||
['Chromium', 'Chromium', 'chrome.exe'],
|
||||
['Brave', 'BraveSoftware\\Brave-Browser', 'brave.exe'],
|
||||
['Edge', 'Microsoft\\Edge', 'msedge.exe'],
|
||||
];
|
||||
for (const [name, relative, exe] of expected) {
|
||||
const mapping = nativeBrowserPaths(name, env);
|
||||
expect(mapping.userDataDir).toBe(`${env.LOCALAPPDATA}\\${relative}\\User Data`);
|
||||
expect(mapping.executables.length).toBeGreaterThan(0);
|
||||
for (const candidate of mapping.executables) expect(candidate.endsWith(`\\${relative}\\Application\\${exe}`)).toBe(true);
|
||||
}
|
||||
expect(nativeBrowserPaths('Edge', env).executables[0]).toStartWith('C:\\Apps32');
|
||||
expect(nativeBrowserPaths('Brave', env).executables.join(' ')).not.toContain('chrome.exe');
|
||||
});
|
||||
|
||||
test('browser environment excludes inherited secrets and runtime injection', () => {
|
||||
expect(nativeCookieEnvironment({ ...env, SystemRoot: 'C:\\Windows', API_KEY: 'sensitive-sentinel', NODE_OPTIONS: '--require=unsafe', DEBUG: 'pw:*', PWDEBUG: '1' })).toEqual({ ...env, SystemRoot: 'C:\\Windows' });
|
||||
});
|
||||
});
|
||||
|
||||
async function qualifiedAdapterFixture() {
|
||||
const fixture = mkdtempSync(path.join(root, 'admission-'));
|
||||
for (const file of NATIVE_CODE_INPUTS) {
|
||||
const destination = path.join(fixture, file);
|
||||
mkdirSync(path.dirname(destination), { recursive: true });
|
||||
if (file === 'browse/dist/server-node.mjs') continue;
|
||||
copyFileSync(path.resolve(import.meta.dir, '../..', file === 'browse/dist/bun-polyfill.cjs' ? 'browse/src/bun-polyfill.cjs' : file), destination);
|
||||
}
|
||||
const activation = path.join(fixture, NATIVE_QUALIFICATION_DATA);
|
||||
writeFileSync(activation, '[]\n');
|
||||
const bundle = path.join(fixture, 'browse/dist/server-node.mjs');
|
||||
const build = async () => {
|
||||
const result = await Bun.build({
|
||||
entrypoints: [path.join(fixture, 'browse/src/cookie-import-native.ts')],
|
||||
target: 'node',
|
||||
define: { 'import.meta.dir': JSON.stringify(path.join(fixture, 'browse/src')) },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
writeFileSync(bundle, await result.outputs[0].text());
|
||||
};
|
||||
await build();
|
||||
const probe = spawnSync(node!, ['-p', 'JSON.stringify({ version: process.version, architecture: process.arch })'], { env: isolatedEnv(), encoding: 'utf8', timeout: 10_000 });
|
||||
expect(probe.status).toBe(0);
|
||||
const runtime = JSON.parse(probe.stdout);
|
||||
const sourceHashes = await nativeCodeHashes(fixture, Date.now() + 25_000);
|
||||
const receipt = {
|
||||
browserName: 'Edge', architecture: runtime.architecture, windowsRelease: release(),
|
||||
executableSha256: '0'.repeat(64), nodeVersion: runtime.version, bunVersion: Bun.version,
|
||||
playwrightVersion: '1.62.1', sourceHashes,
|
||||
};
|
||||
const virtualRoot = `C:\\gstack-${path.basename(fixture)}`;
|
||||
const windowsEnv = { LOCALAPPDATA: `${virtualRoot}\\Local`, PROGRAMFILES: `${virtualRoot}\\Apps`, 'PROGRAMFILES(X86)': `${virtualRoot}\\Apps32` };
|
||||
const input = { ...options, userDataDir: nativeBrowserPaths('Edge', windowsEnv).userDataDir };
|
||||
const invoke = () => {
|
||||
const script = `
|
||||
import cp from 'node:child_process';
|
||||
import { syncBuiltinESMExports } from 'node:module';
|
||||
let spawns = 0;
|
||||
cp.spawn = () => { spawns++; throw new Error('Unexpected browser launch before source admission'); };
|
||||
syncBuiltinESMExports();
|
||||
const { importNativeCookies } = await import(${JSON.stringify(pathToFileURL(bundle).href)});
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
Object.assign(process.env, ${JSON.stringify(windowsEnv)});
|
||||
try { await importNativeCookies(${JSON.stringify(input)}); console.log(JSON.stringify({ accepted: true, spawns })); }
|
||||
catch (error) { console.log(JSON.stringify({ code: error.code, message: error.message, spawns, typed: error.name === 'CookieImportError' })); }
|
||||
`;
|
||||
const result = spawnSync(node!, ['--input-type=module', '-e', script], { cwd: fixture, env: isolatedEnv(), encoding: 'utf8', timeout: 10_000 });
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe('');
|
||||
return JSON.parse(result.stdout);
|
||||
};
|
||||
return { fixture, activation, bundle, build, receipt, invoke };
|
||||
}
|
||||
|
||||
describe('production adapter source-bound qualification', () => {
|
||||
test('matching code passes admission, while later worker/core/database/bundle edits reject the old receipt', async () => {
|
||||
const fixture = await qualifiedAdapterFixture();
|
||||
expect(fixture.invoke()).toMatchObject({ code: 'native_unqualified', spawns: 0, typed: true });
|
||||
writeFileSync(fixture.activation, JSON.stringify([fixture.receipt]));
|
||||
expect(fixture.invoke()).toMatchObject({ code: 'not_installed', spawns: 0, typed: true });
|
||||
for (const file of ['browse/src/cookie-import-native-worker.ts', 'browse/src/cookie-import-native-job.ts', 'browse/src/cookie-import-native-integrity.ts', 'browse/src/cookie-import-browser.ts', 'browse/src/cookie-database.ts', 'browse/scripts/build-node-server.sh', 'browse/dist/server-node.mjs']) {
|
||||
const target = path.join(fixture.fixture, file);
|
||||
const original = readFileSync(target);
|
||||
writeFileSync(target, Buffer.concat([original, Buffer.from('\n')]));
|
||||
expect(fixture.invoke()).toMatchObject({ code: 'native_unqualified', spawns: 0, typed: true });
|
||||
writeFileSync(target, original);
|
||||
}
|
||||
expect(fixture.invoke()).toMatchObject({ code: 'not_installed', spawns: 0, typed: true });
|
||||
});
|
||||
|
||||
test('activation-only edits preserve all bound bytes, including a rebuilt Node bundle', async () => {
|
||||
const fixture = await qualifiedAdapterFixture();
|
||||
const before = readFileSync(fixture.bundle);
|
||||
writeFileSync(fixture.activation, JSON.stringify([fixture.receipt], null, 2) + '\n');
|
||||
await fixture.build();
|
||||
expect(readFileSync(fixture.bundle)).toEqual(before);
|
||||
expect(await nativeCodeHashes(fixture.fixture, Date.now() + 25_000)).toEqual(fixture.receipt.sourceHashes);
|
||||
expect(fixture.invoke()).toMatchObject({ code: 'not_installed', spawns: 0 });
|
||||
});
|
||||
|
||||
test('partial or legacy receipts cannot activate even when their browser/runtime tuple matches', async () => {
|
||||
const fixture = await qualifiedAdapterFixture();
|
||||
const sourceHashes = { ...fixture.receipt.sourceHashes };
|
||||
delete sourceHashes['browse/src/cookie-database.ts'];
|
||||
writeFileSync(fixture.activation, JSON.stringify([{ ...fixture.receipt, sourceHashes }]));
|
||||
expect(fixture.invoke()).toMatchObject({ code: 'native_unqualified', spawns: 0 });
|
||||
writeFileSync(fixture.activation, JSON.stringify([{ ...fixture.receipt, sourceHashes: undefined }]));
|
||||
expect(fixture.invoke()).toMatchObject({ code: 'native_unqualified', spawns: 0 });
|
||||
expect(nativeCodeMatches(sourceHashes, fixture.receipt.sourceHashes)).toBe(false);
|
||||
});
|
||||
|
||||
test('qualification reads have a size cap and share the operation deadline', async () => {
|
||||
const fixture = mkdtempSync(path.join(root, 'bounds-'));
|
||||
const activation = path.join(fixture, NATIVE_QUALIFICATION_DATA);
|
||||
mkdirSync(path.dirname(activation), { recursive: true });
|
||||
writeFileSync(activation, ' '.repeat(1024 * 1024 + 1));
|
||||
await expect(readNativeQualifications(fixture, Date.now() + 25_000)).rejects.toThrow('native_unqualified');
|
||||
await expect(hashNativeFile(activation, Date.now() - 1)).rejects.toThrow('native_timeout');
|
||||
});
|
||||
|
||||
test('a stalled source read aborts and destroys its stream at the deadline', async () => {
|
||||
const fixture = mkdtempSync(path.join(root, 'stalled-read-'));
|
||||
const entry = path.join(fixture, 'integrity.mjs');
|
||||
const built = await Bun.build({ entrypoints: [path.resolve(import.meta.dir, '../src/cookie-import-native-integrity.ts')], target: 'node' });
|
||||
expect(built.success).toBe(true);
|
||||
writeFileSync(entry, await built.outputs[0].text());
|
||||
const script = `
|
||||
import fs from 'node:fs';
|
||||
import { Readable } from 'node:stream';
|
||||
import { syncBuiltinESMExports } from 'node:module';
|
||||
let aborted = false;
|
||||
let closed = false;
|
||||
fs.createReadStream = (file, options) => {
|
||||
const stream = new Readable({ read() {} });
|
||||
stream.once('close', () => { closed = true; });
|
||||
options.signal.addEventListener('abort', () => { aborted = true; stream.destroy(new Error('synthetic interruption')); }, { once: true });
|
||||
return stream;
|
||||
};
|
||||
syncBuiltinESMExports();
|
||||
const { hashNativeFile } = await import(${JSON.stringify(pathToFileURL(entry).href)});
|
||||
const started = Date.now();
|
||||
let code;
|
||||
try { await hashNativeFile('synthetic-source', started + 20); }
|
||||
catch (error) { code = error.message; }
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
console.log(JSON.stringify({ code, aborted, closed, elapsed: Date.now() - started }));
|
||||
`;
|
||||
const result = spawnSync(node!, ['--input-type=module', '-e', script], { env: isolatedEnv(), encoding: 'utf8', timeout: 10_000 });
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe('');
|
||||
const outcome = JSON.parse(result.stdout);
|
||||
expect(outcome).toMatchObject({ code: 'native_timeout', aborted: true, closed: true });
|
||||
expect(outcome.elapsed).toBeLessThan(1500);
|
||||
});
|
||||
});
|
||||
|
||||
function runNodeWorker(mode: string, cookies: object[] = [], exitCode = 21) {
|
||||
const fixture = mkdtempSync(path.join(root, 'worker-'));
|
||||
const observation = path.join(fixture, 'observed.json');
|
||||
const playwrightEntry = path.join(fixture, 'playwright.cjs');
|
||||
writeFileSync(playwrightEntry, `
|
||||
exports.chromium = {
|
||||
async launchPersistentContext(userDataDir, options) {
|
||||
require('node:fs').writeFileSync(${JSON.stringify(observation)}, JSON.stringify({ userDataDir, options, nodeVersion: process.version }));
|
||||
if (${JSON.stringify(mode)} === 'locked') throw new Error('ProcessSingleton sensitive-sentinel');
|
||||
if (${JSON.stringify(mode)} === 'policy') throw new Error('Remote debugging requires a non-default data directory sensitive-sentinel');
|
||||
if (${JSON.stringify(mode)} === 'failure') throw new Error('sensitive-sentinel');
|
||||
if (${JSON.stringify(mode)} === 'process-exit') throw new Error('<process did exit: exitCode=${exitCode}, signal=null> sensitive-sentinel');
|
||||
return { cookies: async () => ${JSON.stringify(cookies)}, close: async () => {} };
|
||||
},
|
||||
};
|
||||
`);
|
||||
const userDataDir = path.join(fixture, 'profile');
|
||||
mkdirSync(userDataDir);
|
||||
const result = spawnSync(node!, ['--input-type=commonjs', '-e', NATIVE_COOKIE_NODE_SCRIPT], {
|
||||
env: isolatedEnv(),
|
||||
input: JSON.stringify({ playwrightEntry, userDataDir, executablePath: 'C:\\Apps\\Microsoft\\Edge\\Application\\msedge.exe', profile: 'Profile 2', domains: ['EXAMPLE.TEST.'], deadline: Date.now() + 25_000 }),
|
||||
encoding: 'utf8',
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
const progress = expectNativeProgress(result.stderr);
|
||||
expect(progress).toContainEqual({ stage: 'node_input' });
|
||||
expect(progress).toContainEqual({ stage: 'node_load' });
|
||||
return { result: JSON.parse(result.stdout), observation: JSON.parse(readFileSync(observation, 'utf8')), userDataDir };
|
||||
}
|
||||
|
||||
describe('production Node Playwright worker', () => {
|
||||
test('real synthetic Playwright pipe smoke uses no TCP and leaves no browser', () => {
|
||||
const require = createRequire(import.meta.url);
|
||||
const { chromium } = require('playwright');
|
||||
const fixture = mkdtempSync(path.join(root, 'pipe-'));
|
||||
const observation = path.join(fixture, 'launch.json');
|
||||
const playwrightEntry = path.join(fixture, 'instrumented-playwright.cjs');
|
||||
writeFileSync(playwrightEntry, `
|
||||
const cp = require('node:child_process');
|
||||
const spawn = cp.spawn;
|
||||
cp.spawn = function(command, args, options) {
|
||||
const child = spawn.call(this, command, args, options);
|
||||
require('node:fs').writeFileSync(${JSON.stringify(observation)}, JSON.stringify({ args, pid: child.pid }));
|
||||
return child;
|
||||
};
|
||||
const { chromium } = require(${JSON.stringify(require.resolve('playwright'))});
|
||||
exports.chromium = { async launchPersistentContext(root, options) {
|
||||
const context = await chromium.launchPersistentContext(root, options);
|
||||
await context.addCookies([{ name: 'synthetic', value: 'synthetic', domain: 'example.test', path: '/' }]);
|
||||
return context;
|
||||
} };
|
||||
`);
|
||||
const result = spawnSync(node!, ['--input-type=commonjs', '-e', NATIVE_COOKIE_NODE_SCRIPT], {
|
||||
env: isolatedEnv(),
|
||||
input: JSON.stringify({ playwrightEntry, userDataDir: path.join(fixture, 'User Data'), executablePath: chromium.executablePath(), profile: 'Default', domains: ['example.test'], deadline: Date.now() + 25_000 }),
|
||||
encoding: 'utf8',
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
const progress = expectNativeProgress(result.stderr);
|
||||
expect(progress).toContainEqual({ stage: 'browser_launch' });
|
||||
expect(progress).toContainEqual({ stage: 'cookie_read' });
|
||||
expect(JSON.parse(result.stdout).cookies).toHaveLength(1);
|
||||
const launched = JSON.parse(readFileSync(observation, 'utf8'));
|
||||
expect(launched.args).toContain('--remote-debugging-pipe');
|
||||
expect(launched.args.some((arg: string) => arg.startsWith('--remote-debugging-port'))).toBe(false);
|
||||
expect(launched.args.some((arg: string) => /^--(?:no-sandbox|disable-setuid-sandbox)(?:=|$)/.test(arg))).toBe(false);
|
||||
expect(launched.args).toContain(`--user-data-dir=${path.join(fixture, 'User Data')}`);
|
||||
expect(() => process.kill(launched.pid, 0)).toThrow();
|
||||
}, 35_000);
|
||||
|
||||
test('launches the requested profile once through Playwright with no TCP or bypass arguments', () => {
|
||||
const { result, observation, userDataDir } = runNodeWorker('success');
|
||||
expect(result).toEqual({ cookies: [] });
|
||||
expect(observation.userDataDir).toBe(userDataDir);
|
||||
expect(observation.options).toMatchObject({
|
||||
executablePath: 'C:\\Apps\\Microsoft\\Edge\\Application\\msedge.exe',
|
||||
args: ['--profile-directory=Profile 2'],
|
||||
handleSIGINT: false, handleSIGTERM: false, handleSIGHUP: false,
|
||||
headless: true,
|
||||
chromiumSandbox: true,
|
||||
});
|
||||
expect(observation.options.timeout).toBeGreaterThan(0);
|
||||
expect(observation.options.timeout).toBeLessThanOrEqual(25_000);
|
||||
expect(observation.nodeVersion).toStartWith('v');
|
||||
expect(Object.keys(observation.options.env)).not.toContain('NODE_OPTIONS');
|
||||
});
|
||||
|
||||
test('bare/dotted matching preserves scope and cookie attributes', () => {
|
||||
const cookie = { name: 'synthetic', value: 'synthetic', domain: 'example.test', path: '/', httpOnly: true, secure: true, sameSite: 'Lax', expires: -1 };
|
||||
const selected = [cookie, { ...cookie, domain: '.example.test' }];
|
||||
const { result } = runNodeWorker('success', [...selected, { ...cookie, domain: 'other.example.test' }, { ...cookie, domain: 'badexample.test' }]);
|
||||
expect(result.cookies).toEqual(selected);
|
||||
});
|
||||
|
||||
test.each([['locked', 'browser_running'], ['policy', 'native_profile_unsupported'], ['failure', 'native_failed']])('classifies %s without leaking browser stderr or retrying', (mode, error) => {
|
||||
const { result } = runNodeWorker(mode);
|
||||
expect(result).toEqual({ error, diagnostic: { stage: 'browser_launch' } });
|
||||
expect(JSON.stringify(result)).not.toContain('sensitive-sentinel');
|
||||
});
|
||||
|
||||
test('reports only the managed browser exit code, not raw launch errors', () => {
|
||||
const { result } = runNodeWorker('process-exit');
|
||||
expect(result).toEqual({ error: 'browser_running', diagnostic: { stage: 'browser_launch', exitCode: 21 } });
|
||||
expect(JSON.stringify(result)).not.toContain('sensitive-sentinel');
|
||||
});
|
||||
|
||||
test.each([0, 1, 20, 22, 24, -1, -1073741819])('does not classify unrelated browser exit %d as a profile lock', exitCode => {
|
||||
const { result } = runNodeWorker('process-exit', [], exitCode);
|
||||
expect(result).toEqual({ error: 'native_failed', diagnostic: { stage: 'browser_launch', exitCode } });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user