Files
gstack/browse/test/fixtures/native-cookie-process-observer.ts
T
Garry Tan a84b0b5b6d 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
2026-09-25 12:06:45 -04:00

151 lines
8.6 KiB
TypeScript

import { createHash } from 'node:crypto';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { dlopen, FFIType, ptr, toArrayBuffer } from 'bun:ffi';
const hash = (text: string) => createHash('sha256').update(text).digest('hex');
export function decodeNativeCommandLine(buffer: Buffer, address: number | bigint): string | null {
if (buffer.length < 16) return null;
const length = buffer.readUInt16LE(0);
const offset = Number(buffer.readBigUInt64LE(8) - BigInt(address));
if (!Number.isSafeInteger(offset) || offset < 16 || offset + length > buffer.length || length % 2 !== 0) return null;
return buffer.subarray(offset, offset + length).toString('utf16le');
}
function knownFolders() {
const shell = dlopen('shell32.dll', {
SHGetFolderPathW: { args: [FFIType.u64, FFIType.i32, FFIType.u64, FFIType.u32, FFIType.ptr], returns: FFIType.i32 },
});
const normalized = (value: string) => path.win32.normalize(value).toLowerCase();
try {
return Object.fromEntries([['local', 0x1c, 'LOCALAPPDATA'], ['roaming', 0x1a, 'APPDATA']].map(([name, id, env]) => {
const calls = Object.fromEntries([['verified', 0], ['dontVerify', 0x4000]].map(([kind, flag]) => {
const output = Buffer.alloc(520);
const status = shell.symbols.SHGetFolderPathW(0, Number(id) | Number(flag), 0, 0, ptr(output));
let end = 0;
while (end + 2 <= output.length && output.readUInt16LE(end) !== 0) end += 2;
const folder = status >= 0 && end > 0 && end + 2 <= output.length ? output.subarray(0, end).toString('utf16le') : null;
return [kind, {
hresult: status,
pathHash: folder ? hash(normalized(folder)) : null,
exists: folder ? existsSync(folder) : null,
matchesEnvironment: folder ? normalized(folder) === normalized(process.env[String(env)] || '') : null,
underUserProfile: folder ? normalized(folder).startsWith(normalized(process.env.USERPROFILE || '') + '\\') : null,
}];
}));
return [name, calls];
}));
} finally {
shell.close();
}
}
function observe() {
if (process.platform !== 'win32' || !['x64', 'arm64'].includes(process.arch)) return { available: false, reason: 'not_windows' };
const input = JSON.parse(Buffer.from(process.argv[2], 'base64').toString('utf8'));
if (input.mode === 'known-folders') return { available: true, knownFolders: knownFolders() };
if (!Number.isSafeInteger(input.pid) || input.pid <= 0 || input.pid > 0xffffffff || !Number.isSafeInteger(input.owner) || input.owner <= 0 || input.owner > 0xffffffff || typeof input.image !== 'string' || input.image.length > 32768) {
return { available: false, reason: 'invalid_input' };
}
const kernel = dlopen('kernel32.dll', {
OpenProcess: { args: [FFIType.u32, FFIType.i32, FFIType.u32], returns: FFIType.u64 },
CloseHandle: { args: [FFIType.u64], returns: FFIType.i32 },
QueryFullProcessImageNameW: { args: [FFIType.u64, FFIType.u32, FFIType.ptr, FFIType.ptr], returns: FFIType.i32 },
QueryInformationJobObject: { args: [FFIType.u64, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.ptr], returns: FFIType.i32 },
IsProcessInJob: { args: [FFIType.u64, FFIType.u64, FFIType.ptr], returns: FFIType.i32 },
LocalFree: { args: [FFIType.ptr], returns: FFIType.ptr },
LocalSize: { args: [FFIType.ptr], returns: FFIType.u64 },
GetLastError: { args: [], returns: FFIType.u32 },
});
const nt = dlopen('ntdll.dll', {
NtQueryInformationProcess: { args: [FFIType.u64, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.ptr], returns: FFIType.i32 },
});
const shell = dlopen('shell32.dll', {
CommandLineToArgvW: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.ptr },
});
let processHandle: number | bigint = 0;
let argumentMemory: ReturnType<typeof shell.symbols.CommandLineToArgvW> = null;
let stage = 'process_open';
try {
processHandle = kernel.symbols.OpenProcess(0x1000, 0, input.pid);
if (!processHandle) return { available: false, reason: stage, win32Error: kernel.symbols.GetLastError() };
stage = 'process_identity';
const basic = Buffer.alloc(48);
const returned = Buffer.alloc(4);
let status = nt.symbols.NtQueryInformationProcess(processHandle, 0, ptr(basic), basic.length, ptr(returned));
if (status !== 0) return { available: false, reason: stage, ntStatus: status };
const parentMatched = basic.readBigUInt64LE(40) === BigInt(input.owner);
const pidMatched = basic.readBigUInt64LE(32) === BigInt(input.pid);
const imageBuffer = Buffer.alloc(65536);
const imageLength = Buffer.alloc(4);
imageLength.writeUInt32LE(32768);
if (!kernel.symbols.QueryFullProcessImageNameW(processHandle, 0, ptr(imageBuffer), ptr(imageLength))) {
return { available: false, reason: stage, win32Error: kernel.symbols.GetLastError() };
}
const imageChars = imageLength.readUInt32LE(0);
const imageMatched = imageChars <= 32768 && imageBuffer.subarray(0, imageChars * 2).toString('utf16le').toLowerCase() === input.image.toLowerCase();
if (!parentMatched || !pidMatched || !imageMatched) return { available: false, reason: 'owned_process_unavailable', parentMatched, imageMatched };
stage = 'command_line';
status = nt.symbols.NtQueryInformationProcess(processHandle, 60, null, 0, ptr(returned));
const length = returned.readUInt32LE(0);
if (length < 16 || length > 131072) return { available: false, reason: 'command_line_length', ntStatus: status };
const commandBuffer = Buffer.alloc(length);
const commandAddress = ptr(commandBuffer);
status = nt.symbols.NtQueryInformationProcess(processHandle, 60, commandAddress, length, ptr(returned));
if (status !== 0) return { available: false, reason: stage, ntStatus: status };
const commandLine = decodeNativeCommandLine(commandBuffer, commandAddress);
if (commandLine === null) return { available: false, reason: 'command_line_bounds' };
stage = 'argument_parse';
const wideCommand = Buffer.from(commandLine + '\0', 'utf16le');
const count = Buffer.alloc(4);
argumentMemory = shell.symbols.CommandLineToArgvW(ptr(wideCommand), ptr(count));
const argumentCount = count.readInt32LE(0);
if (!argumentMemory || argumentCount < 1 || argumentCount > 4096) return { available: false, reason: stage };
const size = Number(kernel.symbols.LocalSize(argumentMemory));
if (!Number.isSafeInteger(size) || size < argumentCount * 8 || size > 1048576) return { available: false, reason: 'argument_bounds' };
const argumentsBuffer = Buffer.from(toArrayBuffer(argumentMemory, 0, size));
const args: string[] = [];
for (let index = 1; index < argumentCount; index++) {
const start = Number(argumentsBuffer.readBigUInt64LE(index * 8) - BigInt(argumentMemory));
if (!Number.isSafeInteger(start) || start < argumentCount * 8 || start % 2 !== 0 || start >= size) return { available: false, reason: 'argument_bounds' };
let end = start;
while (end + 2 <= size && argumentsBuffer.readUInt16LE(end) !== 0) end += 2;
if (end + 2 > size) return { available: false, reason: 'argument_bounds' };
args.push(argumentsBuffer.subarray(start, end).toString('utf16le'));
}
stage = 'job_query';
const limits = Buffer.alloc(144);
const jobKnown = kernel.symbols.QueryInformationJobObject(0, 9, ptr(limits), limits.length, ptr(returned));
const jobError = jobKnown ? undefined : kernel.symbols.GetLastError();
const inJob = Buffer.alloc(4);
const membershipKnown = kernel.symbols.IsProcessInJob(processHandle, 0, ptr(inJob));
const dataArgs = args.filter(arg => arg.startsWith('--user-data-dir='));
return {
available: true, parentMatched, imageMatched,
commandLineHash: hash(commandLine), argumentHashes: args.map(hash),
userDataDirCount: dataArgs.length,
userDataDirHash: dataArgs.length === 1 ? hash(dataArgs[0].slice(16)) : null,
pipePresent: args.includes('--remote-debugging-pipe'),
browserInJob: membershipKnown ? inJob.readInt32LE(0) !== 0 : null,
observerJobLimitFlags: jobKnown ? limits.readUInt32LE(16) : null,
observerJobQueryError: jobError,
knownFolders: knownFolders(),
};
} catch {
return { available: false, reason: stage };
} finally {
if (argumentMemory) kernel.symbols.LocalFree(argumentMemory);
if (processHandle) kernel.symbols.CloseHandle(processHandle);
shell.close(); nt.close(); kernel.close();
}
}
if (import.meta.main) {
let result: object;
let exitCode = 0;
try { result = observe(); }
catch { result = { available: false, reason: 'observer_initialize' }; exitCode = 1; }
process.stdout.write(JSON.stringify(result) + '\n', () => process.exit(exitCode));
}