diff --git a/.github/cookie-workflow-manual-review.json b/.github/cookie-workflow-manual-review.json new file mode 100644 index 000000000..61d80dbde --- /dev/null +++ b/.github/cookie-workflow-manual-review.json @@ -0,0 +1,13 @@ +{ + "schema_version": 1, + "test_name": "setup-browser-cookies/SKILL.md workflow", + "prompt_sha256": "7f7f76f49912818d51f25c86d686aab6ad2c95ccdea60ac912c1e4c8b6936e5f", + "prompt_bytes": 10159, + "model": "claude-fable-5-1", + "max_tokens": 8192, + "thresholds": { "clarity": 4, "completeness": 3, "actionability": 4 }, + "approved_by": "garrytan", + "approved_at": "2026-09-24", + "approval_url": "https://github.com/garrytan/gstack/pull/2964#issuecomment-5822514476", + "reason": "Maintainer-approved manual review of this exact cookie workflow after empty provider refusals. No automated quality score or pass credit; other errors and changed inputs remain blocking." +} diff --git a/.github/cookie-workflow-manual-review.md b/.github/cookie-workflow-manual-review.md new file mode 100644 index 000000000..96f8611d6 --- /dev/null +++ b/.github/cookie-workflow-manual-review.md @@ -0,0 +1,22 @@ +# Cookie workflow manual-review exception + +`cookie-workflow-manual-review.json` records the maintainer's approval for one +exact cookie-workflow judge request. It is not generated content. Do not update +its hash, model, budget, thresholds, or provenance just to make a changed test +pass; a changed request needs a new explicit review and approval. + +The ordinary judge request still runs. Only an explicit provider refusal with +complete request/response identifiers, zero output tokens, and no text blocks +can use this approval. Low scores, malformed output or evidence, other errors, +timeouts, and late or superseded attempts remain failures. Every other case +remains subject to its existing gate. + +Manual acceptance is first-attempt-only: a retry refusal cannot erase an earlier +scored failure, timeout, or other error. Normal configured retries are unchanged. + +The collector preserves `passed: false`, `execution: executed`, the refusal, +and the manual approval, without a score or score-cache receipt. Reports count +manual acceptance separately from automated passes and failures; “executed” +counts the actual provider request, not a completed scored evaluation. Historical +records retain that distinction. CI also checks manual claims against the +current source and committed approval before accepting them. diff --git a/.github/scripts/dia-gui-readiness.c b/.github/scripts/dia-gui-readiness.c new file mode 100644 index 000000000..b046dec49 --- /dev/null +++ b/.github/scripts/dia-gui-readiness.c @@ -0,0 +1,138 @@ +#define _DARWIN_C_SOURCE +#define _POSIX_C_SOURCE 200809L +#include +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef __APPLE__ +#include +#include +#endif + +struct root_fact { + const char *state; + const char *kind; + int owner_matches; + int ancestor_blocked; +}; + +static const char *kind_of(mode_t mode) { + if (S_ISDIR(mode)) return "directory"; + if (S_ISREG(mode)) return "file"; + if (S_ISLNK(mode)) return "symlink"; + return "other"; +} + +static struct root_fact inspect_root(int home, const char *relative, uid_t owner) { + struct root_fact fact = {"unavailable", NULL, -1, 0}; + char components[256]; + if (strlen(relative) >= sizeof(components)) return fact; + memcpy(components, relative, strlen(relative) + 1); + int directory = dup(home); + if (directory < 0) return fact; + char *state = NULL; + char *component = strtok_r(components, "/", &state); + while (component) { + char *next = strtok_r(NULL, "/", &state); + struct stat before; + if (fstatat(directory, component, &before, AT_SYMLINK_NOFOLLOW) != 0) { + if (errno == ENOENT) fact.state = "absent"; + break; + } + fact.kind = kind_of(before.st_mode); + fact.owner_matches = before.st_uid == owner; + if (!next) { fact.state = "present"; break; } + if (!S_ISDIR(before.st_mode) || before.st_uid != owner) { fact.ancestor_blocked = 1; break; } + int child = openat(directory, component, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_NONBLOCK); + struct stat after; + if (child < 0) { fact.ancestor_blocked = 1; break; } + if (fstat(child, &after) != 0 || after.st_dev != before.st_dev || after.st_ino != before.st_ino + || !S_ISDIR(after.st_mode) || after.st_uid != owner) { + close(child); + fact.ancestor_blocked = 1; + break; + } + close(directory); + directory = child; + fact.kind = NULL; + fact.owner_matches = -1; + component = next; + } + close(directory); + return fact; +} + +static const char *boolean_or_null(int value) { + return value < 0 ? "null" : value ? "true" : "false"; +} + +static void print_browser_roots(int home, uid_t owner) { + const char *names[] = {"chrome", "chromium", "arc", "dia", "comet", "brave", "edge", "safari", "cookies"}; + const char *paths[] = {"Library/Application Support/Google/Chrome", "Library/Application Support/Chromium", + "Library/Application Support/Arc", "Library/Application Support/Dia", "Library/Application Support/Comet", + "Library/Application Support/BraveSoftware/Brave-Browser", "Library/Application Support/Microsoft Edge", "Library/Safari", "Library/Cookies"}; + printf("{"); + for (size_t index = 0; index < sizeof(names) / sizeof(names[0]); index++) { + struct root_fact fact = inspect_root(home, paths[index], owner); + printf("%s\"%s\":{\"state\":\"%s\",\"kind\":", index ? "," : "", names[index], fact.state); + if (fact.kind) printf("\"%s\"", fact.kind); else printf("null"); + printf(",\"ownerMatches\":%s,\"ancestorBlocked\":%s}", boolean_or_null(fact.owner_matches), boolean_or_null(fact.ancestor_blocked)); + } + printf("}"); +} + +#ifdef __APPLE__ +static int dictionary_boolean(CFDictionaryRef dictionary, CFStringRef key) { + CFTypeRef value = CFDictionaryGetValue(dictionary, key); + return value && CFGetTypeID(value) == CFBooleanGetTypeID() ? CFBooleanGetValue(value) : -1; +} + +int main(int argc, char **argv) { + int browser_roots = argc == 2 && strcmp(argv[1], "--browser-roots") == 0; + if (argc != 1 && !browser_roots) return 2; + uid_t uid = getuid(); + struct passwd *account = getpwuid(uid); + char registered[PATH_MAX], environment[PATH_MAX]; + const char *home = getenv("HOME"); + int home_matches = account && home && realpath(account->pw_dir, registered) && realpath(home, environment) + && strcmp(registered, account->pw_dir) == 0 && strcmp(registered, environment) == 0; + int home_fd = home_matches ? open(registered, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_NONBLOCK) : -1; + struct stat home_info; + home_matches = home_fd >= 0 && fstat(home_fd, &home_info) == 0 && S_ISDIR(home_info.st_mode) && home_info.st_uid == uid; + SessionAttributeBits attributes = 0; + OSStatus status = SessionGetInfo(callerSecuritySession, NULL, &attributes); + CFDictionaryRef quartz = CGSessionCopyCurrentDictionary(); + int same_uid = -1, login_done = -1, on_console = -1; + if (quartz) { + CFTypeRef value = CFDictionaryGetValue(quartz, kCGSessionUserIDKey); + long long session_uid = -1; + if (value && CFGetTypeID(value) == CFNumberGetTypeID() && CFNumberGetValue(value, kCFNumberLongLongType, &session_uid)) same_uid = session_uid == uid; + if (same_uid == 1) { + login_done = dictionary_boolean(quartz, kCGSessionLoginDoneKey); + on_console = dictionary_boolean(quartz, kCGSessionOnConsoleKey); + } + } + printf("{\"protocol\":1,\"supported\":true,\"identity\":{\"effectiveUidMatches\":%s,\"homeMatchesRegistered\":%s},", + boolean_or_null(geteuid() == uid), boolean_or_null(home_matches)); + printf("\"security\":{\"status\":%d,\"graphicAccess\":%s,\"rootSession\":%s,\"tty\":%s,\"remote\":%s},", + (int)status, boolean_or_null(status ? -1 : !!(attributes & sessionHasGraphicAccess)), boolean_or_null(status ? -1 : !!(attributes & sessionIsRoot)), + boolean_or_null(status ? -1 : !!(attributes & sessionHasTTY)), boolean_or_null(status ? -1 : !!(attributes & sessionIsRemote))); + printf("\"quartz\":{\"present\":%s,\"sameUid\":%s,\"loginDone\":%s,\"onConsole\":%s},\"browserRoots\":", + boolean_or_null(quartz != NULL), boolean_or_null(same_uid), boolean_or_null(login_done), boolean_or_null(on_console)); + if (browser_roots && home_matches) print_browser_roots(home_fd, uid); else printf("null"); + printf("}\n"); + if (home_fd >= 0) close(home_fd); + if (quartz) CFRelease(quartz); + return 0; +} +#else +int main(void) { + printf("{\"protocol\":1,\"supported\":false}\n"); + return 2; +} +#endif diff --git a/.github/scripts/dia-launch-driver.mjs b/.github/scripts/dia-launch-driver.mjs new file mode 100644 index 000000000..028076148 --- /dev/null +++ b/.github/scripts/dia-launch-driver.mjs @@ -0,0 +1,231 @@ +import { createHash } from 'node:crypto'; +import { closeSync, constants, createReadStream, fstatSync, lstatSync, openSync, readdirSync, readSync, realpathSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { release } from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { assertOwnedDiaProfile, browserOperationTimedOut, browserPreflightError, browserRootFacts, browserStartupFacts, browserStderrFacts, + nativeDiaLaunchOptions, observeBrowserLaunches, ownsFreshAccount, parseDirectoryRecord, + readFreshAccountConfiguration, stopOwnedBrowserGroup, validateQualificationHost } from './qualify-dia-macos.ts'; + +const require = createRequire(import.meta.url); +const sha256 = async file => { + const hash = createHash('sha256'); + for await (const chunk of createReadStream(file)) hash.update(chunk); + return hash.digest('hex'); +}; + +export function normalizedLaunchHashes(args, env, ownedPaths) { + const paths = Object.entries(ownedPaths).sort(([, left], [, right]) => right.length - left.length); + const normalize = value => { + const equals = value.startsWith('--') ? value.indexOf('=') : -1; + const prefix = equals >= 0 ? value.slice(0, equals + 1) : ''; + const candidate = equals >= 0 ? value.slice(equals + 1) : value; + for (const [role, owned] of paths) { + if (candidate === owned || candidate.startsWith(owned + path.sep)) return prefix + '<' + role + '>' + candidate.slice(owned.length); + } + return value; + }; + const hash = value => createHash('sha256').update(JSON.stringify(value)).digest('hex'); + return { argvSha256: hash(args.map(normalize)), environmentSha256: hash(Object.entries(env).sort(([a], [b]) => a.localeCompare(b)) + .map(([key, value]) => [key, normalize(value)])) }; +} + +export function compareDiaLaunchReceipts(left, right) { + const invalid = { comparable: false, qualificationCredit: false, reason: 'incomplete_or_incompatible_arms' }; + if (!left?.launcher?.accountGuid || left.launcher.accountGuid === right?.launcher?.accountGuid) return invalid; + const fingerprints = []; + for (const [receipt, runtime] of [[left, 'bun'], [right, 'node']]) { + const qualification = receipt?.qualification; + const control = receipt?.backgroundPreflight?.comparisonControl; + const source = qualification?.launchComparison?.source; + const config = receipt?.launchComparison; + if (config?.mode !== 'launch-only' || config.runtime !== runtime || config.qualificationCredit !== false + || receipt.backgroundPreflight?.status !== 'passed' || !control?.ready || qualification?.launchComparison?.qualificationCredit !== false + || qualification.keychainStage !== 'completed' || qualification.isolation?.registeredIdentity !== true + || qualification.isolation?.sharedRegisteredHome !== true || qualification.artifact?.signatureVerified !== true + || qualification.artifact?.gatekeeperNotarized !== true || qualification.artifact?.macosCompatibility?.compatible !== true + || qualification.platform?.os !== 'darwin' || qualification.platform?.architecture !== 'arm64' + || qualification.platform?.bun !== '1.4.0' || qualification.platform?.playwright !== '1.62.1' + || !/^\d+(?:\.\d+){1,2}$/.test(qualification.artifact.macosCompatibility.hostVersion) + || qualification.artifact?.architectures?.includes('arm64') !== true + || ['serviceStopped', 'userDomainStopped', 'userProcessesStopped', 'accountRemoved', 'groupRemoved', 'stagingRemoved'].some(key => receipt.launcherCleanup?.[key] !== true) + || ['ownedBrowsersStopped', 'sourceProfileRemoved', 'keychainRestored', 'mountDetached', 'fixtureRemoved'].some(key => qualification.cleanup?.[key] !== true) + || ['pass', 'fail', 'skip'].some(key => receipt.counts?.[key] !== 0 || qualification.counts?.[key] !== 0)) return invalid; + for (const [result, purpose] of [[control, 'control'], [source, 'source']]) { + const policy = result?.launchAttempts?.[0]; + if (result?.protocol !== 1 || result.purpose !== purpose || result.samplingEnabled !== false || result.rootCount !== 1 + || result.supervisor?.closed !== true || result.supervisor?.exitCode !== 0 || result.cleanup?.confirmed !== true + || result.cleanup?.childClosed !== true || result.cleanup?.groupAbsent !== true || result.cleanup?.launchSettled !== true || result.launchAttempts?.length !== 1 + || result.cleanup?.groups?.length !== 1 || result.cleanup.groups[0].absenceConfirmed !== true || result.cleanup.groups[0].childCloseObserved !== true + || typeof result.ready !== 'boolean' || typeof result.launchReturned !== 'boolean' + || policy?.sandboxRequired !== true || policy?.sandboxDisablingFlag !== false || policy?.pipeFlag !== true || policy?.tcpDebuggingFlag !== false + || policy?.mockKeychainFlag !== false || policy?.passwordStoreFlag !== false || policy?.firstRunSuppressed !== false + || policy?.headlessFlag !== true || policy?.expectedProfile !== true || policy?.detached !== true || policy?.shellDisabled !== true + || policy?.stdioCount !== 5 || policy?.extraPipeDescriptors !== true || policy?.profileArgumentCount !== 1 + || result.driver?.runtime !== runtime || result.driver?.version !== (runtime === 'bun' ? '1.4.0' : '24.18.0') + || result.driver?.os !== 'darwin' || result.driver?.architecture !== 'arm64' || result.driver?.playwright !== '1.62.1' + || result.driver?.release !== qualification.platform.release + || result.driver?.executableSha256 !== config.executableSha256 || result.driver?.driverSha256 !== config.driverSha256 + || result.driver?.helpersSha256 !== config.helpersSha256 || (result.ready && (!result.protocolResponded || !result.startupPages?.allowed || !result.postProbePages?.allowed)) + || ![result.argvSha256, result.environmentSha256, config.executableSha256, config.driverSha256, config.helpersSha256].every(value => /^[a-f0-9]{64}$/.test(value))) return invalid; + } + const hashes = [receipt.launcher?.sourceRevision, receipt.launcher?.archiveSha256, receipt.launcher?.destinationSha256, + qualification.artifact.sha256, qualification.artifact.executableSha256]; + if (!/^[a-f0-9]{40}$/.test(hashes[0]) || !hashes.slice(1).every(value => /^[a-f0-9]{64}$/.test(value))) return invalid; + fingerprints.push(JSON.stringify({ hashes, platform: qualification.platform, compatibility: qualification.artifact.macosCompatibility, + version: qualification.artifact.version, bundle: qualification.artifact.bundleId, team: qualification.artifact.team, + driver: config.driverSha256, helpers: config.helpersSha256, controlArgs: control.argvSha256, controlEnv: control.environmentSha256, + sourceArgs: source.argvSha256, sourceEnv: source.environmentSha256 })); + } + if (fingerprints[0] !== fingerprints[1]) return { ...invalid, reason: 'comparison_inputs_differ' }; + const bun = left.qualification.launchComparison.source.ready === true; + const node = right.qualification.launchComparison.source.ready === true; + return { comparable: true, qualificationCredit: false, outcome: bun ? node ? 'both_ready' : 'bun_only_ready' : node ? 'node_only_ready' : 'neither_ready' }; +} + +export async function runProtectedLaunch(executable, profile, env, purpose, ownedPaths) { + const result = { protocol: 1, purpose, stage: 'runtime_import', launchReturned: false, protocolResponded: false, ready: false, + timedOut: false, error: null, samplingEnabled: false, cleanup: { childClosed: false, groupAbsent: false, confirmed: false } }; + const { chromium } = await import('playwright'); + const cp = require('node:child_process'); + const original = cp.spawn; + cp.spawn = function(command, args, options) { + if (command === executable) Object.assign(result, normalizedLaunchHashes(args, options.env, ownedPaths)); + return original.call(this, command, args, options); + }; + const observer = observeBrowserLaunches(new Map([[executable, profile]])); + let context; + let timer; + let launchSettled = false; + try { + result.stage = 'launch'; + const launch = chromium.launchPersistentContext(profile, nativeDiaLaunchOptions(executable, env)); + void launch.then(() => { launchSettled = true; }, () => { launchSettled = true; }); + context = await Promise.race([launch, + new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('operation_timeout')), 30_000); })]); + clearTimeout(timer); + result.launchReturned = true; + result.stage = 'ownership'; + if (observer.children.length !== 1) throw new Error('source_process_ownership_unconfirmed'); + result.stage = 'startup_pages'; + const urls = context.pages().map(page => page.url()); + result.startupPages = { ...browserStartupFacts(urls, 'http://127.0.0.1:1'), allowed: urls.every(url => url === 'about:blank') }; + if (!result.startupPages.allowed) throw new Error('onboarding_or_external_page'); + result.stage = 'protocol_probe'; + if (!context.pages()[0]) throw new Error('source_protocol_page_unavailable'); + result.protocolResponded = await Promise.race([context.pages()[0].evaluate(() => 1).then(value => value === 1), + new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('operation_timeout')), 5000); })]); + clearTimeout(timer); + const after = context.pages().map(page => page.url()); + result.postProbePages = { ...browserStartupFacts(after, 'http://127.0.0.1:1'), allowed: after.every(url => url === 'about:blank') }; + if (!result.postProbePages.allowed) throw new Error('onboarding_or_external_page'); + result.ready = result.protocolResponded; + result.stage = 'ready'; + } catch (error) { + const reasons = browserStderrFacts(observer.children).flatMap(facts => Object.entries(facts.reasonCounts ?? {}) + .filter(([, count]) => count > 0).map(([reason]) => reason)); + result.error = browserPreflightError(error, reasons); + result.timedOut = browserOperationTimedOut(error); + } finally { + clearTimeout(timer); + const deadline = performance.now() + 5_000; + observer.stop(); + result.rootCount = observer.children.length; + result.launchAttempts = observer.attempts; + result.rootsBeforeCleanup = browserRootFacts(observer.children); + result.stderrBeforeCleanup = browserStderrFacts(observer.children); + if (context) void context.close().catch(() => {}); + const groups = []; + for (const child of observer.children) { + const facts = { pid: child.pid, signalSent: false, absenceConfirmed: false, childCloseObserved: false }; + try { await stopOwnedBrowserGroup(child, deadline, facts); } + catch { facts.failed = true; } + groups.push(facts); + } + result.cleanup.groups = groups; + result.cleanup.childClosed = observer.children.length === 1 && observer.children.every(child => child.closeObserved); + result.cleanup.groupAbsent = groups.length === 1 && groups.every(group => group.absenceConfirmed); + result.cleanup.launchSettled = launchSettled; + result.cleanup.confirmed = result.cleanup.childClosed && result.cleanup.groupAbsent && launchSettled; + result.rootsAfterCleanup = browserRootFacts(observer.children); + result.stderrAfterCleanup = browserStderrFacts(observer.children); + if (launchSettled) { observer.restore(); cp.spawn = original; } + } + return result; +} + +export function readComparisonRequest() { + const buffer = Buffer.alloc(16 * 1024 + 1); + let length = 0; + while (length < buffer.length) { + const read = readSync(0, buffer, length, buffer.length - length, null); + if (!read) break; + length += read; + } + if (length > 16 * 1024) throw new Error('invalid_driver_request'); + const request = JSON.parse(buffer.subarray(0, length).toString()); + if (!request || !['control', 'source'].includes(request.purpose) + || Object.keys(request).some(key => !(request.purpose === 'control' ? ['purpose'] : ['purpose', 'assetRoot', 'executableName', 'executableSha256']).includes(key))) throw new Error('invalid_driver_request'); + return request; +} + +async function main() { + validateQualificationHost(process.env); + const request = readComparisonRequest(); + const account = readFreshAccountConfiguration(process.argv[2], 'comparison-driver'); + const config = account.launchComparison; + const runtimeVersion = process.versions.bun ?? process.versions.node; + if ((config.runtime === 'bun' ? process.versions.bun !== '1.4.0' : Boolean(process.versions.bun) || runtimeVersion !== '24.18.0') + || process.arch !== 'arm64' || require('playwright/package.json').version !== '1.62.1') throw new Error('invalid_driver_runtime'); + if (await sha256(process.execPath) !== config.executableSha256 || await sha256(import.meta.filename) !== config.driverSha256 + || await sha256(path.join(import.meta.dirname, 'qualify-dia-macos.ts')) !== config.helpersSha256) throw new Error('driver_inputs_changed'); + const identity = spawnSync('/usr/bin/dscl', ['.', '-read', '/Users/' + account.account, 'UniqueID', 'PrimaryGroupID', 'NFSHomeDirectory', 'GeneratedUID'], + { env: account.environment, encoding: 'utf8', timeout: 5000, maxBuffer: 64 * 1024 }); + if (identity.error || identity.status !== 0 || !ownsFreshAccount(parseDirectoryRecord(identity.stdout), account)) throw new Error('driver_identity_unconfirmed'); + let executable = account.destinationExecutable; + let profile = path.join(account.temporary, 'probe/chromium'); + let temporary = account.temporary; + const ownedPaths = { home: account.home, snapshot: account.snapshot, temporary: account.temporary, work: account.work }; + if (request.purpose === 'source') { + const root = request.assetRoot; + if (typeof root !== 'string' || realpathSync(root) !== root || path.dirname(root) !== account.temporary || !path.basename(root).startsWith('dia-') + || lstatSync(root).uid !== account.uid || typeof request.executableName !== 'string' || !request.executableName + || path.basename(request.executableName) !== request.executableName || ['.', '..'].includes(request.executableName)) throw new Error('invalid_source_request'); + executable = realpathSync(path.join(root, 'Dia.app/Contents/MacOS', request.executableName)); + if (!executable.startsWith(path.join(root, 'Dia.app/Contents/MacOS') + path.sep) + || !/^[a-f0-9]{64}$/.test(request.executableSha256) || await sha256(executable) !== request.executableSha256) throw new Error('source_identity_unconfirmed'); + profile = path.join(account.home, 'Library/Application Support/Dia/User Data'); + if (realpathSync(profile) !== profile || lstatSync(profile).uid !== account.uid) throw new Error('source_profile_unowned'); + if (JSON.stringify(readdirSync(profile)) !== JSON.stringify(['.gstack-dia-owner'])) throw new Error('source_profile_not_fresh'); + const descriptor = openSync(path.join(profile, '.gstack-dia-owner'), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + const marker = Buffer.alloc(65); + let bytes; + try { + const entry = fstatSync(descriptor); + if (!entry.isFile() || entry.uid !== account.uid || entry.nlink !== 1 || entry.size !== 64 || (entry.mode & 0o077) !== 0) throw new Error('source_profile_unowned'); + bytes = readSync(descriptor, marker, 0, marker.length, 0); + } finally { closeSync(descriptor); } + if (bytes !== 64) throw new Error('source_profile_unowned'); + const info = lstatSync(profile, { bigint: true }); + assertOwnedDiaProfile({ home: account.home, profile, uid: account.uid, dev: info.dev, ino: info.ino, nonce: marker.subarray(0, bytes).toString() }); + temporary = path.join(root, 't'); + ownedPaths.assets = root; + } else { + if (await sha256(executable) !== account.destinationSha256) throw new Error('control_identity_unconfirmed'); + try { lstatSync(profile); throw new Error('control_profile_not_fresh'); } + catch (error) { if (error.code !== 'ENOENT') throw error; } + } + const env = { HOME: account.home, TMPDIR: temporary, PATH: '/usr/bin:/bin:/usr/sbin:/sbin', LANG: 'en_US.UTF-8' }; + const result = await runProtectedLaunch(executable, profile, env, request.purpose, ownedPaths); + result.driver = { runtime: config.runtime, version: runtimeVersion, architecture: process.arch, os: process.platform, release: release(), + executableSha256: config.executableSha256, driverSha256: config.driverSha256, helpersSha256: config.helpersSha256, playwright: '1.62.1' }; + return result; +} + +if (import.meta.main) { + main().then(result => { process.stdout.write(JSON.stringify(result) + '\n'); }, () => { + process.stdout.write(JSON.stringify({ protocol: 1, ready: false, error: 'driver_admission_failed', cleanup: { confirmed: false } }) + '\n'); + process.exitCode = 2; + }); +} diff --git a/.github/scripts/qualify-dia-macos.ts b/.github/scripts/qualify-dia-macos.ts new file mode 100644 index 000000000..f42470429 --- /dev/null +++ b/.github/scripts/qualify-dia-macos.ts @@ -0,0 +1,1429 @@ +import { createHash, randomBytes } from 'node:crypto'; +import { spawnSync, type ChildProcess } from 'node:child_process'; +import { accessSync, chmodSync, closeSync, constants, createReadStream, existsSync, fstatSync, linkSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, realpathSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { homedir, release } from 'node:os'; +import path from 'node:path'; +import type { BrowserContext } from 'playwright'; + +const require = createRequire(import.meta.url); +const repository = path.resolve(import.meta.dirname, '../..'); +export const DIA_DOWNLOAD = 'https://releases.diabrowser.com/release/Dia-latest.dmg'; +export const FRESH_WORK_PREFIX = '/private/tmp/dn-'; + +export interface FreshAccount { + work: string; home: string; temporary: string; snapshot: string; bun: string; destinationExecutable?: string; + uid: number; gid: number; account: string; guid: string; groupGuid: string; label: string; + sourceRevision: string; archiveSha256: string; bunSha256: string; destinationSha256?: string; + configFile: string; environment: Record; + launchComparison?: { mode: 'launch-only'; runtime: 'bun' | 'node'; executable: string; executableSha256: string; + driverSha256: string; helpersSha256: string }; + guiReadiness?: { mode: 'gui-readiness-only'; executable: string; executableSha256: string; sourceSha256: string }; +} + +export function parseDirectoryRecord(output: string): Record { + const result: Record = {}; + for (const line of output.trim().split('\n')) { + const match = line.match(/^([A-Za-z]+):\s+(.+)$/); + if (!match || Object.hasOwn(result, match[1])) throw new Error('invalid_directory_record'); + result[match[1]] = match[2].trim(); + } + return result; +} + +export function ownsFreshAccount(record: Record, account: Pick): boolean { + return record.GeneratedUID?.toUpperCase() === account.guid.toUpperCase() && record.UniqueID === String(account.uid) + && record.PrimaryGroupID === String(account.gid) && record.NFSHomeDirectory === account.home; +} + +export function validateGuiReadinessAuthority(account: FreshAccount, role: 'coordinator' | 'comparison-driver') { + if (account.guiReadiness === undefined) return; + if (!account.guiReadiness || account.guiReadiness.mode !== 'gui-readiness-only' || account.launchComparison !== undefined + || account.destinationExecutable !== undefined || account.destinationSha256 !== undefined || role !== 'coordinator' + || account.guiReadiness.executable !== path.join(account.work, 'bin/gui-readiness') + || ![account.guiReadiness.executableSha256, account.guiReadiness.sourceSha256].every(value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value))) { + throw new Error('gui_readiness_authority_invalid'); + } +} + +export function readFreshAccountConfiguration(configFile: string, role: 'coordinator' | 'comparison-driver' = 'coordinator'): FreshAccount { + const work = path.dirname(configFile); + if (!path.isAbsolute(configFile) || path.basename(configFile) !== 'account.json' || path.dirname(work) !== '/private/tmp' + || !path.basename(work).startsWith(path.basename(FRESH_WORK_PREFIX)) || realpathSync(work) !== work) throw new Error('unsafe_fresh_account_configuration'); + const parent = lstatSync(work); + const info = lstatSync(configFile); + if (!parent.isDirectory() || parent.uid !== 0 || (parent.mode & 0o022) !== 0 || !info.isFile() || info.uid !== 0 + || info.nlink !== 1 || (info.mode & 0o022) !== 0 || info.size > 64 * 1024 || realpathSync(configFile) !== configFile) throw new Error('unsafe_fresh_account_configuration'); + const account: FreshAccount = JSON.parse(readFileSync(configFile, 'utf8')); + validateGuiReadinessAuthority(account, role); + if (role === 'comparison-driver' && (!account.launchComparison || account.launchComparison.mode !== 'launch-only' + || !['bun', 'node'].includes(account.launchComparison.runtime) + || account.launchComparison.executable !== path.join(work, 'bin', account.launchComparison.runtime) + || ![account.launchComparison.executableSha256, account.launchComparison.driverSha256, account.launchComparison.helpersSha256] + .every(value => /^[a-f0-9]{64}$/.test(value)))) throw new Error('comparison_driver_authority_missing'); + const expectedRuntime = role === 'comparison-driver' ? account.launchComparison!.executable : account.bun; + if (account.work !== work || account.configFile !== configFile || account.home !== path.join(work, 'home') + || account.temporary !== path.join(work, 'tmp') || account.snapshot !== repository || account.snapshot !== path.join(work, 'repo') + || account.bun !== path.join(work, 'bin/bun') || realpathSync(process.execPath) !== expectedRuntime + || !Number.isSafeInteger(account.uid) || account.uid < 20_000 || account.uid >= 60_000 || account.gid !== account.uid + || process.getuid?.() !== account.uid || process.geteuid?.() !== account.uid || process.getgid?.() !== account.gid + || !/^[a-z][a-z0-9]{8,24}$/.test(account.account) || !/^[A-F0-9-]{36}$/.test(account.guid) + || process.env.GSTACK_DIA_EXPECT_UID !== String(account.uid) || process.env.GSTACK_DIA_SOURCE_REVISION !== account.sourceRevision + || realpathSync(homedir()) !== account.home || realpathSync(process.env.HOME!) !== account.home + || realpathSync(process.env.RUNNER_TEMP!) !== account.temporary) throw new Error('fresh_identity_mismatch'); + for (const directory of [account.home, account.temporary, account.snapshot]) { + const entry = lstatSync(directory); + if (!entry.isDirectory() || entry.uid !== account.uid || (entry.mode & 0o022) !== 0 || realpathSync(directory) !== directory) throw new Error('fresh_directory_ownership_mismatch'); + } + if (!account.guiReadiness && (typeof account.destinationExecutable !== 'string' || !/^[a-f0-9]{64}$/.test(account.destinationSha256 ?? '') + || !account.destinationExecutable.startsWith(path.join(work, 'browser') + path.sep) + || realpathSync(account.destinationExecutable) !== account.destinationExecutable)) throw new Error('staged_executable_escape'); + return account; +} + +export function parseGuiReadiness(value: unknown, browserRootsExpected: boolean) { + const exact = (item: any, keys: string[]) => item !== null && typeof item === 'object' && !Array.isArray(item) + && Object.keys(item).length === keys.length && keys.every(key => Object.hasOwn(item, key)); + const nullableBoolean = (item: unknown) => item === null || typeof item === 'boolean'; + const result: any = value; + if (!exact(result, ['protocol', 'supported', 'identity', 'security', 'quartz', 'browserRoots']) || result.protocol !== 1 || result.supported !== true + || !exact(result.identity, ['effectiveUidMatches', 'homeMatchesRegistered']) || Object.values(result.identity).some(item => typeof item !== 'boolean') + || !exact(result.security, ['status', 'graphicAccess', 'rootSession', 'tty', 'remote']) || !Number.isInteger(result.security.status) + || result.security.status < -2_147_483_648 || result.security.status > 2_147_483_647 + || ['graphicAccess', 'rootSession', 'tty', 'remote'].some(key => result.security.status === 0 ? typeof result.security[key] !== 'boolean' : result.security[key] !== null) + || !exact(result.quartz, ['present', 'sameUid', 'loginDone', 'onConsole']) || typeof result.quartz.present !== 'boolean' + || ['sameUid', 'loginDone', 'onConsole'].some(key => !nullableBoolean(result.quartz[key])) + || (!result.quartz.present && ['sameUid', 'loginDone', 'onConsole'].some(key => result.quartz[key] !== null)) + || (result.quartz.sameUid !== true && (result.quartz.loginDone !== null || result.quartz.onConsole !== null))) throw new Error('invalid_gui_readiness_receipt'); + if (browserRootsExpected && result.identity.homeMatchesRegistered) { + if (!exact(result.browserRoots, ['chrome', 'chromium', 'arc', 'dia', 'comet', 'brave', 'edge', 'safari', 'cookies'])) throw new Error('invalid_gui_readiness_receipt'); + for (const fact of Object.values(result.browserRoots) as any[]) { + if (!exact(fact, ['state', 'kind', 'ownerMatches', 'ancestorBlocked']) || !['present', 'absent', 'unavailable'].includes(fact.state) + || ![null, 'directory', 'file', 'symlink', 'other'].includes(fact.kind) || !nullableBoolean(fact.ownerMatches) || typeof fact.ancestorBlocked !== 'boolean' + || (fact.state === 'absent' && (fact.kind !== null || fact.ownerMatches !== null || fact.ancestorBlocked)) + || (fact.state === 'present' && (fact.kind === null || typeof fact.ownerMatches !== 'boolean' || fact.ancestorBlocked)) + || (fact.kind === null && fact.ownerMatches !== null)) throw new Error('invalid_gui_readiness_receipt'); + } + } else if (result.browserRoots !== null) throw new Error('invalid_gui_readiness_receipt'); + return { ...result, usableGui: result.identity.effectiveUidMatches && result.identity.homeMatchesRegistered + && result.security.status === 0 && result.security.graphicAccess && result.quartz.present && result.quartz.sameUid === true && result.quartz.loginDone === true }; +} + +export async function runGuiReadiness(executable: string, executableSha256: string, source: string, sourceSha256: string, + browserRoots: boolean, env: Record, milliseconds = 5000, execute: typeof spawnSync = spawnSync) { + if (!Number.isFinite(milliseconds) || milliseconds < 1) throw new Error('gui_readiness_budget_exhausted'); + const deadline = performance.now() + Math.min(5000, milliseconds); + if (await sha256(executable) !== executableSha256 || await sha256(source) !== sourceSha256) throw new Error('gui_readiness_inputs_changed'); + const timeout = Math.floor(deadline - performance.now()); + if (timeout < 1) throw new Error('gui_readiness_budget_exhausted'); + const result = execute(executable, browserRoots ? ['--browser-roots'] : [], { env, encoding: 'utf8', timeout, + killSignal: 'SIGKILL', maxBuffer: 16 * 1024 }); + const execution = { exitCode: result.status, timedOut: (result.error as NodeJS.ErrnoException | undefined)?.code === 'ETIMEDOUT', + stdoutBytes: Buffer.byteLength(result.stdout || ''), stderrBytes: Buffer.byteLength(result.stderr || '') }; + if (result.error || result.status !== 0) return { available: false, reason: 'gui_readiness_probe_failed', execution }; + try { return { available: true, observation: parseGuiReadiness(JSON.parse(result.stdout), browserRoots), execution }; } + catch { return { available: false, reason: 'gui_readiness_receipt_rejected', execution }; } +} + +export function safeDiaComparisonResponse(value: unknown): boolean { + const keys = new Set(['protocol', 'purpose', 'stage', 'launchReturned', 'protocolResponded', 'ready', 'timedOut', 'error', 'samplingEnabled', + 'cleanup', 'startupPages', 'postProbePages', 'argvSha256', 'environmentSha256', 'rootCount', 'launchAttempts', 'rootsBeforeCleanup', + 'stderrBeforeCleanup', 'stderrAfterCleanup', 'rootsAfterCleanup', 'driver', 'childClosed', 'groupAbsent', 'launchSettled', 'confirmed', 'groups', 'pid', 'signalSent', + 'absenceConfirmed', 'childCloseObserved', 'failed', 'initialSignalFailure', 'reason', 'code', 'errno', 'closeObserved', 'exitCode', 'signal', + 'count', 'categories', 'truncated', 'allowed', 'admissionOpen', 'argumentsArray', 'pipeFlag', 'profileArgumentCount', 'expectedProfile', + 'detached', 'shellDisabled', 'stdioCount', 'extraPipeDescriptors', 'headlessFlag', 'blankStartupArgument', 'tcpDebuggingFlag', 'mockKeychainFlag', + 'passwordStoreFlag', 'firstRunSuppressed', 'sandboxRequired', 'sandboxDisablingFlag', 'available', 'bytesSeen', 'bytesInspected', + 'discardedLongLines', 'ended', 'reasonCounts', ...Object.keys(BROWSER_STDERR_REASONS), 'runtime', 'version', 'architecture', 'os', 'release', + 'executableSha256', 'driverSha256', 'helpersSha256', 'playwright']); + const strings = new Set(['control', 'source', 'runtime_import', 'launch', 'ownership', 'startup_pages', 'protocol_probe', 'ready', + 'probe_before_signal', 'signal', 'join_child_close', 'probe_after_signal', 'completed', 'signal_or_probe_failed', 'cleanup_deadline', + 'child_close_timeout', 'child_close_unconfirmed', 'group_still_live', 'ESRCH', 'EPERM', 'EACCES', 'EINVAL', 'ENOSYS', 'ETIMEDOUT', + 'unclassified', 'SIGABRT', 'SIGTRAP', 'SIGSEGV', 'SIGBUS', 'SIGKILL', 'SIGTERM', 'SIGILL', 'other', 'blank', 'other_about', + 'chromium_new_tab', 'chromium_onboarding', 'chromium_internal', 'dia_internal', 'extension', 'loopback_web', 'external_web', 'file', + 'data', 'other_scheme', 'invalid', 'bun', 'node', 'arm64', 'darwin', ...Object.keys(BROWSER_STDERR_REASONS), 'module_unavailable', + 'module_export_unavailable', 'module_format_error', 'launch_policy_rejected', 'ownership_unconfirmed', 'startup_page_rejected', + 'render_mismatch', 'operation_timeout', 'executable_unavailable', 'permission_denied', 'invalid_runtime_range', 'runtime_type_error', + 'target_closed', 'protocol_error', 'unclassified_browser_error']); + const safe = (item: unknown): boolean => item === null || typeof item === 'boolean' + || (typeof item === 'number' && Number.isSafeInteger(item)) + || (typeof item === 'string' && (strings.has(item) || /^[a-f0-9]{64}$/.test(item) || /^\d{1,3}(?:\.\d{1,3}){1,2}$/.test(item))) + || (Array.isArray(item) && item.length <= 64 && item.every(safe)) + || (typeof item === 'object' && !Array.isArray(item) && item !== null && Object.entries(item).every(([key, child]) => keys.has(key) && safe(child))); + return safe(value); +} + +export async function runDiaLaunchComparison(account: FreshAccount, purpose: 'control' | 'source', source?: { + assetRoot: string; executableName: string; executableSha256: string; +}, execute: typeof spawnSync = spawnSync, milliseconds = 40_000) { + const deadline = performance.now() + Math.min(40_000, milliseconds); + if (!Number.isFinite(milliseconds) || !Number.isFinite(deadline) || milliseconds < 1) throw new Error('comparison_budget_exhausted'); + const config = account.launchComparison; + if (!config || config.mode !== 'launch-only' || !['bun', 'node'].includes(config.runtime) + || config.executable !== path.join(account.work, 'bin', config.runtime)) throw new Error('comparison_driver_authority_missing'); + if (await sha256(config.executable) !== config.executableSha256 + || await sha256(path.join(account.snapshot, '.github/scripts/dia-launch-driver.mjs')) !== config.driverSha256 + || await sha256(path.join(account.snapshot, '.github/scripts/qualify-dia-macos.ts')) !== config.helpersSha256) throw new Error('comparison_driver_inputs_changed'); + const args = [...(config.runtime === 'bun' ? ['--no-env-file', '--no-install', '--no-macros', '--config=/dev/null'] : []), + path.join(account.snapshot, '.github/scripts/dia-launch-driver.mjs'), account.configFile]; + const timeout = Math.floor(deadline - performance.now()); + if (timeout < 1) throw new Error('comparison_budget_exhausted'); + const result = execute(config.executable, args, { env: account.environment, cwd: account.snapshot, + input: JSON.stringify({ purpose, ...source }), encoding: 'utf8', timeout, killSignal: 'SIGKILL', maxBuffer: 64 * 1024 }); + const failed = { protocol: 1, purpose, ready: false, launchReturned: false, error: 'driver_exchange_failed', + cleanup: { confirmed: false }, supervisor: { closed: !result.error && result.status !== null, exitCode: result.status, + timedOut: (result.error as NodeJS.ErrnoException | undefined)?.code === 'ETIMEDOUT', stderrBytes: Buffer.byteLength(result.stderr || '') } }; + if (result.error || result.status !== 0) return failed; + try { + const response = JSON.parse(result.stdout); + const keys = ['protocol', 'purpose', 'stage', 'launchReturned', 'protocolResponded', 'ready', 'timedOut', 'error', 'samplingEnabled', 'cleanup', 'startupPages', 'postProbePages', + 'argvSha256', 'environmentSha256', 'rootCount', 'launchAttempts', 'rootsBeforeCleanup', 'stderrBeforeCleanup', 'stderrAfterCleanup', 'rootsAfterCleanup', 'driver']; + if (!response || !safeDiaComparisonResponse(response) || Object.keys(response).some(key => !keys.includes(key)) || response.protocol !== 1 || response.purpose !== purpose + || response.samplingEnabled !== false || response.driver?.runtime !== config.runtime + || response.driver?.version !== (config.runtime === 'bun' ? '1.4.0' : '24.18.0') || response.driver?.architecture !== 'arm64' + || response.driver?.os !== 'darwin' || response.driver?.playwright !== '1.62.1' + || response.driver?.executableSha256 !== config.executableSha256 || response.driver?.driverSha256 !== config.driverSha256 + || response.driver?.helpersSha256 !== config.helpersSha256 || typeof response.ready !== 'boolean' + || typeof response.launchReturned !== 'boolean' || typeof response.cleanup?.confirmed !== 'boolean') return failed; + if (response.ready && (response.launchReturned !== true || response.protocolResponded !== true + || response.startupPages?.allowed !== true || response.postProbePages?.allowed !== true)) return failed; + if (response.cleanup.confirmed && (response.cleanup.childClosed !== true || response.cleanup.groupAbsent !== true || response.cleanup.launchSettled !== true || response.rootCount !== 1 + || response.cleanup.groups?.length !== 1 || response.cleanup.groups[0].absenceConfirmed !== true || response.cleanup.groups[0].childCloseObserved !== true + || response.rootsAfterCleanup?.length !== 1 || response.rootsAfterCleanup[0].closeObserved !== true + || response.launchAttempts?.length !== 1 || response.launchAttempts[0].sandboxRequired !== true || response.launchAttempts[0].sandboxDisablingFlag !== false + || response.launchAttempts[0].pipeFlag !== true || !/^[a-f0-9]{64}$/.test(response.argvSha256) || !/^[a-f0-9]{64}$/.test(response.environmentSha256))) return failed; + return { ...response, supervisor: { closed: true, exitCode: 0, timedOut: false, stderrBytes: Buffer.byteLength(result.stderr || '') } }; + } catch { return failed; } +} + +export function createOwnedDiaProfile(home: string) { + const uid = process.getuid?.(); + for (const directory of [home, path.join(home, 'Library'), path.join(home, 'Library/Application Support')]) { + try { lstatSync(directory); } catch (error: any) { + if (error.code !== 'ENOENT' || directory === home) throw error; + mkdirSync(directory, { mode: 0o700 }); + } + const info = lstatSync(directory); + if (!info.isDirectory() || info.uid !== uid || (info.mode & 0o022) !== 0 || realpathSync(directory) !== directory) throw new Error('unsafe_profile_ancestor'); + } + const browserRoot = path.join(home, 'Library/Application Support/Dia'); + mkdirSync(browserRoot, { mode: 0o700 }); + const profile = path.join(browserRoot, 'User Data'); + mkdirSync(profile, { mode: 0o700 }); + const info = lstatSync(profile, { bigint: true }); + const nonce = randomBytes(32).toString('hex'); + writeFileSync(path.join(profile, '.gstack-dia-owner'), nonce, { mode: 0o600, flag: 'wx' }); + return { home, profile, uid, dev: info.dev, ino: info.ino, nonce }; +} + +export function assertOwnedDiaProfile(ownership: ReturnType) { + const { home, profile, uid, dev, ino, nonce } = ownership; + if (uid === undefined || uid !== process.getuid?.() || profile !== path.join(home, 'Library/Application Support/Dia/User Data') + || !/^[a-f0-9]{64}$/.test(nonce)) throw new Error('profile_ownership_unconfirmed'); + for (const directory of [home, path.join(home, 'Library'), path.join(home, 'Library/Application Support'), path.dirname(profile), profile]) { + const info = lstatSync(directory); + if (!info.isDirectory() || info.uid !== uid || (info.mode & 0o022) !== 0 || realpathSync(directory) !== directory) throw new Error('profile_ownership_unconfirmed'); + } + const info = lstatSync(profile, { bigint: true }); + if (info.dev !== dev || info.ino !== ino) throw new Error('profile_ownership_unconfirmed'); + const fd = openSync(path.join(profile, '.gstack-dia-owner'), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + try { + const marker = fstatSync(fd); + if (!marker.isFile() || marker.uid !== uid || marker.nlink !== 1 || (marker.mode & 0o077) !== 0 || marker.size !== 64) throw new Error('profile_ownership_unconfirmed'); + const buffer = Buffer.alloc(65); + if (readSync(fd, buffer, 0, 65, 0) !== 64 || buffer.subarray(0, 64).toString() !== nonce) throw new Error('profile_ownership_unconfirmed'); + const after = fstatSync(fd); + if (after.size !== marker.size || after.mtimeMs !== marker.mtimeMs || after.ctimeMs !== marker.ctimeMs) throw new Error('profile_ownership_unconfirmed'); + } finally { closeSync(fd); } +} + +export function removeOwnedDiaProfile(ownership: ReturnType, browsersStopped: boolean) { + if (!browsersStopped) throw new Error('owned_browsers_not_stopped'); + assertOwnedDiaProfile(ownership); + rmSync(ownership.profile, { recursive: true }); + try { lstatSync(ownership.profile); } catch (error: any) { if (error.code === 'ENOENT') return; throw error; } + throw new Error('owned_profile_still_present'); +} + +export function assertDiaSocketPath(sourceProfile: string): void { + if (Buffer.byteLength(path.join(sourceProfile, 'SingletonSocket')) >= 100) throw new Error('fixture_socket_path_too_long'); +} + +export function inspectMachOArchitectures(file: string, milliseconds = 10_000) { + if (!Number.isFinite(milliseconds) || milliseconds < 1) throw new Error('macho_read_budget_exhausted'); + const deadline = performance.now() + milliseconds; + if (realpathSync(file) !== file) throw new Error('unsafe_macho_file'); + const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + let bytesRead = 0; + try { + const before = fstatSync(fd, { bigint: true }); + if (!before.isFile() || before.size < 8n || before.size > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error('unsafe_macho_file'); + const size = Number(before.size); + const read = (offset: number, length: number) => { + if (performance.now() >= deadline || bytesRead + length > 4096) throw new Error('macho_read_budget_exhausted'); + if (!Number.isSafeInteger(offset) || offset < 0 || offset > size - length) throw new Error('invalid_macho_header'); + const buffer = Buffer.alloc(length); + if (readSync(fd, buffer, 0, length, offset) !== length) throw new Error('invalid_macho_header'); + bytesRead += length; + return buffer; + }; + const thin = (offset: number, sliceSize: number, expectedCpu?: number, expectedSubtype?: number) => { + const magic = read(offset, 4).readUInt32BE(0); + const little = magic === 0xcefaedfe || magic === 0xcffaedfe; + const wide = magic === 0xfeedfacf || magic === 0xcffaedfe; + if (![0xfeedface, 0xcefaedfe, 0xfeedfacf, 0xcffaedfe].includes(magic)) throw new Error('invalid_macho_header'); + const headerSize = wide ? 32 : 28; + if (sliceSize < headerSize) throw new Error('invalid_macho_header'); + const header = read(offset, headerSize); + const word = (at: number) => little ? header.readUInt32LE(at) : header.readUInt32BE(at); + const cpu = word(4); + const subtype = word(8); + if ((expectedCpu !== undefined && cpu !== expectedCpu) || (expectedSubtype !== undefined && subtype !== expectedSubtype) + || Boolean(cpu & 0x01000000) !== wide || word(12) !== 2 || word(20) > sliceSize - headerSize + || word(16) * 8 > word(20) || word(20) % (wide ? 8 : 4) !== 0) throw new Error('invalid_macho_header'); + const names: Record = { '7:3': 'i386', '16777223:3': 'x86_64', '16777223:8': 'x86_64h', + '16777228:0': 'arm64', '16777228:1': 'arm64v8', '16777228:2': 'arm64e' }; + const architecture = names[cpu + ':' + (subtype & 0x00ffffff)]; + if (!architecture) throw new Error('unsupported_macho_architecture'); + return architecture; + }; + const header = read(0, 8); + const magic = header.readUInt32BE(0); + const fat = [0xcafebabe, 0xbebafeca, 0xcafebabf, 0xbfbafeca].includes(magic); + const architectures: string[] = []; + let format = 'thin'; + if (!fat) architectures.push(thin(0, size)); + else { + const little = magic === 0xbebafeca || magic === 0xbfbafeca; + const wide = magic === 0xcafebabf || magic === 0xbfbafeca; + format = wide ? 'fat64' : 'fat32'; + const count = little ? header.readUInt32LE(4) : header.readUInt32BE(4); + if (count < 1 || count > 32) throw new Error('invalid_macho_header'); + const entrySize = wide ? 32 : 20; + const tableEnd = 8 + count * entrySize; + const table = read(8, count * entrySize); + const word = (at: number) => little ? table.readUInt32LE(at) : table.readUInt32BE(at); + const wideWord = (at: number) => little ? table.readBigUInt64LE(at) : table.readBigUInt64BE(at); + const ranges: Array<{ offset: bigint; end: bigint }> = []; + for (let index = 0; index < count; index++) { + const at = index * entrySize; + const offset = wide ? wideWord(at + 8) : BigInt(word(at + 8)); + const sliceSize = wide ? wideWord(at + 16) : BigInt(word(at + 12)); + const align = word(at + (wide ? 24 : 16)); + const end = offset + sliceSize; + if (offset < BigInt(tableEnd) || sliceSize < 28n || end > before.size || align > 63 + || offset % (1n << BigInt(align)) !== 0n || (wide && word(at + 28) !== 0) + || ranges.some(range => offset < range.end && end > range.offset)) throw new Error('invalid_macho_header'); + ranges.push({ offset, end }); + const architecture = thin(Number(offset), Number(sliceSize), word(at), word(at + 4)); + if (architectures.includes(architecture)) throw new Error('invalid_macho_header'); + architectures.push(architecture); + } + } + const after = fstatSync(fd, { bigint: true }); + const current = lstatSync(file, { bigint: true }); + if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size + || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs || current.dev !== before.dev || current.ino !== before.ino + || !current.isFile() || realpathSync(file) !== file) throw new Error('macho_changed_during_inspection'); + if (performance.now() >= deadline) throw new Error('macho_read_budget_exhausted'); + return { method: 'bounded_macho_headers', format, architectures, slices: architectures.length, bytesRead }; + } finally { closeSync(fd); } +} + +export function writePrivateReceipt(file: string, value: unknown, replace = false): void { + const directory = path.dirname(file); + const owner = process.getuid?.(); + const parent = lstatSync(directory); + if (!path.isAbsolute(file) || path.resolve(file) !== file || realpathSync(directory) !== directory + || !parent.isDirectory() || parent.uid !== owner || (parent.mode & 0o022) !== 0) throw new Error('unsafe_receipt_directory'); + if (replace && existsSync(file)) { + const previous = lstatSync(file); + if (!previous.isFile() || previous.uid !== owner || realpathSync(file) !== file || (previous.mode & 0o022) !== 0) throw new Error('unsafe_receipt_replacement'); + } + const text = JSON.stringify(value, null, 2) + '\n'; + if (Buffer.byteLength(text) > 1024 * 1024) throw new Error('receipt_too_large'); + const temporary = path.join(directory, '.dia-receipt-' + randomBytes(12).toString('hex') + '.json'); + writeFileSync(temporary, text, { mode: 0o600, flag: 'wx' }); + try { + if (replace) renameSync(temporary, file); + else linkSync(temporary, file); + } finally { + if (existsSync(temporary)) unlinkSync(temporary); + } +} + +export function validateQualificationHost(env: NodeJS.ProcessEnv, platform = process.platform, architecture = process.arch): void { + if (platform !== 'darwin' || architecture !== 'arm64' || env.CI !== 'true' || env.GITHUB_ACTIONS !== 'true' + || env.RUNNER_ENVIRONMENT !== 'github-hosted' || env.RUNNER_OS !== 'macOS' || env.RUNNER_ARCH !== 'ARM64' + || env.GSTACK_DIA_NATIVE_QUALIFY !== '1' || !env.RUNNER_TEMP || !env.GITHUB_RUN_ID || !env.GITHUB_RUN_ATTEMPT) { + throw new Error('disposable_arm64_macos_ci_required'); + } +} + +export function nativeDiaLaunchOptions(executablePath: string, env: Record) { + return { + executablePath, env, headless: true, chromiumSandbox: true, timeout: 30_000, serviceWorkers: 'block' as const, + ignoreDefaultArgs: ['--use-mock-keychain', '--password-store=basic', '--no-first-run'], + args: ['--disable-sync', '--no-default-browser-check', '--profile-directory=Default'], + handleSIGINT: false, handleSIGTERM: false, handleSIGHUP: false, + }; +} + +export function hasSandboxDisablingArgument(args: string[]): boolean { + return args.some((arg, index) => /^--(?:no-sandbox|no-zygote-sandbox|disable-(?:[a-z0-9-]+-)?sandbox|single-process|in-process-gpu)(?:=|$)/i.test(arg) + || (/^--disable-features(?:=|$)/i.test(arg) && /sandbox/i.test(arg.includes('=') ? arg.slice(arg.indexOf('=') + 1) : args[index + 1] ?? ''))); +} + +export function allowedFixturePage(url: string, origin: string): boolean { + if (url === 'about:blank') return true; + try { const page = new URL(url); return !page.username && !page.password && page.origin === origin && new URL(origin).hostname === '127.0.0.1'; } + catch { return false; } +} + +export function browserStartupCategory(value: string): string { + if (value === 'about:blank') return 'blank'; + try { + const url = new URL(value); + if (url.protocol === 'about:') return 'other_about'; + if (url.protocol === 'chrome:' || url.protocol === 'chrome-untrusted:') { + if (['newtab', 'new-tab-page'].includes(url.hostname)) return 'chromium_new_tab'; + if (['intro', 'welcome', 'first-run', 'signin', 'sync-confirmation', 'profile-picker'].includes(url.hostname)) return 'chromium_onboarding'; + return 'chromium_internal'; + } + if (url.protocol === 'dia:') return 'dia_internal'; + if (url.protocol === 'chrome-extension:') return 'extension'; + if (['http:', 'https:'].includes(url.protocol)) return ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname) ? 'loopback_web' : 'external_web'; + if (url.protocol === 'file:') return 'file'; + if (url.protocol === 'data:') return 'data'; + return 'other_scheme'; + } catch { return 'invalid'; } +} + +const BROWSER_STDERR_REASONS = { + default_profile_policy: /DevTools remote debugging requires a non-default data directory\./i, + debugging_pipe_unavailable: /Remote debugging pipe file descriptors are not open\./i, + keychain_interaction_disallowed: /\berrSecInteractionNotAllowed\b|User interaction is not allowed\./, + keychain_interaction_required: /\berrSecInteractionRequired\b/, + keychain_access_failed: /\berrSecAuthFailed\b|KeychainReauthorize failed\. Cannot retrieve item\./, + dynamic_library_error: /Library not loaded:|dyld(?:\[\d+\])?:|no suitable image found/i, + code_signing_error: /code signature (?:invalid|not valid)|mapped file has no cdhash|library load disallowed by system policy|CODESIGNING/i, + graphics_or_bootstrap_error: /(?:CGSConnection|WindowServer|bootstrap_check_in)[^\r\n]{0,160}(?:failed|failure|denied|invalid)|Failed to (?:connect to|initialize) (?:the )?WindowServer/i, + browser_profile_unavailable: /ProcessSingleton|SingletonLock|user data directory is already in use/i, +} as const; + +type BrowserStderrReason = keyof typeof BROWSER_STDERR_REASONS; + +export function browserStderrReasons(text: string): BrowserStderrReason[] { + const bounded = text.slice(0, 65_536); + return (Object.keys(BROWSER_STDERR_REASONS) as BrowserStderrReason[]).filter(reason => BROWSER_STDERR_REASONS[reason].test(bounded)); +} + +export function createBrowserStderrCapture() { + const reasonCounts = Object.fromEntries(Object.keys(BROWSER_STDERR_REASONS).map(reason => [reason, 0])) as Record; + let bytesSeen = 0; + let bytesInspected = 0; + let pending = ''; + let droppingLine = false; + let discardedLongLines = 0; + let ended = false; + const classify = (line: string) => { for (const reason of browserStderrReasons(line)) reasonCounts[reason]++; }; + return { + consume(chunk: Buffer | string) { + const size = Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(chunk); + bytesSeen = Math.min(Number.MAX_SAFE_INTEGER, bytesSeen + size); + const remaining = 65_536 - bytesInspected; + if (remaining < 1) return; + const buffer = Buffer.isBuffer(chunk) ? chunk.subarray(0, remaining) : Buffer.from(chunk.slice(0, remaining)).subarray(0, remaining); + bytesInspected += buffer.length; + for (const character of buffer.toString('utf8')) { + if (character === '\n') { + if (!droppingLine) classify(pending); + pending = ''; + droppingLine = false; + } else if (!droppingLine) { + pending += character; + if (pending.length >= 4096) { + classify(pending); + pending = ''; + droppingLine = true; + discardedLongLines++; + } + } + } + }, + end() { if (!droppingLine) classify(pending); pending = ''; ended = true; }, + clear() { pending = ''; }, + snapshot() { + const counts = { ...reasonCounts }; + if (!droppingLine) for (const reason of browserStderrReasons(pending)) counts[reason]++; + return { bytesSeen, bytesInspected, truncated: bytesSeen > bytesInspected || discardedLongLines > 0, discardedLongLines, ended, reasonCounts: counts }; + }, + }; +} + +export function browserOperationTimedOut(error: unknown): boolean { + return error instanceof Error && (error.name === 'TimeoutError' + || ['native_operation_timed_out', 'operation_timeout', 'qualification_budget_exhausted'].includes(error.message)); +} + +export function browserPreflightError(error: unknown, stderrReasons: readonly BrowserStderrReason[] = []): string { + const message = error instanceof Error ? error.message : ''; + const code = (error as { code?: string } | null)?.code; + if (['MODULE_NOT_FOUND', 'ERR_MODULE_NOT_FOUND'].includes(code ?? '')) return 'module_unavailable'; + if (code === 'ERR_PACKAGE_PATH_NOT_EXPORTED') return 'module_export_unavailable'; + if (['ERR_REQUIRE_ESM', 'ERR_UNKNOWN_FILE_EXTENSION', 'ERR_INVALID_PACKAGE_CONFIG'].includes(code ?? '')) return 'module_format_error'; + if (/\bbrowser_launch_policy_rejected\b/.test(message)) return 'launch_policy_rejected'; + if (['background_browser_ownership_failed', 'source_process_ownership_unconfirmed', 'destination_process_ownership_unconfirmed'].includes(message)) return 'ownership_unconfirmed'; + if (['background_browser_startup_page_rejected', 'onboarding_or_external_page'].includes(message)) return 'startup_page_rejected'; + if (message === 'background_browser_render_failed') return 'render_mismatch'; + const causalReasons = [...stderrReasons, ...browserStderrReasons(message)]; + const causal = (Object.keys(BROWSER_STDERR_REASONS) as BrowserStderrReason[]).find(reason => causalReasons.includes(reason)); + if (causal) return causal; + if (browserOperationTimedOut(error)) return 'operation_timeout'; + if (code === 'ENOENT' || /Executable doesn't exist|spawn .* ENOENT/.test(message)) return 'executable_unavailable'; + if (['EACCES', 'EPERM'].includes(code ?? '') || /spawn .* EACCES/.test(message)) return 'permission_denied'; + if (code === 'ERR_OUT_OF_RANGE' || (error instanceof Error && error.name === 'RangeError')) return 'invalid_runtime_range'; + if (error instanceof Error && error.name === 'TypeError') return 'runtime_type_error'; + if (/ProcessSingleton|SingletonLock|profile.*in use/i.test(message)) return 'browser_profile_unavailable'; + if (/WindowServer|CGSConnection|audit session|bootstrap_check_in/i.test(message)) return 'graphics_or_bootstrap_error'; + if (/Target page, context or browser has been closed|Browser closed|Target closed/.test(message)) return 'target_closed'; + if (/Protocol error/.test(message)) return 'protocol_error'; + return 'unclassified_browser_error'; +} + +export function macosCompatibility(plist: unknown, host: string) { + const properties = plist && typeof plist === 'object' ? plist as Record : {}; + const version = (value: unknown) => typeof value === 'string' && /^\d{1,3}(?:\.\d{1,3}){0,2}$/.test(value.trim()) ? value.trim() : null; + const hostVersion = version(host); + const minimumSystemVersion = version(properties.LSMinimumSystemVersion); + const minimumArm64Version = version(properties.LSMinimumSystemVersionByArchitecture?.arm64); + const requirements = [minimumSystemVersion, minimumArm64Version].filter((value): value is string => value !== null); + const malformed = (properties.LSMinimumSystemVersion !== undefined && minimumSystemVersion === null) + || (properties.LSMinimumSystemVersionByArchitecture !== undefined && (!properties.LSMinimumSystemVersionByArchitecture + || typeof properties.LSMinimumSystemVersionByArchitecture !== 'object' || Array.isArray(properties.LSMinimumSystemVersionByArchitecture))) + || (properties.LSMinimumSystemVersionByArchitecture?.arm64 !== undefined && minimumArm64Version === null); + const compatible = hostVersion && requirements.length && !malformed ? requirements.every(minimum => { + const actual = hostVersion.split('.').map(Number); + const required = minimum.split('.').map(Number); + for (let index = 0; index < 3; index++) { + const difference = (actual[index] ?? 0) - (required[index] ?? 0); + if (difference) return difference > 0; + } + return true; + }) : null; + return { hostVersion, minimumSystemVersion, minimumArm64Version, compatible }; +} + +export function browserStartupFacts(urls: string[], origin: string) { + return { count: urls.length, categories: urls.slice(0, 64).map(browserStartupCategory), truncated: urls.length > 64, + allowed: urls.every(url => allowedFixturePage(url, origin)) }; +} + +export function browserRootFacts(children: Array<{ pid: number; closeObserved?: boolean; process: { exitCode: number | null; signalCode: string | null } }>) { + return children.slice(0, 64).map(child => ({ pid: child.pid, exitCode: Number.isInteger(child.process.exitCode) ? child.process.exitCode : null, + ...(child.closeObserved === undefined ? {} : { closeObserved: child.closeObserved }), + signal: child.process.signalCode == null ? null + : ['SIGABRT', 'SIGTRAP', 'SIGSEGV', 'SIGBUS', 'SIGKILL', 'SIGTERM', 'SIGILL'].includes(child.process.signalCode) ? child.process.signalCode : 'other' })); +} + +export function browserCleanupError(error: unknown) { + const code = (error as { code?: unknown } | null)?.code; + const errno = (error as { errno?: unknown } | null)?.errno; + const reason = error instanceof Error && error.message === 'owned_process_group_still_live' ? 'group_still_live' + : error instanceof Error && error.message === 'cleanup_budget_exhausted' ? 'cleanup_deadline' + : error instanceof Error && error.message === 'operation_timeout' ? 'child_close_timeout' + : error instanceof Error && error.message === 'owned_child_close_unconfirmed' ? 'child_close_unconfirmed' : 'signal_or_probe_failed'; + return { reason, code: typeof code === 'string' && ['ESRCH', 'EPERM', 'EACCES', 'EINVAL', 'ENOSYS', 'ETIMEDOUT'].includes(code) ? code : 'unclassified', + errno: typeof errno === 'number' && Number.isSafeInteger(errno) ? errno : null }; +} + +export function browserGroupFacts(output: string, uid: number, pgid: number) { + if (!Number.isSafeInteger(uid) || uid < 0 || !Number.isSafeInteger(pgid) || pgid < 1) throw new Error('invalid_group_snapshot_target'); + const processes: Array<{ pid: number; ppid: number; state: string }> = []; + let foreignUidCount = 0; + for (const line of output.split('\n').filter(line => line.trim())) { + const fields = line.match(/^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s*$/); + if (!fields) throw new Error('invalid_group_snapshot'); + if (Number(fields[4]) !== pgid) continue; + if (Number(fields[1]) !== uid) { foreignUidCount++; continue; } + const pid = Number(fields[2]); + const ppid = Number(fields[3]); + if (!Number.isSafeInteger(pid) || pid < 1 || !Number.isSafeInteger(ppid)) throw new Error('invalid_group_snapshot'); + processes.push({ pid, ppid, state: ['I', 'R', 'S', 'T', 'U', 'Z', 'D', 'X'].includes(fields[5][0]) ? fields[5][0] : 'other' }); + } + return { available: true, count: processes.length, foreignUidCount, zombies: processes.filter(process => process.state === 'Z').length, + live: processes.filter(process => process.state !== 'Z').length, truncated: processes.length > 64, processes: processes.slice(0, 64) }; +} + +export function playwrightModuleLoadFacts(snapshot: string, error: unknown) { + const detail = error as { name?: unknown; code?: unknown; message?: unknown } | null; + const names = ['Error', 'ResolveMessage', 'BuildMessage', 'SyntaxError', 'TypeError', 'RangeError', 'TimeoutError']; + const codes = ['MODULE_NOT_FOUND', 'ERR_MODULE_NOT_FOUND', 'ERR_PACKAGE_PATH_NOT_EXPORTED', 'ERR_REQUIRE_ESM', 'ERR_UNKNOWN_FILE_EXTENSION', + 'ERR_INVALID_PACKAGE_CONFIG', 'ENOENT', 'EACCES', 'EPERM', 'ERR_OUT_OF_RANGE']; + const message = typeof detail?.message === 'string' ? detail.message : ''; + const module = ['playwright', 'playwright-core', './lib/bootstrap', './lib/coreBundle'] + .find(name => message.includes("'" + name + "'") || message.includes('"' + name + '"')) ?? 'unclassified'; + const files = Object.fromEntries(['playwright/package.json', 'playwright/index.mjs', 'playwright/index.js', 'playwright-core/package.json', + 'playwright-core/index.mjs', 'playwright-core/index.js', 'playwright-core/lib/bootstrap.js', 'playwright-core/lib/coreBundle.js'].map(name => { + const file = path.join(snapshot, 'node_modules', name); + const facts = { exists: false, readable: false, ownedByCurrentUid: false, insideSnapshot: false }; + try { + const info = lstatSync(file); + facts.exists = true; + facts.ownedByCurrentUid = info.uid === process.getuid?.(); + facts.insideSnapshot = realpathSync(file).startsWith(realpathSync(snapshot) + path.sep); + accessSync(file, constants.R_OK); + facts.readable = true; + } catch {} + return [name, facts]; + })); + return { errorType: typeof detail?.name === 'string' && names.includes(detail.name) ? detail.name : 'unclassified', + errorCode: typeof detail?.code === 'string' && codes.includes(detail.code) ? detail.code : 'unclassified', requestedModule: module, files }; +} + +export function parseKeychainPaths(output: string, allowEmpty = false): string[] { + const paths = output.split('\n').map(line => line.trim()).filter(Boolean).map(line => { + const value = JSON.parse(line); + if (typeof value !== 'string' || !path.isAbsolute(value)) throw new Error('invalid_keychain_snapshot'); + return value; + }); + if (!paths.length && !allowEmpty) throw new Error('empty_keychain_snapshot'); + return paths; +} + +export function parseDefaultKeychain(result: { status: number | null; stdout: string; stderr: string; error?: unknown }): string[] { + if (!result.error && result.status === 1 && !result.stdout.trim() + && /^security: SecKeychainCopy(?:DomainDefault user|Default): A default keychain could not be found\.$/.test(result.stderr.trim())) return []; + if (result.error || result.status !== 0 || (!result.stdout.trim() && result.stderr.trim())) throw new Error('user_default_keychain_unavailable'); + const paths = parseKeychainPaths(result.stdout, true); + if (paths.length > 1) throw new Error('invalid_default_keychain_snapshot'); + return paths; +} + +export function prepareKeychainHome(home: string, uid = process.getuid?.()) { + if (uid === undefined || realpathSync(home) !== home || !lstatSync(home).isDirectory() || lstatSync(home).uid !== uid) throw new Error('keychain_home_unsafe'); + const directories = ['Library', 'Library/Preferences', 'Library/Keychains']; + const before = Object.fromEntries(directories.map(name => [name, existsSync(path.join(home, name))])); + for (const name of directories) { + const directory = path.join(home, name); + if (!existsSync(directory)) mkdirSync(directory, { mode: 0o700 }); + const info = lstatSync(directory); + if (!info.isDirectory() || info.isSymbolicLink() || info.uid !== uid || (info.mode & 0o022) !== 0 || realpathSync(directory) !== directory) throw new Error('keychain_home_unsafe'); + } + return { before, directoriesReady: true }; +} + +type KeychainResult = { status: number | null; stdout: string; stderr: string; error?: unknown }; +type KeychainExecutor = (args: string[], timeout: number) => KeychainResult; + +function keychainCommand(env: Record, args: string[], deadline: number, execute?: KeychainExecutor, onDispatch?: () => void): KeychainResult { + const timeout = Math.floor(deadline - performance.now()); + if (!Number.isFinite(timeout) || timeout < 1) throw new Error('user_keychain_probe_timeout'); + onDispatch?.(); + return execute ? execute(args, timeout) : spawnSync('/usr/bin/security', args, { + env, encoding: 'utf8', timeout, maxBuffer: 1024 * 1024, + }); +} + +function requireOwnedKeychains(paths: string[], allowedRoots: string[]) { + for (const keychain of paths) { + const resolved = existsSync(keychain) ? realpathSync(keychain) : path.resolve(keychain); + if (!allowedRoots.some(root => resolved.startsWith(realpathSync(root) + path.sep))) throw new Error('keychain_outside_owned_home_refused'); + } +} + +export function captureUserKeychains(env: Record, allowedRoots: string[], milliseconds = 10_000, + execute?: KeychainExecutor) { + const deadline = performance.now() + milliseconds; + const probe = (args: string[]) => keychainCommand(env, args, deadline, execute); + const search = probe(['list-keychains', '-d', 'user']); + if (search.error || search.status !== 0 || (!search.stdout.trim() && search.stderr.trim())) throw new Error('user_keychain_search_unavailable'); + const snapshot = { search: parseKeychainPaths(search.stdout, true), default: parseDefaultKeychain(probe(['default-keychain', '-d', 'user'])) }; + requireOwnedKeychains([...snapshot.search, ...snapshot.default], allowedRoots); + return snapshot; +} + +export function observeFixtureKeychain(env: Record, allowedRoots: string[], keychain: string, expected: string, + milliseconds = 10_000, execute?: KeychainExecutor, service: 'Gstack Native Probe' | 'Dia Safe Storage' = 'Gstack Native Probe') { + requireOwnedKeychains([keychain], allowedRoots); + const info = lstatSync(keychain); + if (!info.isFile() || info.isSymbolicLink() || info.uid !== process.getuid?.()) throw new Error('fixture_keychain_not_owned'); + const expectedPath = realpathSync(keychain); + const deadline = performance.now() + milliseconds; + const result = { searchCount: null as number | null, searchPathMatches: false, defaultCount: null as number | null, + defaultPathMatches: false, explicitReadAttempted: false, explicitReadSucceeded: false, explicitReadMatches: false, + lookupReadAttempted: false, lookupReadSucceeded: false, lookupReadMatches: false, + preferencesDirectoryExists: existsSync(path.join(env.HOME, 'Library/Preferences')), + preferencesFileExists: existsSync(path.join(env.HOME, 'Library/Preferences/com.apple.security.plist')) }; + try { + const search = keychainCommand(env, ['list-keychains', '-d', 'user'], deadline, execute); + if (search.error || search.status !== 0 || (!search.stdout.trim() && search.stderr.trim())) throw new Error('search_unavailable'); + const paths = parseKeychainPaths(search.stdout, true); + result.searchCount = paths.length; + requireOwnedKeychains(paths, allowedRoots); + result.searchPathMatches = paths.length === 1 && realpathSync(paths[0]) === expectedPath; + } catch {} + try { + const paths = parseDefaultKeychain(keychainCommand(env, ['default-keychain', '-d', 'user'], deadline, execute)); + result.defaultCount = paths.length; + requireOwnedKeychains(paths, allowedRoots); + result.defaultPathMatches = paths.length === 1 && realpathSync(paths[0]) === expectedPath; + } catch {} + try { + const read = keychainCommand(env, ['find-generic-password', '-s', service, '-w', keychain], deadline, execute, + () => { result.explicitReadAttempted = true; }); + result.explicitReadSucceeded = !read.error && read.status === 0; + result.explicitReadMatches = result.explicitReadSucceeded && read.stdout.trim() === expected; + } catch {} + if (service === 'Dia Safe Storage' && result.searchPathMatches && result.defaultPathMatches && result.explicitReadMatches) { + try { + const read = keychainCommand(env, ['find-generic-password', '-s', service, '-w'], deadline, execute, + () => { result.lookupReadAttempted = true; }); + result.lookupReadSucceeded = !read.error && read.status === 0; + result.lookupReadMatches = result.lookupReadSucceeded && read.stdout.trim() === expected; + } catch {} + } + return result; +} + +export function observeDiaKeychainEnvironments(environments: { keychainHome: Record; profileHome: Record }, + allowedRoots: string[], keychain: string, expected: string, milliseconds = 20_000, + execute?: (env: Record, args: string[], timeout: number) => KeychainResult) { + const deadline = performance.now() + milliseconds; + const environmentsObserved: Record | { unavailable: true }> = {}; + for (const label of ['keychainHome', 'profileHome'] as const) { + const env = environments[label]; + try { + environmentsObserved[label] = observeFixtureKeychain(env, allowedRoots, keychain, expected, Math.min(10_000, deadline - performance.now()), + execute ? (args, timeout) => execute(env, args, timeout) : undefined, 'Dia Safe Storage'); + } catch { environmentsObserved[label] = { unavailable: true }; } + } + let firstFailure: { environment: 'keychainHome' | 'profileHome'; check: 'search_path' | 'explicit_read' } | undefined; + for (const label of ['keychainHome', 'profileHome'] as const) { + const facts = environmentsObserved[label]; + if (!('searchPathMatches' in facts) || !facts.searchPathMatches) firstFailure ??= { environment: label, check: 'search_path' }; + } + const keychainFacts = environmentsObserved.keychainHome; + if (!('explicitReadMatches' in keychainFacts) || !keychainFacts.explicitReadMatches) firstFailure ??= { environment: 'keychainHome', check: 'explicit_read' }; + return { environments: environmentsObserved, firstFailure }; +} + +export function fixtureKeychainRestoreCommands(snapshot: { search: string[]; default: string[] }, keychain: string, created: boolean): string[][] { + if (created && [...snapshot.search, ...snapshot.default].some(original => path.resolve(original) === path.resolve(keychain))) throw new Error('fixture_keychain_not_fresh'); + return [ + ...(snapshot.default.length ? [['default-keychain', '-d', 'user', '-s', ...snapshot.default]] : []), + ...(created ? [['delete-keychain', keychain]] : []), + ['list-keychains', '-d', 'user', '-s', ...snapshot.search], + ]; +} + +export function observeBrowserLaunches(expected: ReadonlyMap) { + const childProcess = require('node:child_process'); + const original = childProcess.spawn; + const children: Array<{ process: ChildProcess; executable: string; pid: number; closed: Promise; closeObserved: boolean; + stderr?: ReturnType }> = []; + const attempts: Array> = []; + const detachStderr: Array<() => void> = []; + let accepting = true; + childProcess.spawn = function(command: string, args: string[], options: any) { + if (!expected.has(command)) return original.call(this, command, args, options); + const argumentsArray = Array.isArray(args); + const sandboxRequired = process.platform === 'darwin'; + const sandboxDisablingFlag = argumentsArray && hasSandboxDisablingArgument(args); + attempts.push({ admissionOpen: accepting, argumentsArray, pipeFlag: argumentsArray && args.includes('--remote-debugging-pipe'), + profileArgumentCount: argumentsArray ? args.filter(arg => arg.startsWith('--user-data-dir')).length : null, + expectedProfile: argumentsArray && args.includes('--user-data-dir=' + expected.get(command)), + detached: options?.detached === true, shellDisabled: options?.shell === undefined || options.shell === false, + stdioCount: Array.isArray(options?.stdio) ? options.stdio.length : null, + extraPipeDescriptors: Array.isArray(options?.stdio) && options.stdio[3] === 'pipe' && options.stdio[4] === 'pipe', + headlessFlag: argumentsArray && args.some(arg => /^--headless(?:=|$)/.test(arg)), + blankStartupArgument: argumentsArray && args.includes('about:blank'), + tcpDebuggingFlag: argumentsArray && args.some(arg => /^--remote-debugging-port(?:=|$)/.test(arg)), + mockKeychainFlag: argumentsArray && args.some(arg => /^--use-mock-keychain(?:=|$)/.test(arg)), + passwordStoreFlag: argumentsArray && args.some(arg => /^--password-store(?:=|$)/.test(arg)), + firstRunSuppressed: argumentsArray && args.some(arg => /^--no-first-run(?:=|$)/.test(arg)), sandboxRequired, sandboxDisablingFlag }); + if (!accepting || !Array.isArray(args) || !args.includes('--remote-debugging-pipe') + || (sandboxRequired && sandboxDisablingFlag) + || args.filter(arg => arg.startsWith('--user-data-dir')).length !== 1 || !args.includes('--user-data-dir=' + expected.get(command)) + || options?.detached !== true || (options.shell !== undefined && options.shell !== false) || !Array.isArray(options?.stdio) + || options.stdio.length !== 5 || options.stdio[3] !== 'pipe' || options.stdio[4] !== 'pipe' + || args.some(arg => /^--(?:remote-debugging-port|use-mock-keychain|password-store|no-first-run)(?:=|$)/.test(arg))) { + throw new Error('browser_launch_policy_rejected'); + } + const child: ChildProcess = original.call(this, command, args, options); + if (child.pid) { + let resolveClose!: () => void; + const closed = new Promise(resolve => { resolveClose = resolve; }); + const observed = { process: child, executable: command, pid: child.pid, closed, closeObserved: false, + stderr: child.stderr ? createBrowserStderrCapture() : undefined }; + child.once('close', () => { observed.closeObserved = true; resolveClose(); }); + const stderr = observed.stderr; + if (stderr && child.stderr) { + const stream = child.stderr; + stream.on('data', stderr.consume); + stream.once('end', stderr.end); + detachStderr.push(() => { stream.off('data', stderr.consume); stream.off('end', stderr.end); stderr.clear(); }); + } + children.push(observed); + } + return child; + }; + return { children, attempts, stop() { accepting = false; }, restore() { childProcess.spawn = original; for (const detach of detachStderr) detach(); } }; +} + +export function browserStderrFacts(children: ReturnType['children']) { + return children.slice(0, 64).map(child => ({ pid: child.pid, available: Boolean(child.stderr), ...child.stderr?.snapshot() })); +} + +export async function joinOwnedBrowserClose(child: ReturnType['children'][number], deadline: number) { + const remaining = deadline - performance.now(); + if (!Number.isFinite(remaining) || remaining <= 0) throw new Error('cleanup_budget_exhausted'); + await bounded(child.closed, remaining); + if (!child.closeObserved) throw new Error('owned_child_close_unconfirmed'); +} + +export async function stopOwnedBrowserGroup(child: ReturnType['children'][number], deadline: number, + facts: Record, signal: (pid: number, signal: 0 | 'SIGKILL') => unknown = (pid, signal) => process.kill(pid, signal)) { + if (!Number.isSafeInteger(child.pid) || child.pid <= 1 || child.pid === process.pid) throw new Error('owned_browser_pid_unconfirmed'); + if (!Number.isFinite(deadline) || performance.now() >= deadline) throw new Error('cleanup_budget_exhausted'); + facts.stage = 'probe_before_signal'; + try { + signal(-child.pid, 0); + facts.stage = 'signal'; + signal(-child.pid, 'SIGKILL'); + facts.signalSent = true; + } catch (error: any) { + if (error.code !== 'ESRCH') facts.initialSignalFailure = { stage: facts.stage, ...browserCleanupError(error) }; + } + facts.stage = 'join_child_close'; + await joinOwnedBrowserClose(child, deadline); + facts.childCloseObserved = child.closeObserved; + facts.stage = 'probe_after_signal'; + while (true) { + try { signal(-child.pid, 0); } + catch (error: any) { + if (error.code !== 'ESRCH') throw error; + facts.absenceConfirmed = true; + facts.stage = 'completed'; + return; + } + const remaining = deadline - performance.now(); + if (remaining <= 0) throw new Error('owned_process_group_still_live'); + await new Promise(resolve => setTimeout(resolve, Math.min(50, remaining))); + } +} + +export async function observePendingBrowserLaunch(launch: () => Promise, observe: (deadline: number) => void, + deadline: number, delay = 10_000): Promise { + const sampleDeadline = Math.min(deadline, performance.now() + 25_000); + const timer = setTimeout(() => { + if (performance.now() + 1000 >= sampleDeadline) return; + observe(sampleDeadline); + }, delay); + try { return await launch(); } + finally { clearTimeout(timer); } +} + +export function classifyNativeWaitSample(output: string) { + const families = { + security_keychain: /\b(?:SecKeychain\w*|SecItem\w*|securityd|Security)\b|libsecurity_keychain/, + appkit_bootstrap: /\b(?:AppKit|NSApplication\w*|SkyLight|HIToolbox|CGSConnection\w*|bootstrap_\w+)\b/, + network: /\b(?:CFNetwork|NSURLSession\w*|nw_connection_\w*)\b|Network\.framework/, + runloop: /\b(?:CFRunLoop\w*|__CFRunLoop\w*|NSRunLoop\w*|mach_msg\w*|__psynch_cvwait|__ulock_wait|kevent\w*)\b/, + }; + const frameCounts = Object.fromEntries(Object.keys(families).map(name => [name, 0])); + const mainThreadFrameCounts = { ...frameCounts }; + let callGraphSeen = false; + let mainThreadSeen = false; + let mainThread = false; + let frames = 0; + const shape = { graphLines: 0, nonemptyGraphLines: 0, threadTokenLines: 0, numericPrefixLines: 0, imageAnnotatedLines: 0, + nonAsciiGraphLines: 0, unrecognizedGraphLines: 0, graphEnd: 'missing', binaryImagesSeen: /^Binary Images:/m.test(output) }; + if (Buffer.byteLength(output) > 1024 * 1024) return { available: false, reason: 'sample_output_oversized' }; + for (const line of output.split('\n')) { + if (/^Call graph:\s*$/.test(line)) { callGraphSeen = true; shape.graphEnd = 'eof'; continue; } + if (!callGraphSeen) continue; + if (/^(?:Total number in stack|Sort by top of stack|Binary Images:)/.test(line)) { + shape.graphEnd = line.startsWith('Binary Images:') ? 'binary_images' : line.startsWith('Total number') ? 'totals' : 'top_of_stack'; + break; + } + shape.graphLines++; + if (line.trim()) shape.nonemptyGraphLines++; + if (/\bThread[_\s]/.test(line)) shape.threadTokenLines++; + if (/^\s*\d+\s/.test(line)) shape.numericPrefixLines++; + if (/\(in [^)]+\)/.test(line)) shape.imageAnnotatedLines++; + if (/[^\x00-\x7f]/.test(line)) shape.nonAsciiGraphLines++; + if (/^\s*\d+\s+Thread_/.test(line)) { + mainThread = /\bcom\.apple\.main-thread\b/.test(line); + mainThreadSeen ||= mainThread; + continue; + } + if (!/^\s*[+|:! ]*\d+\s+\S/.test(line)) { if (line.trim()) shape.unrecognizedGraphLines++; continue; } + frames++; + for (const [name, pattern] of Object.entries(families)) { + if (!pattern.test(line)) continue; + frameCounts[name]++; + if (mainThread) mainThreadFrameCounts[name]++; + } + } + return { available: callGraphSeen && frames > 0, callGraphSeen, mainThreadSeen, frames, frameCounts, mainThreadFrameCounts, shape }; +} + +export function sampleOwnedDiaWait(child: ReturnType['children'][number], uid: number, expectedExecutable: string, + deadline: number, env: Record, spawn: typeof spawnSync = spawnSync) { + const result: Record = { available: false, reason: 'target_not_live_owned_child', attempted: false }; + if (child.executable !== expectedExecutable || path.basename(expectedExecutable) !== 'Dia' || !Number.isSafeInteger(child.pid) || child.pid < 1 + || child.process.pid !== child.pid || child.closeObserved || child.process.exitCode !== null || child.process.signalCode !== null + || uid !== process.getuid?.() || uid !== process.geteuid?.() || !Number.isFinite(deadline)) return result; + const until = Math.min(deadline, performance.now() + 5_000); + const timeout = (limit: number) => { + const value = Math.floor(Math.min(limit, until - performance.now())); + if (value < 1) throw new Error('sample_budget_exhausted'); + return value; + }; + try { + const identity = spawn('/bin/ps', ['-p', String(child.pid), '-o', 'uid=,pid=,ppid=,state=,ucomm='], { + env, encoding: 'utf8', timeout: timeout(2_000), maxBuffer: 16 * 1024, killSignal: 'SIGKILL', + }); + if (identity.error || identity.status !== 0 || typeof identity.stdout !== 'string') { result.reason = 'ownership_probe_unavailable'; return result; } + const row = identity.stdout.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+([IRSUTD][^\s]*)\s+Dia$/); + if (!row || Number(row[1]) !== uid || Number(row[2]) !== child.pid || Number(row[3]) !== process.pid) return result; + result.ownedLiveChildConfirmed = true; + result.attempted = true; + const sample = spawn('/usr/bin/sample', [String(child.pid), '1', '10', '-file', '/dev/stdout'], { + env, encoding: 'utf8', timeout: timeout(5_000), maxBuffer: 1024 * 1024, killSignal: 'SIGKILL', + }); + const stdout = typeof sample.stdout === 'string' ? sample.stdout : ''; + const stderr = typeof sample.stderr === 'string' ? sample.stderr : ''; + result.exitCode = sample.status; + result.stdoutBytes = Buffer.byteLength(stdout); + result.stderrBytes = Buffer.byteLength(stderr); + result.timedOut = (sample.error as NodeJS.ErrnoException | undefined)?.code === 'ETIMEDOUT'; + if (sample.error || sample.status !== 0) { + result.reason = /not permitted|permission denied|(?:unable|failed) to (?:get|obtain).*task|task_for_pid.*fail|requires root/i.test((stdout + '\n' + stderr).slice(0, 65_536)) + ? 'sampling_permission_denied' : result.timedOut ? 'sampling_timeout' : 'sampling_failed'; + return result; + } + result.waitFamilies = classifyNativeWaitSample(stdout); + result.available = result.waitFamilies.available; + result.reason = result.available ? 'sampled' : result.waitFamilies.reason ?? (!result.waitFamilies.callGraphSeen ? 'sample_call_graph_missing' + : result.waitFamilies.shape.nonemptyGraphLines === 0 ? 'sample_graph_empty' + : result.waitFamilies.shape.unrecognizedGraphLines === 0 ? 'sample_no_stack_frames' : 'sample_graph_unrecognized'); + } catch { result.reason = 'sampling_unavailable_or_budget_exhausted'; } + return result; +} + +async function sha256(file: string): Promise { + const hash = createHash('sha256'); + for await (const chunk of createReadStream(file)) hash.update(chunk); + return hash.digest('hex'); +} + +async function bounded(operation: Promise, milliseconds: number): Promise { + let timer: ReturnType; + try { + return await Promise.race([operation, new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('operation_timeout')), milliseconds); + })]); + } finally { clearTimeout(timer!); } +} + +export async function qualifyDia(isolation: { root: string; configFile: string }): Promise> { + validateQualificationHost(process.env); + const account = readFreshAccountConfiguration(isolation.configFile); + if (account.guiReadiness || !account.destinationExecutable || !account.destinationSha256) throw new Error('browser_qualification_authority_required'); + if (Bun.version !== '1.4.0' || require('playwright/package.json').version !== '1.62.1') throw new Error('pinned_runtimes_required'); + const runnerTemp = realpathSync(process.env.RUNNER_TEMP!); + const output = path.join(runnerTemp, 'dia-native-qualification.json'); + if (existsSync(output)) throw new Error('fresh_receipt_path_required'); + const root = realpathSync(isolation.root); + if (path.dirname(root) !== runnerTemp || !path.basename(root).startsWith('dia-')) throw new Error('fixture_root_unowned'); + const home = account.home; + const temporary = path.join(root, 't'); + const sourceProfile = path.join(home, 'Library/Application Support/Dia/User Data'); + const destinationProfile = path.join(root, 'd'); + const keychain = path.join(root, 'fixture.keychain-db'); + const mount = path.join(root, 'm'); + const image = path.join(root, 'Dia.dmg'); + const app = path.join(root, 'Dia.app'); + const environment = { HOME: home, PATH: '/usr/bin:/bin:/usr/sbin:/sbin', LANG: 'en_US.UTF-8', TMPDIR: temporary }; + const deadline = performance.now() + 9 * 60_000; + let cleaning = false; + let cleanupDeadline = 0; + let stage = 'fixture_preflight'; + let mounted = false; + let keychainCreated = false; + let keychainChanged = false; + let originalSearch: string[] = []; + let originalDefault: string[] = []; + let source: BrowserContext | undefined; + let destination: BrowserContext | undefined; + let observer: ReturnType | undefined; + let launchAttempts = 0; + let comparisonAttempted = false; + let comparisonSource: Record | undefined; + let profileCreationAttempted = false; + let profileOwnership: ReturnType | undefined; + let server: ReturnType | undefined; + let browserRole: 'source' | 'destination' | undefined; + const attemptStarts: Partial> = {}; + const receipt: Record = { + status: 'incomplete', reason: 'not_run', runId: process.env.GITHUB_RUN_ID, runAttempt: process.env.GITHUB_RUN_ATTEMPT, + platform: { os: 'darwin', architecture: 'arm64', release: release(), bun: Bun.version, playwright: '1.62.1' }, + isolation: { registeredHomeAtStartup: true, sharedRegisteredHome: true, registeredIdentity: false, + productionLookupMocked: false, keychainCommandHome: 'fresh_registered_home' }, + artifact: { url: DIA_DOWNLOAD }, expectedCases: 8, + cases: Object.fromEntries(['headless_without_account_interaction', 'synthetic_session_created', 'native_encrypted_cookie_persisted', + 'dia_profile_and_domain_discovered', 'native_keychain_decryption_and_verified_import', 'default_storage_preserved', + 'wrong_identity_not_verified', 'explicit_storage_reset'].map(name => [name, 'not_run'])), counts: { pass: 0, fail: 0, skip: 0 }, + coverage: { mockKeychain: false, nativeKeychainRead: false, nativePermissionPrompts: false, accountLogin: false, sync: false, browserProfileImport: false }, + browsers: { source: { stage: 'not_started', launchReturned: false }, destination: { stage: 'not_started', launchReturned: false } }, + cleanup: { ownedBrowsersStopped: false, sourceProfileRemoved: false, keychainRestored: false, mountDetached: false, fixtureRemoved: false }, + }; + const run = (command: string, args: string[], milliseconds = 10_000, env = environment) => { + const remaining = (cleaning ? cleanupDeadline : deadline) - performance.now(); + const timeout = Math.floor(Math.min(milliseconds, remaining)); + if (!Number.isFinite(timeout) || timeout < 1) throw new Error('qualification_budget_exhausted'); + const result = spawnSync(command, args, { env, encoding: 'utf8', timeout, maxBuffer: 1024 * 1024 }); + if (result.error || result.status !== 0) { + receipt[cleaning ? 'cleanupCommandFailure' : 'commandFailure'] ??= { command: path.basename(command), operation: args[0], exitCode: result.status, + timedOut: (result.error as NodeJS.ErrnoException | undefined)?.code === 'ETIMEDOUT' }; + throw new Error('command_failed'); + } + return { stdout: result.stdout.trim(), stderr: result.stderr.trim() }; + }; + const check = (name: string, passed: boolean) => { + receipt.cases[name] = passed ? 'passed' : 'failed'; + if (!passed) throw new Error('case_failed'); + }; + const within = (operation: () => Promise, milliseconds: number) => { + const remaining = deadline - performance.now(); + if (remaining <= 0) throw new Error('qualification_budget_exhausted'); + return bounded(operation(), Math.min(milliseconds, remaining)); + }; + try { + const record = parseDirectoryRecord(run('/usr/bin/dscl', ['.', '-read', '/Users/' + account.account, 'UniqueID', 'PrimaryGroupID', 'NFSHomeDirectory', 'GeneratedUID']).stdout); + if (!ownsFreshAccount(record, account)) throw new Error('fresh_registered_identity_mismatch'); + receipt.isolation.registeredIdentity = true; + for (const directory of [temporary, mount]) { + if (!existsSync(directory)) mkdirSync(directory, { mode: 0o700 }); + else if (realpathSync(directory) !== directory || !lstatSync(directory).isDirectory()) throw new Error('fixture_directory_escape'); + } + receipt.sourceSocketPathBytes = Buffer.byteLength(path.join(sourceProfile, 'SingletonSocket')); + assertDiaSocketPath(sourceProfile); + if (existsSync(sourceProfile) || existsSync(destinationProfile)) throw new Error('existing_profile_refused'); + for (const relative of ['Library/Application Support/Google/Chrome', 'Library/Application Support/Chromium', + 'Library/Application Support/Arc', 'Library/Application Support/Dia', 'Library/Application Support/Comet', + 'Library/Application Support/BraveSoftware/Brave-Browser', 'Library/Application Support/Microsoft Edge', + 'Library/Safari', 'Library/Cookies']) { + if (existsSync(path.join(home, relative))) { + receipt.preexistingState = relative; + throw new Error('preexisting_browser_state_refused'); + } + } + receipt.keychainHome = prepareKeychainHome(home); + const revision = process.env.GSTACK_DIA_SOURCE_REVISION; + if (revision && !/^[0-9a-f]{40}$/.test(revision)) throw new Error('invalid_source_revision'); + receipt.sourceRevision = revision ?? run('/usr/bin/git', ['-C', repository, 'rev-parse', 'HEAD']).stdout; + const sourceFiles = ['.github/scripts/qualify-dia-macos.ts', 'browse/src/cookie-import-browser.ts', 'browse/src/cookie-import-operation.ts', 'browse/src/cookie-database.ts', 'browse/src/cookie-auth-verification.ts', 'browse/src/cdp-bridge.ts']; + receipt.sourceHashes = Object.fromEntries(await within(() => Promise.all(sourceFiles.map(async file => [file, await sha256(path.join(repository, file))])), 10_000)); + stage = 'official_download'; + const downloaded = run('/usr/bin/curl', ['--fail', '--silent', '--show-error', '--location', '--proto', '=https', '--proto-redir', '=https', + '--connect-timeout', '20', '--max-time', '300', '--output', image, '--write-out', '%{url_effective}', DIA_DOWNLOAD], 310_000); + if (new URL(downloaded.stdout).origin !== 'https://releases.diabrowser.com') throw new Error('unexpected_download_origin'); + chmodSync(image, 0o600); + receipt.artifact.sha256 = await within(() => sha256(image), 30_000); + stage = 'signed_app_staging'; + run('/usr/bin/hdiutil', ['attach', '-readonly', '-nobrowse', '-noautoopen', '-mountpoint', mount, image], 45_000); + mounted = true; + const mountedApp = path.join(mount, 'Dia.app'); + if (!lstatSync(mountedApp).isDirectory() || !realpathSync(mountedApp).startsWith(realpathSync(mount) + path.sep)) throw new Error('mounted_app_escape'); + run('/usr/bin/ditto', ['--rsrc', '--extattr', mountedApp, app], 45_000); + run('/usr/bin/hdiutil', ['detach', mount], 15_000); + mounted = false; + if (realpathSync(app) !== app) throw new Error('staged_app_escape'); + run('/usr/bin/codesign', ['--verify', '--deep', '--strict', '--verbose=2', app], 45_000); + const signature = run('/usr/bin/codesign', ['--display', '--verbose=4', app]).stderr; + const gatekeeper = run('/usr/sbin/spctl', ['--assess', '--type', 'execute', '--verbose=4', app], 45_000).stderr; + const authority = signature.match(/^Authority=(Developer ID Application: .+)$/m)?.[1]; + const team = signature.match(/^TeamIdentifier=([A-Z0-9]{10})$/m)?.[1]; + if (!authority || !team || !gatekeeper.includes('accepted') || !gatekeeper.includes('Notarized Developer ID')) throw new Error('signer_or_notarization_unconfirmed'); + const plist = path.join(app, 'Contents/Info.plist'); + const property = (name: string) => run('/usr/bin/plutil', ['-extract', name, 'raw', '-o', '-', plist]).stdout; + const executableName = property('CFBundleExecutable'); + if (!executableName || path.basename(executableName) !== executableName) throw new Error('invalid_bundle_executable'); + const executable = realpathSync(path.join(app, 'Contents/MacOS', executableName)); + if (!executable.startsWith(realpathSync(app) + path.sep)) throw new Error('bundle_executable_escape'); + receipt.artifact = { ...receipt.artifact, version: property('CFBundleShortVersionString'), bundleId: property('CFBundleIdentifier'), authority, team, + signatureVerified: true, gatekeeperNotarized: true }; + stage = 'signed_app_architecture'; + receipt.artifact.architectureInspection = inspectMachOArchitectures(executable, Math.min(10_000, deadline - performance.now())); + receipt.artifact.architectures = receipt.artifact.architectureInspection.architectures; + if (!receipt.artifact.architectures.includes('arm64')) throw new Error('dia_arm64_binary_required'); + receipt.artifact.executableSha256 = await within(() => sha256(executable), 10_000); + stage = 'signed_app_os_compatibility'; + receipt.artifact.macosCompatibility = macosCompatibility(JSON.parse(run('/usr/bin/plutil', ['-convert', 'json', '-o', '-', plist]).stdout), + run('/usr/bin/sw_vers', ['-productVersion']).stdout); + if (receipt.artifact.macosCompatibility.compatible === false) throw new Error('source_macos_version_unsupported'); + stage = 'temporary_keychain'; + receipt.keychainStage = 'capture_original'; + const originalKeychains = captureUserKeychains(environment, [home, root]); + originalSearch = originalKeychains.search; + originalDefault = originalKeychains.default; + const keychainPassword = randomBytes(24).toString('hex'); + const fixtureKey = randomBytes(24).toString('hex'); + let setupFailure: unknown; + try { + receipt.keychainStage = 'create_fixture'; + keychainChanged = true; + run('/usr/bin/security', ['create-keychain', '-p', keychainPassword, keychain]); + keychainCreated = true; + receipt.keychainStage = 'configure_settings'; + run('/usr/bin/security', ['set-keychain-settings', '-lut', '600', keychain]); + receipt.keychainStage = 'unlock_fixture'; + run('/usr/bin/security', ['unlock-keychain', '-p', keychainPassword, keychain]); + receipt.keychainStage = 'seed_fixture'; + run('/usr/bin/security', ['add-generic-password', '-a', 'Dia', '-s', 'Dia Safe Storage', '-w', fixtureKey, '-T', executable, '-T', '/usr/bin/security', keychain]); + receipt.keychainStage = 'set_search'; + run('/usr/bin/security', ['list-keychains', '-d', 'user', '-s', keychain]); + receipt.keychainStage = 'set_default'; + run('/usr/bin/security', ['default-keychain', '-d', 'user', '-s', keychain]); + receipt.keychainStage = 'validate_environments'; + } catch (error) { setupFailure = error; } + if (keychainCreated) { + receipt.keychainObservations = observeDiaKeychainEnvironments({ keychainHome: environment, profileHome: environment }, + [home, root], keychain, fixtureKey, Math.min(20_000, deadline - performance.now())); + } + if (setupFailure !== undefined) throw setupFailure; + const keychainFailure = receipt.keychainObservations.firstFailure; + if (keychainFailure) throw new Error(keychainFailure.check === 'search_path' ? 'keychain_search_isolation_failed' : 'fixture_keychain_read_failed'); + receipt.keychainStage = 'completed'; + if (account.launchComparison) { + stage = 'diagnostic_source_launch'; + profileCreationAttempted = true; + profileOwnership = createOwnedDiaProfile(home); + assertOwnedDiaProfile(profileOwnership); + receipt.isolation.sourceProfileOwnershipConfirmed = true; + comparisonAttempted = true; + comparisonSource = await runDiaLaunchComparison(account, 'source', { assetRoot: root, executableName, executableSha256: receipt.artifact.executableSha256 }, + undefined, deadline - performance.now()); + receipt.launchComparison = { mode: 'launch-only', qualificationCredit: false, source: comparisonSource }; + receipt.browsers.source = { stage: 'delegated_comparison', launchReturned: comparisonSource.launchReturned, + timedOut: comparisonSource.timedOut ?? false, error: comparisonSource.error ?? null }; + receipt.reason = 'diagnostic_launch_comparison_only'; + return receipt; + } + delete process.env.DEBUG; + delete process.env.PWDEBUG; + browserRole = 'source'; + const sourceFacts = receipt.browsers.source; + sourceFacts.stage = 'runtime_import'; + const { chromium } = await import('playwright'); + sourceFacts.stage = 'fixture_setup'; + browserRole = undefined; + stage = 'synthetic_fixture_setup'; + const destinationExecutable = realpathSync(account.destinationExecutable); + observer = observeBrowserLaunches(new Map([[executable, sourceProfile], [destinationExecutable, destinationProfile]])); + const token = randomBytes(24).toString('hex'); + const identity = 'Synthetic Dia qualification account'; + server = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch(request) { + const url = new URL(request.url); + if (url.pathname === '/seed') return new Response('Synthetic session seeded.', { headers: { 'Content-Type': 'text/html', 'Set-Cookie': `dia_fixture_session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=3600` } }); + const authenticated = (request.headers.get('cookie') || '').split(';').some(value => value.trim() === `dia_fixture_session=${token}`); + return new Response(authenticated ? `
${identity}
` : '
Not signed in
', { status: authenticated ? 200 : 401, headers: { 'Content-Type': 'text/html' } }); + } }); + const origin = `http://127.0.0.1:${server.port}`; + stage = 'owned_source_profile_creation'; + profileCreationAttempted = true; + profileOwnership = createOwnedDiaProfile(home); + assertOwnedDiaProfile(profileOwnership); + receipt.isolation.sourceProfileOwnershipConfirmed = true; + stage = 'dia_headless_startup_or_onboarding'; + browserRole = 'source'; + sourceFacts.stage = 'launch'; + attemptStarts.source = observer.attempts.length; + launchAttempts++; + sourceFacts.nativeWait = { available: false, attempted: false, reason: 'launch_settled_before_sample_or_window_expired' }; + source = await observePendingBrowserLaunch( + () => within(() => chromium.launchPersistentContext(sourceProfile, nativeDiaLaunchOptions(executable, environment)), 40_000), + sampleDeadline => { + try { + const children = observer!.children.filter(child => child.executable === executable); + sourceFacts.nativeWait = children.length === 1 + ? sampleOwnedDiaWait(children[0], account.uid, executable, sampleDeadline, environment) + : { available: false, attempted: false, reason: 'source_root_ownership_unconfirmed' }; + sourceFacts.nativeWait.phase = 'launch_pending_before_timeout'; + } catch { sourceFacts.nativeWait = { available: false, attempted: false, reason: 'sampling_observer_failed' }; } + }, deadline); + sourceFacts.launchReturned = true; + sourceFacts.stage = 'ownership'; + sourceFacts.ownedRootCount = observer.children.filter(child => child.executable === executable).length; + if (observer.children.filter(child => child.executable === executable).length !== 1) throw new Error('source_process_ownership_unconfirmed'); + sourceFacts.stage = 'startup_pages'; + sourceFacts.startupPages = browserStartupFacts(source.pages().map(page => page.url()), origin); + if (!sourceFacts.startupPages.allowed) throw new Error('onboarding_or_external_page'); + sourceFacts.stage = 'route_registration'; + await within(() => source!.route('**/*', route => new URL(route.request().url()).origin === origin ? route.continue() : route.abort()), 10_000); + sourceFacts.stage = 'page_selection'; + const sourcePage = source.pages()[0] ?? await within(() => source!.newPage(), 10_000); + sourceFacts.stage = 'seed_navigation'; + const seed = await within(() => sourcePage.goto(origin + '/seed', { waitUntil: 'domcontentloaded', timeout: 10_000 }), 10_000); + sourceFacts.stage = 'seed_validation'; + sourceFacts.seedResponseStatus = seed?.status() ?? null; + sourceFacts.postNavigationPages = browserStartupFacts(source.pages().map(page => page.url()), origin); + check('headless_without_account_interaction', seed?.status() === 200 && sourcePage.url() === origin + '/seed' + && source.pages().every(page => allowedFixturePage(page.url(), origin))); + receipt.sourceBrowserVersion = source.browser()?.version(); + sourceFacts.stage = 'cookies_readback'; + const sourceCookies = await within(() => source!.cookies(origin), 10_000); + check('synthetic_session_created', sourceCookies.some(cookie => cookie.name === 'dia_fixture_session' && cookie.value === token)); + sourceFacts.stage = 'close'; + await within(() => source!.close(), 10_000); + source = undefined; + sourceFacts.stage = 'completed'; + browserRole = undefined; + stage = 'native_profile_persistence'; + assertOwnedDiaProfile(profileOwnership); + const cookieFile = path.join(sourceProfile, 'Default/Cookies'); + if (!existsSync(cookieFile) || !realpathSync(cookieFile).startsWith(sourceProfile + path.sep)) throw new Error('expected_profile_layout_missing'); + for (const metadata of ['Local State', 'Default/Preferences']) { + const file = path.join(sourceProfile, metadata); + if (existsSync(file) && !realpathSync(file).startsWith(sourceProfile + path.sep)) throw new Error('profile_metadata_escape'); + } + const { openCookieDatabase } = await import('../../browse/src/cookie-database'); + const database = openCookieDatabase(cookieFile); + try { + const rows = database.query('SELECT value, encrypted_value FROM cookies WHERE host_key = ? AND name = ?').all('127.0.0.1', 'dia_fixture_session') as any[]; + check('native_encrypted_cookie_persisted', rows.length === 1 && !rows[0].value && Buffer.from(rows[0].encrypted_value).subarray(0, 3).toString() === 'v10'); + } finally { database.close(); } + stage = 'production_import'; + const { listProfiles, listDomains } = await import('../../browse/src/cookie-import-browser'); + check('dia_profile_and_domain_discovered', listProfiles('Dia').some(profile => profile.name === 'Default') + && listDomains('Dia', 'Default').domains.some(entry => entry.domain === '127.0.0.1' && entry.count >= 1)); + browserRole = 'destination'; + const destinationFacts = receipt.browsers.destination; + destinationFacts.stage = 'launch'; + attemptStarts.destination = observer.attempts.length; + launchAttempts++; + destination = await within(() => chromium.launchPersistentContext(destinationProfile, nativeDiaLaunchOptions(destinationExecutable, environment)), 40_000); + destinationFacts.launchReturned = true; + destinationFacts.stage = 'ownership'; + destinationFacts.ownedRootCount = observer.children.filter(child => child.executable === destinationExecutable).length; + if (observer.children.filter(child => child.executable === destinationExecutable).length !== 1) throw new Error('destination_process_ownership_unconfirmed'); + destinationFacts.stage = 'startup_pages'; + destinationFacts.startupPages = browserStartupFacts(destination.pages().map(page => page.url()), origin); + destinationFacts.stage = 'page_selection'; + const target = destination.pages()[0] ?? await within(() => destination!.newPage(), 10_000); + destinationFacts.stage = 'protected_navigation'; + await within(() => target.goto(origin + '/protected', { waitUntil: 'domcontentloaded', timeout: 10_000 }), 10_000); + destinationFacts.postNavigationPages = browserStartupFacts(destination.pages().map(page => page.url()), origin); + destinationFacts.stage = 'storage_seed'; + await within(() => target.evaluate(() => { localStorage.setItem('fixture-local', 'preserved'); sessionStorage.setItem('fixture-session', 'preserved'); }), 10_000); + destinationFacts.stage = 'cookie_import'; + const { runCookieImport } = await import('../../browse/src/cookie-import-operation'); + const tracked: string[] = []; + const imported = await within(() => runCookieImport({ browser: 'Dia', profile: 'Default', domains: ['127.0.0.1'], verifyAuth: true }, + { page: target, url: target.url() }, domains => tracked.push(...domains), { identitySelector: '#fixture-identity', expectedIdentity: identity }), 30_000); + check('native_keychain_decryption_and_verified_import', imported.imported >= 1 && imported.failed === 0 && imported.verification.verified && tracked.includes('127.0.0.1')); + receipt.coverage.nativeKeychainRead = true; + destinationFacts.stage = 'storage_preservation'; + check('default_storage_preserved', await within(() => target.evaluate(() => localStorage.getItem('fixture-local') === 'preserved' && sessionStorage.getItem('fixture-session') === 'preserved'), 10_000)); + destinationFacts.stage = 'wrong_identity'; + const wrong = await within(() => runCookieImport({ browser: 'Dia', profile: 'Default', domains: ['127.0.0.1'], verifyAuth: true }, + { page: target, url: target.url() }, () => {}, { identitySelector: '#fixture-identity', expectedIdentity: 'Different synthetic account', timeoutMs: 500 }), 15_000); + check('wrong_identity_not_verified', !wrong.verification.verified && wrong.verification.reason === 'identity_mismatch'); + destinationFacts.stage = 'storage_reset'; + const reset = await within(() => runCookieImport({ browser: 'Dia', profile: 'Default', domains: ['127.0.0.1'], clearStorage: true, verifyAuth: true }, + { page: target, url: target.url() }, () => {}, { identitySelector: '#fixture-identity', expectedIdentity: identity }), 30_000); + check('explicit_storage_reset', reset.reset === 'cleared' && reset.verification.verified + && await within(() => target.evaluate(() => localStorage.getItem('fixture-local') === null && sessionStorage.getItem('fixture-session') === null), 10_000)); + destinationFacts.stage = 'completed'; + browserRole = undefined; + for (const file of sourceFiles) if (await within(() => sha256(path.join(repository, file)), 5_000) !== receipt.sourceHashes[file]) throw new Error('source_changed_during_qualification'); + if (await within(() => sha256(executable), 10_000) !== receipt.artifact.executableSha256) throw new Error('source_app_changed_during_qualification'); + receipt.status = 'passed'; + receipt.reason = 'native_dia_synthetic_profile_verified'; + } catch (error) { + receipt.reason = stage; + receipt.failureStage = stage; + if (browserRole) { + const facts = receipt.browsers[browserRole]; + const children = (observer?.children ?? []).filter(child => (child.executable === account.destinationExecutable ? 'destination' : 'source') === browserRole); + const stderrReasons = browserStderrFacts(children).flatMap(fact => Object.entries(fact.reasonCounts ?? {}) + .filter(([, count]) => count > 0).map(([reason]) => reason as BrowserStderrReason)); + facts.timedOut = browserOperationTimedOut(error); + facts.error = browserPreflightError(error, stderrReasons); + receipt.blocker = facts.error; + if (facts.stage === 'runtime_import') facts.moduleLoad = playwrightModuleLoadFacts(repository, error); + } + if (error instanceof Error && error.message === 'onboarding_or_external_page') receipt.blocker = 'onboarding_or_unexpected_startup_page'; + if (error instanceof Error && error.message === 'preexisting_browser_state_refused') receipt.blocker = 'preexisting_browser_state_refused'; + if (error instanceof Error && ['user_keychain_search_unavailable', 'user_default_keychain_unavailable', 'invalid_keychain_snapshot', + 'empty_keychain_snapshot', 'invalid_default_keychain_snapshot', 'keychain_outside_owned_home_refused', + 'keychain_search_isolation_failed', 'fixture_keychain_read_failed', 'user_keychain_probe_timeout', 'fixture_keychain_not_owned', + 'fresh_registered_identity_mismatch', 'unsafe_profile_ancestor', 'profile_ownership_unconfirmed', 'source_macos_version_unsupported', + 'invalid_macho_header', 'unsupported_macho_architecture', 'unsafe_macho_file', 'macho_changed_during_inspection', + 'macho_read_budget_exhausted', 'dia_arm64_binary_required', 'comparison_driver_inputs_changed', + 'comparison_driver_authority_missing', 'comparison_budget_exhausted'].includes(error.message)) receipt.blocker = error.message; + const code = (error as { code?: string } | null)?.code; + if (code && ['keychain_timeout', 'keychain_denied', 'keychain_not_found', 'keychain_error', 'db_read_error', 'db_corrupt', 'target_changed', 'target_mismatch'].includes(code)) receipt.blocker = code; + receipt.initialFailure ??= { stage, blocker: receipt.blocker ?? 'qualification_step_failed', + ...(browserRole ? { browser: browserRole, browserStage: receipt.browsers[browserRole].stage } : {}), + ...(browserRole ? { timedOut: receipt.browsers[browserRole].timedOut } : {}), + ...(stage === 'temporary_keychain' ? { keychainStage: receipt.keychainStage } : {}) }; + receipt.status = Object.values(receipt.cases).includes('failed') ? 'failed' : 'incomplete'; + } finally { + cleaning = true; + cleanupDeadline = performance.now() + 45_000; + observer?.stop(); + for (const role of ['source', 'destination'] as const) { + const facts = receipt.browsers[role]; + if (role === 'source' && comparisonAttempted && comparisonSource) { + facts.ownedRootCount = comparisonSource.rootCount ?? 0; + facts.rootStatesBeforeCleanup = comparisonSource.rootsBeforeCleanup ?? []; + facts.stderrBeforeCleanup = comparisonSource.stderrBeforeCleanup ?? []; + facts.launchAttempts = comparisonSource.launchAttempts ?? []; + facts.cleanup = comparisonSource.cleanup; + continue; + } + const children = (observer?.children ?? []).filter(child => (child.executable === account.destinationExecutable ? 'destination' : 'source') === role); + facts.ownedRootCount = children.length; + facts.rootStatesBeforeCleanup = browserRootFacts(children); + facts.stderrBeforeCleanup = browserStderrFacts(children); + facts.launchAttempts = attemptStarts[role] === undefined ? [] : (observer?.attempts ?? []) + .slice(attemptStarts[role], role === 'source' ? attemptStarts.destination : undefined); + facts.cleanup = { closeAttempted: false, groups: [] }; + } + for (const [role, context] of [['source', source], ['destination', destination]] as const) { + if (!context) continue; + receipt.browsers[role].cleanup.closeAttempted = true; + try { await bounded(context.close(), 5_000); } + catch (error) { receipt.browsers[role].cleanup.closeError = browserPreflightError(error); } + } + let stopped = comparisonAttempted ? comparisonSource?.cleanup?.confirmed === true : !observer || observer.children.length >= launchAttempts; + receipt.browserCleanup = { launchAttempts: comparisonAttempted ? 1 : launchAttempts, + capturedRootCount: comparisonAttempted ? comparisonSource?.rootCount ?? 0 : observer?.children.length ?? 0, + rootCaptureComplete: comparisonAttempted ? comparisonSource?.rootCount === 1 : stopped }; + for (const child of observer?.children ?? []) { + const role = child.executable === account.destinationExecutable ? 'destination' : 'source'; + const group: Record = { pid: child.pid, stage: 'probe_before_signal', signalSent: false, absenceConfirmed: false }; + receipt.browsers[role].cleanup.groups.push(group); + const until = Math.min(cleanupDeadline, performance.now() + 5_000); + try { + await stopOwnedBrowserGroup(child, until, group); + } catch (error) { + stopped = false; + group.childCloseObserved = child.closeObserved; + group.failure = browserCleanupError(error); + try { + const timeout = Math.floor(Math.min(2_000, cleanupDeadline - performance.now())); + if (timeout < 1) throw new Error('cleanup_budget_exhausted'); + const snapshot = spawnSync('/bin/ps', ['-axo', 'uid=,pid=,ppid=,pgid=,state='], { + env: environment, encoding: 'utf8', timeout, maxBuffer: 128 * 1024, + }); + if (snapshot.error || snapshot.status !== 0 || !snapshot.stdout.trim()) throw new Error('process_snapshot_unavailable'); + group.membersAfterFailure = browserGroupFacts(snapshot.stdout, account.uid, child.pid); + } catch { group.membersAfterFailure = { available: false }; } + } + group.rootAfterCleanup = browserRootFacts([child])[0]; + } + for (const role of ['source', 'destination'] as const) { + receipt.browsers[role].stderrAfterCleanup = role === 'source' && comparisonAttempted ? comparisonSource?.stderrAfterCleanup ?? [] + : browserStderrFacts((observer?.children ?? []).filter(child => (child.executable === account.destinationExecutable ? 'destination' : 'source') === role)); + } + receipt.cleanup.ownedBrowsersStopped = stopped; + receipt.observedBrowserRoots = comparisonAttempted ? comparisonSource?.rootCount ?? 0 : observer?.children.length ?? 0; + server?.stop(true); + if (!profileCreationAttempted) receipt.cleanup.sourceProfileRemoved = true; + else if (stopped && profileOwnership) { + try { removeOwnedDiaProfile(profileOwnership, stopped); receipt.cleanup.sourceProfileRemoved = true; } catch {} + } + if (stopped) { + try { + if (keychainChanged) { + let restored = true; + for (const args of fixtureKeychainRestoreCommands({ search: originalSearch, default: originalDefault }, keychain, keychainCreated)) { + try { run('/usr/bin/security', args); } catch { restored = false; } + } + if (!restored) throw new Error('keychain_restore_failed'); + const restoredSnapshot = captureUserKeychains(environment, [home, root], cleanupDeadline - performance.now()); + if (JSON.stringify(restoredSnapshot.search) !== JSON.stringify(originalSearch) + || JSON.stringify(restoredSnapshot.default) !== JSON.stringify(originalDefault)) throw new Error('keychain_restore_failed'); + } + receipt.cleanup.keychainRestored = true; + } catch {} + } + try { + if (mounted) run('/usr/bin/hdiutil', ['detach', mount], 10_000); + receipt.cleanup.mountDetached = true; + } catch {} + if (stopped && receipt.cleanup.sourceProfileRemoved && receipt.cleanup.keychainRestored && receipt.cleanup.mountDetached) { + try { + if (realpathSync(root) !== root || path.dirname(root) !== runnerTemp) throw new Error('fixture_root_changed'); + rmSync(root, { recursive: true, force: true }); + receipt.cleanup.fixtureRemoved = true; + } catch {} + } + if (Object.values(receipt.cleanup).some(value => value !== true)) { receipt.status = 'failed'; receipt.reason = 'cleanup_incomplete'; } + receipt.counts = { pass: Object.values(receipt.cases).filter(value => value === 'passed').length, + fail: Object.values(receipt.cases).filter(value => value === 'failed').length, skip: 0 }; + writePrivateReceipt(output, receipt); + } + return receipt; +} + +if (import.meta.main) { + try { + if (process.argv[2] === '--isolated-worker') { + const receipt = await qualifyDia({ root: process.argv[3], configFile: process.argv[4] }); + console.log(JSON.stringify({ status: receipt.status, reason: receipt.reason, counts: receipt.counts, artifact: 'dia-native-qualification.json' })); + process.exitCode = receipt.status === 'passed' ? 0 : 2; + } else { + validateQualificationHost(process.env); + if (process.argv[2] !== '--fresh-account' || !process.argv[3]) throw new Error('fresh_account_configuration_required'); + const account = readFreshAccountConfiguration(process.argv[3]); + if (account.guiReadiness) throw new Error('browser_qualification_authority_required'); + if (Bun.version !== '1.4.0' || require('playwright/package.json').version !== '1.62.1') throw new Error('pinned_runtimes_required'); + const originalHome = account.home; + const originalHomeEnvironment = process.env.HOME; + const runnerTemp = realpathSync(process.env.RUNNER_TEMP!); + const output = path.join(runnerTemp, 'dia-native-qualification.json'); + if (existsSync(output)) throw new Error('fresh_receipt_path_required'); + const root = realpathSync(mkdtempSync(path.join(runnerTemp, 'dia-'))); + chmodSync(root, 0o700); + const temporary = path.join(root, 't'); + mkdirSync(temporary, { mode: 0o700 }); + const metadata = Object.fromEntries(['CI', 'GITHUB_ACTIONS', 'RUNNER_ENVIRONMENT', 'RUNNER_OS', 'RUNNER_ARCH', 'RUNNER_TEMP', + 'GITHUB_RUN_ID', 'GITHUB_RUN_ATTEMPT', 'GSTACK_DIA_NATIVE_QUALIFY', 'GSTACK_DIA_EXPECT_UID', 'GSTACK_DIA_SOURCE_REVISION'] + .filter(name => process.env[name] !== undefined).map(name => [name, process.env[name]!])); + const child = spawnSync(process.execPath, ['--no-env-file', '--no-install', '--no-macros', '--config=/dev/null', import.meta.path, + '--isolated-worker', root, account.configFile], { + cwd: repository, env: { ...metadata, HOME: account.home, TMPDIR: temporary, PATH: '/usr/bin:/bin:/usr/sbin:/sbin', LANG: 'en_US.UTF-8' }, + encoding: 'utf8', timeout: 630_000, killSignal: 'SIGKILL', maxBuffer: 1024 * 1024, + }); + if (realpathSync(homedir()) !== originalHome || process.env.HOME !== originalHomeEnvironment + || child.error || ![0, 2].includes(child.status ?? -1) || !existsSync(output)) { + let receipt: Record = { counts: { pass: 0, fail: 0, skip: 0 }, cleanup: { confirmed: false } }; + if (existsSync(output)) { + if (!lstatSync(output).isFile() || realpathSync(output) !== output || lstatSync(output).size > 1024 * 1024) throw new Error('invalid_worker_receipt'); + try { receipt = JSON.parse(readFileSync(output, 'utf8')); } catch {} + } + receipt.status = 'incomplete'; + receipt.reason = 'qualification_worker_did_not_complete'; + receipt.runId = process.env.GITHUB_RUN_ID; + receipt.runAttempt = process.env.GITHUB_RUN_ATTEMPT; + receipt.supervisor = { completed: false, exitCode: child.status }; + receipt.recovery = 'Discard this disposable runner; do not reuse its Keychain or staged profile.'; + writePrivateReceipt(output, receipt, existsSync(output)); + console.log(JSON.stringify({ status: 'incomplete', reason: 'qualification_worker_did_not_complete', artifact: 'dia-native-qualification.json' })); + process.exitCode = 2; + } else { + const summary = JSON.parse(child.stdout); + console.log(JSON.stringify({ status: summary.status, reason: summary.reason, counts: summary.counts, artifact: 'dia-native-qualification.json' })); + process.exitCode = child.status === 0 && summary.status === 'passed' && summary.counts?.pass === 8 && summary.counts?.fail === 0 && summary.counts?.skip === 0 ? 0 : 2; + } + } + } catch { + console.log(JSON.stringify({ status: 'incomplete', reason: 'qualification_preflight_failed', counts: { pass: 0, fail: 0, skip: 0 } })); + process.exitCode = 2; + } +} diff --git a/.github/scripts/run-cookie-native-qualification.ps1 b/.github/scripts/run-cookie-native-qualification.ps1 new file mode 100644 index 000000000..0b5d9980e --- /dev/null +++ b/.github/scripts/run-cookie-native-qualification.ps1 @@ -0,0 +1,125 @@ +param( + [Parameter(Mandatory = $true)][string]$OutputRoot, + [switch]$Child, + [switch]$Initialized, + [string]$ExpectedSid, + [string]$BinDirectory, + [string]$GitDirectory +) + +$ErrorActionPreference = 'Stop' +if (-not $IsWindows -or $env:GITHUB_ACTIONS -ne 'true' -or $env:CI -ne 'true') { + throw 'Native cookie qualification requires a disposable GitHub Actions Windows runner.' +} +$repository = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path + +if ($Child) { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + if ($identity.User.Value -ne $ExpectedSid) { throw 'Unexpected qualification account identity.' } + $registered = (Get-ItemProperty "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\$ExpectedSid").ProfileImagePath + $registered = [Environment]::ExpandEnvironmentVariables($registered) + $env:USERPROFILE = $registered + $env:HOME = $registered + $folders = [Microsoft.Win32.Registry]::Users.OpenSubKey("$ExpectedSid\Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders") + if (-not $folders) { throw 'The new account known-folder registry is unavailable.' } + try { + $rawLocal = $folders.GetValue('Local AppData', $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + $rawRoaming = $folders.GetValue('AppData', $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + if (-not $rawLocal -or -not $rawRoaming) { throw 'The new account app-data folders are undefined.' } + $env:LOCALAPPDATA = [Environment]::ExpandEnvironmentVariables($rawLocal) + $env:APPDATA = [Environment]::ExpandEnvironmentVariables($rawRoaming) + } finally { $folders.Dispose() } + if (-not $Initialized) { + & (Join-Path $PSHOME 'pwsh.exe') -NoLogo -NoProfile -NonInteractive -File $PSCommandPath -Child -Initialized -ExpectedSid $ExpectedSid -BinDirectory $BinDirectory -GitDirectory $GitDirectory -OutputRoot $OutputRoot + exit $LASTEXITCODE + } + $profile = [Environment]::GetFolderPath('UserProfile') + $local = [Environment]::GetFolderPath('LocalApplicationData', 'DoNotVerify') + $roaming = [Environment]::GetFolderPath('ApplicationData', 'DoNotVerify') + if ($profile -ne $registered -or -not $local.StartsWith($profile + '\', [StringComparison]::OrdinalIgnoreCase)) { + Write-Output (ConvertTo-Json -Compress @{ profileMatchesRegistered = ($profile -eq $registered); localInsideProfile = $local.StartsWith($profile + '\', [StringComparison]::OrdinalIgnoreCase); localEmpty = [string]::IsNullOrEmpty($local); localMatchesInherited = ($local -eq $env:LOCALAPPDATA) }) + throw 'Qualification must use the new account real Windows profile.' + } + $keep = @('SystemRoot', 'WINDIR', 'ProgramFiles', 'ProgramFiles(x86)', 'ProgramData', 'PATHEXT') + Get-ChildItem Env: | Where-Object { $_.Name -notin $keep } | ForEach-Object { Remove-Item "Env:$($_.Name)" } + $env:USERPROFILE = $profile + $env:HOME = $profile + $env:LOCALAPPDATA = $local + $env:APPDATA = $roaming + $env:TEMP = Join-Path $local 'Temp' + $env:TMP = $env:TEMP + $env:PATH = "$BinDirectory;$GitDirectory\cmd;$GitDirectory\bin;$GitDirectory\usr\bin;$env:SystemRoot\System32;$env:SystemRoot" + $env:CI = 'true' + $env:GITHUB_ACTIONS = 'true' + New-Item -ItemType Directory -Force -Path $env:TEMP | Out-Null + Set-Location $repository + & (Join-Path $BinDirectory 'bun.exe') install --frozen-lockfile + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + & (Join-Path $GitDirectory 'bin\bash.exe') browse/scripts/build-node-server.sh + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + & (Join-Path $BinDirectory 'bun.exe') --no-env-file --no-install --no-macros --config=NUL browse/test/cookie-import-native-qualification.ts $OutputRoot + exit $LASTEXITCODE +} + +$work = Join-Path $OutputRoot ('cookie-native-host-' + [Guid]::NewGuid().ToString('N')) +$bin = Join-Path $work 'bin' +$evidence = Join-Path $work 'evidence' +$snapshot = Join-Path $work 'repository' +New-Item -ItemType Directory -Path $work | Out-Null +$name = 'gstack' + [Guid]::NewGuid().ToString('N').Substring(0, 10) +$password = ConvertTo-SecureString ([Convert]::ToBase64String([Security.Cryptography.RandomNumberGenerator]::GetBytes(32)) + '!aA1') -AsPlainText -Force +$account = $null +$process = $null +$exitCode = 1 +try { + $account = New-LocalUser -Name $name -Password $password -AccountExpires (Get-Date).AddHours(1) -Description 'Disposable gstack cookie qualification' + Add-LocalGroupMember -SID 'S-1-5-32-545' -Member $account + $principal = "$env:COMPUTERNAME\$name" + & icacls.exe $work /grant "${principal}:(OI)(CI)M" /Q | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'Could not grant fixture output access.' } + New-Item -ItemType Directory -Path $bin, $evidence, $snapshot | Out-Null + $archive = Join-Path $work 'source.tar' + & git -C $repository archive --format=tar -o $archive HEAD + if ($LASTEXITCODE -ne 0) { throw 'Could not snapshot the candidate source.' } + & (Join-Path $env:SystemRoot 'System32\tar.exe') -xf $archive -C $snapshot + if ($LASTEXITCODE -ne 0) { throw 'Could not materialize the isolated source snapshot.' } + Copy-Item (Get-Command bun).Source (Join-Path $bin 'bun.exe') + Copy-Item (Get-Command node).Source (Join-Path $bin 'node.exe') + $gitDirectory = Split-Path (Split-Path (Get-Command git).Source) + if (-not (Test-Path (Join-Path $gitDirectory 'bin\bash.exe'))) { throw 'Git Bash is required for the isolated Node build.' } + $childScript = Join-Path $snapshot '.github\scripts\run-cookie-native-qualification.ps1' + $credential = [Management.Automation.PSCredential]::new($principal, $password) + $arguments = @('-NoLogo', '-NoProfile', '-NonInteractive', '-File', ('"' + $childScript + '"'), '-Child', '-ExpectedSid', $account.SID.Value, + '-BinDirectory', ('"' + $bin + '"'), '-GitDirectory', ('"' + $gitDirectory + '"'), '-OutputRoot', ('"' + $evidence + '"')) + $process = Start-Process -FilePath (Join-Path $PSHOME 'pwsh.exe') -ArgumentList $arguments -Credential $credential -LoadUserProfile -WorkingDirectory $snapshot -PassThru -WindowStyle Hidden -RedirectStandardOutput (Join-Path $work 'stdout.log') -RedirectStandardError (Join-Path $work 'stderr.log') + $null = $process.Handle + if (-not $process.WaitForExit(360000)) { + $process.Kill($true) + throw 'Native qualification exceeded its launcher deadline.' + } + if ($null -eq $process.ExitCode) { throw 'Native qualification did not return an exit status.' } + $exitCode = $process.ExitCode + foreach ($file in Get-ChildItem $evidence -Filter qualification.json -Recurse) { + $receipt = Get-Content $file.FullName -Raw | ConvertFrom-Json + if ($receipt.status -eq 'passed') { + $expected = (Get-FileHash (Join-Path $repository 'browse\dist\server-node.mjs') -Algorithm SHA256).Hash.ToLowerInvariant() + if ($receipt.qualifiedBuild.sourceHashes.'browse/dist/server-node.mjs' -ne $expected) { + throw 'The qualified Node bundle differs from the candidate workspace build.' + } + } + } +} finally { + try { + if ($process -and -not $process.HasExited) { $process.Kill($true) } + } finally { + try { + if (Test-Path (Join-Path $work 'stdout.log')) { Get-Content (Join-Path $work 'stdout.log') } + if (Test-Path (Join-Path $work 'stderr.log')) { Get-Content (Join-Path $work 'stderr.log') } + if (Test-Path $evidence) { Get-ChildItem $evidence -Directory -Filter 'cookie-native-qualification-*' | Copy-Item -Destination $OutputRoot -Recurse } + } finally { + try { if ($account) { Remove-LocalUser -SID $account.SID } } + finally { $password.Dispose() } + } + } +} +exit $exitCode diff --git a/.github/scripts/run-dia-native-qualification.ts b/.github/scripts/run-dia-native-qualification.ts new file mode 100644 index 000000000..ee38c9418 --- /dev/null +++ b/.github/scripts/run-dia-native-qualification.ts @@ -0,0 +1,863 @@ +import { createHash, randomBytes, randomUUID } from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import { accessSync, chmodSync, constants, copyFileSync, createReadStream, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { homedir } from 'node:os'; +import path from 'node:path'; +import { assertDiaSocketPath, browserPreflightError, browserStartupCategory, captureUserKeychains, fixtureKeychainRestoreCommands, FRESH_WORK_PREFIX, type FreshAccount, + nativeDiaLaunchOptions, observeBrowserLaunches, observeFixtureKeychain, ownsFreshAccount, parseDirectoryRecord, stopOwnedBrowserGroup, + inspectMachOArchitectures, playwrightModuleLoadFacts, prepareKeychainHome, readFreshAccountConfiguration, runDiaLaunchComparison, runGuiReadiness, + validateQualificationHost, writePrivateReceipt } from './qualify-dia-macos'; +export { FRESH_WORK_PREFIX, ownsFreshAccount, parseDirectoryRecord } from './qualify-dia-macos'; + +const require = createRequire(import.meta.url); +const repository = path.resolve(import.meta.dir, '../..'); + +interface UserDomainObservation { + uid: number; + state: 'present' | 'absent' | 'unavailable'; + hasGuiDomain: boolean; + exitCode: number | null; + stdoutBytes: number; + stderrBytes: number; + structure?: { + complete: boolean; + type: 'user' | 'other' | 'unavailable'; + handleMatchesUid: boolean | null; + creator: 'launchctl' | 'other' | 'unavailable'; + creatorIsProbe: boolean | null; + counts: Record; + sectionNonemptyLines: Record; + }; +} + +export function classifyUserDomain(uid: number, result: { status: number | null; stdout: string; stderr: string; error?: unknown }, probePid?: number): UserDomainObservation { + if (!Number.isSafeInteger(uid) || uid < 20_000 || uid >= 60_000) throw new Error('invalid_fresh_user_domain'); + const text = result.stdout.trimStart(); + const diagnostic = [result.stdout.trim(), result.stderr.trim()].filter(Boolean).join('\n'); + const missing = new RegExp('^(?:Bad request\\.\\s*)?Could not find domain for (?:(?:user (?:uid|user)|uid|user):\\s*' + uid + '|user/' + uid + ')\\.?$'); + const present = !result.error && result.status === 0 + && (text.startsWith('user/' + uid + ' = {') || text.startsWith('com.apple.xpc.launchd.domain.user.' + uid + ' = {')); + const absent = !result.error && Number.isInteger(result.status) && result.status! > 0 && missing.test(diagnostic); + const observation: UserDomainObservation = { uid, state: present ? 'present' : absent ? 'absent' : 'unavailable', + hasGuiDomain: present && (new RegExp('\\bgui/' + uid + '(?:\\b|/)').test(text) || /\bsession\s*=\s*Aqua\b/.test(text) + || new RegExp('com\\.apple\\.xpc\\.launchd\\.user\\.domain\\.' + uid + '\\.\\d+\\.Aqua\\b').test(text)), + exitCode: result.status, stdoutBytes: Buffer.byteLength(result.stdout), stderrBytes: Buffer.byteLength(result.stderr) }; + if (!present || observation.stdoutBytes > 1024 * 1024) return observation; + const lines = text.trimEnd().split('\n'); + const indent = lines.find(line => /^\s+type = \S+\s*$/.test(line))?.match(/^(\s+)/)?.[1]; + const fields = new Map(); + const sections: Record = Object.fromEntries(['services', 'jobs', 'subdomains', 'unmanaged processes', 'endpoints', + 'externally-hosted endpoints', 'pending requests', 'pending attachments'].map(key => [key, null])); + let complete = Boolean(indent) && lines.at(-1) === '}'; + for (let index = 1; indent && index < lines.length - 1; index++) { + const line = lines[index]; + if (!line.trim()) continue; + if (!line.startsWith(indent) || /^\s/.test(line.slice(indent.length))) { complete = false; break; } + const entry = line.slice(indent.length).match(/^([^=]+?) = (.*)$/); + if (!entry || fields.has(entry[1])) { complete = false; break; } + fields.set(entry[1], entry[2]); + if (entry[2] === '{') { + let nonemptyLines = 0; + while (++index < lines.length - 1 && lines[index] !== indent + '}') { + if (lines[index].trim()) nonemptyLines++; + } + if (index >= lines.length - 1) { complete = false; break; } + if (Object.hasOwn(sections, entry[1])) sections[entry[1]] = nonemptyLines; + } else if (entry[2] === '{}' && Object.hasOwn(sections, entry[1])) sections[entry[1]] = 0; + } + const counts = Object.fromEntries(['active count', 'on-demand count', 'service count', 'active service count', 'external activation count', + 'in-progress bootstraps', 'pended requests', 'creator euid'].map(key => { + const value = fields.get(key); + return [key, value && /^\d+$/.test(value) && Number.isSafeInteger(Number(value)) ? Number(value) : null]; + })); + const creatorMatch = fields.get('creator')?.match(/^launchctl(?:\.([1-9]\d*)|\[([1-9]\d*)\])$/); + const creatorPid = creatorMatch?.[1] ?? creatorMatch?.[2]; + observation.structure = { complete, type: fields.has('type') ? fields.get('type') === 'user' ? 'user' : 'other' : 'unavailable', + handleMatchesUid: fields.has('handle') ? fields.get('handle') === String(uid) : null, + creator: creatorPid ? 'launchctl' : fields.has('creator') ? 'other' : 'unavailable', + creatorIsProbe: creatorPid && Number.isSafeInteger(probePid) && probePid! > 0 ? Number(creatorPid) === probePid : null, + counts, sectionNonemptyLines: sections }; + return observation; +} + +export function inspectUserDomain(uid: number, deadline: number, env: Record, spawn: typeof spawnSync = spawnSync): UserDomainObservation { + if (!Number.isSafeInteger(uid) || uid < 20_000 || uid >= 60_000) throw new Error('invalid_fresh_user_domain'); + if (!Number.isFinite(deadline)) throw new Error('fresh_launcher_deadline'); + const timeout = Math.floor(Math.min(3_000, deadline - performance.now())); + if (!Number.isFinite(timeout) || timeout < 1) throw new Error('fresh_launcher_deadline'); + const result = spawn('/usr/bin/sudo', ['-n', '/bin/sh', '-c', 'printf "GSTACK_DIA_DOMAIN_PROBE_PID=%s\\n" "$$"; exec /bin/launchctl print "$1"', + 'gstack-dia-domain-probe', 'user/' + uid], { env, encoding: 'utf8', timeout, maxBuffer: 1024 * 1024 }); + const stdout = typeof result.stdout === 'string' ? result.stdout : ''; + const stderr = typeof result.stderr === 'string' ? result.stderr : ''; + const prefix = stdout.match(/^GSTACK_DIA_DOMAIN_PROBE_PID=([1-9]\d*)\n/); + const pid = prefix ? Number(prefix[1]) : undefined; + return classifyUserDomain(uid, { ...result, stdout: prefix ? stdout.slice(prefix[0].length) : stdout, stderr, + error: result.error || (!Number.isSafeInteger(pid) ? new Error('domain_probe_pid_unavailable') : undefined) }, pid); +} + +export function classifyParentDomain(uid: number, result: { status: number | null; stdout: string; stderr: string; error?: unknown }) { + if (!Number.isSafeInteger(uid) || uid < 20_000 || uid >= 60_000) throw new Error('invalid_fresh_user_domain'); + const observation = { uid, state: 'unavailable' as 'present' | 'absent' | 'unavailable', parseStage: 'command_failure', + subdomainCount: 0, matchingUserDomains: 0, matchingGuiDomains: 0, unrecognizedEntries: 0, duplicateEntries: 0, + exitCode: result.status, stdoutBytes: Buffer.byteLength(result.stdout), stderrBytes: Buffer.byteLength(result.stderr) }; + if (result.error || result.status !== 0) return observation; + observation.parseStage = 'oversized'; + if (observation.stdoutBytes > 1024 * 1024) return observation; + observation.parseStage = 'unexpected_parent'; + const lines = result.stdout.trim().split('\n'); + if (!['system = {', 'com.apple.xpc.launchd.domain.system = {'].includes(lines[0]) || lines.at(-1) !== '}') return observation; + const indent = lines.find(line => /^\s+type = system$/.test(line))?.match(/^(\s+)/)?.[1]; + if (!indent) return observation; + const fields = new Set(); + let subdomains: string[] | undefined; + observation.parseStage = 'malformed_structure'; + for (let index = 1; index < lines.length - 1; index++) { + const line = lines[index]; + if (!line.trim()) continue; + if (!line.startsWith(indent) || /^\s/.test(line.slice(indent.length))) return observation; + const entry = line.slice(indent.length).match(/^([^=]+?) = (.*)$/); + if (!entry || fields.has(entry[1])) return observation; + fields.add(entry[1]); + if (entry[2] === '{') { + const start = index + 1; + while (++index < lines.length - 1 && lines[index] !== indent + '}') {} + if (index >= lines.length - 1) return observation; + if (entry[1] === 'subdomains') subdomains = lines.slice(start, index).map(line => line.trim()).filter(Boolean); + } else if (entry[1] === 'subdomains' && entry[2] === '{}') subdomains = []; + } + observation.parseStage = 'missing_subdomains'; + if (!subdomains) return observation; + const seen = new Set(); + for (const entry of subdomains) { + observation.subdomainCount++; + const user = entry.match(/^(?:user\/|com\.apple\.xpc\.launchd\.domain\.user\.)(\d+)$/); + const gui = entry.match(/^(?:gui\/(\d+)|com\.apple\.xpc\.launchd\.user\.domain\.(\d+)\.\d+\.Aqua)$/); + const listedUid = user?.[1] ?? gui?.[1] ?? gui?.[2]; + const process = entry.match(/^(?:pid\/(\d+)|com\.apple\.xpc\.launchd\.domain\.pid\.[^{}\r\n]+\.(\d+))$/); + const session = entry.match(/^(?:session\/(\d+)|com\.apple\.xpc\.launchd\.domain\.session\.(\d+))$/); + const identity = user ? 'user/' + listedUid : gui ? 'gui/' + listedUid : process ? 'pid/' + (process[1] ?? process[2]) + : session ? 'session/' + (session[1] ?? session[2]) : entry; + if (seen.has(identity)) observation.duplicateEntries++; + seen.add(identity); + if (listedUid && (!Number.isSafeInteger(Number(listedUid)) || String(Number(listedUid)) !== listedUid)) observation.unrecognizedEntries++; + else if (user) observation.matchingUserDomains += Number(user[1]) === uid ? 1 : 0; + else if (gui) observation.matchingGuiDomains += Number(gui[1] ?? gui[2]) === uid ? 1 : 0; + else if (!/^(?:(?:pid|session|login)\/\d+|com\.apple\.xpc\.launchd\.domain\.(?:pid\.[^{}\r\n]+\.\d+|session\.\d+))$/.test(entry)) observation.unrecognizedEntries++; + } + observation.parseStage = observation.unrecognizedEntries ? 'unrecognized_subdomain' : observation.duplicateEntries ? 'duplicate_subdomain' : 'parsed'; + if (!observation.unrecognizedEntries && !observation.duplicateEntries) observation.state = observation.matchingUserDomains || observation.matchingGuiDomains ? 'present' : 'absent'; + return observation; +} + +type ParentDomainObservation = ReturnType; + +export function passiveUserDomainState(observation: ParentDomainObservation | undefined, uid: number): 'absent' | 'present' | 'unavailable' { + if (!observation || observation.uid !== uid || observation.exitCode !== 0 || observation.parseStage !== 'parsed' + || observation.duplicateEntries !== 0 || observation.unrecognizedEntries !== 0 || observation.matchingGuiDomains !== 0) return 'unavailable'; + if (observation.state === 'absent' && observation.matchingUserDomains === 0) return 'absent'; + if (observation.state === 'present' && observation.matchingUserDomains === 1) return 'present'; + return 'unavailable'; +} + +export function inspectParentDomain(uid: number, deadline: number, env: Record, spawn: typeof spawnSync = spawnSync) { + if (!Number.isSafeInteger(uid) || uid < 20_000 || uid >= 60_000) throw new Error('invalid_fresh_user_domain'); + const timeout = Math.floor(Math.min(3_000, deadline - performance.now())); + if (!Number.isFinite(deadline) || !Number.isFinite(timeout) || timeout < 1) throw new Error('fresh_launcher_deadline'); + const result = spawn('/usr/bin/sudo', ['-n', '/bin/launchctl', 'print', 'system'], { env, encoding: 'utf8', timeout, maxBuffer: 1024 * 1024 }); + return classifyParentDomain(uid, { ...result, stdout: typeof result.stdout === 'string' ? result.stdout : '', + stderr: typeof result.stderr === 'string' ? result.stderr : '' }); +} + +export const ARCHIVE_CHECK = `import json, posixpath, sys, tarfile, unicodedata +try: + with tarfile.open(sys.argv[1], 'r:') as archive: + members = archive.getmembers() + if len(members) > 200000 or sum(item.size for item in members) > 2 * 1024**3: + raise ValueError() + seen, links = set(), set() + for item in members: + name = item.name.rstrip('/') + if not name or name.startswith('/') or '\\\\' in name or any(ord(char) < 32 for char in name): + raise ValueError() + canonical = unicodedata.normalize('NFC', name).casefold() + if '..' in name.split('/') or posixpath.normpath(name) != name or canonical in seen: + raise ValueError() + if not (item.isfile() or item.isdir() or item.issym()): + raise ValueError() + seen.add(canonical) + if item.issym(): + target = posixpath.normpath(posixpath.join(posixpath.dirname(name), item.linkname)) + if item.linkname.startswith('/') or '\\\\' in item.linkname or '..' in item.linkname.split('/') or target == '..' or target.startswith('../'): + raise ValueError() + links.add(canonical) + for item in members: + parts = unicodedata.normalize('NFC', item.name.rstrip('/')).casefold().split('/') + if any('/'.join(parts[:index]) in links for index in range(1, len(parts))): + raise ValueError() + print(json.dumps({'valid': True, 'members': len(members)})) +except Exception: + print(json.dumps({'valid': False, 'reason': 'unsafe_source_archive'})) + sys.exit(2) +`; + +export const PRIVATE_RECEIPT_READ = `import json, os, stat, sys +fds = [] +try: + file, uid, root = sys.argv[1], int(sys.argv[2]), os.path.realpath(sys.argv[3]) + if root != sys.argv[3] or not os.path.isabs(file) or os.path.normpath(file) != file or os.path.commonpath([file, root]) != root: + raise ValueError() + parts = os.path.relpath(file, root).split(os.sep) + if any(part in ('', '.', '..') for part in parts): + raise ValueError() + fds.append(os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)) + for part in parts[:-1]: + fds.append(os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fds[-1])) + parent = os.fstat(fds[-1]) + if parent.st_uid != uid or parent.st_mode & 0o022: + raise ValueError() + fds.append(os.open(parts[-1], os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=fds[-1])) + fd = fds[-1] + info = os.fstat(fd) + if not stat.S_ISREG(info.st_mode) or info.st_uid != uid or info.st_mode & 0o022 or info.st_size > 1024**2: + raise ValueError() + data = os.read(fd, 1024**2 + 1) + after = os.fstat(fd) + if len(data) != info.st_size or (info.st_size, info.st_mtime_ns, info.st_ctime_ns) != (after.st_size, after.st_mtime_ns, after.st_ctime_ns): + raise ValueError() + value = json.loads(data) + if not isinstance(value, dict): + raise ValueError() + print(json.dumps(value)) +except Exception: + sys.exit(2) +finally: + for fd in reversed(fds): + os.close(fd) +`; + +export function uidProcessFacts(output: string, uid: number) { + const known = ['bun', 'security', 'osascript', 'launchd', 'cfprefsd', 'trustd', 'distnoted', 'lsd', 'tccd', 'securityd', 'secd', 'usernoted', + 'UserEventAgent', 'pkd', 'nsurlsessiond', 'containermanagerd', 'Google Chrome for Testing', 'Google Chrome', 'Chromium', + 'Chromium Helper', 'Dia', 'Dia Helper', 'chrome', 'chrome_crashpad_handler']; + const aliases = new Map(known.flatMap(name => [name, name.slice(0, 15), name.slice(0, 16)].map(alias => [alias, name] as const))); + const processes: Array<{ pid: number; ppid: number; state: string; basename: string }> = []; + for (const line of output.split('\n').filter(line => line.trim())) { + const match = line.match(/^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.+?)\s*$/); + if (!match) throw new Error('invalid_uid_process_snapshot'); + if (Number(match[1]) !== uid) continue; + const pid = Number(match[2]); + const ppid = Number(match[3]); + if (!Number.isSafeInteger(pid) || pid < 1 || !Number.isSafeInteger(ppid) || ppid < 0) throw new Error('invalid_uid_process_snapshot'); + const state = ['I', 'R', 'S', 'T', 'U', 'Z', 'D', 'X'].includes(match[4][0]) ? match[4][0] : 'other'; + processes.push({ pid, ppid, state, basename: aliases.get(match[5]) ?? 'other' }); + } + return { available: true, count: processes.length, zombies: processes.filter(process => process.state === 'Z').length, + live: processes.filter(process => process.state !== 'Z').length, truncated: processes.length > 64, processes: processes.slice(0, 64) }; +} + +export function inspectUidProcesses(uid: number, deadline: number, env: Record, spawn: typeof spawnSync = spawnSync) { + if (!Number.isSafeInteger(uid) || uid < 20_000 || uid >= 60_000) throw new Error('invalid_fresh_user_domain'); + try { + const timeout = Math.floor(Math.min(2_000, deadline - performance.now())); + if (!Number.isFinite(deadline) || !Number.isFinite(timeout) || timeout < 1) throw new Error('fresh_launcher_deadline'); + const result = spawn('/bin/ps', ['-axo', 'uid=,pid=,ppid=,state=,ucomm='], { env, encoding: 'utf8', timeout, maxBuffer: 128 * 1024 }); + if (result.error || result.status !== 0 || typeof result.stdout !== 'string' || !result.stdout.trim()) throw new Error('uid_process_snapshot_failed'); + return uidProcessFacts(result.stdout, uid); + } catch { return { available: false }; } +} + +export function freshQualificationPassed(workerExit: number | undefined, backgroundStatus: unknown, qualificationStatus: unknown, cleanup: Record): boolean { + return workerExit === 0 && backgroundStatus === 'passed' && qualificationStatus === 'passed' + && ['serviceStopped', 'userDomainStopped', 'userProcessesStopped', 'accountRemoved', 'groupRemoved', 'stagingRemoved'].every(key => cleanup[key] === true) + && Object.values(cleanup).every(value => value === true); +} + +export function parseDirectoryIds(output: string): Set { + const ids = new Set(); + for (const line of output.split('\n').filter(line => line.trim())) { + const value = line.match(/^\S.*?\s+(-?\d+)\s*$/)?.[1]; + if (!value || !Number.isSafeInteger(Number(value))) throw new Error('invalid_directory_id_list'); + ids.add(Number(value)); + } + if (!ids.size) throw new Error('empty_directory_id_list'); + return ids; +} + +export function ownedUserDomainTarget(record: Record, account: Pick, + beforeCreation: ParentDomainObservation | undefined, current: ParentDomainObservation, currentUid = process.getuid?.()): string | null { + const currentState = passiveUserDomainState(current, account.uid); + if (!Number.isSafeInteger(account.uid) || account.uid < 20_000 || account.uid >= 60_000 || account.uid === currentUid + || !Number.isSafeInteger(currentUid) || !ownsFreshAccount(record, account) + || passiveUserDomainState(beforeCreation, account.uid) !== 'absent' || currentState === 'unavailable') { + throw new Error('fresh_user_domain_ownership_unconfirmed'); + } + return currentState === 'present' ? 'user/' + account.uid : null; +} + +export function freshLaunchDefinition(account: FreshAccount) { + return { + Label: account.label, UserName: account.account, GroupName: account.account, SessionCreate: true, + RunAtLoad: true, KeepAlive: false, ExitTimeOut: 5, Umask: 63, + WorkingDirectory: account.snapshot, + ProgramArguments: [account.bun, '--no-env-file', '--no-install', '--no-macros', '--config=/dev/null', + path.join(account.snapshot, '.github/scripts/run-dia-native-qualification.ts'), '--fresh-worker', account.configFile], + EnvironmentVariables: account.environment, + StandardOutPath: '/dev/null', StandardErrorPath: '/dev/null', + }; +} + +export function ownsLaunchService(state: string, account: Pick): boolean { + return state.trimStart().startsWith('system/' + account.label + ' = {') + && state.match(/^\s*program = (.+)$/m)?.[1].trim() === account.bun + && state.match(/^\s*username = (.+)$/m)?.[1].trim() === account.account + && state.match(/^\s*group = (.+)$/m)?.[1].trim() === account.account; +} + +async function digest(file: string) { + const hash = createHash('sha256'); + for await (const chunk of createReadStream(file)) hash.update(chunk); + return hash.digest('hex'); +} + +function safeCommand(command: string, args: string[], timeout: number, env: NodeJS.ProcessEnv, cwd?: string) { + timeout = Math.floor(timeout); + if (!Number.isFinite(timeout) || timeout < 1) throw new Error('native_operation_timed_out'); + const result = spawnSync(command, args, { env, cwd, encoding: 'utf8', timeout, maxBuffer: 1024 * 1024 }); + if (result.error || result.status !== 0) { + const elevated = command === '/usr/bin/sudo'; + const errorCode = (result.error as NodeJS.ErrnoException | undefined)?.code; + throw Object.assign(new Error('native_command_failed'), { diagnostic: { + command: path.basename(elevated ? args[1] : command), operation: args[elevated ? 2 : 0], exitCode: result.status, + stdoutBytes: Buffer.byteLength(result.stdout || ''), stderrBytes: Buffer.byteLength(result.stderr || ''), + spawnError: result.error ? (['ENOENT', 'EACCES', 'EPERM', 'ETIMEDOUT'].includes(errorCode || '') ? errorCode : 'spawn_failed') : undefined, + } }); + } + return result.stdout.trim(); +} + +async function limit(promise: Promise, timeout: number): Promise { + let timer: ReturnType; + try { + return await Promise.race([promise, new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('native_operation_timed_out')), timeout); + })]); + } finally { clearTimeout(timer!); } +} + +async function freshWorker(configFile: string) { + validateQualificationHost(process.env); + const account = readFreshAccountConfiguration(configFile); + const preflightFile = path.join(account.temporary, 'dia-background-preflight.json'); + const seed = lstatSync(preflightFile); + if (!seed.isFile() || seed.uid !== process.getuid?.() || realpathSync(preflightFile) !== preflightFile) throw new Error('unsafe_preflight_receipt'); + const receipt: Record = { status: 'incomplete', reason: 'fresh_identity_preflight', nativeCasesRun: false, + preflight: { registeredIdentity: false, foundationHome: false, keychain: false, headlessChromium: false }, + cleanup: { probeBrowsersStopped: false, probeKeychainRestored: false }, sessionCreate: true }; + const env = account.environment; + let cleaning = false; + const run = (command: string, args: string[], timeout = 10_000) => { + try { return safeCommand(command, args, timeout, env, account.snapshot); } + catch (error) { + const diagnostic = (error as { diagnostic?: object }).diagnostic ?? { command: path.basename(command), operation: args[0] }; + if (cleaning) (receipt.cleanupCommandFailures ??= []).push(diagnostic); + else receipt.initialCommandFailure ??= diagnostic; + throw new Error('native_command_failed'); + } + }; + let context: any; + let observer: ReturnType | undefined; + let comparisonControl: Record | undefined; + let launchAttempted = false; + let keychainCreated = false; + let keychainChanged = false; + let snapshot: ReturnType | undefined; + const probe = path.join(account.temporary, 'probe'); + const keychain = path.join(probe, 'probe.keychain-db'); + try { + if (!/^[a-z][a-z0-9]{8,24}$/.test(account.account) || process.getuid?.() !== account.uid || process.geteuid?.() !== account.uid + || process.getgid?.() !== account.gid || realpathSync(homedir()) !== account.home || realpathSync(account.work) !== account.work) throw new Error('fresh_identity_mismatch'); + for (const directory of [account.home, account.temporary, account.snapshot, path.dirname(account.bun), + ...(account.guiReadiness ? [] : [path.join(account.snapshot, 'node_modules')])]) { + if (!directory.startsWith(account.work + path.sep) || realpathSync(directory) !== directory || lstatSync(directory).uid !== account.uid) throw new Error('fresh_directory_ownership_mismatch'); + } + const record = parseDirectoryRecord(run('/usr/bin/dscl', ['.', '-read', '/Users/' + account.account, 'UniqueID', 'PrimaryGroupID', 'NFSHomeDirectory', 'GeneratedUID'])); + if (!ownsFreshAccount(record, account)) throw new Error('fresh_registered_identity_mismatch'); + receipt.preflight.registeredIdentity = true; + if (account.guiReadiness) { + if (await digest(account.bun) !== account.bunSha256) throw new Error('staged_executable_changed'); + receipt.reason = 'gui_readiness_only'; + receipt.operations = { dependencyInstall: false, browserLaunch: false, keychainAccess: false, diaDownload: false }; + receipt.guiReadiness = await runGuiReadiness(account.guiReadiness.executable, account.guiReadiness.executableSha256, + path.join(account.snapshot, '.github/scripts/dia-gui-readiness.c'), account.guiReadiness.sourceSha256, false, env); + return receipt.guiReadiness.available && receipt.guiReadiness.observation.identity.effectiveUidMatches + && receipt.guiReadiness.observation.identity.homeMatchesRegistered ? 0 : 2; + } + if (!account.destinationExecutable || !account.destinationSha256) throw new Error('browser_qualification_authority_required'); + const foundationHome = run('/usr/bin/osascript', ['-l', 'JavaScript', '-e', 'ObjC.import("Foundation"); $.NSHomeDirectory().js']); + if (realpathSync(foundationHome) !== account.home) throw new Error('foundation_home_mismatch'); + receipt.preflight.foundationHome = true; + receipt.keychainHome = prepareKeychainHome(account.home, account.uid); + receipt.dependencyDirectoryPresentBeforeInstall = true; + for (const executable of [account.bun, account.destinationExecutable]) { + accessSync(executable, constants.X_OK); + if (!path.isAbsolute(executable) || realpathSync(executable) !== executable || !executable.startsWith(account.work + path.sep)) throw new Error('staged_executable_escape'); + } + if (await digest(account.bun) !== account.bunSha256 || await digest(account.destinationExecutable) !== account.destinationSha256) throw new Error('staged_executable_changed'); + receipt.reason = 'fresh_dependency_install'; + run(account.bun, ['install', '--frozen-lockfile', '--ignore-scripts'], 180_000); + if (Bun.version !== '1.4.0' || require(path.join(account.snapshot, 'node_modules/playwright/package.json')).version !== '1.62.1') throw new Error('pinned_runtime_mismatch'); + mkdirSync(probe, { mode: 0o700 }); + receipt.reason = 'background_keychain_preflight'; + snapshot = captureUserKeychains(env, [account.home, account.temporary]); + const password = randomBytes(24).toString('hex'); + const value = randomBytes(24).toString('hex'); + keychainChanged = true; + run('/usr/bin/security', ['create-keychain', '-p', password, keychain]); + keychainCreated = true; + run('/usr/bin/security', ['set-keychain-settings', '-lut', '300', keychain]); + run('/usr/bin/security', ['unlock-keychain', '-p', password, keychain]); + run('/usr/bin/security', ['list-keychains', '-d', 'user', '-s', keychain]); + run('/usr/bin/security', ['default-keychain', '-d', 'user', '-s', keychain]); + run('/usr/bin/security', ['add-generic-password', '-s', 'Gstack Native Probe', '-a', 'fixture', '-w', value, + '-T', '/usr/bin/security', keychain]); + const observed = observeFixtureKeychain(env, [account.home, account.temporary], keychain, value); + receipt.keychainObservations = { ...observed, preferencesFileExists: existsSync(path.join(account.home, 'Library/Preferences/com.apple.security.plist')) }; + if (!observed.searchPathMatches || !observed.defaultPathMatches || !observed.explicitReadMatches) throw new Error('native_keychain_probe_failed'); + receipt.preflight.keychain = true; + if (account.launchComparison) { + receipt.reason = 'comparison_chromium_control'; + launchAttempted = true; + comparisonControl = await runDiaLaunchComparison(account, 'control'); + receipt.comparisonControl = comparisonControl; + if (!comparisonControl.ready || !comparisonControl.cleanup?.confirmed) throw new Error('comparison_control_failed'); + receipt.preflight.headlessChromium = true; + receipt.status = 'passed'; + receipt.reason = 'background_session_ready'; + } else { + receipt.browserPreflight = { stage: 'runtime_import', launchReturned: false, ownedRootCount: 0, + startupPageCount: null, startupPageCategories: [], pageSelected: false, contentSet: false, readbackMatched: false }; + receipt.reason = 'background_browser_runtime_import'; + const { chromium } = await import('playwright'); + const profile = path.join(probe, 'chromium'); + observer = observeBrowserLaunches(new Map([[account.destinationExecutable, profile]])); + launchAttempted = true; + receipt.browserPreflight.stage = 'launch'; + receipt.reason = 'background_browser_launch'; + context = await limit(chromium.launchPersistentContext(profile, nativeDiaLaunchOptions(account.destinationExecutable, env)), 40_000); + receipt.browserPreflight.launchReturned = true; + receipt.browserPreflight.stage = 'ownership'; + receipt.reason = 'background_browser_ownership'; + receipt.browserPreflight.ownedRootCount = observer.children.length; + if (observer.children.length !== 1) throw new Error('background_browser_ownership_failed'); + receipt.browserPreflight.stage = 'startup_pages'; + receipt.reason = 'background_browser_startup_pages'; + const pages = context.pages(); + const startupUrls = pages.map((page: any) => page.url()); + receipt.browserPreflight.startupPageCount = pages.length; + receipt.browserPreflight.startupPageCategories = startupUrls.map(browserStartupCategory); + if (startupUrls.some((url: string) => url !== 'about:blank')) throw new Error('background_browser_startup_page_rejected'); + receipt.browserPreflight.stage = 'page_selection'; + receipt.reason = 'background_browser_page_selection'; + const page = pages[0] ?? await limit(context.newPage(), 5_000); + receipt.browserPreflight.pageSelected = true; + receipt.browserPreflight.stage = 'content_set'; + receipt.reason = 'background_browser_content_set'; + await limit(page.setContent('
background browser ready
'), 5_000); + receipt.browserPreflight.contentSet = true; + receipt.browserPreflight.stage = 'readback'; + receipt.reason = 'background_browser_readback'; + receipt.browserPreflight.readbackMatched = await limit(page.locator('#fixture').innerText(), 5_000) === 'background browser ready'; + if (!receipt.browserPreflight.readbackMatched) throw new Error('background_browser_render_failed'); + receipt.browserPreflight.stage = 'completed'; + receipt.preflight.headlessChromium = true; + receipt.status = 'passed'; + receipt.reason = 'background_session_ready'; + } + } catch (error) { + receipt.status = 'incomplete'; + if (error instanceof Error && ['fresh_identity_mismatch', 'fresh_directory_ownership_mismatch', 'fresh_registered_identity_mismatch', + 'foundation_home_mismatch', 'staged_executable_escape', 'staged_executable_changed', 'pinned_runtime_mismatch', + 'user_keychain_search_unavailable', 'user_default_keychain_unavailable', 'keychain_outside_owned_home_refused', + 'keychain_home_unsafe', 'fixture_keychain_not_owned', 'native_keychain_probe_failed', 'comparison_control_failed', + 'gui_readiness_inputs_changed', 'gui_readiness_budget_exhausted'].includes(error.message)) receipt.blocker = error.message; + if (receipt.browserPreflight) { + receipt.blocker = browserPreflightError(error); + receipt.browserPreflight.error = receipt.blocker; + if (receipt.browserPreflight.stage === 'runtime_import') receipt.browserPreflight.moduleLoad = playwrightModuleLoadFacts(account.snapshot, error); + receipt.browserPreflight.ownedRootCount = observer?.children.length ?? 0; + receipt.browserPreflight.launchAttempts = observer?.attempts ?? []; + } + receipt.initialFailure = { stage: receipt.reason, blocker: receipt.blocker ?? 'native_preflight_failed' }; + } finally { + cleaning = true; + if (receipt.browserPreflight) { + receipt.browserPreflight.launchAttempts ??= observer?.attempts ?? []; + receipt.browserPreflight.rootStatesBeforeCleanup = (observer?.children ?? []).map(child => ({ + pid: child.pid, exitCode: Number.isInteger(child.process.exitCode) ? child.process.exitCode : null, + signal: child.process.signalCode == null ? null + : ['SIGABRT', 'SIGTRAP', 'SIGSEGV', 'SIGBUS', 'SIGKILL', 'SIGTERM', 'SIGILL'].includes(child.process.signalCode) ? child.process.signalCode : 'other', + })); + } + observer?.stop(); + if (context) await limit(context.close().catch(() => {}), 5_000).catch(() => {}); + let stopped = !launchAttempted || (account.launchComparison ? comparisonControl?.cleanup?.confirmed === true : observer?.children.length === 1); + for (const child of observer?.children ?? []) { + const until = performance.now() + 5_000; + try { + await stopOwnedBrowserGroup(child, until, {}); + } catch { stopped = false; } + } + receipt.cleanup.probeBrowsersStopped = stopped; + if (stopped) { + try { + if (keychainChanged && snapshot) { + let restored = true; + for (const args of fixtureKeychainRestoreCommands(snapshot, keychain, keychainCreated)) { + try { run('/usr/bin/security', args); } catch { restored = false; } + } + if (!restored || JSON.stringify(captureUserKeychains(env, [account.home, account.temporary])) !== JSON.stringify(snapshot)) throw new Error('probe_keychain_restore_failed'); + } + receipt.cleanup.probeKeychainRestored = true; + if (existsSync(probe) && realpathSync(probe) === probe) rmSync(probe, { recursive: true, force: true }); + } catch { receipt.cleanupFailure = 'probe_keychain_restore_failed'; } + } + if (!receipt.cleanup.probeBrowsersStopped || !receipt.cleanup.probeKeychainRestored) { receipt.status = 'incomplete'; receipt.reason = 'background_probe_cleanup_incomplete'; } + writePrivateReceipt(preflightFile, receipt, true); + } + if (receipt.status !== 'passed') return 2; + const result = spawnSync(account.bun, ['--no-env-file', '--no-install', '--no-macros', '--config=/dev/null', + path.join(account.snapshot, '.github/scripts/qualify-dia-macos.ts'), '--fresh-account', account.configFile], { + cwd: account.snapshot, env, stdio: 'ignore', timeout: 660_000, killSignal: 'SIGKILL', + }); + return !result.error && result.status === 0 ? 0 : 2; +} + +export async function runFreshAccountQualification(comparisonRuntime?: 'bun' | 'node', guiReadinessOnly = false) { + validateQualificationHost(process.env); + if (comparisonRuntime !== undefined && !['bun', 'node'].includes(comparisonRuntime)) throw new Error('invalid_comparison_runtime'); + if (guiReadinessOnly && comparisonRuntime !== undefined) throw new Error('conflicting_diagnostic_modes'); + if (process.getuid?.() === 0 || Bun.version !== '1.4.0') throw new Error('run_as_unprivileged_pinned_ci_runner'); + const outputRoot = realpathSync(process.env.RUNNER_TEMP!); + const output = path.join(outputRoot, 'dia-native-qualification.json'); + if (existsSync(output)) throw new Error('fresh_output_required'); + const work = realpathSync(mkdtempSync(FRESH_WORK_PREFIX)); + const home = path.join(work, 'home'); + const temporary = path.join(work, 'tmp'); + const snapshot = path.join(work, 'repo'); + const bin = path.join(work, 'bin'); + const browserDirectory = path.join(work, 'browser'); + const archive = path.join(work, 'source.tar'); + const suffix = randomBytes(6).toString('hex'); + const accountName = 'gsdia' + suffix; + const label = 'ai.gstack.dia.' + suffix; + const deadline = performance.now() + 16 * 60_000; + const hostEnv = { HOME: homedir(), PATH: '/usr/bin:/bin:/usr/sbin:/sbin', LANG: 'en_US.UTF-8' }; + let cleanupDeadline = 0; + const run = (command: string, args: string[], timeout = 10_000) => { + const remaining = (cleanupDeadline || deadline) - performance.now(); + if (remaining <= 0) throw new Error('fresh_launcher_deadline'); + return safeCommand(command, args, Math.min(timeout, remaining), hostEnv); + }; + const rootCommand = (command: string, args: string[], timeout = 10_000) => run('/usr/bin/sudo', ['-n', command, ...args], timeout); + let account: FreshAccount | undefined; + let userCreated = false; + let groupCreated = false; + let serviceAttempted = false; + let domainBeforeCreation: ParentDomainObservation | undefined; + let stage = 'fresh_launcher_preflight'; + const receipt: Record = { status: 'incomplete', reason: stage, runId: process.env.GITHUB_RUN_ID, runAttempt: process.env.GITHUB_RUN_ATTEMPT, + counts: { pass: 0, fail: 0, skip: 0 }, launcher: { sessionCreate: true, aquaLogin: false }, + launcherCleanup: { serviceStopped: false, userDomainStopped: false, userProcessesStopped: false, accountRemoved: false, groupRemoved: false, stagingRemoved: false } }; + let workerExit: number | undefined; + let pythonExecutable: string | undefined; + const probeParentDomain = (uid: number) => inspectParentDomain(uid, cleanupDeadline || deadline, hostEnv); + const snapshotProcesses = (phase: string, uid: number) => { + const facts = inspectUidProcesses(uid, cleanupDeadline || deadline, hostEnv); + (receipt.uidProcessSnapshots ??= {})[phase] = facts; + return facts; + }; + try { + rootCommand('/usr/bin/true', []); + for (const directory of [home, temporary, snapshot, bin, ...(guiReadinessOnly ? [] : [browserDirectory])]) mkdirSync(directory, { mode: 0o700 }); + assertDiaSocketPath(path.join(home, 'Library/Application Support/Dia/User Data')); + writeFileSync(path.join(temporary, 'dia-background-preflight.json'), JSON.stringify({ status: 'incomplete', reason: 'fresh_worker_not_started', + nativeCasesRun: false, preflight: { registeredIdentity: false, foundationHome: false, keychain: false, headlessChromium: false } }) + '\n', { mode: 0o600, flag: 'wx' }); + const sourceRevision = run('/usr/bin/git', ['-C', repository, 'rev-parse', 'HEAD']); + if (!/^[0-9a-f]{40}$/.test(sourceRevision)) throw new Error('invalid_source_revision'); + const python = Bun.which('python3'); + if (!python) throw new Error('archive_validator_unavailable'); + pythonExecutable = realpathSync(python); + stage = 'source_archive_preflight'; + run('/usr/bin/git', ['-C', repository, 'archive', '--format=tar', '--output', archive, 'HEAD'], 30_000); + const archiveResult = JSON.parse(run(pythonExecutable, ['-I', '-c', ARCHIVE_CHECK, archive], 30_000)); + if (archiveResult.valid !== true) throw new Error('unsafe_source_archive'); + run('/usr/bin/tar', ['--no-same-owner', '--no-same-permissions', '-xf', archive, '-C', snapshot], 30_000); + if (!guiReadinessOnly) mkdirSync(path.join(snapshot, 'node_modules'), { mode: 0o700 }); + const sourceBun = realpathSync(process.execPath); + const bun = path.join(bin, 'bun'); + copyFileSync(sourceBun, bun); + chmodSync(bun, 0o755); + let guiReadiness: FreshAccount['guiReadiness']; + if (guiReadinessOnly) { + stage = 'gui_readiness_build'; + const source = path.join(snapshot, '.github/scripts/dia-gui-readiness.c'); + const executable = path.join(bin, 'gui-readiness'); + const sourceSha256 = await digest(source); + run('/usr/bin/xcrun', ['clang', '-arch', 'arm64', '-std=c11', '-O2', '-Wall', '-Wextra', source, + '-framework', 'Security', '-framework', 'ApplicationServices', '-o', executable], 30_000); + if (await digest(source) !== sourceSha256 || !inspectMachOArchitectures(executable).architectures.includes('arm64')) throw new Error('gui_readiness_build_unconfirmed'); + chmodSync(executable, 0o755); + guiReadiness = { mode: 'gui-readiness-only', executable, sourceSha256, executableSha256: await digest(executable) }; + stage = 'gui_readiness_original_runner'; + receipt.guiReadiness = { mode: 'gui-readiness-only', qualificationCredit: false, + helper: { sourceSha256, executableSha256: guiReadiness.executableSha256 }, + originalRunner: await runGuiReadiness(executable, guiReadiness.executableSha256, source, sourceSha256, true, hostEnv, + Math.min(5000, deadline - performance.now())) }; + } + let launchComparison: FreshAccount['launchComparison']; + if (comparisonRuntime) { + let executable = bun; + if (comparisonRuntime === 'node') { + const sourceNode = Bun.which('node'); + if (!sourceNode) throw new Error('pinned_node_unavailable'); + const resolvedNode = realpathSync(sourceNode); + const node = JSON.parse(run(resolvedNode, ['-p', 'JSON.stringify({version:process.versions.node,arch:process.arch,os:process.platform,bun:Boolean(process.versions.bun)})'])); + if (node.version !== '24.18.0' || node.arch !== 'arm64' || node.os !== 'darwin' || node.bun) throw new Error('pinned_node_required'); + executable = path.join(bin, 'node'); + copyFileSync(resolvedNode, executable); + chmodSync(executable, 0o755); + } + launchComparison = { mode: 'launch-only', runtime: comparisonRuntime, executable, executableSha256: await digest(executable), + driverSha256: await digest(path.join(snapshot, '.github/scripts/dia-launch-driver.mjs')), + helpersSha256: await digest(path.join(snapshot, '.github/scripts/qualify-dia-macos.ts')) }; + } + let destination: Pick = {}; + if (!guiReadinessOnly) { + const { chromium } = await import('playwright'); + if (require('playwright/package.json').version !== '1.62.1') throw new Error('pinned_playwright_required'); + const originalExecutable = realpathSync(chromium.executablePath()); + let bundle = path.dirname(originalExecutable); + while (!bundle.endsWith('.app')) { + const parent = path.dirname(bundle); + if (parent === bundle) throw new Error('destination_app_bundle_missing'); + bundle = parent; + } + const copiedBundle = path.join(browserDirectory, path.basename(bundle)); + run('/usr/bin/ditto', ['--rsrc', '--extattr', bundle, copiedBundle], 45_000); + const destinationExecutable = realpathSync(path.join(copiedBundle, path.relative(bundle, originalExecutable))); + if (!destinationExecutable.startsWith(browserDirectory + path.sep)) throw new Error('destination_bundle_escape'); + destination = { destinationExecutable, destinationSha256: await digest(originalExecutable) }; + } + const userIds = parseDirectoryIds(run('/usr/bin/dscl', ['.', '-list', '/Users', 'UniqueID'])); + const groupIds = parseDirectoryIds(run('/usr/bin/dscl', ['.', '-list', '/Groups', 'PrimaryGroupID'])); + const used = new Set([...userIds, ...groupIds]); + for (const uid of run('/bin/ps', ['-axo', 'uid=']).split(/\s+/).filter(Boolean)) used.add(Number(uid)); + let uid = 20_000; + while (used.has(uid) && uid < 60_000) uid++; + if (uid >= 60_000) throw new Error('fresh_uid_unavailable'); + stage = 'fresh_user_domain_preflight'; + domainBeforeCreation = probeParentDomain(uid); + receipt.userDomain = { beforeCreation: domainBeforeCreation }; + receipt.candidateIdentity = { uid, accountUidAbsent: !userIds.has(uid), groupUidAbsent: !groupIds.has(uid) }; + const candidateProcesses = snapshotProcesses('before_account_creation', uid); + if (passiveUserDomainState(domainBeforeCreation, uid) !== 'absent' || !('count' in candidateProcesses) || candidateProcesses.count !== 0 + || !receipt.candidateIdentity.accountUidAbsent || !receipt.candidateIdentity.groupUidAbsent) { + throw new Error('candidate_domain_baseline_unconfirmed'); + } + const configFile = path.join(work, 'account.json'); + const metadata = Object.fromEntries(['CI', 'GITHUB_ACTIONS', 'RUNNER_ENVIRONMENT', 'RUNNER_OS', 'RUNNER_ARCH', 'GITHUB_RUN_ID', + 'GITHUB_RUN_ATTEMPT', 'GSTACK_DIA_NATIVE_QUALIFY'].map(name => [name, process.env[name]!])); + account = { work, home, temporary, snapshot, bun, ...destination, uid, gid: uid, account: accountName, + ...(launchComparison ? { launchComparison } : {}), ...(guiReadiness ? { guiReadiness } : {}), + guid: randomUUID().toUpperCase(), groupGuid: randomUUID().toUpperCase(), label, sourceRevision, + archiveSha256: await digest(archive), bunSha256: await digest(bun), configFile, + environment: { ...metadata, HOME: home, TMPDIR: temporary, RUNNER_TEMP: temporary, PATH: bin + ':/usr/bin:/bin:/usr/sbin:/sbin', LANG: 'en_US.UTF-8', + GSTACK_DIA_EXPECT_UID: String(uid), GSTACK_DIA_SOURCE_REVISION: sourceRevision, + ...(destination.destinationExecutable ? { GSTACK_DIA_DESTINATION_EXECUTABLE: destination.destinationExecutable } : {}) } }; + stage = 'fresh_account_creation'; + rootCommand('/usr/bin/dscl', ['.', '-create', '/Groups/' + accountName]); + groupCreated = true; + for (const [name, value] of [['GeneratedUID', account.groupGuid], ['PrimaryGroupID', String(uid)], ['RealName', 'gstack native fixture group']]) { + rootCommand('/usr/bin/dscl', ['.', '-create', '/Groups/' + accountName, name, value]); + } + rootCommand('/usr/bin/dscl', ['.', '-create', '/Users/' + accountName]); + userCreated = true; + for (const [name, value] of [['GeneratedUID', account.guid], ['UniqueID', String(uid)], ['PrimaryGroupID', String(uid)], + ['NFSHomeDirectory', home], ['UserShell', '/usr/bin/false'], ['RealName', 'gstack native fixture'], ['IsHidden', '1'], ['Password', '*']]) { + rootCommand('/usr/bin/dscl', ['.', '-create', '/Users/' + accountName, name, value]); + } + if (!ownsFreshAccount(parseDirectoryRecord(run('/usr/bin/dscl', ['.', '-read', '/Users/' + accountName, 'UniqueID', 'PrimaryGroupID', 'NFSHomeDirectory', 'GeneratedUID'])), account)) throw new Error('fresh_account_not_registered'); + for (const directory of [home, temporary, snapshot, bin, ...(guiReadinessOnly ? [] : [browserDirectory])]) rootCommand('/usr/sbin/chown', ['-R', '-P', `${uid}:${uid}`, directory], 30_000); + writeFileSync(configFile, JSON.stringify(account), { mode: 0o644, flag: 'wx' }); + const json = path.join(work, 'service.json'); + const plist = path.join(work, label + '.plist'); + writeFileSync(json, JSON.stringify(freshLaunchDefinition(account)), { mode: 0o600, flag: 'wx' }); + run('/usr/bin/plutil', ['-convert', 'xml1', '-o', plist, json]); + rootCommand('/usr/sbin/chown', ['root:wheel', configFile, plist, work]); + rootCommand('/bin/chmod', ['644', configFile, plist]); + rootCommand('/bin/chmod', ['755', work]); + stage = 'background_session_bootstrap'; + serviceAttempted = true; + rootCommand('/bin/launchctl', ['bootstrap', 'system', plist]); + stage = 'background_session_probe'; + while (performance.now() < deadline) { + const state = rootCommand('/bin/launchctl', ['print', 'system/' + label]); + const exit = state.match(/^\s*last exit code = (\d+)\s*$/m); + const running = /^\s*pid = \d+\s*$/m.test(state); + if (!running && exit) { workerExit = Number(exit[1]); break; } + await Bun.sleep(500); + } + if (workerExit === undefined) throw new Error('background_session_timeout'); + receipt.reason = workerExit === 0 ? 'fresh_account_qualification_completed' : 'fresh_account_preflight_or_qualification_failed'; + } catch (error) { + receipt.reason = stage; + receipt.failureStage = stage; + if ((error as { diagnostic?: object }).diagnostic) receipt.commandFailure = (error as { diagnostic: object }).diagnostic; + } finally { + cleanupDeadline = performance.now() + 60_000; + if (serviceAttempted && account) { + try { + if (!ownsLaunchService(rootCommand('/bin/launchctl', ['print', 'system/' + label]), account)) throw new Error('service_identity_changed'); + rootCommand('/bin/launchctl', ['bootout', 'system/' + label], 10_000); + receipt.launcherCleanup.serviceStopped = true; + } catch {} + } else receipt.launcherCleanup.serviceStopped = true; + let owned = false; + if (account && userCreated) { + try { owned = ownsFreshAccount(parseDirectoryRecord(run('/usr/bin/dscl', ['.', '-read', '/Users/' + accountName, 'UniqueID', 'PrimaryGroupID', 'NFSHomeDirectory', 'GeneratedUID'])), account); } catch {} + } + if (owned && account) { + const collect = (phase: string) => { + const results: Record = {}; + for (const [name, filename] of [['backgroundPreflight', 'dia-background-preflight.json'], ['qualification', 'dia-native-qualification.json']]) { + try { + if (!pythonExecutable) throw new Error('receipt_reader_unavailable'); + const text = rootCommand(pythonExecutable, ['-I', '-c', PRIVATE_RECEIPT_READ, path.join(temporary, filename), String(account!.uid), work], 3_000); + if (text.length > 1024 * 1024) throw new Error('oversized_receipt'); + receipt[name] = JSON.parse(text); + results[name] = 'captured'; + } catch { results[name] = 'unavailable'; } + } + (receipt.diagnosticCollection ??= {})[phase] = results; + }; + const active = () => { + const facts = inspectUidProcesses(account!.uid, cleanupDeadline, hostEnv); + if (!('count' in facts)) throw new Error('uid_process_snapshot_unavailable'); + return facts.count !== 0; + }; + collect('before_signal'); + snapshotProcesses('before_signal', account.uid); + let domainOwnershipConfirmed = false; + try { + if (!receipt.launcherCleanup.serviceStopped) throw new Error('service_still_loaded'); + const record = parseDirectoryRecord(run('/usr/bin/dscl', ['.', '-read', '/Users/' + accountName, 'UniqueID', 'PrimaryGroupID', 'NFSHomeDirectory', 'GeneratedUID'])); + const before = probeParentDomain(account.uid); + (receipt.userDomain ??= {}).beforeTeardown = before; + const domain = ownedUserDomainTarget(record, account, domainBeforeCreation, before); + domainOwnershipConfirmed = true; + if (domain !== null) { + try { rootCommand('/bin/launchctl', ['bootout', domain], 10_000); } + catch (error) { + receipt.userDomain.teardownCommandFailure = (error as { diagnostic?: object }).diagnostic ?? { failed: true }; + } + } + receipt.userDomain.afterTeardown = probeParentDomain(account.uid); + receipt.launcherCleanup.userDomainStopped = passiveUserDomainState(receipt.userDomain.afterTeardown, account.uid) === 'absent'; + } catch { (receipt.userDomain ??= {}).teardownRefusedOrUnconfirmed = true; } + snapshotProcesses('after_domain_teardown', account.uid); + try { + if (!domainOwnershipConfirmed || !receipt.launcherCleanup.userDomainStopped) throw new Error('user_domain_ownership_or_absence_unconfirmed'); + if (active()) { + const record = parseDirectoryRecord(run('/usr/bin/dscl', ['.', '-read', '/Users/' + accountName, 'UniqueID', 'PrimaryGroupID', 'NFSHomeDirectory', 'GeneratedUID'])); + if (!ownsFreshAccount(record, account)) throw new Error('account_identity_changed'); + try { rootCommand('/usr/bin/pkill', ['-KILL', '-u', String(account.uid)]); } catch {} + const until = Math.min(cleanupDeadline, performance.now() + 10_000); + snapshotProcesses('after_signal', account.uid); + while (active() && performance.now() < until) await Bun.sleep(100); + } + receipt.launcherCleanup.userProcessesStopped = !active(); + } catch {} + snapshotProcesses('after_wait', account.uid); + if (domainOwnershipConfirmed) { + try { + receipt.userDomain.afterWait = probeParentDomain(account.uid); + receipt.launcherCleanup.userDomainStopped = passiveUserDomainState(receipt.userDomain.afterWait, account.uid) === 'absent'; + } catch { receipt.launcherCleanup.userDomainStopped = false; } + } + collect('after_wait'); + if (receipt.launcherCleanup.userProcessesStopped) { + try { + if (!receipt.launcherCleanup.serviceStopped || !receipt.launcherCleanup.userDomainStopped) throw new Error('owned_domain_or_service_still_loaded'); + const record = parseDirectoryRecord(run('/usr/bin/dscl', ['.', '-read', '/Users/' + accountName, 'UniqueID', 'PrimaryGroupID', 'NFSHomeDirectory', 'GeneratedUID'])); + if (!ownsFreshAccount(record, account)) throw new Error('account_identity_changed'); + receipt.userDomain.beforeAccountRemoval = probeParentDomain(account.uid); + receipt.launcherCleanup.userDomainStopped = passiveUserDomainState(receipt.userDomain.beforeAccountRemoval, account.uid) === 'absent'; + if (!receipt.launcherCleanup.userDomainStopped) throw new Error('fresh_uid_domain_reappeared'); + if (active()) { receipt.launcherCleanup.userProcessesStopped = false; throw new Error('fresh_uid_processes_reappeared'); } + rootCommand('/usr/bin/dscl', ['.', '-delete', '/Users/' + accountName]); + receipt.launcherCleanup.accountRemoved = true; + } catch {} + } + } else if (!userCreated) { + receipt.launcherCleanup.userDomainStopped = true; + receipt.launcherCleanup.userProcessesStopped = true; + receipt.launcherCleanup.accountRemoved = true; + } + if (account && groupCreated && receipt.launcherCleanup.accountRemoved) { + try { + const group = parseDirectoryRecord(run('/usr/bin/dscl', ['.', '-read', '/Groups/' + accountName, 'GeneratedUID', 'PrimaryGroupID'])); + if (group.GeneratedUID?.toUpperCase() !== account.groupGuid || group.PrimaryGroupID !== String(account.gid)) throw new Error('group_identity_changed'); + rootCommand('/usr/bin/dscl', ['.', '-delete', '/Groups/' + accountName]); + receipt.launcherCleanup.groupRemoved = true; + } catch {} + } else if (!groupCreated) receipt.launcherCleanup.groupRemoved = true; + const mountSafe = !serviceAttempted || (receipt.qualification ? receipt.qualification.cleanup?.mountDetached === true + : receipt.backgroundPreflight?.status === 'incomplete'); + if (receipt.launcherCleanup.serviceStopped && receipt.launcherCleanup.userDomainStopped && receipt.launcherCleanup.userProcessesStopped && receipt.launcherCleanup.accountRemoved + && receipt.launcherCleanup.groupRemoved && mountSafe && (workerExit !== undefined || !serviceAttempted)) { + try { + const owner = lstatSync(work).uid; + if (realpathSync(work) !== work || path.dirname(work) !== '/private/tmp' || !path.basename(work).startsWith(path.basename(FRESH_WORK_PREFIX)) + || (owner !== 0 && owner !== process.getuid?.())) throw new Error('staging_identity_changed'); + rootCommand('/bin/rm', ['-rf', '--', work], 20_000); + receipt.launcherCleanup.stagingRemoved = true; + } catch {} + } + if (receipt.qualification) receipt.counts = receipt.qualification.counts; + if (account) receipt.launcher = { ...receipt.launcher, uid: account.uid, gid: account.gid, accountGuid: account.guid, groupGuid: account.groupGuid, serviceLabel: account.label, + sourceRevision: account.sourceRevision, archiveSha256: account.archiveSha256, bunSha256: account.bunSha256, destinationSha256: account.destinationSha256 }; + if (account?.launchComparison) receipt.launchComparison = { mode: 'launch-only', runtime: account.launchComparison.runtime, + executableSha256: account.launchComparison.executableSha256, driverSha256: account.launchComparison.driverSha256, + helpersSha256: account.launchComparison.helpersSha256, qualificationCredit: false }; + const clean = Object.values(receipt.launcherCleanup).every(value => value === true); + receipt.workerExitCode = workerExit ?? null; + receipt.status = !account?.launchComparison && !account?.guiReadiness && freshQualificationPassed(workerExit, receipt.backgroundPreflight?.status, receipt.qualification?.status, receipt.launcherCleanup) ? 'passed' : 'incomplete'; + if (account?.guiReadiness) { + receipt.reason = 'gui_readiness_only'; + receipt.guiReadiness.freshUser = receipt.backgroundPreflight?.guiReadiness ?? { available: false, reason: 'fresh_probe_receipt_unavailable' }; + } + if (account?.launchComparison && receipt.qualification?.reason === 'diagnostic_launch_comparison_only') receipt.reason = 'diagnostic_launch_comparison_only'; + if (!clean) receipt.recovery = 'Discard this disposable runner. Do not reuse its account, session, profile, or Keychain.'; + if (receipt.backgroundPreflight?.status !== 'passed' && receipt.backgroundPreflight) receipt.reason = receipt.backgroundPreflight.reason; + writePrivateReceipt(output, receipt); + } + return receipt; +} + +if (import.meta.main) { + try { + if (process.argv[2] === '--fresh-worker') process.exitCode = await freshWorker(process.argv[3]); + else { + const args = process.argv.slice(2); + const readinessOnly = args.length === 1 && args[0] === '--gui-readiness-only'; + if (args.length && !readinessOnly && (args.length !== 2 || args[0] !== '--launch-comparison' || !['bun', 'node'].includes(args[1]))) throw new Error('invalid_comparison_arguments'); + const receipt = await runFreshAccountQualification(args[1] as 'bun' | 'node' | undefined, readinessOnly); + console.log(JSON.stringify({ status: receipt.status, reason: receipt.reason, counts: receipt.counts, artifact: 'dia-native-qualification.json' })); + process.exitCode = receipt.status === 'passed' ? 0 : 2; + } + } catch { + console.log(JSON.stringify({ status: 'incomplete', reason: 'fresh_account_launcher_preflight_failed', counts: { pass: 0, fail: 0, skip: 0 } })); + process.exitCode = 2; + } +} diff --git a/.github/workflows/evals.yml b/.github/workflows/evals.yml index cbaa52605..613d0079a 100644 --- a/.github/workflows/evals.yml +++ b/.github/workflows/evals.yml @@ -8,6 +8,11 @@ on: description: 'Run ALL gate tests in the sliced lane (bypass diff selection; also arms the hollow-shard guard)' type: boolean default: true + validation_phase: + description: 'Validation branch phase; run quality before behavior on unchanged inputs' + type: choice + options: [all, quality, cookie-quality, behavior, cookie-behavior] + default: all concurrency: group: evals-${{ github.event.pull_request.number || github.run_id }} @@ -133,10 +138,37 @@ jobs: bun-version: 1.4.0 - name: Emit run manifest + if: github.event_name != 'workflow_dispatch' || inputs.validation_phase == 'all' env: EVALS_ALL: ${{ (github.event_name == 'workflow_dispatch' && inputs.evals_all) && '1' || '' }} run: EVALS_TIER=gate bun --no-install run scripts/test-paid-shards.ts --tier gate --emit-plan /tmp/paid-plan/manifest.json --slices 6 + - name: Emit validation-phase manifest + if: github.event_name == 'workflow_dispatch' && inputs.validation_phase != 'all' + env: + VALIDATION_PHASE: ${{ inputs.validation_phase }} + EVALS_ALL: ${{ inputs.evals_all && '1' || '' }} + EVALS_TIER: gate + run: | + bun --no-install -e ' + import { mkdirSync, writeFileSync } from "node:fs"; + import { buildRunManifest, collectPaidTestFiles } from "./scripts/test-paid-shards.ts"; + const phase = process.env.VALIDATION_PHASE; + if (!["quality", "cookie-quality", "behavior", "cookie-behavior"].includes(phase)) throw new Error("Invalid validation phase"); + const cookieBehavior = phase === "cookie-behavior"; + const discovered = phase === "cookie-quality" ? ["test/skill-llm-eval.test.ts"] + : cookieBehavior ? ["test/skill-e2e-bws.test.ts", "test/skill-e2e-qa-workflow.test.ts", "test/skill-e2e-design.test.ts", "test/skill-e2e-diagram.test.ts", "test/skill-e2e-deploy.test.ts"] + : collectPaidTestFiles().filter(file => file.startsWith("test/skill-llm-eval") === (phase === "quality")); + const manifest = buildRunManifest({ tier: "gate", profile: "full", sliceCount: 6, evalsAll: !cookieBehavior && process.env.EVALS_ALL === "1", discovered, + ...(cookieBehavior ? { changedFiles: ["browse/src/cookie-picker-routes.ts", "browse/src/cookie-import-browser.ts", "browse/src/bun-polyfill.cjs"], env: { ...process.env, EVALS_ALL: "" } } : {}) }); + if (phase === "cookie-quality") manifest.selection = { e2e: [], judges: ["setup-browser-cookies/SKILL.md workflow"] }; + if (cookieBehavior) manifest.selection = { e2e: ["browse-basic", "browse-snapshot", "qa-quick", "qa-only-no-fix", "design-review-detector-shim-dom", "diagram-triplet", "canary-workflow", "benchmark-workflow"], judges: [] }; + manifest.selectionReason = phase + " validation subset; " + manifest.selectionReason; + mkdirSync("/tmp/paid-plan", { recursive: true }); + writeFileSync("/tmp/paid-plan/manifest.json", JSON.stringify(manifest, null, 2) + "\n"); + console.log(phase + ": " + manifest.entries.filter(entry => entry.status === "planned").length + " planned shards"); + ' + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: paid-plan @@ -152,9 +184,9 @@ jobs: # 40-way per row queued claude session STARTUP behind 39 siblings and ate # per-test budgets — the documented timeout-flake family). Tune with # parity data before raising. - # The complete gate census needs at most 197 minutes per slice; keep + # The complete gate census needs at most 201 minutes per slice; keep # 20 minutes for setup/upload without preempting configured retries. - timeout-minutes: 220 + timeout-minutes: 221 permissions: contents: read packages: read @@ -334,7 +366,9 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: report-verdict - path: /tmp/report.txt + path: | + /tmp/report.txt + /tmp/paid-report/collector-outcomes.json if-no-files-found: ignore retention-days: 30 @@ -374,7 +408,8 @@ jobs: path: /tmp/verdict continue-on-error: true - # Sourced from the slice artifacts' eval-store JSONs. Keeps the + # Verified counts come from the read-only report job, not repo code in + # this write-token job. Keeps the # "## E2E Evals" marker so the upsert keeps updating the same comment. # Runs even when reconciliation failed — a red lane on the PR is the point. - name: Post PR comment @@ -384,8 +419,36 @@ jobs: run: | # shellcheck disable=SC2086,SC2059 RESULTS=$(find /tmp/paid-report -name '*.json' ! -name 'manifest.json' ! -name 'slice-*.json' ! -name '_partial*' 2>/dev/null | sort) - TOTAL=0; PASSED=0; FAILED=0; FLAKY=0; EXECUTED=0; REUSED=0; COST="0" + TOTAL=0; PASSED=0; FAILED=0; MANUAL=0; FLAKY=0; EXECUTED=0; REUSED=0; COST="0" SUITE_LINES="" + VERIFIED=/tmp/verdict/paid-report/collector-outcomes.json + if ! jq -e ' + . as $summary | + .version == 1 and (.files | type == "array") and (.totals | type == "object") and + ([.files[] | .total == (.passed + .failed + .manual_accepted) and + (.total == (.executed + .reused)) and + ([.total,.passed,.failed,.manual_accepted,.executed,.reused,.attempts,.flaky] | all(. >= 0 and (floor == .))) ] | all) and + (.totals | .total == (.passed + .failed + .manual_accepted) and .total == (.executed + .reused)) and + (["total","passed","failed","manual_accepted","executed","reused","attempts","flaky"] | + all(. as $key | ([$summary.files[] | .[$key]] | add // 0) == $summary.totals[$key])) + ' "$VERIFIED" >/dev/null 2>&1; then + VERIFIED="" + echo 'Verified collector summary unavailable; manual acceptance is unavailable/unverified.' + fi + if [ -n "$VERIFIED" ]; then + while IFS=$'\t' read -r f T P F M FL EX RE _ATTEMPTS C TIER SHARD; do + [ "$T" -eq 0 ] && continue + TOTAL=$((TOTAL + T)); PASSED=$((PASSED + P)); FAILED=$((FAILED + F)) + MANUAL=$((MANUAL + M)); FLAKY=$((FLAKY + FL)) + EXECUTED=$((EXECUTED + EX)); REUSED=$((REUSED + RE)) + COST=$(echo "$COST + $C" | bc) + STATUS_ICON="✅" + [ "$M" -gt 0 ] && STATUS_ICON="⚠ manual/unscored" + [ "$F" -gt 0 ] && STATUS_ICON="❌" + [ "$F" -eq 0 ] && [ "$M" -eq 0 ] && [ "$FL" -gt 0 ] && STATUS_ICON="✅⚠" + SUITE_LINES="${SUITE_LINES}| ${TIER}/${SHARD} | ${P}/${T} | ${M} | ${EX} | ${RE} | ${STATUS_ICON} | \$${C} |\n" + done < <(jq -r '.files[] | [.file,.total,.passed,.failed,.manual_accepted,.flaky,.executed,.reused,.attempts,.cost,.tier,.shard] | @tsv' "$VERIFIED") + else for f in $RESULTS; do if ! jq -e '.total_tests' "$f" >/dev/null 2>&1; then echo "Skipping malformed JSON: $f" @@ -418,22 +481,25 @@ jobs: STATUS_ICON="✅" [ "$F" -gt 0 ] && STATUS_ICON="❌" [ "$F" -eq 0 ] && [ "$FL" -gt 0 ] && STATUS_ICON="✅⚠" - SUITE_LINES="${SUITE_LINES}| ${TIER}/${SHARD} | ${P}/${T} | ${EX} | ${RE} | ${STATUS_ICON} | \$${C} |\n" + SUITE_LINES="${SUITE_LINES}| ${TIER}/${SHARD} | ${P}/${T} | unverified | ${EX} | ${RE} | ${STATUS_ICON} | \$${C} |\n" done + fi COVERAGE=$(jq -r '"Profile: \(.profile // "full") / \(.prCoverage.mode // "broad"); selected behaviors: \(.selection.e2e | if . == null then "all" else length end), judges: \(.selection.judges | if . == null then "all" else length end). Deferred to scheduled/release coverage: \(.prCoverage.deferred // [] | length) behaviors and \(.prCoverage.deferredPromptFiles // [] | length) changed prompt files. Deferred checks did not run and receive no PR-pass credit."' /tmp/paid-report/manifest.json) || COVERAGE='Coverage manifest unavailable; no coverage claim.' STATUS="✅ PASS" if [ "${RECONCILE_EXIT:-1}" != "0" ] || [ "$FAILED" -gt 0 ]; then STATUS="❌ FAIL"; fi + if [ "$STATUS" = '✅ PASS' ] && [ "$MANUAL" -gt 0 ]; then STATUS='⚠ MANUAL ACCEPTED (unscored)'; fi + if [ -z "$VERIFIED" ]; then STATUS='❌ FAIL (manual acceptance unavailable/unverified)'; fi BODY="## E2E Evals: ${STATUS} - **${PASSED}/${TOTAL}** recorded final results passed | **${EXECUTED} executed, ${REUSED} reused** | **\$${COST}** total cost | reconcile exit: ${RECONCILE_EXIT:-missing}$([ "$FLAKY" -gt 0 ] && printf ' | ⚠ %s cases with multiple attempts' "$FLAKY") + **${PASSED} automated passed / ${TOTAL} final results** | **${FAILED} failed, ${MANUAL} manual accepted (unscored; no score-cache credit)** | **${EXECUTED} executed, ${REUSED} reused** | **\$${COST}** total cost | reconcile exit: ${RECONCILE_EXIT:-missing}$([ "$FLAKY" -gt 0 ] && printf ' | ⚠ %s cases with multiple attempts' "$FLAKY") ${COVERAGE} - | Shard | Result | Executed | Reused | Status | Cost | - |-------|--------|----------|--------|--------|------| + | Shard | Automated result | Manual/unscored | Executed | Reused | Status | Cost | + |-------|------------------|-----------------|----------|--------|--------|------| $(echo -e "$SUITE_LINES")
Fail-closed reconciliation @@ -450,7 +516,11 @@ jobs: FAILURES="" for f in $RESULTS; do if ! jq -e '.failed' "$f" >/dev/null 2>&1; then continue; fi - FAILS=$(jq -r '[.tests | group_by(.name)[] | last | select(.passed == false)][] | "- ❌ \(.name): \(.exit_reason // "unknown")"' "$f" 2>/dev/null || echo "- ⚠️ parse error") + if [ -n "$VERIFIED" ]; then + FAILS=$(jq -r '[.tests | group_by(.name)[] | last | select(.passed == false and (has("manual_review") | not))][] | "- ❌ \(.name): \(.exit_reason // "unknown")"' "$f" 2>/dev/null || echo "- ⚠️ parse error") + else + FAILS=$(jq -r '[.tests | group_by(.name)[] | last | select(.passed == false)][] | "- ❌ \(.name): \(.exit_reason // "unknown")"' "$f" 2>/dev/null || echo "- ⚠️ parse error") + fi FAILURES="${FAILURES}${FAILS}\n" done BODY="${BODY} diff --git a/.github/workflows/free-tests.yml b/.github/workflows/free-tests.yml index ea91ce010..a9c85c3ff 100644 --- a/.github/workflows/free-tests.yml +++ b/.github/workflows/free-tests.yml @@ -182,6 +182,21 @@ jobs: - name: Install Playwright Chromium run: npx playwright install --with-deps chromium + - name: Configure the bundled Chromium sandbox helper + run: | + set -euo pipefail + chrome=$(bun -e 'import { chromium } from "playwright"; import { realpathSync } from "node:fs"; console.log(realpathSync(chromium.executablePath()))') + case "$chrome" in + "$HOME"/.cache/ms-playwright/chromium-*/chrome-linux*/chrome) ;; + *) echo "Unexpected Chromium installation path" >&2; exit 1 ;; + esac + helper="${chrome%/*}/chrome_sandbox" + installed="${chrome%/*}/chrome-sandbox" + test -f "$helper" && test ! -L "$helper" + sudo install -T -o root -g root -m 4755 "$helper" "$installed" + test "$(stat -c '%u:%a' "$installed")" = '0:4755' + cmp -s "$helper" "$installed" + # Headed-browser tests (handoff, extension sidepanel DOM) need a real # DISPLAY — first Linux run failed with Playwright's "launched a headed # browser without an XServer" banner. xvfb-run below provides it; diff --git a/.github/workflows/windows-free-tests.yml b/.github/workflows/windows-free-tests.yml index 8879f79a0..c4c7298c6 100644 --- a/.github/workflows/windows-free-tests.yml +++ b/.github/workflows/windows-free-tests.yml @@ -26,6 +26,23 @@ on: pull_request: branches: [main] workflow_dispatch: + inputs: + dia_native_only: + description: Run disposable ARM64 macOS Dia qualification instead of Windows + type: boolean + default: false + native_diagnostics_only: + description: Run Windows launch diagnostics and credential regressions without qualification + type: boolean + default: false + dia_launch_comparison: + description: Compare protected Dia launch under Bun and Node in separate fresh Mac jobs + type: boolean + default: false + dia_gui_readiness: + description: Inspect disposable Mac GUI-session readiness without launching browsers + type: boolean + default: false concurrency: group: windows-free-${{ github.event.pull_request.number || github.run_id }} @@ -37,6 +54,7 @@ permissions: jobs: windows-free-tests: + if: ${{ !inputs.dia_native_only && !inputs.dia_launch_comparison && !inputs.dia_gui_readiness }} # Ubicloud Windows runner (same provider as the Linux evals workflow). # To revert: swap to `windows-latest` (GitHub's free 4-core Windows runner). runs-on: windows-latest @@ -49,6 +67,10 @@ jobs: with: bun-version: 1.4.0 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 + with: + node-version: 24.18.0 + # bun install was 35s of a 55s job, all network. Cache keyed on the # lockfile; bun's install cache lives under ~/.bun/install/cache on # every platform. @@ -82,6 +104,7 @@ jobs: shell: bash - name: Generate host SKILL.md outputs (.agents, .factory) + if: ${{ !inputs.native_diagnostics_only }} # The golden-file regression tests in test/gen-skill-docs.test.ts read # .agents/skills/gstack-ship/SKILL.md and .factory/skills/gstack-ship/ # SKILL.md. Both are gitignored — generated on demand by gen:skill-docs. @@ -91,6 +114,9 @@ jobs: run: bun run gen:skill-docs --host all shell: bash + - name: Install Chromium for the Node worker smoke + run: bunx playwright install chromium + # The Windows job verifies the new portability work this PR delivers, # not the entire free suite. After v1.20.0.0 ships, full-suite Windows # parity is a P4 follow-up TODO that depends on porting many tests off @@ -110,6 +136,7 @@ jobs: # (test/test-free-shards.test.ts) - name: Run curated Windows-safe suite + if: ${{ !inputs.native_diagnostics_only }} # Replaces the previous hand-listed 13-file subset, which drifted from # the curation registry it was supposed to sample. The runner's # --windows-only curation (scripts/test-free-shards.ts) is the single @@ -125,15 +152,115 @@ jobs: run: bun run test:windows shell: bash + - name: Run focused native launch and credential diagnostics + if: inputs.native_diagnostics_only + shell: bash + run: | + set -o pipefail + status=0 + bun test browse/test/cookie-import-native-job.test.ts --test-name-pattern 'native Windows launch diagnostics|a locked real Edge profile|real Edge synthetic profile' 2>&1 | tee "$RUNNER_TEMP/gstack-free-test-native-diagnostics.log" || status=1 + bun test browse/test/cookie-credential-deadline.test.ts browse/test/cookie-import-node.test.ts browse/test/bun-polyfill.test.ts 2>&1 | tee "$RUNNER_TEMP/gstack-free-test-credential-diagnostics.log" || status=1 + exit "$status" + # Same diagnosability contract as free-tests.yml: a red lane must # carry the WHY (the runner's quiet console names files, not causes). # (#2561 was written against the old hand-listed subset; its two new # test files are pure-TS and flow into the --windows-only curation # automatically, so no per-file entry is needed here.) - - name: Upload shard logs on failure - if: failure() + - name: Upload full shard logs + if: always() uses: actions/upload-artifact@v7 with: name: windows-free-test-shard-logs path: ${{ runner.temp }}/gstack-free-test-*.log if-no-files-found: ignore + + cookie-native-qualification: + if: github.event_name == 'workflow_dispatch' && !inputs.dia_native_only && !inputs.native_diagnostics_only && !inputs.dia_launch_comparison && !inputs.dia_gui_readiness + runs-on: windows-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 + with: + bun-version: 1.4.0 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 + with: + node-version: 24.18.0 + - name: Install pinned dependencies + run: bun install --frozen-lockfile + - name: Build the qualified Node server inputs + run: bash browse/scripts/build-node-server.sh + shell: bash + - name: Qualify owned native cookie extraction + run: ./.github/scripts/run-cookie-native-qualification.ps1 -OutputRoot "$env:RUNNER_TEMP" + - name: Preserve qualification evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: cookie-native-qualification + path: ${{ runner.temp }}/cookie-native-qualification-*/ + if-no-files-found: error + + dia-native-qualification: + if: github.event_name == 'workflow_dispatch' && (inputs.dia_native_only || inputs.dia_launch_comparison || inputs.dia_gui_readiness) + runs-on: macos-15 + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + runtime: ${{ fromJSON(inputs.dia_launch_comparison && !inputs.dia_gui_readiness && '["bun","node"]' || '["bun"]') }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 + with: + bun-version: 1.4.0 + - name: Validate GUI readiness selection + if: inputs.dia_gui_readiness + env: + OTHER_DIA_MODES: ${{ inputs.dia_native_only || inputs.dia_launch_comparison || inputs.native_diagnostics_only }} + run: | + bun --no-env-file --no-install --no-macros --config=/dev/null -e ' + if (process.env.OTHER_DIA_MODES !== "false") { + console.error("dia_gui_readiness must be selected alone"); + process.exit(1); + } + ' + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 + if: inputs.dia_launch_comparison && !inputs.dia_gui_readiness + with: + node-version: 24.18.0 + architecture: arm64 + - name: Install pinned dependencies + if: ${{ !inputs.dia_gui_readiness }} + run: bun install --frozen-lockfile + - name: Install the synthetic destination browser + if: ${{ !inputs.dia_gui_readiness }} + run: bunx --no-install playwright install chromium + - name: Inspect GUI readiness without browser or Keychain access + if: inputs.dia_gui_readiness + env: + GSTACK_DIA_NATIVE_QUALIFY: '1' + run: bun --no-env-file --no-install --no-macros --config=/dev/null .github/scripts/run-dia-native-qualification.ts --gui-readiness-only + - name: Qualify native Dia discovery, decryption, and import + if: ${{ !inputs.dia_launch_comparison && !inputs.dia_gui_readiness }} + env: + GSTACK_DIA_NATIVE_QUALIFY: '1' + run: bun --no-env-file --no-install --no-macros --config=/dev/null .github/scripts/run-dia-native-qualification.ts + - name: Compare protected native Dia launch without qualification credit + if: inputs.dia_launch_comparison && !inputs.dia_gui_readiness + env: + GSTACK_DIA_NATIVE_QUALIFY: '1' + COMPARISON_RUNTIME: ${{ matrix.runtime }} + run: bun --no-env-file --no-install --no-macros --config=/dev/null .github/scripts/run-dia-native-qualification.ts --launch-comparison "$COMPARISON_RUNTIME" + - name: Preserve only the sanitized qualification receipt + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: ${{ inputs.dia_gui_readiness && 'dia-gui-readiness' || inputs.dia_launch_comparison && format('dia-launch-comparison-{0}', matrix.runtime) || 'dia-native-qualification' }} + path: ${{ runner.temp }}/dia-native-qualification.json + if-no-files-found: error diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0f83a5afe..ea9980d40 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -153,7 +153,7 @@ ngrok forwards only the tunnel port. The security property comes from **physical | `POST /pair` | root-only | 404 | Pairing mint — local operator action | | `POST /tunnel/{start,stop}` | root-only | 404 | Daemon configuration | | `POST /token`, `DELETE /token/:id` | root-only | 404 | Scoped token mint/revoke | -| `GET /cookie-picker`, `GET /cookie-picker/*` | public UI, auth API | 404 | Local-only — reads local browser DBs | +| `GET /cookie-picker`, `/cookie-picker/*` | one-use code/session for UI; Bearer or picker session for API | 404 | Local-only — reads local browser DBs | | `GET /inspector`, `/inspector/events`, etc. | auth | 404 | Extension callback, local-only | | `GET /welcome` | public | 404 | GStack Browser landing page, local-only | | `GET /refs` | auth | 404 | Ref map — internal state | @@ -167,31 +167,39 @@ ngrok forwards only the tunnel port. The security property comes from **physical **SSE session cookies.** EventSource can't send Authorization headers, so the extension POSTs `/sse-session` once at bootstrap with the root Bearer and receives a 30-minute view-only cookie (`gstack_sse`, HttpOnly, SameSite=Strict). The cookie is valid ONLY for `/activity/stream` and `/inspector/events` — it is NOT a scoped token and cannot be used on `/command`. Scope isolation is enforced by the module boundary: `sse-session-cookie.ts` has no imports from `token-registry.ts`. -**Non-goal in this wave** (tracked as #1136): the cookie-import-browser path launches Chrome with `--remote-debugging-port=`. On Windows with App-Bound Encryption v20, a same-user local process can connect to that port and exfiltrate decrypted v20 cookies — an elevation path relative to reading the SQLite DB directly (which can't decrypt v20 without DPAPI context). Fix direction is `--remote-debugging-pipe` instead of TCP; requires restructuring the CDP client. +**Windows native-cookie boundary** (#1136): the exposed debugging TCP fallback has been removed. The native adapter uses Playwright's pipe transport and requires browser/runtime process-ownership and cleanup qualification before enablement; its qualification list is currently empty. DPAPI-compatible database imports remain available, but this does not promise recovery of every App-Bound Encryption cookie. Chrome 136+ protects its default user-data directory, including numbered profiles, against both pipe and TCP debugging. Closing Chrome does not bypass that policy. No TCP downgrade, substitute browser, or real-profile copy is allowed; unsupported cases direct the user to manual sign-in in gstack's browser. ### Bearer token auth -Every server session generates a random UUID token, written to the state file with mode 0o600 (owner-only read). Every HTTP request that mutates browser state must include `Authorization: Bearer `. If the token doesn't match, the server returns 401. +Every server session generates a random UUID token, written to the state file with mode 0o600 (owner-only read). Requests to `/command` must include `Authorization: Bearer ` using an authorized root or scoped token. Invalid authentication is rejected. -This prevents other processes on the same machine from talking to your browse server. The cookie picker UI (`/cookie-picker`) and health check (`/health`) are exempt on the local listener — they're 127.0.0.1-bound and don't execute commands. On the tunnel listener nothing is exempt except `/connect`. +Command authorization requires the token. The local cookie picker instead exchanges a five-minute one-use code for a scoped HttpOnly session cookie; that cookie authorizes only picker routes, never `/command`. Its API also accepts Bearer authorization. The local health check (`/health`) is public and does not execute commands. On the tunnel listener nothing is exempt except `/connect`. ### Cookie security Cookies are the most sensitive data gstack handles. The design: -1. **Keychain access requires user approval.** First cookie import per browser triggers a macOS Keychain dialog. The user must click "Allow" or "Always Allow." gstack never silently accesses credentials. +Cookie databases use a read-only runtime adapter: Bun SQLite in Bun, or built-in SQLite in Node.js 22.13+. Large Chromium timestamps remain exact integers; ordinary domain counts remain JSON numbers. Temporary database snapshots are private and removed on close or failure. -2. **Decryption happens in-process.** Cookie values are decrypted in memory (PBKDF2 + AES-128-CBC), loaded into the Playwright context, and never written to disk in plaintext. The cookie picker UI never displays cookie values — only domain names and counts. +1. **OS key access follows platform permissions.** macOS may prompt for Keychain approval on the first import per browser. Linux supports libsecret-backed `v11` and the Chromium fallback key for `v10`; Windows supports DPAPI-compatible cookies. Permission denial stops the operation rather than automatically repeating prompts. -3. **Database is read-only.** gstack copies the Chromium cookie DB to a temp file (to avoid SQLite lock conflicts with the running browser) and opens it read-only. It never modifies your real browser's cookie database. +2. **Import receipts do not contain cookie values.** Database decryption happens in memory using the platform's supported format; decrypted cookies are applied to the captured Playwright context. The picker shows browser/profile labels, domains, counts, and separate import/reset/authentication statuses, never cookie values. Labels can still identify an account and must not be copied to public logs. Optional session persistence is a separate opt-in disk-storage feature. -4. **Key caching is per-session.** The Keychain password + derived AES key are cached in memory for the server's lifetime. When the server shuts down (idle timeout or explicit stop), the cache is gone. +3. **Database reads do not modify the source.** gstack copies the Chromium cookie DB to a temp file to avoid SQLite lock conflicts and opens it read-only. Only classified transient reads retry, at most three attempts with 150ms and 500ms delays. Native browser extraction has a separate lifecycle boundary and remains disabled pending qualification. -5. **No cookie values in logs.** Console, network, and dialog logs never contain cookie values. The `cookies` command outputs cookie metadata (domain, name, expiry) but values are truncated. +4. **Key caching is per-session.** Derived keys are cached in memory for the server's lifetime. When the server shuts down (idle timeout or explicit stop), the cache is gone. + +5. **Diagnostics use safe categories.** Cookie-import failures expose classified reasons and counts, not raw OS errors or decrypted values. The separate `cookies` inspection command redacts values that match its sensitive-name/value rules; it is not a metadata-only receipt and should not be used for public import summaries. + +`cookie-import-operation.ts` owns profile selection, decryption, application, and receipts for the direct CLI, `--all`, and authenticated picker. Explicit profile selection wins; otherwise only a sole relevant profile is chosen, with unreadable profiles treated as unknown. Current `Local State` labels precede Preferences and directory fallbacks. Browser/context/page ownership is captured before asynchronous work, imports serialize per destination context, and applied domains feed the existing JavaScript-origin guard. Cookies are context-wide, not tab-isolated. + +Storage reset and authentication verification are independent opt-ins. `--clear-storage` clears only the captured origin's localStorage (shared by same-origin tabs in that context) and target-tab sessionStorage, never IndexedDB, service workers, sibling origins, or other tabs' sessionStorage; `--all` plus reset is rejected. Reset is Chromium-only: an isolated world binds native-clock sampling and the destructive operation to one system-unique context, while navigation and the host deadline cancel pending work. Other engines retain import/auth checks but reject reset. A failed reset or later application failure does not imply rollback. + +`cookie-auth-verification.ts` validates daemon-side `GSTACK_COOKIE_AUTH_SELECTOR` and `GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY` before a requested verification can mutate cookies or storage. Set them privately before daemon startup. After importing cookies, `--verify-auth` reloads the captured target and requires a successful same-origin response and exactly one visible identity whose normalized text matches exactly. Cookie count, HTTP 200, and substring matches cannot establish authentication. An import without this check is **not checked**, not verified; zero imports cannot verify sign-in. Results do not echo expected identity text. ### Shell injection prevention -The browser registry (Comet, Chrome, Arc, Brave, Edge) is hardcoded. Database paths are constructed from known constants, never from user input. Keychain access uses `Bun.spawn()` with explicit argument arrays, not shell string interpolation. +The browser registry (Chrome, Chromium, Brave, Edge, and macOS-only Comet, Arc, Dia) is hardcoded. Database roots come from known platform locations; profile directory input is validated. Keychain access uses `Bun.spawn()` with explicit argument arrays, not shell string interpolation. ### Egress receipt ledger (v1.63.0.0) @@ -526,5 +534,5 @@ Anything that needs Aside itself — `test/skill-e2e-aside.test.ts`, the Aside c - **No WebSocket streaming.** HTTP request/response is simpler, debuggable with curl, and fast enough. Streaming would add complexity for marginal benefit. - **No MCP protocol.** MCP adds JSON schema overhead per request and requires a persistent connection. Plain HTTP + plain text output is lighter on tokens and easier to debug. - **No multi-user support.** One server per workspace, one user. The token auth is defense-in-depth, not multi-tenancy. -- **No Windows/Linux cookie decryption.** macOS Keychain is the only supported credential store. Linux (GNOME Keyring/kwallet) and Windows (DPAPI) are architecturally possible but not implemented. +- **No universal session recovery.** OS-backed cookie import supports macOS, Linux, and DPAPI-compatible Windows formats, not every encryption scheme or site's authentication state. Windows native extraction stays disabled pending qualification, and Chrome's protected default directory is not bypassed. - **No iframe auto-discovery.** `$B frame` supports cross-frame interaction (CSS selector, @ref, `--name`, `--url` matching), but the ref system does not auto-crawl iframes during `snapshot`. You must explicitly enter a frame context first. diff --git a/BROWSER.md b/BROWSER.md index 4df487ad3..26c3628ea 100644 --- a/BROWSER.md +++ b/BROWSER.md @@ -218,7 +218,7 @@ What changes when the fallback is active: | On Aside | On the fallback engine | |---|---| -| Your sessions are already there | `/setup-browser-cookies` imports them from Chrome, Arc, Brave, Edge, or Comet — or log in once in headed mode | +| Your sessions are already there | `/setup-browser-cookies` copies selected cookies from Chrome, Chromium, Brave, Edge, or macOS-only Comet, Arc, and Dia; verify sign-in separately, or log in once in headed mode | | You watch the tabs the agent opens in Aside | `/open-gstack-browser` (or `$B connect`) shows the headed GStack Browser with the side panel | | Sign-in wall: sign in inside Aside, say "done" | `$B handoff` opens a visible Chrome at the same page; `$B resume` continues | | One `aside repl` script per flow, fresh session each time | Persistent daemon: cookies, tabs, and localStorage carry over between `$B` calls | @@ -562,10 +562,29 @@ from `snapshot`, or `@c` refs from `snapshot -C`. Full table: |---------|-------------| | `cookie =` | Set cookie on current page domain | | `cookie-import ` | Import cookies from JSON file | -| `cookie-import-browser [browser] [--domain d]` | Import from installed Chromium browsers (interactive picker, or `--domain` for direct import) | +| `cookie-import-browser [browser] [--domain d] [--profile p] [--all] [--clear-storage] [--verify-auth]` | Copy selected browser cookies; picker by default, explicit scoped or all-domain import, optional storage reset and sign-in assertion | | `header :` | Set custom request header (sensitive values auto-redacted) | | `useragent ` | Set user agent (triggers context recreation, invalidates refs) | +#### Choosing a source and checking sign-in + +Select the source browser and account/profile explicitly. The picker recognizes Chrome, Chromium, Brave, Edge, and macOS-only Comet, Arc, and Dia. It shows current profile names from `Local State`, falling back to Preferences and then the directory name, with directory labels to distinguish duplicate names. `--profile` takes that directory (`Default`, `Profile 2`), not its display name. Without it, only a sole relevant profile is selected; ambiguity or unreadable profiles require a choice. The omitted-browser default remains `comet` for CLI compatibility, not as a recommendation. The picker opening link is one-use and expires after five minutes. + +For direct import, first navigate to a page matching `--domain`. Example after choosing Chrome's `Profile 2`: + +```bash +$B goto https://example.com +$B cookie-import-browser chrome --domain example.com --profile "Profile 2" +``` + +`--all` explicitly selects every non-expired cookie in the chosen source profile; it cannot accompany `--domain` or `--clear-storage`. Cookies are applied to the captured browser context, not isolated to a tab. The receipt distinguishes imported, partial, empty, and failed results, plus separate storage-reset and authentication outcomes. Cookies copied with authentication `not_requested` means **not checked**, not logged in. Zero imports, cookie counts, and HTTP 200 alone never prove sign-in. + +`--verify-auth` (also an explicit picker checkbox) reloads the captured target. Configure `GSTACK_COOKIE_AUTH_SELECTOR` and `GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY` privately in the **daemon environment before startup**; setting them only on a later CLI call does not reconfigure an existing daemon. Missing configuration rejects before mutation. Verification requires a successful same-origin response and exactly one visible element whose whitespace-normalized text equals the expected identity. A wrong account, login redirect, missing assertion, or changed target is not verified. Do not paste cookie values, passwords, profile/account labels, or expected identity into public logs; report only sanitized outcomes. + +Storage stays intact by default. With explicit approval on a Chromium target, `--clear-storage` clears localStorage for the captured origin (exact scheme, host, and port, shared across that origin's tabs in the context) and sessionStorage for the target tab before applying cookies. Reset runs in an isolated world with a native monotonic deadline, so the site's scripts cannot forge its timeout clock. Other target engines reject reset; ordinary imports and authentication checks remain available. It does not clear other origins, other tabs' sessionStorage, IndexedDB, or service workers. Keep the target open and unchanged. A failed reset may have cleared some storage; a later cookie-application failure does not undo it. + +**Platform limits:** macOS imports may request Keychain approval; Linux `v11` cookies may require libsecret, while `v10` uses Chromium's fallback key. The Windows Node server needs Node.js 22.13 or newer with built-in SQLite enabled for cookie database reads. DPAPI-compatible cookies remain supported, but native App-Bound Encryption extraction is disabled until the browser/runtime passes qualification. Chrome 136+ blocks remote debugging of its default user-data directory, including numbered profiles, over both pipe and TCP; closing Chrome does not remove that protection. There is no TCP fallback or real-profile-copy workaround. If import cannot recover the session, sign in manually in gstack's headed browser when a display is available. + ### Tabs + frames | Command | Description | @@ -1605,6 +1624,12 @@ browse/ │ ├── terminal-agent.ts # Side Panel Claude PTY manager (auth + lifecycle) │ ├── sidebar-utils.ts # Sidebar URL sanitization + helpers │ ├── cookie-import-browser.ts # Decrypt + import cookies from real Chromium browsers +│ ├── cookie-database.ts # Read-only Bun/Node SQLite with integer-safe cookie timestamps +│ ├── cookie-import-operation.ts # Shared source selection, target policy, import receipts +│ ├── cookie-auth-verification.ts # Opt-in target storage reset + exact identity assertion +│ ├── cookie-import-native.ts # Qualification-gated Windows pipe extraction adapter +│ ├── cookie-import-native-worker.ts # Supervised native launch/read/cleanup +│ ├── cookie-import-native-job.ts # Windows owned-process job boundary │ ├── cookie-picker-routes.ts # HTTP routes for /cookie-picker/* │ ├── cookie-picker-ui.ts # Self-contained HTML/CSS/JS for cookie picker │ ├── network-capture.ts # Network request capture for $B network diff --git a/CHANGELOG.md b/CHANGELOG.md index 63dfe468e..bc8917949 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## [1.90.0.0] - 2026-09-24 + +Cookie imports now keep the chosen browser, profile, and destination explicit, show partial failures, and distinguish copying cookies from proving that you are signed in. + +### Added +- macOS Dia discovery and profile selection, using the existing Chromium cookie reader. Live native Dia import remains unqualified; fixture coverage is not a claim of native compatibility. +- Optional sign-in verification against an exact, visible identity assertion on the selected destination, with separate copied and verified results. +- Explicit current-origin storage recovery. Storage is preserved by default; an opted-in reset clears that origin's localStorage and only the target tab's sessionStorage. + +### Fixed +- Prefer renamed profiles from Local State, distinguish duplicate names, and require selection rather than guessing among plausible accounts. Bare and dotted domain selections now match the intended cookie scope without broadening it. +- Register picker imports with the browser's imported-domain security guard, reject cross-origin picker mutations, and bind asynchronous operations to their original destination. Opening another picker makes old windows fail closed instead of silently changing their target. +- Show complete, partial, zero, and failed imports accurately. Stale discovery responses and duplicate submissions no longer overwrite the current picker state. +- Bound credential subprocess output and cleanup, retry only transient database reads, and prevent automatic replay of cookie-import mutations. The Node server uses a real read-only SQLite adapter. + +### Changed +- Picker handoff codes last five minutes and remain single-use. Browser guidance explains profile selection, storage-reset consent, and the difference between copied cookies and verified sign-in. +- Native Windows extraction uses a sandboxed, owned-process, pipe-only path with bounded cleanup and no TCP fallback. Its qualification allowlist is empty in this release, so encrypted-cookie extraction through this path stays disabled with manual-sign-in guidance. +- Added isolated platform qualification, launch diagnostics, and regression coverage. Native Dia and protected default-profile Windows qualification remain incomplete; diagnostic passes do not count as successful imports. + ## [1.89.1.0] - 2026-09-24 ### Removed diff --git a/README.md b/README.md index cf58aa5ae..af1eeda26 100644 --- a/README.md +++ b/README.md @@ -238,7 +238,7 @@ Each skill feeds into the next. `/office-hours` writes a design doc that `/plan- | `/retro` | **Eng Manager** | Team-aware weekly retro. Per-person breakdowns, shipping streaks, test health trends, growth opportunities. `/retro global` runs across all your projects and AI tools (Claude Code, Codex, Gemini). | | `/browse` | **QA Engineer** | Give the agent eyes. Drives your [Aside](https://aside.com) browser first — your real sessions, real clicks, real screenshots — through deterministic `aside repl` scripts. No Aside? It falls back to gstack's own Chromium: real clicks, ~100ms per command, and `/open-gstack-browser` shows it headed with sidebar, anti-bot stealth, and auto model routing. Every other browser skill stands on it. | | `/scrape` | **Data Extractor** | Pull structured data off a web page — tables, lists, prices — in your Aside browser with the page's real logged-in state. On the fallback browser, `/skillify` turns the flow into a permanent browser-skill that runs in ~200ms next time. | -| `/setup-browser-cookies` | **Session Manager** | Import cookies from your real browser (Chrome, Arc, Brave, Edge) into gstack's bundled browser so it can test authenticated pages. Only needed on the fallback path — Aside already has your sessions. | +| `/setup-browser-cookies` | **Session Manager** | Copy selected cookies from Chrome, Chromium, Brave, Edge, or macOS-only Comet, Arc, and Dia into gstack's bundled browser. Choose your profile and domains; copying and sign-in verification are separate. Only needed on the fallback path — Aside already has your sessions. | | `/autoplan` | **Review Pipeline** | One command, fully reviewed plan. Runs CEO → design → DX → eng review automatically (eng always last, so the shipping gate reviews the final amended plan) with encoded decision principles. Surfaces only taste decisions for your approval. | | `/spec` | **Spec Author** | Turn vague intent into a precise, executable spec in five phases (why, scope, technical with mandatory code-reading, draft, file). Outside-review quality gate before filing (Claude Code on Codex; Codex on other harnesses; blocks below 7/10), fail-closed secret redaction, dedupe against existing issues, archive to `$GSTACK_STATE_ROOT/projects/$SLUG/specs/` for team-corpus recall. `--execute` spawns `claude -p` in a fresh worktree; `/ship` auto-closes the source issue on merge. Plan-mode aware. | | `/learn` | **Memory** | Manage what gstack learned across sessions. Review, search, prune, and export project-specific patterns, pitfalls, and preferences. Learnings compound across sessions so gstack gets smarter on your codebase over time. | diff --git a/TODOS.md b/TODOS.md index 4880439b9..683bc55f2 100644 --- a/TODOS.md +++ b/TODOS.md @@ -172,10 +172,6 @@ wave"). Each was explicitly deferred with rationale, not dropped: the harness-pinned agent-sdk) carry `ignoreUntil` expiries (~2026-11-30) and re-justify themselves on expiry. When the agent-sdk pin next moves, drop the GHSA-p7fg ignore. Effort S. **Priority:** P3. -- **#2701 cookie-import profile pills (Local State info_cache)** — confirmed - bug + minimal fix known, but PR #2658 rewrites the same file; land or - reject #2658 first, then apply the info_cache read + numeric-aware sort. - Effort S. **Priority:** P3. **Blocked by:** #2658 disposition. - **#2750 split absorption** — the record-scanning Codex JSONL parser (real fix; current Codex streams interleave envelopes so sessions vanish from /retro global) should be absorbed once the author splits it from the @@ -3587,6 +3583,14 @@ needs one paid run to validate, so it didn't ride the ship. ## Completed +### #2701 cookie-import profile pills (Local State info_cache) + +Current Local State names take precedence, Preferences/directory fallbacks remain, +and Default sorts before numbered profiles in numeric order. Directory labels +distinguish duplicate names. + +**Completed:** v1.90.0.0 (2026-09-24) + ### Reconcile the registered Opus 4.7 overlay efficacy gates **What:** Revisit the two registered fanout experiments against the current overlay diff --git a/VERSION b/VERSION index 944091f66..d944d03d1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.89.1.0 +1.90.0.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 52dc5ed46..145e20bd3 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.89.1.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.90.0.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, diff --git a/browse/SKILL.md b/browse/SKILL.md index 311649466..d53057538 100644 --- a/browse/SKILL.md +++ b/browse/SKILL.md @@ -432,6 +432,14 @@ goes through the Third-Party Web Actions contract, not through here. Rendering l HTML into a PNG or PDF is the render engine's job: use /make-pdf, /diagram, or /design-html for that. +On the fallback path, `/setup-browser-cookies` asks the user to choose the source +browser and account/profile; Dia is macOS-only. Navigate to the matching target +before a direct `--domain` import. Cookies copied is **not checked**, not proof +of login. `--verify-auth` requires a daemon-configured exact visible identity +assertion; HTTP 200 and cookie counts are not enough. Storage stays intact unless +the user explicitly approves `--clear-storage` for the captured origin on a Chromium target. Other engines reject reset, not ordinary import or auth checks. Do not +publish cookie values, passwords, profile/account text, or session details. + ## Fallback command reference The table in the Browser fallback section covers what the cookbook covers. Everything diff --git a/browse/SKILL.md.tmpl b/browse/SKILL.md.tmpl index f16b1f601..6300bb750 100644 --- a/browse/SKILL.md.tmpl +++ b/browse/SKILL.md.tmpl @@ -111,6 +111,14 @@ goes through the Third-Party Web Actions contract, not through here. Rendering l HTML into a PNG or PDF is the render engine's job: use /make-pdf, /diagram, or /design-html for that. +On the fallback path, `/setup-browser-cookies` asks the user to choose the source +browser and account/profile; Dia is macOS-only. Navigate to the matching target +before a direct `--domain` import. Cookies copied is **not checked**, not proof +of login. `--verify-auth` requires a daemon-configured exact visible identity +assertion; HTTP 200 and cookie counts are not enough. Storage stays intact unless +the user explicitly approves `--clear-storage` for the captured origin on a Chromium target. Other engines reject reset, not ordinary import or auth checks. Do not +publish cookie values, passwords, profile/account text, or session details. + ## Fallback command reference The table in the Browser fallback section covers what the cookbook covers. Everything diff --git a/browse/scripts/build-node-server.sh b/browse/scripts/build-node-server.sh index c7b1b09b7..0694782a7 100755 --- a/browse/scripts/build-node-server.sh +++ b/browse/scripts/build-node-server.sh @@ -37,8 +37,6 @@ bun build "$SRC_DIR/server.ts" \ # Step 2: Post-process # Replace import.meta.dir with a resolvable reference perl -pi -e 's/import\.meta\.dir/__browseNodeSrcDir/g' "$DIST_DIR/server-node.mjs" -# Stub out bun:sqlite (macOS-only cookie import, not needed on Windows) -perl -pi -e 's|import \{ Database \} from "bun:sqlite";|const Database = null; // bun:sqlite stubbed on Node|g' "$DIST_DIR/server-node.mjs" # Step 3: Create the final file with polyfill header injected after the first line { diff --git a/browse/sections/command-list.md b/browse/sections/command-list.md index f3fe9220e..4f15eb3e1 100644 --- a/browse/sections/command-list.md +++ b/browse/sections/command-list.md @@ -93,7 +93,7 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`. | `click ` | Click element | | `cookie =` | Set cookie on current page domain | | `cookie-import ` | Import cookies from JSON file | -| `cookie-import-browser [browser] [--domain d] [--profile p] [--all]` | Import cookies from installed Chromium-family browsers. Browser names are the installed browser IDs shown by detection; common values include comet, chrome, chromium, edge, brave, arc. With --domain, imports only that domain after current-page domain validation; without --domain, opens the picker UI. --profile defaults to Default; --all imports every non-expired cookie only when explicitly passed. | +| `cookie-import-browser [browser] [--domain d] [--profile p] [--all] [--clear-storage] [--verify-auth]` | Copy cookies from chrome, chromium, brave, edge, or macOS-only comet, arc, dia. Omitted browser retains legacy comet; select the intended browser explicitly. --domain requires a matching current page; no scope flag opens the picker. --profile is the source directory; ambiguous profiles require selection. --all explicitly selects every non-expired cookie and cannot accompany --domain or --clear-storage. Storage is preserved unless --clear-storage resets captured-origin localStorage (shared across context tabs) and target-tab sessionStorage. --verify-auth requires daemon GSTACK_COOKIE_AUTH_SELECTOR and GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY before startup; missing config rejects before mutation. Verified means an exact visible identity match, not cookie counts or HTTP 200. Windows native extraction remains disabled pending qualification. | | `dialog-accept [text]` | Auto-accept next alert/confirm/prompt. Optional text is sent as the prompt response | | `dialog-dismiss` | Auto-dismiss next dialog | | `fill ` | Fill input | diff --git a/browse/src/bun-polyfill.cjs b/browse/src/bun-polyfill.cjs index 053113e20..3b60cdd3f 100644 --- a/browse/src/bun-polyfill.cjs +++ b/browse/src/bun-polyfill.cjs @@ -159,10 +159,17 @@ globalThis.Bun = { parseInt(process.env.GSTACK_SPAWN_MAX_BUFFER || '', 10) || 16 * 1024 * 1024, ); const drain = (stream) => { - if (!stream) return { done: Promise.resolve(), chunks: [], truncated: false }; - const state = { chunks: [], bytes: 0, truncated: false }; + if (!stream) return { done: Promise.resolve(), chunks: [], cancel() {} }; + const state = { chunks: [], bytes: 0, truncated: false, cancelled: false, finished: false }; + let finish; const done = new Promise((resolve) => { + finish = () => { + if (state.finished) return; + state.finished = true; + resolve(); + }; stream.on('data', (chunk) => { + if (state.cancelled) return; if (state.bytes >= MAX_BUFFER) { state.truncated = true; return; } if (state.bytes + chunk.length <= MAX_BUFFER) { state.chunks.push(chunk); @@ -177,11 +184,18 @@ globalThis.Bun = { // Any terminal event resolves: 'end' on normal close, 'error' on a // stream-level error, 'close' as the belt-and-suspenders for spawn // failures where Node fires 'close' but neither 'end' nor 'error'. - stream.once('end', resolve); - stream.once('error', resolve); - stream.once('close', resolve); + stream.once('end', finish); + stream.once('error', finish); + stream.once('close', finish); }); - return { done, chunks: state.chunks }; + return { done, chunks: state.chunks, cancel() { + if (state.cancelled) return; + state.cancelled = true; + state.chunks.length = 0; + state.bytes = 0; + finish(); + stream.destroy(); + } }; }; const stdoutDrain = drain(proc.stdout); const stderrDrain = drain(proc.stderr); @@ -218,20 +232,29 @@ globalThis.Bun = { .then(() => resolveExited(exitStatus !== undefined ? exitStatus : 0)); }); - // Replay buffered output as a fresh Web ReadableStream. `start()` awaits + // Replay buffered output as a fresh Web ReadableStream. `start()` observes // the drain before enqueueing so `new Response(proc.stdout).text()` yields // the complete output regardless of whether the consumer reads before or // after awaiting `proc.exited`. Stream is single-shot (locked after one // read), matching Bun's behavior. - const replay = (d) => new ReadableStream({ - async start(controller) { - await d.done; - for (const chunk of d.chunks) { - controller.enqueue(chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk)); - } - controller.close(); - }, - }); + const replay = (d) => { + let cancelled = false; + return new ReadableStream({ + start(controller) { + d.done.then(() => { + if (cancelled) return; + for (const chunk of d.chunks) { + controller.enqueue(chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk)); + } + controller.close(); + }); + }, + cancel() { + cancelled = true; + d.cancel(); + }, + }); + }; return { pid: proc.pid, diff --git a/browse/src/cli.ts b/browse/src/cli.ts index 7c32c7fd6..980a311dd 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -823,7 +823,7 @@ export function extractTabId(args: string[]): { tabId: number | undefined; args: } // ─── Command Dispatch ────────────────────────────────────────── -async function sendCommand(state: ServerState, command: string, args: string[], retries = 0): Promise { +export async function sendCommand(state: ServerState, command: string, args: string[], retries = 0): Promise { // Precedence: CLI --tab-id flag > BROWSE_TAB env var. // make-pdf always passes --tab-id; human users typically rely on BROWSE_TAB // or the active tab. @@ -832,6 +832,7 @@ async function sendCommand(state: ServerState, command: string, args: string[], const envTab = process.env.BROWSE_TAB; const tabId = extracted.tabId ?? (envTab ? parseInt(envTab, 10) : undefined); const body = JSON.stringify({ command, args, ...(tabId !== undefined && !isNaN(tabId) ? { tabId } : {}) }); + const timeoutMs = command === 'cookie-import-browser' ? 90_000 : 30_000; try { const resp = await fetch(`http://127.0.0.1:${state.port}/command`, { @@ -841,10 +842,11 @@ async function sendCommand(state: ServerState, command: string, args: string[], 'Authorization': `Bearer ${state.token}`, }, body, - signal: AbortSignal.timeout(30000), + signal: AbortSignal.timeout(timeoutMs), }); if (resp.status === 401) { + if (command === 'cookie-import-browser') throw new Error('Cookie import authorization changed. Reopen the session and retry manually.'); // Token mismatch — server may have restarted console.error('[browse] Auth failed — server may have restarted. Retrying...'); const newState = readState(); @@ -871,6 +873,10 @@ async function sendCommand(state: ServerState, command: string, args: string[], process.exit(1); } } catch (err: any) { + if (command === 'cookie-import-browser' && (['AbortError', 'TimeoutError'].includes(err.name) + || ['ECONNREFUSED', 'ECONNRESET'].includes(err.code) || err.message?.includes('fetch failed'))) { + throw new Error('Cookie import response was lost or timed out. It may have partially completed; inspect the destination before retrying manually. The command was not replayed.'); + } if (err.name === 'AbortError') { // #1781: a 30s timeout on a heavy page usually means busy, not dead. // Don't kill a live server (that's what triggered the crash-loop) — report @@ -1535,7 +1541,7 @@ Interaction: click | fill | select scroll [sel] | wait | viewport upload [file2...] cookie-import - cookie-import-browser [browser] [--domain ] + cookie-import-browser [browser] [--domain | --all] [--profile

] [--clear-storage] [--verify-auth] Inspection: js | eval | css | attrs console [--clear|--errors] | network [--clear] | dialog [--clear] cookies | storage [set ] | perf diff --git a/browse/src/commands.ts b/browse/src/commands.ts index a7c9cbb79..d83019490 100644 --- a/browse/src/commands.ts +++ b/browse/src/commands.ts @@ -130,7 +130,7 @@ export const COMMAND_DESCRIPTIONS: Record] [--scale ]' }, 'cookie': { category: 'Interaction', description: 'Set cookie on current page domain', usage: 'cookie =' }, 'cookie-import': { category: 'Interaction', description: 'Import cookies from JSON file', usage: 'cookie-import ' }, - 'cookie-import-browser': { category: 'Interaction', description: 'Import cookies from installed Chromium-family browsers. Browser names are the installed browser IDs shown by detection; common values include comet, chrome, chromium, edge, brave, arc. With --domain, imports only that domain after current-page domain validation; without --domain, opens the picker UI. --profile defaults to Default; --all imports every non-expired cookie only when explicitly passed.', usage: 'cookie-import-browser [browser] [--domain d] [--profile p] [--all]' }, + 'cookie-import-browser': { category: 'Interaction', description: 'Copy cookies from chrome, chromium, brave, edge, or macOS-only comet, arc, dia. Omitted browser retains legacy comet; select the intended browser explicitly. --domain requires a matching current page; no scope flag opens the picker. --profile is the source directory; ambiguous profiles require selection. --all explicitly selects every non-expired cookie and cannot accompany --domain or --clear-storage. Storage is preserved unless --clear-storage resets captured-origin localStorage (shared across context tabs) and target-tab sessionStorage. --verify-auth requires daemon GSTACK_COOKIE_AUTH_SELECTOR and GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY before startup; missing config rejects before mutation. Verified means an exact visible identity match, not cookie counts or HTTP 200. Windows native extraction remains disabled pending qualification.', usage: 'cookie-import-browser [browser] [--domain d] [--profile p] [--all] [--clear-storage] [--verify-auth]' }, 'header': { category: 'Interaction', description: 'Set custom request header (colon-separated, sensitive values auto-redacted)', usage: 'header :' }, 'useragent': { category: 'Interaction', description: 'Set user agent', usage: 'useragent ' }, 'dialog-accept': { category: 'Interaction', description: 'Auto-accept next alert/confirm/prompt. Optional text is sent as the prompt response', usage: 'dialog-accept [text]' }, diff --git a/browse/src/cookie-auth-verification.ts b/browse/src/cookie-auth-verification.ts new file mode 100644 index 000000000..d8ef66b6a --- /dev/null +++ b/browse/src/cookie-auth-verification.ts @@ -0,0 +1,225 @@ +import { errors, type Page } from 'playwright'; +import { CookieImportError } from './cookie-import-browser'; +import { withCdpSession } from './cdp-bridge'; + +export interface CookieAuthVerificationOptions { + identitySelector?: string; + expectedIdentity?: string; + timeoutMs?: number; +} + +type VerificationReason = 'verified' | 'not_configured' | 'invalid_configuration' + | 'invalid_target' | 'target_closed' | 'target_changed' | 'login_redirect' + | 'http_error' | 'no_response' | 'identity_missing' | 'identity_ambiguous' + | 'identity_mismatch' | 'timeout' | 'verification_failed'; + +type VerificationResult = { verified: boolean; reason: VerificationReason; status?: number }; + +const MAX_TIMEOUT_MS = 15_000; +const LOGIN_PATH = /(?:^|\/)(?:log[-_]?in|sign[-_]?in|logon)(?:[\/.;]|$)|(?:^|\/)sessions?\/new(?:[\/.;]|$)/i; + +function validateOrigin(expectedOrigin: string): void { + try { + const target = new URL(expectedOrigin); + if ((target.protocol === 'http:' || target.protocol === 'https:') && target.origin === expectedOrigin) return; + } catch {} + throw new CookieImportError('A captured HTTP(S) target origin is required.', 'invalid_target'); +} + +export function validateCookieAuthOptions(options: CookieAuthVerificationOptions): void { + if (!options || typeof options.identitySelector !== 'string' || !options.identitySelector.trim() + || typeof options.expectedIdentity !== 'string' || !options.expectedIdentity.replace(/\s+/g, ' ').trim()) { + throw new CookieImportError('Authentication verification requires an identity selector and expected identity.', 'verification_not_configured'); + } + if (options.timeoutMs !== undefined && (typeof options.timeoutMs !== 'number' + || !Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0)) { + throw new CookieImportError('Authentication verification requires a positive finite timeout.', 'invalid_verification_config'); + } +} + +export async function verifyCookieAuthentication( + page: Page, + options: CookieAuthVerificationOptions, + expectedOrigin: string, +): Promise { + try { + validateCookieAuthOptions(options); + validateOrigin(expectedOrigin); + } catch (error) { + const reason = error instanceof CookieImportError && error.code === 'verification_not_configured' + ? 'not_configured' : error instanceof CookieImportError && error.code === 'invalid_target' + ? 'invalid_target' : 'invalid_configuration'; + return { verified: false, reason }; + } + + const timeoutMs = Math.min(options.timeoutMs ?? MAX_TIMEOUT_MS, MAX_TIMEOUT_MS); + const deadline = performance.now() + timeoutMs; + const expectedIdentity = options.expectedIdentity!.replace(/\s+/g, ' ').trim(); + let finished = false; + let status: number | undefined; + let lastFailure: VerificationReason = 'timeout'; + let timer: ReturnType; + const timeout = new Promise(resolve => { + timer = setTimeout(() => { + finished = true; + resolve({ verified: false, reason: lastFailure, ...(status === undefined ? {} : { status }) }); + }, timeoutMs); + }); + + try { + return await Promise.race([timeout, (async (): Promise => { + if (page.isClosed()) return { verified: false, reason: 'target_closed' }; + if (new URL(page.url()).origin !== expectedOrigin) return { verified: false, reason: 'target_changed' }; + const response = await page.reload({ waitUntil: 'domcontentloaded', timeout: Math.max(1, deadline - performance.now()) }); + if (finished || performance.now() >= deadline) return { verified: false, reason: 'timeout' }; + if (page.isClosed()) return { verified: false, reason: 'target_closed' }; + const loadedUrl = page.url(); + if (new URL(loadedUrl).origin !== expectedOrigin) return { verified: false, reason: 'target_changed' }; + if (LOGIN_PATH.test(decodeURIComponent(new URL(loadedUrl).pathname))) return { verified: false, reason: 'login_redirect' }; + if (!response) return { verified: false, reason: 'no_response' }; + status = response.status(); + if (status < 200 || status >= 300) return { verified: false, reason: 'http_error', status }; + for (let request = response.request(); request; request = request.redirectedFrom()!) { + const url = new URL(request.url()); + if (url.origin !== expectedOrigin) return { verified: false, reason: 'target_changed', status }; + if (LOGIN_PATH.test(decodeURIComponent(url.pathname))) return { verified: false, reason: 'login_redirect', status }; + } + if (new URL(response.url()).origin !== expectedOrigin + || new URL(response.url()).href !== new URL(loadedUrl.split('#')[0]).href) { + return { verified: false, reason: 'target_changed', status }; + } + + while (!finished && performance.now() < deadline) { + if (page.isClosed()) return { verified: false, reason: 'target_closed', status }; + if (page.url() !== loadedUrl) return { verified: false, reason: 'target_changed', status }; + const reason = await page.locator(options.identitySelector!).filter({ visible: true }).evaluateAll((elements, expected) => { + if (location.origin !== expected.origin || location.href !== expected.url) return 'target_changed'; + if (elements.length === 0) return 'identity_missing'; + if (elements.length !== 1) return 'identity_ambiguous'; + const element = elements[0]; + const text = element instanceof HTMLElement ? element.innerText : element.textContent ?? ''; + return text.replace(/\s+/g, ' ').trim() === expected.identity ? 'verified' : 'identity_mismatch'; + }, { origin: expectedOrigin, url: loadedUrl, identity: expectedIdentity }); + if (finished || performance.now() >= deadline) return { verified: false, reason: lastFailure, status }; + if (page.isClosed()) return { verified: false, reason: 'target_closed', status }; + if (page.url() !== loadedUrl) return { verified: false, reason: 'target_changed', status }; + if (reason === 'target_changed' || reason === 'verified') return { verified: reason === 'verified', reason, status }; + lastFailure = reason; + const remaining = deadline - performance.now(); + if (remaining <= 0) return { verified: false, reason, status }; + await new Promise(resolve => setTimeout(resolve, Math.min(50, remaining))); + } + return { verified: false, reason: lastFailure, ...(status === undefined ? {} : { status }) }; + })()]); + } catch (error) { + const reason = error instanceof errors.TimeoutError || finished || performance.now() >= deadline ? 'timeout' + : page.isClosed() ? 'target_closed' : 'verification_failed'; + return { verified: false, reason, ...(status === undefined ? {} : { status }) }; + } finally { + finished = true; + clearTimeout(timer!); + } +} + +export function validateCookieStorageSupport(page: Page): void { + try { + if (page.context().browser()?.browserType().name() === 'chromium') return; + } catch {} + throw new CookieImportError('Storage reset requires a Chromium target. Import cookies without storage reset on other browsers.', 'storage_reset_unsupported'); +} + +export async function clearCookieTargetStorage(page: Page, expectedOrigin: string): Promise { + validateOrigin(expectedOrigin); + validateCookieStorageSupport(page); + if (page.isClosed()) throw new CookieImportError('The captured target is closed.', 'target_closed'); + let targetUrl: string; + try { + targetUrl = page.url(); + if (new URL(targetUrl).origin !== expectedOrigin) { + throw new CookieImportError('The captured target has changed.', 'target_changed'); + } + } catch { + throw new CookieImportError('The captured target has changed.', 'target_changed'); + } + + const deadline = performance.now() + MAX_TIMEOUT_MS; + let expired = false; + let navigated = false; + const frame = page.mainFrame(); + const navigation = Promise.withResolvers(); + const onNavigation = (changed: typeof frame) => { + if (changed === frame) { navigated = true; navigation.resolve('target_changed'); } + }; + const onClose = () => { navigated = true; navigation.resolve('target_changed'); }; + page.on('framenavigated', onNavigation); + page.on('close', onClose); + const cancelled = () => expired || performance.now() >= deadline ? 'storage_reset_timeout' + : navigated || page.isClosed() || page.url() !== targetUrl ? 'target_changed' : undefined; + let timer: ReturnType; + const timeout = new Promise(resolve => { + timer = setTimeout(() => { + expired = true; + resolve('storage_reset_timeout'); + }, Math.max(0, deadline - performance.now())); + }); + const cancellation = Promise.race([timeout, navigation.promise]); + let result: string; + try { + result = await Promise.race([cancellation, withCdpSession(page, async session => { + let isolated: { id: number; uniqueId: string } | undefined; + let frameId: string | undefined; + const worldName = 'gstack-cookie-storage-reset'; + const onContext = ({ context }: any) => { + if (context.name === worldName && context.auxData?.frameId === frameId && context.auxData?.isDefault === false) isolated = context; + }; + session.on('Runtime.executionContextCreated', onContext); + try { + return await Promise.race([cancellation, (async () => { + if (cancelled()) return cancelled()!; + const tree = await session.send('Page.getFrameTree'); + if (cancelled()) return cancelled()!; + frameId = tree.frameTree.frame.id; + await session.send('Runtime.enable'); + if (cancelled()) return cancelled()!; + const world = await session.send('Page.createIsolatedWorld', { frameId, worldName, grantUniveralAccess: false }); + if (cancelled()) return cancelled()!; + if (!isolated?.uniqueId || isolated.id !== world.executionContextId) return 'storage_reset_failed'; + const uniqueContextId = isolated.uniqueId; + const clock = await session.send('Runtime.evaluate', { expression: 'performance.now()', uniqueContextId, returnByValue: true, silent: true }); + const remaining = deadline - performance.now(); + if (cancelled() || remaining <= 0) return cancelled() ?? 'storage_reset_timeout'; + if (clock.exceptionDetails || !Number.isFinite(clock.result?.value)) return 'storage_reset_failed'; + const cleared = await session.send('Runtime.callFunctionOn', { + uniqueContextId, + functionDeclaration: String(({ origin, url, deadline }: { origin: string; url: string; deadline: number }) => { + if (performance.now() >= deadline) return 'storage_reset_timeout'; + if (location.origin !== origin || location.href !== url) return 'target_changed'; + localStorage.clear(); + if (performance.now() >= deadline) return 'storage_reset_timeout'; + sessionStorage.clear(); + return 'cleared'; + }), + arguments: [{ value: { origin: expectedOrigin, url: targetUrl, deadline: clock.result.value + remaining } }], + returnByValue: true, + silent: true, + }); + if (cancelled()) return cancelled()!; + if (cleared.exceptionDetails || !['cleared', 'target_changed', 'storage_reset_timeout'].includes(cleared.result?.value)) return 'storage_reset_failed'; + return cleared.result.value; + })()]); + } finally { + session.off('Runtime.executionContextCreated', onContext); + } + })]); + } catch { + result = cancelled() ?? 'storage_reset_failed'; + } finally { + expired = true; + clearTimeout(timer!); + page.off('framenavigated', onNavigation); + page.off('close', onClose); + } + if (result === 'storage_reset_timeout') throw new CookieImportError('Target storage reset timed out; storage may be partially cleared.', 'storage_reset_timeout'); + if (result === 'target_changed') throw new CookieImportError('The captured target has changed.', 'target_changed'); + if (result !== 'cleared') throw new CookieImportError('Target storage reset failed; storage may be partially cleared.', 'storage_reset_failed'); +} diff --git a/browse/src/cookie-database.ts b/browse/src/cookie-database.ts new file mode 100644 index 000000000..ace69b88b --- /dev/null +++ b/browse/src/cookie-database.ts @@ -0,0 +1,73 @@ +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); + +function databaseError(error: unknown): Error & { code: string } { + const detail = error as { errcode?: number; errno?: number; code?: string } | null; + const codes: Record = { + 5: 'SQLITE_BUSY', 6: 'SQLITE_LOCKED', 8: 'SQLITE_READONLY', 10: 'SQLITE_IOERR', + 11: 'SQLITE_CORRUPT', 14: 'SQLITE_CANTOPEN', 26: 'SQLITE_CORRUPT', + }; + const number = detail?.errcode ?? detail?.errno; + const code = typeof number === 'number' ? codes[number & 255] ?? 'SQLITE_ERROR' + : Object.values(codes).includes(detail?.code ?? '') ? detail!.code! : 'SQLITE_ERROR'; + const message = code === 'SQLITE_BUSY' || code === 'SQLITE_LOCKED' + ? 'Cookie database is locked. Close the source browser and retry.' + : 'Cookie database operation failed (' + code + ').'; + return Object.assign(new Error(message), { code }); +} + +export function openCookieDatabase(dbPath: string): { + query(sql: string): { all(...bindings: any[]): unknown[] }; + close(): void; +} { + const isBun = typeof process.versions.bun === 'string'; + let Database; + try { + const sqlite = require(isBun ? 'bun:sqlite' : 'node:sqlite'); + Database = isBun ? sqlite.Database : sqlite.DatabaseSync; + if (typeof Database !== 'function') throw new Error(); + } catch { + throw Object.assign(new Error(isBun + ? 'Cookie import requires a Bun runtime with SQLite support. Upgrade Bun and retry.' + : 'Cookie import requires Node.js 22.13 or newer with built-in SQLite enabled. Upgrade Node.js or enable SQLite and retry.'), { code: 'sqlite_unavailable' }); + } + + let database; + try { + database = new Database(dbPath, isBun ? { readonly: true, safeIntegers: true } : { readOnly: true }); + } catch (error) { + throw databaseError(error); + } + + return { + query(sql) { + let statement; + try { + statement = isBun ? database.query(sql) : database.prepare(sql); + if (!isBun) statement.setReadBigInts(true); + } catch (error) { + throw databaseError(error); + } + return { + all(...bindings) { + try { + return statement.all(...bindings).map((row: Record) => Object.fromEntries( + Object.entries(row).map(([key, value]) => [key, + typeof value === 'bigint' && Number.isSafeInteger(Number(value)) ? Number(value) : value]), + )); + } catch (error) { + throw databaseError(error); + } + }, + }; + }, + close() { + try { + database.close(); + } catch (error) { + throw databaseError(error); + } + }, + }; +} diff --git a/browse/src/cookie-import-browser.ts b/browse/src/cookie-import-browser.ts index 2984d5772..66f6fd6e2 100644 --- a/browse/src/cookie-import-browser.ts +++ b/browse/src/cookie-import-browser.ts @@ -35,12 +35,12 @@ * └──────────────────────────────────────────────────────────────────┘ */ -import { Database } from 'bun:sqlite'; +import { openCookieDatabase } from './cookie-database'; import * as crypto from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { TEMP_DIR } from './platform'; +import { isIP } from 'node:net'; // ─── Types ────────────────────────────────────────────────────── @@ -69,6 +69,7 @@ export interface ImportResult { count: number; failed: number; domainCounts: Record; + failureReasons?: Record; } export interface PlaywrightCookie { @@ -109,6 +110,7 @@ const BROWSER_REGISTRY: BrowserInfo[] = [ { name: 'Chrome', dataDir: 'Google/Chrome/', keychainService: 'Chrome Safe Storage', aliases: ['chrome', 'google-chrome', 'google-chrome-stable'], linuxDataDir: 'google-chrome/', linuxApplication: 'chrome', windowsDataDir: 'Google/Chrome/User Data/' }, { name: 'Chromium', dataDir: 'chromium/', keychainService: 'Chromium Safe Storage', aliases: ['chromium'], linuxDataDir: 'chromium/', linuxApplication: 'chromium', windowsDataDir: 'Chromium/User Data/' }, { name: 'Arc', dataDir: 'Arc/User Data/', keychainService: 'Arc Safe Storage', aliases: ['arc'] }, + { name: 'Dia', dataDir: 'Dia/User Data/', keychainService: 'Dia Safe Storage', aliases: ['dia'] }, { name: 'Brave', dataDir: 'BraveSoftware/Brave-Browser/', keychainService: 'Brave Safe Storage', aliases: ['brave'], linuxDataDir: 'BraveSoftware/Brave-Browser/', linuxApplication: 'brave', windowsDataDir: 'BraveSoftware/Brave-Browser/User Data/' }, { name: 'Edge', dataDir: 'Microsoft Edge/', keychainService: 'Microsoft Edge Safe Storage', aliases: ['edge'], linuxDataDir: 'microsoft-edge/', linuxApplication: 'microsoft-edge', windowsDataDir: 'Microsoft/Edge/User Data/' }, ]; @@ -168,6 +170,11 @@ export function listProfiles(browserName: string): ProfileEntry[] { const browserDir = path.join(getBaseDir(platform), dataDir); if (!fs.existsSync(browserDir)) continue; + let profileNames: Record = {}; + try { + profileNames = JSON.parse(fs.readFileSync(path.join(browserDir, 'Local State'), 'utf-8'))?.profile?.info_cache ?? {}; + } catch {} + let entries: fs.Dirent[]; try { entries = fs.readdirSync(browserDir, { withFileTypes: true }); @@ -209,6 +216,9 @@ export function listProfiles(browserName: string): ProfileEntry[] { // Ignore — fall back to directory name } + const currentName = profileNames?.[entry.name]?.name; + if (typeof currentName === 'string' && currentName.trim()) displayName = currentName.trim(); + profiles.push({ name: entry.name, displayName }); } @@ -216,7 +226,48 @@ export function listProfiles(browserName: string): ProfileEntry[] { if (profiles.length > 0) break; } - return profiles; + return profiles.sort((a, b) => a.name === b.name ? 0 : a.name === 'Default' ? -1 : b.name === 'Default' ? 1 : a.name.localeCompare(b.name, 'en', { numeric: true })); +} + +export function normalizeCookieDomain(domain: string): string { + if (typeof domain !== 'string' || !domain || domain.length > 254 || /[\s\/@?#\\*]/.test(domain)) { + throw new CookieImportError('Invalid cookie domain', 'bad_request'); + } + let host = domain.replace(/^\./, '').replace(/\.$/, ''); + if (isIP(host) === 6) host = '[' + host + ']'; + try { + if (host.includes(':') && (!host.startsWith('[') || !host.endsWith(']') || isIP(host.slice(1, -1)) !== 6)) throw new Error(); + const url = new URL('http://' + host); + if (!url.hostname || url.hostname.length > 253 || url.port || url.pathname !== '/' || url.hostname.split('.').some(label => !label)) throw new Error(); + return url.hostname; + } catch { + throw new CookieImportError('Invalid cookie domain', 'bad_request'); + } +} + +export function cookieDomainMatches(hostname: string, cookieDomain: string): boolean { + const host = normalizeCookieDomain(hostname); + const domain = normalizeCookieDomain(cookieDomain); + const address = isIP(domain.startsWith('[') ? domain.slice(1, -1) : domain); + return host === domain || (!address && cookieDomain.startsWith('.') && host.endsWith('.' + domain)); +} + +export async function withCookieReadRetry(operation: () => T | Promise): Promise { + for (let attempt = 0; ; attempt++) { + try { + return await operation(); + } catch (err: any) { + if (attempt < 2 && ['db_locked', 'SQLITE_BUSY', 'SQLITE_LOCKED'].includes(err?.code)) { + await new Promise(resolve => setTimeout(resolve, [150, 500][attempt])); + continue; + } + if (['SQLITE_BUSY', 'SQLITE_LOCKED'].includes(err?.code)) throw new CookieImportError('Cookie database is busy. Close the source browser and retry.', 'db_locked', 'retry'); + if (err?.code === 'SQLITE_CORRUPT') throw new CookieImportError('Cookie database is corrupt', 'db_corrupt'); + if (err?.code === 'SQLITE_READONLY') throw new CookieImportError('Cookie database access was denied', 'db_permission'); + if (typeof err?.code === 'string' && err.code.startsWith('SQLITE_')) throw new CookieImportError('Cookie database could not be read', 'db_read_error'); + throw err; + } + } } /** @@ -251,15 +302,18 @@ export async function importCookies( ): Promise { if (domains.length === 0) return { cookies: [], count: 0, failed: 0, domainCounts: {} }; + const selectedDomains = [...new Set(domains.flatMap(domain => { + const normalized = normalizeCookieDomain(domain); + return [normalized, '.' + normalized]; + }))]; const browser = resolveBrowser(browserName); const match = getBrowserMatch(browser, profile); - const derivedKeys = await getDerivedKeys(match); const db = openDb(match.dbPath, browser.name); try { const now = chromiumNow(); // Parameterized query — no SQL injection - const placeholders = domains.map(() => '?').join(','); + const placeholders = selectedDomains.map(() => '?').join(','); const rows = db.query( `SELECT host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, has_expires, samesite @@ -267,11 +321,15 @@ export async function importCookies( WHERE host_key IN (${placeholders}) AND (has_expires = 0 OR expires_utc > ?) ORDER BY host_key, name` - ).all(...domains, now) as RawCookie[]; + ).all(...selectedDomains, now) as RawCookie[]; + + const needsKey = rows.some(row => !row.value && row.encrypted_value.length > 0 && Buffer.from(row.encrypted_value).subarray(0, 3).toString() !== 'v20'); + const derivedKeys = needsKey ? await getDerivedKeys(match) : new Map(); const cookies: PlaywrightCookie[] = []; let failed = 0; - const domainCounts: Record = {}; + const domainCounts: Record = Object.create(null); + const failureReasons: Record = {}; for (const row of rows) { try { @@ -279,12 +337,14 @@ export async function importCookies( const cookie = toPlaywrightCookie(row, value); cookies.push(cookie); domainCounts[row.host_key] = (domainCounts[row.host_key] || 0) + 1; - } catch { + } catch (err) { failed++; + const reason = err instanceof CookieImportError && err.code === 'v20_encryption' ? 'unsupported_encryption' : 'decryption_failed'; + failureReasons[reason] = (failureReasons[reason] || 0) + 1; } } - return { cookies, count: cookies.length, failed, domainCounts }; + return { cookies, count: cookies.length, failed, domainCounts, failureReasons }; } finally { db.close(); } @@ -384,7 +444,7 @@ function getBrowserMatch(browser: BrowserInfo, profile: string): BrowserMatch { // ─── Internal: SQLite Access ──────────────────────────────────── -function openDb(dbPath: string, browserName: string): Database { +function openDb(dbPath: string, browserName: string): ReturnType { // On Windows, Chrome holds exclusive WAL locks even when we open readonly. // The readonly open may "succeed" but return empty results because the WAL // (where all actual data lives) can't be replayed. Always use the copy @@ -393,45 +453,59 @@ function openDb(dbPath: string, browserName: string): Database { return openDbFromCopy(dbPath, browserName); } try { - return new Database(dbPath, { readonly: true }); + return openCookieDatabase(dbPath); } catch (err: any) { - if (err.message?.includes('SQLITE_BUSY') || err.message?.includes('database is locked')) { + if (err?.code === 'sqlite_unavailable') throw new CookieImportError(err.message, 'sqlite_unavailable'); + if (['SQLITE_BUSY', 'SQLITE_LOCKED'].includes(err?.code)) { return openDbFromCopy(dbPath, browserName); } - if (err.message?.includes('SQLITE_CORRUPT') || err.message?.includes('malformed')) { + if (err?.code === 'SQLITE_CORRUPT') { throw new CookieImportError( `Cookie database for ${browserName} is corrupt`, 'db_corrupt', ); } - throw err; + throw new CookieImportError('Cookie database could not be read', 'db_read_error'); } } -function openDbFromCopy(dbPath: string, browserName: string): Database { +function openDbFromCopy(dbPath: string, browserName: string): ReturnType { // Use os.tmpdir() instead of hardcoded /tmp for cross-platform support (#708) - const tmpPath = path.join(os.tmpdir(), `browse-cookies-${browserName.toLowerCase()}-${crypto.randomUUID()}.db`); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-cookies-')); + const tmpPath = path.join(tmpDir, 'Cookies'); try { + fs.chmodSync(tmpDir, 0o700); fs.copyFileSync(dbPath, tmpPath); + fs.chmodSync(tmpPath, 0o600); // Also copy WAL and SHM if they exist (for consistent reads) const walPath = dbPath + '-wal'; const shmPath = dbPath + '-shm'; - if (fs.existsSync(walPath)) fs.copyFileSync(walPath, tmpPath + '-wal'); - if (fs.existsSync(shmPath)) fs.copyFileSync(shmPath, tmpPath + '-shm'); + if (fs.existsSync(walPath)) { + fs.copyFileSync(walPath, tmpPath + '-wal'); + fs.chmodSync(tmpPath + '-wal', 0o600); + } + if (fs.existsSync(shmPath)) { + fs.copyFileSync(shmPath, tmpPath + '-shm'); + fs.chmodSync(tmpPath + '-shm', 0o600); + } - const db = new Database(tmpPath, { readonly: true }); + const db = openCookieDatabase(tmpPath); // Schedule cleanup after the DB is closed const origClose = db.close.bind(db); db.close = () => { - origClose(); - try { fs.unlinkSync(tmpPath); } catch {} - try { fs.unlinkSync(tmpPath + '-wal'); } catch {} - try { fs.unlinkSync(tmpPath + '-shm'); } catch {} + try { origClose(); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } }; return db; - } catch { + } catch (err: any) { // Clean up on failure - try { fs.unlinkSync(tmpPath); } catch {} + try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {} + if (err?.code === 'sqlite_unavailable') throw new CookieImportError(err.message, 'sqlite_unavailable'); + if (err?.code === 'SQLITE_CORRUPT') throw new CookieImportError('Cookie database is corrupt', 'db_corrupt'); + if (err?.code === 'EACCES' || err?.code === 'EPERM') throw new CookieImportError('Cookie database access denied', 'db_permission'); + if (err?.code === 'ENOENT') throw new CookieImportError('Cookie database no longer exists', 'db_missing'); + if (!/SQLITE_BUSY|SQLITE_LOCKED|database is locked|EBUSY/.test(String(err?.code) + String(err?.message))) { + throw new CookieImportError('Cookie database could not be read', 'db_read_error'); + } throw new CookieImportError( `Cookie database is locked (${browserName} may be running). Try closing ${browserName} first.`, 'db_locked', @@ -494,9 +568,8 @@ async function getWindowsAesKey(browser: BrowserInfo): Promise { try { localState = JSON.parse(fs.readFileSync(localStatePath, 'utf-8')); } catch (err) { - const reason = err instanceof Error ? `: ${err.message}` : ''; throw new CookieImportError( - `Cannot read Local State for ${browser.name} at ${localStatePath}${reason}`, + `Cannot read Local State for ${browser.name}`, 'keychain_error', ); } @@ -532,33 +605,61 @@ async function dpapiDecrypt(encryptedBytes: Buffer): Promise { stderr: 'pipe', }); - proc.stdin.write(encryptedBytes.toString('base64')); - proc.stdin.end(); - - const timeout = new Promise((_, reject) => - setTimeout(() => { - proc.kill(); - reject(new CookieImportError('DPAPI decryption timed out', 'keychain_timeout', 'retry')); - }, 10_000), - ); - try { - const exitCode = await Promise.race([proc.exited, timeout]); - const stdout = await new Response(proc.stdout).text(); + proc.stdin.write(encryptedBytes.toString('base64')); + proc.stdin.end(); + const { exitCode, stdout } = await readCredentialProcess(proc, 10_000, () => + new CookieImportError('DPAPI decryption timed out', 'keychain_timeout', 'retry')); if (exitCode !== 0) { - const stderr = await new Response(proc.stderr).text(); - throw new CookieImportError(`DPAPI decryption failed: ${stderr.trim()}`, 'keychain_error'); + throw new CookieImportError('DPAPI decryption failed', 'keychain_error'); } return Buffer.from(stdout.trim(), 'base64'); } catch (err) { if (err instanceof CookieImportError) throw err; throw new CookieImportError( - `DPAPI decryption failed: ${(err as Error).message}`, + 'DPAPI decryption failed', 'keychain_error', ); } } +async function readCredentialProcess( + proc: { exited: Promise; stdout: ReadableStream; stderr: ReadableStream; kill(): void }, + timeoutMs: number, + timeoutError: () => Error, +): Promise<{ exitCode: number; stdout: string; stderr: string }> { + const readers = [proc.stdout.getReader(), proc.stderr.getReader()]; + const read = async (reader: ReadableStreamDefaultReader): Promise => { + const chunks: Uint8Array[] = []; + let bytes = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) return Buffer.concat(chunks, bytes).toString('utf8'); + bytes += value.byteLength; + if (bytes > 64 * 1024) throw new Error('Credential process output exceeded the limit'); + chunks.push(value); + } + }; + let timer: ReturnType; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(timeoutError()), timeoutMs); + }); + try { + const [exitCode, stdout, stderr] = await Promise.race([ + Promise.all([proc.exited, read(readers[0]), read(readers[1])]), timeout, + ]); + return { exitCode, stdout, stderr }; + } catch (error) { + try { proc.kill(); } catch {} + for (const reader of readers) { + try { void reader.cancel().catch(() => {}); } catch {} + } + throw error; + } finally { + clearTimeout(timer!); + } +} + async function getMacKeychainPassword(service: string): Promise { // Use async Bun.spawn with timeout to avoid blocking the event loop. // macOS may show an Allow/Deny dialog that blocks until the user responds. @@ -567,21 +668,13 @@ async function getMacKeychainPassword(service: string): Promise { { stdout: 'pipe', stderr: 'pipe', windowsHide: true }, ); - const timeout = new Promise((_, reject) => - setTimeout(() => { - proc.kill(); - reject(new CookieImportError( + try { + const { exitCode, stdout, stderr } = await readCredentialProcess(proc, 10_000, () => + new CookieImportError( `macOS is waiting for Keychain permission. Look for a dialog asking to allow access to "${service}".`, 'keychain_timeout', 'retry', )); - }, 10_000), - ); - - try { - const exitCode = await Promise.race([proc.exited, timeout]); - const stdout = await new Response(proc.stdout).text(); - const stderr = await new Response(proc.stderr).text(); if (exitCode !== 0) { // Distinguish denied vs not found vs other @@ -600,7 +693,7 @@ async function getMacKeychainPassword(service: string): Promise { ); } throw new CookieImportError( - `Could not read Keychain: ${stderr.trim()}`, + 'Could not read Keychain', 'keychain_error', 'retry', ); @@ -610,7 +703,7 @@ async function getMacKeychainPassword(service: string): Promise { } catch (err) { if (err instanceof CookieImportError) throw err; throw new CookieImportError( - `Could not read Keychain: ${(err as Error).message}`, + 'Could not read Keychain', 'keychain_error', 'retry', ); @@ -640,15 +733,7 @@ async function getLinuxSecretPassword(browser: BrowserInfo): Promise { try { const proc = Bun.spawn(cmd, { stdout: 'pipe', stderr: 'pipe', windowsHide: true }); - const timeout = new Promise((_, reject) => - setTimeout(() => { - proc.kill(); - reject(new Error('timeout')); - }, timeoutMs), - ); - - const exitCode = await Promise.race([proc.exited, timeout]); - const stdout = await new Response(proc.stdout).text(); + const { exitCode, stdout } = await readCredentialProcess(proc, timeoutMs, () => new Error('timeout')); if (exitCode !== 0) return null; const password = stdout.trim(); @@ -752,295 +837,28 @@ function mapSameSite(value: number): 'Strict' | 'Lax' | 'None' { } -// ─── CDP-based Cookie Extraction (Windows v20 fallback) ──────── -// When App-Bound Encryption (v20) is detected, we launch Chrome headless -// with remote debugging and extract cookies via the DevTools Protocol. -// This only works when Chrome is NOT already running (profile lock). - -const CHROME_PATHS_WIN = [ - path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'Google', 'Chrome', 'Application', 'chrome.exe'), - path.join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'Google', 'Chrome', 'Application', 'chrome.exe'), -]; - -const EDGE_PATHS_WIN = [ - path.join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'Microsoft', 'Edge', 'Application', 'msedge.exe'), - path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'Microsoft', 'Edge', 'Application', 'msedge.exe'), -]; - -function findBrowserExe(browserName: string): string | null { - const candidates = browserName.toLowerCase().includes('edge') ? EDGE_PATHS_WIN : CHROME_PATHS_WIN; - for (const p of candidates) { - if (fs.existsSync(p)) return p; - } - return null; -} - -function isBrowserRunning(browserName: string): Promise { - const exe = browserName.toLowerCase().includes('edge') ? 'msedge.exe' : 'chrome.exe'; - return new Promise((resolve) => { - const proc = Bun.spawn(['tasklist', '/FI', `IMAGENAME eq ${exe}`, '/NH'], { - stdout: 'pipe', stderr: 'pipe', windowsHide: true, - }); - proc.exited.then(async () => { - const out = await new Response(proc.stdout).text(); - resolve(out.toLowerCase().includes(exe)); - }).catch(() => resolve(false)); - }); -} - -/** - * Extract cookies via Chrome DevTools Protocol. Launches Chrome headless with - * remote debugging on the user's real profile directory. Requires Chrome to be - * closed first (profile lock). - * - * v20 App-Bound Encryption binds decryption keys to the original user-data-dir - * path, so a temp copy of the profile won't work — Chrome silently discards - * cookies it can't decrypt. We must use the real profile. - */ export async function importCookiesViaCdp( browserName: string, domains: string[], profile = 'Default', ): Promise { if (domains.length === 0) return { cookies: [], count: 0, failed: 0, domainCounts: {} }; - if (process.platform !== 'win32') { - throw new CookieImportError('CDP extraction is only needed on Windows', 'not_supported'); - } - + if (process.platform !== 'win32') throw new CookieImportError('Native extraction is only supported on Windows', 'not_supported'); const browser = resolveBrowser(browserName); - const exePath = findBrowserExe(browser.name); - if (!exePath) { - throw new CookieImportError( - `Cannot find ${browser.name} executable. Install it or use /connect-chrome.`, - 'not_installed', - ); - } - - if (await isBrowserRunning(browser.name)) { - throw new CookieImportError( - `${browser.name} is running. Close it first so we can launch headless with your profile, or use /connect-chrome to control your real browser directly.`, - 'browser_running', - 'retry', - ); - } - - // Must use the real user data dir — v20 ABE keys are path-bound + validateProfile(profile); const dataDir = getDataDirForPlatform(browser, 'win32'); - if (!dataDir) throw new CookieImportError(`No Windows data dir for ${browser.name}`, 'not_installed'); - const userDataDir = path.join(getBaseDir('win32'), dataDir); - - // Launch Chrome headless with remote debugging on the real profile. - // - // Security posture of the debug port: - // - Chrome binds --remote-debugging-port to 127.0.0.1 by default. The - // port is NOT exposed to the network. Baseline threat: a local - // process running as the same user can connect. - // - Port is randomized in [9222, 9321] to avoid collisions with other - // Chrome-based tools. Not cryptographic — security relies on - // same-user-access baseline, not port secrecy. - // - Chrome is always killed in the finally block below (even on crash). - // - // KNOWN NON-GOAL (tracked as a separate hardening task for the next - // security wave): - // On Windows 10.15+ with App-Bound Encryption (v20) enabled, a - // same-user process that opens the cookie DB directly cannot decrypt - // v20 values — the DPAPI context is bound to the browser process. - // The CDP port bypasses that: `Network.getAllCookies` runs inside the - // browser, so any same-user process that connects to the debug port - // before we kill Chrome could exfiltrate decrypted v20 cookies. - // Fix direction: switch to `--remote-debugging-pipe` so the CDP - // transport is a parent/child stdio pipe, not TCP. Requires - // restructuring the extractCookiesViaCdp WebSocket client; deferred - // to a follow-up because the transport swap is non-trivial and the - // baseline threat is still "attacker already has same-user access." - // - // Debugging note: if this path starts failing after a Chrome update, - // check the Chrome version logged below — Chrome's ABE key format (v20) - // or /json/list shape can change between major versions. - const debugPort = 9222 + Math.floor(Math.random() * 100); - const chromeProc = Bun.spawn([ - exePath, - `--remote-debugging-port=${debugPort}`, - `--user-data-dir=${userDataDir}`, - `--profile-directory=${profile}`, - '--headless=new', - '--no-first-run', - '--disable-background-networking', - '--disable-default-apps', - '--disable-extensions', - '--disable-sync', - '--no-default-browser-check', - ], { stdout: 'pipe', stderr: 'pipe', windowsHide: true }); - - // Wait for Chrome to start, then find a page target's WebSocket URL. - // Network.getAllCookies is only available on page targets, not browser. - let wsUrl: string | null = null; - const startTime = Date.now(); - let loggedVersion = false; - while (Date.now() - startTime < 15_000) { - try { - // One-time version log for future diagnostics when Chrome changes v20 format. - if (!loggedVersion) { - try { - const versionResp = await fetch(`http://127.0.0.1:${debugPort}/json/version`); - if (versionResp.ok) { - const v = await versionResp.json() as { Browser?: string }; - console.log(`[cookie-import] CDP fallback: ${browser.name} ${v.Browser || 'unknown version'}`); - loggedVersion = true; - } - } catch {} - } - const resp = await fetch(`http://127.0.0.1:${debugPort}/json/list`); - if (resp.ok) { - const targets = await resp.json() as Array<{ type: string; webSocketDebuggerUrl?: string }>; - const page = targets.find(t => t.type === 'page'); - if (page?.webSocketDebuggerUrl) { - wsUrl = page.webSocketDebuggerUrl; - break; - } - } - } catch { - // Not ready yet - } - await new Promise(r => setTimeout(r, 300)); - } - - if (!wsUrl) { - chromeProc.kill(); - throw new CookieImportError( - `${browser.name} headless did not start within 15s`, - 'cdp_timeout', - 'retry', - ); - } - - try { - // Connect via CDP WebSocket - const cookies = await extractCookiesViaCdp(wsUrl, domains); - - const domainCounts: Record = {}; - for (const c of cookies) { - domainCounts[c.domain] = (domainCounts[c.domain] || 0) + 1; - } - - return { cookies, count: cookies.length, failed: 0, domainCounts }; - } finally { - chromeProc.kill(); - } -} - -async function extractCookiesViaCdp(wsUrl: string, domains: string[]): Promise { - return new Promise((resolve, reject) => { - const ws = new WebSocket(wsUrl); - let msgId = 1; - - const timeout = setTimeout(() => { - ws.close(); - reject(new CookieImportError('CDP cookie extraction timed out', 'cdp_timeout')); - }, 10_000); - - ws.onopen = () => { - // Enable Network domain first, then request all cookies - ws.send(JSON.stringify({ id: msgId++, method: 'Network.enable' })); - }; - - ws.onmessage = (event) => { - const data = JSON.parse(String(event.data)); - - // After Network.enable succeeds, request all cookies - if (data.id === 1 && !data.error) { - ws.send(JSON.stringify({ id: msgId, method: 'Network.getAllCookies' })); - return; - } - - if (data.id === msgId && data.result?.cookies) { - clearTimeout(timeout); - ws.close(); - - // Normalize domain matching: domains like ".example.com" match "example.com" and vice versa - const domainSet = new Set(); - for (const d of domains) { - domainSet.add(d); - domainSet.add(d.startsWith('.') ? d.slice(1) : '.' + d); - } - - const matched: PlaywrightCookie[] = []; - for (const c of data.result.cookies as CdpCookie[]) { - if (!domainSet.has(c.domain)) continue; - matched.push({ - name: c.name, - value: c.value, - domain: c.domain, - path: c.path || '/', - expires: c.expires === -1 ? -1 : c.expires, - secure: c.secure, - httpOnly: c.httpOnly, - sameSite: cdpSameSite(c.sameSite), - }); - } - resolve(matched); - } else if (data.id === msgId && data.error) { - clearTimeout(timeout); - ws.close(); - reject(new CookieImportError( - `CDP error: ${data.error.message}`, - 'cdp_error', - )); - } - }; - - ws.onerror = (err) => { - clearTimeout(timeout); - reject(new CookieImportError( - `CDP WebSocket error: ${(err as any).message || 'unknown'}`, - 'cdp_error', - )); - }; + if (!dataDir) throw new CookieImportError('This browser is not supported on Windows', 'not_supported'); + const { importNativeCookies } = await import('./cookie-import-native'); + const cookies = await importNativeCookies({ + browserName: browser.name, + userDataDir: path.join(getBaseDir('win32'), dataDir), + profile, + domains: [...new Set(domains.flatMap(domain => { + const normalized = normalizeCookieDomain(domain); + return [normalized, '.' + normalized]; + }))], }); -} - -interface CdpCookie { - name: string; - value: string; - domain: string; - path: string; - expires: number; - size: number; - httpOnly: boolean; - secure: boolean; - session: boolean; - sameSite: string; -} - -function cdpSameSite(value: string): 'Strict' | 'Lax' | 'None' { - switch (value) { - case 'Strict': return 'Strict'; - case 'Lax': return 'Lax'; - case 'None': return 'None'; - default: return 'Lax'; - } -} - -/** - * Check if a browser's cookie DB contains v20 (App-Bound) encrypted cookies. - * Quick check — reads a small sample, no decryption attempted. - */ -export function hasV20Cookies(browserName: string, profile = 'Default'): boolean { - if (process.platform !== 'win32') return false; - try { - const browser = resolveBrowser(browserName); - const match = getBrowserMatch(browser, profile); - const db = openDb(match.dbPath, browser.name); - try { - const rows = db.query('SELECT encrypted_value FROM cookies LIMIT 10').all() as Array<{ encrypted_value: Buffer | Uint8Array }>; - return rows.some(row => { - const ev = Buffer.from(row.encrypted_value); - return ev.length >= 3 && ev.slice(0, 3).toString('utf-8') === 'v20'; - }); - } finally { - db.close(); - } - } catch { - return false; - } + const domainCounts: Record = Object.create(null); + for (const cookie of cookies) domainCounts[cookie.domain] = (domainCounts[cookie.domain] || 0) + 1; + return { cookies, count: cookies.length, failed: 0, domainCounts }; } diff --git a/browse/src/cookie-import-native-integrity.ts b/browse/src/cookie-import-native-integrity.ts new file mode 100644 index 000000000..ed8105dc7 --- /dev/null +++ b/browse/src/cookie-import-native-integrity.ts @@ -0,0 +1,94 @@ +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import path from 'node:path'; + +export const NATIVE_QUALIFICATION_DATA = 'browse/src/cookie-import-native-qualification.json'; +export const NATIVE_BROWSER_VERSION_COMMAND = '$ErrorActionPreference = "Stop"; [Diagnostics.FileVersionInfo]::GetVersionInfo($env:GSTACK_QUALIFY_BROWSER_EXE).ProductVersion'; + +export const NATIVE_CODE_INPUTS = Object.freeze([ + 'browse/src/cookie-import-browser.ts', + 'browse/src/cookie-database.ts', + 'browse/src/cookie-import-native.ts', + 'browse/src/cookie-import-native-integrity.ts', + 'browse/src/cookie-import-native-job.ts', + 'browse/src/cookie-import-native-worker.ts', + 'browse/src/bun-polyfill.cjs', + 'browse/scripts/build-node-server.sh', + '.github/scripts/run-cookie-native-qualification.ps1', + 'browse/dist/server-node.mjs', + 'browse/dist/bun-polyfill.cjs', + 'browse/test/cookie-import-native.test.ts', + 'browse/test/cookie-import-native-job.test.ts', + 'browse/test/cookie-import-native-qualification.ts', + 'browse/test/fixtures/native-cookie-process.cjs', + 'browse/test/fixtures/native-cookie-launch.cjs', + 'browse/test/fixtures/native-cookie-process-observer.ts', + 'browse/test/fixtures/native-cookie-file-owners.ts', + 'browse/test/fixtures/native-cookie-remove-fixture.cjs', + 'node_modules/playwright/package.json', + 'node_modules/playwright/index.js', + 'node_modules/playwright-core/package.json', + 'node_modules/playwright-core/index.js', + 'node_modules/playwright-core/lib/bootstrap.js', + 'node_modules/playwright-core/lib/coreBundle.js', + 'node_modules/playwright-core/lib/utilsBundle.js', +]); + +export interface NativeQualifiedBuild { + browserName: 'Chrome' | 'Chromium' | 'Brave' | 'Edge'; + architecture: 'x64' | 'arm64'; + windowsRelease: string; + executableSha256: string; + nodeVersion: string; + bunVersion: string; + playwrightVersion: string; + sourceHashes: Record; +} + +async function readBoundedFile(file: string, deadline: number, maximumBytes: number): Promise { + if (!Number.isFinite(deadline) || Date.now() >= deadline) throw new Error('native_timeout'); + const cancellation = new AbortController(); + const timer = setTimeout(() => cancellation.abort(), Math.max(0, deadline - Date.now())); + const stream = createReadStream(file, { signal: cancellation.signal }); + const chunks: Buffer[] = []; + let size = 0; + try { + for await (const chunk of stream) { + size += chunk.length; + if (size > maximumBytes) throw new Error('native_unqualified'); + chunks.push(chunk); + } + if (Date.now() >= deadline) throw new Error('native_timeout'); + return Buffer.concat(chunks); + } catch (error) { + if (cancellation.signal.aborted) throw new Error('native_timeout'); + throw error; + } finally { + stream.destroy(); + clearTimeout(timer); + } +} + +export async function readNativeQualifications(root: string, deadline: number): Promise { + const builds = JSON.parse((await readBoundedFile(path.join(root, NATIVE_QUALIFICATION_DATA), deadline, 1024 * 1024)).toString('utf8')); + if (!Array.isArray(builds) || builds.some(build => !build || typeof build !== 'object')) throw new Error('native_unqualified'); + return builds; +} + +export async function hashNativeFile(file: string, deadline: number): Promise { + return createHash('sha256').update(await readBoundedFile(file, deadline, 64 * 1024 * 1024)).digest('hex'); +} + +export async function nativeCodeHashes(root: string, deadline: number): Promise> { + const hashes: Record = {}; + for (const file of NATIVE_CODE_INPUTS) hashes[file] = await hashNativeFile(path.join(root, file), deadline); + return hashes; +} + +export function nativeCodeMatches(expected: unknown, actual: Record): boolean { + if (!expected || typeof expected !== 'object' || Array.isArray(expected) || Object.keys(expected).length !== NATIVE_CODE_INPUTS.length) return false; + return NATIVE_CODE_INPUTS.every(file => { + const hash = (expected as Record)[file]; + return typeof hash === 'string' && /^[0-9a-f]{64}$/.test(hash) && hash === actual[file]; + }); +} diff --git a/browse/src/cookie-import-native-job.ts b/browse/src/cookie-import-native-job.ts new file mode 100644 index 000000000..60c678d2f --- /dev/null +++ b/browse/src/cookie-import-native-job.ts @@ -0,0 +1,161 @@ +import { randomUUID } from 'node:crypto'; + +const NATIVE_COOKIE_STAGES = [ + 'platform', 'ffi_import', 'ffi_open', 'job_create', 'job_limits', 'job_open', + 'process_open', 'job_assign', 'job_assigned', 'job_joined', 'process_close', 'job_query', 'job_terminate', 'job_close', + 'worker_boot', 'supervisor_input', 'runtime_check', 'member_start', 'member_input', 'member_decoded', 'member_exit', + 'node_start', 'node_spawned', 'node_exit', 'node_input', 'node_load', 'browser_launch', 'cookie_read', 'browser_close', +] as const; + +export interface NativeCookieDiagnostic { + stage: typeof NATIVE_COOKIE_STAGES[number]; + win32Error?: number; + lastStage?: typeof NATIVE_COOKIE_STAGES[number]; + exitCode?: number; + nodeExitCode?: number; + signal?: string; + memberMode?: boolean; + stderrBytes?: number; +} + +export class NativeCookieJobError extends Error { + readonly diagnostic: NativeCookieDiagnostic; + + constructor(stage: NativeCookieDiagnostic['stage'], win32Error?: number) { + super(stage === 'platform' ? 'native_supervision_unavailable' : 'native_supervision_failed'); + this.name = 'NativeCookieJobError'; + this.diagnostic = { stage, ...(Number.isInteger(win32Error) && win32Error! >= 0 && win32Error! <= 0xffffffff ? { win32Error } : {}) }; + } +} + +export function nativeCookieDiagnostic(error: unknown, fallback: NativeCookieDiagnostic['stage']): NativeCookieDiagnostic { + return error instanceof NativeCookieJobError ? error.diagnostic : { stage: fallback }; +} + +export function parseNativeCookieDiagnostic(value: unknown): NativeCookieDiagnostic | undefined { + if (!value || typeof value !== 'object') return undefined; + const candidate = value as NativeCookieDiagnostic; + if (!NATIVE_COOKIE_STAGES.includes(candidate.stage)) return undefined; + const diagnostic = new NativeCookieJobError(candidate.stage, candidate.win32Error).diagnostic; + if (NATIVE_COOKIE_STAGES.includes(candidate.lastStage!)) diagnostic.lastStage = candidate.lastStage; + for (const field of ['exitCode', 'nodeExitCode', 'stderrBytes'] as const) { + const value = candidate[field]; + if (typeof value === 'number' && Number.isInteger(value) && value >= (field === 'stderrBytes' ? 0 : -0x80000000) && value <= 0xffffffff) diagnostic[field] = value; + } + if (['SIGTERM', 'SIGKILL', 'SIGINT', 'SIGSEGV', 'SIGABRT', 'SIGBREAK', 'SIGHUP'].includes(candidate.signal!)) diagnostic.signal = candidate.signal; + if (typeof candidate.memberMode === 'boolean') diagnostic.memberMode = candidate.memberMode; + return diagnostic; +} + +export interface NativeCookieJob { + name: string; + terminate(): void; + activeProcesses(): number; + close(): void; +} + +export async function createNativeCookieJob(): Promise { + if (process.platform !== 'win32' || !['x64', 'arm64'].includes(process.arch)) { + throw new NativeCookieJobError('platform'); + } + const { api, ptr, closeLibrary } = await openKernel(); + const name = `Local\\gstack-cookie-${randomUUID()}`; + const wideName = Buffer.from(`${name}\0`, 'utf16le'); + let stage: NativeCookieDiagnostic['stage'] = 'job_create'; + let handle: number | bigint = 0; + let closed = false; + const close = () => { + if (closed) return; + closed = true; + try { + if (handle && !api.CloseHandle(handle)) throw new NativeCookieJobError('job_close', api.GetLastError()); + } finally { + closeLibrary(); + } + }; + try { + handle = api.CreateJobObjectW(null, ptr(wideName)); + const createError = api.GetLastError(); + if (!handle || createError === 183) throw new NativeCookieJobError('job_create', createError); + const limits = Buffer.alloc(144); + limits.writeUInt32LE(0x2000, 16); + stage = 'job_limits'; + if (!api.SetInformationJobObject(handle, 9, ptr(limits), limits.byteLength)) { + throw new NativeCookieJobError(stage, api.GetLastError()); + } + return { + name, + terminate() { + if (closed) throw new NativeCookieJobError('job_terminate'); + if (!api.TerminateJobObject(handle, 1)) throw new NativeCookieJobError('job_terminate', api.GetLastError()); + }, + activeProcesses() { + const accounting = Buffer.alloc(48); + if (closed) throw new NativeCookieJobError('job_query'); + if (!api.QueryInformationJobObject(handle, 1, ptr(accounting), accounting.byteLength, null)) throw new NativeCookieJobError('job_query', api.GetLastError()); + return accounting.readUInt32LE(40); + }, + close, + }; + } catch (error) { + try { close(); } catch {} + throw error instanceof NativeCookieJobError ? error : new NativeCookieJobError(stage); + } +} + +export async function joinNativeCookieJob(name: string, observe?: (stage: NativeCookieDiagnostic['stage']) => void): Promise { + if (process.platform !== 'win32' || !/^Local\\gstack-cookie-[0-9a-f-]{36}$/.test(name)) { + throw new NativeCookieJobError('job_open'); + } + observe?.('ffi_import'); + const { api, ptr, closeLibrary } = await openKernel(); + const wideName = Buffer.from(`${name}\0`, 'utf16le'); + let stage: NativeCookieDiagnostic['stage'] = 'job_open'; + let handle: number | bigint = 0; + let currentProcess: number | bigint = 0; + try { + observe?.(stage); + handle = api.OpenJobObjectW(1, 0, ptr(wideName)); + if (!handle) throw new NativeCookieJobError(stage, api.GetLastError()); + stage = 'process_open'; + observe?.(stage); + currentProcess = api.OpenProcess(0x0101, 0, process.pid); + if (!currentProcess) throw new NativeCookieJobError(stage, api.GetLastError()); + stage = 'job_assign'; + observe?.(stage); + if (!api.AssignProcessToJobObject(handle, currentProcess)) throw new NativeCookieJobError(stage, api.GetLastError()); + observe?.('job_assigned'); + } catch (error) { + throw error instanceof NativeCookieJobError ? error : new NativeCookieJobError(stage); + } finally { + let closeError: NativeCookieJobError | undefined; + observe?.('process_close'); + if (currentProcess && !api.CloseHandle(currentProcess)) closeError = new NativeCookieJobError('process_close', api.GetLastError()); + observe?.('job_close'); + if (handle && !api.CloseHandle(handle)) closeError ??= new NativeCookieJobError('job_close', api.GetLastError()); + closeLibrary(); + if (closeError) throw closeError; + } +} + +async function openKernel() { + let stage: NativeCookieDiagnostic['stage'] = 'ffi_import'; + try { + const { dlopen, FFIType, ptr } = await import('bun:ffi'); + stage = 'ffi_open'; + const library = dlopen('kernel32.dll', { + CreateJobObjectW: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.u64 }, + OpenJobObjectW: { args: [FFIType.u32, FFIType.i32, FFIType.ptr], returns: FFIType.u64 }, + SetInformationJobObject: { args: [FFIType.u64, FFIType.u32, FFIType.ptr, FFIType.u32], returns: FFIType.i32 }, + QueryInformationJobObject: { args: [FFIType.u64, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.ptr], returns: FFIType.i32 }, + AssignProcessToJobObject: { args: [FFIType.u64, FFIType.u64], returns: FFIType.i32 }, + OpenProcess: { args: [FFIType.u32, FFIType.i32, FFIType.u32], returns: FFIType.u64 }, + TerminateJobObject: { args: [FFIType.u64, FFIType.u32], returns: FFIType.i32 }, + CloseHandle: { args: [FFIType.u64], returns: FFIType.i32 }, + GetLastError: { args: [], returns: FFIType.u32 }, + }); + return { api: library.symbols, ptr, closeLibrary: () => library.close() }; + } catch { + throw new NativeCookieJobError(stage); + } +} diff --git a/browse/src/cookie-import-native-qualification.json b/browse/src/cookie-import-native-qualification.json new file mode 100644 index 000000000..fe51488c7 --- /dev/null +++ b/browse/src/cookie-import-native-qualification.json @@ -0,0 +1 @@ +[] diff --git a/browse/src/cookie-import-native-worker.ts b/browse/src/cookie-import-native-worker.ts new file mode 100644 index 000000000..afcc52686 --- /dev/null +++ b/browse/src/cookie-import-native-worker.ts @@ -0,0 +1,267 @@ +import { spawn } from 'node:child_process'; +import { createInterface } from 'node:readline'; +import { writeSync } from 'node:fs'; +import { createNativeCookieJob, joinNativeCookieJob, nativeCookieDiagnostic, parseNativeCookieDiagnostic, type NativeCookieDiagnostic, type NativeCookieJob } from './cookie-import-native-job'; +import type { PlaywrightCookie } from './cookie-import-browser'; + +export interface NativeCookieRequest { + nodeExecutable: string; + nodeArchitecture: string; + playwrightEntry: string; + executablePath: string; + userDataDir: string; + profile: string; + domains: string[]; + deadline: number; + qualifiedBunVersions: string[]; +} + +export type NativeCookieReply = + | { cookies: PlaywrightCookie[] } + | { error: 'native_timeout' | 'native_failed' | 'native_cleanup_failed' | 'native_supervision_failed' | 'browser_running' | 'native_profile_unsupported'; diagnostic?: NativeCookieDiagnostic }; + +export interface NativeCookieMember { + result: Promise; + closed: Promise; + stop(): void; +} + +const MAX_REPLY_BYTES = 8 * 1024 * 1024; +export const NATIVE_PROGRESS_PREFIX = 'GSTACK_NATIVE_PROGRESS '; + +function nativeProgress(diagnostic: NativeCookieDiagnostic): void { + try { writeSync(2, NATIVE_PROGRESS_PREFIX + JSON.stringify(diagnostic) + '\n'); } catch {} +} + +export function nativeCookieEnvironment(env: NodeJS.ProcessEnv): Record { + const allowed = new Set(['systemroot', 'windir', 'temp', 'tmp', 'userprofile', 'localappdata', 'appdata', 'programfiles', 'programfiles(x86)', 'programdata', 'path', 'pathext']); + return Object.fromEntries(Object.entries(env).filter(([key, value]) => allowed.has(key.toLowerCase()) && typeof value === 'string')) as Record; +} + +export const NATIVE_COOKIE_NODE_SCRIPT = String.raw` +const fs = require('node:fs'); +let stage = 'node_input'; +const progress = () => { try { fs.writeSync(2, 'GSTACK_NATIVE_PROGRESS ' + JSON.stringify({ stage }) + '\n'); } catch {} }; +progress(); +(async () => { + const request = JSON.parse(fs.readFileSync(0, 'utf8')); + stage = 'node_load'; + progress(); + const { chromium } = require(request.playwrightEntry); + let context; + try { + const remaining = request.deadline - Date.now(); + if (remaining <= 0) throw new Error('native_timeout'); + stage = 'browser_launch'; + progress(); + context = await chromium.launchPersistentContext(request.userDataDir, { + executablePath: request.executablePath, + args: ['--profile-directory=' + request.profile], + headless: true, + chromiumSandbox: true, + timeout: remaining, + handleSIGINT: false, + handleSIGTERM: false, + handleSIGHUP: false, + env: process.env, + }); + stage = 'cookie_read'; + progress(); + const selected = new Set(request.domains.map(domain => domain.toLowerCase().replace(/^\./, '').replace(/\.$/, ''))); + const cookies = (await context.cookies()).filter(cookie => selected.has(cookie.domain.toLowerCase().replace(/^\./, '').replace(/\.$/, ''))); + await new Promise(resolve => process.stdout.write(JSON.stringify({ cookies }) + '\n', resolve)); + } catch (error) { + const message = error instanceof Error ? error.message : ''; + const exited = message.match(//); + const exitCode = exited ? Number(exited[1]) : undefined; + const code = (stage === 'browser_launch' && exitCode === 21) || /ProcessSingleton|profile.*in use|user data directory is already in use|opening in existing browser session/i.test(message) + ? 'browser_running' + : /remote debugging requires a non-default data directory/i.test(message) + ? 'native_profile_unsupported' + : /Timeout|native_timeout/.test(message) ? 'native_timeout' : 'native_failed'; + await new Promise(resolve => process.stdout.write(JSON.stringify({ error: code, diagnostic: { stage, ...(Number.isInteger(exitCode) ? { exitCode } : {}) } }) + '\n', resolve)); + } finally { + stage = 'browser_close'; + progress(); + await context?.close().catch(() => {}); + } +})().catch(() => { process.stdout.write(JSON.stringify({ error: 'native_failed', diagnostic: { stage } }) + '\n'); process.exitCode = 1; }); +`; + +export async function superviseNativeCookieImport( + request: NativeCookieRequest, + dependencies: { + createJob?: () => Promise; + startMember?: (request: NativeCookieRequest, jobName: string) => NativeCookieMember; + now?: () => number; + sleep?: (milliseconds: number) => Promise; + signal?: AbortSignal; + } = {}, +): Promise { + const now = dependencies.now ?? Date.now; + const sleep = dependencies.sleep ?? (milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))); + const deadline = Math.min(request.deadline, now() + 25_000); + let job: NativeCookieJob | undefined; + let member: NativeCookieMember | undefined; + let reply: NativeCookieReply | undefined; + let closed = false; + try { + job = await (dependencies.createJob ?? createNativeCookieJob)(); + if (now() >= deadline || dependencies.signal?.aborted) return { error: 'native_timeout' }; + member = (dependencies.startMember ?? startMember)({ ...request, deadline }, job.name); + void member.result.then(value => { reply ??= value; }, () => { reply ??= { error: 'native_failed' }; }); + void member.closed.then(() => { closed = true; }, () => { closed = true; }); + while (!reply && !closed && now() < deadline && !dependencies.signal?.aborted) await sleep(Math.min(20, deadline - now())); + reply ??= { error: now() >= deadline ? 'native_timeout' : 'native_failed' }; + const cleanupDeadline = now() + 5_000; + const graceDeadline = now() + ('cookies' in reply ? 2_000 : 0); + while ((!closed || job.activeProcesses() !== 0) && now() < graceDeadline) await sleep(20); + if (job.activeProcesses() !== 0) job.terminate(); + while ((!closed || job.activeProcesses() !== 0) && now() < cleanupDeadline) await sleep(20); + if (!closed || job.activeProcesses() !== 0) return { error: 'native_cleanup_failed' }; + return reply; + } catch (error) { + return { error: job ? 'native_cleanup_failed' : 'native_supervision_failed', diagnostic: nativeCookieDiagnostic(error, job ? 'job_query' : 'job_create') }; + } finally { + let diagnostic: NativeCookieDiagnostic | undefined; + try { job?.close(); } catch (error) { diagnostic = nativeCookieDiagnostic(error, 'job_close'); } + try { member?.stop(); } catch (error) { diagnostic ??= nativeCookieDiagnostic(error, 'member_exit'); } + if (diagnostic) return { error: 'native_cleanup_failed', diagnostic }; + } +} + +function startMember(request: NativeCookieRequest, jobName: string, mode = '--member'): NativeCookieMember { + const child = spawn(process.execPath, ['--no-env-file', '--no-install', '--no-macros', '--config=NUL', import.meta.path, mode], { + env: nativeCookieEnvironment(process.env), + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }); + const closed = new Promise(resolve => child.once('close', () => resolve())); + let progressInput = ''; + let lastStage: NativeCookieDiagnostic['stage'] | undefined; + let memberMode: boolean | undefined; + let nodeExitCode: number | undefined; + let stderrBytes = 0; + child.stderr.on('data', chunk => { + stderrBytes = Math.min(0xffffffff, stderrBytes + chunk.length); + progressInput += chunk.toString('utf8'); + for (let end = progressInput.indexOf('\n'); end >= 0; end = progressInput.indexOf('\n')) { + const line = progressInput.slice(0, end); + progressInput = progressInput.slice(end + 1); + if (!line.startsWith(NATIVE_PROGRESS_PREFIX)) continue; + try { + const diagnostic = parseNativeCookieDiagnostic(JSON.parse(line.slice(NATIVE_PROGRESS_PREFIX.length))); + if (!diagnostic) continue; + lastStage = diagnostic.stage; + memberMode ??= diagnostic.memberMode; + if (diagnostic.stage === 'node_exit') nodeExitCode = diagnostic.exitCode; + } catch {} + } + if (progressInput.length > 4096) progressInput = ''; + }); + const result = new Promise(resolve => { + let output = ''; + child.stdout.setEncoding('utf8'); + child.stdout.on('data', chunk => { + output += chunk; + if (Buffer.byteLength(output) > MAX_REPLY_BYTES) { + resolve({ error: 'native_failed' }); + child.stdout.destroy(); + } else if (output.includes('\n')) { + try { + const parsed = JSON.parse(output.slice(0, output.indexOf('\n'))); + const errors = ['native_timeout', 'native_failed', 'native_cleanup_failed', 'native_supervision_failed', 'browser_running', 'native_profile_unsupported']; + const diagnostic = parseNativeCookieDiagnostic(parsed.diagnostic); + resolve(Array.isArray(parsed.cookies) ? { cookies: parsed.cookies } : { error: errors.includes(parsed.error) ? parsed.error : 'native_failed', ...(diagnostic ? { diagnostic } : {}) }); + } catch { + resolve({ error: 'native_failed' }); + } + } + }); + child.once('error', () => resolve({ error: 'native_failed', diagnostic: { stage: 'member_start' } })); + child.once('close', (code, signal) => resolve({ error: 'native_failed', diagnostic: parseNativeCookieDiagnostic({ stage: 'member_exit', exitCode: code, signal, lastStage, memberMode, nodeExitCode, stderrBytes }) })); + child.stdin.on('error', () => resolve({ error: 'native_failed', diagnostic: { stage: 'member_input' } })); + child.stdin.end(JSON.stringify({ request, jobName })); + }); + return { result, closed, stop: () => { if (child.exitCode === null && child.signalCode === null) child.kill(); } }; +} + +export async function probeNativeCookieMember(): Promise { + return superviseNativeCookieImport({ + nodeExecutable: '', nodeArchitecture: process.arch, playwrightEntry: '', executablePath: '', + userDataDir: '', profile: '', domains: [], deadline: Date.now() + 5_000, qualifiedBunVersions: [Bun.version], + }, { startMember: (request, jobName) => startMember(request, jobName, '--member-smoke') }); +} + +let mainStage: NativeCookieDiagnostic['stage'] = 'supervisor_input'; + +async function main(): Promise { + if (process.argv[2] === '--member' || process.argv[2] === '--member-smoke') { + mainStage = 'member_input'; + nativeProgress({ stage: mainStage }); + const serialized = await new Promise((resolve, reject) => { + let payload = ''; + let bytes = 0; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', chunk => { + bytes += Buffer.byteLength(chunk); + if (bytes > 1024 * 1024) { + reject(new Error('native_supervision_failed')); + process.stdin.destroy(); + return; + } + payload += chunk; + }); + process.stdin.once('end', () => resolve(payload)); + process.stdin.once('error', () => reject(new Error('native_supervision_failed'))); + process.stdin.once('close', () => reject(new Error('native_supervision_failed'))); + process.stdin.resume(); + }); + const input = JSON.parse(serialized); + nativeProgress({ stage: 'member_decoded' }); + await joinNativeCookieJob(input.jobName, stage => nativeProgress({ stage })); + nativeProgress({ stage: 'job_joined' }); + if (process.argv[2] === '--member-smoke') { + process.stdout.write(JSON.stringify({ cookies: [] }) + '\n', () => process.exit(0)); + return; + } + mainStage = 'node_start'; + nativeProgress({ stage: mainStage }); + const child = spawn(input.request.nodeExecutable, ['--input-type=commonjs', '-e', NATIVE_COOKIE_NODE_SCRIPT], { + env: nativeCookieEnvironment(process.env), + stdio: ['pipe', 'inherit', 'inherit'], + windowsHide: true, + }); + nativeProgress({ stage: 'node_spawned' }); + child.stdin.on('error', () => {}); + child.stdin.end(JSON.stringify(input.request)); + child.once('error', () => process.stdout.write(JSON.stringify({ error: 'native_failed', diagnostic: { stage: 'node_start' } }) + '\n', () => process.exit(1))); + child.once('close', (code, signal) => { + nativeProgress(parseNativeCookieDiagnostic({ stage: 'node_exit', exitCode: code, signal })!); + process.exit(code ?? 1); + }); + return; + } + const cancellation = new AbortController(); + const lines = createInterface({ input: process.stdin }); + lines.once('close', () => cancellation.abort()); + const input = await new Promise((resolve, reject) => { + lines.once('line', line => { + try { resolve(JSON.parse(line)); } catch { reject(new Error('native_supervision_failed')); } + }); + lines.once('close', () => reject(new Error('native_supervision_failed'))); + }); + mainStage = 'runtime_check'; + if (input.nodeArchitecture !== process.arch || !Array.isArray(input.qualifiedBunVersions) || !input.qualifiedBunVersions.includes(Bun.version)) { + throw new Error('native_supervision_failed'); + } + const result = await superviseNativeCookieImport(input, { signal: cancellation.signal }); + process.stdout.write(JSON.stringify(result) + '\n', () => process.exit(0)); +} + +if (import.meta.main) { + nativeProgress({ stage: 'worker_boot', memberMode: process.argv[2] === '--member' || process.argv[2] === '--member-smoke' }); + void main().catch(error => { + process.stdout.write(JSON.stringify({ error: 'native_supervision_failed', diagnostic: nativeCookieDiagnostic(error, mainStage) }) + '\n', () => process.exit(1)); + }); +} diff --git a/browse/src/cookie-import-native.ts b/browse/src/cookie-import-native.ts new file mode 100644 index 000000000..95ccbc6bc --- /dev/null +++ b/browse/src/cookie-import-native.ts @@ -0,0 +1,165 @@ +import { spawn } from 'node:child_process'; +import { realpathSync, statSync } from 'node:fs'; +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 { hashNativeFile, nativeCodeHashes, nativeCodeMatches, readNativeQualifications, type NativeQualifiedBuild } from './cookie-import-native-integrity'; + +type BrowserName = 'Chrome' | 'Chromium' | 'Brave' | 'Edge'; + +export function nativeBrowserPaths(browserName: string, env: NodeJS.ProcessEnv): { + name: BrowserName; + userDataDir: string; + executables: string[]; +} { + const get = (key: string) => Object.entries(env).find(([name]) => name.toLowerCase() === key.toLowerCase())?.[1]; + const local = get('LOCALAPPDATA'); + if (!local || !path.win32.isAbsolute(local)) { + throw new CookieImportError('The Windows browser data location is unavailable. Sign in manually in the gstack browser.', 'native_profile_unsupported'); + } + const pf = get('PROGRAMFILES') || 'C:\\Program Files'; + const pf86 = get('PROGRAMFILES(X86)') || 'C:\\Program Files (x86)'; + const mappings = { + chrome: { name: 'Chrome', root: ['Google', 'Chrome'], exe: 'chrome.exe', installs: [pf, pf86, local] }, + chromium: { name: 'Chromium', root: ['Chromium'], exe: 'chrome.exe', installs: [local] }, + brave: { name: 'Brave', root: ['BraveSoftware', 'Brave-Browser'], exe: 'brave.exe', installs: [pf, pf86, local] }, + edge: { name: 'Edge', root: ['Microsoft', 'Edge'], exe: 'msedge.exe', installs: [pf86, pf, local] }, + } as const; + const key = browserName.toLowerCase(); + if (!Object.hasOwn(mappings, key)) { + throw new CookieImportError('This browser has no supported Windows native-cookie mapping. Sign in manually in the gstack browser.', 'not_supported'); + } + const browser = mappings[key as keyof typeof mappings]; + return { + name: browser.name, + userDataDir: path.win32.join(local, ...browser.root, 'User Data'), + executables: [...new Set(browser.installs.map(root => path.win32.join(root, ...browser.root, 'Application', browser.exe)))], + }; +} + +export async function importNativeCookies(options: { + browserName: string; + userDataDir: string; + profile: string; + domains: string[]; +}): Promise { + let domains: string[]; + try { + if (!Array.isArray(options.domains)) throw new Error(); + domains = [...new Set(options.domains.map(normalizeCookieDomain))]; + } catch { + throw new CookieImportError('Native cookie import needs explicit valid domain selections.', 'invalid_domain'); + } + if (!domains.length) return []; + if (process.platform !== 'win32' || process.versions.bun) { + throw new CookieImportError('Native cookie import requires the Windows Node server. Sign in manually in the gstack browser.', 'not_supported'); + } + if (!/^(Default|Profile [0-9]+)$/.test(options.profile)) { + throw new CookieImportError('Select an existing browser profile before importing cookies.', 'invalid_profile'); + } + const browser = nativeBrowserPaths(options.browserName, process.env); + if (path.win32.resolve(options.userDataDir).toLowerCase() !== path.win32.resolve(browser.userDataDir).toLowerCase()) { + throw new CookieImportError('Native cookie import cannot substitute or copy the selected browser profile. Sign in manually in the gstack browser.', 'native_profile_unsupported'); + } + if (browser.name === 'Chrome') { + throw new CookieImportError('Chrome 136 and later block remote debugging of the default user-data directory, including every profile inside it, over both pipe and TCP. Default-directory extraction is unavailable; sign in manually in the gstack browser.', 'native_profile_unsupported'); + } + const deadline = Date.now() + 25_000; + let qualified: NativeQualifiedBuild[]; + try { + const root = path.resolve(import.meta.dir, '../..'); + const builds = await readNativeQualifications(root, deadline); + qualified = builds.filter(build => build.browserName === browser.name && build.nodeVersion === process.version && build.architecture === process.arch && build.windowsRelease === release()); + if (qualified.length) { + const hashes = await nativeCodeHashes(root, deadline); + qualified = qualified.filter(build => nativeCodeMatches(build.sourceHashes, hashes)); + } + } catch (error) { + throw new CookieImportError('The native-cookie qualification inputs could not be verified. Sign in manually in the gstack browser.', error instanceof Error && error.message === 'native_timeout' ? 'native_timeout' : 'native_unqualified'); + } + if (!qualified.length) { + throw new CookieImportError('Windows native cookie extraction is disabled until this browser and runtime pass native process-ownership and forced-cleanup qualification. Sign in manually in the gstack browser.', 'native_unqualified'); + } + const executablePath = browser.executables.find(candidate => { + try { return statSync(candidate).isFile(); } catch { return false; } + }); + if (!executablePath) throw new CookieImportError('The selected browser executable is not installed.', 'not_installed'); + try { + const profilePath = path.join(options.userDataDir, options.profile); + if (realpathSync(options.userDataDir).toLowerCase() !== path.win32.resolve(options.userDataDir).toLowerCase() + || realpathSync(profilePath).toLowerCase() !== path.win32.resolve(profilePath).toLowerCase() + || !statSync(profilePath).isDirectory()) { + throw new Error('invalid_profile'); + } + } catch { + throw new CookieImportError('The selected source profile is unavailable or redirects to another location.', 'native_profile_unsupported'); + } + const require = createRequire(import.meta.url); + let playwrightVersion: string; + let playwrightEntry: string; + let executableSha256: string; + try { + playwrightVersion = require('playwright/package.json').version; + playwrightEntry = require.resolve('playwright'); + executableSha256 = await hashNativeFile(executablePath, deadline); + } 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; } + }); + 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'); + } + if (Date.now() >= deadline) throw new CookieImportError('Native cookie import exceeded its operation deadline.', 'native_timeout'); + const env = Object.fromEntries(Object.entries(process.env).filter(([key, value]) => /^(systemroot|windir|temp|tmp|userprofile|localappdata|appdata|programfiles|programfiles\(x86\)|programdata|path|pathext)$/i.test(key) && typeof value === 'string')); + const worker = spawn(bunExecutable, ['--no-env-file', '--no-install', '--no-macros', '--config=NUL', path.join(import.meta.dir, 'cookie-import-native-worker.ts')], { + env, + stdio: ['pipe', 'pipe', 'ignore'], + windowsHide: true, + }); + return new Promise((resolve, reject) => { + let output = ''; + const timer = setTimeout(() => { + worker.kill(); + reject(new CookieImportError('Native cookie cleanup could not be confirmed within its deadline.', 'native_cleanup_failed')); + }, Math.max(0, deadline + 5_000 - Date.now())); + worker.stdout.setEncoding('utf8'); + worker.stdout.on('data', chunk => { + output += chunk; + if (Buffer.byteLength(output) > 8 * 1024 * 1024) worker.kill(); + }); + worker.once('error', () => { + clearTimeout(timer); + reject(new CookieImportError('Native cookie supervision could not start.', 'native_supervision_failed')); + }); + worker.once('close', () => { + clearTimeout(timer); + try { + const result = JSON.parse(output); + if (Array.isArray(result.cookies)) resolve(result.cookies); + else reject(new CookieImportError('Native cookie import did not complete. Sign in manually in the gstack browser.', ['native_timeout', 'native_failed', 'native_cleanup_failed', 'native_supervision_failed', 'browser_running', 'native_profile_unsupported'].includes(result.error) ? result.error : 'native_failed')); + } catch { + reject(new CookieImportError('Native cookie supervision did not return a complete result.', 'native_failed')); + } + }); + worker.stdin.on('error', () => {}); + worker.stdin.write(JSON.stringify({ + nodeExecutable: process.execPath, + nodeArchitecture: process.arch, + playwrightEntry, + executablePath, + userDataDir: options.userDataDir, + profile: options.profile, + domains, + deadline, + qualifiedBunVersions: qualified.filter(build => build.executableSha256 === executableSha256 && build.playwrightVersion === playwrightVersion).map(build => build.bunVersion), + }) + '\n'); + }); +} diff --git a/browse/src/cookie-import-operation.ts b/browse/src/cookie-import-operation.ts new file mode 100644 index 000000000..e33d072c0 --- /dev/null +++ b/browse/src/cookie-import-operation.ts @@ -0,0 +1,198 @@ +import type { Page } from 'playwright'; +import { + CookieImportError, cookieDomainMatches, importCookies, importCookiesViaCdp, + listDomains, listProfiles, normalizeCookieDomain, withCookieReadRetry, + type ProfileEntry, +} from './cookie-import-browser'; +import { clearCookieTargetStorage, validateCookieAuthOptions, validateCookieStorageSupport, verifyCookieAuthentication, type CookieAuthVerificationOptions } from './cookie-auth-verification'; + +export interface CookieImportTarget { + page: Page; + url: string; +} + +export interface CookieImportOptions { + browser: string; + domains?: string[]; + profile?: string; + all?: boolean; + clearStorage?: boolean; + verifyAuth?: boolean; +} + +const activeImports = new WeakSet(); + +export function parseCookieImportArgs(args: string[]): CookieImportOptions { + const options: CookieImportOptions = { browser: 'comet' }; + let browserSet = false; + const seen = new Set(); + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (!arg.startsWith('--')) { + if (browserSet) throw new CookieImportError('Specify only one source browser.', 'bad_request'); + options.browser = arg; + browserSet = true; + continue; + } + if (seen.has(arg)) throw new CookieImportError('Duplicate cookie-import option.', 'bad_request'); + seen.add(arg); + if (arg === '--domain' || arg === '--profile') { + const value = args[++i]; + if (!value || value.startsWith('--')) throw new CookieImportError('Cookie-import option requires a value.', 'bad_request'); + if (arg === '--domain') options.domains = [normalizeCookieDomain(value)]; + else options.profile = value; + } else if (arg === '--all') options.all = true; + else if (arg === '--clear-storage') options.clearStorage = true; + else if (arg === '--verify-auth') options.verifyAuth = true; + else throw new CookieImportError('Unknown cookie-import option.', 'bad_request'); + } + if (options.all && options.domains) throw new CookieImportError('Choose --domain or --all, not both.', 'bad_request'); + if (options.all && options.clearStorage) throw new CookieImportError('Storage reset requires a single target origin; --all is not supported.', 'bad_request'); + return options; +} + +export async function getCookieProfiles(browser: string, domains: string[] = [], hostname?: string) { + const profiles: Array = listProfiles(browser); + if (domains.length || hostname) { + const selected = domains.map(normalizeCookieDomain); + let index = 0; + const check = async () => { + while (index < profiles.length) { + const profile = profiles[index++]; + try { + const result = await withCookieReadRetry(() => listDomains(browser, profile.name)); + profile.matches = result.domains.some(entry => selected.length + ? selected.includes(normalizeCookieDomain(entry.domain)) + : cookieDomainMatches(hostname!, entry.domain)); + } catch (err) { + if (err instanceof CookieImportError && err.code === 'sqlite_unavailable') throw err; + profile.unavailable = true; + } + } + }; + await Promise.all([check(), check()]); + } + const matching = profiles.filter(profile => profile.matches === true); + const recommendedProfile = !profiles.some(profile => profile.unavailable) && matching.length === 1 + ? matching[0].name : profiles.length === 1 && profiles[0].matches !== false && !profiles[0].unavailable ? profiles[0].name : undefined; + return { profiles, recommendedProfile }; +} + +export function validateCookieTarget(target: CookieImportTarget): URL { + try { + const url = new URL(target.url); + if (!['http:', 'https:'].includes(url.protocol)) throw new Error(); + if (target.page.isClosed() || target.page.url() !== target.url) throw new Error(); + return url; + } catch { + throw new CookieImportError('The captured HTTP(S) target has changed or is unavailable. Reopen the picker on the intended page.', 'target_changed'); + } +} + +export async function runCookieImport( + options: CookieImportOptions, + target: CookieImportTarget, + trackDomains: (domains: string[]) => void, + authOptions: CookieAuthVerificationOptions = {}, +) { + for (const value of [options.all, options.clearStorage, options.verifyAuth]) { + if (value !== undefined && typeof value !== 'boolean') throw new CookieImportError('Cookie import options must be booleans.', 'bad_request'); + } + if (typeof options.browser !== 'string' || !options.browser.trim()) throw new CookieImportError('Select a source browser.', 'bad_request'); + if (options.profile !== undefined && (typeof options.profile !== 'string' || !options.profile)) throw new CookieImportError('Invalid source profile.', 'bad_request'); + if (options.all && options.domains || options.all && options.clearStorage) throw new CookieImportError('All-domain import cannot be combined with a scoped domain or storage reset.', 'bad_request'); + if (!options.all && (!Array.isArray(options.domains) || !options.domains.length)) throw new CookieImportError('Select at least one cookie domain.', 'bad_request'); + const selected = options.domains?.map(normalizeCookieDomain) ?? []; + const needsTarget = options.clearStorage || options.verifyAuth; + const targetUrl = needsTarget ? validateCookieTarget(target) : undefined; + if (options.clearStorage) validateCookieStorageSupport(target.page); + if (options.verifyAuth) validateCookieAuthOptions(authOptions); + if (targetUrl && selected.length && !selected.some(domain => cookieDomainMatches(targetUrl.hostname, '.' + domain))) { + throw new CookieImportError('The selected cookies do not match the captured target origin.', 'target_mismatch'); + } + const context = target.page.context(); + if (target.page.isClosed()) throw new CookieImportError('The captured target is closed.', 'target_closed'); + if (activeImports.has(context)) throw new CookieImportError('Another cookie import is still running. Wait before retrying.', 'import_busy', 'retry'); + activeImports.add(context); + try { + let profile = options.profile; + if (!profile) { + const suggestion = await getCookieProfiles(options.browser, selected); + profile = suggestion.recommendedProfile; + if (!profile) throw new CookieImportError('Choose a source profile explicitly; matching profiles are ambiguous, unavailable, or empty.', 'profile_required'); + } + const domains = options.all + ? (await withCookieReadRetry(() => listDomains(options.browser, profile!))).domains.map(entry => entry.domain) + : selected; + let result = await withCookieReadRetry(() => importCookies(options.browser, domains, profile)); + if (result.count === 0 && result.failureReasons?.unsupported_encryption && process.platform === 'win32') { + const failed = result.failed; + result = await importCookiesViaCdp(options.browser, domains, profile); + result.failed = Math.max(result.failed, failed - result.count); + if (result.failed) result.failureReasons = { native_unrecovered: result.failed }; + } + const receipt = { + browser: options.browser, + profile, + imported: 0, + failed: result.failed, + domainCounts: {} as Record, + failureReasons: result.failureReasons ?? {}, + outcome: (result.failed ? 'failed' : 'empty') as 'empty' | 'imported' | 'partial' | 'failed', + reset: 'not_requested' as 'not_requested' | 'cleared' | 'failed', + verification: { verified: false, reason: 'not_requested' } as { verified: boolean; reason: string; status?: number }, + message: result.failed ? 'No cookies imported; cookies could not be decrypted.' : 'No matching cookies found.', + }; + if (!result.count) { + if (options.verifyAuth) receipt.verification.reason = 'no_cookies_imported'; + return receipt; + } + if (targetUrl && !result.cookies.some(cookie => cookieDomainMatches(targetUrl.hostname, cookie.domain))) { + throw new CookieImportError('No imported cookies apply to the captured target origin.', 'target_mismatch'); + } + if (target.page.isClosed()) throw new CookieImportError('The captured target is closed.', 'target_closed'); + if (needsTarget) validateCookieTarget(target); + if (options.clearStorage) { + try { + await clearCookieTargetStorage(target.page, targetUrl!.origin); + receipt.reset = 'cleared'; + } catch { + receipt.outcome = 'failed'; + receipt.reset = 'failed'; + receipt.message = 'Storage reset did not complete; storage may be partially cleared. No new cookies were applied.'; + receipt.verification.reason = 'reset_failed'; + return receipt; + } + } + const appliedDomains = [...new Set(result.cookies.map(cookie => cookie.domain))]; + try { + await context.addCookies(result.cookies); + } catch { + trackDomains(appliedDomains); + receipt.outcome = 'failed'; + receipt.message = 'Cookie application failed; the browser may contain a partial import. Authentication was not verified.'; + receipt.verification.reason = 'application_failed'; + return receipt; + } + trackDomains(appliedDomains); + receipt.imported = result.count; + receipt.domainCounts = result.domainCounts; + receipt.outcome = result.failed ? 'partial' : 'imported'; + receipt.message = result.failed ? 'Some cookies could not be decrypted.' : 'Cookie copy complete.'; + if (options.verifyAuth) { + try { + validateCookieTarget(target); + receipt.verification = await verifyCookieAuthentication(target.page, authOptions, targetUrl!.origin); + } catch { + receipt.verification = { verified: false, reason: 'target_changed' }; + } + } + return receipt; + } finally { + activeImports.delete(context); + } +} + +export function formatCookieImportResult(result: Awaited>): string { + return `Imported ${result.imported} cookies from ${result.browser} (profile: ${result.profile}); ${result.failed} failed to decrypt. ${result.message} Storage reset: ${result.reset}. Authentication: ${result.verification.reason}.`; +} diff --git a/browse/src/cookie-picker-routes.ts b/browse/src/cookie-picker-routes.ts index 07ab5a2c2..0d07933bb 100644 --- a/browse/src/cookie-picker-routes.ts +++ b/browse/src/cookie-picker-routes.ts @@ -19,24 +19,34 @@ import * as crypto from 'crypto'; import type { BrowserManager } from './browser-manager'; -import { findInstalledBrowsers, listProfiles, listDomains, importCookies, importCookiesViaCdp, hasV20Cookies, CookieImportError, type PlaywrightCookie } from './cookie-import-browser'; +import { findInstalledBrowsers, listDomains, withCookieReadRetry, CookieImportError } from './cookie-import-browser'; import { getCookiePickerHTML } from './cookie-picker-ui'; +import { getCookieProfiles, runCookieImport, type CookieImportTarget } from './cookie-import-operation'; +import { validateCookieStorageSupport } from './cookie-auth-verification'; // ─── Auth State ───────────────────────────────────────────────── // One-time codes for the cookie picker UI (code → expiry timestamp). // Codes are generated by generatePickerCode() and consumed on first use. -const pendingCodes = new Map(); -const CODE_TTL_MS = 30_000; // 30 seconds +interface PickerContext { + target?: CookieImportTarget; + browser?: string; + profile?: string; + clearStorage?: boolean; + verifyAuth?: boolean; +} + +const pendingCodes = new Map(); +const CODE_TTL_MS = 5 * 60_000; // Session cookies for authenticated picker access (session → expiry timestamp). // Sessions are created after a valid code exchange and last 1 hour. -const validSessions = new Map(); +const validSessions = new Map(); const SESSION_TTL_MS = 3_600_000; // 1 hour /** Generate a one-time code for opening the cookie picker UI. */ -export function generatePickerCode(): string { +export function generatePickerCode(context: PickerContext = {}): string { const code = crypto.randomUUID(); - pendingCodes.set(code, Date.now() + CODE_TTL_MS); + pendingCodes.set(code, { ...context, expiry: Date.now() + CODE_TTL_MS }); return code; } @@ -44,13 +54,13 @@ export function generatePickerCode(): string { export function hasActivePicker(): boolean { const now = Date.now(); - for (const [code, expiry] of pendingCodes) { - if (expiry > now) return true; + for (const [code, context] of pendingCodes) { + if (context.expiry > now) return true; pendingCodes.delete(code); } - for (const [session, expiry] of validSessions) { - if (expiry > now) return true; + for (const [session, context] of validSessions) { + if (context.expiry > now) return true; validSessions.delete(session); } @@ -67,9 +77,9 @@ function getSessionFromCookie(req: Request): string | null { /** Check if a session cookie value is valid and not expired. */ function isValidSession(session: string): boolean { - const expiry = validSessions.get(session); - if (!expiry) return false; - if (Date.now() > expiry) { validSessions.delete(session); return false; } + const context = validSessions.get(session); + if (!context) return false; + if (Date.now() >= context.expiry) { validSessions.delete(session); return false; } return true; } @@ -121,7 +131,7 @@ export async function handleCookiePickerRoute( headers: { 'Access-Control-Allow-Origin': corsOrigin(port), 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Gstack-Picker-Instance', }, }); } @@ -133,8 +143,8 @@ export async function handleCookiePickerRoute( // Code exchange: validate one-time code, set session cookie, redirect if (code) { - const expiry = pendingCodes.get(code); - if (!expiry || Date.now() > expiry) { + const context = pendingCodes.get(code); + if (!context || Date.now() >= context.expiry) { pendingCodes.delete(code); return new Response('Invalid or expired code. Re-run cookie-import-browser.', { status: 403, @@ -143,7 +153,7 @@ export async function handleCookiePickerRoute( } pendingCodes.delete(code); // one-time use const session = crypto.randomUUID(); - validSessions.set(session, Date.now() + SESSION_TTL_MS); + validSessions.set(session, { ...context, expiry: Date.now() + SESSION_TTL_MS, pickerInstance: crypto.randomUUID() }); return new Response(null, { status: 302, headers: { @@ -157,7 +167,26 @@ export async function handleCookiePickerRoute( // Session cookie: serve HTML (no auth token inlined) const session = getSessionFromCookie(req); if (session && isValidSession(session)) { - const html = getCookiePickerHTML(port); + const context = validSessions.get(session)!; + let targetOrigin: string | undefined; + let storageResetAvailable = false; + try { + const url = new URL(context.target?.url ?? ''); + if (['http:', 'https:'].includes(url.protocol)) targetOrigin = url.origin; + } catch {} + if (context.target) { + try { validateCookieStorageSupport(context.target.page); storageResetAvailable = true; } catch {} + } + const html = getCookiePickerHTML(port, { + pickerInstance: context.pickerInstance, + browser: context.browser, + profile: context.profile, + clearStorage: context.clearStorage, + verifyAuth: context.verifyAuth, + targetOrigin, + storageResetAvailable, + verificationAvailable: !!process.env.GSTACK_COOKIE_AUTH_SELECTOR?.trim() && !!process.env.GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY?.trim(), + }); return new Response(html, { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' }, @@ -182,6 +211,13 @@ export async function handleCookiePickerRoute( headers: { 'Content-Type': 'application/json' }, }); } + if (req.method === 'POST' && !hasBearer && req.headers.get('origin') !== url.origin) { + return errorResponse('Cookie picker mutations require a same-origin request.', 'invalid_origin', { port, status: 403 }); + } + const pickerContext = !hasBearer && hasSession ? validSessions.get(sessionId!)! : undefined; + if (pickerContext && req.headers.get('X-Gstack-Picker-Instance') !== pickerContext.pickerInstance) { + return errorResponse('This picker no longer matches the active picker session. Reopen the picker from the intended page before continuing.', 'picker_changed', { port, status: 403 }); + } // GET /cookie-picker/browsers — list installed browsers if (pathname === '/cookie-picker/browsers' && req.method === 'GET') { @@ -200,8 +236,9 @@ export async function handleCookiePickerRoute( if (!browserName) { return errorResponse("Missing 'browser' parameter", 'missing_param', { port }); } - const profiles = listProfiles(browserName); - return jsonResponse({ profiles }, { port }); + let hostname: string | undefined; + try { hostname = new URL(pickerContext?.target?.url ?? '').hostname || undefined; } catch {} + return jsonResponse(await getCookieProfiles(browserName, [], hostname), { port }); } // GET /cookie-picker/domains?browser=&profile= — list domains + counts @@ -211,7 +248,7 @@ export async function handleCookiePickerRoute( return errorResponse("Missing 'browser' parameter", 'missing_param', { port }); } const profile = url.searchParams.get('profile') || 'Default'; - const result = listDomains(browserName, profile); + const result = await withCookieReadRetry(() => listDomains(browserName, profile)); return jsonResponse({ browser: result.browser, domains: result.domains, @@ -227,61 +264,27 @@ export async function handleCookiePickerRoute( return errorResponse('Invalid JSON body', 'bad_request', { port }); } - const { browser, domains, profile } = body; + const { browser, domains, profile, clearStorage, verifyAuth } = body ?? {}; if (!browser) return errorResponse("Missing 'browser' field", 'missing_param', { port }); if (!domains || !Array.isArray(domains) || domains.length === 0) { return errorResponse("Missing or empty 'domains' array", 'missing_param', { port }); } - // Decrypt cookies from the browser DB - const selectedProfile = profile || 'Default'; - let result = await importCookies(browser, domains, selectedProfile); - - // If all cookies failed and v20 encryption is detected, try CDP extraction - if (result.cookies.length === 0 && result.failed > 0 && hasV20Cookies(browser, selectedProfile)) { - console.log(`[cookie-picker] v20 App-Bound Encryption detected, trying CDP extraction...`); - try { - result = await importCookiesViaCdp(browser, domains, selectedProfile); - } catch (cdpErr: any) { - console.log(`[cookie-picker] CDP fallback failed: ${cdpErr.message}`); - return jsonResponse({ - imported: 0, - failed: result.failed, - domainCounts: {}, - message: `Cookies use App-Bound Encryption (v20). Close ${browser}, retry, or use /connect-chrome to browse with your real browser directly.`, - code: 'v20_encryption', - }, { port }); - } - } - - if (result.cookies.length === 0) { - return jsonResponse({ - imported: 0, - failed: result.failed, - domainCounts: {}, - message: result.failed > 0 - ? `All ${result.failed} cookies failed to decrypt` - : 'No cookies found for the specified domains', - }, { port }); - } - - // Add to Playwright context - const page = bm.getActiveSession().getPage(); - await page.context().addCookies(result.cookies); - - // Track what was imported + const page = pickerContext?.target?.page ?? bm.getActiveSession().getPage(); + const target = pickerContext?.target ?? { page, url: page.url() }; + const result = await runCookieImport({ + browser, domains, profile, + clearStorage: clearStorage ?? pickerContext?.clearStorage ?? false, + verifyAuth: verifyAuth ?? pickerContext?.verifyAuth ?? false, + }, target, domains => bm.trackCookieImportDomains(domains), { + identitySelector: process.env.GSTACK_COOKIE_AUTH_SELECTOR, + expectedIdentity: process.env.GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY, + }); for (const domain of Object.keys(result.domainCounts)) { importedDomains.add(domain); - importedCounts.set(domain, (importedCounts.get(domain) || 0) + result.domainCounts[domain]); + importedCounts.set(domain, result.domainCounts[domain]); } - - console.log(`[cookie-picker] Imported ${result.count} cookies for ${Object.keys(result.domainCounts).length} domains`); - - return jsonResponse({ - imported: result.count, - failed: result.failed, - domainCounts: result.domainCounts, - }, { port }); + return jsonResponse(result, { port }); } // POST /cookie-picker/remove — clear cookies for domains @@ -334,7 +337,7 @@ export async function handleCookiePickerRoute( if (err instanceof CookieImportError) { return errorResponse(err.message, err.code, { port, status: 400, action: err.action }); } - console.error(`[cookie-picker] Error: ${err.message}`); - return errorResponse(err.message || 'Internal error', 'internal_error', { port, status: 500 }); + console.error('[cookie-picker] Operation failed'); + return errorResponse('Cookie picker operation failed. Retry or reopen the picker.', 'internal_error', { port, status: 500 }); } } diff --git a/browse/src/cookie-picker-ui.ts b/browse/src/cookie-picker-ui.ts index bf151adbf..5f62886cb 100644 --- a/browse/src/cookie-picker-ui.ts +++ b/browse/src/cookie-picker-ui.ts @@ -7,8 +7,32 @@ * No cookie values exposed anywhere. */ -export function getCookiePickerHTML(serverPort: number): string { +export function getCookiePickerHTML(serverPort: number, options: { + pickerInstance?: string; + browser?: string; + profile?: string; + targetOrigin?: string; + verifyAuth?: boolean; + clearStorage?: boolean; + verificationAvailable?: boolean; + storageResetAvailable?: boolean; +} = {}): string { const baseUrl = `http://127.0.0.1:${serverPort}`; + let targetOrigin: string | undefined; + try { + const target = new URL(options.targetOrigin ?? ''); + if (['http:', 'https:'].includes(target.protocol)) targetOrigin = target.origin; + } catch {} + const config = JSON.stringify({ + pickerInstance: options.pickerInstance, + browser: options.browser, + profile: options.profile, + targetOrigin, + verifyAuth: options.verifyAuth === true, + clearStorage: options.clearStorage === true, + verificationAvailable: options.verificationAvailable === true, + storageResetAvailable: options.storageResetAvailable !== false, + }).replace(/[<>&\u2028\u2029]/g, character => '\\u' + character.charCodeAt(0).toString(16).padStart(4, '0')); return ` @@ -24,6 +48,8 @@ export function getCookiePickerHTML(serverPort: number): string { color: #e0e0e0; height: 100vh; overflow: hidden; + display: flex; + flex-direction: column; } /* ─── Header ──────────────────────────── */ @@ -58,7 +84,8 @@ export function getCookiePickerHTML(serverPort: number): string { /* ─── Layout ──────────────────────────── */ .container { display: flex; - height: calc(100vh - 53px); + flex: 1; + min-height: 0; } .panel { flex: 1; @@ -255,7 +282,7 @@ export function getCookiePickerHTML(serverPort: number): string { .banner { padding: 10px 20px; font-size: 13px; - display: none; + display: flex; align-items: center; gap: 10px; } @@ -269,6 +296,26 @@ export function getCookiePickerHTML(serverPort: number): string { border-bottom: 1px solid #112233; color: #60a5fa; } + .banner.warning { + background: #241b0a; + border-bottom: 1px solid #49330d; + color: #fbbf24; + } + .target-options { + border: 0; + border-bottom: 1px solid #222; + padding: 10px 24px; + font-size: 12px; + color: #aaa; + display: grid; + gap: 8px; + } + .target-options legend { padding-top: 10px; color: #ccc; } + .target-options label { display: flex; align-items: flex-start; gap: 8px; line-height: 1.5; } + .target-options input { margin-top: 3px; accent-color: #60a5fa; } + .target-options small { color: #888; } + button:disabled { opacity: 0.4; cursor: not-allowed; } + button:focus-visible, input:focus-visible { outline: 2px solid #60a5fa; outline-offset: 3px; } .banner .banner-text { flex: 1; } .banner .banner-close, .banner .banner-retry { background: none; @@ -309,9 +356,14 @@ export function getCookiePickerHTML(serverPort: number): string { localhost:${serverPort} -

Select the domains of cookies you want to import to GStack Browser. You'll be able to browse those sites with the same login as your other browser.

+

Copy cookies from the browser and profile you choose to this GStack Browser session. Copying cookies does not prove that you are signed in. Cookies are shared by tabs in this session.

- +
+ Captured target: No HTTP(S) target bound + + +
+
@@ -320,7 +372,7 @@ export function getCookiePickerHTML(serverPort: number): string {
- +
Detecting browsers...
@@ -338,15 +390,42 @@ export function getCookiePickerHTML(serverPort: number): string {
+ diff --git a/browse/src/write-commands.ts b/browse/src/write-commands.ts index 6382d8b03..113221102 100644 --- a/browse/src/write-commands.ts +++ b/browse/src/write-commands.ts @@ -7,8 +7,10 @@ import type { TabSession } from './tab-session'; import type { BrowserManager } from './browser-manager'; -import { findInstalledBrowsers, importCookies, importCookiesViaCdp, hasV20Cookies, listSupportedBrowserNames } from './cookie-import-browser'; +import { CookieImportError, cookieDomainMatches, findInstalledBrowsers, listSupportedBrowserNames } from './cookie-import-browser'; import { generatePickerCode } from './cookie-picker-routes'; +import { formatCookieImportResult, parseCookieImportArgs, runCookieImport, validateCookieTarget } from './cookie-import-operation'; +import { validateCookieAuthOptions, validateCookieStorageSupport } from './cookie-auth-verification'; import { validateNavigationUrl } from './url-validation'; import { validateOutputPath, validateReadPath } from './path-security'; import { guardScreenshotPath } from './screenshot-size-guard'; @@ -687,80 +689,50 @@ export async function handleWriteCommand( } case 'cookie-import-browser': { - // Two modes: - // 1. Direct CLI import: cookie-import-browser --domain [--profile ] - // Requires --domain (or --all to explicitly import everything). - // 2. Open picker UI: cookie-import-browser [browser] (interactive domain selection) - const browserArg = args[0]; - const domainIdx = args.indexOf('--domain'); - const profileIdx = args.indexOf('--profile'); - const hasAll = args.includes('--all'); - const profile = (profileIdx !== -1 && profileIdx + 1 < args.length) ? args[profileIdx + 1] : 'Default'; - - if (domainIdx !== -1 && domainIdx + 1 < args.length) { - // Direct import mode — scoped to specific domain - const domain = args[domainIdx + 1]; - // Validate --domain against current page hostname to prevent cross-site cookie injection - const pageHostname = new URL(page.url()).hostname; - const normalizedDomain = domain.startsWith('.') ? domain.slice(1) : domain; - if (normalizedDomain !== pageHostname && !pageHostname.endsWith('.' + normalizedDomain)) { - throw new Error(`--domain "${domain}" does not match current page domain "${pageHostname}". Navigate to the target site first.`); + const options = parseCookieImportArgs(args); + const target = { page, url: page.url() }; + const authOptions = { + identitySelector: process.env.GSTACK_COOKIE_AUTH_SELECTOR, + expectedIdentity: process.env.GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY, + }; + if (options.domains || options.all) { + if (options.domains) { + const targetUrl = validateCookieTarget(target); + if (!options.domains.every(domain => cookieDomainMatches(targetUrl.hostname, '.' + domain))) { + throw new CookieImportError('The requested cookie domain does not match the current page. Navigate to the target site first.', 'target_mismatch'); + } } - const browser = browserArg || 'comet'; - let result = await importCookies(browser, [domain], profile); - // If all cookies failed and v20 is detected, try CDP extraction - if (result.cookies.length === 0 && result.failed > 0 && hasV20Cookies(browser, profile)) { - result = await importCookiesViaCdp(browser, [domain], profile); + const result = await runCookieImport(options, target, domains => bm.trackCookieImportDomains(domains), authOptions); + const message = formatCookieImportResult(result); + if (result.outcome === 'failed' || options.verifyAuth && !result.verification.verified) { + throw new CookieImportError(message, 'cookie_import_incomplete'); } - if (result.cookies.length > 0) { - await page.context().addCookies(result.cookies); - bm.trackCookieImportDomains([domain]); - } - const msg = [`Imported ${result.count} cookies for ${domain} from ${browser}`]; - if (result.failed > 0) msg.push(`(${result.failed} failed to decrypt)`); - return msg.join(' '); + return message + (options.all ? ' Used --all: all source-profile cookie domains were selected.' : ''); } - if (hasAll) { - // Explicit all-cookies import — requires --all flag as a deliberate opt-in. - // Imports every non-expired cookie domain from the browser. - const browser = browserArg || 'comet'; - const { listDomains } = await import('./cookie-import-browser'); - const { domains } = listDomains(browser, profile); - const allDomainNames = domains.map((d: any) => d.domain); - if (allDomainNames.length === 0) { - return `No cookies found in ${browser} (profile: ${profile})`; - } - const result = await importCookies(browser, allDomainNames, profile); - if (result.cookies.length > 0) { - await page.context().addCookies(result.cookies); - bm.trackCookieImportDomains(allDomainNames); - } - const msg = [`Imported ${result.count} cookies across ${Object.keys(result.domainCounts).length} domains from ${browser}`]; - msg.push('(used --all: all browser cookies imported, consider --domain for tighter scoping)'); - if (result.failed > 0) msg.push(`(${result.failed} failed to decrypt)`); - return msg.join(' '); - } - - // Picker UI mode — open in user's browser for interactive domain selection const port = bm.serverPort; - if (!port) throw new Error('Server port not available'); - + if (!port) throw new CookieImportError('Server port not available', 'unavailable'); const browsers = findInstalledBrowsers(); - if (browsers.length === 0) { - throw new Error(`No Chromium browsers found. Supported: ${listSupportedBrowserNames().join(', ')}`); - } - - const code = generatePickerCode(); + if (browsers.length === 0) throw new CookieImportError(`No Chromium browsers found. Supported: ${listSupportedBrowserNames().join(', ')}`, 'not_installed'); + if (options.clearStorage || options.verifyAuth) validateCookieTarget(target); + if (options.clearStorage) validateCookieStorageSupport(page); + if (options.verifyAuth) validateCookieAuthOptions(authOptions); + const code = generatePickerCode({ + target, + browser: options.browser, + profile: options.profile, + clearStorage: options.clearStorage, + verifyAuth: options.verifyAuth, + }); const pickerUrl = `http://127.0.0.1:${port}/cookie-picker?code=${code}`; + const openCommand = process.platform === 'darwin' ? ['open', pickerUrl] + : process.platform === 'win32' ? ['cmd.exe', '/d', '/c', 'start', '', pickerUrl] : ['xdg-open', pickerUrl]; try { - Bun.spawn(['open', pickerUrl], { stdout: 'ignore', stderr: 'ignore', windowsHide: true }); - } catch (err: any) { - // open may fail on non-macOS or if 'open' binary is missing — URL is in the message below - if (err?.code !== 'ENOENT' && !err?.message?.includes('spawn')) throw err; + Bun.spawn(openCommand, { stdout: 'ignore', stderr: 'ignore', windowsHide: true }); + } catch { + throw new CookieImportError('Could not open the cookie picker in a local browser. Retry from a desktop session.', 'picker_open_failed'); } - - return `Cookie picker opened at http://127.0.0.1:${port}/cookie-picker\nDetected browsers: ${browsers.map(b => b.name).join(', ')}\nSelect domains to import, then close the picker when done.\n\nTip: For scripted imports, use --domain to scope cookies to a single domain.`; + return `Cookie picker opened at http://127.0.0.1:${port}/cookie-picker\nDetected browsers: ${browsers.map(b => b.name).join(', ')}\nSelect the source profile and domains. Cookies copied does not mean sign-in verified.`; } case 'style': { diff --git a/browse/test/bun-polyfill.test.ts b/browse/test/bun-polyfill.test.ts index 4028630e1..0b852da7b 100644 --- a/browse/test/bun-polyfill.test.ts +++ b/browse/test/bun-polyfill.test.ts @@ -1,5 +1,7 @@ import { describe, test, expect, afterAll, setDefaultTimeout } from 'bun:test'; import * as path from 'path'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; // Every test here spawnSync's a `node` child; Windows CI cold-start (AV scan, // first-touch of node.exe) alone can blow bun's 5s default — observed 5,007ms @@ -204,6 +206,118 @@ describe('bun-polyfill', () => { expect(result.stdout.toString().trim()).toBe('1048576:0'); }, 15000); + test('cancelled replay readers release inherited pipes after the direct child exits', () => { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'polyfill-cancel-'))); + const marker = path.join(root, 'descendant.pid'); + const pidPath = path.join(root, 'spawned.pid'); + expect(fs.realpathSync(root)).toBe(root); + try { + const descendantScript = ` + const fs = require('node:fs'); + fs.writeSync(1, 'fixture-stdout'); + fs.writeSync(2, 'fixture-stderr'); + fs.writeFileSync(${JSON.stringify(marker + '.tmp')}, JSON.stringify({ pid: process.pid, stdout: true, stderr: true })); + fs.renameSync(${JSON.stringify(marker + '.tmp')}, ${JSON.stringify(marker)}); + setInterval(() => {}, 1000); + `; + const childScript = ` + const { spawn } = require('node:child_process'); + const fs = require('node:fs'); + const descendant = spawn(process.execPath, ['-e', ${JSON.stringify(descendantScript)}], + { stdio: ['ignore', 'inherit', 'inherit'], windowsHide: true, detached: process.platform === 'win32' }); + fs.writeFileSync(${JSON.stringify(pidPath)}, String(descendant.pid)); + const deadline = Date.now() + 5000; + const ready = () => { + if (fs.existsSync(${JSON.stringify(marker)})) process.exit(0); + if (descendant.exitCode !== null || Date.now() >= deadline) process.exit(1); + setTimeout(ready, 10); + }; + ready(); + `; + const script = ` + const childProcess = require('node:child_process'); + const originalSpawn = childProcess.spawn; + let direct; + childProcess.spawn = (...args) => { direct = originalSpawn(...args); return direct; }; + require(${JSON.stringify(polyfillPath)}); + let stage = 'spawn'; + let directExitCode; + let markerValid = false; + let stdoutAck = false; + let stderrAck = false; + let descendantAlive = false; + let checkErrorCode = null; + (async () => { + const proc = Bun.spawn([process.execPath, '-e', ${JSON.stringify(childScript)}], + { stdio: ['ignore', 'pipe', 'pipe'] }); + if (!direct) throw new Error('capture_missing'); + const stdout = proc.stdout.getReader(); + const stderr = proc.stderr.getReader(); + const stdoutRead = stdout.read(); + const stderrRead = stderr.read(); + stage = 'direct_exit'; + directExitCode = await new Promise((resolve, reject) => { direct.once('exit', resolve); direct.once('error', reject); }); + let readyPid; + try { + const fs = require('node:fs'); + const marker = JSON.parse(fs.readFileSync(${JSON.stringify(marker)}, 'utf8')); + readyPid = marker.pid; + markerValid = Number.isSafeInteger(readyPid) && readyPid > 0 + && String(readyPid) === fs.readFileSync(${JSON.stringify(pidPath)}, 'utf8'); + stdoutAck = marker.stdout === true; + stderrAck = marker.stderr === true; + } catch (error) { checkErrorCode = typeof error.code === 'string' ? error.code : 'invalid_marker'; } + if (markerValid) { + try { process.kill(readyPid, 0); descendantAlive = true; } + catch (error) { checkErrorCode = typeof error.code === 'string' ? error.code : 'liveness_error'; } + } + if (!markerValid || !stdoutAck || !stderrAck || !descendantAlive) throw new Error('descendant_not_ready'); + stage = 'pending_check'; + await new Promise(resolve => setImmediate(resolve)); + let settled = false; + proc.exited.then(() => { settled = true; }); + await new Promise(resolve => setImmediate(resolve)); + if (settled) throw new Error('Inherited pipes unexpectedly closed before cancellation'); + stage = 'cancel'; + await Promise.all([stdout.cancel(), stderr.cancel()]); + const reads = await Promise.all([stdoutRead, stderrRead]); + stage = 'await_exited'; + let timer; + const code = await Promise.race([proc.exited, new Promise((_, reject) => + { timer = setTimeout(() => reject(new Error('cancel did not settle exited')), 5000); })]) + .finally(() => clearTimeout(timer)); + console.log(JSON.stringify({ code, directExitCode, reads: reads.map(read => read.done), descendantAlive: (() => { + try { process.kill(JSON.parse(require('node:fs').readFileSync(${JSON.stringify(marker)}, 'utf8')).pid, 0); return true; } + catch { return false; } + })() })); + })().catch(error => { + const reason = error.message === 'Inherited pipes unexpectedly closed before cancellation' ? 'early_pipes' + : error.message === 'cancel did not settle exited' ? 'cancel_stalled' + : error.message === 'capture_missing' ? 'capture_missing' + : error.message === 'descendant_not_ready' ? 'descendant_not_ready' : 'unexpected'; + console.error(JSON.stringify({ stage, reason, directExitCode, markerValid, stdoutAck, stderrAck, + descendantAlive, checkErrorCode, errorCode: typeof error.code === 'string' ? error.code : null })); + process.exitCode = 1; + }); + `; + const result = Bun.spawnSync(['node', '-e', script], { stdout: 'pipe', stderr: 'pipe', timeout: 30_000 }); + const errorOutput = result.stderr.toString().trim(); + let diagnostic: object | null = null; + try { if (errorOutput) diagnostic = JSON.parse(errorOutput); } + catch { diagnostic = { stage: 'unframed', stderrBytes: Buffer.byteLength(errorOutput) }; } + expect({ exitCode: result.exitCode, diagnostic }).toEqual({ exitCode: 0, diagnostic: null }); + expect(JSON.parse(result.stdout.toString())).toEqual({ code: 0, directExitCode: 0, reads: [true, true], descendantAlive: true }); + } finally { + if (fs.existsSync(pidPath)) { + const pidText = fs.readFileSync(pidPath, 'utf8'); + if (/^[1-9]\d*$/.test(pidText)) { + try { process.kill(Number(pidText)); } catch (error: any) { if (error.code !== 'ESRCH') throw error; } + } + } + fs.rmSync(root, { recursive: true, force: true }); + } + }); + test('Bun.serve creates an HTTP server that responds', async () => { const result = Bun.spawnSync(['node', '-e', ` require(${JSON.stringify(polyfillPath)}); diff --git a/browse/test/cookie-auth-verification.test.ts b/browse/test/cookie-auth-verification.test.ts new file mode 100644 index 000000000..135d22d8d --- /dev/null +++ b/browse/test/cookie-auth-verification.test.ts @@ -0,0 +1,760 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test'; +import { runInNewContext } from 'node:vm'; +import { EventEmitter } from 'node:events'; +import { chromium, errors, type Browser, type BrowserContext, type Page } from 'playwright'; +import { CookieImportError } from '../src/cookie-import-browser'; +import { clearCookieTargetStorage, validateCookieAuthOptions, validateCookieStorageSupport, verifyCookieAuthentication } from '../src/cookie-auth-verification'; + +const identity = 'Synthetic Account 472'; +const options = { identitySelector: '.identity', expectedIdentity: identity, timeoutMs: 400 }; +const unitOrigin = 'https://fixture.test'; +const credentialUrl = new URL(unitOrigin); +credentialUrl.username = 'fixture-user'; +credentialUrl.password = 'fixture'; +credentialUrl.searchParams.set('token', 'synthetic-token'); + +function mockPage() { + const evaluateAll = mock(async () => 'verified'); + const request = { url: () => `${unitOrigin}/protected`, redirectedFrom: () => null }; + const events = new EventEmitter(); + const frame = {}; + const storage = { engine: 'chromium', now: 100, deadline: 0, origin: unitOrigin, url: request.url(), + localClear: mock(() => {}), sessionClear: mock(() => {}), nativeNow: mock(() => storage.now) }; + const cdpEvents = new EventEmitter(); + const cdp = { + on: cdpEvents.on.bind(cdpEvents), + off: cdpEvents.off.bind(cdpEvents), + detach: mock(async () => {}), + send: mock(async (method: string, params?: any): Promise => { + if (method === 'Page.getFrameTree') return { frameTree: { frame: { id: 'fixture-frame' } } }; + if (method === 'Page.createIsolatedWorld') { + cdpEvents.emit('Runtime.executionContextCreated', { context: { name: params.worldName, id: 7, + uniqueId: 'fixture-unique-world', auxData: { frameId: 'fixture-frame', isDefault: false } } }); + return { executionContextId: 7 }; + } + if (method === 'Runtime.evaluate') return { result: { value: storage.nativeNow() } }; + if (method === 'Runtime.callFunctionOn') { + const arg = params.arguments[0].value; + storage.deadline = arg.deadline; + const value = runInNewContext(`(${params.functionDeclaration})(arg)`, { arg, + performance: { now: storage.nativeNow }, Date: { now: () => 0 }, + location: { origin: storage.origin, href: storage.url }, + localStorage: { clear: storage.localClear }, sessionStorage: { clear: storage.sessionClear }, + }); + return { result: { value } }; + } + return {}; + }), + }; + const context = { + browser: () => ({ browserType: () => ({ name: () => storage.engine }) }), + newCDPSession: mock(async () => cdp), + }; + const page = { + isClosed: mock(() => false), + url: mock(() => `${unitOrigin}/protected`), + reload: mock(async () => ({ status: () => 200, url: request.url, request: () => request })), + locator: mock(() => ({ filter: () => ({ evaluateAll }) })), + evaluate: mock(async () => 'cleared'), + context: () => context, + mainFrame: () => frame, + on: events.on.bind(events), + off: events.off.bind(events), + }; + return { page: page as unknown as Page, calls: page, evaluateAll, storage, cdp, context, events, frame, cdpEvents }; +} + +describe('cookie auth configuration and bounded operations', () => { + for (const [index, invalid] of [undefined, {}, { identitySelector: '.identity' }, { expectedIdentity: identity }, + { identitySelector: ' ', expectedIdentity: identity }, { identitySelector: '.identity', expectedIdentity: '\n\t' }, + { identitySelector: 7, expectedIdentity: identity }, { identitySelector: '.identity', expectedIdentity: [] }].entries()) { + test(`rejects incomplete configuration case ${index + 1}`, () => { + try { + validateCookieAuthOptions(invalid as any); + throw new Error('Expected configuration rejection'); + } catch (error) { + expect(error).toBeInstanceOf(CookieImportError); + expect((error as CookieImportError).code).toBe('verification_not_configured'); + expect(String(error)).not.toContain(identity); + } + }); + } + + for (const timeoutMs of [0, -1, NaN, Infinity, '200']) { + test(`rejects invalid timeout ${String(timeoutMs)}`, async () => { + const { page, calls } = mockPage(); + expect(() => validateCookieAuthOptions({ ...options, timeoutMs } as any)).toThrow(CookieImportError); + expect(await verifyCookieAuthentication(page, { ...options, timeoutMs } as any, unitOrigin)) + .toEqual({ verified: false, reason: 'invalid_configuration' }); + expect(calls.reload).not.toHaveBeenCalled(); + }); + } + + test('requires explicit configuration without reloading or touching storage', async () => { + const { page, calls } = mockPage(); + expect(await verifyCookieAuthentication(page, {}, unitOrigin)).toEqual({ verified: false, reason: 'not_configured' }); + expect(calls.reload).not.toHaveBeenCalled(); + expect(calls.evaluate).not.toHaveBeenCalled(); + expect(calls.locator).not.toHaveBeenCalled(); + }); + + test('caps an oversized requested timeout at fifteen seconds', async () => { + const { page, calls } = mockPage(); + expect((await verifyCookieAuthentication(page, { ...options, timeoutMs: 60_000 }, unitOrigin)).verified).toBe(true); + expect(calls.reload.mock.calls[0][0].timeout).toBeGreaterThan(0); + expect(calls.reload.mock.calls[0][0].timeout).toBeLessThanOrEqual(15_000); + }); + + test('does not run assertions after a timed-out reload eventually resolves', async () => { + const { page, calls, evaluateAll } = mockPage(); + let release!: (response: any) => void; + calls.reload.mockImplementation(() => new Promise(resolve => { release = resolve; })); + const result = await verifyCookieAuthentication(page, { ...options, timeoutMs: 30 }, unitOrigin); + expect(result).toEqual({ verified: false, reason: 'timeout' }); + release({ status: () => 200 }); + await Promise.resolve(); + await Promise.resolve(); + expect(evaluateAll).not.toHaveBeenCalled(); + expect(calls.locator).not.toHaveBeenCalled(); + }); + + for (const stage of ['reload', 'selector']) { + test(`classifies Playwright's ${stage} timeout before the outer timer expires`, async () => { + const { page, calls, evaluateAll } = mockPage(); + const fail = async () => { throw new errors.TimeoutError(`${credentialUrl.href} ${identity}`); }; + if (stage === 'reload') calls.reload.mockImplementation(fail); + else evaluateAll.mockImplementation(fail); + const clock = spyOn(performance, 'now').mockReturnValue(0); + try { + const result = await verifyCookieAuthentication(page, options, unitOrigin); + expect(result).toEqual({ verified: false, reason: 'timeout', ...(stage === 'selector' ? { status: 200 } : {}) }); + expect(JSON.stringify(result)).not.toContain(identity); + expect(JSON.stringify(result)).not.toContain(credentialUrl.href); + } finally { + clock.mockRestore(); + } + }); + } + + test('does not classify generic error text or a copied name as a Playwright timeout', async () => { + const { page, calls } = mockPage(); + calls.reload.mockImplementation(async () => { throw Object.assign(new Error('synthetic timeout exceeded'), { name: 'TimeoutError' }); }); + expect(await verifyCookieAuthentication(page, options, unitOrigin)).toEqual({ verified: false, reason: 'verification_failed' }); + }); + + test('shares one deadline between reload and the identity assertion', async () => { + const { page, calls, evaluateAll } = mockPage(); + const response = await calls.reload(); + let now = 0; + let expire!: () => void; + const clock = spyOn(performance, 'now').mockImplementation(() => now); + const timer = spyOn(globalThis, 'setTimeout').mockImplementation(((callback: () => void, timeout: number) => { + expect(timeout).toBe(100); + expire = callback; + return 1; + }) as any); + try { + calls.reload.mockImplementation(async () => { + now = 70; + return response; + }); + evaluateAll.mockImplementation(() => new Promise(() => {})); + const result = verifyCookieAuthentication(page, { ...options, timeoutMs: 100 }, unitOrigin); + await Promise.resolve(); + expect(evaluateAll).toHaveBeenCalledTimes(1); + now = 100; + expire(); + expect(await result).toEqual({ verified: false, reason: 'timeout', status: 200 }); + expect(timer).toHaveBeenCalledTimes(1); + } finally { + timer.mockRestore(); + clock.mockRestore(); + } + }); + + test('returns only safe reasons for secret-bearing reload and selector failures', async () => { + for (const stage of ['reload', 'selector']) { + const { page, calls, evaluateAll } = mockPage(); + const fail = async () => { throw new Error(`${credentialUrl.href} ${identity}`); }; + if (stage === 'reload') calls.reload.mockImplementation(fail); + else evaluateAll.mockImplementation(fail); + const result = await verifyCookieAuthentication(page, options, unitOrigin); + expect(result.reason).toBe('verification_failed'); + expect(JSON.stringify(result)).not.toMatch(/synthetic-token|fixture|Synthetic Account/); + } + }); + + test('requires a successful reload response even with a positive assertion', async () => { + const { page, calls, evaluateAll } = mockPage(); + calls.reload.mockImplementation(async () => null as any); + expect(await verifyCookieAuthentication(page, options, unitOrigin)).toEqual({ verified: false, reason: 'no_response' }); + expect(evaluateAll).not.toHaveBeenCalled(); + }); + + test('rejects a closed or changed target before reload', async () => { + for (const state of ['closed', 'changed']) { + const { page, calls } = mockPage(); + if (state === 'closed') calls.isClosed.mockReturnValue(true); + else calls.url.mockReturnValue('https://other.test/protected'); + expect((await verifyCookieAuthentication(page, options, unitOrigin)).reason).toBe(`target_${state}`); + expect(calls.reload).not.toHaveBeenCalled(); + } + }); + + for (const expectedOrigin of ['about:blank', 'file:///tmp/fixture', 'data:text/html,fixture', 'invalid', + 'https://fixture.test/path', credentialUrl.href, 'https://fixture.test/?token=synthetic-token']) { + test(`rejects a non-origin target ${expectedOrigin.split(':')[0]}`, async () => { + const { page, calls } = mockPage(); + expect(await verifyCookieAuthentication(page, options, expectedOrigin)).toEqual({ verified: false, reason: 'invalid_target' }); + expect(await clearCookieTargetStorage(page, expectedOrigin).then(() => null, error => error)).toMatchObject({ code: 'invalid_target' }); + expect(calls.evaluate).not.toHaveBeenCalled(); + expect(calls.reload).not.toHaveBeenCalled(); + }); + } + + test('storage reset sanitizes even a typed exception with a secret-bearing message', async () => { + const { page, cdp } = mockPage(); + cdp.send.mockImplementation(async () => { + throw new CookieImportError(`synthetic-token ${identity}`, 'target_changed'); + }); + try { + await clearCookieTargetStorage(page, unitOrigin); + throw new Error('Expected reset failure'); + } catch (error) { + expect(error).toBeInstanceOf(CookieImportError); + expect((error as CookieImportError).code).toBe('storage_reset_failed'); + expect(String(error)).not.toMatch(/synthetic-token|Synthetic Account/); + } + }); + + test('a reset dispatched after its timeout does no destructive work', async () => { + const { page, cdp, storage } = mockPage(); + let runLate!: () => void; + let expire!: () => void; + const dispatched = Promise.withResolvers(); + const timer = spyOn(globalThis, 'setTimeout').mockImplementation(((callback: () => void) => { + expire = callback; + return 1; + }) as any); + const send = cdp.send.getMockImplementation()!; + cdp.send.mockImplementation(async (method, params) => { + if (method !== 'Runtime.callFunctionOn') return send(method, params); + return new Promise(resolve => { + runLate = () => { + storage.now = params.arguments[0].value.deadline; + resolve(send(method, params)); + }; + dispatched.resolve(); + }); + }); + try { + const reset = clearCookieTargetStorage(page, unitOrigin); + await dispatched.promise; + expire(); + expect(await reset.then(() => null, error => error)).toMatchObject({ code: 'storage_reset_timeout' }); + runLate(); + await Promise.resolve(); + expect(storage.localClear).not.toHaveBeenCalled(); + expect(storage.sessionClear).not.toHaveBeenCalled(); + } finally { + timer.mockRestore(); + } + }); + + test('does not clear session storage if the local-storage operation consumed the deadline', async () => { + const { page, storage } = mockPage(); + storage.nativeNow.mockImplementation(() => storage.localClear.mock.calls.length ? storage.deadline : storage.now); + expect(await clearCookieTargetStorage(page, unitOrigin).then(() => null, error => error)).toMatchObject({ code: 'storage_reset_timeout' }); + expect(storage.localClear).toHaveBeenCalledTimes(1); + expect(storage.sessionClear).not.toHaveBeenCalled(); + }); + + for (const engine of ['firefox', 'webkit']) { + test(`rejects reset support before protocol work on ${engine} without disabling authentication checks`, async () => { + const { page, storage, context, calls } = mockPage(); + storage.engine = engine; + expect(() => validateCookieStorageSupport(page)).toThrow(CookieImportError); + expect(await clearCookieTargetStorage(page, unitOrigin).then(() => null, error => error)).toMatchObject({ code: 'storage_reset_unsupported' }); + expect(context.newCDPSession).not.toHaveBeenCalled(); + expect(calls.evaluate).not.toHaveBeenCalled(); + expect((await verifyCookieAuthentication(page, options, unitOrigin)).verified).toBe(true); + }); + } + + test('Chromium support preflight is side-effect free', () => { + const { page, context, calls, cdp } = mockPage(); + validateCookieStorageSupport(page); + expect(context.newCDPSession).not.toHaveBeenCalled(); + expect(cdp.send).not.toHaveBeenCalled(); + expect(calls.evaluate).not.toHaveBeenCalled(); + expect(calls.reload).not.toHaveBeenCalled(); + }); + + for (const phase of ['attachment', 'Page.getFrameTree', 'Runtime.enable', 'Page.createIsolatedWorld', 'Runtime.evaluate']) { + test(`does not dispatch destructive work when timeout wins during ${phase}`, async () => { + const { page, cdp, context, storage } = mockPage(); + const reached = Promise.withResolvers(); + const release = Promise.withResolvers(); + let expire!: () => void; + const timer = spyOn(globalThis, 'setTimeout').mockImplementation(((callback: () => void) => { + expire = callback; + return 1; + }) as any); + const send = cdp.send.getMockImplementation()!; + if (phase === 'attachment') context.newCDPSession.mockImplementation(async () => { + reached.resolve(); + await release.promise; + return cdp; + }); + else cdp.send.mockImplementation(async (method, params) => { + if (method === phase) { reached.resolve(); await release.promise; } + return send(method, params); + }); + try { + const reset = clearCookieTargetStorage(page, unitOrigin); + await reached.promise; + expire(); + expect(await reset.then(() => null, error => error)).toMatchObject({ code: 'storage_reset_timeout' }); + release.resolve(); + for (let tick = 0; tick < 10; tick++) await Promise.resolve(); + expect(cdp.send.mock.calls.some(([method]) => method === 'Runtime.callFunctionOn')).toBe(false); + expect(storage.localClear).not.toHaveBeenCalled(); + expect(storage.sessionClear).not.toHaveBeenCalled(); + expect(cdp.detach).toHaveBeenCalledTimes(1); + } finally { + release.resolve(); + timer.mockRestore(); + } + }); + } + + test('calibrates a conservative isolated deadline using host time after the clock response', async () => { + const { page, cdp, storage } = mockPage(); + let now = 0; + const clock = spyOn(performance, 'now').mockImplementation(() => now); + const send = cdp.send.getMockImplementation()!; + cdp.send.mockImplementation(async (method, params) => { + const result = await send(method, params); + if (method === 'Runtime.evaluate') { now = 4_000; storage.now = 4_100; } + return result; + }); + try { + await clearCookieTargetStorage(page, unitOrigin); + const sample = cdp.send.mock.calls.find(([method]) => method === 'Runtime.evaluate')![1]; + const mutation = cdp.send.mock.calls.find(([method]) => method === 'Runtime.callFunctionOn')![1]; + expect(mutation.arguments[0].value.deadline).toBe(11_100); + expect(sample.uniqueContextId).toBe('fixture-unique-world'); + expect(mutation.uniqueContextId).toBe(sample.uniqueContextId); + expect(mutation.executionContextId).toBeUndefined(); + expect(storage.localClear).toHaveBeenCalledTimes(1); + expect(storage.sessionClear).toHaveBeenCalledTimes(1); + } finally { + clock.mockRestore(); + } + }); + + test('does not let pending CDP detach prolong the reported timeout', async () => { + const { page, cdp, storage, events, cdpEvents } = mockPage(); + const detaching = Promise.withResolvers(); + const release = Promise.withResolvers(); + let expire!: () => void; + const timer = spyOn(globalThis, 'setTimeout').mockImplementation(((callback: () => void) => { + expire = callback; + return 1; + }) as any); + cdp.detach.mockImplementation(async () => { detaching.resolve(); await release.promise; }); + try { + const reset = clearCookieTargetStorage(page, unitOrigin); + await detaching.promise; + expire(); + expect(await reset.then(() => null, error => error)).toMatchObject({ code: 'storage_reset_timeout' }); + expect(storage.localClear).toHaveBeenCalledTimes(1); + expect(storage.sessionClear).toHaveBeenCalledTimes(1); + expect(events.listenerCount('framenavigated')).toBe(0); + expect(cdpEvents.listenerCount('Runtime.executionContextCreated')).toBe(0); + } finally { + release.resolve(); + timer.mockRestore(); + } + }); + + test('aborts same-URL target navigation before destructive dispatch', async () => { + const { page, cdp, storage, events, frame } = mockPage(); + const reset = clearCookieTargetStorage(page, unitOrigin); + events.emit('framenavigated', frame); + expect(await reset.then(() => null, error => error)).toMatchObject({ code: 'target_changed' }); + expect(cdp.send.mock.calls.some(([method]) => method === 'Runtime.callFunctionOn')).toBe(false); + expect(storage.localClear).not.toHaveBeenCalled(); + expect(storage.sessionClear).not.toHaveBeenCalled(); + }); + + for (const event of ['framenavigated', 'close']) { + test(`settles ${event} cancellation without waiting for a stalled CDP operation`, async () => { + const { page, cdp, events, frame, storage } = mockPage(); + const reached = Promise.withResolvers(); + const send = cdp.send.getMockImplementation()!; + cdp.send.mockImplementation(async (method, params) => { + if (method === 'Runtime.callFunctionOn') { reached.resolve(); return new Promise(() => {}); } + return send(method, params); + }); + const reset = clearCookieTargetStorage(page, unitOrigin); + await reached.promise; + events.emit(event, frame); + expect(await reset.then(() => null, error => error)).toMatchObject({ code: 'target_changed' }); + expect(storage.localClear).not.toHaveBeenCalled(); + expect(storage.sessionClear).not.toHaveBeenCalled(); + expect(cdp.detach).toHaveBeenCalledTimes(1); + }); + } + + for (const changed of ['origin', 'url']) { + test(`checks the captured ${changed} inside the isolated destructive operation`, async () => { + const { page, cdp, storage } = mockPage(); + const send = cdp.send.getMockImplementation()!; + cdp.send.mockImplementation(async (method, params) => { + if (method === 'Runtime.callFunctionOn') storage[changed] = changed === 'origin' ? 'https://other.test' : `${unitOrigin}/other-path`; + return send(method, params); + }); + expect(await clearCookieTargetStorage(page, unitOrigin).then(() => null, error => error)).toMatchObject({ code: 'target_changed' }); + expect(storage.localClear).not.toHaveBeenCalled(); + expect(storage.sessionClear).not.toHaveBeenCalled(); + }); + } +}); + +describe('cookie authentication and storage with isolated Chromium profiles', () => { + let browser: Browser; + let context: BrowserContext; + let page: Page; + let server: ReturnType; + let otherServer: ReturnType; + let origin: string; + let otherOrigin: string; + let releaseSlowResponse: (() => void) | undefined; + + beforeAll(async () => { + const html = (body: string, status = 200) => new Response(body, { status, headers: { 'Content-Type': 'text/html' } }); + const positive = `
${identity}
`; + otherServer = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch(request) { + if (new URL(request.url).pathname === '/back') return Response.redirect(`${origin}/identity`, 302); + return html(positive); + } }); + otherOrigin = `http://127.0.0.1:${otherServer.port}`; + server = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch(request) { + const url = new URL(request.url); + if (url.pathname === '/slow') return new Promise(resolve => { + releaseSlowResponse = () => resolve(html(positive)); + }); + if (url.pathname === '/protected') { + if (!request.headers.get('cookie')?.includes('fixture_session=approved')) return Response.redirect(`${origin}/login`, 302); + return html(positive); + } + if (url.pathname === '/public-login') return html('
'); + if (url.pathname === '/wrong') return html(`
${identity} extra account
`); + if (url.pathname === '/normalized') return html('
\n Synthetic Account\n 472
'); + if (url.pathname === '/hidden') return html(``); + if (url.pathname === '/duplicate') return html(positive + positive); + if (url.pathname === '/one-visible') return html(positive + ``); + if (url.pathname === '/hidden-child') return html(`
${identity}
`); + if (url.pathname === '/delayed') return html(``); + if (url.pathname === '/login' && url.searchParams.has('continue')) return Response.redirect(`${origin}/identity`, 302); + if (url.pathname === '/login-hop') return Response.redirect(`${origin}/login?continue=1`, 302); + if (url.pathname === '/redirect-cross') return Response.redirect(`${otherOrigin}/identity`, 302); + if (url.pathname === '/roundtrip') return Response.redirect(`${otherOrigin}/back`, 302); + if (url.pathname.startsWith('/status/')) return html(positive, Number(url.pathname.split('/')[2])); + return html(positive); + } }); + origin = `http://127.0.0.1:${server.port}`; + browser = await chromium.launch({ headless: true }); + }); + + beforeEach(async () => { + context = await browser.newContext(); + page = await context.newPage(); + }); + + afterEach(async () => { + await context?.close(); + }); + + afterAll(async () => { + await browser?.close(); + server?.stop(true); + otherServer?.stop(true); + }); + + test('verifies a reloaded protected page only with an exact visible identity and synthetic session', async () => { + await context.addCookies([{ name: 'fixture_session', value: 'approved', url: origin }]); + await page.goto(`${origin}/protected`); + expect(await verifyCookieAuthentication(page, options, origin)).toEqual({ verified: true, reason: 'verified', status: 200 }); + }); + + for (const path of ['/normalized', '/one-visible', '/hidden-child', '/delayed']) { + test(`accepts one normalized visible identity at ${path}`, async () => { + await page.goto(`${origin}${path}`); + const result = await verifyCookieAuthentication(page, { ...options, expectedIdentity: ` \n${identity} `, timeoutMs: 1000 }, origin); + expect(result).toEqual({ verified: true, reason: 'verified', status: 200 }); + expect(JSON.stringify(result)).not.toContain(identity); + }); + } + + for (const [path, reason] of [ + ['/wrong', 'identity_mismatch'], ['/hidden', 'identity_missing'], ['/duplicate', 'identity_ambiguous'], + ['/public-login', 'identity_missing'], ['/login', 'login_redirect'], ['/sign-in', 'login_redirect'], + ['/account/LOGIN', 'login_redirect'], ['/login-hop', 'login_redirect'], + ]) { + test(`rejects ${path} with a safe ${reason} result`, async () => { + await page.goto(`${origin}${path === '/login-hop' ? '/identity' : path}`); + if (path === '/login-hop') await page.evaluate(() => history.replaceState({}, '', '/login-hop')); + const result = await verifyCookieAuthentication(page, options, origin); + expect(result.verified).toBe(false); + expect(result.reason).toBe(reason); + expect(JSON.stringify(result)).not.toMatch(/Synthetic Account|127\.0\.0\.1/); + }); + } + + test('rejects an unauthenticated 200 login redirect even when login markup contains the expected identity', async () => { + await context.addCookies([{ name: 'fixture_session', value: 'approved', url: origin }]); + await page.goto(`${origin}/protected`); + await context.clearCookies(); + expect((await verifyCookieAuthentication(page, options, origin)).reason).toBe('login_redirect'); + }); + + for (const status of [401, 403, 500, 503]) { + test(`rejects HTTP ${status} even with a positive identity`, async () => { + await page.goto(`${origin}/status/${status}`); + expect(await verifyCookieAuthentication(page, options, origin)).toEqual({ verified: false, reason: 'http_error', status }); + }); + } + + test('rejects a cross-origin redirect and a redirect that comes back to the original origin', async () => { + for (const destination of ['/redirect-cross', '/roundtrip']) { + await page.goto(`${origin}/identity`); + await page.evaluate(path => history.replaceState({}, '', path), destination); + expect((await verifyCookieAuthentication(page, options, origin)).reason).toBe('target_changed'); + } + }); + + test('rejects same-origin navigation during a delayed identity assertion', async () => { + await page.goto(`${origin}/public-login`); + const evaluateAll = page.locator.bind(page); + const locator = spyOn(page, 'locator').mockImplementation((selector: string) => { + const value = evaluateAll(selector); + const filter = value.filter.bind(value); + spyOn(value, 'filter').mockImplementation((filterOptions: any) => { + const filtered = filter(filterOptions); + const evaluate = filtered.evaluateAll.bind(filtered); + spyOn(filtered, 'evaluateAll').mockImplementation(async (...args: any[]) => { + await page.goto(`${origin}/identity`); + return evaluate(...args as [any, any]); + }); + return filtered; + }); + return value; + }); + try { + expect((await verifyCookieAuthentication(page, options, origin)).reason).toBe('target_changed'); + } finally { + locator.mockRestore(); + } + }); + + test('times out an actual Chromium reload without asserting a login', async () => { + await page.goto(`${origin}/identity`); + await page.evaluate(() => history.replaceState({}, '', '/slow')); + try { + expect(await verifyCookieAuthentication(page, { ...options, timeoutMs: 100 }, origin)) + .toEqual({ verified: false, reason: 'timeout' }); + } finally { + releaseSlowResponse?.(); + } + }); + + test('sanitizes invalid selector errors and handles a closed target', async () => { + await page.goto(`${origin}/identity`); + const result = await verifyCookieAuthentication(page, { ...options, identitySelector: '[synthetic-token' }, origin); + expect(result.reason).toBe('verification_failed'); + expect(JSON.stringify(result)).not.toContain('synthetic-token'); + await page.close(); + expect((await verifyCookieAuthentication(page, options, origin)).reason).toBe('target_closed'); + expect(await clearCookieTargetStorage(page, origin).then(() => null, error => error)).toMatchObject({ code: 'target_closed' }); + }); + + test('preserves all storage during an explicit authentication check', async () => { + await page.goto(`${origin}/identity`); + await page.evaluate(() => { + localStorage.setItem('local', 'original'); + sessionStorage.setItem('session', 'original'); + }); + expect((await verifyCookieAuthentication(page, options, origin)).verified).toBe(true); + expect(await page.evaluate(() => [localStorage.getItem('local'), sessionStorage.getItem('session')])) + .toEqual(['original', 'original']); + }); + + test('clears only exact-origin local storage and target-tab session storage', async () => { + const sibling = await context.newPage(); + const other = await context.newPage(); + const otherHost = await context.newPage(); + const independentProfile = await browser.newContext(); + try { + const independent = await independentProfile.newPage(); + const pages = [page, sibling, other, otherHost, independent]; + await Promise.all(pages.map((tab, index) => tab.goto(index === 2 ? `${otherOrigin}/identity` + : index === 3 ? `http://localhost:${server.port}/identity` : `${origin}/identity`))); + for (const tab of pages) await tab.evaluate(() => { + localStorage.setItem('local', 'original'); + sessionStorage.setItem('session', 'original'); + }); + await clearCookieTargetStorage(page, origin); + const storage = await Promise.all(pages.map(tab => tab.evaluate(() => [localStorage.getItem('local'), sessionStorage.getItem('session')]))); + expect(storage).toEqual([[null, null], [null, 'original'], ['original', 'original'], ['original', 'original'], ['original', 'original']]); + } finally { + await independentProfile.close(); + } + }); + + test('rejects a different scheme, host, or port without clearing storage', async () => { + await page.goto(`${origin}/identity`); + await page.evaluate(() => localStorage.setItem('local', 'original')); + for (const expectedOrigin of [origin.replace('http:', 'https:'), otherOrigin, `http://localhost:${server.port}`]) { + expect(await clearCookieTargetStorage(page, expectedOrigin).then(() => null, error => error)).toMatchObject({ code: 'target_changed' }); + expect(await page.evaluate(() => localStorage.getItem('local'))).toBe('original'); + } + }); + + test('checks target origin and URL inside the storage operation after dispatch races', async () => { + for (const destination of [`${otherOrigin}/identity`, `${origin}/other-path`]) { + const target = await context.newPage(); + await target.goto(`${origin}/identity`); + const other = await context.newPage(); + await other.goto(destination); + await other.evaluate(() => { + localStorage.setItem('local', 'original'); + sessionStorage.setItem('session', 'original'); + }); + const connect = context.newCDPSession.bind(context); + let navigation: Promise | undefined; + const raced = spyOn(context, 'newCDPSession').mockImplementation(async attachedTarget => { + const session = await connect(attachedTarget); + const send = session.send.bind(session); + spyOn(session, 'send').mockImplementation(async (method: any, params: any) => { + if (method === 'Runtime.callFunctionOn') { + navigation = (async () => { + await target.goto(destination); + await target.evaluate(() => sessionStorage.setItem('session', 'original')); + })(); + await navigation; + } + return send(method, params); + }); + return session; + }); + try { + const error = await clearCookieTargetStorage(target, origin).then(() => null, error => error); + expect(error).toMatchObject({ code: 'target_changed' }); + expect(navigation).toBeDefined(); + await navigation; + } finally { + raced.mockRestore(); + } + expect(await target.evaluate(() => [localStorage.getItem('local'), sessionStorage.getItem('session')])).toEqual(['original', 'original']); + await target.close(); + await other.close(); + } + }); + + test('reports a partial reset safely when session storage clearing fails', async () => { + await page.goto(`${origin}/identity`); + await page.evaluate(() => { + localStorage.setItem('local', 'original'); + sessionStorage.setItem('session', 'original'); + Object.defineProperty(sessionStorage, 'clear', { value() { throw new Error('synthetic-token'); } }); + }); + const connect = context.newCDPSession.bind(context); + const failing = spyOn(context, 'newCDPSession').mockImplementation(async target => { + const session = await connect(target); + const send = session.send.bind(session); + spyOn(session, 'send').mockImplementation(async (method: any, params: any) => { + if (method === 'Runtime.callFunctionOn') await send('Runtime.evaluate', { + uniqueContextId: params.uniqueContextId, + expression: "Object.defineProperty(sessionStorage, 'clear', { value() { throw new Error('synthetic-token'); } })", + returnByValue: true, silent: true, + }); + return send(method, params); + }); + return session; + }); + try { + await clearCookieTargetStorage(page, origin); + throw new Error('Expected reset failure'); + } catch (error) { + expect(error).toBeInstanceOf(CookieImportError); + expect((error as CookieImportError).code).toBe('storage_reset_failed'); + expect(String(error)).not.toContain('synthetic-token'); + } finally { + failing.mockRestore(); + } + expect(await page.evaluate(() => [localStorage.getItem('local'), sessionStorage.getItem('session')])).toEqual([null, 'original']); + }); + + test('uses a native isolated clock despite main-world Date and performance tampering', async () => { + await page.goto(`${origin}/identity`); + await page.evaluate(() => { + localStorage.setItem('local', 'original'); + sessionStorage.setItem('session', 'original'); + Date.now = () => 0; + Object.defineProperty(performance, 'now', { value: () => 0 }); + Object.defineProperty(performance, 'timeOrigin', { value: 0 }); + }); + await clearCookieTargetStorage(page, origin); + expect(await page.evaluate(() => [Date.now(), performance.now(), performance.timeOrigin])).toEqual([0, 0, 0]); + expect(await page.evaluate(() => [localStorage.getItem('local'), sessionStorage.getItem('session')])).toEqual([null, null]); + }); + + test('a real late isolated dispatch cannot clear storage after the host timeout even with forged page clocks', async () => { + await page.goto(`${origin}/identity`); + await page.evaluate(() => { + localStorage.setItem('local', 'original'); + sessionStorage.setItem('session', 'original'); + Date.now = () => 0; + Object.defineProperty(performance, 'now', { value: () => 0 }); + Object.defineProperty(performance, 'timeOrigin', { value: 0 }); + }); + const connect = context.newCDPSession.bind(context); + const observer = await connect(page); + await observer.send('Runtime.enable'); + const tree = await observer.send('Page.getFrameTree'); + await observer.send('Page.createIsolatedWorld', { frameId: tree.frameTree.frame.id, worldName: 'gstack-cookie-storage-reset', grantUniveralAccess: false }); + const dispatched = Promise.withResolvers(); + const release = Promise.withResolvers(); + let delayedParameters: any; + const delaying = spyOn(context, 'newCDPSession').mockImplementation(async target => { + const session = await connect(target); + const send = session.send.bind(session); + spyOn(session, 'send').mockImplementation(async (method: any, params: any) => { + if (method === 'Runtime.callFunctionOn') { + delayedParameters = params; + dispatched.resolve(); + return release.promise; + } + return send(method, params); + }); + return session; + }); + try { + const reset = clearCookieTargetStorage(page, origin); + await dispatched.promise; + expect(await reset.then(() => null, error => error)).toMatchObject({ code: 'storage_reset_timeout' }); + expect(await page.evaluate(() => [localStorage.getItem('local'), sessionStorage.getItem('session')])).toEqual(['original', 'original']); + const late = await observer.send('Runtime.callFunctionOn', delayedParameters); + expect(late.result.value).toBe('storage_reset_timeout'); + expect(await page.evaluate(() => [Date.now(), performance.now(), performance.timeOrigin])).toEqual([0, 0, 0]); + expect(await page.evaluate(() => [localStorage.getItem('local'), sessionStorage.getItem('session')])).toEqual(['original', 'original']); + release.resolve(late); + } finally { + release.resolve({ result: { value: 'storage_reset_timeout' } }); + delaying.mockRestore(); + await observer.detach(); + } + }, 25_000); +}); diff --git a/browse/test/cookie-credential-deadline.test.ts b/browse/test/cookie-credential-deadline.test.ts new file mode 100644 index 000000000..c5c76f091 --- /dev/null +++ b/browse/test/cookie-credential-deadline.test.ts @@ -0,0 +1,225 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test'; +import { Database } from 'bun:sqlite'; +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; +import { CookieImportError, importCookies } from '../src/cookie-import-browser'; + +let home: string; +let homedir: ReturnType; +let platform: PropertyDescriptor; +let spawn: typeof Bun.spawn; + +function fixture(encrypted: Buffer, hostPlatform: 'darwin' | 'linux' | 'win32' = 'darwin') { + const folder = hostPlatform === 'darwin' ? 'Library/Application Support/Dia/User Data/Default' + : hostPlatform === 'linux' ? '.config/chromium/Default' : 'AppData/Local/Chromium/User Data/Default'; + const dir = path.join(home, folder); + fs.mkdirSync(dir, { recursive: true }); + expect(fs.realpathSync(dir).startsWith(fs.realpathSync(home) + path.sep)).toBe(true); + const db = new Database(path.join(dir, 'Cookies')); + db.run('CREATE TABLE cookies (host_key TEXT, name TEXT, value TEXT, encrypted_value BLOB, path TEXT, expires_utc INTEGER, is_secure INTEGER, is_httponly INTEGER, has_expires INTEGER, samesite INTEGER)'); + db.run("INSERT INTO cookies VALUES ('.fixture.test', 'session', '', ?, '/', 0, 1, 1, 0, 1)", [encrypted]); + db.close(); +} + +function cbcCookie(password: string, prefix = 'v10') { + const key = crypto.pbkdf2Sync(password, 'saltysalt', prefix === 'v11' ? 1 : 1003, 16, 'sha1'); + const cipher = crypto.createCipheriv('aes-128-cbc', key, Buffer.alloc(16, 0x20)); + return Buffer.concat([Buffer.from(prefix), cipher.update(Buffer.concat([Buffer.alloc(32), Buffer.from('fixture-value')])), cipher.final()]); +} + +function pipe(content?: string): { stream: ReadableStream; close(): void; cancelled(): boolean } { + let controller: ReadableStreamDefaultController; + let cancelled = false; + const stream = new ReadableStream({ + start(value) { + controller = value; + if (content) controller.enqueue(Buffer.from(content)); + }, + cancel() { cancelled = true; }, + }); + return { stream, close: () => controller.close(), cancelled: () => cancelled }; +} + +beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), 'cookie-credential-deadline-')); + homedir = spyOn(os, 'homedir').mockReturnValue(home); + platform = Object.getOwnPropertyDescriptor(process, 'platform')!; + spawn = Bun.spawn; +}); + +afterEach(() => { + Bun.spawn = spawn; + Object.defineProperty(process, 'platform', platform); + homedir.mockRestore(); + fs.rmSync(home, { recursive: true, force: true }); +}); + +describe('credential subprocess whole-operation deadline', () => { + for (const held of ['stdout', 'stderr'] as const) { + test(`rejects an exited Keychain process when ${held} remains open`, async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }); + fixture(Buffer.from('v10synthetic')); + const stdout = pipe(held === 'stdout' ? 'fixture-password' : undefined); + const stderr = pipe(held === 'stderr' ? 'private-error-detail' : undefined); + if (held !== 'stdout') stdout.close(); + if (held !== 'stderr') stderr.close(); + let killed = 0; + Bun.spawn = (() => ({ exited: Promise.resolve(0), stdout: stdout.stream, stderr: stderr.stream, kill() { killed++; } })) as typeof Bun.spawn; + let expire: (() => void) | undefined; + const timer = spyOn(globalThis, 'setTimeout').mockImplementation((callback: any) => { expire = callback; return 19 as any; }); + try { + const result = importCookies('dia', ['fixture.test']); + expect(expire).toBeFunction(); + expire!(); + await expect(result).rejects.toMatchObject({ code: 'keychain_timeout', action: 'retry' }); + expect(killed).toBe(1); + expect(held === 'stdout' ? stdout.cancelled() : stderr.cancelled()).toBe(true); + } finally { + timer.mockRestore(); + } + }); + } + + test('bounds a process that never exits', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }); + fixture(Buffer.from('v10synthetic')); + const stdout = pipe(); + const stderr = pipe(); + let killed = 0; + Bun.spawn = (() => ({ exited: new Promise(() => {}), stdout: stdout.stream, stderr: stderr.stream, kill() { killed++; } })) as typeof Bun.spawn; + let expire: (() => void) | undefined; + const timer = spyOn(globalThis, 'setTimeout').mockImplementation((callback: any) => { expire = callback; return 20 as any; }); + try { + const result = importCookies('dia', ['fixture.test']); + expect(expire).toBeFunction(); + expire!(); + await expect(result).rejects.toMatchObject({ code: 'keychain_timeout' }); + expect(killed).toBe(1); + expect(stdout.cancelled()).toBe(true); + expect(stderr.cancelled()).toBe(true); + } finally { + timer.mockRestore(); + } + }); + + test('caps credential output and does not expose it in errors', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }); + fixture(Buffer.from('v10synthetic')); + const privateValue = 'private-credential-output'; + let killed = 0; + Bun.spawn = (() => ({ exited: Promise.resolve(0), stdout: new Blob([privateValue.repeat(4_000)]).stream(), + stderr: new Blob([]).stream(), kill() { killed++; } })) as typeof Bun.spawn; + let error: any; + try { await importCookies('dia', ['fixture.test']); } catch (caught) { error = caught; } + expect(error).toBeInstanceOf(CookieImportError); + expect(error.code).toBe('keychain_error'); + expect(error.message).not.toContain(privateValue); + expect(killed).toBe(1); + }); + + test('preserves Keychain success and denied/nonexistent policy', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }); + fixture(cbcCookie('fixture-password')); + for (const [code, stderr, expected] of [[0, '', 'fixture-value'], [1, 'user canceled private-detail', 'keychain_denied'], [1, 'could not be found private-detail', 'keychain_not_found']] as const) { + Bun.spawn = (() => ({ exited: Promise.resolve(code), stdout: new Blob([code ? '' : 'fixture-password\n']).stream(), + stderr: new Blob([stderr]).stream(), kill() { throw new Error('Unexpected kill'); } })) as typeof Bun.spawn; + if (!code) expect((await importCookies('dia', ['fixture.test'])).cookies[0].value).toBe(expected); + else await expect(importCookies('dia', ['fixture.test'])).rejects.toMatchObject({ code: expected }); + } + }); + + test('preserves Linux secret lookup through concurrent pipe draining', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }); + fixture(cbcCookie('test-linux-secret', 'v11'), 'linux'); + Bun.spawn = (() => ({ exited: Promise.resolve(0), stdout: new Blob(['test-linux-secret\n']).stream(), + stderr: new Blob([]).stream(), kill() { throw new Error('Unexpected kill'); } })) as typeof Bun.spawn; + expect((await importCookies('chromium', ['fixture.test'])).cookies[0].value).toBe('fixture-value'); + }); + + test('bounds a Linux secret lookup with an exited child and inherited pipe', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }); + fixture(cbcCookie('fixture-other-password', 'v11'), 'linux'); + const stdout = pipe(); + const stderr = pipe(); + stderr.close(); + let killed = 0; + Bun.spawn = (() => ({ exited: Promise.resolve(0), stdout: stdout.stream, stderr: stderr.stream, + kill() { killed++; } })) as typeof Bun.spawn; + let expire: (() => void) | undefined; + const timer = spyOn(globalThis, 'setTimeout').mockImplementation((callback: any) => { expire = callback; return 22 as any; }); + try { + const result = importCookies('chromium', ['fixture.test']); + expect(expire).toBeFunction(); + expire!(); + expect(await result).toMatchObject({ count: 0, failed: 1 }); + expect(killed).toBe(1); + expect(stdout.cancelled()).toBe(true); + } finally { timer.mockRestore(); } + }); + + test('bounds DPAPI stdout and preserves successful extraction without exposing input', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }); + const key = Buffer.alloc(32, 0x42); + const nonce = Buffer.alloc(12, 0x22); + const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce); + const encrypted = Buffer.concat([Buffer.from('v10'), nonce, cipher.update('fixture-value'), cipher.final(), cipher.getAuthTag()]); + fixture(encrypted, 'win32'); + fs.writeFileSync(path.join(home, 'AppData/Local/Chromium/User Data/Local State'), JSON.stringify({ os_crypt: { encrypted_key: Buffer.from('DPAPIsynthetic').toString('base64') } })); + const stdout = pipe(key.toString('base64')); + const stderr = pipe(); + let sent = ''; + let killed = 0; + Bun.spawn = (() => ({ exited: Promise.resolve(0), stdout: stdout.stream, stderr: stderr.stream, + stdin: { write(value: string) { sent = value; }, end() {} }, kill() { killed++; } })) as typeof Bun.spawn; + let expire: (() => void) | undefined; + const timer = spyOn(globalThis, 'setTimeout').mockImplementation((callback: any) => { expire = callback; return 21 as any; }); + try { + const result = importCookies('chromium', ['fixture.test']); + expect(sent).toBe(Buffer.from('synthetic').toString('base64')); + expect(expire).toBeFunction(); + expire!(); + await expect(result).rejects.toMatchObject({ code: 'keychain_timeout' }); + expect(killed).toBe(1); + } finally { timer.mockRestore(); } + Bun.spawn = (() => ({ exited: Promise.resolve(0), stdout: new Blob([key.toString('base64')]).stream(), + stderr: new Blob([]).stream(), stdin: { write(value: string) { sent = value; }, end() {} }, kill() { throw new Error('Unexpected kill'); } })) as typeof Bun.spawn; + expect((await importCookies('chromium', ['fixture.test'])).cookies[0].value).toBe('fixture-value'); + }); + + test('Node polyfill replay streams work with the bundled importer and a synthetic credential child', () => { + const bundle = path.join(home, 'importer.mjs'); + const build = spawnSync(process.execPath, ['build', path.resolve(import.meta.dir, '../src/cookie-import-browser.ts'), '--target=node', '--outfile', bundle], { encoding: 'utf8', timeout: 30_000 }); + expect(build.status).toBe(0); + const node = Bun.which('node')!; + const polyfill = path.resolve(import.meta.dir, '../src/bun-polyfill.cjs'); + const nodeFixture = path.join(home, 'node-fixture'); + fs.mkdirSync(nodeFixture); + const dir = path.join(nodeFixture, 'Library/Application Support/Dia/User Data/Default'); + fs.mkdirSync(dir, { recursive: true }); + expect(fs.realpathSync(dir).startsWith(fs.realpathSync(nodeFixture) + path.sep)).toBe(true); + const db = new Database(path.join(dir, 'Cookies')); + db.run('CREATE TABLE cookies (host_key TEXT, name TEXT, value TEXT, encrypted_value BLOB, path TEXT, expires_utc INTEGER, is_secure INTEGER, is_httponly INTEGER, has_expires INTEGER, samesite INTEGER)'); + db.run("INSERT INTO cookies VALUES ('.fixture.test', 'session', '', ?, '/', 0, 1, 1, 0, 1)", [cbcCookie('fixture-node-password')]); + db.close(); + const result = spawnSync(node, ['--input-type=module', '-e', ` + import { createRequire } from 'node:module'; + const require = createRequire(import.meta.url); + require(process.argv[1]); + const original = Bun.spawn; + Bun.spawn = (_command, options) => original([process.execPath, '-e', 'console.log("fixture-node-password")'], options); + Object.defineProperty(process, 'platform', { value: 'darwin' }); + const { importCookies } = await import(process.argv[2]); + const result = await importCookies('dia', ['fixture.test']); + console.log(JSON.stringify({ count: result.count, value: result.cookies[0]?.value })); + `, polyfill, pathToFileURL(bundle).href], { encoding: 'utf8', timeout: 15_000, + env: { ...process.env, HOME: nodeFixture, USERPROFILE: nodeFixture, NODE_NO_WARNINGS: '1' } }); + expect(result.error).toBeUndefined(); + expect(result.stderr).toBe(''); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ count: 1, value: 'fixture-value' }); + }); +}); diff --git a/browse/test/cookie-database.test.ts b/browse/test/cookie-database.test.ts new file mode 100644 index 000000000..ed7c9a1ab --- /dev/null +++ b/browse/test/cookie-database.test.ts @@ -0,0 +1,277 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { Database } from 'bun:sqlite'; +import { spawnSync } from 'node:child_process'; +import { chmodSync, existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { openCookieDatabase } from '../src/cookie-database'; + +const root = realpathSync(mkdtempSync(path.join(tmpdir(), 'cookie-db-'))); +const dbPath = path.join(root, 'Cookies'); +const adapterPath = path.join(root, 'cookie-database.mjs'); +const corruptPath = path.join(root, 'private-corrupt-fixture'); +const missingPath = path.join(root, 'private-missing-fixture'); +const node = Bun.which('node'); +if (!node) throw new Error('Node.js is required for cookie database adapter tests'); + +beforeAll(() => { + const database = new Database(dbPath); + database.run('CREATE TABLE cookies (host_key TEXT, name TEXT, encrypted_value BLOB, expires_utc INTEGER, has_expires INTEGER)'); + const insert = database.query('INSERT INTO cookies VALUES (?, ?, ?, ?, ?)'); + insert.run('.fixture.test', 'large-expiry', new Uint8Array([0, 127, 255]), 13300000000000001n, 1); + insert.run('.fixture.test', 'session', new Uint8Array([]), 0, 0); + insert.run('.fixture.test', 'expired', new Uint8Array([]), 500, 1); + insert.run('.other.test', 'other-session', new Uint8Array([]), 0, 0); + database.close(); + chmodSync(dbPath, 0o600); + writeFileSync(corruptPath, 'private-fixture-not-a-database', { mode: 0o600 }); + const source = readFileSync(path.resolve(import.meta.dir, '../src/cookie-database.ts'), 'utf8'); + writeFileSync(adapterPath, new Bun.Transpiler({ loader: 'ts', target: 'node' }).transformSync(source), { mode: 0o600 }); +}); + +afterAll(() => rmSync(root, { recursive: true, force: true })); + +function runNode(source: string, flags: string[] = []) { + const result = spawnSync(node!, ['--input-type=module', ...flags, '-e', source, pathToFileURL(adapterPath).href, dbPath, corruptPath, missingPath], { + encoding: 'utf8', timeout: 10_000, env: { + PATH: path.dirname(node!), HOME: root, USERPROFILE: root, TEMP: root, TMP: root, + NODE_NO_WARNINGS: '1', ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}), + }, + }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + return JSON.parse(result.stdout); +} + +describe('cookie database runtime adapter', () => { + test('reproduces the old Node null stub before cookie queries can run', () => { + const result = runNode(` + import { existsSync } from 'node:fs'; + const Database = null; + let attemptedQuery = false; + try { + const database = new Database(process.argv[2], { readonly: true }); + attemptedQuery = true; + database.query('SELECT host_key FROM cookies').all(); + } catch (error) { + console.log(JSON.stringify({ fixtureExists: existsSync(process.argv[2]), error: error.name, attemptedQuery })); + } + `); + expect(result).toEqual({ fixtureExists: true, error: 'TypeError', attemptedQuery: false }); + }); + + test('Bun returns JSON-safe domain counts and preserves precise expiry integers and blobs', () => { + const database = openCookieDatabase(dbPath); + try { + const domains = database.query('SELECT host_key AS domain, COUNT(*) AS count FROM cookies WHERE has_expires = 0 OR expires_utc > ? GROUP BY host_key ORDER BY count DESC').all(1000); + expect(domains).toEqual([{ domain: '.fixture.test', count: 2 }, { domain: '.other.test', count: 1 }]); + expect(JSON.parse(JSON.stringify(domains))).toEqual(domains); + const rows = database.query('SELECT name, encrypted_value, expires_utc, has_expires FROM cookies WHERE host_key IN (?, ?) AND expires_utc = ?').all('fixture.test', '.fixture.test', 13300000000000001n) as any[]; + expect(rows).toHaveLength(1); + expect(rows[0].expires_utc).toBe(13300000000000001n); + expect(rows[0].has_expires).toBe(1); + expect(Array.from(rows[0].encrypted_value)).toEqual([0, 127, 255]); + } finally { + database.close(); + } + }); + + test('Node uses built-in SQLite even with a Bun server polyfill and preserves the same values', () => { + const result = runNode(` + globalThis.Bun = { fixturePolyfill: true }; + const { openCookieDatabase } = await import(process.argv[1]); + const database = openCookieDatabase(process.argv[2]); + try { + const domains = database.query('SELECT host_key AS domain, COUNT(*) AS count FROM cookies WHERE has_expires = 0 OR expires_utc > ? GROUP BY host_key ORDER BY count DESC').all(1000); + const rows = database.query('SELECT name, encrypted_value, expires_utc, has_expires FROM cookies WHERE host_key IN (?, ?) AND expires_utc = ?').all('fixture.test', '.fixture.test', 13300000000000001n); + console.log(JSON.stringify({ domains, rowCount: rows.length, expiry: String(rows[0].expires_utc), expiryType: typeof rows[0].expires_utc, + flag: rows[0].has_expires, blob: Array.from(rows[0].encrypted_value) })); + } finally { database.close(); } + `); + expect(result).toEqual({ domains: [{ domain: '.fixture.test', count: 2 }, { domain: '.other.test', count: 1 }], + rowCount: 1, expiry: '13300000000000001', expiryType: 'bigint', flag: 1, blob: [0, 127, 255] }); + }); + + for (const runtime of ['Bun', 'Node']) { + test(`${runtime} converts only integers inside the safe Number range`, () => { + const sql = 'SELECT 9007199254740991 AS safe_positive, -9007199254740991 AS safe_negative, 9007199254740992 AS unsafe_positive, -9007199254740992 AS unsafe_negative, 9223372036854775807 AS maximum, 1.5 AS fractional, NULL AS empty'; + let row: any; + if (runtime === 'Bun') { + const database = openCookieDatabase(dbPath); + try { + row = database.query(sql).all()[0]; + row = Object.fromEntries(Object.entries(row).map(([key, value]) => [key, { type: typeof value, value: String(value) }])); + } finally { database.close(); } + } else { + row = runNode(` + const { openCookieDatabase } = await import(process.argv[1]); + const database = openCookieDatabase(process.argv[2]); + try { + const row = database.query(${JSON.stringify(sql)}).all()[0]; + console.log(JSON.stringify(Object.fromEntries(Object.entries(row).map(([key, value]) => [key, { type: typeof value, value: String(value) }])))); + } finally { database.close(); } + `); + } + expect(row).toEqual({ safe_positive: { type: 'number', value: '9007199254740991' }, safe_negative: { type: 'number', value: '-9007199254740991' }, + unsafe_positive: { type: 'bigint', value: '9007199254740992' }, unsafe_negative: { type: 'bigint', value: '-9007199254740992' }, + maximum: { type: 'bigint', value: '9223372036854775807' }, fractional: { type: 'number', value: '1.5' }, empty: { type: 'object', value: 'null' } }); + }); + + test(`${runtime} refuses database writes and leaves the source bytes unchanged`, () => { + const before = readFileSync(dbPath); + const writes = ["INSERT INTO cookies VALUES ('private-insert', 'attempt', NULL, 0, 0)", + "UPDATE cookies SET name = 'private-update'", 'DELETE FROM cookies', 'CREATE TABLE private_created_table (value TEXT)']; + let results: any[]; + if (runtime === 'Bun') { + const database = openCookieDatabase(dbPath); + try { + results = writes.map(sql => { + try { database.query(sql).all(); return { wrote: true }; } + catch (error: any) { return { code: error.code, message: error.message }; } + }); + } finally { database.close(); } + } else { + results = runNode(` + const { openCookieDatabase } = await import(process.argv[1]); + const database = openCookieDatabase(process.argv[2]); + try { + const results = ${JSON.stringify(writes)}.map(sql => { + try { database.query(sql).all(); return { wrote: true }; } + catch (error) { return { code: error.code, message: error.message }; } + }); + console.log(JSON.stringify(results)); + } finally { database.close(); } + `); + } + expect(results).toHaveLength(4); + for (const result of results) { + expect(result.code).toBe('SQLITE_READONLY'); + expect(result.message).not.toContain('private-'); + expect(result.message).not.toContain('private_created_table'); + } + expect(readFileSync(dbPath)).toEqual(before); + }); + + test(`${runtime} keeps parameters bound instead of interpreting SQL-looking values`, () => { + const value = ".fixture.test'; DROP TABLE cookies; --"; + let rows: unknown[]; + if (runtime === 'Bun') { + const database = openCookieDatabase(dbPath); + try { + rows = database.query('SELECT name FROM cookies WHERE host_key = ?').all(value); + expect(database.query('SELECT COUNT(*) AS count FROM cookies').all()).toEqual([{ count: 4 }]); + } finally { database.close(); } + } else { + const result = runNode(` + const { openCookieDatabase } = await import(process.argv[1]); + const database = openCookieDatabase(process.argv[2]); + try { + const rows = database.query('SELECT name FROM cookies WHERE host_key = ?').all(${JSON.stringify(value)}); + console.log(JSON.stringify({ rows, counts: database.query('SELECT COUNT(*) AS count FROM cookies').all() })); + } finally { database.close(); } + `); + rows = result.rows; + expect(result.counts).toEqual([{ count: 4 }]); + } + expect(rows).toEqual([]); + }); + + test(`${runtime} supports a mutable close method for copied-profile cleanup`, () => { + if (runtime === 'Bun') { + const database = openCookieDatabase(dbPath); + const close = database.close.bind(database); + let cleanup = false; + database.close = () => { close(); cleanup = true; }; + database.close(); + expect(cleanup).toBe(true); + expect(() => database.query('SELECT 1').all()).toThrow(); + } else { + expect(runNode(` + const { openCookieDatabase } = await import(process.argv[1]); + const database = openCookieDatabase(process.argv[2]); + const close = database.close.bind(database); + let cleanup = false; + database.close = () => { close(); cleanup = true; }; + database.close(); + let closed = false; + try { database.query('SELECT 1').all(); } catch { closed = true; } + console.log(JSON.stringify({ cleanup, closed })); + `)).toEqual({ cleanup: true, closed: true }); + } + }); + + test(`${runtime} sanitizes open, corrupt-database, query, and binding failures`, () => { + const exercise = (open: typeof openCookieDatabase, missing: string, corrupt: string, valid: string) => { + const results: { code: string; message: string }[] = []; + for (const file of [missing, corrupt]) { + let database: ReturnType | undefined; + try { database = open(file); database.query('SELECT * FROM cookies').all(); } + catch (error: any) { results.push({ code: error.code, message: error.message }); } + finally { database?.close(); } + } + const database = open(valid); + try { + try { database.query('SELECT private_query_sentinel FROM cookies').all(); } + catch (error: any) { results.push({ code: error.code, message: error.message }); } + try { database.query('SELECT ? AS value').all(Symbol('private-binding-sentinel')); } + catch (error: any) { results.push({ code: error.code, message: error.message }); } + } finally { database.close(); } + return results; + }; + const results = runtime === 'Bun' ? exercise(openCookieDatabase, missingPath, corruptPath, dbPath) : runNode(` + const { openCookieDatabase } = await import(process.argv[1]); + const exercise = ${exercise.toString()}; + console.log(JSON.stringify(exercise(openCookieDatabase, process.argv[4], process.argv[3], process.argv[2]))); + `); + expect(results.map((result: any) => result.code)).toEqual(['SQLITE_CANTOPEN', 'SQLITE_CORRUPT', 'SQLITE_ERROR', 'SQLITE_ERROR']); + expect(JSON.stringify(results)).not.toMatch(/private[-_]|Cookies|cookie-db-/); + expect(existsSync(missingPath)).toBe(false); + }); + } + + for (const failure of ['missing module', 'missing export']) { + test(`Node import remains available without SQLite and cookie use fails safely (${failure})`, () => { + const result = runNode(` + import Module from 'node:module'; + const original = Module._load; + let loads = 0; + Module._load = function(name, ...args) { + if (name === 'node:sqlite') { + loads++; + if (${JSON.stringify(failure)} === 'missing export') return {}; + throw new Error('private-module-error'); + } + return original.call(this, name, ...args); + }; + try { + const { openCookieDatabase } = await import(process.argv[1]); + const loadsAtImport = loads; + try { openCookieDatabase(process.argv[2]); } + catch (error) { console.log(JSON.stringify({ loadsAtImport, loads, code: error.code, message: error.message })); } + } finally { Module._load = original; } + `); + expect(result.loadsAtImport).toBe(0); + expect(result.loads).toBe(1); + expect(result.code).toBe('sqlite_unavailable'); + expect(result.message).toContain('Node.js 22.13 or newer'); + expect(result.message).toContain('Upgrade Node.js or enable SQLite'); + expect(result.message).not.toContain('private-module-error'); + expect(result.message).not.toContain(dbPath); + }); + } + + test('actual Node with SQLite disabled can import the module and gets version guidance only on cookie use', () => { + const result = runNode(` + const { openCookieDatabase } = await import(process.argv[1]); + try { openCookieDatabase(process.argv[2]); } + catch (error) { console.log(JSON.stringify({ imported: true, code: error.code, message: error.message })); } + `, ['--no-experimental-sqlite']); + expect(result.imported).toBe(true); + expect(result.code).toBe('sqlite_unavailable'); + expect(result.message).toContain('Node.js 22.13 or newer'); + expect(result.message).toContain('Upgrade Node.js or enable SQLite'); + expect(result.message).not.toContain(dbPath); + }); +}); diff --git a/browse/test/cookie-fixture-delete-lease.test.ts b/browse/test/cookie-fixture-delete-lease.test.ts new file mode 100644 index 000000000..1bb90ff31 --- /dev/null +++ b/browse/test/cookie-fixture-delete-lease.test.ts @@ -0,0 +1,256 @@ +import { afterAll, expect, test } from 'bun:test'; +import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, rmdirSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { dlopen, FFIType, ptr } from 'bun:ffi'; +import { createFixtureDeleteLease, deleteWithFixtureLease, FixtureDeleteError, type FixtureDeleteBackend } from './fixtures/native-cookie-delete-lease'; +import { nativeCookieEnvironment } from '../src/cookie-import-native-worker'; + +const root = mkdtempSync(path.join(tmpdir(), 'delete-lease-')); +afterAll(() => rmSync(root, { recursive: true, force: true })); +const identity = { dev: 7n, ino: 9007199254740993n, mode: 0o100666n }; + +function model() { + let time = 0; + const calls: string[] = []; + const backend: FixtureDeleteBackend = { + open: () => { calls.push('open'); return 42; }, + identity: () => { calls.push('identity'); return { ...identity, attributes: 0x80, filesystem: 'NTFS' }; }, + dispose: () => { calls.push('dispose'); }, + close: () => { calls.push('close'); }, + absent: () => { calls.push('absent'); return true; }, + }; + const verify = () => { calls.push('verify'); }; + const clock = { now: () => time, wait: (ms: number) => { calls.push(`wait:${ms}`); time += ms; } }; + return { backend, calls, verify, clock, advance: (ms: number) => { time += ms; } }; +} + +test('an admitted identity is deleted once, closed, and checked for absence in that order', () => { + const f = model(); + expect(deleteWithFixtureLease('owned', identity, 100, f.verify, f.backend, f.clock)).toEqual({ admissionProbes: 1, waitedMs: 0 }); + expect(f.calls).toEqual(['verify', 'open', 'identity', 'verify', 'dispose', 'close', 'absent']); +}); + +test('sharing admission can settle inside the same deadline without retrying deletion', () => { + const f = model(); let attempts = 0; + f.backend.open = () => { f.calls.push('open'); if (++attempts < 3) throw new FixtureDeleteError('admission', 'owned', 32); return 42; }; + expect(deleteWithFixtureLease('owned', identity, 100, f.verify, f.backend, f.clock)).toEqual({ admissionProbes: 3, waitedMs: 40 }); + expect(f.calls.filter(call => call === 'dispose')).toHaveLength(1); + expect(f.calls.filter(call => call === 'close')).toHaveLength(1); +}); + +test('persistent sharing keeps its first error and performs zero deletes', () => { + const f = model(); const first = new FixtureDeleteError('admission', 'owned', 32); let attempts = 0; + f.backend.open = () => { attempts++; throw attempts === 1 ? first : new FixtureDeleteError('admission', 'owned', 32); }; + let failure: unknown; + try { deleteWithFixtureLease('owned', identity, 40, f.verify, f.backend, f.clock); } catch (error) { failure = error; } + expect(failure).toBe(first); + expect(attempts).toBe(2); + expect(f.calls).not.toContain('dispose'); + expect(f.calls).not.toContain('close'); +}); + +test.each([2, 3, 5, 33, 50, 87])('admission error %s is never polled or deleted', code => { + const f = model(); const original = new FixtureDeleteError('admission', 'owned', code); + f.backend.open = () => { f.calls.push('open'); throw original; }; + let failure: unknown; + try { deleteWithFixtureLease('owned', identity, 100, f.verify, f.backend, f.clock); } catch (error) { failure = error; } + expect(failure).toBe(original); + expect(f.calls).toEqual(['verify', 'open']); +}); + +test.each(['volume', 'inode', 'ReFS', 'readonly', 'directory', 'reparse', 'ancestor'])( + 'a rejected %s identity closes its handle without deleting', defect => { + const f = model(); + f.backend.identity = () => ({ ...identity, dev: defect === 'volume' ? 8n : identity.dev, + ino: defect === 'inode' ? 9007199254740992n : identity.ino, filesystem: defect === 'ReFS' ? 'ReFS' : 'NTFS', + attributes: { readonly: 1, directory: 16, reparse: 1024 }[defect] ?? 0x80 }); + let checks = 0; + const verify = () => { if (++checks === 2 && defect === 'ancestor') throw new Error('Owned ancestor changed'); }; + expect(() => deleteWithFixtureLease('owned', identity, 100, verify, f.backend, f.clock)).toThrow(); + expect(f.calls.filter(call => call === 'close')).toHaveLength(1); + expect(f.calls).not.toContain('dispose'); + }); + +test.each(['before_open', 'after_verify', 'after_identity', 'after_revalidation'])( + 'expiration %s never grants late deletion', stage => { + const f = model(); let checks = 0; + if (stage === 'before_open') f.advance(100); + const verify = () => { checks++; if (stage === 'after_verify' && checks === 1 || stage === 'after_revalidation' && checks === 2) f.advance(100); }; + const identify = f.backend.identity; + f.backend.identity = (handle, file) => { const result = identify(handle, file); if (stage === 'after_identity') f.advance(100); return result; }; + expect(() => deleteWithFixtureLease('owned', identity, 100, verify, f.backend, f.clock)).toThrow('deadline'); + expect(f.calls).not.toContain('dispose'); + expect(f.calls.filter(call => call === 'close')).toHaveLength(stage.startsWith('before') || stage === 'after_verify' ? 0 : 1); + }); + +test('a disposition failure is not retried and survives close', () => { + const f = model(); const original = new FixtureDeleteError('disposition', 'owned', 32); + f.backend.dispose = () => { f.calls.push('dispose'); throw original; }; + let failure: unknown; + try { deleteWithFixtureLease('owned', identity, 100, f.verify, f.backend, f.clock); } catch (error) { failure = error; } + expect(failure).toBe(original); + expect(f.calls.filter(call => call === 'dispose')).toHaveLength(1); + expect(f.calls.at(-1)).toBe('close'); + expect(f.calls.some(call => call.startsWith('wait'))).toBe(false); +}); + +test.each(['close', 'absence'])('%s failure never reports completed removal', stage => { + const f = model(); + if (stage === 'close') f.backend.close = () => { throw new FixtureDeleteError('close', 'owned', 6); }; + else f.backend.absent = () => false; + expect(() => deleteWithFixtureLease('owned', identity, 100, f.verify, f.backend, f.clock)).toThrow(stage); +}); + +test('the actual Windows binding uses the checked handle, NTFS identity, and Ex flags without a fallback', () => { + const source = readFileSync(path.join(import.meta.dir, 'fixtures/native-cookie-delete-lease.ts'), 'utf8'); + const start = source.indexOf('export function createFixtureDeleteLease('); + expect(start).toBeGreaterThan(0); + const body = new Bun.Transpiler({ loader: 'ts' }).transformSync(source.slice(start).replace('export function', 'function')); + const calls: string[] = []; + const api = { + CreateFileW(name: Buffer, access: number, share: number, security: unknown, creation: number, flags: number, template: number) { + calls.push('open'); + expect(name.toString('utf16le')).toBe('namespaced-owned\0'); + expect([access, share, security, creation, flags, template]).toEqual([0x10080, 3, null, 3, 0x00200000, 0]); + return 42; + }, + GetLastError() { calls.push('last-error'); return 0; }, + GetFileInformationByHandle(handle: number, info: Buffer) { + calls.push('identity'); expect(handle).toBe(42); expect(info.length).toBe(52); + info.writeUInt32LE(0x80, 0); info.writeUInt32LE(Number(identity.dev), 28); + info.writeUInt32LE(Number(identity.ino >> 32n), 44); info.writeUInt32LE(Number(identity.ino & 0xffffffffn), 48); + return 1; + }, + GetVolumeInformationByHandleW(handle: number, name: unknown, length: number, serial: unknown, component: unknown, flags: unknown, filesystem: Buffer, size: number) { + calls.push('filesystem'); expect([handle, name, length, serial, component, flags, size]).toEqual([42, null, 0, null, null, null, 64]); + expect(filesystem.length).toBe(128); filesystem.write('NTFS\0', 'utf16le'); return 1; + }, + SetFileInformationByHandle(handle: number, kind: number, flags: Buffer, size: number) { + calls.push('dispose'); expect([handle, kind, flags.length, flags.readUInt32LE(0), size]).toEqual([42, 21, 4, 3, 4]); return 1; + }, + CloseHandle(handle: number) { calls.push('close'); expect(handle).toBe(42); return 1; }, + }; + const factory = new Function('process', 'dlopen', 'FFIType', 'ptr', 'unlinkSync', 'lstatSync', 'toNamespacedPath', + 'FixtureDeleteError', 'deleteWithFixtureLease', 'leaseClock', `${body}\nreturn createFixtureDeleteLease;`)( + { platform: 'win32' }, (name: string) => { expect(name).toBe('kernel32.dll'); return { symbols: api, close() { calls.push('unload'); } }; }, + { ptr: 'ptr', u32: 'u32', u64: 'u64', i32: 'i32' }, (value: Buffer) => value, + () => { throw new Error('Regular files must not use a path-based fallback'); }, + () => { calls.push('absence'); throw Object.assign(new Error('Absent'), { code: 'ENOENT' }); }, + (file: string) => { expect(file).toBe('owned'); return 'namespaced-owned'; }, + FixtureDeleteError, deleteWithFixtureLease, { now: () => 0, wait: () => { throw new Error('Unexpected wait'); } }); + const lease = factory(100); + lease.unlink('owned', identity, () => { calls.push('verify'); }); + lease.close(); + expect(calls).toEqual(['verify', 'open', 'last-error', 'identity', 'filesystem', 'verify', 'dispose', 'close', 'absence', 'unload']); +}); + +test.skipIf(process.platform !== 'win32')('the native lease refuses a persistent no-delete-sharing holder', () => { + const file = path.join(root, 'persistent'); writeFileSync(file, 'fixture-only'); + const expected = lstatSync(file, { bigint: true }); + const kernel = dlopen('kernel32.dll', { + CreateFileW: { args: [FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.u64], returns: FFIType.u64 }, + CloseHandle: { args: [FFIType.u64], returns: FFIType.i32 }, + }); + const name = Buffer.from(file + '\0', 'utf16le'); + const handle = kernel.symbols.CreateFileW(ptr(name), 0x80000000, 3, null, 3, 0x80, 0); + const lease = createFixtureDeleteLease(performance.now() + 1_000); + try { + expect(BigInt(handle)).not.toBe(0xffffffffffffffffn); + expect(BigInt(handle)).not.toBe(0n); + let failure: unknown; + try { lease.unlink(file, expected, () => {}); } catch (error) { failure = error; } + expect(failure).toBeInstanceOf(FixtureDeleteError); + expect(failure).toMatchObject({ stage: 'admission', win32Error: 32, code: 'EBUSY' }); + expect(readFileSync(file, 'utf8')).toBe('fixture-only'); + expect(lstatSync(file, { bigint: true }).ino).toBe(expected.ino); + } finally { lease.close(); if (BigInt(handle) !== 0xffffffffffffffffn && BigInt(handle) !== 0n) kernel.symbols.CloseHandle(handle); kernel.close(); } +}); + +test.skipIf(process.platform !== 'win32')('a compatible reader retains its data while the admitted delete removes the namespace', () => { + const directory = mkdtempSync(path.join(root, 'reader-')); + const file = path.join(directory, 'data'); writeFileSync(file, 'fixture-only'); + const expected = lstatSync(file, { bigint: true }); + const kernel = dlopen('kernel32.dll', { + CreateFileW: { args: [FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.u64], returns: FFIType.u64 }, + ReadFile: { args: [FFIType.u64, FFIType.ptr, FFIType.u32, FFIType.ptr, FFIType.ptr], returns: FFIType.i32 }, + CloseHandle: { args: [FFIType.u64], returns: FFIType.i32 }, + }); + const name = Buffer.from(file + '\0', 'utf16le'); + const handle = kernel.symbols.CreateFileW(ptr(name), 0x80000000, 7, null, 3, 0x80, 0); + const lease = createFixtureDeleteLease(performance.now() + 2_000); + try { + expect(BigInt(handle)).not.toBe(0xffffffffffffffffn); + expect(BigInt(handle)).not.toBe(0n); + let checks = 0; + lease.unlink(file, expected, () => { if (++checks === 2) expect(() => renameSync(file, path.join(directory, 'moved'))).toThrow(); }); + expect(checks).toBe(2); + expect(existsSync(file)).toBe(false); + rmdirSync(directory); + const buffer = Buffer.alloc(32), bytes = Buffer.alloc(4); + expect(kernel.symbols.ReadFile(handle, ptr(buffer), buffer.length, ptr(bytes), null)).toBe(1); + expect(buffer.subarray(0, bytes.readUInt32LE()).toString()).toBe('fixture-only'); + } finally { lease.close(); if (BigInt(handle) !== 0xffffffffffffffffn && BigInt(handle) !== 0n) kernel.symbols.CloseHandle(handle); kernel.close(); } +}); + +test.skipIf(process.platform !== 'win32')('an independently released holder admits one identity-bound deletion', async () => { + const directory = mkdtempSync(path.join(root, 'released-')); + const file = path.join(directory, 'held'), ready = path.join(directory, 'ready'), release = path.join(directory, 'release'); + writeFileSync(file, 'fixture-only'); const expected = lstatSync(file, { bigint: true }); + const script = ` + const { dlopen, FFIType, ptr } = await import('bun:ffi'); + const { existsSync, writeFileSync } = await import('node:fs'); + const api = dlopen('kernel32.dll', { + CreateFileW: { args: [FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.u64], returns: FFIType.u64 }, + CloseHandle: { args: [FFIType.u64], returns: FFIType.i32 }, + }); + const name = Buffer.from(${JSON.stringify(file)} + '\\0', 'utf16le'); + const handle = api.symbols.CreateFileW(ptr(name), 0x80000000, 3, null, 3, 0x80, 0); + if (BigInt(handle) === 0xffffffffffffffffn || BigInt(handle) === 0n) throw new Error('Holder open failed'); + try { + writeFileSync(${JSON.stringify(ready)}, 'ready'); + while (!existsSync(${JSON.stringify(release)})) await Bun.sleep(10); + } finally { if (!api.symbols.CloseHandle(handle)) throw new Error('Holder close failed'); api.close(); } + `; + const child: ChildProcess = spawn(process.execPath, ['--no-env-file', '--no-install', '--config=NUL', '-e', script], + { env: nativeCookieEnvironment(process.env), stdio: 'ignore', windowsHide: true }); + let spawnError: Error | undefined; + child.once('error', error => { spawnError = error; }); + const closed = new Promise(resolve => child.once('close', () => resolve())); + const timer = setTimeout(() => child.kill(), 5_000); + let lease: ReturnType | undefined; + try { + const deadline = performance.now() + 3_000; + while (!existsSync(ready) && child.exitCode === null && !spawnError && performance.now() < deadline) await Bun.sleep(10); + expect(spawnError).toBeUndefined(); + expect(existsSync(ready)).toBe(true); + let blocked = 0; + lease = createFixtureDeleteLease(performance.now() + 2_000, () => { blocked++; writeFileSync(release, 'release'); }); + lease.unlink(file, expected, () => {}); + expect(blocked).toBe(1); + expect(existsSync(file)).toBe(false); + await closed; + expect(child.exitCode).toBe(0); + } finally { clearTimeout(timer); lease?.close(); child.kill(); await closed; } +}, 10_000); + +test.skipIf(process.platform !== 'win32').each(['identity', 'readonly', 'reparse'])( + 'the native lease refuses %s without removing the file or target', defect => { + const directory = mkdtempSync(path.join(root, 'refused-')); + const file = path.join(directory, 'data'); writeFileSync(file, 'fixture-only'); + const expected = lstatSync(file, { bigint: true }); + const destination = path.join(directory, 'destination'); + if (defect === 'readonly') chmodSync(file, 0o444); + if (defect === 'reparse') { + unlinkSync(file); mkdirSync(destination); writeFileSync(path.join(destination, 'preserved'), 'fixture-only'); + symlinkSync(destination, file, 'junction'); + } + const lease = createFixtureDeleteLease(performance.now() + 2_000); + try { + expect(() => lease.unlink(file, defect === 'identity' ? { ...expected, ino: expected.ino + 1n } : expected, () => {})).toThrow(); + expect(existsSync(file)).toBe(true); + if (defect === 'reparse') expect(readFileSync(path.join(destination, 'preserved'), 'utf8')).toBe('fixture-only'); + else expect(readFileSync(file, 'utf8')).toBe('fixture-only'); + } finally { lease.close(); if (defect === 'readonly') chmodSync(file, 0o666); } + }); diff --git a/browse/test/cookie-import-native-job.test.ts b/browse/test/cookie-import-native-job.test.ts new file mode 100644 index 000000000..2a8c753f9 --- /dev/null +++ b/browse/test/cookie-import-native-job.test.ts @@ -0,0 +1,1588 @@ +import { afterAll, describe, expect, test } from 'bun:test'; +import { spawn, spawnSync, type ChildProcess } from 'node:child_process'; +import { existsSync, lstatSync, mkdirSync, mkdtempSync, opendirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, rmdirSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { once } from 'node:events'; +import { createHash, randomUUID } from 'node:crypto'; +import { Database } from 'bun:sqlite'; +import { dlopen, FFIType, ptr } from 'bun:ffi'; +import { nativeBrowserPaths } from '../src/cookie-import-native'; +import { NATIVE_BROWSER_VERSION_COMMAND } from '../src/cookie-import-native-integrity'; +import { createNativeCookieJob, joinNativeCookieJob, NativeCookieJobError, nativeCookieDiagnostic, parseNativeCookieDiagnostic, type NativeCookieJob } from '../src/cookie-import-native-job'; +import { nativeCookieEnvironment, NATIVE_COOKIE_NODE_SCRIPT, superviseNativeCookieImport, type NativeCookieMember, type NativeCookieReply, type NativeCookieRequest } from '../src/cookie-import-native-worker'; +import { decodeNativeCommandLine } from './fixtures/native-cookie-process-observer'; +import { createFixtureDeleteLease, FixtureDeleteError } from './fixtures/native-cookie-delete-lease'; + +const root = mkdtempSync(path.join(tmpdir(), 'cookie-job-')); +const resolvedRoot = realpathSync(root); +const initialRootState = lstatSync(root, { bigint: true }); +const fixtureChildren = new Set(); +const fixturePrimitiveFailures = new WeakMap(); + +function removeOwnedFixtureDirectory(directory: string, identity: { dev: bigint; ino: bigint }, operations = { + lstat: (file: string) => lstatSync(file, { bigint: true }), + enumerate: (file: string) => readdirSync(file), + unlink: (file: string, _identity: { dev: bigint; ino: bigint; mode: bigint }, _verify: () => void) => unlinkSync(file), + rmdir: (file: string) => rmdirSync(file), +}): void { + let primitive = 'identity'; + let object = directory; + let objectIdentityMatched = false; + let rootIdentityMatched = false; + let objectIdentity: { dev: bigint; ino: bigint; mode: bigint } | undefined; + const ancestors: { file: string; dev: bigint; ino: bigint }[] = [{ file: root, dev: initialRootState.dev, ino: initialRootState.ino }]; + const inspect = (file: string) => { + primitive = 'lstat'; object = file; objectIdentityMatched = false; + objectIdentity = undefined; + const state = operations.lstat(file); + objectIdentity = { dev: state.dev, ino: state.ino, mode: state.mode }; + return state; + }; + const verifyAncestors = (stopBefore?: string) => { + for (const ancestor of ancestors) { + if (ancestor.file === stopBefore) break; + if (ancestor.file === root) rootIdentityMatched = false; + const state = inspect(ancestor.file); + const matches = state.isDirectory() && !state.isSymbolicLink() && state.dev === ancestor.dev && state.ino === ancestor.ino; + if (ancestor.file === root) rootIdentityMatched = matches; + if (!matches) { primitive = 'identity'; throw new Error('Native fixture ancestor identity changed'); } + objectIdentityMatched = true; + } + }; + const remove = (file: string, expected?: { dev: bigint; ino: bigint }) => { + const ancestorCount = ancestors.length; + try { + verifyAncestors(); + const state = inspect(file); + if (expected && (state.dev !== expected.dev || state.ino !== expected.ino || !state.isDirectory() || state.isSymbolicLink())) { + primitive = 'identity'; throw new Error('Native fixture directory identity changed'); + } + if (state.isDirectory() && !state.isSymbolicLink()) { + ancestors.push({ file, dev: state.dev, ino: state.ino }); + verifyAncestors(); + primitive = 'enumerate'; object = file; objectIdentityMatched = true; + const entries = operations.enumerate(file); + for (const entry of entries) { + if (!entry || entry === '.' || entry === '..' || path.basename(entry) !== entry) { + primitive = 'identity'; object = file; throw new Error('Native fixture enumeration escaped its directory'); + } + remove(path.join(file, entry)); + } + verifyAncestors(); + primitive = 'rmdir'; object = file; objectIdentityMatched = true; + operations.rmdir(file); + } else { + const verify = () => { + verifyAncestors(); + if (realpathSync(root) !== resolvedRoot) { primitive = 'identity'; throw new Error('Native fixture root path changed'); } + const current = inspect(file); + if (current.dev !== state.dev || current.ino !== state.ino || current.mode !== state.mode) { + primitive = 'identity'; throw new Error('Native fixture entry identity changed'); + } + primitive = 'unlink'; object = file; objectIdentityMatched = true; + }; + verify(); + operations.unlink(file, state, verify); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT' || object !== file) throw error; + const failedPrimitive = primitive; + const matchedBeforeFailure = objectIdentityMatched; + verifyAncestors(file); + try { inspect(file); } + catch (absenceError) { + if ((absenceError as NodeJS.ErrnoException).code === 'ENOENT') return; + throw absenceError; + } + primitive = failedPrimitive; object = file; objectIdentityMatched = matchedBeforeFailure; + throw error; + } finally { + ancestors.length = ancestorCount; + } + }; + try { + const relative = path.relative(path.resolve(root), path.resolve(directory)); + if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) throw new Error('Native fixture removal must stay below its owned root'); + verifyAncestors(); + if (realpathSync(root) !== resolvedRoot) { primitive = 'identity'; rootIdentityMatched = false; throw new Error('Native fixture root path changed'); } + let parent = root; + for (const part of relative.split(path.sep).slice(0, -1)) { + verifyAncestors(); + parent = path.join(parent, part); + const state = inspect(parent); + if (!state.isDirectory() || state.isSymbolicLink()) { primitive = 'identity'; throw new Error('Native fixture ancestor is not an owned directory'); } + ancestors.push({ file: parent, dev: state.dev, ino: state.ino }); + } + remove(directory, identity); + verifyAncestors(); + primitive = 'postcondition'; object = directory; objectIdentityMatched = false; + try { operations.lstat(directory); } + catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; throw error; } + throw new Error('Native fixture directory remains after removal'); + } catch (error) { + const relative = path.relative(path.resolve(directory), path.resolve(object)); + if (error && typeof error === 'object') fixturePrimitiveFailures.set(error, { + primitive, rootIdentityMatched, objectIdentityMatched, + ...(objectIdentity ? { objectDev: objectIdentity.dev.toString(), objectIno: objectIdentity.ino.toString(), objectMode: Number(objectIdentity.mode) } : {}), + relativeObjectHash: !relative.startsWith('..') && !path.isAbsolute(relative) ? createHash('sha256').update(relative).digest('hex') : undefined, + objectScope: object === directory ? 'target' : relative.startsWith('..') || path.isAbsolute(relative) ? 'ancestor' : 'child', + }); + throw error; + } +} + +function resetOwnedProfileDirectory(directory: string, identity: { dev: bigint; ino: bigint }, supervisionDeadline: number): void { + const lease = createFixtureDeleteLease(Math.min(supervisionDeadline, performance.now() + 5_000)); + try { + removeOwnedFixtureDirectory(directory, identity, { + lstat: file => lstatSync(file, { bigint: true }), enumerate: file => readdirSync(file), + unlink: lease.unlink, rmdir: file => rmdirSync(file), + }); + } finally { lease.close(); } +} + +function ownFixtureChild(child: T): T { + fixtureChildren.add(child); + child.once('close', () => fixtureChildren.delete(child)); + return child; +} + +function nativeFixtureEnvironment(fixture: string, node: string): Record { + const relative = path.relative(resolvedRoot, realpathSync(fixture)); + if (relative.startsWith('..') || path.isAbsolute(relative)) throw new Error('Native environment fixture is outside its owned root'); + const local = path.join(fixture, 'AppData', 'Local'); + const roaming = path.join(fixture, 'AppData', 'Roaming'); + const temporary = path.join(local, 'Temp'); + for (const directory of [local, roaming, temporary]) mkdirSync(directory, { recursive: true }); + return nativeCookieEnvironment({ + SystemRoot: process.env.SystemRoot, USERPROFILE: fixture, + LOCALAPPDATA: local, APPDATA: roaming, TEMP: temporary, TMP: temporary, + PATH: path.dirname(node), + }); +} + +function fixtureFileOwners(file: string, timeout = 5_000, expectedIdentity?: { dev: string; ino: string }): object { + if (process.platform !== 'win32') return { available: false, reason: 'not_windows' }; + const result = spawnSync(process.execPath, [ + '--no-env-file', '--no-install', '--no-macros', '--config=NUL', path.resolve(import.meta.dir, 'fixtures/native-cookie-file-owners.ts'), + Buffer.from(JSON.stringify({ root: resolvedRoot, file, testPid: process.pid, expectedIdentity })).toString('base64'), + ], { env: nativeCookieEnvironment(process.env), encoding: 'utf8', timeout, maxBuffer: 65536, windowsHide: true }); + try { return { ...JSON.parse(result.stdout), exitCode: result.status, stderrBytes: Buffer.byteLength(result.stderr || '') }; } + catch { return { available: false, reason: 'owner_probe_no_receipt', exitCode: result.status }; } +} + +function fixtureRemovalEvidence(error: unknown, directory: string, identity: { dev: bigint; ino: bigint }, probeOwners = fixtureFileOwners): object { + const failure = error as NodeJS.ErrnoException; + const primitive = error && typeof error === 'object' ? fixturePrimitiveFailures.get(error) as { + primitive: string; relativeObjectHash?: string; objectDev?: string; objectIno?: string; + } | undefined : undefined; + const evidence = { + code: ['EBUSY', 'EPERM', 'EACCES', 'ENOENT', 'ENOTEMPTY', 'ENOTDIR'].includes(failure?.code || '') ? failure.code : 'filesystem_error', + syscall: ['rm', 'rmdir', 'unlink', 'scandir', 'lstat', 'open', 'fstat', 'close'].includes(failure?.syscall || '') ? failure.syscall : 'unavailable', + errno: Number.isSafeInteger(failure?.errno) ? failure.errno : undefined, + pendingChildCloses: fixtureChildren.size, + ...(error instanceof FixtureDeleteError ? { deletionStage: error.stage, win32Error: error.win32Error, deadlineExceeded: error.deadlineExceeded } : {}), + ...(error && typeof error === 'object' ? fixturePrimitiveFailures.get(error) : undefined), + }; + try { + const rootState = lstatSync(root, { bigint: true }); + if (rootState.isSymbolicLink() || rootState.dev !== initialRootState.dev || rootState.ino !== initialRootState.ino || realpathSync(root) !== resolvedRoot) { + return { ...evidence, inspected: false, reason: 'root_identity_changed' }; + } + const relative = path.relative(resolvedRoot, path.resolve(directory)); + const parts = relative ? relative.split(path.sep) : []; + if (relative.startsWith('..') || path.isAbsolute(relative) || parts.length > 16) return { ...evidence, inspected: false, reason: 'outside_owned_fixture' }; + let current = root; + for (const part of parts) { + current = path.join(current, part); + if (lstatSync(current).isSymbolicLink()) return { ...evidence, inspected: false, reason: 'linked_object' }; + } + const state = lstatSync(directory, { bigint: true }); + if (!state.isDirectory() || state.dev !== identity.dev || state.ino !== identity.ino) return { ...evidence, inspected: false, reason: 'object_identity_changed' }; + const reported = typeof failure.path === 'string' ? path.relative(directory, path.resolve(failure.path)) : null; + let fileOwners: object | undefined; + if (failure.code === 'EBUSY' && primitive?.primitive === 'unlink' && reported && !reported.startsWith('..') && !path.isAbsolute(reported) + && createHash('sha256').update(reported).digest('hex') === primitive.relativeObjectHash) { + try { + const file = path.join(directory, reported); + const current = lstatSync(file, { bigint: true }); + const resolved = realpathSync(file); + if (!current.isFile() || current.isSymbolicLink() || current.dev.toString() !== primitive.objectDev + || current.ino.toString() !== primitive.objectIno || path.relative(realpathSync(directory), resolved) !== reported) { + fileOwners = { available: false, reason: 'failed_object_identity_changed' }; + } else { + fileOwners = probeOwners(file, 5_000, { dev: primitive.objectDev!, ino: primitive.objectIno! }); + } + } catch (probeError) { + const code = (probeError as NodeJS.ErrnoException).code; + fileOwners = { available: false, reason: 'failed_object_unavailable', errorCode: ['ENOENT', 'EBUSY', 'EPERM', 'EACCES'].includes(code || '') ? code : 'filesystem_error' }; + } + } + const entries: { nameHash: string; type: string }[] = []; + const listing = opendirSync(directory); + let entriesTruncated = false; + try { + while (entries.length < 16) { + const entry = listing.readSync(); + if (!entry) break; + entries.push({ nameHash: createHash('sha256').update(entry.name).digest('hex'), type: entry.isSymbolicLink() ? 'link' : entry.isDirectory() ? 'directory' : entry.isFile() ? 'file' : 'other' }); + } + entriesTruncated = entries.length === 16 && listing.readSync() !== null; + } finally { listing.closeSync(); } + return { + ...evidence, inspected: true, directoryIdentityMatched: true, directoryMode: Number(state.mode), + reportedPath: reported === null ? 'unavailable' : reported === '' ? 'target' : reported.startsWith('..') || path.isAbsolute(reported) ? 'outside_target' : 'child', + reportedPathHash: reported !== null && !reported.startsWith('..') && !path.isAbsolute(reported) ? createHash('sha256').update(reported).digest('hex') : undefined, + entries, entriesTruncated, ...(fileOwners ? { fileOwners } : {}), + }; + } catch (inspectionError) { + const code = (inspectionError as NodeJS.ErrnoException).code; + return { ...evidence, inspected: false, reason: 'inspection_failed', inspectionCode: ['ENOENT', 'EBUSY', 'EPERM', 'EACCES', 'ENOTDIR'].includes(code || '') ? code : 'filesystem_error' }; + } +} + +function clearOwnedFixtureContents(fixture: string, identity: { path: string; dev: bigint; ino: bigint }, listEntries: (directory: string) => string[] = readdirSync): void { + const before = lstatSync(fixture, { bigint: true }); + if (before.isSymbolicLink() || realpathSync(fixture) !== identity.path || before.dev !== identity.dev || before.ino !== identity.ino) { + throw new Error('Native fixture root identity changed'); + } + const ownerProbeDeadline = Date.now() + 5_000; + for (const entry of listEntries(fixture)) { + const child = path.join(fixture, entry); + try { + const state = lstatSync(child); + if (state.isSymbolicLink()) { + unlinkSync(child); + continue; + } + const relative = path.relative(identity.path, realpathSync(child)); + if (relative.startsWith('..') || path.isAbsolute(relative)) throw new Error('Native fixture entry is outside its owned root'); + const ownersBeforeDelete = state.isFile() && /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\.tmp$/i.test(entry) + ? Date.now() < ownerProbeDeadline ? fixtureFileOwners(child, Math.max(1, ownerProbeDeadline - Date.now())) : { available: false, reason: 'owner_probe_budget_exhausted' } + : undefined; + if (ownersBeforeDelete) console.log(JSON.stringify({ nativeFixtureBeforeDelete: { file: path.relative(resolvedRoot, child), owners: ownersBeforeDelete } })); + try { rmSync(child, { recursive: true, force: true }); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue; + console.error(JSON.stringify({ nativeFixtureFileLock: { + file: path.relative(resolvedRoot, child), code: (error as NodeJS.ErrnoException).code, ownersBeforeDelete, + ownersAfterFailure: state.isFile() ? fixtureFileOwners(child) : undefined, + } })); + throw error; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue; + throw error; + } + } + const after = lstatSync(fixture, { bigint: true }); + if (readdirSync(fixture).length !== 0 || realpathSync(fixture) !== identity.path || after.dev !== identity.dev || after.ino !== identity.ino) { + throw new Error('Native fixture contents reset did not preserve its empty root'); + } +} + +afterAll(() => { + try { + if (existsSync(root) && realpathSync(root) !== resolvedRoot) throw new Error('Native fixture root ownership changed'); + rmSync(root, { recursive: true, force: true }); + } catch (error) { + console.error(JSON.stringify({ nativeFixtureRemovalFailure: { stage: 'after_all', ...fixtureRemovalEvidence(error, root, initialRootState) } })); + const entries: { path: string; type: string; mode?: number; code?: string }[] = []; + let rootVerified = false; + let rootMode: number | null = null; + try { rootVerified = realpathSync(root) === resolvedRoot; rootMode = lstatSync(root).mode; } catch {} + const pending = rootVerified ? [root] : []; + while (pending.length && entries.length < 128) { + const directory = pending.shift()!; + try { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (entries.length >= 128) break; + const child = path.join(directory, entry.name); + const state = lstatSync(child); + entries.push({ path: path.relative(root, child), type: state.isSymbolicLink() ? 'link' : state.isDirectory() ? 'directory' : 'file', mode: state.mode }); + if (state.isDirectory() && !state.isSymbolicLink() && realpathSync(child).startsWith(resolvedRoot + path.sep)) pending.push(child); + } + } catch (inspectionError) { + entries.push({ path: path.relative(root, directory), type: 'unreadable', code: (inspectionError as NodeJS.ErrnoException).code }); + } + } + const lockedFile = entries.find(entry => entry.type === 'file' && /^[0-9a-f-]{36}\.tmp$/i.test(path.basename(entry.path))); + console.error(JSON.stringify({ nativeFixtureCleanup: { code: (error as NodeJS.ErrnoException).code, pendingChildCloses: fixtureChildren.size, rootVerified, rootMode, remaining: entries, + fileOwners: lockedFile ? { file: lockedFile.path, owners: fixtureFileOwners(path.join(root, lockedFile.path)) } : undefined, + } })); + const node = Bun.which('node'); + if (rootVerified && node) { + const comparison = spawnSync(node, [path.resolve(import.meta.dir, 'fixtures/native-cookie-remove-fixture.cjs'), Buffer.from(JSON.stringify({ + root, realpath: resolvedRoot, dev: initialRootState.dev.toString(), ino: initialRootState.ino.toString(), + })).toString('base64')], { env: nativeCookieEnvironment(process.env), encoding: 'utf8', timeout: 5_000, windowsHide: true, maxBuffer: 65536 }); + let evidence: object; + try { evidence = JSON.parse(comparison.stdout); } + catch { evidence = { removed: false, reason: 'node_cleanup_no_receipt', exitCode: comparison.status }; } + console.error(JSON.stringify({ nativeFixtureNodeCleanupComparison: evidence })); + } + throw error; + } +}, 15_000); + +const request: NativeCookieRequest = { + nodeExecutable: 'C:\\fixture\\node.exe', + nodeArchitecture: process.arch, + playwrightEntry: 'C:\\fixture\\playwright.cjs', + executablePath: 'C:\\fixture\\msedge.exe', + userDataDir: 'C:\\fixture\\User Data', + profile: 'Default', + domains: ['example.test'], + deadline: 25_000, + qualifiedBunVersions: ['1.4.0'], +}; + +function kernelContract(mode: string) { + const script = ` + const calls = []; + globalThis.__nativeCookieFfi = { + FFIType: { ptr: 'ptr', u64: 'u64', u32: 'u32', i32: 'i32' }, + ptr: buffer => buffer, + dlopen: (name, signatures) => { + calls.push(['library', name, Object.keys(signatures)]); + return { close() { calls.push(['unload']); }, symbols: { + CreateJobObjectW: (security, name) => { calls.push(['create', security, name.toString('utf16le')]); return 42; }, + OpenJobObjectW: (access, inherit, name) => { calls.push(['open', access, inherit, name.toString('utf16le')]); return 42; }, + SetInformationJobObject: (handle, kind, buffer, length) => { calls.push(['limits', handle, kind, length, buffer.readUInt32LE(16)]); return 1; }, + QueryInformationJobObject: (handle, kind, buffer, length) => { calls.push(['query', handle, kind, length]); buffer.writeUInt32LE(3, 40); return 1; }, + OpenProcess: (access, inherit, pid) => { calls.push(['open-process', access, inherit, pid === process.pid]); return 999; }, + AssignProcessToJobObject: (job, process) => { calls.push(['assign', job, process]); return ${mode === 'join-fail' ? 0 : 1}; }, + TerminateJobObject: (job, code) => { calls.push(['terminate', job, code]); return 1; }, + CloseHandle: handle => { calls.push(['close', handle]); return 1; }, + GetLastError: () => ${mode === 'join-fail' ? 5 : 0}, + } }; + }, + }; + const source = await Bun.file(${JSON.stringify(path.resolve(import.meta.dir, '../src/cookie-import-native-job.ts'))}).text(); + const boundary = "await import('bun:ffi')"; + if (source.split(boundary).length !== 2) throw new Error('FFI adapter boundary changed'); + const javascript = new Bun.Transpiler({ loader: 'ts' }).transformSync(source.replace(boundary, 'globalThis.__nativeCookieFfi')); + const { createNativeCookieJob, joinNativeCookieJob } = await import('data:text/javascript;base64,' + Buffer.from(javascript).toString('base64')); + Object.defineProperty(process, 'platform', { value: 'win32' }); + if (${JSON.stringify(mode)} === 'create') { + const job = await createNativeCookieJob(); + const active = job.activeProcesses(); + job.terminate(); job.close(); job.close(); + console.log(JSON.stringify({ name: job.name, active, calls })); + } else { + let error, diagnostic; + try { await joinNativeCookieJob('Local\\\\gstack-cookie-12345678-1234-1234-1234-123456789abc'); } catch (caught) { error = caught.message; diagnostic = caught.diagnostic; } + console.log(JSON.stringify({ calls, error, diagnostic })); + } + `; + const result = spawnSync(process.execPath, ['--no-env-file', '--no-install', `--config=${process.platform === 'win32' ? 'NUL' : '/dev/null'}`, '-e', script], { + env: { TEMP: root, TMP: root, HOME: root, USERPROFILE: root, ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}) }, + encoding: 'utf8', + timeout: 10_000, + }); + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + return JSON.parse(result.stdout); +} + +describe('production Windows Job Object API contract', () => { + test('diagnostics expose only known native stages and numeric Windows errors', () => { + expect(nativeCookieDiagnostic(new NativeCookieJobError('job_assign', 5), 'job_create')).toEqual({ stage: 'job_assign', win32Error: 5 }); + expect(nativeCookieDiagnostic(new Error('sensitive-sentinel'), 'ffi_open')).toEqual({ stage: 'ffi_open' }); + expect(parseNativeCookieDiagnostic({ stage: 'job_assign', win32Error: 87, message: 'sensitive-sentinel' })).toEqual({ stage: 'job_assign', win32Error: 87 }); + expect(parseNativeCookieDiagnostic({ stage: 'sensitive-sentinel', win32Error: 5 })).toBeUndefined(); + expect(parseNativeCookieDiagnostic({ stage: 'job_assign', win32Error: 'sensitive-sentinel' })).toEqual({ stage: 'job_assign' }); + expect(parseNativeCookieDiagnostic({ stage: 'member_exit', lastStage: 'node_spawned', exitCode: 1, nodeExitCode: 1, signal: 'SIGTERM', memberMode: true, stderrBytes: 50 })).toEqual({ stage: 'member_exit', lastStage: 'node_spawned', exitCode: 1, nodeExitCode: 1, signal: 'SIGTERM', memberMode: true, stderrBytes: 50 }); + expect(parseNativeCookieDiagnostic({ stage: 'member_exit', lastStage: 'sensitive-sentinel', signal: 'sensitive-sentinel', exitCode: 'sensitive-sentinel', memberMode: 'sensitive-sentinel', stderrBytes: -1 })).toEqual({ stage: 'member_exit' }); + }); + + test('owner creates a non-inheritable kill-on-close job and queries active members', () => { + const result = kernelContract('create'); + expect(result.name).toMatch(/^Local\\gstack-cookie-[0-9a-f-]{36}$/); + expect(result.active).toBe(3); + expect(result.calls).toContainEqual(['create', null, `${result.name}\0`]); + expect(result.calls).toContainEqual(['limits', 42, 9, 144, 0x2000]); + expect(result.calls).toContainEqual(['query', 42, 1, 48]); + expect(result.calls).toContainEqual(['terminate', 42, 1]); + expect(result.calls.filter((call: unknown[]) => call[0] === 'close')).toEqual([['close', 42]]); + }); + + test('member assigns its own handle, never a discovered PID', () => { + const result = kernelContract('join'); + expect(result.error).toBeUndefined(); + expect(result.calls).toContainEqual(['open', 1, 0, 'Local\\gstack-cookie-12345678-1234-1234-1234-123456789abc\0']); + expect(result.calls).toContainEqual(['open-process', 0x0101, 0, true]); + expect(result.calls).toContainEqual(['assign', 42, 999]); + expect(result.calls).toContainEqual(['close', 999]); + expect(result.calls).toContainEqual(['close', 42]); + }); + + test('failed self-assignment closes its handle and fails without a child launch', () => { + const result = kernelContract('join-fail'); + expect(result.error).toBe('native_supervision_failed'); + expect(result.diagnostic).toEqual({ stage: 'job_assign', win32Error: 5 }); + expect(result.calls).toContainEqual(['close', 999]); + expect(result.calls).toContainEqual(['close', 42]); + }); +}); + +function simulation(options: { reply?: NativeCookieReply; replyAt?: number; exitAt?: number; retainAfterTerminate?: boolean; signal?: AbortSignal; cancelAt?: () => void } = {}) { + let time = 0; + let active = 1; + let terminated = 0; + let stopped = 0; + let jobClosed = 0; + let started = 0; + let settleReply!: (reply: NativeCookieReply) => void; + let settleClose!: () => void; + const member: NativeCookieMember = { + result: new Promise(resolve => { settleReply = resolve; }), + closed: new Promise(resolve => { settleClose = resolve; }), + stop: () => { stopped++; }, + }; + const job: NativeCookieJob = { + name: 'synthetic-owned-job', + terminate: () => { + terminated++; + if (!options.retainAfterTerminate) { active = 0; settleClose(); } + }, + activeProcesses: () => active, + close: () => { jobClosed++; }, + }; + const run = superviseNativeCookieImport(request, { + createJob: async () => job, + startMember: actual => { expect(actual.deadline).toBe(25_000); started++; return member; }, + now: () => time, + sleep: async milliseconds => { + time += milliseconds; + if (options.reply && time >= (options.replyAt ?? 20)) settleReply(options.reply); + if (options.exitAt !== undefined && time >= options.exitAt) { active = 0; settleClose(); } + options.cancelAt?.(); + }, + signal: options.signal, + }); + return { run, state: () => ({ time, active, terminated, stopped, jobClosed, started }) }; +} + +describe('owned native-cookie lifecycle', () => { + test('single-pass removal visits directories before removing them and never follows a leaf link', () => { + const fixture = mkdtempSync(path.join(root, 'single-pass-')); + const target = path.join(fixture, 'target'); + const outside = path.join(fixture, 'outside'); + mkdirSync(path.join(target, 'nested'), { recursive: true }); + mkdirSync(outside); + writeFileSync(path.join(target, 'nested', 'file'), 'fixture-only'); + writeFileSync(path.join(outside, 'preserved'), 'fixture-only'); + symlinkSync(outside, path.join(target, 'link'), process.platform === 'win32' ? 'junction' : 'dir'); + const identity = lstatSync(target, { bigint: true }); + const actions: string[] = []; + removeOwnedFixtureDirectory(target, identity, { + lstat: file => lstatSync(file, { bigint: true }), + enumerate: file => { actions.push(`enumerate:${path.relative(target, file)}`); return readdirSync(file); }, + unlink: file => { actions.push(`unlink:${path.relative(target, file)}`); unlinkSync(file); }, + rmdir: file => { actions.push(`rmdir:${path.relative(target, file)}`); rmdirSync(file); }, + }); + expect(existsSync(target)).toBe(false); + expect(readFileSync(path.join(outside, 'preserved'), 'utf8')).toBe('fixture-only'); + expect(actions.filter(action => action.startsWith('enumerate:'))).toHaveLength(2); + expect(actions.filter(action => action.startsWith('unlink:'))).toHaveLength(2); + expect(actions.filter(action => action.startsWith('rmdir:'))).toHaveLength(2); + expect(actions.indexOf(`unlink:${path.join('nested', 'file')}`)).toBeLessThan(actions.indexOf('rmdir:nested')); + expect(actions.at(-1)).toBe('rmdir:'); + }); + + test('single-pass removal preserves the first original primitive error and stops', () => { + for (const failedPrimitive of ['lstat', 'enumerate', 'unlink', 'rmdir']) { + const target = mkdtempSync(path.join(root, 'first-removal-error-')); + const identity = lstatSync(target, { bigint: true }); + if (failedPrimitive !== 'rmdir') { + writeFileSync(path.join(target, 'first'), 'fixture-only'); + writeFileSync(path.join(target, 'second'), 'fixture-only'); + } + const original = Object.freeze(Object.assign(new Error('sensitive-sentinel'), { code: 'EBUSY', errno: -4082, syscall: failedPrimitive })); + const calls: string[] = []; + let caught: unknown; + try { + removeOwnedFixtureDirectory(target, identity, { + lstat: file => { + if (failedPrimitive === 'lstat' && file === path.join(target, 'first')) { calls.push('lstat'); throw original; } + return lstatSync(file, { bigint: true }); + }, + enumerate: file => { calls.push('enumerate'); if (failedPrimitive === 'enumerate') throw original; return failedPrimitive === 'rmdir' ? [] : ['first', 'second']; }, + unlink: file => { calls.push('unlink'); throw original; }, + rmdir: file => { calls.push('rmdir'); throw original; }, + }); + } catch (error) { caught = error; } + expect(caught).toBe(original); + expect(calls).toEqual(failedPrimitive === 'enumerate' ? ['enumerate'] : ['enumerate', failedPrimitive]); + const receipt = fixtureRemovalEvidence(caught, target, identity); + expect(receipt).toMatchObject({ primitive: failedPrimitive, rootIdentityMatched: true, objectIdentityMatched: failedPrimitive !== 'lstat', directoryIdentityMatched: true, relativeObjectHash: createHash('sha256').update(['lstat', 'unlink'].includes(failedPrimitive) ? 'first' : '').digest('hex') }); + expect(JSON.stringify(receipt)).not.toContain('sensitive-sentinel'); + if (failedPrimitive !== 'rmdir') { + expect(readFileSync(path.join(target, 'first'), 'utf8')).toBe('fixture-only'); + expect(readFileSync(path.join(target, 'second'), 'utf8')).toBe('fixture-only'); + } + } + }); + + test('single-pass removal accepts disappeared entries without repeating a removal', () => { + for (const stage of ['before_lstat', 'during_unlink', 'during_rmdir']) { + const fixture = mkdtempSync(path.join(root, 'removal-disappearing-')); + const target = path.join(fixture, 'target'); + const vanished = path.join(target, 'first'); + const moved = path.join(fixture, 'moved'); + mkdirSync(target); + if (stage === 'during_rmdir') mkdirSync(vanished); + else writeFileSync(vanished, 'fixture-only'); + writeFileSync(path.join(target, 'second'), 'fixture-only'); + const identity = lstatSync(target, { bigint: true }); + const vanishedIdentity = lstatSync(vanished, { bigint: true }); + const removals: string[] = []; + const enumerated: string[] = []; + removeOwnedFixtureDirectory(target, identity, { + lstat: file => lstatSync(file, { bigint: true }), + enumerate: file => { + enumerated.push(file); + const entries = readdirSync(file).sort(); + if (stage === 'before_lstat' && file === target) renameSync(vanished, moved); + return entries; + }, + unlink: file => { + removals.push(file); + if (stage === 'during_unlink' && file === vanished) renameSync(vanished, moved); + unlinkSync(file); + }, + rmdir: file => { + removals.push(file); + if (stage === 'during_rmdir' && file === vanished) renameSync(vanished, moved); + rmdirSync(file); + }, + }); + expect(existsSync(target)).toBe(false); + expect(new Set(removals).size).toBe(removals.length); + expect(new Set(enumerated).size).toBe(enumerated.length); + expect(removals.filter(file => file === vanished)).toHaveLength(stage === 'before_lstat' ? 0 : 1); + expect(lstatSync(moved, { bigint: true }).ino).toBe(vanishedIdentity.ino); + if (stage === 'during_rmdir') expect(readdirSync(moved)).toEqual([]); + else expect(readFileSync(moved, 'utf8')).toBe('fixture-only'); + } + }); + + test('single-pass removal never treats replacement, ancestor changes, or other errors as absence', () => { + for (const code of ['ENOENT', 'EBUSY', 'EPERM', 'EACCES', 'ancestor_replacement']) { + const fixture = mkdtempSync(path.join(root, 'removal-not-absent-')); + const target = path.join(fixture, 'target'); + const first = path.join(target, 'first'); + const moved = path.join(fixture, 'moved'); + mkdirSync(target); + writeFileSync(first, 'fixture-only'); + writeFileSync(path.join(target, 'second'), 'untouched-only'); + const identity = lstatSync(target, { bigint: true }); + const original = Object.freeze(Object.assign(new Error('synthetic-removal-error'), { code: code === 'ancestor_replacement' ? 'ENOENT' : code, syscall: 'unlink' })); + let calls = 0; + let caught: unknown; + try { + removeOwnedFixtureDirectory(target, identity, { + lstat: file => lstatSync(file, { bigint: true }), + enumerate: file => readdirSync(file).sort(), + unlink: file => { + calls++; + if (code === 'ENOENT') { + renameSync(file, moved); + writeFileSync(file, 'replacement-only'); + } else if (code === 'ancestor_replacement') { + renameSync(target, moved); + mkdirSync(target); + writeFileSync(first, 'replacement-only'); + } + throw original; + }, + rmdir: file => rmdirSync(file), + }); + } catch (error) { caught = error; } + expect(calls).toBe(1); + if (code === 'ancestor_replacement') { + expect(caught).toBeInstanceOf(Error); + expect((caught as Error).message).toContain('ancestor identity changed'); + expect(fixturePrimitiveFailures.get(caught as object)).toMatchObject({ primitive: 'identity', objectIdentityMatched: false }); + expect(readFileSync(path.join(moved, 'second'), 'utf8')).toBe('untouched-only'); + } else { + expect(caught).toBe(original); + expect(fixturePrimitiveFailures.get(caught as object)).toMatchObject({ primitive: 'unlink', relativeObjectHash: createHash('sha256').update('first').digest('hex') }); + expect(readFileSync(path.join(target, 'second'), 'utf8')).toBe('untouched-only'); + } + expect(readFileSync(first, 'utf8')).toBe(code === 'ENOENT' || code === 'ancestor_replacement' ? 'replacement-only' : 'fixture-only'); + } + }); + + test('single-pass removal rejects changed identities and linked ancestors before touching their contents', () => { + const fixture = mkdtempSync(path.join(root, 'removal-identity-')); + const target = path.join(fixture, 'target'); + mkdirSync(target); + writeFileSync(path.join(target, 'preserved'), 'fixture-only'); + const identity = lstatSync(target, { bigint: true }); + expect(() => removeOwnedFixtureDirectory(target, { dev: identity.dev, ino: identity.ino + 1n })).toThrow('directory identity changed'); + expect(() => removeOwnedFixtureDirectory(path.dirname(root), identity)).toThrow('stay below its owned root'); + const link = path.join(fixture, 'link'); + symlinkSync(target, link, process.platform === 'win32' ? 'junction' : 'dir'); + expect(() => removeOwnedFixtureDirectory(path.join(link, 'preserved'), identity)).toThrow('ancestor is not an owned directory'); + unlinkSync(link); + const moved = path.join(fixture, 'moved'); + let caught: unknown; + try { + removeOwnedFixtureDirectory(target, identity, { + lstat: file => lstatSync(file, { bigint: true }), + enumerate: file => { + const entries = readdirSync(file); + renameSync(target, moved); + mkdirSync(target); + writeFileSync(path.join(target, 'preserved'), 'replacement-only'); + return entries; + }, + unlink: file => unlinkSync(file), + rmdir: file => rmdirSync(file), + }); + } catch (error) { caught = error; } + expect(caught).toBeInstanceOf(Error); + expect(fixturePrimitiveFailures.get(caught as object)).toMatchObject({ primitive: 'identity', objectScope: 'target', objectIdentityMatched: false }); + expect(readFileSync(path.join(moved, 'preserved'), 'utf8')).toBe('fixture-only'); + expect(readFileSync(path.join(target, 'preserved'), 'utf8')).toBe('replacement-only'); + }); + + test('removal diagnostics preserve errors and inspect only the unchanged synthetic directory', () => { + const fixture = mkdtempSync(path.join(root, 'removal-evidence-')); + const identity = lstatSync(fixture, { bigint: true }); + writeFileSync(path.join(fixture, 'sensitive-sentinel'), 'fixture-only'); + const error = Object.assign(new Error('sensitive-sentinel'), { code: 'EBUSY', syscall: 'rm', errno: -4082, path: fixture }); + const evidence = fixtureRemovalEvidence(error, fixture, identity); + expect(evidence).toMatchObject({ code: 'EBUSY', syscall: 'rm', errno: -4082, inspected: true, directoryIdentityMatched: true, reportedPath: 'target', entries: [{ type: 'file' }] }); + expect(JSON.stringify(evidence)).not.toContain('sensitive-sentinel'); + expect(JSON.stringify(evidence)).not.toContain(fixture); + expect(readFileSync(path.join(fixture, 'sensitive-sentinel'), 'utf8')).toBe('fixture-only'); + expect(error.path).toBe(fixture); + expect(fixtureRemovalEvidence(error, fixture, { dev: identity.dev, ino: identity.ino + 1n })).toMatchObject({ inspected: false, reason: 'object_identity_changed' }); + expect(fixtureRemovalEvidence(error, path.dirname(root), identity)).toMatchObject({ inspected: false, reason: 'outside_owned_fixture' }); + const missing = path.join(fixture, 'missing'); + expect(fixtureRemovalEvidence(error, missing, identity)).toMatchObject({ inspected: false, inspectionCode: 'ENOENT' }); + const link = path.join(root, 'removal-evidence-link'); + symlinkSync(fixture, link, process.platform === 'win32' ? 'junction' : 'dir'); + expect(fixtureRemovalEvidence(error, link, identity)).toMatchObject({ inspected: false, reason: 'linked_object' }); + unlinkSync(link); + for (let index = 0; index < 20; index++) writeFileSync(path.join(fixture, String(index)), 'fixture-only'); + const bounded = fixtureRemovalEvidence(error, fixture, identity) as { entries: unknown[]; entriesTruncated: boolean }; + expect(bounded.entries).toHaveLength(16); + expect(bounded.entriesTruncated).toBe(true); + }); + + test.each(['unchanged', 'replaced', 'linked', 'missing', 'wrong_path'])('a leaf lock probe is bound to the exact failed object: %s', state => { + const fixture = mkdtempSync(path.join(root, 'leaf-owner-')); + const target = path.join(fixture, 'target'); + const nested = path.join(target, 'nested'); + mkdirSync(nested, { recursive: true }); + const file = path.join(nested, 'sensitive-sentinel.sqlite'); + writeFileSync(file, 'fixture-only'); + const identity = lstatSync(target, { bigint: true }); + const fileIdentity = lstatSync(file, { bigint: true }); + const original = Object.assign(new Error('sensitive-sentinel'), { code: 'EBUSY', syscall: 'unlink', errno: -4082, path: file }); + let removals = 0; + let caught: unknown; + try { + removeOwnedFixtureDirectory(target, identity, { + lstat: candidate => lstatSync(candidate, { bigint: true }), enumerate: candidate => readdirSync(candidate), + unlink: candidate => { expect(candidate).toBe(file); removals++; throw original; }, + rmdir: () => { throw new Error('Must stop at the first failure'); }, + }); + } catch (error) { caught = error; } + expect(caught).toBe(original); + if (state === 'replaced') { + renameSync(file, path.join(fixture, 'original')); + writeFileSync(file, 'replacement-only'); + } else if (state === 'linked') { + const moved = path.join(fixture, 'moved'); + renameSync(nested, moved); + symlinkSync(moved, nested, process.platform === 'win32' ? 'junction' : 'dir'); + } else if (state === 'missing') unlinkSync(file); + else if (state === 'wrong_path') original.path = path.join(fixture, 'elsewhere'); + const queried: string[] = []; + const owners = { available: true, owners: [{ pid: 123, image: 'bun.exe', creationMatched: true, isTestHost: true }] }; + const receipt = fixtureRemovalEvidence(caught, target, identity, (candidate, timeout, expectedIdentity) => { + queried.push(candidate); + expect(timeout).toBe(5_000); + expect(expectedIdentity).toEqual({ dev: fileIdentity.dev.toString(), ino: fileIdentity.ino.toString() }); + return owners; + }); + expect(receipt).toMatchObject({ code: 'EBUSY', primitive: 'unlink', objectDev: fileIdentity.dev.toString(), objectIno: fileIdentity.ino.toString() }); + expect(queried).toEqual(state === 'unchanged' ? [file] : []); + if (state === 'unchanged') { + expect(receipt).toMatchObject({ fileOwners: owners }); + expect(readFileSync(file, 'utf8')).toBe('fixture-only'); + } else if (state !== 'wrong_path') expect(receipt).toMatchObject({ fileOwners: { available: false } }); + else expect(receipt).not.toHaveProperty('fileOwners'); + expect(removals).toBe(1); + expect(JSON.stringify(receipt)).not.toContain('sensitive-sentinel'); + expect(JSON.stringify(receipt)).not.toContain(fixture); + expect(caught).toBe(original); + }); + + test('positive fixture environments create matching Windows known-folder directories', () => { + const fixture = mkdtempSync(path.join(root, 'environment-')); + const node = Bun.which('node'); + if (!node) throw new Error('Node is required for fixture setup'); + const environment = nativeFixtureEnvironment(fixture, node); + expect(environment.USERPROFILE).toBe(fixture); + expect(environment.LOCALAPPDATA).toBe(path.join(fixture, 'AppData', 'Local')); + expect(environment.APPDATA).toBe(path.join(fixture, 'AppData', 'Roaming')); + for (const name of ['LOCALAPPDATA', 'APPDATA', 'TEMP']) expect(lstatSync(environment[name]).isDirectory()).toBe(true); + expect(environment.TMP).toBe(environment.TEMP); + }); + + test('a contents reset preserves an existing root even while an owned process holds it', async () => { + const fixture = mkdtempSync(path.join(root, 'held-root-')); + const state = lstatSync(fixture, { bigint: true }); + const identity = { path: realpathSync(fixture), dev: state.dev, ino: state.ino }; + const node = Bun.which('node'); + if (!node) throw new Error('Node is required for root ownership verification'); + const child = ownFixtureChild(spawn(node, ['-e', 'process.stdout.write("ready"); setInterval(() => {}, 1000)'], { + cwd: fixture, + env: { PATH: path.dirname(node), ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}) }, + stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true, + })); + const closed = new Promise(resolve => child.once('close', () => resolve())); + try { + await once(child.stdout, 'data', { signal: AbortSignal.timeout(5_000) }); + if (process.platform === 'win32') expect(() => rmdirSync(fixture)).toThrow(); + mkdirSync(path.join(fixture, 'AppData', 'Local'), { recursive: true }); + writeFileSync(path.join(fixture, 'AppData', 'Local', 'synthetic'), 'fixture-only'); + writeFileSync(path.join(fixture, 'wrapper.cjs'), 'fixture-only'); + clearOwnedFixtureContents(fixture, identity); + expect(readdirSync(fixture)).toEqual([]); + const after = lstatSync(fixture, { bigint: true }); + expect(after.dev).toBe(identity.dev); + expect(after.ino).toBe(identity.ino); + expect(alive(child.pid!)).toBe(true); + } finally { + child.kill(); + await closed; + } + }, 10_000); + + test('a contents reset rejects a different root identity without removing entries', () => { + const fixture = mkdtempSync(path.join(root, 'identity-')); + const state = lstatSync(fixture, { bigint: true }); + const marker = path.join(fixture, 'preserved'); + writeFileSync(marker, 'fixture-only'); + expect(() => clearOwnedFixtureContents(fixture, { path: realpathSync(fixture), dev: state.dev, ino: state.ino + 1n })).toThrow('identity changed'); + expect(readFileSync(marker, 'utf8')).toBe('fixture-only'); + }); + + test('a contents reset accepts an entry that disappears after enumeration without retrying it', () => { + const fixture = mkdtempSync(path.join(root, 'disappearing-entry-')); + const state = lstatSync(fixture, { bigint: true }); + const identity = { path: realpathSync(fixture), dev: state.dev, ino: state.ino }; + const disappearing = path.join(fixture, '00000000-0000-0000-0000-000000000000.tmp'); + writeFileSync(disappearing, 'fixture-only'); + writeFileSync(path.join(fixture, 'remaining'), 'fixture-only'); + let listings = 0; + clearOwnedFixtureContents(fixture, identity, directory => { + listings++; + const entries = readdirSync(directory); + unlinkSync(disappearing); + return entries; + }); + expect(listings).toBe(1); + expect(readdirSync(fixture)).toEqual([]); + const after = lstatSync(fixture, { bigint: true }); + expect(after.dev).toBe(identity.dev); + expect(after.ino).toBe(identity.ino); + }); + + test('a contents reset still rejects a nonempty postcondition', () => { + const fixture = mkdtempSync(path.join(root, 'nonempty-reset-')); + const state = lstatSync(fixture, { bigint: true }); + writeFileSync(path.join(fixture, 'preserved'), 'fixture-only'); + expect(() => clearOwnedFixtureContents(fixture, { path: realpathSync(fixture), dev: state.dev, ino: state.ino }, () => [])).toThrow('empty root'); + expect(readFileSync(path.join(fixture, 'preserved'), 'utf8')).toBe('fixture-only'); + }); + + test('the Node cleanup comparison refuses changed identity and removes only its owned fixture', () => { + const node = Bun.which('node'); + if (!node) throw new Error('Node is required for cleanup comparison'); + const fixture = mkdtempSync(path.join(root, 'node-cleanup-')); + const state = lstatSync(fixture, { bigint: true }); + const marker = path.join(fixture, 'preserved'); + writeFileSync(marker, 'fixture-only'); + const invoke = (ino: bigint) => spawnSync(node, [path.resolve(import.meta.dir, 'fixtures/native-cookie-remove-fixture.cjs'), Buffer.from(JSON.stringify({ + root: fixture, realpath: realpathSync(fixture), dev: state.dev.toString(), ino: ino.toString(), + })).toString('base64')], { env: nativeCookieEnvironment(process.env), encoding: 'utf8', timeout: 5_000, windowsHide: true }); + const refused = invoke(state.ino + 1n); + expect(refused.status).toBe(1); + expect(JSON.parse(refused.stdout).reason).toBe('identity_mismatch'); + expect(readFileSync(marker, 'utf8')).toBe('fixture-only'); + const removed = invoke(state.ino); + expect(removed.status).toBe(0); + expect(JSON.parse(removed.stdout).removed).toBe(true); + expect(existsSync(fixture)).toBe(false); + expect(existsSync(root)).toBe(true); + }, 15_000); + + test('success is withheld until the entire job is empty and member exits', async () => { + const run = simulation({ reply: { cookies: [] }, exitAt: 200 }); + expect(await run.run).toEqual({ cookies: [] }); + expect(run.state()).toMatchObject({ time: 200, active: 0, terminated: 0, stopped: 1, jobClosed: 1, started: 1 }); + }); + + test('a stuck graceful close is forcibly cleaned after two seconds', async () => { + const run = simulation({ reply: { cookies: [] } }); + expect(await run.run).toEqual({ cookies: [] }); + expect(run.state()).toMatchObject({ active: 0, terminated: 1, jobClosed: 1, started: 1 }); + expect(run.state().time).toBeLessThan(5_000); + }); + + test('launch/read timeout gets one 25s operation budget and owned cleanup', async () => { + const run = simulation(); + expect(await run.run).toEqual({ error: 'native_timeout' }); + expect(run.state()).toMatchObject({ active: 0, terminated: 1, jobClosed: 1, started: 1 }); + expect(run.state().time).toBeGreaterThanOrEqual(25_000); + expect(run.state().time).toBeLessThan(30_000); + }); + + test.each(['failure', 'success', 'rejection'])('a late member %s cannot overwrite the timeout selected before cleanup', async mode => { + let now = 0; + let active = 1; + let resolveReply!: (reply: NativeCookieReply) => void; + let rejectReply!: (error: Error) => void; + let resolveClosed!: () => void; + const reply = new Promise((resolve, reject) => { resolveReply = resolve; rejectReply = reject; }); + const closed = new Promise(resolve => { resolveClosed = resolve; }); + const result = await superviseNativeCookieImport(request, { + now: () => now, + sleep: async milliseconds => { now += milliseconds; }, + createJob: async () => ({ + name: 'synthetic-owned-job', activeProcesses: () => active, close() {}, + terminate() { + expect(now).toBe(25_000); + active = 0; + if (mode === 'rejection') rejectReply(new Error('synthetic late rejection')); + else resolveReply(mode === 'success' ? { cookies: [] } : { error: 'native_failed', diagnostic: { stage: 'member_exit' } }); + resolveClosed(); + }, + }), + startMember: () => ({ result: reply, closed, stop() {} }), + }); + expect(result).toEqual({ error: 'native_timeout' }); + expect(active).toBe(0); + expect(now).toBeLessThan(30_000); + }); + + test('unconfirmed termination is reported as cleanup failure, never success', async () => { + const run = simulation({ retainAfterTerminate: true }); + expect(await run.run).toEqual({ error: 'native_cleanup_failed' }); + expect(run.state()).toMatchObject({ time: 30_000, terminated: 1, stopped: 1, jobClosed: 1 }); + }); + + test('a process crash cannot strand remaining members', async () => { + let terminated = 0; + let active = 1; + const result = await superviseNativeCookieImport(request, { + now: () => 0, + sleep: async () => {}, + createJob: async () => ({ name: 'synthetic', activeProcesses: () => active, terminate: () => { active = 0; terminated++; }, close() {} }), + startMember: () => ({ result: new Promise(() => {}), closed: Promise.resolve(), stop() {} }), + }); + expect(result).toEqual({ error: 'native_failed' }); + expect(terminated).toBe(1); + }); + + test('a failed job close still stops its owned member and cannot report success', async () => { + let stopped = false; + const result = await superviseNativeCookieImport(request, { + now: () => 0, + sleep: async () => {}, + createJob: async () => ({ name: 'synthetic', activeProcesses: () => 0, terminate() {}, close() { throw new Error('sensitive-close-detail'); } }), + startMember: () => ({ result: Promise.resolve({ cookies: [] }), closed: Promise.resolve(), stop() { stopped = true; } }), + }); + expect(result).toEqual({ error: 'native_cleanup_failed', diagnostic: { stage: 'job_close' } }); + expect(stopped).toBe(true); + }); + + test('locked-profile errors are classified and never retried', async () => { + const run = simulation({ reply: { error: 'browser_running' } }); + expect(await run.run).toEqual({ error: 'browser_running' }); + expect(run.state()).toMatchObject({ active: 0, terminated: 1, started: 1 }); + }); + + test('loss of the parent channel cancels the operation and cleans the job', async () => { + const cancellation = new AbortController(); + const run = simulation({ signal: cancellation.signal, cancelAt: () => cancellation.abort() }); + expect(await run.run).toEqual({ error: 'native_failed' }); + expect(run.state()).toMatchObject({ active: 0, terminated: 1, started: 1 }); + expect(run.state().time).toBeLessThan(5_000); + }); + + test('job initialization failure dispatches no child and hides native errors', async () => { + let started = false; + const result = await superviseNativeCookieImport(request, { + createJob: async () => { throw new Error('sensitive-sentinel'); }, + startMember: () => { started = true; throw new Error('unexpected'); }, + }); + expect(result).toEqual({ error: 'native_supervision_failed', diagnostic: { stage: 'job_create' } }); + expect(started).toBe(false); + }); + + test.skipIf(process.platform === 'win32')('non-Windows job API fails before any process is launched', async () => { + await expect(createNativeCookieJob()).rejects.toThrow('native_supervision_unavailable'); + await expect(joinNativeCookieJob('invalid')).rejects.toThrow('native_supervision_failed'); + }); +}); + +function alive(pid: number): boolean { + try { process.kill(pid, 0); return true; } catch { return false; } +} + +function safeNativeEnvelope(output: string): object { + try { + const parsed = JSON.parse(output); + if (Array.isArray(parsed.cookies)) return { cookiesRead: parsed.cookies.length }; + const errors = ['native_timeout', 'native_failed', 'native_cleanup_failed', 'native_supervision_failed', 'browser_running', 'native_profile_unsupported']; + return { error: errors.includes(parsed.error) ? parsed.error : 'unexpected_reply', diagnostic: parseNativeCookieDiagnostic(parsed.diagnostic) }; + } catch { + return { error: 'no_complete_reply' }; + } +} + +function assertOwnerReceipt(receipt: { available: boolean; owners: { pid: number; creationMatched: boolean; isTestHost: boolean }[] }, pid: number) { + const before = JSON.stringify(receipt); + expect(receipt.available).toBe(true); + expect(Array.isArray(receipt.owners)).toBe(true); + expect(receipt.owners.some(owner => owner.pid === pid && owner.creationMatched && owner.isTestHost)).toBe(true); + expect(JSON.stringify(receipt)).toBe(before); +} + +function assertCookieReceipt(receipt: NativeCookieReply, evidence: object = {}) { + if (!('cookies' in receipt) || !Array.isArray(receipt.cookies)) { + throw new Error(JSON.stringify({ nativeCookieReceipt: safeNativeEnvelope(JSON.stringify(receipt)), ...evidence })); + } + return receipt.cookies; +} + +function safeLaunchEvidence(file: string): object { + try { + const observed = JSON.parse(readFileSync(file, 'utf8')); + return { + spawned: Number.isInteger(observed.pid), pipe: observed.args?.includes('--remote-debugging-pipe'), + argsHash: observed.argsHash, envHash: observed.envHash, + reasons: observed.reasons, stderrBytes: observed.stderrBytes, + exitCode: observed.exitCode, signal: observed.signal, spawnError: observed.spawnError, + runtime: observed.runtime, observedCommandLine: observed.observedCommandLine, + folderEvidence: observed.folderEvidence, + }; + } catch { + return { spawned: false }; + } +} + +function nativeSupervisor(input: NativeCookieRequest, env: NodeJS.ProcessEnv) { + const cleanupDeadline = performance.now() + 30_000; + const child = ownFixtureChild(spawn(process.execPath, ['--no-env-file', '--no-install', '--no-macros', '--config=NUL', path.resolve(import.meta.dir, '../src/cookie-import-native-worker.ts')], { env, stdio: ['pipe', 'pipe', 'ignore'], windowsHide: true })); + let output = ''; + child.stdout.on('data', chunk => { output += chunk; }); + const timer = setTimeout(() => child.kill(), 30_000); + const done = new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', () => { + clearTimeout(timer); + try { resolve(JSON.parse(output)); } catch { reject(new Error('Native supervisor did not return a complete receipt')); } + }); + }); + void done.catch(() => {}); + child.stdin.on('error', () => {}); + child.stdin.write(JSON.stringify({ ...input, deadline: Date.now() + 25_000, qualifiedBunVersions: [Bun.version] }) + '\n'); + return { child, done, cleanupDeadline, envelope: () => safeNativeEnvelope(output) }; +} + +describe('native Windows process qualification', () => { + test.skipIf(process.platform !== 'win32' || process.env.GSTACK_COOKIE_NATIVE_DEFAULT_FIXTURE !== '1')('an exclusively created default Edge profile persists v20 and reimports it through the owned Node worker', async () => { + if (process.env.GITHUB_ACTIONS !== 'true' || process.env.CI !== 'true') throw new Error('Default-profile qualification requires a disposable GitHub Actions Windows runner'); + const node = Bun.which('node'); + const mapping = nativeBrowserPaths('Edge', process.env); + const edge = mapping.executables.find(existsSync); + if (!node || !edge) throw new Error('Native qualification requires Node and installed Microsoft Edge'); + const fixture = mkdtempSync(path.join(root, 'default-edge-')); + const observation = path.join(fixture, 'default-launch.json'); + const playwrightEntry = path.join(fixture, 'seed-playwright.cjs'); + const require = createRequire(import.meta.url); + const marker = path.join(mapping.userDataDir, '.gstack-owned-fixture'); + const nonce = randomUUID(); + let owned = false; + const createdParents: string[] = []; + let supervisor: ReturnType | undefined; + try { + if (existsSync(mapping.userDataDir)) throw new Error('Refusing an existing Edge default data directory; qualification needs a fresh disposable runner'); + let parent = process.env.LOCALAPPDATA!; + if (realpathSync(parent).toLowerCase() !== path.resolve(parent).toLowerCase()) throw new Error('Refusing a redirected local-data root'); + for (const segment of ['Microsoft', 'Edge']) { + parent = path.join(parent, segment); + if (!existsSync(parent)) { mkdirSync(parent); createdParents.push(parent); } + if (realpathSync(parent).toLowerCase() !== path.resolve(parent).toLowerCase()) throw new Error('Refusing a redirected default-profile parent'); + } + mkdirSync(mapping.userDataDir); + owned = true; + writeFileSync(marker, nonce, { flag: 'wx', mode: 0o600 }); + writeFileSync(playwrightEntry, `module.exports = require(${JSON.stringify(path.resolve(import.meta.dir, 'fixtures/native-cookie-launch.cjs'))})(${JSON.stringify({ + observation, playwrightEntry: require.resolve('playwright'), + seedCookie: { name: 'synthetic-native-qualification', value: 'synthetic-only', domain: 'example.test', path: '/', secure: true, httpOnly: true, expires: Math.floor(Date.now() / 1000) + 3600 }, + })});`); + const input = { ...request, nodeExecutable: node, executablePath: edge, userDataDir: mapping.userDataDir, playwrightEntry }; + const env = nativeCookieEnvironment(process.env); + supervisor = nativeSupervisor(input, env); + const seeded = await supervisor.done; + const seededCookies = assertCookieReceipt(seeded, { realDefaultProfile: true, launch: safeLaunchEvidence(observation) }); + expect(seededCookies).toHaveLength(1); + const database = new Database(path.join(mapping.userDataDir, 'Default', 'Network', 'Cookies'), { readonly: true }); + try { + const row = database.query("SELECT hex(substr(encrypted_value, 1, 3)) AS prefix FROM cookies WHERE name = 'synthetic-native-qualification' AND host_key = 'example.test'").get() as { prefix: string } | null; + expect(row?.prefix).toBe('763230'); + } finally { + database.close(); + } + supervisor = nativeSupervisor({ ...input, playwrightEntry: require.resolve('playwright') }, env); + const imported = await supervisor.done; + const importedCookies = assertCookieReceipt(imported); + expect(importedCookies).toHaveLength(1); + expect(importedCookies[0].name).toBe('synthetic-native-qualification'); + expect(importedCookies[0].value).toBe('synthetic-only'); + expect(importedCookies[0].domain).toBe('example.test'); + } finally { + supervisor?.child.kill(); + await supervisor?.done.catch(() => {}); + if (owned) { + if (realpathSync(mapping.userDataDir).toLowerCase() !== path.resolve(mapping.userDataDir).toLowerCase() || readFileSync(marker, 'utf8') !== nonce) throw new Error('Default fixture ownership changed; refusing cleanup'); + rmSync(mapping.userDataDir, { recursive: true, force: true }); + } + for (const parent of createdParents.reverse()) rmdirSync(parent); + } + }, 65_000); + + test.skipIf(process.platform !== 'win32')('a locked real Edge profile leaves its existing owner alive', async () => { + const node = Bun.which('node'); + const edge = nativeBrowserPaths('Edge', process.env).executables.find(existsSync); + if (!node || !edge) throw new Error('Native qualification requires Node and installed Microsoft Edge'); + const fixture = mkdtempSync(path.join(root, 'locked-edge-')); + const marker = path.join(fixture, 'owner-ready.json'); + const contenderObservation = path.join(fixture, 'contender-launch.json'); + const contenderEntry = path.join(fixture, 'contender-playwright.cjs'); + const playwrightEntry = path.join(fixture, 'held-playwright.cjs'); + const require = createRequire(import.meta.url); + writeFileSync(contenderEntry, `module.exports = require(${JSON.stringify(path.resolve(import.meta.dir, 'fixtures/native-cookie-launch.cjs'))})(${JSON.stringify({ observation: contenderObservation, playwrightEntry: require.resolve('playwright') })});`); + writeFileSync(playwrightEntry, ` + const cp = require('node:child_process'); + const spawn = cp.spawn; + let pid; + cp.spawn = function(command, args, options) { + if (args.some(arg => /^--(?:no-sandbox|disable-setuid-sandbox)(?:=|$)/.test(arg))) throw new Error('Native owner fixture refuses a sandbox-disabled browser'); + const child = spawn.call(this, command, args, options); 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); + require('node:fs').writeFileSync(${JSON.stringify(marker)}, JSON.stringify({ pid })); + context.cookies = () => new Promise(() => {}); + return context; + } }; + `); + const env = nativeFixtureEnvironment(fixture, node); + const input = { ...request, nodeExecutable: node, executablePath: edge, userDataDir: path.join(fixture, 'User Data'), playwrightEntry }; + const owner = nativeSupervisor(input, env); + let contender: ReturnType | undefined; + try { + const readyBy = Date.now() + 10_000; + while (!existsSync(marker) && Date.now() < readyBy) await Bun.sleep(20); + expect({ ready: existsSync(marker), reply: owner.envelope() }).toMatchObject({ ready: true }); + const { pid } = JSON.parse(readFileSync(marker, 'utf8')); + contender = nativeSupervisor({ ...input, playwrightEntry: contenderEntry }, env); + expect({ result: await contender.done, launch: safeLaunchEvidence(contenderObservation) }).toMatchObject({ result: { error: 'browser_running' } }); + expect(alive(pid)).toBe(true); + } finally { + contender?.child.kill(); + owner.child.kill(); + await contender?.done.catch(() => {}); + await owner.done.catch(() => {}); + } + }, 35_000); + + for (const mode of ['normal-close', 'stalled-close']) { + test.skipIf(process.platform !== 'win32')(`real Edge synthetic profile: ${mode} returns only after the owned browser exits`, async () => { + const node = Bun.which('node'); + const edge = nativeBrowserPaths('Edge', process.env).executables.find(existsSync); + if (!node || !edge) throw new Error('Native qualification requires Node and installed Microsoft Edge'); + const fixture = mkdtempSync(path.join(root, 'edge-')); + const observation = path.join(fixture, 'browser.json'); + const playwrightEntry = path.join(fixture, 'observed-playwright.cjs'); + const require = createRequire(import.meta.url); + writeFileSync(playwrightEntry, `module.exports = require(${JSON.stringify(path.resolve(import.meta.dir, 'fixtures/native-cookie-launch.cjs'))})(${JSON.stringify({ observation, playwrightEntry: require.resolve('playwright'), mode })});`); + const environment = nativeFixtureEnvironment(fixture, node); + const supervisor = ownFixtureChild(spawn(process.execPath, ['--no-env-file', '--no-install', '--no-macros', '--config=NUL', path.resolve(import.meta.dir, '../src/cookie-import-native-worker.ts')], { env: environment, stdio: ['pipe', 'pipe', 'ignore'], windowsHide: true })); + const closed = new Promise(resolve => supervisor.once('close', () => resolve())); + let output = ''; + supervisor.stdout.on('data', chunk => { output += chunk; }); + const started = Date.now(); + const deadline = setTimeout(() => supervisor.kill(), 30_000); + supervisor.stdin.write(JSON.stringify({ ...request, nodeExecutable: node, playwrightEntry, executablePath: edge, userDataDir: path.join(fixture, 'User Data'), deadline: started + 25_000, qualifiedBunVersions: [Bun.version] }) + '\n'); + try { + await closed; + const result = JSON.parse(output); + const cookies = assertCookieReceipt(result, { launch: safeLaunchEvidence(observation) }); + expect(cookies).toHaveLength(1); + expect(cookies[0].domain).toBe('example.test'); + const browser = JSON.parse(readFileSync(observation, 'utf8')); + expect(browser.command).toBe(edge); + expect(browser.args).toContain('--remote-debugging-pipe'); + expect(browser.args.some((arg: string) => arg.startsWith('--remote-debugging-port'))).toBe(false); + expect(browser.args.some((arg: string) => /^--(?:no-sandbox|disable-setuid-sandbox)(?:=|$)/.test(arg))).toBe(false); + expect(alive(browser.pid)).toBe(false); + expect(Date.now() - started).toBeLessThan(30_000); + } finally { + clearTimeout(deadline); + supervisor.kill(); + await closed; + } + }, 35_000); + } + + for (const mode of ['timeout', 'owner-exit', 'worker-crash']) { + test.skipIf(process.platform !== 'win32')(`${mode} kills owned descendants and preserves an unrelated process`, async () => { + const node = Bun.which('node'); + if (!node) throw new Error('Node is required for Windows qualification'); + const fixture = mkdtempSync(path.join(root, 'native-')); + const pids = path.join(fixture, 'owned.json'); + const playwrightEntry = path.join(fixture, 'playwright.cjs'); + writeFileSync(playwrightEntry, `module.exports = require(${JSON.stringify(path.resolve(import.meta.dir, 'fixtures/native-cookie-process.cjs'))})(${JSON.stringify({ pidsFile: pids, mode })});`); + const environment = nativeFixtureEnvironment(fixture, node); + const sibling = ownFixtureChild(spawn(node, ['-e', 'setInterval(() => {}, 1000)'], { env: environment, stdio: 'ignore', windowsHide: true })); + const siblingClosed = new Promise(resolve => sibling.once('close', () => resolve())); + const supervisor = ownFixtureChild(spawn(process.execPath, ['--no-env-file', '--no-install', '--no-macros', '--config=NUL', path.resolve(import.meta.dir, '../src/cookie-import-native-worker.ts')], { env: environment, stdio: ['pipe', 'pipe', 'ignore'], windowsHide: true })); + const closed = new Promise(resolve => supervisor.once('close', () => resolve())); + let output = ''; + supervisor.stdout.on('data', chunk => { output += chunk; }); + const started = Date.now(); + supervisor.stdin.write(JSON.stringify({ ...request, nodeExecutable: node, playwrightEntry, userDataDir: fixture, deadline: started + (mode === 'timeout' ? 3_000 : 25_000), qualifiedBunVersions: [Bun.version] }) + '\n'); + try { + while (!existsSync(pids) && Date.now() - started < 2_500) await Bun.sleep(20); + expect({ ready: existsSync(pids), reply: safeNativeEnvelope(output) }).toMatchObject({ ready: true }); + const owned = JSON.parse(readFileSync(pids, 'utf8')) as number[]; + if (mode === 'owner-exit') supervisor.kill(); + await closed; + const cleanupDeadline = Date.now() + 5_000; + while (owned.some(alive) && Date.now() < cleanupDeadline) await Bun.sleep(20); + expect(owned.some(alive)).toBe(false); + expect(alive(sibling.pid!)).toBe(true); + if (mode === 'timeout') { + expect(JSON.parse(output)).toEqual({ error: 'native_timeout' }); + expect(Date.now() - started).toBeLessThan(8_000); + } + if (mode === 'worker-crash') expect(JSON.parse(output)).toMatchObject({ error: 'native_failed' }); + } finally { + supervisor.kill(); + sibling.kill(); + await Promise.all([closed, siblingClosed]); + } + }, 15_000); + } +}); + +describe('native Windows launch diagnostics', () => { + for (const kind of ['file', 'directory'] as const) { + test.skipIf(process.platform !== 'win32')(`single-pass removal preserves a real ${kind} delete-sharing conflict`, () => { + const fixture = mkdtempSync(path.join(root, 'removal-lock-')); + const target = path.join(fixture, 'target'); + const sibling = path.join(fixture, 'preserved'); + mkdirSync(target); + writeFileSync(sibling, 'unrelated-fixture-only'); + const locked = kind === 'file' ? path.join(target, 'held') : target; + if (kind === 'file') writeFileSync(locked, 'held-fixture-only'); + const identity = lstatSync(target, { bigint: true }); + const lockedIdentity = lstatSync(locked, { bigint: true }); + expect(realpathSync(target)).toBe(path.join(realpathSync(fixture), 'target')); + expect(lockedIdentity.isSymbolicLink()).toBe(false); + const kernel = dlopen('kernel32.dll', { + CreateFileW: { args: [FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.u64], returns: FFIType.u64 }, + CloseHandle: { args: [FFIType.u64], returns: FFIType.i32 }, + }); + const name = Buffer.from(locked + '\0', 'utf16le'); + const handle = kernel.symbols.CreateFileW(ptr(name), 0x80000000, 3, null, 3, kind === 'directory' ? 0x02000000 : 0x80, 0); + try { + expect(BigInt(handle)).not.toBe(0xffffffffffffffffn); + expect(BigInt(handle)).not.toBe(0n); + let caught: unknown; + try { removeOwnedFixtureDirectory(target, identity); } + catch (error) { caught = error; } + expect(caught).toBeInstanceOf(Error); + expect((caught as NodeJS.ErrnoException).code).toBe('EBUSY'); + const primitive = kind === 'file' ? 'unlink' : 'rmdir'; + expect((caught as NodeJS.ErrnoException).syscall).toBe(primitive); + const receipt = fixtureRemovalEvidence(caught, target, identity); + expect(receipt).toMatchObject({ primitive, rootIdentityMatched: true, objectIdentityMatched: true, directoryIdentityMatched: true, relativeObjectHash: createHash('sha256').update(kind === 'file' ? 'held' : '').digest('hex') }); + if (kind === 'file') { + const owners = (receipt as { fileOwners: { available: boolean; owners: { pid: number; creationMatched: boolean; isTestHost: boolean }[] } }).fileOwners; + assertOwnerReceipt(owners, process.pid); + } + console.log(JSON.stringify({ nativeFixtureOwnedLockControl: { kind, ...receipt } })); + expect(lstatSync(locked, { bigint: true }).ino).toBe(lockedIdentity.ino); + expect(lstatSync(locked, { bigint: true }).dev).toBe(lockedIdentity.dev); + if (kind === 'file') expect(readFileSync(locked, 'utf8')).toBe('held-fixture-only'); + else expect(readdirSync(locked)).toEqual([]); + expect(readFileSync(sibling, 'utf8')).toBe('unrelated-fixture-only'); + } finally { + try { + if (BigInt(handle) !== 0xffffffffffffffffn && BigInt(handle) !== 0n) expect(kernel.symbols.CloseHandle(handle)).toBe(1); + } finally { kernel.close(); } + } + removeOwnedFixtureDirectory(target, identity); + expect(existsSync(target)).toBe(false); + expect(readFileSync(sibling, 'utf8')).toBe('unrelated-fixture-only'); + }, 10_000); + } + + test.each(['resolve_root', 'resolve_file'])('file-owner diagnostics preserve the %s filesystem failure without exposing its path', stage => { + const fixture = mkdtempSync(path.join(root, 'owner-stage-')); + const missing = path.join(fixture, 'sensitive-sentinel'); + const input = { root: stage === 'resolve_root' ? missing : fixture, file: missing, testPid: process.pid }; + const script = ` + await import('node:fs'); + await import('node:path'); + Object.defineProperty(process, 'platform', { value: 'win32' }); + process.argv = [process.execPath, 'fixture', ${JSON.stringify(Buffer.from(JSON.stringify(input)).toString('base64'))}]; + await import(${JSON.stringify(path.resolve(import.meta.dir, 'fixtures/native-cookie-file-owners.ts'))}); + `; + const result = spawnSync(process.execPath, ['--no-env-file', '--no-install', `--config=${process.platform === 'win32' ? 'NUL' : '/dev/null'}`, '-e', script], { + env: { TEMP: fixture, TMP: fixture, HOME: fixture, USERPROFILE: fixture, ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}) }, + encoding: 'utf8', timeout: 10_000, + }); + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + expect(JSON.parse(result.stdout)).toEqual({ available: false, reason: 'owner_query_failed', stage, errorCode: 'ENOENT' }); + expect(result.stdout).not.toContain('sensitive-sentinel'); + }); + + test.skipIf(process.platform !== 'win32')('Restart Manager identifies the exact fixture file holder without stopping it', () => { + const fixture = mkdtempSync(path.join(root, 'file-owner-')); + const file = path.join(fixture, 'held.tmp'); + writeFileSync(file, 'fixture-only'); + const kernel = dlopen('kernel32.dll', { + CreateFileW: { args: [FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.u64], returns: FFIType.u64 }, + CloseHandle: { args: [FFIType.u64], returns: FFIType.i32 }, + }); + const name = Buffer.from(file + '\0', 'utf16le'); + const handle = kernel.symbols.CreateFileW(ptr(name), 0x80000000, 1, null, 3, 0x80, 0); + try { + expect(BigInt(handle)).not.toBe(0xffffffffffffffffn); + expect(() => unlinkSync(file)).toThrow(); + const evidence = fixtureFileOwners(file) as { available: boolean; owners: { pid: number; image: string; creationMatched: boolean; isTestHost: boolean }[] }; + expect(evidence.available).toBe(true); + expect(Array.isArray(evidence.owners)).toBe(true); + expect(evidence.owners.some(owner => owner.pid === process.pid && owner.image === 'bun.exe' && owner.creationMatched && owner.isTestHost)).toBe(true); + expect(readFileSync(file, 'utf8')).toBe('fixture-only'); + } finally { + if (BigInt(handle) !== 0xffffffffffffffffn) kernel.symbols.CloseHandle(handle); + kernel.close(); + } + }, 10_000); + + test('the owner-probe child rejects a changed file identity before loading native APIs', () => { + const fixture = mkdtempSync(path.join(root, 'owner-identity-')); + const file = path.join(fixture, 'sensitive-sentinel'); + writeFileSync(file, 'fixture-only'); + const identity = lstatSync(file, { bigint: true }); + const input = { root: fixture, file, testPid: process.pid, + expectedIdentity: { dev: identity.dev.toString(), ino: (identity.ino + 1n).toString() } }; + const script = ` + await import('node:fs'); + await import('node:path'); + Object.defineProperty(process, 'platform', { value: 'win32' }); + process.argv = [process.execPath, 'fixture', ${JSON.stringify(Buffer.from(JSON.stringify(input)).toString('base64'))}]; + await import(${JSON.stringify(path.resolve(import.meta.dir, 'fixtures/native-cookie-file-owners.ts'))}); + `; + const result = spawnSync(process.execPath, ['--no-env-file', '--no-install', `--config=${process.platform === 'win32' ? 'NUL' : '/dev/null'}`, '-e', script], { + env: { TEMP: fixture, TMP: fixture, HOME: fixture, USERPROFILE: fixture, ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}) }, + encoding: 'utf8', timeout: 10_000, + }); + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + expect(JSON.parse(result.stdout)).toEqual({ available: false, reason: 'failed_object_identity_changed' }); + expect(result.stdout).not.toContain('sensitive-sentinel'); + expect(readFileSync(file, 'utf8')).toBe('fixture-only'); + }); + + test('receipt assertions preserve the cookie array and all cookie fields', () => { + const receipt: NativeCookieReply = { cookies: [{ name: 'synthetic', value: 'synthetic', domain: 'example.test', path: '/', expires: -1, httpOnly: true, secure: true, sameSite: 'Lax' }] }; + const before = JSON.stringify(receipt); + const cookies = receipt.cookies; + Object.freeze(receipt); + Object.freeze(cookies); + Object.freeze(cookies[0]); + expect(assertCookieReceipt(receipt)).toBe(cookies); + expect(Array.isArray(receipt.cookies)).toBe(true); + expect(JSON.stringify(receipt)).toBe(before); + expect(() => assertCookieReceipt({ error: 'native_timeout' })).toThrow('native_timeout'); + }); + + test('holder assertions preserve the real owner array and reject nonmatching identities', () => { + const receipt = { available: true, owners: [{ pid: 123, image: 'bun.exe', creationMatched: true, isTestHost: true }] }; + const before = JSON.stringify(receipt); + const owners = receipt.owners; + Object.freeze(receipt); + Object.freeze(owners); + Object.freeze(owners[0]); + assertOwnerReceipt(receipt, 123); + expect(receipt.owners).toBe(owners); + expect(Array.isArray(receipt.owners)).toBe(true); + expect(JSON.stringify(receipt)).toBe(before); + expect(() => assertOwnerReceipt(receipt, 124)).toThrow(); + expect(() => assertOwnerReceipt({ available: true, owners: [{ pid: 123, creationMatched: false, isTestHost: true }] }, 123)).toThrow(); + }); + + test('native Unicode output retains the exact allocation address for short and long buffers', () => { + const library = dlopen(process.platform === 'win32' ? 'msvcrt.dll' : process.platform === 'darwin' ? '/usr/lib/libSystem.B.dylib' : 'libc.so.6', { + memcpy: { args: [FFIType.ptr, FFIType.ptr, FFIType.u64], returns: FFIType.ptr }, + }); + try { + for (const length of [32, 128, 512, 1024, 4096]) { + const target = Buffer.alloc(length); + const address = ptr(target); + const source = Buffer.alloc(length); + source.writeUInt16LE(4, 0); + source.writeBigUInt64LE(BigInt(address) + 16n, 8); + source.write('ab', 16, 'utf16le'); + library.symbols.memcpy(address, ptr(source), length); + expect(decodeNativeCommandLine(target, address)).toBe('ab'); + target.writeBigUInt64LE(BigInt(address) + BigInt(length), 8); + expect(decodeNativeCommandLine(target, address)).toBeNull(); + } + } finally { + library.close(); + } + }); + + test.skipIf(process.platform !== 'win32')('the native observer reads only the owned process and preserves Windows argument boundaries', async () => { + const node = Bun.which('node'); + if (!node) throw new Error('Node is required for process metadata verification'); + const environment = nativeCookieEnvironment(process.env); + const args = ['-e', 'setInterval(() => {}, 1000)', '--', 'argument with spaces', '--user-data-dir=C:\\synthetic sensitive-sentinel']; + const child = ownFixtureChild(spawn(node, args, { env: environment, stdio: 'ignore', windowsHide: true })); + const closed = new Promise(resolve => child.once('close', () => resolve())); + const observe = (owner: number) => spawnSync(process.execPath, [ + '--no-env-file', '--no-install', '--no-macros', '--config=NUL', path.resolve(import.meta.dir, 'fixtures/native-cookie-process-observer.ts'), + Buffer.from(JSON.stringify({ pid: child.pid, owner, image: node })).toString('base64'), + ], { env: environment, encoding: 'utf8', timeout: 5_000, windowsHide: true }); + try { + const denied = observe(process.pid + 1); + expect(denied.status).toBe(0); + expect(JSON.parse(denied.stdout)).toMatchObject({ available: false, reason: 'owned_process_unavailable', parentMatched: false }); + const observed = observe(process.pid); + expect(observed.status).toBe(0); + expect(observed.stderr).toBe(''); + expect(observed.stdout).not.toContain('sensitive-sentinel'); + expect(JSON.parse(observed.stdout)).toMatchObject({ + available: true, parentMatched: true, imageMatched: true, + argumentHashes: args.map(arg => createHash('sha256').update(arg).digest('hex')), + userDataDirCount: 1, + }); + } finally { + child.kill(); + await closed; + } + }, 15_000); + + test.skipIf(process.platform !== 'win32')('the known-folder observer reports hashes and statuses without exposing profile paths', () => { + const result = spawnSync(process.execPath, [ + '--no-env-file', '--no-install', '--no-macros', '--config=NUL', path.resolve(import.meta.dir, 'fixtures/native-cookie-process-observer.ts'), + Buffer.from(JSON.stringify({ mode: 'known-folders' })).toString('base64'), + ], { env: nativeCookieEnvironment(process.env), encoding: 'utf8', timeout: 5_000, windowsHide: true }); + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + const resultObject = JSON.parse(result.stdout); + expect(resultObject).toMatchObject({ available: true }); + for (const name of ['local', 'roaming']) { + expect(Number.isInteger(resultObject.knownFolders[name].verified.hresult)).toBe(true); + expect(resultObject.knownFolders[name].dontVerify.pathHash).toMatch(/^[a-f0-9]{64}$/); + } + if (process.env.USERPROFILE) expect(result.stdout).not.toContain(process.env.USERPROFILE); + }); + + test.skipIf(process.platform !== 'win32')('the qualification version command reads the explicit executable environment variable', () => { + const node = Bun.which('node'); + if (!node) throw new Error('Node is required for version metadata verification'); + const powershell = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); + const env = { ...nativeCookieEnvironment(process.env), GSTACK_QUALIFY_BROWSER_EXE: node }; + const old = spawnSync(powershell, ['-NoProfile', '-NonInteractive', '-Command', NATIVE_BROWSER_VERSION_COMMAND.replace('$env:GSTACK_QUALIFY_BROWSER_EXE', '$env.GSTACK_QUALIFY_BROWSER_EXE')], { + env, encoding: 'utf8', timeout: 10_000, windowsHide: true, + }); + expect(old.status).not.toBe(0); + expect(old.stdout.trim()).toBe(''); + const result = spawnSync(powershell, ['-NoProfile', '-NonInteractive', '-Command', NATIVE_BROWSER_VERSION_COMMAND], { + env, encoding: 'utf8', timeout: 10_000, windowsHide: true, + }); + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + expect(result.stdout.trim()).toMatch(/^\d+(?:\.\d+){2,3}$/); + }, 25_000); + + test('the synthetic launch observer records safe reasons without raw stderr or environment values', () => { + const node = Bun.which('node'); + if (!node) throw new Error('Node is required for launch diagnostics'); + const fixture = mkdtempSync(path.join(root, 'launch-observer-')); + const observation = path.join(fixture, 'launch.json'); + const playwrightEntry = path.join(fixture, 'fake-playwright.cjs'); + writeFileSync(playwrightEntry, ` + exports.chromium = { async launchPersistentContext() { + const child = require('node:child_process').spawn('synthetic-browser.exe', ['--remote-debugging-pipe'], { env: { SYNTHETIC: 'sensitive-sentinel' } }); + child.stderr.write('AssignProcessToJobObject ERROR_ACCESS_DENIED sensitive-sentinel'); + child.emit('exit', 5, null); + return { addCookies: async () => {} }; + } }; + `); + const script = ` + const cp = require('node:child_process'); + const child = new (require('node:events').EventEmitter)(); + child.pid = 12345; + child.stderr = new (require('node:stream').PassThrough)(); + cp.spawn = () => child; + const api = require(${JSON.stringify(path.resolve(import.meta.dir, 'fixtures/native-cookie-launch.cjs'))})(${JSON.stringify({ observation, playwrightEntry })}); + api.chromium.launchPersistentContext('synthetic-profile', { chromiumSandbox: true }).then(() => console.log('observed')); + `; + const result = spawnSync(node, ['-e', script], { + env: { PATH: path.dirname(node), TEMP: fixture, TMP: fixture, HOME: fixture, USERPROFILE: fixture, ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}) }, + encoding: 'utf8', timeout: 10_000, + }); + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe('observed'); + expect(result.stderr).toBe(''); + const observed = JSON.parse(readFileSync(observation, 'utf8')); + expect(observed).toMatchObject({ exitCode: 5, reasons: ['job_assignment_failed', 'permission_denied'] }); + expect(observed.argsHash).toMatch(/^[a-f0-9]{64}$/); + expect(observed.envHash).toMatch(/^[a-f0-9]{64}$/); + expect(observed.runtime.node).toMatch(/^v\d+\.\d+\.\d+/); + expect(observed.runtime.bun).toBeNull(); + expect(JSON.stringify(observed)).not.toContain('sensitive-sentinel'); + }); + + for (const layout of ['flat', 'folders-ready']) { + test.skipIf(process.platform !== 'win32' || process.env.GITHUB_ACTIONS !== 'true')(`compares contained and direct Node Edge launch with identical argv and environment (${layout} profile layout)`, async () => { + const node = Bun.which('node'); + const edge = nativeBrowserPaths('Edge', process.env).executables.find(existsSync); + if (!node || !edge) throw new Error('Native launch comparison requires Node and installed Microsoft Edge'); + const fixture = mkdtempSync(path.join(root, 'edge-comparison-')); + const userDataDir = path.join(fixture, 'User Data'); + const observation = path.join(fixture, 'launch.json'); + const playwrightEntry = path.join(fixture, 'observed-playwright.cjs'); + const require = createRequire(import.meta.url); + writeFileSync(playwrightEntry, `module.exports = require(${JSON.stringify(path.resolve(import.meta.dir, 'fixtures/native-cookie-launch.cjs'))})(${JSON.stringify({ observation, playwrightEntry: require.resolve('playwright'), inspectCommandLine: true, observerExecutable: process.execPath })});`); + if (layout !== 'flat') { + mkdirSync(path.join(fixture, 'AppData', 'Local'), { recursive: true }); + mkdirSync(path.join(fixture, 'AppData', 'Roaming'), { recursive: true }); + } + const environment = nativeCookieEnvironment({ SystemRoot: process.env.SystemRoot!, TEMP: fixture, TMP: fixture, USERPROFILE: fixture, LOCALAPPDATA: fixture, APPDATA: fixture, PATH: path.dirname(node) }); + const input = { ...request, nodeExecutable: node, executablePath: edge, userDataDir, playwrightEntry }; + const supervisor = nativeSupervisor(input, environment); + const contained = await supervisor.done; + const containedLaunch = safeLaunchEvidence(observation); + console.log(JSON.stringify({ nativeEdgeBeforeReset: { comparison: 'launch', layout, contained: 'error' in contained ? contained : { cookiesRead: contained.cookies.length }, containedLaunch, pendingChildCloses: fixtureChildren.size } })); + if ('error' in contained && contained.error === 'native_cleanup_failed') throw new Error('Contained cleanup was not confirmed; direct comparison refused'); + if (existsSync(userDataDir)) { + if (realpathSync(userDataDir).toLowerCase() !== path.resolve(userDataDir).toLowerCase()) throw new Error('Synthetic profile ownership changed; comparison refused'); + const identity = lstatSync(userDataDir, { bigint: true }); + try { resetOwnedProfileDirectory(userDataDir, identity, supervisor.cleanupDeadline); } + catch (error) { + console.error(JSON.stringify({ nativeFixtureRemovalFailure: { stage: 'launch_reset', layout, ...fixtureRemovalEvidence(error, userDataDir, identity) } })); + throw error; + } + } + const containedObservation = existsSync(observation) ? JSON.parse(readFileSync(observation, 'utf8')) : null; + rmSync(observation, { force: true }); + const direct = spawnSync(node, ['--input-type=commonjs', '-e', NATIVE_COOKIE_NODE_SCRIPT], { + env: environment, input: JSON.stringify({ ...input, deadline: Date.now() + 25_000 }), + encoding: 'utf8', timeout: 30_000, windowsHide: true, + }); + const directObservation = existsSync(observation) ? JSON.parse(readFileSync(observation, 'utf8')) : null; + console.log(JSON.stringify({ + nativeEdgeLaunchComparison: { + layout, + contained: 'error' in contained ? contained : { cookiesRead: contained.cookies.length }, + containedLaunch, direct: safeNativeEnvelope(direct.stdout || ''), directStatus: direct.status, + directLaunch: safeLaunchEvidence(observation), + argvEqual: containedObservation?.argsHash === directObservation?.argsHash, + environmentEqual: containedObservation?.envHash === directObservation?.envHash, + observedCommandLinesEqual: containedObservation?.observedCommandLine?.available === true && directObservation?.observedCommandLine?.available === true + ? containedObservation.observedCommandLine.commandLineHash === directObservation.observedCommandLine.commandLineHash : null, + }, + })); + expect(direct.error).toBeUndefined(); + expect(direct.status).toBe(0); + assertCookieReceipt(JSON.parse(direct.stdout)); + expect(directObservation).not.toBeNull(); + expect(alive(directObservation.pid)).toBe(false); + expect(containedObservation?.argsHash).toBe(directObservation.argsHash); + expect(containedObservation?.envHash).toBe(directObservation.envHash); + expect(containedObservation?.observedCommandLine).toMatchObject({ available: true }); + expect(directObservation.observedCommandLine).toMatchObject({ available: true }); + expect(containedObservation.folderEvidence.directoriesAfterProbe).toEqual(containedObservation.folderEvidence.directoriesBefore); + expect(directObservation.folderEvidence.directoriesAfterProbe).toEqual(directObservation.folderEvidence.directoriesBefore); + }, 65_000); + } + + for (const state of ['preserved', 'fresh']) { + test.skipIf(process.platform !== 'win32' || process.env.GITHUB_ACTIONS !== 'true')(`separates contained launch initialization from Job membership (${state} filesystem)`, async () => { + const node = Bun.which('node'); + const edge = nativeBrowserPaths('Edge', process.env).executables.find(existsSync); + if (!node || !edge) throw new Error('Native initialization comparison requires Node and installed Microsoft Edge'); + const fixture = mkdtempSync(path.join(root, 'edge-initialization-')); + const ownedRoot = realpathSync(fixture); + const rootState = lstatSync(fixture, { bigint: true }); + const rootIdentity = { path: ownedRoot, dev: rootState.dev, ino: rootState.ino }; + const userDataDir = path.join(fixture, 'User Data'); + const observation = path.join(fixture, 'launch.json'); + const playwrightEntry = path.join(fixture, 'observed-playwright.cjs'); + const require = createRequire(import.meta.url); + const wrapper = `module.exports = require(${JSON.stringify(path.resolve(import.meta.dir, 'fixtures/native-cookie-launch.cjs'))})(${JSON.stringify({ observation, playwrightEntry: require.resolve('playwright'), inspectCommandLine: true, observerExecutable: process.execPath })});`; + const environment = nativeCookieEnvironment({ SystemRoot: process.env.SystemRoot!, TEMP: fixture, TMP: fixture, USERPROFILE: fixture, LOCALAPPDATA: fixture, APPDATA: fixture, PATH: path.dirname(node) }); + const input = { ...request, nodeExecutable: node, executablePath: edge, userDataDir, playwrightEntry }; + writeFileSync(playwrightEntry, wrapper); + const firstSupervisor = nativeSupervisor(input, environment); + const first = await firstSupervisor.done; + const firstLaunch = safeLaunchEvidence(observation); + const firstObservation = JSON.parse(readFileSync(observation, 'utf8')); + console.log(JSON.stringify({ nativeEdgeBeforeReset: { comparison: 'initialization', state, first: 'error' in first ? first : { cookiesRead: first.cookies.length }, firstLaunch, pendingChildCloses: fixtureChildren.size } })); + if ('error' in first && first.error === 'native_cleanup_failed') throw new Error('Initial containment cleanup was not confirmed'); + if (realpathSync(fixture) !== ownedRoot) throw new Error('Initialization fixture ownership changed'); + if (state === 'fresh') { + clearOwnedFixtureContents(fixture, rootIdentity); + } else { + if (existsSync(userDataDir) && realpathSync(userDataDir) !== path.join(ownedRoot, 'User Data')) throw new Error('Synthetic profile ownership changed'); + const identity = existsSync(userDataDir) ? lstatSync(userDataDir, { bigint: true }) : undefined; + try { if (identity) resetOwnedProfileDirectory(userDataDir, identity, firstSupervisor.cleanupDeadline); } + catch (error) { + console.error(JSON.stringify({ nativeFixtureRemovalFailure: { stage: 'initialization_reset', state, ...(identity ? fixtureRemovalEvidence(error, userDataDir, identity) : { inspected: false, reason: 'no_pre_reset_identity' }) } })); + throw error; + } + rmSync(observation, { force: true }); + } + writeFileSync(playwrightEntry, wrapper); + const second = await nativeSupervisor(input, environment).done; + const secondObservation = JSON.parse(readFileSync(observation, 'utf8')); + console.log(JSON.stringify({ nativeEdgeInitializationComparison: { + state, + first: 'error' in first ? first : { cookiesRead: first.cookies.length }, firstLaunch, + second: 'error' in second ? second : { cookiesRead: second.cookies.length }, secondLaunch: safeLaunchEvidence(observation), + argvEqual: firstObservation.argsHash === secondObservation.argsHash, + environmentEqual: firstObservation.envHash === secondObservation.envHash, + } })); + expect(firstObservation.argsHash).toBe(secondObservation.argsHash); + expect(firstObservation.envHash).toBe(secondObservation.envHash); + expect(firstObservation.observedCommandLine).toMatchObject({ available: true }); + expect(secondObservation.observedCommandLine).toMatchObject({ available: true }); + expect('error' in second ? second.error : null).not.toBe('native_cleanup_failed'); + if (state === 'fresh') expect(firstObservation.folderEvidence.directoriesBefore).toEqual(secondObservation.folderEvidence.directoriesBefore); + }, 65_000); + } +}); diff --git a/browse/test/cookie-import-native-qualification.ts b/browse/test/cookie-import-native-qualification.ts new file mode 100644 index 000000000..950fcc39b --- /dev/null +++ b/browse/test/cookie-import-native-qualification.ts @@ -0,0 +1,128 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { release, tmpdir } from 'node:os'; +import path from 'node:path'; +import { nativeBrowserPaths } from '../src/cookie-import-native'; +import { nativeCookieEnvironment, probeNativeCookieMember } from '../src/cookie-import-native-worker'; +import { hashNativeFile, nativeCodeHashes, nativeCodeMatches, NATIVE_BROWSER_VERSION_COMMAND, NATIVE_QUALIFICATION_DATA } from '../src/cookie-import-native-integrity'; +import { createNativeCookieJob, NativeCookieJobError, nativeCookieDiagnostic, type NativeCookieDiagnostic, type NativeCookieJob } from '../src/cookie-import-native-job'; + +if (process.platform !== 'win32') { + console.error('Native cookie qualification requires Windows; no cases ran and no qualification was issued.'); + process.exit(1); +} +if (process.env.GITHUB_ACTIONS !== 'true' || process.env.CI !== 'true') { + console.error('Native cookie qualification requires a disposable GitHub Actions runner and never uses an existing browser profile.'); + process.exit(1); +} + +const output = mkdtempSync(path.join(process.argv[2] || tmpdir(), 'cookie-native-qualification-')); +function incomplete(reason: string, diagnostic?: NativeCookieDiagnostic): never { + const receipt = { status: 'incomplete', reason, ...(diagnostic ? { diagnostic } : {}), counts: { pass: 0, fail: 0, skip: 0 }, activation: 'No build was qualified; production extraction remains disabled.' }; + writeFileSync(path.join(output, 'qualification.json'), JSON.stringify(receipt, null, 2) + '\n', { mode: 0o600, flag: 'wx' }); + console.log(JSON.stringify({ ...receipt, artifactDirectory: output })); + process.exit(2); +} +if (Bun.version !== '1.4.0') incomplete('bun_1_4_0_required'); +let emptyJob: NativeCookieJob | undefined; +try { + emptyJob = await createNativeCookieJob(); + if (emptyJob.activeProcesses() !== 0) throw new NativeCookieJobError('job_query'); + emptyJob.terminate(); + if (emptyJob.activeProcesses() !== 0) throw new NativeCookieJobError('job_query'); + emptyJob.close(); + const preflight = { status: 'passed', activeProcesses: 0 }; + writeFileSync(path.join(output, 'job-preflight.json'), JSON.stringify(preflight, null, 2) + '\n', { mode: 0o600, flag: 'wx' }); + console.log(JSON.stringify({ nativeJobPreflight: preflight })); +} catch (error) { + const diagnostic = nativeCookieDiagnostic(error, 'job_create'); + try { emptyJob?.close(); } catch {} + writeFileSync(path.join(output, 'job-preflight.json'), JSON.stringify({ status: 'failed', diagnostic }, null, 2) + '\n', { mode: 0o600, flag: 'wx' }); + incomplete('native_job_preflight_failed', diagnostic); +} +const memberProbe = await probeNativeCookieMember(); +const memberPreflight = 'cookies' in memberProbe ? { status: 'passed', activeProcesses: 0 } : { status: 'failed', error: memberProbe.error, diagnostic: memberProbe.diagnostic }; +writeFileSync(path.join(output, 'member-preflight.json'), JSON.stringify(memberPreflight, null, 2) + '\n', { mode: 0o600, flag: 'wx' }); +console.log(JSON.stringify({ nativeMemberPreflight: memberPreflight })); +if ('error' in memberProbe) incomplete('native_member_preflight_failed', memberProbe.diagnostic); +const root = path.resolve(import.meta.dir, '../..'); +let sourceHashes: Record; +try { + sourceHashes = await nativeCodeHashes(root, Date.now() + 25_000); +} catch { + incomplete('source_inputs_unavailable_or_timed_out'); +} +const environment = nativeCookieEnvironment(process.env); +const browser = nativeBrowserPaths('Edge', environment); +const executable = browser.executables.find(existsSync); +const node = Bun.which('node'); +if (!executable || !node) incomplete('node_or_edge_not_installed'); +if (existsSync(browser.userDataDir)) incomplete('existing_default_profile_refused'); +const executableSha256 = await hashNativeFile(executable, Date.now() + 25_000); +const nodeProbe = spawnSync(node, ['-p', 'JSON.stringify({ version: process.version, architecture: process.arch })'], { env: environment, encoding: 'utf8', timeout: 10_000, windowsHide: true }); +if (nodeProbe.status !== 0) incomplete('node_runtime_preflight_failed'); +const nodeInfo = JSON.parse(nodeProbe.stdout); +if (nodeInfo.architecture !== process.arch || !['x64', 'arm64'].includes(process.arch)) incomplete('runtime_architecture_mismatch'); +const require = createRequire(import.meta.url); +const playwrightVersion = require('playwright/package.json').version; +if (playwrightVersion !== '1.62.1') incomplete('playwright_1_62_1_required'); +const versionProbe = spawnSync(path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), [ + '-NoProfile', '-NonInteractive', '-Command', NATIVE_BROWSER_VERSION_COMMAND, +], { env: { ...environment, GSTACK_QUALIFY_BROWSER_EXE: executable }, encoding: 'utf8', timeout: 10_000, windowsHide: true }); +const versionErrorCode = (versionProbe.error as NodeJS.ErrnoException | undefined)?.code; +const versionMetadata = { + exitCode: versionProbe.status, + signal: versionProbe.signal, + spawnError: versionProbe.error ? (['ENOENT', 'EACCES', 'EPERM', 'ETIMEDOUT'].includes(versionErrorCode || '') ? versionErrorCode : 'spawn_failed') : undefined, + stdoutBytes: Buffer.byteLength(versionProbe.stdout || ''), + stderrBytes: Buffer.byteLength(versionProbe.stderr || ''), + versionValid: /^\d+(?:\.\d+){2,3}$/.test((versionProbe.stdout || '').trim()), +}; +writeFileSync(path.join(output, 'browser-version-preflight.json'), JSON.stringify(versionMetadata, null, 2) + '\n', { mode: 0o600, flag: 'wx' }); +console.log(JSON.stringify({ nativeBrowserVersionPreflight: versionMetadata })); +if (versionProbe.status !== 0 || !versionMetadata.versionValid) incomplete('browser_version_preflight_failed'); +const result = spawnSync(process.execPath, ['--no-env-file', '--no-install', '--no-macros', '--config=NUL', 'test', 'browse/test/cookie-import-native-job.test.ts', '--test-name-pattern', '^native Windows process qualification'], { + cwd: root, + env: { ...environment, CI: 'true', GITHUB_ACTIONS: 'true', GSTACK_COOKIE_NATIVE_DEFAULT_FIXTURE: '1', NO_COLOR: '1' }, + encoding: 'utf8', + timeout: 300_000, + maxBuffer: 16 * 1024 * 1024, + windowsHide: true, +}); +const log = `${result.stdout || ''}\n${result.stderr || ''}`; +writeFileSync(path.join(output, 'qualification.log'), log, { mode: 0o600, flag: 'wx' }); +const count = (kind: string) => Number([...log.matchAll(new RegExp(`(?:^|\\n)\\s*(\\d+) ${kind}\\b`, 'g'))].at(-1)?.[1] ?? 0); +const counts = { pass: count('pass'), fail: count('fail'), skip: count('skip') }; +let inputsUnchanged = false; +try { + const deadline = Date.now() + 25_000; + inputsUnchanged = await hashNativeFile(executable, deadline) === executableSha256 && nativeCodeMatches(sourceHashes, await nativeCodeHashes(root, deadline)); +} catch {} +const passed = result.status === 0 && !result.error && counts.pass === 7 && counts.fail === 0 && counts.skip === 0 && inputsUnchanged && !existsSync(browser.userDataDir); +const receipt = { + status: passed ? 'passed' : 'failed', + scope: 'Edge default-profile v20 extraction, pipe transport, profile identity, and owned-process cleanup', + qualifiedBuild: { + browserName: 'Edge', + architecture: nodeInfo.architecture, + windowsRelease: release(), + executableSha256, + nodeVersion: nodeInfo.version, + bunVersion: Bun.version, + playwrightVersion, + sourceHashes, + }, + browserVersion: versionProbe.stdout.trim(), + supervisorArchitecture: process.arch, + jobPreflight: { status: 'passed', activeProcesses: 0 }, + memberPreflight, + counts, + inputsUnchanged, + sourceHashes, + activationData: NATIVE_QUALIFICATION_DATA, + activation: 'Review this receipt and add only this exact qualifiedBuild to the separate activation data; the runner never enables extraction and activation does not modify the recorded code hashes.', +}; +writeFileSync(path.join(output, 'qualification.json'), JSON.stringify(receipt, null, 2) + '\n', { mode: 0o600, flag: 'wx' }); +console.log(JSON.stringify({ status: receipt.status, counts, artifactDirectory: output })); +process.exitCode = passed ? 0 : 1; diff --git a/browse/test/cookie-import-native.test.ts b/browse/test/cookie-import-native.test.ts new file mode 100644 index 000000000..400789e9a --- /dev/null +++ b/browse/test/cookie-import-native.test.ts @@ -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(' 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 } }); + }); +}); diff --git a/browse/test/cookie-import-node.test.ts b/browse/test/cookie-import-node.test.ts new file mode 100644 index 000000000..e0a068c3c --- /dev/null +++ b/browse/test/cookie-import-node.test.ts @@ -0,0 +1,56 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { Database } from 'bun:sqlite'; +import { spawnSync } from 'node:child_process'; +import { copyFileSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const root = realpathSync(mkdtempSync(path.join(tmpdir(), 'cookie-node-'))); +const bundle = path.join(root, 'importer.mjs'); +const node = Bun.which('node'); +if (!node) throw new Error('Node.js is required for importer runtime coverage'); + +beforeAll(() => { + const build = spawnSync(process.execPath, ['build', path.resolve(import.meta.dir, '../src/cookie-import-browser.ts'), '--target=node', '--outfile', bundle], { + encoding: 'utf8', timeout: 30_000, + }); + expect(build.status).toBe(0); + const profile = path.join(root, '.config/chromium/Default'); + mkdirSync(profile, { recursive: true }); + expect(realpathSync(profile).startsWith(root + path.sep)).toBe(true); + const dbPath = path.join(profile, 'Cookies'); + const database = new Database(dbPath); + database.run('CREATE TABLE cookies (host_key TEXT, name TEXT, value TEXT, encrypted_value BLOB, path TEXT, expires_utc INTEGER, is_secure INTEGER, is_httponly INTEGER, has_expires INTEGER, samesite INTEGER)'); + database.run("INSERT INTO cookies VALUES ('.fixture.test', 'synthetic', 'fixture-value', x'', '/', 0, 0, 1, 0, 1)"); + database.close(); + const windows = path.join(root, 'AppData/Local/Chromium/User Data/Default'); + mkdirSync(windows, { recursive: true }); + expect(realpathSync(windows).startsWith(root + path.sep)).toBe(true); + copyFileSync(dbPath, path.join(windows, 'Cookies')); +}); + +afterAll(() => rmSync(root, { recursive: true, force: true })); + +describe('actual Node importer runtime', () => { + test('lists and imports cookies through the bundled production module', () => { + const child = spawnSync(node!, ['--input-type=module', '-e', ` + const { listDomains, importCookies } = await import(process.argv[1]); + const domains = listDomains('chromium'); + const result = await importCookies('chromium', ['fixture.test']); + console.log(JSON.stringify({ domains, count: result.count, failed: result.failed, scope: Object.keys(result.domainCounts), cookieName: result.cookies[0]?.name })); + `, pathToFileURL(bundle).href], { + encoding: 'utf8', timeout: 15_000, + env: { HOME: root, USERPROFILE: root, LOCALAPPDATA: path.join(root, 'AppData/Local'), TEMP: root, TMP: root, NODE_NO_WARNINGS: '1', PATH: path.dirname(node!), ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}) }, + }); + expect(child.error).toBeUndefined(); + expect(child.status).toBe(0); + expect(child.stderr).toBe(''); + expect(JSON.parse(child.stdout)).toEqual({ domains: { browser: 'Chromium', domains: [{ domain: '.fixture.test', count: 1 }] }, count: 1, failed: 0, scope: ['.fixture.test'], cookieName: 'synthetic' }); + }); + + test('Node server build does not stub away the database', () => { + const script = readFileSync(path.resolve(import.meta.dir, '../scripts/build-node-server.sh'), 'utf8'); + expect(script).not.toContain('const Database = null'); + }); +}); diff --git a/browse/test/cookie-import-operation.test.ts b/browse/test/cookie-import-operation.test.ts new file mode 100644 index 000000000..816af3d0c --- /dev/null +++ b/browse/test/cookie-import-operation.test.ts @@ -0,0 +1,317 @@ +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test'; +import { Database } from 'bun:sqlite'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { EventEmitter } from 'node:events'; +import { BrowserManager } from '../src/browser-manager'; +import { getCookieProfiles, parseCookieImportArgs, runCookieImport } from '../src/cookie-import-operation'; +import { generatePickerCode, handleCookiePickerRoute, hasActivePicker } from '../src/cookie-picker-routes'; +import { handleReadCommand } from '../src/read-commands'; +import { handleWriteCommand } from '../src/write-commands'; +import * as importer from '../src/cookie-import-browser'; + +let home: string; +let homeMock: ReturnType; +let currentUrl: string; +let page: any; +let context: any; +let session: any; +let bm: BrowserManager; +let cdp: any; + +function installProfile(name = 'Default', domain = '.example.test', badCookie = false) { + const dir = path.join(home, '.config/chromium', name); + fs.mkdirSync(dir, { recursive: true }); + expect(fs.realpathSync(dir).startsWith(fs.realpathSync(home) + path.sep)).toBe(true); + const db = new Database(path.join(dir, 'Cookies')); + db.run('CREATE TABLE cookies (host_key TEXT, name TEXT, value TEXT, encrypted_value BLOB, path TEXT, expires_utc INTEGER, is_secure INTEGER, is_httponly INTEGER, has_expires INTEGER, samesite INTEGER)'); + db.run('INSERT INTO cookies VALUES (?, ?, ?, ?, ?, 0, 1, 1, 0, 1)', [domain, 'session', 'synthetic-session', Buffer.alloc(0), '/']); + if (badCookie) db.run('INSERT INTO cookies VALUES (?, ?, ?, ?, ?, 0, 1, 1, 0, 1)', [domain, 'broken', '', Buffer.from('v20synthetic'), '/']); + db.close(); +} + +async function route(method: string, pathname: string, body?: unknown, cookie?: string) { + const url = new URL('http://127.0.0.1:9470/cookie-picker' + pathname); + let pickerInstance = ''; + if (cookie) { + const document = await handleCookiePickerRoute(new URL(url.origin + '/cookie-picker'), new Request(url.origin + '/cookie-picker', { + headers: { Cookie: cookie }, + }), bm, 'fixture'); + const html = await document.text(); + pickerInstance = JSON.parse(html.match(/'; + options = { browser: payload, profile: payload, targetOrigin: 'https://target.test' }; + browsers = [{ name: payload }]; + profiles = { [payload]: { profiles: [{ name: payload, displayName: payload }] } }; + handler = request => request.path === '/domains' ? Response.json({ domains: [{ domain: payload, count: 1 }] }) + : request.path === '/import' ? Response.json({ imported: 1, failed: 0, domainCounts: { [payload]: 1 }, outcome: 'imported', message: payload }) : undefined; + await openPicker(); + await page.locator('.btn-add').waitFor(); + expect(await page.locator('.profile-pill').getAttribute('data-profile')).toBe(payload); + expect(await page.locator('.btn-add').getAttribute('data-domain')).toBe(payload); + expect(await page.locator('img').count()).toBe(0); + expect(await page.evaluate(() => (window as any).fixtureXss)).toBeUndefined(); + await page.locator('.btn-add').click(); + await page.getByRole('status').filter({ hasText: 'Cookies imported.' }).waitFor(); + expect(await page.locator('img').count()).toBe(0); + expect(await page.evaluate(() => (window as any).fixtureXss)).toBeUndefined(); + expect(requests.find(request => request.path === '/import')?.body.profile).toBe(payload); + expect(await page.locator('.btn-trash').getAttribute('data-domain')).toBe(payload); + }); + + test('serializes only a safe target origin, never credentials, path, or query data', async () => { + const target = new URL('https://target.test/private-path?fixture-token=private-value'); + target.username = 'fixture-user'; + target.password = 'fixture'; + options = { targetOrigin: target.href }; + const html = getCookiePickerHTML(server.port!, options); + expect(html).not.toContain('fixture-user'); + expect(html).not.toContain('private-path'); + expect(html).not.toContain('private-value'); + await openPicker(); + expect(await page.locator('#target-origin').textContent()).toBe('https://target.test'); + expect(await page.getByRole('status').getAttribute('aria-live')).toBe('polite'); + }); +}); diff --git a/browse/test/dia-gui-readiness.test.ts b/browse/test/dia-gui-readiness.test.ts new file mode 100644 index 000000000..c34ff3bf6 --- /dev/null +++ b/browse/test/dia-gui-readiness.test.ts @@ -0,0 +1,174 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { createHash } from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import { chmodSync, copyFileSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { parseGuiReadiness, runGuiReadiness, validateGuiReadinessAuthority } from '../../.github/scripts/qualify-dia-macos'; + +const root = realpathSync(mkdtempSync(path.join(tmpdir(), 'dia-gui-readiness-test-'))); +const source = path.resolve(import.meta.dir, '../../.github/scripts/dia-gui-readiness.c'); +const executable = path.join(root, 'metadata-probe'); +const names = ['chrome', 'chromium', 'arc', 'dia', 'comet', 'brave', 'edge', 'safari', 'cookies']; +const observation = () => ({ protocol: 1, supported: true, identity: { effectiveUidMatches: true, homeMatchesRegistered: true }, + security: { status: 0, graphicAccess: true, rootSession: false, tty: false, remote: false }, + quartz: { present: true, sameUid: true, loginDone: true, onConsole: true }, browserRoots: null as any }); + +beforeAll(() => { + const wrapper = path.join(root, 'metadata-probe.c'); + writeFileSync(wrapper, `#define main native_readiness_main\n#include ${JSON.stringify(source)}\n#undef main + int main(int argc, char **argv) { + if (argc != 2) return 2; + int home = open(argv[1], O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_NONBLOCK); + if (home < 0) return 2; + print_browser_roots(home, getuid()); close(home); printf("\\n"); return 0; + }`); + const compiler = Bun.which('clang'); + if (!compiler) throw new Error('clang is required for the POSIX metadata regression'); + const compiled = spawnSync(compiler, ['-std=c11', '-O2', '-Wall', '-Wextra', wrapper, + ...(process.platform === 'darwin' ? ['-framework', 'Security', '-framework', 'ApplicationServices'] : []), '-o', executable], + { encoding: 'utf8', timeout: 30_000 }); + if (compiled.status !== 0) throw new Error('metadata_fixture_compile_failed'); +}); + +afterAll(() => rmSync(root, { recursive: true, force: true })); + +const inspect = (home: string) => { + const result = spawnSync(executable, [home], { encoding: 'utf8', timeout: 5000 }); + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + return JSON.parse(result.stdout); +}; + +describe('read-only GUI readiness', () => { + test('metadata inspection reports missing fixed roots without creating state', () => { + const home = realpathSync(mkdtempSync(path.join(root, 'empty-'))); + const before = readdirSync(home); + const result = inspect(home); + expect(Object.keys(result)).toEqual(names); + for (const name of names) expect(result[name]).toEqual({ state: 'absent', kind: null, ownerMatches: null, ancestorBlocked: false }); + expect(readdirSync(home)).toEqual(before); + expect(lstatSync(home).mode & 0o777).toBe(0o700); + }); + + test('metadata inspection never follows ancestor or target symlinks', () => { + const outside = realpathSync(mkdtempSync(path.join(root, 'other-'))); + writeFileSync(path.join(outside, 'private-sentinel'), 'unchanged fixture contents'); + const linked = realpathSync(mkdtempSync(path.join(root, 'linked-'))); + symlinkSync(outside, path.join(linked, 'Library'), 'dir'); + const blocked = inspect(linked); + for (const name of names) expect(blocked[name]).toEqual({ state: 'unavailable', kind: 'symlink', ownerMatches: true, ancestorBlocked: true }); + const home = realpathSync(mkdtempSync(path.join(root, 'target-'))); + mkdirSync(path.join(home, 'Library')); + symlinkSync(outside, path.join(home, 'Library/Safari'), 'dir'); + expect(inspect(home).safari).toEqual({ state: 'present', kind: 'symlink', ownerMatches: true, ancestorBlocked: false }); + expect(readdirSync(outside)).toEqual(['private-sentinel']); + expect(readFileSync(path.join(outside, 'private-sentinel'), 'utf8')).toBe('unchanged fixture contents'); + }); + + test('wrong ancestor types and access failures are unavailable, never absent', () => { + const home = realpathSync(mkdtempSync(path.join(root, 'wrong-type-'))); + writeFileSync(path.join(home, 'Library'), 'not a directory'); + expect(inspect(home).dia).toEqual({ state: 'unavailable', kind: 'file', ownerMatches: true, ancestorBlocked: true }); + const denied = realpathSync(mkdtempSync(path.join(root, 'denied-'))); + const library = path.join(denied, 'Library'); + mkdirSync(library, { mode: 0o700 }); + chmodSync(library, 0); + try { + expect(inspect(denied).dia).toEqual({ state: 'unavailable', kind: 'directory', ownerMatches: true, ancestorBlocked: true }); + expect(lstatSync(library).mode & 0o777).toBe(0); + } finally { chmodSync(library, 0o700); } + }); + + test('present root metadata does not open contents or change them', () => { + const home = realpathSync(mkdtempSync(path.join(root, 'present-'))); + const safari = path.join(home, 'Library/Safari'); + mkdirSync(safari, { recursive: true }); + const file = path.join(safari, 'private-sentinel'); + writeFileSync(file, 'private fixture content'); + chmodSync(file, 0); + const before = lstatSync(file); + const result = inspect(home); + expect(result.safari).toEqual({ state: 'present', kind: 'directory', ownerMatches: true, ancestorBlocked: false }); + expect(lstatSync(file).mode).toBe(before.mode); + expect(lstatSync(file).mtimeMs).toBe(before.mtimeMs); + expect(JSON.stringify(result)).not.toContain('private'); + chmodSync(file, 0o600); + expect(readFileSync(file, 'utf8')).toBe('private fixture content'); + }); + + test('GUI usability requires both the security capability and the caller-owned logged-in Quartz session', () => { + const original = observation(); + const before = JSON.stringify(original); + Object.freeze(original.identity); Object.freeze(original.security); Object.freeze(original.quartz); Object.freeze(original); + expect(parseGuiReadiness(original, false).usableGui).toBe(true); + expect(JSON.stringify(original)).toBe(before); + for (const changed of [ + { ...observation(), security: { ...observation().security, graphicAccess: false } }, + { ...observation(), quartz: { present: false, sameUid: null, loginDone: null, onConsole: null } }, + { ...observation(), quartz: { present: true, sameUid: false, loginDone: null, onConsole: null } }, + { ...observation(), quartz: { ...observation().quartz, loginDone: false } }, + { ...observation(), identity: { effectiveUidMatches: true, homeMatchesRegistered: false } }, + { ...observation(), security: { status: -60500, graphicAccess: null, rootSession: null, tty: null, remote: null } }, + ]) expect(parseGuiReadiness(changed, false).usableGui).toBe(false); + }); + + test('schema rejects extra identity text, cross-UID session details and false absence claims', () => { + for (const changed of [ + { ...observation(), username: 'private-name' }, + { ...observation(), quartz: { ...observation().quartz, uid: 501 } }, + { ...observation(), quartz: { present: true, sameUid: false, loginDone: true, onConsole: true } }, + { ...observation(), security: { ...observation().security, status: -1 } }, + { ...observation(), security: { ...observation().security, graphicAccess: 1 } }, + ]) expect(() => parseGuiReadiness(changed, false)).toThrow('invalid_gui_readiness_receipt'); + const roots = Object.fromEntries(names.map(name => [name, { state: 'absent', kind: null, ownerMatches: null, ancestorBlocked: false }])); + expect(parseGuiReadiness({ ...observation(), browserRoots: roots }, true).browserRoots).toEqual(roots); + expect(() => parseGuiReadiness({ ...observation(), browserRoots: roots }, false)).toThrow('invalid_gui_readiness_receipt'); + expect(() => parseGuiReadiness({ ...observation(), browserRoots: { ...roots, dia: { state: 'absent', kind: 'symlink', ownerMatches: true, ancestorBlocked: true } } }, true)) + .toThrow('invalid_gui_readiness_receipt'); + }); + + test('GUI authority cannot carry browser/comparison authority or placeholder destination hashes', () => { + const account: any = { work: '/private/tmp/dn-fixture', guiReadiness: { mode: 'gui-readiness-only', executable: '/private/tmp/dn-fixture/bin/gui-readiness', + executableSha256: 'a'.repeat(64), sourceSha256: 'b'.repeat(64) } }; + expect(() => validateGuiReadinessAuthority(account, 'coordinator')).not.toThrow(); + for (const changed of [{ ...account, launchComparison: {} }, { ...account, destinationExecutable: '' }, { ...account, destinationSha256: 'a'.repeat(64) }, + { ...account, guiReadiness: null }, { ...account, guiReadiness: { ...account.guiReadiness, executableSha256: ['a'.repeat(64)] } }, + { ...account, guiReadiness: { ...account.guiReadiness, executable: '/unowned/probe' } }]) { + expect(() => validateGuiReadinessAuthority(changed, 'coordinator')).toThrow('gui_readiness_authority_invalid'); + } + expect(() => validateGuiReadinessAuthority(account, 'comparison-driver')).toThrow('gui_readiness_authority_invalid'); + }); + + test('the registered probe validates hashes, bounds execution, and discards unrecognized output', async () => { + const hash = (file: string) => createHash('sha256').update(readFileSync(file)).digest('hex'); + const exeHash = hash(executable); + const sourceHash = hash(source); + const input = observation(); + const execute = ((command: string, args: string[], options: any) => { + expect(command).toBe(executable); expect(args).toEqual([]); + expect(options.timeout).toBeGreaterThan(0); expect(options.timeout).toBeLessThanOrEqual(5000); + expect(options.killSignal).toBe('SIGKILL'); expect(options.maxBuffer).toBe(16 * 1024); + return { status: 0, stdout: JSON.stringify(input), stderr: 'private diagnostic text' }; + }) as typeof spawnSync; + const result = await runGuiReadiness(executable, exeHash, source, sourceHash, false, { HOME: root }, 5000, execute); + expect(result.available).toBe(true); + expect(JSON.stringify(result)).not.toContain('private diagnostic'); + await expect(runGuiReadiness(executable, 'c'.repeat(64), source, sourceHash, false, {}, 5000, execute)).rejects.toThrow('gui_readiness_inputs_changed'); + const malformed = (() => ({ status: 0, stdout: JSON.stringify({ ...input, username: 'private-name' }), stderr: '' })) as typeof spawnSync; + expect((await runGuiReadiness(executable, exeHash, source, sourceHash, false, {}, 5000, malformed)).available).toBe(false); + for (const timeout of [0, NaN, Infinity]) await expect(runGuiReadiness(executable, exeHash, source, sourceHash, false, {}, timeout, execute)).rejects.toThrow('gui_readiness_budget_exhausted'); + }); + + test('the readiness launcher loads without node_modules or Playwright and rejects conflicting CLI modes', () => { + const directory = path.join(root, 'no-dependencies/.github/scripts'); + mkdirSync(directory, { recursive: true }); + for (const file of ['run-dia-native-qualification.ts', 'qualify-dia-macos.ts']) copyFileSync(path.resolve(import.meta.dir, '../../.github/scripts', file), path.join(directory, file)); + for (const args of [['--gui-readiness-only'], ['--gui-readiness-only', '--launch-comparison', 'node']]) { + const result = spawnSync(process.execPath, ['--no-env-file', '--no-install', '--no-macros', '--config=/dev/null', path.join(directory, 'run-dia-native-qualification.ts'), ...args], + { cwd: root, env: { HOME: root, PATH: '/usr/bin:/bin' }, encoding: 'utf8', timeout: 5000 }); + expect(result.status).toBe(2); expect(result.stderr).toBe(''); + expect(JSON.parse(result.stdout).reason).toBe('fresh_account_launcher_preflight_failed'); + } + }); +}); diff --git a/browse/test/dia-launch-comparison.test.ts b/browse/test/dia-launch-comparison.test.ts new file mode 100644 index 000000000..871473c2a --- /dev/null +++ b/browse/test/dia-launch-comparison.test.ts @@ -0,0 +1,180 @@ +import { afterAll, describe, expect, test } from 'bun:test'; +import { createHash } from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { compareDiaLaunchReceipts, normalizedLaunchHashes } from '../../.github/scripts/dia-launch-driver.mjs'; +import { runDiaLaunchComparison, safeDiaComparisonResponse } from '../../.github/scripts/qualify-dia-macos'; + +const root = realpathSync(mkdtempSync(path.join(tmpdir(), 'dc-'))); +const driverFile = path.resolve(import.meta.dir, '../../.github/scripts/dia-launch-driver.mjs'); +afterAll(() => rmSync(root, { recursive: true, force: true })); + +describe('diagnostic-only Dia runtime comparison', () => { + test('normalization replaces only declared owned path boundaries', () => { + const first = normalizedLaunchHashes(['--user-data-dir=/owned/a/home/profile', '--headless'], { HOME: '/owned/a/home', PATH: '/usr/bin:/bin' }, { home: '/owned/a/home' }); + const second = normalizedLaunchHashes(['--user-data-dir=/owned/b/home/profile', '--headless'], { HOME: '/owned/b/home', PATH: '/usr/bin:/bin' }, { home: '/owned/b/home' }); + expect(first).toEqual(second); + expect(normalizedLaunchHashes(['--user-data-dir=/owned/a/home-other'], {}, { home: '/owned/a/home' }).argvSha256) + .not.toBe(normalizedLaunchHashes(['--user-data-dir=/owned/b/home-other'], {}, { home: '/owned/b/home' }).argvSha256); + expect(normalizedLaunchHashes(['--label=https://private.invalid/owned/a/home'], {}, { home: '/owned/a/home' }).argvSha256) + .not.toBe(normalizedLaunchHashes(['--label=https://private.invalid/owned/b/home'], {}, { home: '/owned/b/home' }).argvSha256); + expect(JSON.stringify(first)).not.toContain('/owned'); + }); + + test('IPC rejects raw payloads at any nesting depth', () => { + for (const value of [{ args: ['private-argv'] }, { cleanup: { raw: 'private-value' } }, { error: 'private-error-value' }, + { startupPages: { categories: ['https://private.invalid'] } }, { driver: { release: '/private/path' } }]) { + expect(safeDiaComparisonResponse(value)).toBe(false); + } + expect(safeDiaComparisonResponse({ protocol: 1, purpose: 'source', error: 'operation_timeout', cleanup: { confirmed: false } })).toBe(true); + }); + + test('the actual driver entry refuses an unapproved host without exposing its request', () => { + for (const executable of [process.execPath, Bun.which('node')!]) { + const result = spawnSync(executable, [driverFile, '/private-untrusted/account.json'], { + env: { PATH: '/usr/bin:/bin', HOME: root }, input: '{"private":"request-value"}', encoding: 'utf8', timeout: 10_000, + }); + expect(result.status).toBe(2); + expect(result.stderr).toBe(''); + expect(JSON.parse(result.stdout)).toEqual({ protocol: 1, ready: false, error: 'driver_admission_failed', cleanup: { confirmed: false } }); + } + }); + + test('the real bounded stdin callback works under both runtimes and refuses raw request payloads', () => { + const entry = path.join(root, 'stdin-probe.mjs'); + writeFileSync(entry, `import { readComparisonRequest } from ${JSON.stringify(pathToFileURL(driverFile).href)}; + try { const request = readComparisonRequest(); process.stdout.write(JSON.stringify({purpose:request.purpose})); } + catch { process.stdout.write(JSON.stringify({rejected:true})); }`); + for (const executable of [process.execPath, Bun.which('node')!]) { + for (const input of ['{"purpose":"control"}', '{"purpose":"control","env":{"PRIVATE":"value"}}', 'x'.repeat(16 * 1024 + 1)]) { + const result = spawnSync(executable, [entry], { env: { HOME: root, PATH: '/usr/bin:/bin' }, input, encoding: 'utf8', timeout: 5000 }); + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + expect(JSON.parse(result.stdout)).toEqual(input === '{"purpose":"control"}' ? { purpose: 'control' } : { rejected: true }); + } + } + }); + test('both pinned runtimes execute the same protected worker with matched normalized inputs', async () => { + const node = Bun.which('node'); + if (!node) throw new Error('Node 24.18 is required for the comparison regression'); + const entry = path.join(root, 'probe.mjs'); + const executablePath = realpathSync((await import('playwright')).chromium.executablePath()); + writeFileSync(entry, `import { runProtectedLaunch } from ${JSON.stringify(pathToFileURL(driverFile).href)}; + const home = process.env.HOME; + const result = await runProtectedLaunch(${JSON.stringify(executablePath)}, home + '/profile', + { HOME: home, TMPDIR: home + '/tmp', PATH: '/usr/bin:/bin:/usr/sbin:/sbin', LANG: 'en_US.UTF-8' }, 'control', { home }); + process.stdout.write(JSON.stringify(result) + '\\n');`); + const results: any[] = []; + for (const [name, executable] of [['bun', process.execPath], ['node', node]]) { + const home = path.join(root, name); + mkdirSync(home); + mkdirSync(path.join(home, 'tmp')); + const result = spawnSync(executable, [entry], { env: { HOME: home, PATH: '/usr/bin:/bin:/usr/sbin:/sbin' }, + encoding: 'utf8', timeout: 40_000, killSignal: 'SIGKILL', maxBuffer: 64 * 1024 }); + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + const observed = JSON.parse(result.stdout); + expect(observed.ready).toBe(true); + expect(observed.protocolResponded).toBe(true); + expect(observed.cleanup.confirmed).toBe(true); + expect(observed.cleanup.childClosed).toBe(true); + expect(observed.cleanup.groupAbsent).toBe(true); + expect(observed.cleanup.launchSettled).toBe(true); + expect(observed.rootCount).toBe(1); + expect(observed.launchAttempts[0].pipeFlag).toBe(true); + expect(observed.launchAttempts[0].sandboxDisablingFlag).toBe(false); + expect(observed.samplingEnabled).toBe(false); + expect(safeDiaComparisonResponse(observed)).toBe(true); + expect(JSON.stringify(observed)).not.toContain(home); + results.push(observed); + } + expect(results[0].argvSha256).toBe(results[1].argvSha256); + expect(results[0].environmentSha256).toBe(results[1].environmentSha256); + }, 90_000); + + const arm = (runtime: 'bun' | 'node', ready: boolean) => { + const sha = 'a'.repeat(64); + const config = { mode: 'launch-only', runtime, qualificationCredit: false, executableSha256: runtime === 'bun' ? sha : 'b'.repeat(64), driverSha256: sha, helpersSha256: sha }; + const driver = { runtime, version: runtime === 'bun' ? '1.4.0' : '24.18.0', architecture: 'arm64', os: 'darwin', release: '24.6.0', playwright: '1.62.1', + executableSha256: config.executableSha256, driverSha256: sha, helpersSha256: sha }; + const result = (purpose: string, success: boolean) => ({ protocol: 1, purpose, ready: success, launchReturned: success, protocolResponded: success, + samplingEnabled: false, rootCount: 1, supervisor: { closed: true, exitCode: 0 }, cleanup: { confirmed: true, childClosed: true, groupAbsent: true, launchSettled: true, + groups: [{ absenceConfirmed: true, childCloseObserved: true }] }, startupPages: { allowed: true }, postProbePages: { allowed: true }, + argvSha256: sha, environmentSha256: sha, driver, launchAttempts: [{ sandboxRequired: true, sandboxDisablingFlag: false, pipeFlag: true, + tcpDebuggingFlag: false, mockKeychainFlag: false, passwordStoreFlag: false, firstRunSuppressed: false, headlessFlag: true, + expectedProfile: true, detached: true, shellDisabled: true, stdioCount: 5, extraPipeDescriptors: true, profileArgumentCount: 1 }] }); + return { launchComparison: config, counts: { pass: 0, fail: 0, skip: 0 }, launcher: { accountGuid: runtime + '-separate-account', sourceRevision: 'c'.repeat(40), archiveSha256: sha, destinationSha256: sha }, + launcherCleanup: { serviceStopped: true, userDomainStopped: true, userProcessesStopped: true, accountRemoved: true, groupRemoved: true, stagingRemoved: true }, + backgroundPreflight: { status: 'passed', comparisonControl: result('control', true) }, + qualification: { launchComparison: { qualificationCredit: false, source: result('source', ready) }, counts: { pass: 0, fail: 0, skip: 0 }, + keychainStage: 'completed', isolation: { registeredIdentity: true, sharedRegisteredHome: true }, + cleanup: { ownedBrowsersStopped: true, sourceProfileRemoved: true, keychainRestored: true, mountDetached: true, fixtureRemoved: true }, + platform: { os: 'darwin', architecture: 'arm64', bun: '1.4.0', playwright: '1.62.1', release: '24.6.0' }, + artifact: { signatureVerified: true, gatekeeperNotarized: true, macosCompatibility: { compatible: true, hostVersion: '15.7.9' }, + architectures: ['arm64'], sha256: sha, executableSha256: sha, version: '1.49.1', bundleId: 'company.thebrowser.dia', team: 'S6N382Y83G' } } }; + }; + + test('paired receipts distinguish runtime readiness without awarding qualification credit', () => { + expect(compareDiaLaunchReceipts(arm('bun', false), arm('node', true))).toEqual({ comparable: true, qualificationCredit: false, outcome: 'node_only_ready' }); + expect(compareDiaLaunchReceipts(arm('bun', false), arm('node', false)).outcome).toBe('neither_ready'); + expect(compareDiaLaunchReceipts(arm('bun', true), arm('node', true)).outcome).toBe('both_ready'); + }); + + test('incomplete or incompatible arms cannot support a runtime conclusion', () => { + const left = arm('bun', false); + const mutations = [ + (right: any) => { right.backgroundPreflight.comparisonControl.ready = false; }, + (right: any) => { right.launcherCleanup.userDomainStopped = false; }, + (right: any) => { right.qualification.cleanup.sourceProfileRemoved = false; }, + (right: any) => { right.qualification.artifact.executableSha256 = 'b'.repeat(64); }, + (right: any) => { right.qualification.launchComparison.source.argvSha256 = 'b'.repeat(64); }, + (right: any) => { right.qualification.launchComparison.source.launchAttempts[0].sandboxDisablingFlag = true; }, + (right: any) => { right.qualification.launchComparison.source.driver.version = '24.19.0'; }, + (right: any) => { right.qualification.launchComparison.source.cleanup.launchSettled = false; }, + (right: any) => { right.counts.pass = 1; }, + (right: any) => { right.launcher.accountGuid = left.launcher.accountGuid; }, + (right: any) => { delete right.qualification.platform; }, + ]; + for (const mutate of mutations) { + const right = arm('node', true); + mutate(right); + expect(compareDiaLaunchReceipts(left, right).comparable).toBe(false); + } + }); + + test('comparison supervisor binds code hashes and rejects extra private response fields', async () => { + const work = path.join(root, 'ipc'); + mkdirSync(path.join(work, 'bin'), { recursive: true }); + mkdirSync(path.join(work, 'repo/.github/scripts'), { recursive: true }); + const executable = path.join(work, 'bin/node'); + const driver = path.join(work, 'repo/.github/scripts/dia-launch-driver.mjs'); + const helpers = path.join(work, 'repo/.github/scripts/qualify-dia-macos.ts'); + for (const file of [executable, driver, helpers]) writeFileSync(file, 'synthetic code'); + const hash = (file: string) => createHash('sha256').update(readFileSync(file)).digest('hex'); + const account: any = { work, snapshot: path.join(work, 'repo'), configFile: path.join(work, 'account.json'), environment: { HOME: work }, + launchComparison: { mode: 'launch-only', runtime: 'node', executable, executableSha256: hash(executable), driverSha256: hash(driver), helpersSha256: hash(helpers) } }; + const response: any = { protocol: 1, purpose: 'control', ready: false, launchReturned: false, samplingEnabled: false, cleanup: { confirmed: false }, + driver: { runtime: 'node', version: '24.18.0', architecture: 'arm64', os: 'darwin', playwright: '1.62.1', + executableSha256: hash(executable), driverSha256: hash(driver), helpersSha256: hash(helpers) } }; + let calls = 0; + const execute = ((command: string, args: string[], options: any) => { + calls++; + expect(command).toBe(executable); + expect(args).toEqual([driver, account.configFile]); + expect(JSON.parse(options.input)).toEqual({ purpose: 'control' }); + expect(options.timeout).toBeLessThanOrEqual(40_000); + expect(options.maxBuffer).toBe(64 * 1024); + return { status: 0, stdout: JSON.stringify(response), stderr: '' }; + }) as typeof spawnSync; + expect((await runDiaLaunchComparison(account, 'control', undefined, execute)).supervisor.exitCode).toBe(0); + response.error = 'private-error-value'; + expect((await runDiaLaunchComparison(account, 'control', undefined, execute)).error).toBe('driver_exchange_failed'); + writeFileSync(executable, 'changed executable'); + await expect(runDiaLaunchComparison(account, 'control', undefined, execute)).rejects.toThrow('comparison_driver_inputs_changed'); + expect(calls).toBe(2); + await expect(runDiaLaunchComparison({ ...account, launchComparison: undefined }, 'control', undefined, execute)).rejects.toThrow('comparison_driver_authority_missing'); + }); +}); diff --git a/browse/test/dia-macos-qualification.test.ts b/browse/test/dia-macos-qualification.test.ts new file mode 100644 index 000000000..40e13bf67 --- /dev/null +++ b/browse/test/dia-macos-qualification.test.ts @@ -0,0 +1,1695 @@ +import { afterAll, describe, expect, spyOn, test } from 'bun:test'; +import { createRequire } from 'node:module'; +import { spawnSync } from 'node:child_process'; +import { appendFileSync, chmodSync, existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { Database } from 'bun:sqlite'; +import { + allowedFixturePage, assertDiaSocketPath, assertOwnedDiaProfile, browserCleanupError, browserGroupFacts, browserOperationTimedOut, browserPreflightError, browserRootFacts, + browserStartupCategory, browserStartupFacts, browserStderrFacts, browserStderrReasons, captureUserKeychains, classifyNativeWaitSample, createBrowserStderrCapture, createOwnedDiaProfile, DIA_DOWNLOAD, fixtureKeychainRestoreCommands, + hasSandboxDisablingArgument, inspectMachOArchitectures, joinOwnedBrowserClose, macosCompatibility, nativeDiaLaunchOptions, observeBrowserLaunches, + observePendingBrowserLaunch, sampleOwnedDiaWait, stopOwnedBrowserGroup, + observeDiaKeychainEnvironments, observeFixtureKeychain, parseDefaultKeychain, parseKeychainPaths, playwrightModuleLoadFacts, prepareKeychainHome, + qualifyDia, readFreshAccountConfiguration, removeOwnedDiaProfile, validateQualificationHost, writePrivateReceipt, +} from '../../.github/scripts/qualify-dia-macos'; +import { ARCHIVE_CHECK, FRESH_WORK_PREFIX, PRIVATE_RECEIPT_READ, classifyParentDomain, classifyUserDomain, freshLaunchDefinition, freshQualificationPassed, + inspectParentDomain, inspectUidProcesses, inspectUserDomain, + ownedUserDomainTarget, ownsFreshAccount, ownsLaunchService, parseDirectoryIds, parseDirectoryRecord, passiveUserDomainState, + runFreshAccountQualification, uidProcessFacts } from '../../.github/scripts/run-dia-native-qualification'; + +const require = createRequire(import.meta.url); +const root = mkdtempSync(path.join(tmpdir(), 'dia-qualification-test-')); +const script = path.resolve(import.meta.dir, '../../.github/scripts/qualify-dia-macos.ts'); +const nativeEnvironment = { + CI: 'true', GITHUB_ACTIONS: 'true', RUNNER_ENVIRONMENT: 'github-hosted', RUNNER_OS: 'macOS', RUNNER_ARCH: 'ARM64', + GSTACK_DIA_NATIVE_QUALIFY: '1', RUNNER_TEMP: root, GITHUB_RUN_ID: 'fixture-run', GITHUB_RUN_ATTEMPT: '1', +}; + +afterAll(() => rmSync(root, { recursive: true, force: true })); + +describe('Dia macOS CI qualification safety', () => { + test('admission accepts only the explicitly enabled disposable ARM64 Mac combination', () => { + expect(() => validateQualificationHost(nativeEnvironment, 'darwin', 'arm64')).not.toThrow(); + for (const [platform, architecture] of [['linux', 'arm64'], ['win32', 'arm64'], ['darwin', 'x64']]) { + expect(() => validateQualificationHost(nativeEnvironment, platform as any, architecture as any)).toThrow('disposable_arm64_macos_ci_required'); + } + expect(readdirSync(root)).toEqual([]); + }); + + for (const name of Object.keys(nativeEnvironment)) { + test(`requires admission field ${name} before any native work`, () => { + const env: NodeJS.ProcessEnv = { ...nativeEnvironment }; + delete env[name]; + expect(() => validateQualificationHost(env, 'darwin', 'arm64')).toThrow('disposable_arm64_macos_ci_required'); + expect(readdirSync(root)).toEqual([]); + }); + } + + test('rejects a self-hosted machine even when the OS and opt-in match', () => { + expect(() => validateQualificationHost({ ...nativeEnvironment, RUNNER_ENVIRONMENT: 'self-hosted' }, 'darwin', 'arm64')) + .toThrow('disposable_arm64_macos_ci_required'); + }); + + test('the actual launcher refuses an unapproved environment without staging files', () => { + const nativeTemp = mkdtempSync(path.join(root, 'runner-temp-')); + const result = spawnSync(process.execPath, ['--no-env-file', '--no-install', '--no-macros', `--config=${process.platform === 'win32' ? 'NUL' : '/dev/null'}`, script], { + cwd: root, env: { PATH: path.dirname(process.execPath), HOME: root, RUNNER_TEMP: nativeTemp, + ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}) }, + encoding: 'utf8', timeout: 10_000, + }); + expect(result.status).toBe(2); + expect(result.stderr).toBe(''); + expect(JSON.parse(result.stdout)).toEqual({ status: 'incomplete', reason: 'qualification_preflight_failed', counts: { pass: 0, fail: 0, skip: 0 } }); + expect(readdirSync(nativeTemp)).toEqual([]); + }); + + test('a fresh fixture-HOME child uses unmodified production Dia profile and domain discovery', () => { + const home = realpathSync(mkdtempSync(path.join(root, 'fixture-home-'))); + const ownership = createOwnedDiaProfile(home); + const profile = path.join(ownership.profile, 'Default'); + mkdirSync(profile, { recursive: true }); + const database = new Database(path.join(profile, 'Cookies')); + database.run('CREATE TABLE cookies (host_key TEXT, has_expires INTEGER, expires_utc INTEGER)'); + database.query('INSERT INTO cookies VALUES (?, ?, ?)').run('.fixture.test', 0, 0); + database.close(); + const production = pathToFileURL(path.resolve(import.meta.dir, '../src/cookie-import-browser.ts')).href; + const result = spawnSync(process.execPath, ['--no-env-file', '--no-install', '--no-macros', `--config=${process.platform === 'win32' ? 'NUL' : '/dev/null'}`, '-e', ` + import { homedir } from 'node:os'; + const { listProfiles, listDomains } = await import(${JSON.stringify(production)}); + console.log(JSON.stringify({ homeAtStartup: homedir() === process.env.HOME, + profiles: listProfiles('Dia').map(profile => profile.name), domains: listDomains('Dia', 'Default').domains })); + `], { + cwd: root, env: { PATH: path.dirname(process.execPath), HOME: home, USERPROFILE: home, + ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}) }, + encoding: 'utf8', timeout: 10_000, + }); + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + expect(JSON.parse(result.stdout)).toEqual({ homeAtStartup: true, profiles: ['Default'], domains: [{ domain: '.fixture.test', count: 1 }] }); + expect(() => assertOwnedDiaProfile(ownership)).not.toThrow(); + removeOwnedDiaProfile(ownership, true); + expect(existsSync(home)).toBe(true); + }); + + for (const precreate of [false, true]) { + test(`late-installed real Playwright loads only with its dependency directory present at startup (${precreate})`, () => { + const fixture = realpathSync(mkdtempSync(path.join(root, 'late-playwright-'))); + const home = path.join(fixture, 'home'); + const scripts = path.join(fixture, '.github/scripts'); + const modules = path.join(fixture, 'node_modules'); + mkdirSync(home); + mkdirSync(scripts, { recursive: true }); + if (precreate) mkdirSync(modules, { mode: 0o700 }); + writeFileSync(path.join(fixture, 'package.json'), JSON.stringify({ type: 'module', dependencies: { playwright: '1.62.1' } })); + const sourceModules = path.resolve(import.meta.dir, '../../node_modules'); + const worker = path.join(scripts, 'worker.ts'); + writeFileSync(worker, ` + import { cpSync, mkdirSync } from 'node:fs'; + import { createRequire } from 'node:module'; + const require = createRequire(import.meta.url); + mkdirSync(${JSON.stringify(modules)}, { recursive: true }); + for (const name of ['playwright', 'playwright-core']) cpSync(${JSON.stringify(sourceModules)} + '/' + name, ${JSON.stringify(modules)} + '/' + name, { recursive: true }); + const version = require(${JSON.stringify(path.join(modules, 'playwright/package.json'))}).version; + try { + const loaded = await import('playwright'); + console.log(JSON.stringify({ version, loaded: !!loaded.chromium })); + } catch (error) { + console.log(JSON.stringify({ version, type: error.name, code: error.code })); + process.exitCode = 2; + } + `); + const result = spawnSync(process.execPath, ['--no-env-file', '--no-install', '--no-macros', + `--config=${process.platform === 'win32' ? 'NUL' : '/dev/null'}`, worker], { + cwd: fixture, env: { HOME: home, PATH: path.dirname(process.execPath) }, encoding: 'utf8', timeout: 30_000, + }); + expect(result.stderr).toBe(''); + expect(result.status).toBe(precreate ? 0 : 2); + expect(JSON.parse(result.stdout)).toEqual(precreate ? { version: '1.62.1', loaded: true } + : { version: '1.62.1', type: 'ResolveMessage', code: 'ERR_MODULE_NOT_FOUND' }); + }); + } + + test('the download is the published HTTPS Dia release endpoint', () => { + expect(DIA_DOWNLOAD).toBe('https://releases.diabrowser.com/release/Dia-latest.dmg'); + }); + + describe('bounded Mach-O architecture inspection', () => { + const thin = (cpu = 0x0100000c, subtype = 0, little = true, wide = true) => { + const buffer = Buffer.alloc((wide ? 32 : 28) + 8); + const word = (value: number, at: number) => little ? buffer.writeUInt32LE(value, at) : buffer.writeUInt32BE(value, at); + word(wide ? 0xfeedfacf : 0xfeedface, 0); + word(cpu, 4); + word(subtype, 8); + word(2, 12); + word(1, 16); + word(8, 20); + return buffer; + }; + const fat = (little = false, wide = false) => { + const buffer = Buffer.alloc(512); + const word = (value: number, at: number) => little ? buffer.writeUInt32LE(value, at) : buffer.writeUInt32BE(value, at); + const wideWord = (value: bigint, at: number) => little ? buffer.writeBigUInt64LE(value, at) : buffer.writeBigUInt64BE(value, at); + word(wide ? 0xcafebabf : 0xcafebabe, 0); + word(2, 4); + for (const [index, cpu, subtype] of [[0, 0x01000007, 3], [1, 0x0100000c, 0]]) { + const at = 8 + index * (wide ? 32 : 20); + const image = thin(cpu, subtype); + word(cpu, at); + word(subtype, at + 4); + if (wide) { wideWord(BigInt(128 * (index + 1)), at + 8); wideWord(BigInt(image.length), at + 16); } + else { word(128 * (index + 1), at + 8); word(image.length, at + 12); } + word(7, at + (wide ? 24 : 16)); + image.copy(buffer, 128 * (index + 1)); + } + return buffer; + }; + const store = (buffer: Buffer) => { + const directory = realpathSync(mkdtempSync(path.join(root, 'macho-'))); + const file = path.join(directory, 'executable'); + writeFileSync(file, buffer, { mode: 0o600 }); + return file; + }; + for (const little of [true, false]) { + test(`thin headers honor byte order (${little}) and distinguish arm64 subtypes`, () => { + expect(inspectMachOArchitectures(store(thin(0x0100000c, 0, little))).architectures).toEqual(['arm64']); + expect(inspectMachOArchitectures(store(thin(0x0100000c, 0x80000002, little))).architectures).toEqual(['arm64e']); + expect(inspectMachOArchitectures(store(thin(0x01000007, 3, little))).architectures).toEqual(['x86_64']); + expect(inspectMachOArchitectures(store(thin(7, 3, little, false))).architectures).toEqual(['i386']); + }); + for (const wide of [true, false]) { + test(`fat headers verify their actual slices (${little}, ${wide})`, () => { + const result = inspectMachOArchitectures(store(fat(little, wide))); + expect(result).toMatchObject({ method: 'bounded_macho_headers', format: wide ? 'fat64' : 'fat32', slices: 2, + architectures: ['x86_64', 'arm64'] }); + expect(result.bytesRead).toBeLessThanOrEqual(4096); + }); + } + } + + for (const invalid of ['magic', 'truncated', 'empty_fat', 'excessive_fat', 'truncated_table', 'table_overlap', 'slice_overlap', + 'slice_out_of_bounds', 'slice_too_short', 'misaligned', 'alignment_overflow', 'cpu_mismatch', 'subtype_mismatch', 'nested_fat', + 'duplicate_architecture', 'wide_offset_overflow', 'reserved', 'header_width', 'file_type', 'load_commands', 'unknown_cpu']) { + test(`architecture inspection rejects ${invalid} rather than guessing arm64`, () => { + let image = fat(); + if (invalid === 'magic') image = Buffer.from('not a macho executable'); + if (invalid === 'truncated') image = thin().subarray(0, 20); + if (invalid === 'empty_fat') image.writeUInt32BE(0, 4); + if (invalid === 'excessive_fat') image.writeUInt32BE(33, 4); + if (invalid === 'truncated_table') image = image.subarray(0, 24); + if (invalid === 'table_overlap') image.writeUInt32BE(0, 16); + if (invalid === 'slice_overlap') image.writeUInt32BE(128, 36); + if (invalid === 'slice_out_of_bounds') image.writeUInt32BE(1024, 36); + if (invalid === 'slice_too_short') image.writeUInt32BE(28, 40); + if (invalid === 'misaligned') image.writeUInt32BE(257, 36); + if (invalid === 'alignment_overflow') image.writeUInt32BE(0xffffffff, 44); + if (invalid === 'cpu_mismatch') image.writeUInt32LE(0x01000007, 260); + if (invalid === 'subtype_mismatch') image.writeUInt32LE(2, 264); + if (invalid === 'nested_fat') image.writeUInt32BE(0xcafebabe, 256); + if (invalid === 'duplicate_architecture') { image.writeUInt32BE(0x01000007, 28); image.writeUInt32BE(3, 32); thin(0x01000007, 3).copy(image, 256); } + if (invalid === 'wide_offset_overflow') { image = fat(false, true); image.writeBigUInt64BE(1n << 60n, 16); } + if (invalid === 'reserved') { image = fat(false, true); image.writeUInt32BE(1, 36); } + if (invalid === 'header_width') image = thin(0x0100000c, 0, true, false); + if (invalid === 'file_type') { image = thin(); image.writeUInt32LE(6, 12); } + if (invalid === 'load_commands') { image = thin(); image.writeUInt32LE(1000, 20); } + if (invalid === 'unknown_cpu') image = thin(0x010000ff, 0); + expect(() => inspectMachOArchitectures(store(image))).toThrow(); + }); + } + + test('architecture inspection rejects links, non-files, and expired budgets', () => { + const file = store(thin()); + symlinkSync(file, file + '.link'); + expect(() => inspectMachOArchitectures(file + '.link')).toThrow('unsafe_macho_file'); + expect(() => inspectMachOArchitectures(path.dirname(file))).toThrow('unsafe_macho_file'); + for (const budget of [0, NaN, Infinity]) expect(() => inspectMachOArchitectures(file, budget)).toThrow('macho_read_budget_exhausted'); + expect(qualifyDia.toString()).not.toContain('/usr/bin/lipo'); + }); + + for (const change of ['append', 'replace']) { + test(`architecture inspection detects a file that changes by ${change}`, () => { + const image = thin(); + const file = store(image); + let ticks = 0; + const clock = spyOn(performance, 'now').mockImplementation(() => { + if (++ticks === 3) { + if (change === 'append') appendFileSync(file, Buffer.from([0])); + else { renameSync(file, file + '.previous'); writeFileSync(file, image); } + } + return 100; + }); + try { expect(() => inspectMachOArchitectures(file)).toThrow('macho_changed_during_inspection'); } + finally { clock.mockRestore(); } + }); + } + }); + test('the exact nested macOS account and qualifier socket layout stays below the unchanged limit', () => { + const sourceProfile = path.posix.join(FRESH_WORK_PREFIX + 'XXXXXX', 'tmp', 'dia-XXXXXX', 'h', 'Library/Application Support/Dia/User Data'); + expect(Buffer.byteLength(sourceProfile + '/SingletonSocket')).toBe(97); + expect(() => assertDiaSocketPath(sourceProfile)).not.toThrow(); + const oldProfile = '/private/tmp/dia-native-XXXXXX/tmp/dia-XXXXXX/h/Library/Application Support/Dia/User Data'; + expect(Buffer.byteLength(oldProfile + '/SingletonSocket')).toBe(105); + expect(() => assertDiaSocketPath(oldProfile)).toThrow('fixture_socket_path_too_long'); + const atLimit = '/' + 'x'.repeat(100 - Buffer.byteLength('//SingletonSocket')); + expect(Buffer.byteLength(atLimit + '/SingletonSocket')).toBe(100); + expect(() => assertDiaSocketPath(atLimit)).toThrow('fixture_socket_path_too_long'); + }); + + test('the registered-home source profile fits the socket bound without a shadow HOME', () => { + const profile = path.posix.join(FRESH_WORK_PREFIX + 'XXXXXX', 'home', 'Library/Application Support/Dia/User Data'); + expect(Buffer.byteLength(profile + '/SingletonSocket')).toBe(85); + expect(() => assertDiaSocketPath(profile)).not.toThrow(); + const implementation = qualifyDia.toString(); + expect(implementation).toContain('readFreshAccountConfiguration'); + expect(implementation).toContain('const home = account.home'); + expect(implementation).not.toMatch(/fixtureEnvironment|systemEnvironment|isolation\.originalHome/); + expect(implementation).toContain('removeOwnedDiaProfile'); + }); + + test('profile ownership is exclusive and cleanup preserves the account home and sibling state', () => { + const home = realpathSync(mkdtempSync(path.join(root, 'owned-profile-home-'))); + prepareKeychainHome(home); + const preserved = path.join(home, 'Library/Keychains/fixture-state'); + writeFileSync(preserved, 'preserved fixture state'); + const ownership = createOwnedDiaProfile(home); + const sibling = path.join(path.dirname(ownership.profile), 'sibling-state'); + writeFileSync(sibling, 'preserved sibling state'); + expect(() => assertOwnedDiaProfile(ownership)).not.toThrow(); + expect(() => createOwnedDiaProfile(home)).toThrow(); + expect(() => removeOwnedDiaProfile(ownership, false)).toThrow('owned_browsers_not_stopped'); + expect(existsSync(ownership.profile)).toBe(true); + removeOwnedDiaProfile(ownership, true); + expect(existsSync(ownership.profile)).toBe(false); + expect(existsSync(home)).toBe(true); + expect(readFileSync(preserved, 'utf8')).toBe('preserved fixture state'); + expect(readFileSync(sibling, 'utf8')).toBe('preserved sibling state'); + }); + + for (const change of ['nonce', 'inode', 'marker_symlink', 'profile_symlink', 'uid', 'home_escape', 'marker_permissions', 'marker_hardlink']) { + test(`profile cleanup refuses changed ${change} ownership without deleting the directory`, () => { + const home = realpathSync(mkdtempSync(path.join(root, 'profile-refusal-'))); + const ownership = createOwnedDiaProfile(home); + const profile = ownership.profile; + const marker = path.join(profile, '.gstack-dia-owner'); + if (change === 'nonce') writeFileSync(marker, (ownership.nonce[0] === '0' ? '1' : '0') + ownership.nonce.slice(1)); + if (change === 'inode') { + renameSync(profile, profile + '.original'); + mkdirSync(profile, { mode: 0o700 }); + writeFileSync(marker, ownership.nonce, { mode: 0o600 }); + } + if (change === 'marker_symlink') { renameSync(marker, marker + '.original'); symlinkSync(marker + '.original', marker); } + if (change === 'profile_symlink') { renameSync(profile, profile + '.original'); symlinkSync(profile + '.original', profile, 'dir'); } + if (change === 'uid') ownership.uid = process.getuid!() + 1; + if (change === 'home_escape') ownership.profile = home; + if (change === 'marker_permissions') chmodSync(marker, 0o644); + if (change === 'marker_hardlink') linkSync(marker, path.join(home, 'marker-link')); + expect(() => removeOwnedDiaProfile(ownership, true)).toThrow(); + expect(existsSync(profile)).toBe(true); + expect(existsSync(home)).toBe(true); + }); + } + + test('source profile creation refuses existing profiles and linked ancestors without writing through them', () => { + const home = realpathSync(mkdtempSync(path.join(root, 'existing-profile-'))); + const profile = path.join(home, 'Library/Application Support/Dia/User Data'); + mkdirSync(profile, { recursive: true }); + writeFileSync(path.join(profile, 'existing-state'), 'untouched'); + expect(() => createOwnedDiaProfile(home)).toThrow(); + expect(readdirSync(profile)).toEqual(['existing-state']); + const linkedHome = realpathSync(mkdtempSync(path.join(root, 'linked-profile-home-'))); + const elsewhere = realpathSync(mkdtempSync(path.join(root, 'linked-profile-target-'))); + symlinkSync(elsewhere, path.join(linkedHome, 'Library'), 'dir'); + expect(() => createOwnedDiaProfile(linkedHome)).toThrow('unsafe_profile_ancestor'); + expect(readdirSync(elsewhere)).toEqual([]); + }); + + test('the qualifier rejects an arbitrary caller-supplied home as fresh account authority', () => { + const fake = path.join(root, 'account.json'); + writeFileSync(fake, JSON.stringify({ home: root, uid: process.getuid?.() })); + expect(() => readFreshAccountConfiguration(fake)).toThrow('unsafe_fresh_account_configuration'); + expect(() => readFreshAccountConfiguration('account.json')).toThrow('unsafe_fresh_account_configuration'); + }); + + test('headless source launch removes mock Keychain and first-run suppression defaults', () => { + const env = { HOME: '/fixture/home', PATH: '/usr/bin:/bin' }; + const options = nativeDiaLaunchOptions('/fixture/Dia.app/Contents/MacOS/Dia', env); + expect(options.headless).toBe(true); + expect(options.chromiumSandbox).toBe(true); + expect(options.timeout).toBe(30_000); + expect(options.ignoreDefaultArgs).toEqual(['--use-mock-keychain', '--password-store=basic', '--no-first-run']); + expect(options.args).toEqual(['--disable-sync', '--no-default-browser-check', '--profile-directory=Default']); + expect(options.env).toBe(env); + expect(options.serviceWorkers).toBe('block'); + expect(options.args.some(arg => /onboarding|skip-login|remote-debugging-port/.test(arg))).toBe(false); + }); + + test('Mac native spawn policy refuses sandbox-disabling arguments regardless of caller options', () => { + const descriptor = Object.getOwnPropertyDescriptor(process, 'platform')!; + const profile = path.join(root, 'mac-sandbox-policy'); + const childProcess = require('node:child_process'); + const observer = observeBrowserLaunches(new Map([[process.execPath, profile]])); + try { + Object.defineProperty(process, 'platform', { ...descriptor, value: 'darwin' }); + for (const disabling of [['--no-sandbox'], ['--no-sandbox=false'], ['--disable-sandbox'], ['--disable-gpu-sandbox'], + ['--disable-setuid-sandbox'], ['--disable-seccomp-filter-sandbox'], ['--disable-namespace-sandbox'], ['--no-zygote-sandbox'], + ['--single-process'], ['--in-process-gpu'], ['--disable-features=GpuSandboxV2'], ['--disable-features', 'RendererSandbox']]) { + expect(hasSandboxDisablingArgument(disabling)).toBe(true); + expect(() => childProcess.spawn(process.execPath, ['--remote-debugging-pipe', '--user-data-dir=' + profile, ...disabling], { + detached: true, shell: false, stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'], chromiumSandbox: false, + allowSandboxDisable: true, env: { DISABLE_SANDBOX: '1' }, + })).toThrow('browser_launch_policy_rejected'); + expect(observer.attempts.at(-1)).toMatchObject({ sandboxRequired: true, sandboxDisablingFlag: true }); + } + expect(observer.children).toHaveLength(0); + } finally { Object.defineProperty(process, 'platform', descriptor); observer.restore(); } + expect(hasSandboxDisablingArgument(['--headless', '--disable-sync', '--disable-features=MediaRouter,Translate'])).toBe(false); + }); + + test('startup admits only blank pages or the exact synthetic loopback origin', () => { + const origin = 'http://127.0.0.1:8123'; + expect(allowedFixturePage('about:blank', origin)).toBe(true); + expect(allowedFixturePage(origin + '/seed', origin)).toBe(true); + for (const url of ['https://www.diabrowser.com/login', 'dia://onboarding', 'chrome://welcome', 'http://localhost:8123/seed', + 'http://127.0.0.1:8124/seed', 'javascript:alert(1)', 'about:config', 'invalid']) expect(allowedFixturePage(url, origin)).toBe(false); + const authenticated = new URL(origin); + authenticated.username = 'fixture-user'; + authenticated.password = 'fixture'; + expect(allowedFixturePage(authenticated.href, origin)).toBe(false); + }); + + test('startup diagnostics classify pages without exposing URLs or broadening admission', () => { + const pages = [ + ['about:blank', 'blank'], ['about:blank#synthetic-private-value', 'other_about'], + ['chrome://newtab/?token=synthetic-private-value', 'chromium_new_tab'], + ['chrome://new-tab-page/', 'chromium_new_tab'], ['chrome://intro/', 'chromium_onboarding'], + ['chrome://welcome/', 'chromium_onboarding'], ['chrome://settings/', 'chromium_internal'], + ['dia://onboarding', 'dia_internal'], ['chrome-extension://fixture/path', 'extension'], + ['https://fixture.invalid/login?token=synthetic-private-value', 'external_web'], + ['http://127.0.0.1:8123' + '/fixture', 'loopback_web'], ['file:///synthetic-private-value', 'file'], + ['data:text/html,synthetic-private-value', 'data'], ['invalid synthetic-private-value', 'invalid'], + ]; + const categories = pages.map(([url]) => browserStartupCategory(url)); + expect(categories).toEqual(pages.map(([, category]) => category)); + expect(JSON.stringify(categories)).not.toContain('synthetic-private-value'); + expect(JSON.stringify(categories)).not.toContain('fixture.invalid'); + for (const [url] of pages.slice(1, 10)) expect(allowedFixturePage(url, 'http://127.0.0.1:8123')).toBe(false); + }); + + test('browser stderr classifies only known policy, pipe, Keychain, loader and bootstrap diagnostics', () => { + for (const [line, reason] of [ + ['DevTools remote debugging requires a non-default data directory. Specify this using --user-data-dir.', 'default_profile_policy'], + ['Remote debugging pipe file descriptors are not open.', 'debugging_pipe_unavailable'], + ['errSecInteractionNotAllowed: User interaction is not allowed.', 'keychain_interaction_disallowed'], + ['errSecInteractionRequired', 'keychain_interaction_required'], + ['errSecAuthFailed', 'keychain_access_failed'], + ['dyld[123]: Library not loaded:', 'dynamic_library_error'], + ['code signature invalid', 'code_signing_error'], + ['bootstrap_check_in failed', 'graphics_or_bootstrap_error'], + ['ProcessSingleton', 'browser_profile_unavailable'], + ]) { + const reasons = browserStderrReasons(line + ' synthetic-private-value /private/fixture/profile'); + expect(reasons).toContain(reason); + expect(JSON.stringify(reasons)).not.toContain('synthetic-private'); + expect(JSON.stringify(reasons)).not.toContain('/private'); + } + for (const line of ['using a non-default data directory', 'remote-debugging-pipe enabled', 'Waiting for Keychain', + 'WindowServer connection available', 'private unknown diagnostic']) expect(browserStderrReasons(line)).toEqual([]); + }); + + test('stderr capture handles chunk boundaries and final unterminated lines without retaining text', () => { + const capture = createBrowserStderrCapture(); + const policy = 'DevTools remote debugging requires a non-default data directory.'; + capture.consume(Buffer.from('synthetic-private-value\n' + policy.slice(0, 23))); + expect(capture.snapshot().reasonCounts.default_profile_policy).toBe(0); + capture.consume(Buffer.from(policy.slice(23))); + const before = capture.snapshot(); + expect(before.reasonCounts.default_profile_policy).toBe(1); + capture.consume(Buffer.from('\n' + policy)); + capture.end(); + expect(capture.snapshot().reasonCounts.default_profile_policy).toBe(2); + expect(before.reasonCounts.default_profile_policy).toBe(1); + expect(capture.snapshot().ended).toBe(true); + expect(JSON.stringify(capture.snapshot())).not.toContain('synthetic-private'); + expect(JSON.stringify(capture.snapshot())).not.toContain('DevTools'); + }); + + test('stderr inspection caps bytes and pending line size while exposing truncation honestly', () => { + const capture = createBrowserStderrCapture(); + capture.consume(Buffer.alloc(65_536, 120)); + capture.consume(Buffer.from('\nDevTools remote debugging requires a non-default data directory.\n')); + const facts = capture.snapshot(); + expect(facts.bytesInspected).toBe(65_536); + expect(facts.bytesSeen).toBeGreaterThan(65_536); + expect(facts.truncated).toBe(true); + expect(facts.discardedLongLines).toBe(1); + expect(facts.reasonCounts.default_profile_policy).toBe(0); + const lines = createBrowserStderrCapture(); + lines.consume('x'.repeat(5000) + '\nRemote debugging pipe file descriptors are not open.\n'); + expect(lines.snapshot().reasonCounts.debugging_pipe_unavailable).toBe(1); + expect(lines.snapshot().truncated).toBe(true); + }); + + test('causal stderr reasons outrank a generic timeout without changing its timeout identity', () => { + const timeout = Object.assign(new Error('synthetic-private-timeout'), { name: 'TimeoutError' }); + expect(browserOperationTimedOut(timeout)).toBe(true); + expect(browserPreflightError(timeout)).toBe('operation_timeout'); + expect(browserPreflightError(timeout, ['default_profile_policy'])).toBe('default_profile_policy'); + expect(browserPreflightError(timeout, ['debugging_pipe_unavailable'])).toBe('debugging_pipe_unavailable'); + expect(browserPreflightError(timeout, ['keychain_interaction_required'])).toBe('keychain_interaction_required'); + const embedded = Object.assign(new Error('Timed out\nDevTools remote debugging requires a non-default data directory.'), { name: 'TimeoutError' }); + expect(browserPreflightError(embedded)).toBe('default_profile_policy'); + expect(browserOperationTimedOut(embedded)).toBe(true); + }); + + test('stderr snapshots keep source, destination, and cleanup-time observations separate', () => { + const source = createBrowserStderrCapture(); + const destination = createBrowserStderrCapture(); + source.consume('DevTools remote debugging requires a non-default data directory.\n'); + const beforeCleanup = source.snapshot(); + source.consume('bootstrap_check_in failed\n'); + destination.consume('Remote debugging pipe file descriptors are not open.\n'); + expect(beforeCleanup.reasonCounts.graphics_or_bootstrap_error).toBe(0); + expect(source.snapshot().reasonCounts.graphics_or_bootstrap_error).toBe(1); + expect(destination.snapshot().reasonCounts.default_profile_policy).toBe(0); + expect(destination.snapshot().reasonCounts.debugging_pipe_unavailable).toBe(1); + }); + + test('signed macOS requirements compare numeric versions and preserve unknown metadata', () => { + expect(macosCompatibility({ LSMinimumSystemVersion: '14.0' }, '15.6.1')).toEqual({ hostVersion: '15.6.1', + minimumSystemVersion: '14.0', minimumArm64Version: null, compatible: true }); + expect(macosCompatibility({ LSMinimumSystemVersion: '26.0' }, '15.6.1').compatible).toBe(false); + expect(macosCompatibility({ LSMinimumSystemVersion: '15.9' }, '15.10').compatible).toBe(true); + expect(macosCompatibility({ LSMinimumSystemVersion: '15.6.1' }, '15.6').compatible).toBe(false); + expect(macosCompatibility({ LSMinimumSystemVersion: '15.6' }, '15.6.0').compatible).toBe(true); + expect(macosCompatibility({ LSMinimumSystemVersion: '14.0', LSMinimumSystemVersionByArchitecture: { arm64: '26.0' } }, '15.6.1').compatible).toBe(false); + expect(macosCompatibility({ LSMinimumSystemVersion: '26.0', LSMinimumSystemVersionByArchitecture: { arm64: '14.0' } }, '15.6.1').compatible).toBe(false); + expect(macosCompatibility({ LSMinimumSystemVersionByArchitecture: { arm64: '14.0' } }, '15.6.1').compatible).toBe(true); + for (const plist of [{}, null, { LSMinimumSystemVersion: 'synthetic-private-value' }, { LSMinimumSystemVersion: 14 }, + { LSMinimumSystemVersion: '14.0', LSMinimumSystemVersionByArchitecture: 'synthetic-private-value' }, + { LSMinimumSystemVersion: '14.0', LSMinimumSystemVersionByArchitecture: [] }, + { LSMinimumSystemVersion: '14.0', LSMinimumSystemVersionByArchitecture: { arm64: 'private-version' } }]) { + const facts = macosCompatibility(plist, '15.6.1'); + expect(facts.compatible).toBeNull(); + expect(JSON.stringify(facts)).not.toContain('private'); + } + expect(macosCompatibility({ LSMinimumSystemVersion: '14.0' }, 'private-host-version').compatible).toBeNull(); + }); + + test('bounded startup categories do not hide a disallowed page beyond the receipt limit', () => { + const urls = [...Array(64).fill('about:blank'), 'https://private.invalid/?token=synthetic-private-value']; + const facts = browserStartupFacts(urls, 'http://127.0.0.1:8123'); + expect(facts).toMatchObject({ count: 65, truncated: true, allowed: false }); + expect(facts.categories).toHaveLength(64); + expect(JSON.stringify(facts)).not.toContain('private'); + }); + + test('browser root facts retain exit and allowlisted signal evidence without process payloads', () => { + const facts = browserRootFacts([ + { pid: 300, process: { exitCode: null, signalCode: null, spawnargs: ['synthetic-private-value'] } }, + { pid: 301, process: { exitCode: 0, signalCode: null } }, + { pid: 302, process: { exitCode: null, signalCode: 'SIGABRT' } }, + { pid: 303, process: { exitCode: null, signalCode: 'synthetic-private-signal' } }, + ] as any); + expect(facts).toEqual([{ pid: 300, exitCode: null, signal: null }, { pid: 301, exitCode: 0, signal: null }, + { pid: 302, exitCode: null, signal: 'SIGABRT' }, { pid: 303, exitCode: null, signal: 'other' }]); + expect(JSON.stringify(facts)).not.toContain('private'); + }); + + test('browser cleanup distinguishes live groups from signal errors without retaining raw errors', () => { + expect(browserCleanupError(new Error('owned_process_group_still_live')).reason).toBe('group_still_live'); + expect(browserCleanupError(new Error('cleanup_budget_exhausted')).reason).toBe('cleanup_deadline'); + expect(browserCleanupError(Object.assign(new Error('synthetic-private-value'), { code: 'EPERM', errno: 1 }))) + .toEqual({ reason: 'signal_or_probe_failed', code: 'EPERM', errno: 1 }); + const unknown = browserCleanupError({ message: 'synthetic-private-value', code: 'private-code', errno: 'private-errno' }); + expect(unknown).toEqual({ reason: 'signal_or_probe_failed', code: 'unclassified', errno: null }); + }); + + test('process-group diagnostics distinguish owned zombies, live members, and unrelated UID counts', () => { + const facts = browserGroupFacts('20000 300 1 300 Z\n20000 301 1 300 S+\n501 302 1 300 S\n20000 400 1 400 S\n', 20000, 300); + expect(facts).toEqual({ available: true, count: 2, foreignUidCount: 1, zombies: 1, live: 1, truncated: false, + processes: [{ pid: 300, ppid: 1, state: 'Z' }, { pid: 301, ppid: 1, state: 'S' }] }); + expect(browserGroupFacts(Array.from({ length: 70 }, (_, index) => `20000 ${index + 300} 1 300 S`).join('\n'), 20000, 300).processes).toHaveLength(64); + expect(() => browserGroupFacts('private-invalid-row', 20000, 300)).toThrow('invalid_group_snapshot'); + expect(() => browserGroupFacts('', 20000, 0)).toThrow('invalid_group_snapshot_target'); + }); + + test('native samples retain fixed wait families only from call-graph frames', () => { + const output = `Process: private-process [123] +Path: /private/sensitive/Security/AppKit/CFNetwork +Call graph: + 100 Thread_11 DispatchQueue_1: com.apple.main-thread (serial) + + 100 NSApplicationMain (in AppKit) private-source-path + + 100 SecKeychainFindGenericPassword (in Security) private-item-name + + 100 mach_msg_trap (in libsystem_kernel) + 100 Thread_12 private-thread-name + + 100 NSURLSessionTask (in CFNetwork) private-request-url +Total number in stack (recursive counted multiple): + 999 SecItemCopyMatching private-unrelated-summary +Binary Images: + Security AppKit CFNetwork /private/image/path +`; + const result = classifyNativeWaitSample(output); + expect(result).toEqual({ available: true, callGraphSeen: true, mainThreadSeen: true, frames: 4, + frameCounts: { security_keychain: 1, appkit_bootstrap: 1, network: 1, runloop: 1 }, + mainThreadFrameCounts: { security_keychain: 1, appkit_bootstrap: 1, network: 0, runloop: 1 }, + shape: { graphLines: 6, nonemptyGraphLines: 6, threadTokenLines: 2, numericPrefixLines: 2, imageAnnotatedLines: 4, + nonAsciiGraphLines: 0, unrecognizedGraphLines: 0, graphEnd: 'totals', binaryImagesSeen: true } }); + expect(JSON.stringify(result)).not.toContain('private'); + expect(JSON.stringify(result)).not.toContain('SecKeychain'); + expect(classifyNativeWaitSample('private missing call graph').available).toBe(false); + expect(classifyNativeWaitSample('x'.repeat(1024 * 1024 + 1))).toEqual({ available: false, reason: 'sample_output_oversized' }); + }); + + test('sample structure distinguishes an empty graph from unrecognized stack rows without exposing text', () => { + const empty = classifyNativeWaitSample('Call graph:\n\nTotal number in stack:\nBinary Images:\nprivate-image'); + expect(empty).toMatchObject({ available: false, frames: 0, callGraphSeen: true, + shape: { graphLines: 1, nonemptyGraphLines: 0, unrecognizedGraphLines: 0, graphEnd: 'totals', binaryImagesSeen: true } }); + const unfamiliar = classifyNativeWaitSample('Call graph:\n ◇ private-row (in Security)\nBinary Images:\nprivate-image'); + expect(unfamiliar).toMatchObject({ available: false, frames: 0, + shape: { nonemptyGraphLines: 1, unrecognizedGraphLines: 1, nonAsciiGraphLines: 1, imageAnnotatedLines: 1, graphEnd: 'binary_images' } }); + expect(JSON.stringify(unfamiliar)).not.toContain('private'); + expect(JSON.stringify(unfamiliar)).not.toContain('Security'); + }); + + test('a pending launch is sampled once with a deadline before the native launch timeout', async () => { + let resolve!: (value: string) => void; + const pending = new Promise(done => { resolve = done; }); + const started = performance.now(); + const deadlines: number[] = []; + const result = await observePendingBrowserLaunch(() => pending, deadline => { deadlines.push(deadline); resolve('ready'); }, started + 60_000, 5); + expect(result).toBe('ready'); + expect(deadlines).toHaveLength(1); + expect(deadlines[0]).toBeLessThan(started + 30_000); + expect(deadlines[0]).toBeGreaterThan(started + 24_000); + }); + + test('settled launches cancel sampling and exhausted sampling windows do not extend the launch', async () => { + let observations = 0; + const observe = () => { observations++; }; + expect(await observePendingBrowserLaunch(async () => 'ready', observe, performance.now() + 60_000, 5)).toBe('ready'); + const original = new Error('original_launch_failure'); + await expect(observePendingBrowserLaunch(async () => { throw original; }, observe, performance.now() + 60_000, 5)).rejects.toBe(original); + expect(await observePendingBrowserLaunch(async () => { await Bun.sleep(10); return 'ready'; }, observe, performance.now() + 100, 0)).toBe('ready'); + await Bun.sleep(10); + expect(observations).toBe(0); + }); + + test('native wait sampling uses only the observed live child and never writes a stack artifact', () => { + const pid = 12345; + const uid = process.getuid!(); + const child: any = { pid, executable: '/owned/Dia', closeObserved: false, process: { pid, exitCode: null, signalCode: null } }; + const calls: string[][] = []; + const result = sampleOwnedDiaWait(child, uid, child.executable, performance.now() + 10_000, { HOME: root }, + ((command: string, args: string[], options: any) => { + calls.push([command, ...args]); + expect(Number.isInteger(options.timeout)).toBe(true); + expect(options.timeout).toBeGreaterThan(0); + expect(options.timeout).toBeLessThanOrEqual(5_000); + expect(options.killSignal).toBe('SIGKILL'); + if (command === '/bin/ps') return { status: 0, stdout: `${uid} ${pid} ${process.pid} S Dia\n`, stderr: '' }; + return { status: 0, stdout: 'private-header\nCall graph:\n 10 Thread_1 DispatchQueue_1: com.apple.main-thread\n + 10 CFRunLoopRun (in CoreFoundation) private-path\nBinary Images:\nprivate-image', stderr: '' }; + }) as typeof spawnSync); + expect(calls).toEqual([['/bin/ps', '-p', String(pid), '-o', 'uid=,pid=,ppid=,state=,ucomm='], + ['/usr/bin/sample', String(pid), '1', '10', '-file', '/dev/stdout']]); + expect(result).toMatchObject({ available: true, attempted: true, ownedLiveChildConfirmed: true, reason: 'sampled', + waitFamilies: { mainThreadFrameCounts: { runloop: 1 } } }); + expect(JSON.stringify(result)).not.toContain('private-'); + expect(JSON.stringify(result)).not.toContain('CFRunLoopRun'); + }); + + test('sampling refuses changed identity, zombies, and closed roots without attempting a native sample', () => { + const uid = process.getuid!(); + const child: any = { pid: 12345, executable: '/owned/Dia', closeObserved: false, process: { pid: 12345, exitCode: null, signalCode: null } }; + for (const row of [`${uid + 1} 12345 ${process.pid} S Dia`, `${uid} 12345 ${process.pid + 1} S Dia`, + `${uid} 12345 ${process.pid} Z Dia`, `${uid} 12345 ${process.pid} S other`, `${uid} 12346 ${process.pid} S Dia`]) { + let calls = 0; + const result = sampleOwnedDiaWait(child, uid, child.executable, performance.now() + 10_000, {}, + ((command: string) => { calls++; expect(command).toBe('/bin/ps'); return { status: 0, stdout: row, stderr: '' }; }) as typeof spawnSync); + expect(calls).toBe(1); + expect(result.attempted).toBe(false); + } + for (const changed of [{ ...child, closeObserved: true }, { ...child, executable: '/other/Chromium' }, + { ...child, process: { ...child.process, exitCode: 0 } }]) { + const result = sampleOwnedDiaWait(changed, uid, '/owned/Dia', performance.now() + 10_000, {}, (() => { throw new Error('must_not_spawn'); }) as typeof spawnSync); + expect(result).toMatchObject({ available: false, attempted: false }); + } + }); + + test('sampling reports OS permission refusal without escalation or raw failure text', () => { + const uid = process.getuid!(); + const child: any = { pid: 12345, executable: '/owned/Dia', closeObserved: false, process: { pid: 12345, exitCode: null, signalCode: null } }; + const result = sampleOwnedDiaWait(child, uid, child.executable, performance.now() + 10_000, {}, + ((command: string) => command === '/bin/ps' + ? { status: 0, stdout: `${uid} 12345 ${process.pid} S Dia`, stderr: '' } + : { status: 1, stdout: '', stderr: 'Failed to get task for pid: private-process-path. Operation not permitted.' }) as typeof spawnSync); + expect(result).toMatchObject({ available: false, attempted: true, reason: 'sampling_permission_denied' }); + expect(JSON.stringify(result)).not.toContain('private-process'); + }); + + test('child close joining stays within its deadline and refuses an unconfirmed close', async () => { + await expect(joinOwnedBrowserClose({ closed: Promise.resolve(), closeObserved: false } as any, performance.now() + 1000)) + .rejects.toThrow('owned_child_close_unconfirmed'); + await expect(joinOwnedBrowserClose({ closed: new Promise(() => {}), closeObserved: false } as any, performance.now() + 5)) + .rejects.toThrow('operation_timeout'); + await expect(joinOwnedBrowserClose({ closed: Promise.resolve(), closeObserved: true } as any, 0)) + .rejects.toThrow('cleanup_budget_exhausted'); + }); + + for (const phase of ['probe', 'signal']) { + test(`an initial ${phase} error is retained while child reaping still precedes the final absence proof`, async () => { + const facts: Record = { signalSent: false, absenceConfirmed: false }; + const child: any = { pid: 12345, closeObserved: false }; + child.closed = Promise.resolve().then(() => { child.closeObserved = true; }); + const calls: Array<0 | 'SIGKILL'> = []; + await stopOwnedBrowserGroup(child, performance.now() + 1000, facts, (_pid, signal) => { + calls.push(signal); + if (!child.closeObserved) { + if (phase === 'signal' && signal === 0) return; + throw Object.assign(new Error('private-probe-error'), { code: 'EPERM', errno: 1 }); + } + throw Object.assign(new Error('gone'), { code: 'ESRCH' }); + }); + expect(calls).toEqual(phase === 'probe' ? [0, 0] : [0, 'SIGKILL', 0]); + expect(facts).toMatchObject({ stage: 'completed', signalSent: false, childCloseObserved: true, absenceConfirmed: true, + initialSignalFailure: { stage: phase === 'probe' ? 'probe_before_signal' : 'signal', code: 'EPERM', errno: 1 } }); + expect(JSON.stringify(facts)).not.toContain('private-probe-error'); + }); + } + + test('permission errors and zombies never replace the required child close and actual group absence', async () => { + const facts = { signalSent: false, absenceConfirmed: false }; + const waiting: any = { pid: 12345, closed: new Promise(() => {}), closeObserved: false }; + const denied = () => { throw Object.assign(new Error('denied'), { code: 'EPERM' }); }; + await expect(stopOwnedBrowserGroup(waiting, performance.now() + 10, facts, denied)).rejects.toThrow('operation_timeout'); + expect(facts.absenceConfirmed).toBe(false); + const closed: any = { pid: 12345, closed: Promise.resolve(), closeObserved: true }; + await expect(stopOwnedBrowserGroup(closed, performance.now() + 1000, facts, denied)).rejects.toMatchObject({ code: 'EPERM' }); + expect(facts.absenceConfirmed).toBe(false); + await expect(stopOwnedBrowserGroup(closed, performance.now() + 10, facts, () => {})).rejects.toThrow('owned_process_group_still_live'); + expect(facts.absenceConfirmed).toBe(false); + }); + for (const [error, category] of [ + [new Error('browserType.launchPersistentContext: browser_launch_policy_rejected synthetic-private-value'), 'launch_policy_rejected'], + [new Error('background_browser_ownership_failed'), 'ownership_unconfirmed'], + [new Error('background_browser_startup_page_rejected'), 'startup_page_rejected'], + [new Error('background_browser_render_failed'), 'render_mismatch'], + [Object.assign(new Error('synthetic-private-value'), { name: 'TimeoutError' }), 'operation_timeout'], + [new Error('native_operation_timed_out'), 'operation_timeout'], + [new Error('operation_timeout'), 'operation_timeout'], + [new Error('qualification_budget_exhausted'), 'operation_timeout'], + [new Error('source_process_ownership_unconfirmed'), 'ownership_unconfirmed'], + [new Error('destination_process_ownership_unconfirmed'), 'ownership_unconfirmed'], + [new Error('onboarding_or_external_page'), 'startup_page_rejected'], + [Object.assign(new Error('synthetic-private-value'), { code: 'ENOENT' }), 'executable_unavailable'], + [Object.assign(new Error('synthetic-private-value'), { code: 'EACCES' }), 'permission_denied'], + [{ name: 'ResolveMessage', code: 'ERR_MODULE_NOT_FOUND', message: 'synthetic-private-value' }, 'module_unavailable'], + [Object.assign(new Error('synthetic-private-value'), { code: 'MODULE_NOT_FOUND' }), 'module_unavailable'], + [Object.assign(new Error('synthetic-private-value'), { code: 'ERR_PACKAGE_PATH_NOT_EXPORTED' }), 'module_export_unavailable'], + [Object.assign(new Error('synthetic-private-value'), { code: 'ERR_REQUIRE_ESM' }), 'module_format_error'], + [new RangeError('synthetic-private-value'), 'invalid_runtime_range'], + [new TypeError('synthetic-private-value'), 'runtime_type_error'], + [new Error('dyld[123]: Library not loaded: synthetic-private-value'), 'dynamic_library_error'], + [new Error('code signature invalid: synthetic-private-value'), 'code_signing_error'], + [new Error('ProcessSingleton synthetic-private-value'), 'browser_profile_unavailable'], + [new Error('bootstrap_check_in failed synthetic-private-value'), 'graphics_or_bootstrap_error'], + [new Error('Target page, context or browser has been closed synthetic-private-value'), 'target_closed'], + [new Error('Protocol error: synthetic-private-value'), 'protocol_error'], + [new Error('synthetic-private-value'), 'unclassified_browser_error'], + [{ code: 'synthetic-private-value', message: 'synthetic-private-value' }, 'unclassified_browser_error'], + ] as const) { + test(`browser diagnostics return only the allowlisted ${category} category`, () => { + expect(browserPreflightError(error)).toBe(category); + expect(browserPreflightError(error)).not.toContain('synthetic-private-value'); + }); + } + + test('module-load facts retain only known error identifiers and known dependency filenames', () => { + const snapshot = realpathSync(mkdtempSync(path.join(root, 'module-facts-'))); + const packageDirectory = path.join(snapshot, 'node_modules/playwright'); + mkdirSync(packageDirectory, { recursive: true }); + writeFileSync(path.join(packageDirectory, 'package.json'), '{}', { mode: 0o600 }); + const facts = playwrightModuleLoadFacts(snapshot, { name: 'ResolveMessage', code: 'ERR_MODULE_NOT_FOUND', + message: "Cannot find package 'playwright' imported from /synthetic-private-value/worker.ts" }); + expect(facts.errorType).toBe('ResolveMessage'); + expect(facts.errorCode).toBe('ERR_MODULE_NOT_FOUND'); + expect(facts.requestedModule).toBe('playwright'); + expect(facts.files['playwright/package.json']).toEqual({ exists: true, readable: true, ownedByCurrentUid: true, insideSnapshot: true }); + expect(facts.files['playwright-core/lib/coreBundle.js'].exists).toBe(false); + expect(JSON.stringify(facts)).not.toContain('synthetic-private-value'); + expect(JSON.stringify(facts)).not.toContain(snapshot); + const unknown = playwrightModuleLoadFacts(snapshot, { name: 'synthetic-private-value', code: 'synthetic-private-value', message: "Cannot find package 'synthetic-private-value'" }); + expect(unknown.errorType).toBe('unclassified'); + expect(unknown.errorCode).toBe('unclassified'); + expect(unknown.requestedModule).toBe('unclassified'); + expect(JSON.stringify(unknown)).not.toContain('synthetic-private-value'); + }); + + test('Keychain snapshots preserve exact quoted paths without shell parsing', () => { + expect(parseKeychainPaths(' "/Users/runner/Library/Keychains/login.keychain-db"\n "/tmp/fixture keychain.keychain-db"\n')) + .toEqual(['/Users/runner/Library/Keychains/login.keychain-db', '/tmp/fixture keychain.keychain-db']); + for (const output of ['', 'not-json', '"relative-path"', '42', '"/valid/path"\ninvalid']) { + expect(() => parseKeychainPaths(output)).toThrow(); + } + }); + + test('fresh users can have an empty user search list and no default Keychain', () => { + const calls: string[][] = []; + const snapshot = captureUserKeychains({ HOME: root }, [root], 1000, (args, timeout) => { + calls.push(args); + expect(timeout).toBeGreaterThan(0); + expect(timeout).toBeLessThanOrEqual(1000); + return args[0] === 'list-keychains' ? { status: 0, stdout: '', stderr: '' } + : { status: 1, stdout: '', stderr: 'security: SecKeychainCopyDomainDefault user: A default keychain could not be found.\n' }; + }); + expect(snapshot).toEqual({ search: [], default: [] }); + expect(calls).toEqual([['list-keychains', '-d', 'user'], ['default-keychain', '-d', 'user']]); + }); + + test('computed Keychain timeouts reach real spawnSync as bounded integer milliseconds', () => { + let reads = 0; + const times = [0.125, 0.5, 5.75]; + const clock = spyOn(performance, 'now').mockImplementation(() => times[Math.min(reads++, times.length - 1)]); + const timeouts: number[] = []; + const calls: string[][] = []; + try { + const snapshot = captureUserKeychains({ HOME: root }, [root], 10_000, (args, timeout) => { + const result = spawnSync(process.execPath, ['--no-env-file', '--no-install', '--no-macros', + `--config=${process.platform === 'win32' ? 'NUL' : '/dev/null'}`, '-e', 'process.exit(0)'], { + cwd: root, env: { HOME: root, PATH: path.dirname(process.execPath) }, encoding: 'utf8', timeout, + }); + expect(result.status).toBe(0); + expect(result.error).toBeUndefined(); + timeouts.push(timeout); + calls.push(args); + return result; + }); + expect(snapshot).toEqual({ search: [], default: [] }); + expect(timeouts).toEqual([9999, 9994]); + expect(calls).toEqual([['list-keychains', '-d', 'user'], ['default-keychain', '-d', 'user']]); + } finally { + clock.mockRestore(); + } + }); + + test('an expired or sub-millisecond Keychain budget never becomes an unbounded subprocess', () => { + const clock = spyOn(performance, 'now').mockReturnValue(100); + let commands = 0; + try { + for (const budget of [0.75, 0, -1, NaN, Infinity]) { + expect(() => captureUserKeychains({ HOME: root }, [root], budget, () => { + commands++; + return { status: 0, stdout: '', stderr: '' }; + })).toThrow('user_keychain_probe_timeout'); + } + expect(commands).toBe(0); + } finally { + clock.mockRestore(); + } + }); + + test('permission, securityd, and transport errors are never mistaken for no default Keychain', () => { + for (const result of [ + { status: 1, stdout: '', stderr: 'security: SecKeychainCopyDefault: User interaction is not allowed.' }, + { status: 1, stdout: '', stderr: 'security: SecKeychainCopyDomainDefault system: A default keychain could not be found.' }, + { status: 1, stdout: '', stderr: 'synthetic-private-error' }, + { status: 0, stdout: '', stderr: 'synthetic-private-error' }, + { status: 1, stdout: 'unexpected-data', stderr: 'security: SecKeychainCopyDefault: A default keychain could not be found.' }, + { status: null, stdout: '', stderr: '', error: new Error('synthetic-private-error') }, + ]) expect(() => parseDefaultKeychain(result)).toThrow('user_default_keychain_unavailable'); + expect(parseDefaultKeychain({ status: 0, stdout: '', stderr: '' })).toEqual([]); + expect(parseDefaultKeychain({ status: 1, stdout: '', stderr: 'security: SecKeychainCopyDefault: A default keychain could not be found.' })).toEqual([]); + }); + + test('the snapshot boundary rejects System Keychain fallback and paths outside the owned home', () => { + for (const file of ['/Library/Keychains/System.keychain', '/System/Library/Keychains/SystemRootCertificates.keychain', '/unowned/keychain']) { + expect(() => captureUserKeychains({ HOME: root }, [root], 1000, args => ({ status: 0, + stdout: args[0] === 'list-keychains' ? JSON.stringify(file) : '', stderr: '' }))) + .toThrow('keychain_outside_owned_home_refused'); + } + }); + + test('a fresh Keychain home gains only owned standard directories, not a fabricated preference file', () => { + const home = realpathSync(mkdtempSync(path.join(root, 'keychain-home-'))); + expect(prepareKeychainHome(home)).toEqual({ before: { Library: false, 'Library/Preferences': false, 'Library/Keychains': false }, directoriesReady: true }); + for (const directory of ['Library', 'Library/Preferences', 'Library/Keychains']) { + expect(lstatSync(path.join(home, directory)).uid).toBe(process.getuid!()); + expect(lstatSync(path.join(home, directory)).isDirectory()).toBe(true); + } + const preferences = path.join(home, 'Library/Preferences/com.apple.security.plist'); + expect(existsSync(preferences)).toBe(false); + expect(existsSync(path.join(home, 'Library/Safari'))).toBe(false); + writeFileSync(preferences, 'opaque fixture preferences', { mode: 0o600 }); + expect(prepareKeychainHome(home).before).toEqual({ Library: true, 'Library/Preferences': true, 'Library/Keychains': true }); + expect(readFileSync(preferences, 'utf8')).toBe('opaque fixture preferences'); + }); + + test('home preparation rejects a linked or unowned home without writing through it', () => { + const home = realpathSync(mkdtempSync(path.join(root, 'linked-home-'))); + const elsewhere = realpathSync(mkdtempSync(path.join(root, 'other-home-'))); + symlinkSync(elsewhere, path.join(home, 'Library'), 'dir'); + expect(() => prepareKeychainHome(home)).toThrow('keychain_home_unsafe'); + expect(readdirSync(elsewhere)).toEqual([]); + expect(() => prepareKeychainHome(elsewhere, process.getuid!() + 1)).toThrow('keychain_home_unsafe'); + expect(readdirSync(elsewhere)).toEqual([]); + }); + + for (const mismatch of ['search-empty', 'default-empty', 'search-path', 'default-path', 'search-error', 'default-error', 'read-mismatch', 'read-error']) { + test(`Keychain diagnostics observe the explicit read independently of ${mismatch}`, () => { + const directory = realpathSync(mkdtempSync(path.join(root, 'keychain-facts-'))); + const keychain = path.join(directory, 'fixture.keychain-db'); + const other = path.join(directory, 'other.keychain-db'); + writeFileSync(keychain, 'opaque synthetic keychain', { mode: 0o600 }); + writeFileSync(other, 'other synthetic keychain', { mode: 0o600 }); + const expected = 'synthetic-private-equality-value'; + const calls: string[][] = []; + const facts = observeFixtureKeychain({ HOME: directory }, [directory], keychain, expected, 1000, (args, timeout) => { + expect(Number.isInteger(timeout)).toBe(true); + expect(timeout).toBeGreaterThan(0); + calls.push(args); + if (args[0] === 'list-keychains') return mismatch === 'search-error' + ? { status: 1, stdout: '', stderr: 'synthetic-private-error' } + : { status: 0, stdout: mismatch === 'search-empty' ? '' : JSON.stringify(mismatch === 'search-path' ? other : keychain), stderr: '' }; + if (args[0] === 'default-keychain') return mismatch === 'default-error' + ? { status: 1, stdout: '', stderr: 'synthetic-private-error' } + : mismatch === 'default-empty' + ? { status: 1, stdout: '', stderr: 'security: SecKeychainCopyDomainDefault user: A default keychain could not be found.' } + : { status: 0, stdout: JSON.stringify(mismatch === 'default-path' ? other : keychain), stderr: '' }; + if (mismatch === 'read-error') return { status: 1, stdout: '', stderr: 'synthetic-private-error' }; + return { status: 0, stdout: mismatch === 'read-mismatch' ? 'other synthetic value' : expected + '\n', stderr: '' }; + }); + expect(calls).toEqual([['list-keychains', '-d', 'user'], ['default-keychain', '-d', 'user'], + ['find-generic-password', '-s', 'Gstack Native Probe', '-w', keychain]]); + expect(facts.searchCount).toBe(mismatch === 'search-error' ? null : mismatch === 'search-empty' ? 0 : 1); + expect(facts.searchPathMatches).toBe(!mismatch.startsWith('search-')); + expect(facts.defaultCount).toBe(mismatch === 'default-error' ? null : mismatch === 'default-empty' ? 0 : 1); + expect(facts.defaultPathMatches).toBe(!mismatch.startsWith('default-')); + expect(facts.explicitReadAttempted).toBe(true); + expect(facts.explicitReadSucceeded).toBe(mismatch !== 'read-error'); + expect(facts.explicitReadMatches).toBe(!mismatch.startsWith('read-')); + expect(JSON.stringify(facts)).not.toContain(expected); + expect(JSON.stringify(facts)).not.toContain('synthetic-private-error'); + expect(JSON.stringify(facts)).not.toContain(keychain); + }); + } + + test('Dia Keychain diagnostics distinguish both HOME environments without configuring the shadow home', () => { + const directory = realpathSync(mkdtempSync(path.join(root, 'dual-keychain-'))); + const registered = path.join(directory, 'registered'); + const shadow = path.join(directory, 'shadow'); + mkdirSync(registered); + mkdirSync(shadow); + prepareKeychainHome(registered); + writeFileSync(path.join(registered, 'Library/Preferences/com.apple.security.plist'), 'opaque private fixture preference'); + const keychain = path.join(directory, 'fixture.keychain-db'); + writeFileSync(keychain, 'opaque synthetic keychain', { mode: 0o600 }); + const expected = 'synthetic-private-dual-home-value'; + const environments = { keychainHome: { HOME: registered }, profileHome: { HOME: shadow } }; + const calls: Array<{ home: string; args: string[] }> = []; + const observed = observeDiaKeychainEnvironments(environments, [directory], keychain, expected, 20_000, (env, args, timeout) => { + expect(Number.isInteger(timeout)).toBe(true); + expect(timeout).toBeGreaterThan(0); + expect(timeout).toBeLessThanOrEqual(10_000); + calls.push({ home: env.HOME, args }); + if (args[0] === 'list-keychains') return { status: 0, stdout: env.HOME === registered ? JSON.stringify(keychain) : '', stderr: '' }; + if (args[0] === 'default-keychain') return env.HOME === registered + ? { status: 0, stdout: JSON.stringify(keychain), stderr: '' } + : { status: 1, stdout: '', stderr: 'security: SecKeychainCopyDomainDefault user: A default keychain could not be found.' }; + expect(args.slice(0, 4)).toEqual(['find-generic-password', '-s', 'Dia Safe Storage', '-w']); + return { status: 0, stdout: expected, stderr: '' }; + }); + expect(observed.firstFailure).toEqual({ environment: 'profileHome', check: 'search_path' }); + expect(observed.environments.keychainHome).toMatchObject({ searchCount: 1, searchPathMatches: true, defaultCount: 1, defaultPathMatches: true, + explicitReadMatches: true, lookupReadAttempted: true, lookupReadMatches: true, preferencesDirectoryExists: true, preferencesFileExists: true }); + expect(observed.environments.profileHome).toMatchObject({ searchCount: 0, searchPathMatches: false, defaultCount: 0, defaultPathMatches: false, + explicitReadMatches: true, lookupReadAttempted: false, preferencesDirectoryExists: false, preferencesFileExists: false }); + expect(calls).toHaveLength(7); + expect(calls.filter(call => call.home === shadow)).toHaveLength(3); + expect(calls.some(call => call.args[0] === 'default-keychain' && call.args.includes('-s'))).toBe(false); + expect(existsSync(path.join(shadow, 'Library'))).toBe(false); + const serialized = JSON.stringify(observed); + for (const privateValue of [expected, registered, shadow, keychain, 'opaque private']) expect(serialized).not.toContain(privateValue); + }); + + for (const failure of ['search', 'default', 'explicit_read']) { + test(`Dia diagnostic refuses unqualified lookup when the owned ${failure} proof fails`, () => { + const directory = realpathSync(mkdtempSync(path.join(root, 'guarded-keychain-'))); + const keychain = path.join(directory, 'fixture.keychain-db'); + writeFileSync(keychain, 'opaque synthetic keychain', { mode: 0o600 }); + let implicitCalls = 0; + const result = observeFixtureKeychain({ HOME: directory }, [directory], keychain, 'expected-private-value', 1000, args => { + if (args[0] === 'list-keychains') return { status: 0, stdout: failure === 'search' ? '' : JSON.stringify(keychain), stderr: '' }; + if (args[0] === 'default-keychain') return { status: 0, stdout: failure === 'default' ? '' : JSON.stringify(keychain), stderr: '' }; + if (args.length === 4) implicitCalls++; + return { status: 0, stdout: failure === 'explicit_read' ? 'wrong-private-value' : 'expected-private-value', stderr: '' }; + }, 'Dia Safe Storage'); + expect(result.explicitReadAttempted).toBe(true); + expect(result.lookupReadAttempted).toBe(false); + expect(implicitCalls).toBe(0); + }); + } + + test('both Keychain environments remain observable while the first original validation failure is retained', () => { + const directory = realpathSync(mkdtempSync(path.join(root, 'keychain-failures-'))); + const keychain = path.join(directory, 'fixture.keychain-db'); + writeFileSync(keychain, 'opaque synthetic keychain', { mode: 0o600 }); + const environments = { keychainHome: { HOME: directory }, profileHome: { HOME: directory, MARKER: 'shadow' } }; + for (const keychainSearchFails of [true, false]) { + const homesObserved = new Set>(); + const observed = observeDiaKeychainEnvironments(environments, [directory], keychain, 'expected-private-value', 20_000, (env, args) => { + homesObserved.add(env); + if (args[0] === 'list-keychains') return { status: 0, + stdout: env === environments.profileHome || keychainSearchFails ? '' : JSON.stringify(keychain), stderr: '' }; + if (args[0] === 'default-keychain') return { status: 1, stdout: '', stderr: 'private-default-error' }; + return { status: 0, stdout: 'wrong-private-value', stderr: '' }; + }); + expect(homesObserved.size).toBe(2); + expect(observed.firstFailure).toEqual({ environment: keychainSearchFails ? 'keychainHome' : 'profileHome', check: 'search_path' }); + expect(observed.environments.keychainHome).toMatchObject({ defaultCount: null, explicitReadAttempted: true, explicitReadMatches: false }); + expect(observed.environments.profileHome).toMatchObject({ defaultCount: null, explicitReadAttempted: true, explicitReadMatches: false }); + expect(JSON.stringify(observed)).not.toContain('private-default-error'); + expect(JSON.stringify(observed)).not.toContain('wrong-private-value'); + } + }); + + test('Dia environment validation preserves successful and explicit-read-only outcomes', () => { + const directory = realpathSync(mkdtempSync(path.join(root, 'keychain-outcomes-'))); + const keychain = path.join(directory, 'fixture.keychain-db'); + writeFileSync(keychain, 'opaque synthetic keychain', { mode: 0o600 }); + const environments = { keychainHome: { HOME: directory }, profileHome: { HOME: directory } }; + for (const readMismatch of [false, true]) { + const observed = observeDiaKeychainEnvironments(environments, [directory], keychain, 'expected-private-value', 20_000, (env, args) => { + if (args[0] === 'list-keychains' || args[0] === 'default-keychain') return { status: 0, stdout: JSON.stringify(keychain), stderr: '' }; + return { status: 0, stdout: readMismatch && env === environments.keychainHome ? 'wrong-private-value' : 'expected-private-value', stderr: '' }; + }); + if (readMismatch) expect(observed.firstFailure).toEqual({ environment: 'keychainHome', check: 'explicit_read' }); + else expect(observed.firstFailure).toBeUndefined(); + expect(observed.environments.profileHome).toMatchObject({ searchPathMatches: true, defaultPathMatches: true, explicitReadMatches: true, + lookupReadAttempted: true, lookupReadMatches: true }); + } + }); + + test('an exhausted dual-environment diagnostic budget launches no Keychain commands', () => { + const directory = realpathSync(mkdtempSync(path.join(root, 'keychain-budget-'))); + const keychain = path.join(directory, 'fixture.keychain-db'); + writeFileSync(keychain, 'opaque synthetic keychain', { mode: 0o600 }); + let calls = 0; + const observed = observeDiaKeychainEnvironments({ keychainHome: { HOME: directory }, profileHome: { HOME: directory } }, + [directory], keychain, 'expected-private-value', 0, () => { calls++; throw new Error('must_not_spawn'); }); + expect(calls).toBe(0); + expect(observed.environments.keychainHome).toMatchObject({ searchCount: null, defaultCount: null, explicitReadAttempted: false, lookupReadAttempted: false }); + expect(observed.environments.profileHome).toMatchObject({ searchCount: null, defaultCount: null, explicitReadAttempted: false, lookupReadAttempted: false }); + expect(observed.firstFailure).toEqual({ environment: 'keychainHome', check: 'search_path' }); + }); + + test('an originally absent default is restored by deleting only the created fixture, never by a null default setter', () => { + expect(fixtureKeychainRestoreCommands({ search: [], default: [] }, '/owned/fixture.keychain-db', true)).toEqual([ + ['delete-keychain', '/owned/fixture.keychain-db'], ['list-keychains', '-d', 'user', '-s'], + ]); + expect(fixtureKeychainRestoreCommands({ search: [], default: [] }, '/owned/fixture.keychain-db', false)).toEqual([ + ['list-keychains', '-d', 'user', '-s'], + ]); + }); + + test('an existing default is restored before deleting the fixture, and preexisting fixture references are refused', () => { + const before = { search: ['/owned/prior.keychain-db', '/owned/other.keychain-db'], default: ['/owned/prior.keychain-db'] }; + expect(fixtureKeychainRestoreCommands(before, '/owned/fixture.keychain-db', true)).toEqual([ + ['default-keychain', '-d', 'user', '-s', '/owned/prior.keychain-db'], + ['delete-keychain', '/owned/fixture.keychain-db'], + ['list-keychains', '-d', 'user', '-s', '/owned/prior.keychain-db', '/owned/other.keychain-db'], + ]); + expect(() => fixtureKeychainRestoreCommands(before, '/owned/prior.keychain-db', true)).toThrow('fixture_keychain_not_fresh'); + }); + + test('fresh account cleanup requires the same GUID, UID, private group, and registered home', () => { + const identity = { guid: 'A38AC39B-5960-4F0C-B02F-C32A4F625B33', uid: 23456, gid: 23456, home: '/private/tmp/fixture/home' }; + const record = parseDirectoryRecord(`GeneratedUID: ${identity.guid}\nUniqueID: ${identity.uid}\nPrimaryGroupID: ${identity.gid}\nNFSHomeDirectory: ${identity.home}\n`); + expect(ownsFreshAccount(record, identity)).toBe(true); + for (const key of ['GeneratedUID', 'UniqueID', 'PrimaryGroupID', 'NFSHomeDirectory']) { + expect(ownsFreshAccount({ ...record, [key]: 'different' }, identity)).toBe(false); + } + expect(() => parseDirectoryRecord('UniqueID: 23456\nUniqueID: 501')).toThrow('invalid_directory_record'); + }); + + test('user-domain absence requires an explicit matching-domain response, not an arbitrary command failure', () => { + for (const status of [64, 113]) { + const absent = classifyUserDomain(23456, { status, stdout: '', stderr: 'Bad request.\nCould not find domain for user uid: 23456\n' }); + expect(absent.state).toBe('absent'); + expect(absent.uid).toBe(23456); + } + for (const result of [ + { status: 1, stdout: '', stderr: 'sudo: a password is required' }, + { status: 113, stdout: '', stderr: 'Could not find domain for user uid: 23457' }, + { status: 113, stdout: '', stderr: 'Could not find domain for user gui: 23456' }, + { status: 113, stdout: '', stderr: 'synthetic-private-error' }, + { status: null, stdout: '', stderr: '', error: new Error('synthetic-private-error') }, + ]) expect(classifyUserDomain(23456, result).state).toBe('unavailable'); + expect(() => classifyUserDomain(0, { status: 0, stdout: '', stderr: '' })).toThrow('invalid_fresh_user_domain'); + }); + + test('user-domain inspection records only safe state facts and notices an unexpected GUI domain', () => { + const present = classifyUserDomain(23456, { status: 0, stdout: 'user/23456 = {\n type = user\n synthetic-private-value\n}', stderr: '' }); + expect(present).toMatchObject({ uid: 23456, state: 'present', hasGuiDomain: false, exitCode: 0 }); + expect(JSON.stringify(present)).not.toContain('synthetic-private-value'); + expect(classifyUserDomain(23456, { status: 0, stdout: 'user/23456 = {\n subdomains = { gui/23456 }\n}', stderr: '' }).hasGuiDomain).toBe(true); + expect(classifyUserDomain(23456, { status: 0, stdout: 'user/23456 = {\n session = Aqua\n}', stderr: '' }).hasGuiDomain).toBe(true); + expect(classifyUserDomain(23456, { status: 0, stdout: 'user/501 = { }', stderr: '' }).state).toBe('unavailable'); + }); + + test('domain structure retains counts and creator correlation, never arbitrary labels or environment values', () => { + const stdout = `user/23456 = { +\ttype = user +\thandle = 23456 +\tactive count = 3 +\ton-demand count = 0 +\tservice count = 2 +\tactive service count = 1 +\texternal activation count = 0 +\tin-progress bootstraps = 0 +\tpended requests = 0 +\tcreator = launchctl.4567 +\tcreator euid = 0 +\tenvironment = { +\t\tsynthetic-private-variable => synthetic-private-value +\t\tservice count = 999 +\t} +\tservices = { +\t\t234 0 synthetic-private-service +\t\t0 0 synthetic-private-job +\t} +\tsubdomains = { +\t\tsynthetic-private-child +\t} +\tunmanaged processes = { +\t} +\tendpoints = {} +}`; + const observation = classifyUserDomain(23456, { status: 0, stdout, stderr: '' }, 4567); + expect(observation.structure).toMatchObject({ complete: true, type: 'user', handleMatchesUid: true, creator: 'launchctl', creatorIsProbe: true, + counts: { 'service count': 2, 'active service count': 1, 'creator euid': 0, 'in-progress bootstraps': 0 }, + sectionNonemptyLines: { services: 2, subdomains: 1, 'unmanaged processes': 0, endpoints: 0, jobs: null } }); + expect(JSON.stringify(observation)).not.toContain('synthetic-private'); + expect(JSON.stringify(observation)).not.toContain('4567'); + expect(classifyUserDomain(23456, { status: 0, stdout, stderr: '' }, 4568).structure?.creatorIsProbe).toBe(false); + expect(classifyUserDomain(23456, { status: 0, stdout, stderr: '' }).structure?.creatorIsProbe).toBeNull(); + expect(classifyUserDomain(23456, { status: 0, stdout: stdout.replace('launchctl.4567', 'synthetic-private-creator.4567'), stderr: '' }, 4567).structure?.creator).toBe('other'); + }); + + test('missing, malformed, duplicate, and incomplete domain facts never become empty-baseline evidence', () => { + const minimal = 'user/23456 = {\n\ttype = user\n\thandle = 23456\n}'; + const observation = classifyUserDomain(23456, { status: 0, stdout: minimal, stderr: '' }); + expect(observation.structure?.counts['service count']).toBeNull(); + expect(observation.structure?.sectionNonemptyLines.services).toBeNull(); + expect(observation.structure?.creatorIsProbe).toBeNull(); + for (const stdout of [minimal.slice(0, -1), minimal.replace('\thandle', '\ttype = user\n\thandle'), + minimal.replace('\thandle = 23456', '\tservices = {\n\t\tsynthetic-private-service')]) { + expect(classifyUserDomain(23456, { status: 0, stdout, stderr: '' }).structure?.complete).toBe(false); + } + for (const value of ['-1', 'NaN', '1.5', '9007199254740992', 'synthetic-private-value']) { + const stdout = minimal.replace('\thandle', '\tservice count = ' + value + '\n\thandle'); + expect(classifyUserDomain(23456, { status: 0, stdout, stderr: '' }).structure?.counts['service count']).toBeNull(); + } + }); + + test('known bracketed creator syntax is matched exactly while unsupported spellings remain unknown', () => { + const fixture = (creator: string) => classifyUserDomain(23456, { status: 0, + stdout: `user/23456 = {\n\ttype = user\n\tcreator = ${creator}\n}`, stderr: '' }, 4567); + expect(fixture('launchctl[4567]').structure?.creatorIsProbe).toBe(true); + expect(fixture('launchctl[4568]').structure?.creatorIsProbe).toBe(false); + for (const creator of ['launchctl(4567)', 'launchctl[4567] extra', 'other[4567]', 'launchctl[0]', 'launchctl[4567)']) { + expect(fixture(creator).structure?.creatorIsProbe).toBeNull(); + expect(fixture(creator).structure?.creator).toBe('other'); + } + }); + + test('parent-domain observation extracts only candidate UID membership from complete subdomains', () => { + for (const [header, target, gui] of [ + ['system', 'user/23456', 'gui/23456'], + ['com.apple.xpc.launchd.domain.system', 'com.apple.xpc.launchd.domain.user.23456', 'com.apple.xpc.launchd.user.domain.23456.100007.Aqua'], + ]) { + const stdout = `${header} = {\n\ttype = system\n\tsubdomains = {\n\t\t${target}\n\t\t${gui}\n\t\tpid/15\n\t}\n}`; + expect(classifyParentDomain(23456, { status: 0, stdout, stderr: '' })).toMatchObject({ state: 'present', parseStage: 'parsed', + subdomainCount: 3, matchingUserDomains: 1, matchingGuiDomains: 1, unrecognizedEntries: 0 }); + } + const stdout = `system = { +\ttype = system +\tenvironment = { +\t\tsynthetic-private-name => user/23456 +\t\tsubdomains = { +\t\t\tuser/23456 +\t\t} +\t} +\tsubdomains = { +\t\tuser/234560 +\t\tgui/501 +\t\tpid/123 +\t\tsession/100007 +\t\tcom.apple.xpc.launchd.domain.pid.synthetic-private-process.23456 +\t} +\tservices = { +\t\tsynthetic-private-service-user/23456 +\t} +}`; + const observation = classifyParentDomain(23456, { status: 0, stdout, stderr: '' }); + expect(observation).toMatchObject({ state: 'absent', parseStage: 'parsed', subdomainCount: 5, matchingUserDomains: 0 }); + expect(JSON.stringify(observation)).not.toContain('synthetic-private'); + expect(JSON.stringify(observation)).not.toContain('234560'); + }); + + test('parent-domain absence refuses incomplete, missing, duplicate, nested-only, or unknown subdomain output', () => { + const empty = 'system = {\n\ttype = system\n\tsubdomains = {}\n}'; + expect(classifyParentDomain(23456, { status: 0, stdout: empty, stderr: '' }).state).toBe('absent'); + for (const stdout of [empty.slice(0, -1), empty.replace('system = {', 'user/23456 = {'), + empty.replace('\tsubdomains = {}\n', ''), empty.replace('\tsubdomains = {}', '\tsubdomains = {}\n\tsubdomains = {}'), + empty.replace('type = system', 'type = user'), + empty.replace('\tsubdomains = {}', '\tenvironment = {\n\t\tsubdomains = {}\n\t}'), + empty.replace('subdomains = {}', 'subdomains = {\n\t\tunknown-private-domain\n\t}'), + empty.replace('subdomains = {}', 'subdomains = {\n\t\tuser/23456\n\t\tunknown-private-domain\n\t}')]) { + const observation = classifyParentDomain(23456, { status: 0, stdout, stderr: '' }); + expect(observation.state).toBe('unavailable'); + expect(JSON.stringify(observation)).not.toContain('private-domain'); + } + expect(classifyParentDomain(23456, { status: 1, stdout: empty, stderr: 'synthetic-private-error' }).state).toBe('unavailable'); + expect(classifyParentDomain(23456, { status: 0, stdout: empty, stderr: '', error: new Error('synthetic-private-error') }).state).toBe('unavailable'); + expect(classifyParentDomain(23456, { status: 0, stdout: empty + ' '.repeat(1024 * 1024), stderr: '' }).parseStage).toBe('oversized'); + }); + + test('the registered parent observer queries only the existing system domain under its original bounds', () => { + const env = { HOME: root, PATH: '/usr/bin:/bin' }; + const observation = inspectParentDomain(23456, performance.now() + 10_000, env, + ((command: string, args: string[], options: any) => { + expect(command).toBe('/usr/bin/sudo'); + expect(args).toEqual(['-n', '/bin/launchctl', 'print', 'system']); + expect(options.env).toBe(env); + expect(options.maxBuffer).toBe(1024 * 1024); + expect(Number.isInteger(options.timeout)).toBe(true); + expect(options.timeout).toBeGreaterThan(0); + expect(options.timeout).toBeLessThanOrEqual(3_000); + return { status: 0, stdout: 'system = {\n\ttype = system\n\tsubdomains = {}\n}', stderr: '' }; + }) as typeof spawnSync); + expect(observation.state).toBe('absent'); + const never = (() => { throw new Error('must_not_spawn'); }) as typeof spawnSync; + expect(() => inspectParentDomain(501, performance.now() + 10_000, {}, never)).toThrow('invalid_fresh_user_domain'); + for (const deadline of [0, NaN, Infinity]) expect(() => inspectParentDomain(23456, deadline, {}, never)).toThrow('fresh_launcher_deadline'); + }); + + test('the registered UID observer filters numeric global ps rows without resolving an unregistered account', () => { + const env = { HOME: root, PATH: '/usr/bin:/bin' }; + const observation = inspectUidProcesses(23456, performance.now() + 10_000, env, + ((command: string, args: string[], options: any) => { + expect(command).toBe('/bin/ps'); + expect(args).toEqual(['-axo', 'uid=,pid=,ppid=,state=,ucomm=']); + expect(options.env).toBe(env); + expect(options.maxBuffer).toBe(128 * 1024); + expect(Number.isInteger(options.timeout)).toBe(true); + expect(options.timeout).toBeGreaterThan(0); + expect(options.timeout).toBeLessThanOrEqual(2_000); + return { status: 0, stdout: '501 300 1 S synthetic-private-name\n23456 400 1 S distnoted\n', stderr: '' }; + }) as typeof spawnSync); + expect(observation).toMatchObject({ available: true, count: 1, processes: [{ pid: 400, ppid: 1, state: 'S', basename: 'distnoted' }] }); + expect(JSON.stringify(observation)).not.toContain('synthetic-private'); + expect(inspectUidProcesses(23456, performance.now() + 10_000, {}, (() => ({ status: 0, + stdout: '501 300 1 S synthetic-private-name\n', stderr: '' })) as typeof spawnSync)).toMatchObject({ available: true, count: 0, processes: [] }); + for (const result of [ + { status: 1, stdout: '', stderr: '' }, { status: 0, stdout: '', stderr: '' }, + { status: 0, stdout: 'truncated-private-row', stderr: '' }, + { status: null, stdout: null, stderr: null, error: new Error('synthetic-private-error') }, + ]) expect(inspectUidProcesses(23456, performance.now() + 10_000, {}, (() => result) as typeof spawnSync)).toEqual({ available: false }); + }); + + test('numeric UID process filtering runs through the real global process table', () => { + const observation = inspectUidProcesses(23456, performance.now() + 10_000, { HOME: root, PATH: '/usr/bin:/bin' }); + expect(observation.available).toBe(true); + expect('count' in observation).toBe(true); + }); + + test('the registered domain probe binds its creator check to the shell exec PID and bounds the query', () => { + const env = { PATH: '/usr/bin:/bin', HOME: root }; + let called = false; + const observation = inspectUserDomain(23456, performance.now() + 10_000, env, ((command: string, args: string[], options: any) => { + called = true; + expect(command).toBe('/usr/bin/sudo'); + expect(args).toEqual(['-n', '/bin/sh', '-c', 'printf "GSTACK_DIA_DOMAIN_PROBE_PID=%s\\n" "$$"; exec /bin/launchctl print "$1"', + 'gstack-dia-domain-probe', 'user/23456']); + expect(options.env).toBe(env); + expect(options.timeout).toBeGreaterThan(0); + expect(options.timeout).toBeLessThanOrEqual(3_000); + expect(Number.isInteger(options.timeout)).toBe(true); + expect(options.maxBuffer).toBe(1024 * 1024); + return { status: 0, stdout: 'GSTACK_DIA_DOMAIN_PROBE_PID=4567\nuser/23456 = {\n\ttype = user\n\tcreator = launchctl.4567\n}', stderr: '' }; + }) as typeof spawnSync); + expect(called).toBe(true); + expect(observation.state).toBe('present'); + expect(observation.structure?.creatorIsProbe).toBe(true); + expect(JSON.stringify(observation)).not.toContain('GSTACK_DIA_DOMAIN_PROBE_PID'); + }); + + test('domain probe failures preserve unknown state instead of manufacturing absence or creator ownership', () => { + for (const result of [ + { status: 0, stdout: 'user/23456 = {\n\ttype = user\n}', stderr: '' }, + { status: 0, stdout: 'GSTACK_DIA_DOMAIN_PROBE_PID=9007199254740992\nuser/23456 = {\n\ttype = user\n}', stderr: '' }, + { status: 113, stdout: '', stderr: 'Could not find domain for user uid: 23456' }, + { status: null, stdout: null, stderr: null, error: new Error('synthetic-private-error') }, + ]) { + const observation = inspectUserDomain(23456, performance.now() + 10_000, {}, (() => result) as typeof spawnSync); + expect(observation.state).toBe('unavailable'); + expect(observation.structure).toBeUndefined(); + expect(JSON.stringify(observation)).not.toContain('synthetic-private'); + } + expect(inspectUserDomain(23456, performance.now() + 10_000, {}, (() => ({ status: 113, + stdout: 'GSTACK_DIA_DOMAIN_PROBE_PID=4567\n', stderr: 'Could not find domain for user uid: 23456' })) as typeof spawnSync).state).toBe('absent'); + const never = (() => { throw new Error('must_not_spawn'); }) as typeof spawnSync; + expect(() => inspectUserDomain(0, performance.now() + 10_000, {}, never)).toThrow('invalid_fresh_user_domain'); + for (const deadline of [0, NaN, Infinity]) expect(() => inspectUserDomain(23456, deadline, {}, never)).toThrow('fresh_launcher_deadline'); + }); + + test('the domain probe PID protocol survives real shell exec without logging the PID or child output', () => { + const fixture = path.join(root, 'domain-probe.cjs'); + writeFileSync(fixture, 'process.stdout.write("user/23456 = {\\n\\ttype = user\\n\\tcreator = launchctl." + process.pid + "\\n}");'); + const observation = inspectUserDomain(23456, performance.now() + 10_000, { PATH: '/usr/bin:/bin', HOME: root }, + ((_command: string, args: string[], options: any) => spawnSync('/bin/sh', ['-c', + args[3].replace('/bin/launchctl print "$1"', '"$1" "$2"'), args[4], process.execPath, fixture], + { ...options, timeout: 3_000 })) as typeof spawnSync); + expect(observation.state).toBe('present'); + expect(observation.structure?.creatorIsProbe).toBe(true); + expect(observation.structure?.complete).toBe(true); + expect(JSON.stringify(observation)).not.toContain('GSTACK_DIA_DOMAIN_PROBE_PID'); + }); + + test('user-domain teardown is bound to the new account and its pre-creation absence proof', () => { + const account = { guid: 'A38AC39B-5960-4F0C-B02F-C32A4F625B33', uid: 23456, gid: 23456, home: '/private/tmp/dn-fixture/home' }; + const record = { GeneratedUID: account.guid, UniqueID: '23456', PrimaryGroupID: '23456', NFSHomeDirectory: account.home }; + const proof = classifyParentDomain(23456, { status: 0, stdout: 'system = {\n\ttype = system\n\tsubdomains = {}\n}', stderr: '' }); + const current = classifyParentDomain(23456, { status: 0, stdout: 'system = {\n\ttype = system\n\tsubdomains = {\n\t\tuser/23456\n\t}\n}', stderr: '' }); + expect(ownedUserDomainTarget(record, account, proof, current, 501)).toBe('user/23456'); + expect(ownedUserDomainTarget(record, account, proof, proof, 501)).toBeNull(); + for (const key of ['GeneratedUID', 'UniqueID', 'PrimaryGroupID', 'NFSHomeDirectory']) { + expect(() => ownedUserDomainTarget({ ...record, [key]: 'changed' }, account, proof, current, 501)).toThrow('fresh_user_domain_ownership_unconfirmed'); + } + expect(() => ownedUserDomainTarget(record, account, undefined, current, 501)).toThrow('fresh_user_domain_ownership_unconfirmed'); + expect(() => ownedUserDomainTarget(record, account, { ...proof, state: 'present' }, current, 501)).toThrow('fresh_user_domain_ownership_unconfirmed'); + expect(() => ownedUserDomainTarget(record, account, { ...proof, uid: 23457 }, current, 501)).toThrow('fresh_user_domain_ownership_unconfirmed'); + expect(() => ownedUserDomainTarget(record, account, proof, current, 23456)).toThrow('fresh_user_domain_ownership_unconfirmed'); + expect(() => ownedUserDomainTarget(record, account, proof, current, NaN)).toThrow('fresh_user_domain_ownership_unconfirmed'); + for (const changed of [{ ...current, matchingGuiDomains: 1 }, { ...current, uid: 23457 }, { ...current, state: 'unavailable' as const }, + { ...current, duplicateEntries: 1 }, { ...current, matchingUserDomains: 2 }, { ...current, parseStage: 'missing_subdomains' }, + { ...current, unrecognizedEntries: 1 }]) { + expect(() => ownedUserDomainTarget(record, account, proof, changed, 501)).toThrow('fresh_user_domain_ownership_unconfirmed'); + } + }); + + test('duplicated native subdomain entries and equivalent aliases never authorize teardown or absence', () => { + for (const entries of [ + ['user/23456', 'user/23456'], ['user/23456', 'com.apple.xpc.launchd.domain.user.23456'], + ['user/23456', 'gui/23456', 'gui/23456'], ['user/501', 'user/501'], + ['user/23456', 'pid/22', 'com.apple.xpc.launchd.domain.pid.synthetic-private-process.22'], + ]) { + const observation = classifyParentDomain(23456, { status: 0, + stdout: 'system = {\n\ttype = system\n\tsubdomains = {\n' + entries.map(entry => '\t\t' + entry).join('\n') + '\n\t}\n}', stderr: '' }); + expect(observation.duplicateEntries).toBe(1); + expect(observation.parseStage).toBe('duplicate_subdomain'); + expect(observation.state).toBe('unavailable'); + expect(passiveUserDomainState(observation, 23456)).toBe('unavailable'); + } + }); + + test('passive absence and presence require consistent complete facts with no GUI association', () => { + const absent = classifyParentDomain(23456, { status: 0, stdout: 'system = {\n\ttype = system\n\tsubdomains = {}\n}', stderr: '' }); + expect(passiveUserDomainState(absent, 23456)).toBe('absent'); + for (const changed of [undefined, { ...absent, uid: 23457 }, { ...absent, matchingUserDomains: 1 }, + { ...absent, matchingGuiDomains: 1 }, { ...absent, exitCode: 1 }, { ...absent, duplicateEntries: 1 }, + { ...absent, unrecognizedEntries: 1 }, { ...absent, state: 'present' as const }]) { + expect(passiveUserDomainState(changed, 23456)).toBe('unavailable'); + } + const gui = classifyParentDomain(23456, { status: 0, + stdout: 'system = {\n\ttype = system\n\tsubdomains = {\n\t\tuser/23456\n\t\tgui/23456\n\t}\n}', stderr: '' }); + expect(passiveUserDomainState(gui, 23456)).toBe('unavailable'); + const oldTargetObservation = classifyUserDomain(23456, { status: 113, stdout: '', stderr: 'Could not find domain for user uid: 23456' }); + expect(passiveUserDomainState(oldTargetObservation as any, 23456)).toBe('unavailable'); + }); + + test('directory UID inventory retains occupied candidate IDs and refuses malformed or empty listings', () => { + expect([...parseDirectoryIds('root 0\nfixture 20000\nnobody -2\nother fixture 20001 \n')]).toEqual([0, 20000, -2, 20001]); + for (const value of ['', 'malformed', 'root 0\nfixture unknown\n', 'root 0\nfixture 9007199254740992\n']) { + expect(() => parseDirectoryIds(value)).toThrow(); + } + }); + + test('normal qualification never invokes the materializing target-domain diagnostic', () => { + const implementation = runFreshAccountQualification.toString(); + expect(implementation).toContain('inspectParentDomain'); + expect(implementation).toContain('passiveUserDomainState'); + expect(implementation).not.toMatch(/inspectUserDomain|probeUserDomain|classifyUserDomain/); + expect(implementation).not.toMatch(/['"]print['"],\s*['"](?:user|gui)\//); + }); + + test('launchd receives a one-shot fresh-user security session without an Aqua or auto-login workaround', () => { + const account: any = { label: 'ai.gstack.dia.fixture', account: 'gsdiafixture', bun: '/private/tmp/fixture/bin/bun', + snapshot: '/private/tmp/fixture/repo', configFile: '/private/tmp/fixture/account.json', environment: { HOME: '/private/tmp/fixture/home', CI: 'true' } }; + const definition = freshLaunchDefinition(account); + expect(definition.UserName).toBe(account.account); + expect(definition.GroupName).toBe(account.account); + expect(definition.SessionCreate).toBe(true); + expect(definition.RunAtLoad).toBe(true); + expect(definition.KeepAlive).toBe(false); + expect(definition.Umask).toBe(63); + expect(definition.ProgramArguments[0]).toBe(account.bun); + expect(definition.ProgramArguments).toContain('--fresh-worker'); + expect(definition.StandardOutPath).toBe('/dev/null'); + expect(definition.StandardErrorPath).toBe('/dev/null'); + expect(JSON.stringify(definition)).not.toMatch(/Aqua|autoLogin|LoginWindow|GITHUB_TOKEN/); + }); + + test('service cleanup binds the exact system label, executable, user, and private group', () => { + const owner = { label: 'ai.gstack.dia.fixture', bun: '/private/tmp/fixture/bin/bun', account: 'gsdiafixture' }; + const state = `system/${owner.label} = {\n program = ${owner.bun}\n username = ${owner.account}\n group = ${owner.account}\n}`; + expect(ownsLaunchService(state, owner)).toBe(true); + for (const replacement of [state.replace('system/', 'gui/501/'), state.replace(owner.label, 'unrelated'), + state.replace(owner.bun, '/unrelated/bun'), state.replace('username = gsdiafixture', 'username = runner'), + state.replace('group = gsdiafixture', 'group = staff')]) expect(ownsLaunchService(replacement, owner)).toBe(false); + }); + + test('the fresh-account launcher refuses this non-authorized invocation without privileged work', () => { + const launcher = path.resolve(import.meta.dir, '../../.github/scripts/run-dia-native-qualification.ts'); + const result = spawnSync(process.execPath, ['--no-env-file', '--no-install', '--no-macros', `--config=${process.platform === 'win32' ? 'NUL' : '/dev/null'}`, launcher], { + cwd: root, env: { HOME: root, PATH: path.dirname(process.execPath) }, encoding: 'utf8', timeout: 10_000, + }); + expect(result.status).toBe(2); + expect(result.stderr).toBe(''); + expect(JSON.parse(result.stdout).reason).toBe('fresh_account_launcher_preflight_failed'); + }); + + for (const shape of ['normal', 'safe-link', 'traversal', 'absolute', 'escape-link', 'symlink-parent', 'symlink-dotdot', 'hardlink', 'case-collision', 'unicode-collision', 'device']) { + test(`archive preflight classifies ${shape} before extraction`, () => { + const python = Bun.which('python3'); + if (!python) throw new Error('Python 3 is required for archive boundary tests'); + const archive = path.join(root, shape + '.tar'); + const create = spawnSync(python, ['-I', '-c', ` +import io, sys, tarfile +shape, file = sys.argv[1:] +with tarfile.open(file, 'w') as out: + def entry(name, kind=tarfile.REGTYPE, link=''): + item = tarfile.TarInfo(name); item.type = kind; item.linkname = link + if kind == tarfile.REGTYPE: + item.size = 1; out.addfile(item, io.BytesIO(b'x')) + else: out.addfile(item) + if shape == 'normal': entry('src/file.ts') + elif shape == 'safe-link': entry('target'); entry('link', tarfile.SYMTYPE, 'target') + elif shape == 'traversal': entry('../outside') + elif shape == 'absolute': entry('/outside') + elif shape == 'escape-link': entry('link', tarfile.SYMTYPE, '../outside') + elif shape == 'symlink-parent': entry('link', tarfile.SYMTYPE, 'target'); entry('link/child') + elif shape == 'symlink-dotdot': entry('b', tarfile.SYMTYPE, '.'); entry('a/link', tarfile.SYMTYPE, '../b/..') + elif shape == 'hardlink': entry('target'); entry('link', tarfile.LNKTYPE, 'target') + elif shape == 'case-collision': entry('File'); entry('file') + elif shape == 'unicode-collision': entry('Caf' + chr(233)); entry('Cafe' + chr(769)) + elif shape == 'device': entry('device', tarfile.CHRTYPE) +`, shape, archive], { encoding: 'utf8', timeout: 10_000 }); + expect(create.status).toBe(0); + const checked = spawnSync(python, ['-I', '-c', ARCHIVE_CHECK, archive], { encoding: 'utf8', timeout: 10_000 }); + expect(checked.status).toBe(['normal', 'safe-link'].includes(shape) ? 0 : 2); + expect(JSON.parse(checked.stdout).valid).toBe(['normal', 'safe-link'].includes(shape)); + expect(checked.stderr).toBe(''); + }); + } + + test('receipt collection rejects symlinks and an unrelated owner without printing content', () => { + const python = Bun.which('python3'); + if (!python) throw new Error('Python 3 is required for receipt boundary tests'); + const directory = realpathSync(mkdtempSync(path.join(root, 'receipts-'))); + const file = path.join(directory, 'receipt.json'); + writeFileSync(file, JSON.stringify({ status: 'incomplete', reason: 'synthetic_fixture' }), { mode: 0o600 }); + chmodSync(file, 0o600); + const uid = process.getuid!(); + const read = (selected: string, owner: number) => spawnSync(python, ['-I', '-c', PRIVATE_RECEIPT_READ, selected, String(owner), directory], { + encoding: 'utf8', timeout: 10_000, + }); + const valid = read(file, uid); + expect(valid.status).toBe(0); + expect(JSON.parse(valid.stdout).status).toBe('incomplete'); + const wrong = read(file, uid + 1); + expect(wrong.status).toBe(2); + expect(wrong.stdout).toBe(''); + const link = path.join(directory, 'linked.json'); + symlinkSync(file, link); + const linked = read(link, uid); + expect(linked.status).toBe(2); + expect(linked.stdout).toBe(''); + }); + + test('receipt collection rejects linked ancestors and incomplete JSON without disclosing content', () => { + const python = Bun.which('python3'); + if (!python) throw new Error('Python 3 is required for receipt boundary tests'); + const directory = realpathSync(mkdtempSync(path.join(root, 'receipt-ancestors-'))); + const nested = path.join(directory, 'nested'); + mkdirSync(nested, { mode: 0o700 }); + const file = path.join(nested, 'receipt.json'); + writePrivateReceipt(file, { status: 'incomplete' }); + const linked = path.join(directory, 'linked'); + symlinkSync(nested, linked, 'dir'); + const read = (selected: string) => spawnSync(python, ['-I', '-c', PRIVATE_RECEIPT_READ, selected, String(process.getuid!()), directory], { + encoding: 'utf8', timeout: 3_000, + }); + expect(read(file).status).toBe(0); + const rejected = read(path.join(linked, 'receipt.json')); + expect(rejected.status).toBe(2); + expect(rejected.stdout).toBe(''); + writeFileSync(file, '{"synthetic-private-value":'); + const partial = read(file); + expect(partial.status).toBe(2); + expect(partial.stdout).toBe(''); + }); + + test('receipt publication is private, atomic, and refuses unintended overwrites or symlink targets', () => { + const directory = realpathSync(mkdtempSync(path.join(root, 'atomic-receipt-'))); + const file = path.join(directory, 'receipt.json'); + writePrivateReceipt(file, { status: 'incomplete', phase: 'first' }); + expect(lstatSync(file).mode & 0o777).toBe(0o600); + expect(() => writePrivateReceipt(file, { status: 'passed' })).toThrow(); + expect(JSON.parse(readFileSync(file, 'utf8')).phase).toBe('first'); + writePrivateReceipt(file, { status: 'incomplete', phase: 'second' }, true); + expect(JSON.parse(readFileSync(file, 'utf8')).phase).toBe('second'); + const link = path.join(directory, 'linked.json'); + symlinkSync(file, link); + expect(() => writePrivateReceipt(link, { status: 'passed' }, true)).toThrow('unsafe_receipt_replacement'); + expect(JSON.parse(readFileSync(file, 'utf8')).phase).toBe('second'); + expect(readdirSync(directory).some(name => name.startsWith('.dia-receipt-'))).toBe(false); + }); + + test('an atomic owner-checked diagnostic receipt is readable while its producer is still alive', async () => { + const python = Bun.which('python3'); + if (!python) throw new Error('Python 3 is required for receipt boundary tests'); + const directory = realpathSync(mkdtempSync(path.join(root, 'live-receipt-'))); + const file = path.join(directory, 'receipt.json'); + const module = pathToFileURL(path.resolve(import.meta.dir, '../../.github/scripts/qualify-dia-macos.ts')).href; + const child = Bun.spawn([process.execPath, '--no-env-file', '--no-install', '--no-macros', '--config=/dev/null', '-e', ` + const { writePrivateReceipt } = await import(${JSON.stringify(module)}); + writePrivateReceipt(${JSON.stringify(file)}, { status: 'incomplete', reason: 'worker_diagnostic', cleanup: { complete: false } }); + console.log('ready'); + setInterval(() => {}, 1000); + `], { cwd: directory, env: { HOME: directory, PATH: path.dirname(process.execPath) }, stdout: 'pipe', stderr: 'pipe' }); + try { + const reader = child.stdout.getReader(); + expect(new TextDecoder().decode((await reader.read()).value)).toContain('ready'); + reader.releaseLock(); + expect(child.exitCode).toBeNull(); + const result = spawnSync(python, ['-I', '-c', PRIVATE_RECEIPT_READ, file, String(process.getuid!()), directory], { + encoding: 'utf8', timeout: 3_000, + }); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ status: 'incomplete', reason: 'worker_diagnostic', cleanup: { complete: false } }); + expect(child.exitCode).toBeNull(); + } finally { + child.kill(); + await child.exited; + } + }); + + test('UID process diagnostics preserve zombie/service/browser distinctions without argv or arbitrary names', () => { + const facts = uidProcessFacts('20000 301 1 Ss cfprefsd\n20000 302 1 Z bun\n20000 303 301 R Google Chrome for Testing\n20000 304 1 S synthetic-private-value\n501 999 1 S security\n', 20000); + expect(facts).toEqual({ available: true, count: 4, zombies: 1, live: 3, truncated: false, processes: [ + { pid: 301, ppid: 1, state: 'S', basename: 'cfprefsd' }, { pid: 302, ppid: 1, state: 'Z', basename: 'bun' }, + { pid: 303, ppid: 301, state: 'R', basename: 'Google Chrome for Testing' }, { pid: 304, ppid: 1, state: 'S', basename: 'other' }, + ] }); + expect(JSON.stringify(facts)).not.toContain('synthetic-private-value'); + expect(JSON.stringify(facts)).not.toContain('999'); + expect(uidProcessFacts('20000 401 1 S cfprefsd\n', 20000).processes[0].pid).toBe(401); + expect(uidProcessFacts('20000 402 1 Z+ bun\n', 20000)).toMatchObject({ count: 1, zombies: 1, live: 0 }); + expect(() => uidProcessFacts('malformed synthetic-private-value', 20000)).toThrow('invalid_uid_process_snapshot'); + expect(uidProcessFacts(Array.from({ length: 80 }, (_, index) => `20000 ${1000 + index} 1 S bun`).join('\n'), 20000)) + .toMatchObject({ count: 80, truncated: true }); + expect(uidProcessFacts(Array.from({ length: 80 }, (_, index) => `20000 ${1000 + index} 1 S bun`).join('\n'), 20000).processes).toHaveLength(64); + }); + + test('captured passing inner receipts cannot qualify a run with incomplete cleanup', () => { + const cleanup = { serviceStopped: true, userDomainStopped: true, userProcessesStopped: true, accountRemoved: true, groupRemoved: true, stagingRemoved: true }; + expect(freshQualificationPassed(0, 'passed', 'passed', cleanup)).toBe(true); + for (const key of Object.keys(cleanup)) expect(freshQualificationPassed(0, 'passed', 'passed', { ...cleanup, [key]: false })).toBe(false); + expect(freshQualificationPassed(0, 'passed', 'passed', {})).toBe(false); + expect(freshQualificationPassed(2, 'passed', 'passed', cleanup)).toBe(false); + expect(freshQualificationPassed(0, 'incomplete', 'passed', cleanup)).toBe(false); + }); + + test('the registered spawn observer records the actual owned child and closes launch admission', async () => { + const profile = path.join(root, 'profile'); + const childProcess = require('node:child_process'); + const original = childProcess.spawn; + const observer = observeBrowserLaunches(new Map([[process.execPath, profile]])); + const args = ['--no-env-file', '--no-install', '-e', 'process.exit(0)', '--', '--remote-debugging-pipe', '--user-data-dir=' + profile]; + const options = { cwd: root, detached: true, shell: false, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'], + env: { PATH: path.dirname(process.execPath), HOME: root, ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}) } }; + try { + const child = childProcess.spawn(process.execPath, args, options); + const code = await new Promise(resolve => child.once('close', resolve)); + expect(code).toBe(0); + expect(observer.children).toHaveLength(1); + expect(observer.children[0].process).toBe(child); + expect(observer.children[0].pid).toBe(child.pid); + observer.stop(); + expect(() => childProcess.spawn(process.execPath, args, options)).toThrow('browser_launch_policy_rejected'); + expect(observer.children).toHaveLength(1); + } finally { + observer.restore(); + } + expect(childProcess.spawn).toBe(original); + }); + + test('the owned spawn stderr observer preserves the consumer stream and removes only its own listeners', async () => { + const childProcess = require('node:child_process'); + const original = childProcess.spawn; + const profile = path.join(root, 'stderr-observer-profile'); + const observer = observeBrowserLaunches(new Map([[process.execPath, profile]])); + const text = 'DevTools remote debugging requires a non-default data directory.\nsynthetic-private-value\n'; + try { + const child = childProcess.spawn(process.execPath, ['--no-env-file', '--no-install', '-e', + `process.stderr.write(${JSON.stringify(text)})`, '--', '--remote-debugging-pipe', '--user-data-dir=' + profile], { + detached: true, shell: false, stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'], env: { HOME: root, PATH: path.dirname(process.execPath) }, + }); + const chunks: Buffer[] = []; + const consumer = (chunk: Buffer) => { chunks.push(chunk); }; + child.stderr.on('data', consumer); + const code = await new Promise(resolve => child.once('close', resolve)); + expect(code).toBe(0); + expect(Buffer.concat(chunks).toString()).toBe(text); + const facts = browserStderrFacts(observer.children); + expect(facts).toHaveLength(1); + expect(facts[0]).toMatchObject({ available: true, bytesSeen: Buffer.byteLength(text), ended: true, + reasonCounts: { default_profile_policy: 1 } }); + expect(JSON.stringify(facts)).not.toContain('synthetic-private'); + observer.restore(); + expect(child.stderr.listeners('data')).toContain(consumer); + expect(childProcess.spawn).toBe(original); + } finally { observer.restore(); } + }); + + test('an owned killed child is joined before proving its process group absent', async () => { + const childProcess = require('node:child_process'); + const profile = path.join(root, 'reaping-profile'); + const observer = observeBrowserLaunches(new Map([[process.execPath, profile]])); + let timer: ReturnType | undefined; + try { + const child = childProcess.spawn(process.execPath, ['--no-env-file', '--no-install', '-e', + 'process.stdout.write("ready\\n"); setInterval(() => {}, 1000)', '--', '--remote-debugging-pipe', '--user-data-dir=' + profile], { + detached: true, shell: false, stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'], env: { HOME: root, PATH: path.dirname(process.execPath) }, + }); + await Promise.race([new Promise(resolve => child.stdout.once('data', resolve)), new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('fixture_startup_timeout')), 3000); + })]); + clearTimeout(timer); + const owned = observer.children[0]; + expect(owned.closeObserved).toBe(false); + const until = performance.now() + 5_000; + const facts: Record = { signalSent: false, absenceConfirmed: false }; + await stopOwnedBrowserGroup(owned, until, facts); + expect(owned.closeObserved).toBe(true); + expect(facts).toMatchObject({ signalSent: true, childCloseObserved: true, absenceConfirmed: true }); + let absent = false; + while (performance.now() < until) { + try { process.kill(-owned.pid, 0); } + catch (error: any) { if (error.code === 'ESRCH') { absent = true; break; } throw error; } + await Bun.sleep(10); + } + expect(absent).toBe(true); + expect(browserRootFacts(observer.children)[0]).toMatchObject({ closeObserved: true, signal: 'SIGKILL' }); + } finally { + clearTimeout(timer); + for (const owned of observer.children) { + if (owned.closeObserved) continue; + try { process.kill(-owned.pid, 'SIGKILL'); } catch {} + await joinOwnedBrowserClose(owned, performance.now() + 5_000); + } + observer.restore(); + } + }); + + test('the pinned Playwright launch is captured with the observer installed after importing Playwright', async () => { + const { chromium } = await import('playwright'); + expect(require('playwright/package.json').version).toBe('1.62.1'); + const executable = realpathSync(chromium.executablePath()); + const profile = path.join(root, 'playwright-profile'); + const observer = observeBrowserLaunches(new Map([[executable, profile]])); + let context: Awaited> | undefined; + try { + const nativeOptions = nativeDiaLaunchOptions(executable, { + HOME: root, PATH: path.dirname(process.execPath) + ':/usr/bin:/bin:/usr/sbin:/sbin', + ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}), + }); + expect(nativeOptions.chromiumSandbox).toBe(true); + context = await chromium.launchPersistentContext(profile, nativeOptions); + expect(observer.children).toHaveLength(1); + expect(observer.children[0].executable).toBe(executable); + expect(observer.children[0].pid).toBeGreaterThan(1); + expect(browserRootFacts(observer.children)).toEqual([{ pid: observer.children[0].pid, exitCode: null, closeObserved: false, signal: null }]); + expect(browserStartupFacts(context.pages().map(page => page.url()), 'http://127.0.0.1:8123').allowed).toBe(true); + expect(browserStderrFacts(observer.children)[0].available).toBe(true); + expect(browserStderrFacts(observer.children)[0].bytesInspected).toBeLessThanOrEqual(65_536); + expect(observer.attempts).toHaveLength(1); + expect(observer.attempts[0]).toEqual({ admissionOpen: true, argumentsArray: true, pipeFlag: true, profileArgumentCount: 1, + expectedProfile: true, detached: true, shellDisabled: true, stdioCount: 5, extraPipeDescriptors: true, + headlessFlag: true, blankStartupArgument: true, tcpDebuggingFlag: false, mockKeychainFlag: false, + passwordStoreFlag: false, firstRunSuppressed: false, sandboxRequired: process.platform === 'darwin', sandboxDisablingFlag: false }); + expect(JSON.stringify(observer.attempts)).not.toContain(executable); + expect(JSON.stringify(observer.attempts)).not.toContain(profile); + const page = context.pages()[0] ?? await context.newPage(); + await page.setContent('
isolated browser smoke
'); + expect(await page.locator('#fixture').innerText()).toBe('isolated browser smoke'); + await context.close(); + context = undefined; + expect(observer.children[0].process.exitCode !== null || observer.children[0].process.signalCode !== null).toBe(true); + } finally { + observer.stop(); + await context?.close(); + observer.restore(); + } + }, 40_000); + + for (const forbidden of ['--remote-debugging-port=9222', '--use-mock-keychain', '--password-store=basic', '--no-first-run']) { + test(`the actual spawn callback rejects ${forbidden} before process creation`, () => { + const profile = path.join(root, 'profile'); + const childProcess = require('node:child_process'); + const observer = observeBrowserLaunches(new Map([[process.execPath, profile]])); + try { + expect(() => childProcess.spawn(process.execPath, ['--remote-debugging-pipe', '--user-data-dir=' + profile, forbidden], + { detached: true, shell: false, stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'] })).toThrow('browser_launch_policy_rejected'); + expect(observer.children).toHaveLength(0); + } finally { observer.restore(); } + }); + } + + test('the spawn callback rejects an unowned profile, shell expansion, and a shared process group', () => { + const profile = path.join(root, 'profile'); + const childProcess = require('node:child_process'); + const observer = observeBrowserLaunches(new Map([[process.execPath, profile]])); + try { + for (const [selected, detached, shell] of [[path.join(root, 'other'), true, false], [profile, false, false], [profile, true, true]]) { + expect(() => childProcess.spawn(process.execPath, ['--remote-debugging-pipe', '--user-data-dir=' + selected], { detached, shell, stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'] })) + .toThrow('browser_launch_policy_rejected'); + } + expect(() => childProcess.spawn(process.execPath, ['--remote-debugging-pipe', '--user-data-dir=' + profile, '--user-data-dir=' + path.join(root, 'other')], + { detached: true, shell: false, stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'] })).toThrow('browser_launch_policy_rejected'); + expect(() => childProcess.spawn(process.execPath, ['--remote-debugging-pipe', '--user-data-dir=' + profile], + { detached: true, shell: false, stdio: 'inherit' })).toThrow('browser_launch_policy_rejected'); + expect(observer.children).toHaveLength(0); + } finally { observer.restore(); } + }); +}); diff --git a/browse/test/fixtures/native-cookie-delete-lease.ts b/browse/test/fixtures/native-cookie-delete-lease.ts new file mode 100644 index 000000000..f1b1d1d83 --- /dev/null +++ b/browse/test/fixtures/native-cookie-delete-lease.ts @@ -0,0 +1,125 @@ +import { lstatSync, unlinkSync } from 'node:fs'; +import { toNamespacedPath } from 'node:path'; +import { dlopen, FFIType, ptr } from 'bun:ffi'; + +type Identity = { dev: bigint; ino: bigint; mode: bigint }; +type Handle = number | bigint; +type Stage = 'admission' | 'identity' | 'disposition' | 'close' | 'absence'; + +export class FixtureDeleteError extends Error { + readonly code: string; + readonly syscall: string; + constructor(public stage: Stage, public path: string, public win32Error?: number, public deadlineExceeded = false) { + super(`Owned fixture deletion failed at ${stage}${deadlineExceeded ? ' (deadline)' : win32Error === undefined ? '' : ` (Windows ${win32Error})`}`); + this.name = 'FixtureDeleteError'; + this.code = deadlineExceeded ? 'ETIMEDOUT' : win32Error === 32 ? 'EBUSY' : win32Error === 2 || win32Error === 3 ? 'ENOENT' : win32Error === 5 ? 'EACCES' : 'EIO'; + this.syscall = { admission: 'open', identity: 'fstat', disposition: 'unlink', close: 'close', absence: 'lstat' }[stage]; + } +} + +export interface FixtureDeleteBackend { + open(file: string): Handle; + identity(handle: Handle, file: string): { dev: bigint; ino: bigint; attributes: number; filesystem: string }; + dispose(handle: Handle, file: string): void; + close(handle: Handle, file: string): void; + absent(file: string): boolean; +} + +type LeaseClock = { now(): number; wait(ms: number): void; onSharing?(): void }; +const leaseClock: LeaseClock = { now: () => performance.now(), wait: ms => { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } }; + +export function deleteWithFixtureLease(file: string, expected: Identity, deadline: number, verify: () => void, + backend: FixtureDeleteBackend, clock: LeaseClock = leaseClock): { admissionProbes: number; waitedMs: number } { + if (!Number.isFinite(deadline)) throw new Error('Invalid fixture deletion deadline'); + let handle: Handle | undefined; + let firstSharing: FixtureDeleteError | undefined; + let admissionProbes = 0; + let waitedMs = 0; + while (handle === undefined) { + if (clock.now() >= deadline) throw firstSharing ?? new FixtureDeleteError('admission', file, undefined, true); + verify(); + if (clock.now() >= deadline) throw firstSharing ?? new FixtureDeleteError('admission', file, undefined, true); + try { admissionProbes++; handle = backend.open(file); } + catch (error) { + if (!(error instanceof FixtureDeleteError) || error.stage !== 'admission' || error.win32Error !== 32) throw error; + if (!firstSharing) { firstSharing = error; clock.onSharing?.(); } + const remaining = deadline - clock.now(); + if (remaining <= 0) throw firstSharing; + const before = clock.now(); + clock.wait(Math.min(20, remaining)); + waitedMs += Math.max(0, clock.now() - before); + } + } + let failed = false; + let failure: unknown; + try { + const current = backend.identity(handle, file); + if (current.filesystem !== 'NTFS' || current.dev !== expected.dev || current.ino !== expected.ino + || (current.attributes & (0x1 | 0x10 | 0x400)) !== 0) throw new FixtureDeleteError('identity', file); + verify(); + if (clock.now() >= deadline) throw firstSharing ?? new FixtureDeleteError('admission', file, undefined, true); + backend.dispose(handle, file); + } catch (error) { failed = true; failure = error; } + try { backend.close(handle, file); } + catch (error) { + if (failed) console.error(JSON.stringify({ nativeFixtureDeleteCloseFailure: { + stage: error instanceof FixtureDeleteError ? error.stage : 'close', + win32Error: error instanceof FixtureDeleteError ? error.win32Error : undefined, + } })); + else { failed = true; failure = error; } + } + if (failed) throw failure; + if (!backend.absent(file)) throw new FixtureDeleteError('absence', file); + return { admissionProbes, waitedMs }; +} + +export function createFixtureDeleteLease(deadline: number, onSharing?: () => void) { + if (process.platform !== 'win32') return { unlink: (file: string, _identity: Identity, _verify: () => void) => unlinkSync(file), close: () => {} }; + const kernel = dlopen('kernel32.dll', { + CreateFileW: { args: [FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.u64], returns: FFIType.u64 }, + GetFileInformationByHandle: { args: [FFIType.u64, FFIType.ptr], returns: FFIType.i32 }, + GetVolumeInformationByHandleW: { args: [FFIType.u64, FFIType.ptr, FFIType.u32, FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.u32], returns: FFIType.i32 }, + SetFileInformationByHandle: { args: [FFIType.u64, FFIType.i32, FFIType.ptr, FFIType.u32], returns: FFIType.i32 }, + CloseHandle: { args: [FFIType.u64], returns: FFIType.i32 }, + GetLastError: { args: [], returns: FFIType.u32 }, + }); + const backend: FixtureDeleteBackend = { + open(file) { + const name = Buffer.from(toNamespacedPath(file) + '\0', 'utf16le'); + const handle = kernel.symbols.CreateFileW(ptr(name), 0x10080, 3, null, 3, 0x00200000, 0); + const error = kernel.symbols.GetLastError(); + if (BigInt(handle) === 0xffffffffffffffffn || BigInt(handle) === 0n) throw new FixtureDeleteError('admission', file, error); + return handle; + }, + identity(handle, file) { + const info = Buffer.alloc(52); + if (!kernel.symbols.GetFileInformationByHandle(handle, ptr(info))) throw new FixtureDeleteError('identity', file, kernel.symbols.GetLastError()); + const filesystem = Buffer.alloc(128); + if (!kernel.symbols.GetVolumeInformationByHandleW(handle, null, 0, null, null, null, ptr(filesystem), 64)) throw new FixtureDeleteError('identity', file, kernel.symbols.GetLastError()); + return { dev: BigInt(info.readUInt32LE(28)), ino: (BigInt(info.readUInt32LE(44)) << 32n) | BigInt(info.readUInt32LE(48)), + attributes: info.readUInt32LE(0), filesystem: filesystem.toString('utf16le').split('\0')[0] }; + }, + dispose(handle, file) { + const flags = Buffer.alloc(4); + flags.writeUInt32LE(3); + if (!kernel.symbols.SetFileInformationByHandle(handle, 21, ptr(flags), 4)) throw new FixtureDeleteError('disposition', file, kernel.symbols.GetLastError()); + }, + close(handle, file) { + if (!kernel.symbols.CloseHandle(handle)) throw new FixtureDeleteError('close', file, kernel.symbols.GetLastError()); + }, + absent(file) { + try { lstatSync(file); return false; } + catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return true; throw error; } + }, + }; + return { + unlink(file: string, identity: Identity, verify: () => void) { + if ((identity.mode & 0o170000n) !== 0o100000n) { verify(); unlinkSync(file); return; } + const receipt = deleteWithFixtureLease(file, identity, deadline, verify, backend, { ...leaseClock, onSharing }); + if (receipt.admissionProbes > 1) console.log(JSON.stringify({ nativeFixtureSharingAdmission: { + objectDev: identity.dev.toString(), objectIno: identity.ino.toString(), ...receipt, dispositionCalls: 1, + } })); + }, + close() { kernel.close(); }, + }; +} diff --git a/browse/test/fixtures/native-cookie-file-owners.ts b/browse/test/fixtures/native-cookie-file-owners.ts new file mode 100644 index 000000000..1a0399c09 --- /dev/null +++ b/browse/test/fixtures/native-cookie-file-owners.ts @@ -0,0 +1,132 @@ +import { lstatSync, realpathSync } from 'node:fs'; +import path from 'node:path'; +import { dlopen, FFIType, ptr } from 'bun:ffi'; + +let stage = 'platform'; + +class FileOwnerProbeError extends Error { + errorCode?: string; + constructor(public stage: string, error: unknown) { + super('owner_query_failed'); + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (['EPERM', 'EACCES', 'EBUSY', 'ENOENT', 'EINVAL', 'ENOTDIR', 'ENAMETOOLONG', 'ETIMEDOUT'].includes(code || '')) this.errorCode = code; + } +} + +function inspect() { + if (process.platform !== 'win32' || !['x64', 'arm64'].includes(process.arch)) return { available: false, reason: 'not_windows' }; + stage = 'input_decode'; + const input = JSON.parse(Buffer.from(process.argv[2], 'base64').toString('utf8')); + if (typeof input.root !== 'string' || typeof input.file !== 'string' || !Number.isSafeInteger(input.testPid)) return { available: false, reason: 'invalid_input' }; + stage = 'resolve_root'; + const root = realpathSync(input.root); + stage = 'resolve_file'; + const file = realpathSync(input.file); + const relative = path.relative(root, file); + stage = 'file_stat'; + const initial = lstatSync(input.file, { bigint: true }); + if (relative.startsWith('..') || path.isAbsolute(relative) || !initial.isFile() || initial.isSymbolicLink() + || relative !== path.relative(root, path.resolve(input.file))) return { available: false, reason: 'outside_owned_fixture' }; + if (input.expectedIdentity !== undefined && (input.expectedIdentity?.dev !== initial.dev.toString() + || input.expectedIdentity?.ino !== initial.ino.toString())) return { available: false, reason: 'failed_object_identity_changed' }; + const unchanged = () => { + stage = 'file_recheck'; + const current = lstatSync(input.file, { bigint: true }); + return current.isFile() && !current.isSymbolicLink() && current.dev === initial.dev && current.ino === initial.ino + && realpathSync(input.file) === file; + }; + stage = 'load_restart_manager'; + const restart = dlopen(path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'rstrtmgr.dll'), { + RmStartSession: { args: [FFIType.ptr, FFIType.u32, FFIType.ptr], returns: FFIType.u32 }, + RmRegisterResources: { args: [FFIType.u32, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.ptr], returns: FFIType.u32 }, + RmGetList: { args: [FFIType.u32, FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr], returns: FFIType.u32 }, + RmEndSession: { args: [FFIType.u32], returns: FFIType.u32 }, + }); + stage = 'load_kernel'; + const kernel = dlopen('kernel32.dll', { + OpenProcess: { args: [FFIType.u32, FFIType.i32, FFIType.u32], returns: FFIType.u64 }, + GetProcessTimes: { args: [FFIType.u64, FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr], returns: FFIType.i32 }, + QueryFullProcessImageNameW: { args: [FFIType.u64, FFIType.u32, FFIType.ptr, FFIType.ptr], returns: FFIType.i32 }, + CloseHandle: { args: [FFIType.u64], returns: FFIType.i32 }, + }); + let session: number | undefined; + try { + const sessionBuffer = Buffer.alloc(4); + const key = Buffer.alloc(66); + stage = 'session_start'; + let status = restart.symbols.RmStartSession(ptr(sessionBuffer), 0, ptr(key)); + if (status !== 0) return { available: false, reason: 'session_start', status }; + session = sessionBuffer.readUInt32LE(0); + const wideFile = Buffer.from(file + '\0', 'utf16le'); + const names = Buffer.alloc(8); + names.writeBigUInt64LE(BigInt(ptr(wideFile))); + stage = 'register_file'; + status = restart.symbols.RmRegisterResources(session, 1, ptr(names), 0, null, 0, null); + if (status !== 0) return { available: false, reason: 'register_file', status }; + const needed = Buffer.alloc(4); + const count = Buffer.alloc(4); + const rebootReasons = Buffer.alloc(4); + stage = 'owner_count'; + status = restart.symbols.RmGetList(session, ptr(needed), ptr(count), null, ptr(rebootReasons)); + if (status === 0 && needed.readUInt32LE(0) === 0) return unchanged() + ? { available: true, owners: [], rebootReasons: rebootReasons.readUInt32LE(0) } + : { available: false, reason: 'failed_object_identity_changed' }; + const entries = needed.readUInt32LE(0); + if (status !== 234 || entries < 1 || entries > 64) return { available: false, reason: 'owner_count', status, entries }; + const information = Buffer.alloc(entries * 668); + count.writeUInt32LE(entries); + stage = 'owner_list'; + status = restart.symbols.RmGetList(session, ptr(needed), ptr(count), ptr(information), ptr(rebootReasons)); + const returned = count.readUInt32LE(0); + if (status !== 0 || returned > entries) return { available: false, reason: 'owner_list', status }; + const owners = []; + stage = 'owner_identity'; + for (let index = 0; index < returned; index++) { + const offset = index * 668; + const pid = information.readUInt32LE(offset); + const recordedStart = information.readBigUInt64LE(offset + 4); + let image = 'unavailable'; + let creationMatched = false; + const handle = kernel.symbols.OpenProcess(0x1000, 0, pid); + if (handle) { + try { + const times = Buffer.alloc(32); + const timeAddress = ptr(times); + if (kernel.symbols.GetProcessTimes(handle, timeAddress, timeAddress + 8, timeAddress + 16, timeAddress + 24)) { + creationMatched = times.readBigUInt64LE(0) === recordedStart; + } + if (creationMatched) { + const imageBuffer = Buffer.alloc(65536); + const imageLength = Buffer.alloc(4); + imageLength.writeUInt32LE(32768); + if (kernel.symbols.QueryFullProcessImageNameW(handle, 0, ptr(imageBuffer), ptr(imageLength))) { + const chars = imageLength.readUInt32LE(0); + const name = chars <= 32768 ? path.basename(imageBuffer.subarray(0, chars * 2).toString('utf16le')).toLowerCase() : ''; + image = ['bun.exe', 'node.exe', 'msedge.exe', 'msmpeng.exe', 'mssense.exe', 'dllhost.exe', 'explorer.exe', 'powershell.exe', 'pwsh.exe', 'svchost.exe', 'conhost.exe'].includes(name) ? name : 'other'; + } + } + } finally { + kernel.symbols.CloseHandle(handle); + } + } + owners.push({ pid, image, creationMatched, isTestHost: creationMatched && pid === input.testPid, applicationType: information.readUInt32LE(offset + 652) }); + } + return unchanged() ? { available: true, owners, rebootReasons: rebootReasons.readUInt32LE(0) } + : { available: false, reason: 'failed_object_identity_changed' }; + } catch (error) { + throw new FileOwnerProbeError(stage, error); + } finally { + stage = 'session_end'; + if (session !== undefined) restart.symbols.RmEndSession(session); + stage = 'library_close'; + kernel.close(); restart.close(); + } +} + +let result: object; +try { result = inspect(); } +catch (error) { + const failure = error instanceof FileOwnerProbeError ? error : new FileOwnerProbeError(stage, error); + result = { available: false, reason: 'owner_query_failed', stage: failure.stage, errorCode: failure.errorCode }; +} +process.stdout.write(JSON.stringify(result) + '\n', () => process.exit(0)); diff --git a/browse/test/fixtures/native-cookie-launch.cjs b/browse/test/fixtures/native-cookie-launch.cjs new file mode 100644 index 000000000..b05b94f32 --- /dev/null +++ b/browse/test/fixtures/native-cookie-launch.cjs @@ -0,0 +1,131 @@ +const fs = require('node:fs'); +const cp = require('node:child_process'); +const { createHash } = require('node:crypto'); +const path = require('node:path'); + +module.exports = ({ observation, playwrightEntry, mode = 'normal-close', inspectCommandLine = false, observerExecutable, seedCookie = { name: 'synthetic', value: 'synthetic', domain: 'example.test', path: '/' } }) => { + if (inspectCommandLine && process.platform === 'win32' && typeof observerExecutable !== 'string') throw new Error('Native observer executable is required'); + const originalSpawn = cp.spawn; + let inspected = Promise.resolve(); + let folderEvidence; + const directoryState = (env, root) => ({ + requestedProfile: fs.existsSync(root), + localEnvironment: fs.existsSync(env.LOCALAPPDATA || ''), + roamingEnvironment: fs.existsSync(env.APPDATA || ''), + localUnderProfile: fs.existsSync(path.join(env.USERPROFILE || '', 'AppData', 'Local')), + roamingUnderProfile: fs.existsSync(path.join(env.USERPROFILE || '', 'AppData', 'Roaming')), + }); + const safeFolders = value => Object.fromEntries(['local', 'roaming'].map(name => [name, + Object.fromEntries(['verified', 'dontVerify'].map(kind => { + const item = value?.[name]?.[kind]; + return [kind, { + hresult: Number.isInteger(item?.hresult) ? item.hresult : null, + pathHash: /^[a-f0-9]{64}$/.test(item?.pathHash) ? item.pathHash : null, + exists: typeof item?.exists === 'boolean' ? item.exists : null, + matchesEnvironment: typeof item?.matchesEnvironment === 'boolean' ? item.matchesEnvironment : null, + underUserProfile: typeof item?.underUserProfile === 'boolean' ? item.underUserProfile : null, + }]; + })), + ])); + const runProbe = (input, env) => new Promise(resolve => { + const probe = originalSpawn(observerExecutable, [ + '--no-env-file', '--no-install', '--no-macros', '--config=NUL', path.join(__dirname, 'native-cookie-process-observer.ts'), + Buffer.from(JSON.stringify(input)).toString('base64'), + ], { env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }); + let output = ''; + let stderrBytes = 0; + let spawnFailed = false; + const timer = setTimeout(() => probe.kill(), 5_000); + probe.stdout.on('data', chunk => { output += chunk.toString('utf8'); if (output.length > 16384) probe.kill(); }); + probe.stderr.on('data', chunk => { stderrBytes += chunk.length; }); + probe.once('error', () => { spawnFailed = true; }); + probe.once('close', code => { + clearTimeout(timer); + try { resolve({ measured: JSON.parse(output), code, stderrBytes }); } + catch { resolve({ measured: { available: false, reason: spawnFailed ? 'probe_spawn_failed' : 'probe_no_receipt' }, code, stderrBytes }); } + }); + }); + cp.spawn = function(command, args, options) { + if (args.some(arg => /^--(?:no-sandbox|disable-setuid-sandbox)(?:=|$)/.test(arg))) throw new Error('Native fixture refuses a sandbox-disabled browser'); + const child = originalSpawn.call(this, command, args, options); + const evidence = { + command, args, pid: child.pid, + argsHash: createHash('sha256').update(JSON.stringify(args)).digest('hex'), + envHash: createHash('sha256').update(JSON.stringify(Object.entries(options.env || {}).sort(([a], [b]) => a.localeCompare(b)))).digest('hex'), + stderrBytes: 0, + reasons: [], + runtime: { node: process.version, bun: process.versions.bun || null, architecture: process.arch }, + folderEvidence, + }; + const publish = () => fs.writeFileSync(observation, JSON.stringify(evidence)); + publish(); + let tail = ''; + child.stderr?.on('data', chunk => { + evidence.stderrBytes += chunk.length; + if (evidence.stderrBytes > 65536) return; + const text = tail + chunk.toString('utf8'); + const patterns = { + job_assignment_failed: /AssignProcessToJobObject|failed to (?:assign|create).*job object/i, + sandbox_failed: /sandbox.*(?:failed|error)|SBOX_FATAL/i, + profile_locked: /ProcessSingleton|profile.*in use/i, + default_profile_policy: /remote debugging requires a non-default data directory/i, + permission_denied: /access is denied|ERROR_ACCESS_DENIED|permission denied/i, + crashpad_failed: /crashpad.*(?:failed|error)/i, + missing_dependency: /specified module could not be found|0xc0000135/i, + }; + for (const [reason, pattern] of Object.entries(patterns)) { + if (pattern.test(text) && !evidence.reasons.includes(reason)) evidence.reasons.push(reason); + } + tail = text.slice(-512); + publish(); + }); + child.once('exit', (code, signal) => { evidence.exitCode = code; evidence.signal = signal; publish(); }); + child.once('error', error => { + evidence.spawnError = ['ENOENT', 'EACCES', 'EPERM', 'EINVAL'].includes(error.code) ? error.code : 'spawn_failed'; + publish(); + }); + if (inspectCommandLine && process.platform === 'win32' && Number.isInteger(child.pid)) { + inspected = runProbe({ pid: child.pid, owner: process.pid, image: command }, options.env).then(({ measured, code, stderrBytes }) => { + const expectedHashes = args.map(arg => createHash('sha256').update(arg).digest('hex')); + const dataDir = args.find(arg => arg.startsWith('--user-data-dir='))?.slice(16); + evidence.observedCommandLine = { + available: measured.available === true, + parentMatched: measured.parentMatched === true, + imageMatched: measured.imageMatched === true, + reason: ['not_windows', 'invalid_input', 'process_open', 'process_identity', 'owned_process_unavailable', 'command_line', 'command_line_length', 'command_line_bounds', 'argument_parse', 'argument_bounds', 'job_query', 'observer_initialize', 'probe_spawn_failed', 'probe_no_receipt'].includes(measured.reason) ? measured.reason : undefined, + win32Error: Number.isInteger(measured.win32Error) ? measured.win32Error : undefined, + ntStatus: Number.isInteger(measured.ntStatus) ? measured.ntStatus : undefined, + commandLineHash: /^[a-f0-9]{64}$/.test(measured.commandLineHash) ? measured.commandLineHash : undefined, + argumentsMatchRequested: measured.available === true && JSON.stringify(measured.argumentHashes) === JSON.stringify(expectedHashes), + userDataDirCount: Number.isInteger(measured.userDataDirCount) && measured.userDataDirCount >= 0 && measured.userDataDirCount <= 128 ? measured.userDataDirCount : undefined, + userDataDirMatchesRequested: typeof dataDir === 'string' && measured.userDataDirHash === createHash('sha256').update(dataDir).digest('hex'), + pipePresent: measured.pipePresent === true, + browserInJob: typeof measured.browserInJob === 'boolean' ? measured.browserInJob : undefined, + observerJobLimitFlags: Number.isInteger(measured.observerJobLimitFlags) ? measured.observerJobLimitFlags : undefined, + observerJobQueryError: Number.isInteger(measured.observerJobQueryError) ? measured.observerJobQueryError : undefined, + exitCode: code, stderrBytes, + }; + evidence.folderEvidence.afterLaunch = safeFolders(measured.knownFolders); + evidence.folderEvidence.directoriesAfterLaunch = directoryState(options.env, args.find(arg => arg.startsWith('--user-data-dir='))?.slice(16) || ''); + publish(); + }); + } + return child; + }; + const { chromium } = require(playwrightEntry); + return { chromium: { async launchPersistentContext(root, options) { + if (options.chromiumSandbox !== true) throw new Error('Native fixture requires the browser sandbox'); + const started = Date.now(); + if (inspectCommandLine && process.platform === 'win32') { + const directoriesBefore = directoryState(options.env, root); + const before = await runProbe({ mode: 'known-folders' }, options.env); + folderEvidence = { beforeLaunch: safeFolders(before.measured.knownFolders), directoriesBefore, directoriesAfterProbe: directoryState(options.env, root) }; + } + const context = await chromium.launchPersistentContext(root, inspectCommandLine && process.platform === 'win32' + ? { ...options, timeout: Math.max(1, options.timeout - (Date.now() - started)) } : options); + await inspected; + await context.addCookies([seedCookie]); + if (mode === 'stalled-close') context.close = () => { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0); }; + return context; + } } }; +}; diff --git a/browse/test/fixtures/native-cookie-process-observer.ts b/browse/test/fixtures/native-cookie-process-observer.ts new file mode 100644 index 000000000..3eaaa967d --- /dev/null +++ b/browse/test/fixtures/native-cookie-process-observer.ts @@ -0,0 +1,150 @@ +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 = 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)); +} diff --git a/browse/test/fixtures/native-cookie-process.cjs b/browse/test/fixtures/native-cookie-process.cjs new file mode 100644 index 000000000..b6530d680 --- /dev/null +++ b/browse/test/fixtures/native-cookie-process.cjs @@ -0,0 +1,13 @@ +module.exports = ({ pidsFile, mode }) => ({ + chromium: { + async launchPersistentContext() { + const child = require('node:child_process').spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + stdio: 'ignore', + detached: true, + }); + require('node:fs').writeFileSync(pidsFile, JSON.stringify([process.pid, child.pid])); + if (mode === 'worker-crash') setTimeout(() => process.exit(3), 100); + await new Promise(() => {}); + }, + }, +}); diff --git a/browse/test/fixtures/native-cookie-remove-fixture.cjs b/browse/test/fixtures/native-cookie-remove-fixture.cjs new file mode 100644 index 000000000..cf2338c83 --- /dev/null +++ b/browse/test/fixtures/native-cookie-remove-fixture.cjs @@ -0,0 +1,42 @@ +const fs = require('node:fs'); +const path = require('node:path'); +let stage = 'input'; +let root; +const samePath = (a, b) => process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b; +try { + const input = JSON.parse(Buffer.from(process.argv[2], 'base64').toString('utf8')); + root = input.root; + if (typeof root !== 'string' || typeof input.realpath !== 'string' || typeof input.dev !== 'string' || typeof input.ino !== 'string') throw new Error('invalid_input'); + stage = 'identity'; + const state = fs.lstatSync(root, { bigint: true }); + const identity = { + directory: state.isDirectory() && !state.isSymbolicLink(), + pathMatches: samePath(fs.realpathSync(root), input.realpath), + deviceMatches: state.dev.toString() === input.dev, + inodeMatches: state.ino.toString() === input.ino, + }; + if (Object.values(identity).some(value => !value)) { + console.log(JSON.stringify({ removed: false, reason: 'identity_mismatch', identity })); + process.exitCode = 1; + } else { + stage = 'remove'; + fs.rmSync(root, { recursive: true, force: true, maxRetries: 0 }); + console.log(JSON.stringify({ removed: !fs.existsSync(root), identity, retries: 0 })); + } +} catch (error) { + const code = ['EPERM', 'EACCES', 'EBUSY', 'ENOENT', 'EINVAL', 'ENOTEMPTY', 'ENOTDIR'].includes(error.code) ? error.code : 'filesystem_error'; + let failingPath = null; + let metadata = null; + if (typeof root === 'string' && typeof error.path === 'string') { + const relative = path.relative(root, error.path); + if (!relative.startsWith('..') && !path.isAbsolute(relative)) { + failingPath = relative || '.'; + try { + const state = fs.lstatSync(error.path); + metadata = { mode: state.mode, directory: state.isDirectory(), link: state.isSymbolicLink() }; + } catch {} + } + } + console.log(JSON.stringify({ removed: false, stage, code, failingPath, metadata })); + process.exitCode = 1; +} diff --git a/browse/test/security-audit-r2.test.ts b/browse/test/security-audit-r2.test.ts index fc827af53..c079099e3 100644 --- a/browse/test/security-audit-r2.test.ts +++ b/browse/test/security-audit-r2.test.ts @@ -6,7 +6,7 @@ * that could silently remove a fix without breaking compilation. */ -import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; +import { describe, it, expect, beforeAll, afterAll, spyOn } from 'bun:test'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; @@ -364,11 +364,38 @@ describe('cookie-import domain validation', () => { expect(block).toContain('does not match current page domain'); }); - it('cookie-import-browser handler validates --domain against page hostname', () => { - const block = sliceBetween(WRITE_SRC, "case 'cookie-import-browser':", "case 'style':"); - expect(block).toContain('normalizedDomain'); - expect(block).toContain('pageHostname'); - expect(block).toContain('does not match current page domain'); + it('cookie-import-browser handler validates --domain against page hostname', async () => { + const operation = await import('../src/cookie-import-operation'); + const { handleWriteCommand } = await import('../src/write-commands'); + const imported = spyOn(operation, 'runCookieImport').mockResolvedValue({ + browser: 'chromium', profile: 'Profile 2', imported: 2, failed: 0, + domainCounts: { '.example.test': 2 }, failureReasons: {}, outcome: 'imported', + reset: 'not_requested', verification: { verified: false, reason: 'not_requested' }, message: 'Cookie copy complete.', + }); + let currentUrl = 'https://example.test'; + const page = { url: () => currentUrl, isClosed: () => false }; + const session = { getPage: () => page, getActiveFrameOrPage: () => page, getFrame: () => null } as any; + const manager = { trackCookieImportDomains() {} } as any; + try { + for (const [target, domain] of [ + ['https://example.test', 'unrelated.test'], + ['https://example.test.evil.invalid', 'example.test'], + ['https://badexample.test', 'example.test'], + ]) { + currentUrl = target; + await expect(handleWriteCommand('cookie-import-browser', ['chromium', '--domain', domain], session, manager)) + .rejects.toMatchObject({ code: 'target_mismatch' }); + } + expect(imported).not.toHaveBeenCalled(); + currentUrl = 'https://sub.example.test/protected'; + const result = await handleWriteCommand('cookie-import-browser', ['chromium', '--domain', '.Example.Test.', '--profile', 'Profile 2'], session, manager); + expect(imported).toHaveBeenCalledTimes(1); + expect(imported.mock.calls[0][0]).toMatchObject({ browser: 'chromium', domains: ['example.test'], profile: 'Profile 2' }); + expect(imported.mock.calls[0][1]).toEqual({ page, url: currentUrl }); + expect(result).toContain('Imported 2 cookies from chromium (profile: Profile 2)'); + } finally { + imported.mockRestore(); + } }); }); diff --git a/browse/test/windows-spawn-hide.test.ts b/browse/test/windows-spawn-hide.test.ts index 10d538aae..cfdddf205 100644 --- a/browse/test/windows-spawn-hide.test.ts +++ b/browse/test/windows-spawn-hide.test.ts @@ -42,10 +42,13 @@ describe('windowsHide on Windows-reachable spawns (#1835)', () => { // isProcessAlive no longer spawns anything (signal-0 on every platform, // #1952) — process-liveness-windows.test.ts pins that it stays // subprocess-free, which is stronger than hiding a window. - // powershell DPAPI + tasklist in cookie import. const cookie = SRC('cookie-import-browser.ts'); expectHideNearEvery(cookie, "'powershell'"); - expectHideNearEvery(cookie, "'tasklist'"); + expect(cookie).not.toContain("'tasklist'"); + expectHideNearEvery(SRC('cookie-import-native.ts'), 'spawn(bunExecutable'); + const worker = SRC('cookie-import-native-worker.ts'); + expectHideNearEvery(worker, 'spawn(process.execPath'); + expectHideNearEvery(worker, 'spawn(input.request.nodeExecutable'); }); test('icacls calls in file-permissions.ts pass windowsHide', () => { diff --git a/docs/skills.md b/docs/skills.md index a23090dc0..6360f4177 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -28,7 +28,7 @@ Detailed guides for every gstack skill — philosophy, workflow, and examples. | [`/document-generate`](#document-generate) | **Technical Writer** | Generate Diataxis docs (tutorial / how-to / reference / explanation) for a feature from code. | | [`/retro`](#retro) | **Eng Manager** | Team-aware weekly retro. Per-person breakdowns, shipping streaks, test health trends, growth opportunities. | | [`/browse`](#browse) | **QA Engineer** | Give the agent eyes. Drives your Aside browser first — real sessions, real clicks, real screenshots — through deterministic `aside repl` scripts, and falls back to gstack's own Chromium (~100ms per command) when Aside isn't there. | -| [`/setup-browser-cookies`](#setup-browser-cookies) | **Session Manager** | Fallback-browser skill: import cookies from your real browser (Chrome, Arc, Brave, Edge) into gstack's headless session to test authenticated pages. Unnecessary on Aside, which already has your sessions. | +| [`/setup-browser-cookies`](#setup-browser-cookies) | **Session Manager** | Copy selected cookies from Chrome, Chromium, Brave, Edge, or macOS-only Comet, Arc, and Dia into the fallback browser. Choose your profile and domains; check sign-in separately. Unnecessary on Aside, which already has your sessions. | | [`/autoplan`](#autoplan) | **Review Pipeline** | One command, fully reviewed plan. Runs CEO → design → DX → eng review automatically (eng always last, so the shipping gate reviews the final amended plan) with encoded decision principles. Surfaces only taste decisions for your approval. | | [`/plan-devex-review`](#plan-devex-review) | **DX Reviewer** | Plan-stage DX review. TTHW (time-to-hello-world), magical moments, friction points, persona traces. Three modes: Expansion, Polish, Triage. | | [`/devex-review`](#devex-review) | **DX Reviewer (live)** | Live developer experience audit. Walks the actual onboarding flow, measures TTHW, catches the docs lies. | @@ -949,31 +949,35 @@ The browser preserves all state across the handoff, and after `resume` the agent This is my **session manager mode** — for the fallback browser. With Aside open, `/qa` and `/browse` already run in your real sessions and this skill has nothing to do. -Before `/qa` or `/browse` can test authenticated pages on gstack's own browser, they need cookies. Instead of manually logging in through the headless browser every time, `/setup-browser-cookies` imports your real sessions directly from your daily browser. +For authenticated testing on gstack's own browser, `/setup-browser-cookies` copies selected cookies from your daily browser. Sites may also need storage or a fresh login, so copying cookies is not proof that the session works. -It auto-detects installed Chromium browsers (Comet, Chrome, Arc, Brave, Edge), decrypts cookies via the macOS Keychain, and loads them into the Playwright session. An interactive picker UI lets you choose exactly which domains to import — no cookie values are ever displayed. +The picker detects Chrome, Chromium, Brave, Edge, and macOS-only Comet, Arc, and Dia. Choose the browser, account/profile, and domains. Profile labels use the current `Local State` name with a directory discriminator, so renamed profiles and duplicate names are distinguishable. No cookie values are displayed; source/profile labels are still sensitive. ``` You: /setup-browser-cookies -Claude: Cookie picker opened — select the domains you want to import - in your browser, then tell me when you're done. +Claude: Cookie picker opened. Select your browser, profile, and domains, + then tell me when you're done. - [You pick github.com, myapp.com in the browser UI] + [You choose a browser/profile and pick github.com, myapp.com] You: done -Claude: Imported 2 domains (47 cookies). Session is ready. +Claude: Imported 2 domains (47 cookies). Sign-in has not been checked. ``` -Or skip the UI entirely: +For direct import, select the browser and profile first and navigate to a matching target. Do not infer an account from the CLI's legacy Comet default: ``` -You: /setup-browser-cookies github.com +You: /setup-browser-cookies github.com from Chrome, Profile 2 -Claude: Imported 12 cookies for github.com from Comet. +Claude: Imported 12 cookies; sign-in has not been checked. ``` +`--verify-auth` is explicit and requires a selector and expected identity configured privately in the daemon environment before startup. It checks one exact visible identity on the captured target, not just HTTP 200 or a cookie count. Missing configuration fails before mutation. `--clear-storage` is separate, opt-in recovery for Chromium targets: it clears only the captured origin's localStorage (shared across that origin's tabs) and the target tab's sessionStorage in an isolated world with a native deadline. Other target engines retain import/auth checks but reject reset. It is never automatic and cannot be combined with `--all`. Partial imports and unsuccessful checks remain visible rather than becoming a false "ready." + +macOS may prompt for Keychain approval; Linux uses its supported keyring/fallback paths; Windows can import DPAPI-compatible cookies, but native App-Bound Encryption extraction remains disabled pending qualification. Closing Chrome does not bypass Chrome 136+ default-directory protection. Use manual sign-in in the headed fallback browser when needed and a display is available, never a TCP downgrade or real-profile copy. Full flags, configuration, and privacy guidance: [cookie import reference](../BROWSER.md#choosing-a-source-and-checking-sign-in). + --- ## `/make-pdf` diff --git a/gstack/llms.txt b/gstack/llms.txt index 71f6d2675..87a41fa7e 100644 --- a/gstack/llms.txt +++ b/gstack/llms.txt @@ -97,7 +97,7 @@ Run with `browse [args]`. Full reference: `browse/SKILL.md`. - `click `: Click element - `cookie =`: Set cookie on current page domain - `cookie-import `: Import cookies from JSON file -- `cookie-import-browser [browser] [--domain d] [--profile p] [--all]`: Import cookies from installed Chromium-family browsers. +- `cookie-import-browser [browser] [--domain d] [--profile p] [--all] [--clear-storage] [--verify-auth]`: Copy cookies from chrome, chromium, brave, edge, or macOS-only comet, arc, dia. - `dialog-accept [text]`: Auto-accept next alert/confirm/prompt. - `dialog-dismiss`: Auto-dismiss next dialog - `fill `: Fill input diff --git a/lib/eval-model.ts b/lib/eval-model.ts index 33dd7b590..75abe33cc 100644 --- a/lib/eval-model.ts +++ b/lib/eval-model.ts @@ -19,6 +19,7 @@ */ export const CLAUDE_FRONTIER_EVAL_MODEL = "claude-fable-5-1"; +export const DEFAULT_JUDGE_MAX_TOKENS = 8192; // `as const satisfies` keeps EvalModelKind the literal union // 'capture' | 'warmup' | 'distill' — a `Record` annotation diff --git a/package.json b/package.json index f95917af3..456eb7d62 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.89.1", + "version": "1.90.0", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", @@ -43,7 +43,7 @@ "start": "bun run browse/src/server.ts", "eval:bg": "bin/gstack-detach --label evals --lock gstack-evals --timeout 5400 -- bun run test:evals", "eval:bg:all": "bin/gstack-detach --label evals-all --lock gstack-evals --timeout 7200 -- bun run test:evals:all", - "eval:bg:gate": "bin/gstack-detach --label evals-gate --lock gstack-evals --timeout 33600 -- bun run test:gate:sharded", + "eval:bg:gate": "bin/gstack-detach --label evals-gate --lock gstack-evals --timeout 33800 -- bun run test:gate:sharded", "eval:bg:periodic": "bin/gstack-detach --label evals-periodic --lock gstack-evals --timeout 66000 -- bun run test:periodic:sharded", "eval:list": "bun run scripts/eval-list.ts", "eval:compare": "bun run scripts/eval-compare.ts", diff --git a/scripts/eval-flake-rank.ts b/scripts/eval-flake-rank.ts index b6698ae48..3e6028a10 100644 --- a/scripts/eval-flake-rank.ts +++ b/scripts/eval-flake-rank.ts @@ -22,6 +22,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { getProjectEvalDir, isPartialEval, isFinalizedEvalResultFile, type EvalResult } from '../test/helpers/eval-store'; +import { evalEntryOutcome } from '../test/helpers/eval-store'; import { flakeLedgerPath, type FlakeLedgerEntry } from './test-free-shards'; interface TestSeries { @@ -29,6 +30,7 @@ interface TestSeries { runs: number; passes: number; fails: number; + manualAccepted: number; retriedPasses: number; totalAttempts: number; totalCostUsd: number; @@ -54,14 +56,18 @@ export function aggregate(evalFiles: string[]): Map { } for (const [name, entries] of byName) { const s = series.get(name) ?? { - name, runs: 0, passes: 0, fails: 0, retriedPasses: 0, + name, runs: 0, passes: 0, fails: 0, manualAccepted: 0, retriedPasses: 0, totalAttempts: 0, totalCostUsd: 0, totalDurationMs: 0, lastSeen: '', }; const final = entries[entries.length - 1]; - s.runs += 1; s.totalAttempts += entries.length; - if (final.passed) s.passes += 1; else s.fails += 1; - if (final.passed && entries.length > 1) s.retriedPasses += 1; + const outcome = evalEntryOutcome(final); + if (outcome === 'manual-review') s.manualAccepted += 1; + else { + s.runs += 1; + if (outcome === 'passed') s.passes += 1; else s.fails += 1; + if (outcome === 'passed' && entries.length > 1) s.retriedPasses += 1; + } for (const e of entries) { s.totalCostUsd += e.cost_usd || 0; s.totalDurationMs += e.duration_ms || 0; @@ -115,21 +121,21 @@ if (import.meta.main) { const files = collectEvalFiles(dir, sinceDays); const series = [...aggregate(files).values()] - .sort((a, b) => b.retriedPasses - a.retriedPasses || (b.fails / b.runs) - (a.fails / a.runs)); + .sort((a, b) => b.retriedPasses - a.retriedPasses || (b.fails / Math.max(1, b.runs)) - (a.fails / Math.max(1, a.runs))); const ledger = readFreeLedger(); if (asJson) { console.log(JSON.stringify({ dir, runsScanned: files.length, tests: series, freeLedger: ledger }, null, 2)); } else { console.log(`flake-rank: ${files.length} finalized run file(s) under ${dir}`); - const flaky = series.filter((s) => s.retriedPasses > 0 || s.fails > 0); + const flaky = series.filter((s) => s.retriedPasses > 0 || s.fails > 0 || s.manualAccepted > 0); if (flaky.length === 0) { console.log(' no retried passes and no failures recorded — clean series'); } else { - console.log(' retries fails/runs avg-dur test'); + console.log(' retries fails/runs manual avg-dur test'); for (const s of flaky.slice(0, 30)) { - console.log(` ${String(s.retriedPasses).padStart(7)} ${String(s.fails).padStart(5)}/${String(s.runs).padEnd(4)} ` - + `${Math.round(s.totalDurationMs / s.totalAttempts / 1000).toString().padStart(5)}s ${s.name}`); + console.log(` ${String(s.retriedPasses).padStart(7)} ${String(s.fails).padStart(5)}/${String(s.runs).padEnd(6)} ` + + `${String(s.manualAccepted).padStart(6)} ${Math.round(s.totalDurationMs / s.totalAttempts / 1000).toString().padStart(5)}s ${s.name}`); } } if (ledger.length > 0) { diff --git a/scripts/eval-list.ts b/scripts/eval-list.ts index 38c14e7f2..d44f30ee9 100644 --- a/scripts/eval-list.ts +++ b/scripts/eval-list.ts @@ -7,7 +7,7 @@ */ import * as fs from 'fs'; -import { getProjectEvalDir, listEvalJsonFiles } from '../test/helpers/eval-store'; +import { evalEntryOutcome, getProjectEvalDir, listEvalJsonFiles } from '../test/helpers/eval-store'; const EVAL_DIR = getProjectEvalDir(); @@ -52,6 +52,7 @@ interface RunSummary { tier: string; version: string; passed: number; + manual: Array<{ name: string; approvedBy: string; approvalUrl: string }>; total: number; cost: number; duration: number; @@ -65,13 +66,19 @@ for (const file of files) { if (filterBranch && data.branch !== filterBranch) continue; if (filterTier && data.tier !== filterTier) continue; const totalTurns = (data.tests || []).reduce((s: number, t: any) => s + (t.turns_used || 0), 0); + const tests = Array.isArray(data.tests) ? data.tests : null; + const final = tests ? [...new Map(tests.map((t: any) => [t.name, t] as const)).values()] : []; + const manual = final.filter((t: any) => evalEntryOutcome(t) === 'manual-review').map((t: any) => ({ + name: t.name, approvedBy: t.manual_review.approval.approved_by, approvalUrl: t.manual_review.approval.approval_url, + })); runs.push({ file, timestamp: data.timestamp || '', branch: data.branch || 'unknown', tier: data.tier || 'unknown', version: data.version || '?', - passed: data.passed || 0, + passed: tests ? tests.filter((t: any) => evalEntryOutcome(t) === 'passed').length : data.passed || 0, + manual, total: data.total_tests || 0, cost: data.total_cost_usd || 0, duration: data.total_duration_ms || 0, @@ -110,7 +117,8 @@ for (const run of displayed) { const cost = `$${run.cost.toFixed(2)}`.padEnd(8); const turns = run.turns > 0 ? `${run.turns}t`.padEnd(7) : ''.padEnd(7); const dur = run.duration > 0 ? `${Math.round(run.duration / 1000)}s`.padEnd(10) : ''.padEnd(10); - console.log(` ${date.padEnd(17)}${branch}${run.tier.padEnd(12)}${pass}${cost}${turns}${dur}v${run.version}`); + const manual = run.manual.map(entry => `MANUAL/unscored ${entry.name}: approved by ${entry.approvedBy} (${entry.approvalUrl})`).join('; '); + console.log(` ${date.padEnd(17)}${branch}${run.tier.padEnd(12)}${pass}${cost}${turns}${dur}v${run.version}${manual ? ` ${manual}` : ''}`); } console.log('─'.repeat(105)); diff --git a/scripts/eval-summary.ts b/scripts/eval-summary.ts index 5122b165d..6aae44d97 100644 --- a/scripts/eval-summary.ts +++ b/scripts/eval-summary.ts @@ -8,7 +8,7 @@ import * as fs from 'fs'; import type { EvalResult } from '../test/helpers/eval-store'; -import { getProjectEvalDir, listEvalJsonFiles } from '../test/helpers/eval-store'; +import { evalEntryOutcome, getProjectEvalDir, listEvalJsonFiles } from '../test/helpers/eval-store'; const EVAL_DIR = getProjectEvalDir(); @@ -77,11 +77,21 @@ const avgDetection = detectionRates.length > 0 // Flaky tests (passed in some runs, failed in others) const testResults = new Map(); +const manualAccepted: Array<{ name: string; approvedBy: string; approvalUrl: string }> = []; for (const r of results) { + const final = new Map(r.tests.map(t => [t.name, t])); + const manuallyAcceptedNames = new Set([...final.values()].filter(t => evalEntryOutcome(t) === 'manual-review').map(t => t.name)); + for (const t of final.values()) { + if (evalEntryOutcome(t) === 'manual-review') manualAccepted.push({ name: t.name, + approvedBy: t.manual_review!.approval.approved_by, approvalUrl: t.manual_review!.approval.approval_url }); + } for (const t of r.tests) { + if (manuallyAcceptedNames.has(t.name)) continue; const key = `${r.tier}:${t.name}`; + const outcome = evalEntryOutcome(t); + if (outcome === 'manual-review') continue; if (!testResults.has(key)) testResults.set(key, []); - testResults.get(key)!.push(t.passed); + testResults.get(key)!.push(outcome === 'passed'); } } const flakyTests: string[] = []; @@ -119,6 +129,10 @@ console.log('Eval Summary'); console.log('═'.repeat(70)); console.log(` Total runs: ${results.length} (${e2eRuns.length} e2e, ${judgeRuns.length} llm-judge)`); console.log(` Total spend: $${totalCost.toFixed(2)}`); +if (manualAccepted.length) { + console.log(` Manual accepted: ${manualAccepted.length} unscored provider refusal(s)`); + for (const entry of manualAccepted) console.log(` ${entry.name}: approved by ${entry.approvedBy} (${entry.approvalUrl})`); +} console.log(` Avg cost/e2e: $${avgE2ECost.toFixed(2)}`); console.log(` Avg cost/judge: $${avgJudgeCost.toFixed(2)}`); if (avgE2EDuration > 0) { diff --git a/scripts/eval-watch.ts b/scripts/eval-watch.ts index 9ca86044f..051a86d1d 100644 --- a/scripts/eval-watch.ts +++ b/scripts/eval-watch.ts @@ -11,7 +11,8 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { getProjectEvalDir } from '../test/helpers/eval-store'; +import { evalEntryOutcome, getProjectEvalDir } from '../test/helpers/eval-store'; +import type { EvalTestEntry } from '../test/helpers/eval-store'; const GSTACK_DEV_DIR = path.join(os.homedir(), '.gstack-dev'); // Heartbeat + per-run progress logs are GLOBAL by design — session-runner.ts @@ -38,16 +39,7 @@ export interface HeartbeatData { } export interface PartialData { - tests: Array<{ - name: string; - suite?: string; - attempt?: number; - passed: boolean; - cost_usd: number; - duration_ms: number; - turns_used?: number; - exit_reason?: string; - }>; + tests: Array & Pick>; total_cost_usd: number; _partial?: boolean; } @@ -120,12 +112,14 @@ export function renderDashboard(heartbeat: HeartbeatData | null, partial: Partia // Completed tests from partial if (partial?.tests) { for (const t of partial.tests) { - const icon = t.passed ? '\u2713' : '\u2717'; + const manual = evalEntryOutcome(t) === 'manual-review'; + const icon = manual ? 'M' : evalEntryOutcome(t) === 'passed' ? '\u2713' : '\u2717'; const cost = `$${t.cost_usd.toFixed(2)}`; const dur = `${Math.round(t.duration_ms / 1000)}s`; const turns = t.turns_used !== undefined ? `${t.turns_used} turns` : ''; const name = t.name.length > 30 ? t.name.slice(0, 27) + '...' : t.name.padEnd(30); - lines.push(` ${icon} ${name} ${cost.padStart(6)} ${dur.padStart(5)} ${turns}`); + const approval = manual ? ` MANUAL/unscored; approved by ${t.manual_review!.approval.approved_by} (${t.manual_review!.approval.approval_url})` : ''; + lines.push(` ${icon} ${name} ${cost.padStart(6)} ${dur.padStart(5)} ${turns}${approval}`); } } @@ -151,6 +145,8 @@ export function renderDashboard(heartbeat: HeartbeatData | null, partial: Partia const totalCost = partial?.total_cost_usd || 0; const running = heartbeat?.status === 'running' ? 1 : 0; lines.push(` Completed: ${completedCount} Running: ${running} Cost: $${totalCost.toFixed(2)} Elapsed: ${formatDuration(elapsed)}`); + const manualAccepted = partial?.tests?.filter(t => evalEntryOutcome(t) === 'manual-review').length ?? 0; + if (manualAccepted) lines.push(` Manual accepted: ${manualAccepted} unscored provider refusal(s)`); if (heartbeat?.runId) { const logPath = path.join(GSTACK_DEV_DIR, 'e2e-runs', heartbeat.runId, 'progress.log'); diff --git a/scripts/test-paid-shards.ts b/scripts/test-paid-shards.ts index 5ee39e87d..69dc55498 100644 --- a/scripts/test-paid-shards.ts +++ b/scripts/test-paid-shards.ts @@ -66,7 +66,8 @@ import { import { PAID_TEST_GLOBS, isPaidTestFile } from '../test/helpers/paid-test-set'; import { PERIODIC_CI_EXCLUDE } from '../test/helpers/periodic-exclude-data'; import { AUTOPLAN_CHAIN_BUDGET, FILE_RETRY_BUDGETS, STRICT_RETRY_CASE_BUDGETS } from '../test/helpers/eval-budgets'; -import { getProjectEvalDir, getClaudeCliVersion, isFinalizedEvalResultFile } from '../test/helpers/eval-store'; +import { getProjectEvalDir, getClaudeCliVersion, isFinalizedEvalResultFile, evalEntryOutcome } from '../test/helpers/eval-store'; +import { manualReviewProblem } from '../test/helpers/cookie-workflow-manual-review'; import { preflightAnthropicApi } from '../test/helpers/anthropic-preflight'; import { OVERLAY_MIN_FILE_WALL_MS } from '../test/helpers/overlay-case-policy'; import { PR_PROFILE_CASE_IDS, PR_PROFILE_FILES, packageChangeOnlyVersion, selectPrProfile, type PrProfileSelection } from './test-pr-profile'; @@ -1314,19 +1315,20 @@ export function formatProfileCoverage(manifest: PaidRunManifest): string[] { /** Final outcomes use each case's last attempt; the attempt total stays visible. */ export function collectorOutcomeCounts(results: Array<{ tests?: Array<{ - name: string; suite?: string; passed: boolean; execution?: string; -}> }>): { executed: number; reused: number; passed: number; failed: number; attempts: number } { - const counts = { executed: 0, reused: 0, passed: 0, failed: 0, attempts: 0 }; + name: string; suite?: string; passed: boolean; execution?: string; manual_review?: unknown; +}> }>): { executed: number; reused: number; passed: number; failed: number; manual_accepted: number; attempts: number } { + const counts = { executed: 0, reused: 0, passed: 0, failed: 0, manual_accepted: 0, attempts: 0 }; for (const result of results) { const cases = new Map[number]>(); for (const entry of result.tests ?? []) { - if (typeof entry.name !== 'string' || typeof entry.passed !== 'boolean') continue; + if (!entry || typeof entry !== 'object' || typeof entry.name !== 'string' || typeof entry.passed !== 'boolean') continue; counts.attempts++; cases.set(`${entry.suite ?? ''}\0${entry.name}`, entry); } for (const entry of cases.values()) { counts[entry.execution === 'reused' ? 'reused' : 'executed']++; - counts[entry.passed ? 'passed' : 'failed']++; + const outcome = evalEntryOutcome(entry); + counts[outcome === 'manual-review' ? 'manual_accepted' : outcome]++; } } return counts; @@ -1479,6 +1481,8 @@ async function main(): Promise { // ── Report mode: reconcile slice artifacts against the manifest. Fail-closed: // a slice whose artifact never landed is a FAILURE, not an absence. if (options.reportDir) { + const summaryPath = path.join(options.reportDir, 'collector-outcomes.json'); + fs.rmSync(summaryPath, { force: true }); const manifest = parseRunManifest(fs.readFileSync(path.join(options.reportDir, 'manifest.json'), 'utf-8')); const results: SliceResult[] = fs.readdirSync(options.reportDir) .filter((name) => /^slice-\d+\.json$/.test(name)) @@ -1498,16 +1502,54 @@ async function main(): Promise { // Source: the finalized eval-store JSONs inside the slice artifacts. const flaky: Array<{ name: string; attempts: number; file: string }> = []; const collectors: Parameters[0] = []; + const files: Array<{ file: string; tier: string; shard: string | number; cost: number; + flaky: number; total: number; executed: number; reused: number; passed: number; + failed: number; manual_accepted: number; attempts: number }> = []; + const manualProblems: string[] = []; + const manualClaims = new Map(); for (const name of fs.readdirSync(options.reportDir, { recursive: true }) as string[]) { if (!isFinalizedEvalResultFile(name)) continue; try { const parsed = JSON.parse(fs.readFileSync(path.join(options.reportDir, name), 'utf-8')); - if (Array.isArray(parsed.tests)) collectors.push(parsed); + if (!Array.isArray(parsed.tests)) { + if (Object.hasOwn(parsed, 'tests') || parsed.total_tests !== undefined || parsed.manual_review !== undefined) { + manualProblems.push(`${name}: malformed collector tests[]`); + } + continue; + } + const seen = new Map(); + for (const [index, entry] of parsed.tests.entries()) { + if (!entry || typeof entry !== 'object' || typeof entry.name !== 'string' || !entry.name + || typeof entry.passed !== 'boolean') { + manualProblems.push(`${name}: attempt ${index + 1}: malformed collector entry (name/passed required)`); + continue; + } + const key = `${entry.suite ?? ''}\0${entry.name}`; + const occurrence = (seen.get(key) ?? 0) + 1; + seen.set(key, occurrence); + if (Object.hasOwn(entry, 'manual_review') && occurrence !== 1) { + manualProblems.push(`${name}: attempt ${index + 1}: manual review is only valid on the first case attempt`); + } + if (Object.hasOwn(entry, 'manual_review')) { + const previous = manualClaims.get(key); + if (previous && previous !== name) manualProblems.push(`${name}: duplicate manual-review claim for ${entry.name} (also in ${previous})`); + else manualClaims.set(key, name); + } + const problem = manualReviewProblem(entry, ROOT); + if (problem) manualProblems.push(`${name}: attempt ${index + 1}: ${problem}`); + } + collectors.push(parsed); + const counts = collectorOutcomeCounts([parsed]); + files.push({ file: name, tier: parsed.tier ?? 'unknown', shard: parsed.shard ?? '-', + cost: parsed.total_cost_usd ?? 0, flaky: parsed.flaky_retries?.length ?? 0, + total: counts.passed + counts.failed + counts.manual_accepted, ...counts }); for (const f of parsed.flaky_retries ?? []) flaky.push({ ...f, file: name }); - } catch { /* non-eval JSON — not this report's business */ } + } catch (error) { + manualProblems.push(`${name}: malformed collector JSON (${error instanceof Error ? error.message : String(error)})`); + } } const evidence = collectorOutcomeCounts(collectors); - console.log(`[test:paid] collector final outcomes: ${evidence.executed} executed, ${evidence.reused} reused; ${evidence.passed} passed, ${evidence.failed} failed (${evidence.attempts} attempt records from ${collectors.length} collectors)`); + console.log(`[test:paid] collector final outcomes: ${evidence.executed} executed, ${evidence.reused} reused; ${evidence.passed} passed, ${evidence.failed} failed, ${evidence.manual_accepted} manual accepted (unscored; no score-cache credit) (${evidence.attempts} attempt records from ${collectors.length} collectors)`); if (flaky.length > 0) { console.log(`[test:paid] report: ⚠ ${flaky.length} cases with multiple attempts this run:`); for (const f of flaky) console.log(` ⚠ ${f.name} (x${f.attempts}) — ${f.file}`); @@ -1525,12 +1567,24 @@ async function main(): Promise { console.log(` ⚠ ${outcome.files.join(' ')} (${outcome.executedTests} skipped — external service missing or tier mismatch)`); } } - if (!verdict.ok) { + if (manualProblems.length) verdict.problems.push(...manualProblems); + if (evidence.failed > 0) verdict.problems.push(`${evidence.failed} unapproved final collector failure(s)`); + if (files.reduce((sum, file) => sum + file.total, 0) !== evidence.passed + evidence.failed + evidence.manual_accepted + || files.reduce((sum, file) => sum + file.executed + file.reused, 0) !== evidence.executed + evidence.reused) { + verdict.problems.push('Collector summary totals are inconsistent'); + } + if (!manualProblems.length) fs.writeFileSync(summaryPath, JSON.stringify({ version: 1, files, totals: { + ...evidence, total: evidence.passed + evidence.failed + evidence.manual_accepted, + flaky: files.reduce((sum, file) => sum + file.flaky, 0), + } }, null, 2) + '\n'); + if (verdict.problems.length) { console.error(`[test:paid] report: ${verdict.problems.length} problem(s):`); for (const problem of verdict.problems) console.error(` ✗ ${problem}`); return 1; } - console.log('[test:paid] report: every planned shard accounted and passed'); + console.log(evidence.manual_accepted + ? `[test:paid] report: every planned shard accounted; ${evidence.manual_accepted} manual acceptance(s), no automated-score credit` + : '[test:paid] report: every planned shard accounted and passed'); return 0; } diff --git a/setup-browser-cookies/SKILL.md b/setup-browser-cookies/SKILL.md index b873e38da..373242d75 100644 --- a/setup-browser-cookies/SKILL.md +++ b/setup-browser-cookies/SKILL.md @@ -152,26 +152,9 @@ Skills that run plan reviews (`/plan-*-review`, `/codex review`) include the EXI # Setup Browser Cookies -Import logged-in sessions from your real Chromium browser into the headless browse session. +## 1. Choose the browser -## CDP mode check - -First, check if browse is already connected to the user's real browser: -```bash -$B status 2>/dev/null | grep -q "Mode: cdp" && echo "CDP_MODE=true" || echo "CDP_MODE=false" -``` -If `CDP_MODE=true`: tell the user "Not needed — you're connected to your real browser via CDP. Your cookies and sessions are already available." and stop. No cookie import needed. - -## How it works - -1. Find the browse binary -2. Run `cookie-import-browser` to detect installed browsers and open the picker UI -3. User selects which cookie domains to import in their browser -4. Cookies are decrypted and loaded into the Playwright session - -## Steps - -### 1. Find the browse binary +Use this checkout as the gstack root if it contains `BROWSER.md` and `browse/SKILL.md`; otherwise use the installed root containing `bin/gstack-skill-start`, never a generated host stub. Read that root's `browse/SKILL.md` **BROWSER SETUP** section and run its probe first. On `READY`, stop importing: use Aside's sessions or ask the user to sign in there. Otherwise follow the probe's fallback handling, then continue below. ## SETUP (run this check BEFORE any browse command) @@ -215,45 +198,38 @@ If `NEEDS_SETUP`: fi ``` -### 2. Open the cookie picker +```bash +$B status +``` +If status says `Mode: cdp`, stop: the real browser already has sessions. + +## 2. Confirm options before import + +Open the known target and keep its tab unchanged. Both options default off and require explicit request: + +- **`--verify-auth` / picker checkbox:** reloads the target. Have the user privately configure daemon `GSTACK_COOKIE_AUTH_SELECTOR` and `GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY` **before startup**. Never invent values or assume CLI env reconfigures an existing daemon. Missing config rejects before mutation. Require a successful same-origin response and exactly one visible element whose whitespace-normalized text exactly matches the expected identity. +- **`--clear-storage`:** for suspected stale storage, obtain explicit approval. Chromium only: clears captured-origin localStorage (shared across same-origin context tabs) and target-tab sessionStorage. Other origins, other tabs' sessionStorage, IndexedDB, and service workers stay intact. It uses an isolated world/native deadline; other engines reject reset, not imports/auth checks. Never auto-approve or claim rollback after partial failure. + +## 3. Select source and scope ```bash $B cookie-import-browser ``` -This auto-detects installed Chromium browsers and opens -an interactive picker UI in your default browser where you can: -- Switch between installed browsers -- Search domains -- Click "+" to import a domain's cookies -- Click trash to remove imported cookies +Ask the user to choose browser, account/profile, and domains, then say when done. Never guess accounts or treat default Comet as consent. Unreadable profiles are unknown, not empty. Rerun for an expired five-minute one-use link. -Tell the user: **"Cookie picker opened — select the domains you want to import in your browser, then tell me when you're done."** +Direct import: pass the chosen browser and `--domain` after navigating to a matching target. `--profile` takes a directory, not a display name; omit only for an unambiguous relevant profile, otherwise use the picker. `--all` requires consent for all non-expired profile cookies; it cannot accompany `--domain` or `--clear-storage`. -### 3. Direct import (alternative) +Read that same root's `BROWSER.md`, **Choosing a source and checking sign-in**, for examples, profile labels, supported sources and platform setup. -If the user specifies a domain directly (e.g., `/setup-browser-cookies github.com`), skip the UI: +## 4. Report honestly -```bash -$B cookie-import-browser comet --domain github.com -``` +Report receipt/picker counts, partial/zero/error and reset outcomes, not raw `$B cookies`. Imports affect the context, not one tab. **Not checked** means no requested check; **not verified** means it failed; **verified** requires positive target evidence. Zero imports, counts, or HTTP 200 never prove login. -Replace `comet` with the appropriate browser if specified. +Never request/publish cookie values, passwords, identity/profile text, session details, or raw errors in public logs. -### 4. Verify +## Platform boundaries -After the user confirms they're done: +Dia is macOS-only. Keychain approval is the user's choice. Database retries are bounded; permission denial needs user action, not repeated prompts. -```bash -$B cookies -``` - -Show the user a summary of imported cookies (domain counts). - -## Notes - -- On macOS, the first import per browser may trigger a Keychain dialog — click "Allow" / "Always Allow" -- On Linux, `v11` cookies may require `secret-tool`/libsecret access; `v10` cookies use Chromium's standard fallback key -- Cookie picker is served on the same port as the browse server (no extra process) -- Only domain names and cookie counts are shown in the UI — no cookie values are exposed -- The browse session persists cookies between commands, so imported cookies work immediately +Windows supports DPAPI-compatible cookies, not all App-Bound Encryption; native extraction stays disabled pending qualification. Closing Chrome cannot bypass Chrome 136+ default-directory protection, including numbered profiles. No TCP fallback or real-profile copies. Offer headed manual sign-in only with a display available. diff --git a/setup-browser-cookies/SKILL.md.tmpl b/setup-browser-cookies/SKILL.md.tmpl index f812d9f56..f6ed3f61e 100644 --- a/setup-browser-cookies/SKILL.md.tmpl +++ b/setup-browser-cookies/SKILL.md.tmpl @@ -21,68 +21,44 @@ allowed-tools: # Setup Browser Cookies -Import logged-in sessions from your real Chromium browser into the headless browse session. +## 1. Choose the browser -## CDP mode check - -First, check if browse is already connected to the user's real browser: -```bash -$B status 2>/dev/null | grep -q "Mode: cdp" && echo "CDP_MODE=true" || echo "CDP_MODE=false" -``` -If `CDP_MODE=true`: tell the user "Not needed — you're connected to your real browser via CDP. Your cookies and sessions are already available." and stop. No cookie import needed. - -## How it works - -1. Find the browse binary -2. Run `cookie-import-browser` to detect installed browsers and open the picker UI -3. User selects which cookie domains to import in their browser -4. Cookies are decrypted and loaded into the Playwright session - -## Steps - -### 1. Find the browse binary +Use this checkout as the gstack root if it contains `BROWSER.md` and `browse/SKILL.md`; otherwise use the installed root containing `bin/gstack-skill-start`, never a generated host stub. Read that root's `browse/SKILL.md` **BROWSER SETUP** section and run its probe first. On `READY`, stop importing: use Aside's sessions or ask the user to sign in there. Otherwise follow the probe's fallback handling, then continue below. {{BROWSE_SETUP}} -### 2. Open the cookie picker +```bash +$B status +``` +If status says `Mode: cdp`, stop: the real browser already has sessions. + +## 2. Confirm options before import + +Open the known target and keep its tab unchanged. Both options default off and require explicit request: + +- **`--verify-auth` / picker checkbox:** reloads the target. Have the user privately configure daemon `GSTACK_COOKIE_AUTH_SELECTOR` and `GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY` **before startup**. Never invent values or assume CLI env reconfigures an existing daemon. Missing config rejects before mutation. Require a successful same-origin response and exactly one visible element whose whitespace-normalized text exactly matches the expected identity. +- **`--clear-storage`:** for suspected stale storage, obtain explicit approval. Chromium only: clears captured-origin localStorage (shared across same-origin context tabs) and target-tab sessionStorage. Other origins, other tabs' sessionStorage, IndexedDB, and service workers stay intact. It uses an isolated world/native deadline; other engines reject reset, not imports/auth checks. Never auto-approve or claim rollback after partial failure. + +## 3. Select source and scope ```bash $B cookie-import-browser ``` -This auto-detects installed Chromium browsers and opens -an interactive picker UI in your default browser where you can: -- Switch between installed browsers -- Search domains -- Click "+" to import a domain's cookies -- Click trash to remove imported cookies +Ask the user to choose browser, account/profile, and domains, then say when done. Never guess accounts or treat default Comet as consent. Unreadable profiles are unknown, not empty. Rerun for an expired five-minute one-use link. -Tell the user: **"Cookie picker opened — select the domains you want to import in your browser, then tell me when you're done."** +Direct import: pass the chosen browser and `--domain` after navigating to a matching target. `--profile` takes a directory, not a display name; omit only for an unambiguous relevant profile, otherwise use the picker. `--all` requires consent for all non-expired profile cookies; it cannot accompany `--domain` or `--clear-storage`. -### 3. Direct import (alternative) +Read that same root's `BROWSER.md`, **Choosing a source and checking sign-in**, for examples, profile labels, supported sources and platform setup. -If the user specifies a domain directly (e.g., `/setup-browser-cookies github.com`), skip the UI: +## 4. Report honestly -```bash -$B cookie-import-browser comet --domain github.com -``` +Report receipt/picker counts, partial/zero/error and reset outcomes, not raw `$B cookies`. Imports affect the context, not one tab. **Not checked** means no requested check; **not verified** means it failed; **verified** requires positive target evidence. Zero imports, counts, or HTTP 200 never prove login. -Replace `comet` with the appropriate browser if specified. +Never request/publish cookie values, passwords, identity/profile text, session details, or raw errors in public logs. -### 4. Verify +## Platform boundaries -After the user confirms they're done: +Dia is macOS-only. Keychain approval is the user's choice. Database retries are bounded; permission denial needs user action, not repeated prompts. -```bash -$B cookies -``` - -Show the user a summary of imported cookies (domain counts). - -## Notes - -- On macOS, the first import per browser may trigger a Keychain dialog — click "Allow" / "Always Allow" -- On Linux, `v11` cookies may require `secret-tool`/libsecret access; `v10` cookies use Chromium's standard fallback key -- Cookie picker is served on the same port as the browse server (no extra process) -- Only domain names and cookie counts are shown in the UI — no cookie values are exposed -- The browse session persists cookies between commands, so imported cookies work immediately +Windows supports DPAPI-compatible cookies, not all App-Bound Encryption; native extraction stays disabled pending qualification. Closing Chrome cannot bypass Chrome 136+ default-directory protection, including numbered profiles. No TCP fallback or real-profile copies. Offer headed manual sign-in only with a display available. diff --git a/test/chromium-sandbox-ci.test.ts b/test/chromium-sandbox-ci.test.ts new file mode 100644 index 000000000..ec90534a2 --- /dev/null +++ b/test/chromium-sandbox-ci.test.ts @@ -0,0 +1,77 @@ +import { afterEach, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +const root = path.resolve(import.meta.dir, '..'); +const workflow = Bun.YAML.parse(readFileSync(path.join(root, '.github/workflows/free-tests.yml'), 'utf8')) as any; +const steps = workflow.jobs['free-suite'].steps; +const index = steps.findIndex((step: any) => step.name === 'Configure the bundled Chromium sandbox helper'); +const command = steps[index]?.run; +const fixtures: string[] = []; + +afterEach(() => { + for (const fixture of fixtures.splice(0)) rmSync(fixture, { recursive: true, force: true }); +}); + +function run(options: { outside?: boolean; link?: boolean; mode?: string; corrupt?: boolean } = {}) { + const fixture = mkdtempSync(path.join(tmpdir(), 'chromium-ci-')); + fixtures.push(fixture); + const bin = path.join(fixture, 'bin'); + const home = path.join(fixture, 'home'); + const directory = options.outside ? path.join(fixture, 'outside') : path.join(home, '.cache/ms-playwright/chromium-1234/chrome-linux64'); + mkdirSync(bin, { recursive: true }); + mkdirSync(directory, { recursive: true }); + const executable = path.join(directory, 'chrome'); + const helper = path.join(directory, 'chrome_sandbox'); + const installed = path.join(directory, 'chrome-sandbox'); + const receipt = path.join(fixture, 'install.json'); + writeFileSync(executable, 'synthetic browser'); + if (options.link) symlinkSync(executable, helper); + else writeFileSync(helper, 'synthetic matching helper'); + writeFileSync(path.join(bin, 'bun'), '#!/bin/sh\nprintf "%s\\n" "$FIXTURE_CHROME"\n', { mode: 0o755 }); + writeFileSync(path.join(bin, 'stat'), '#!/bin/sh\nprintf "%s\\n" "$FIXTURE_STAT"\n', { mode: 0o755 }); + writeFileSync(path.join(bin, 'sudo'), `#!/usr/bin/env node +const fs = require('node:fs'); +const args = process.argv.slice(2); +fs.writeFileSync(process.env.FIXTURE_RECEIPT, JSON.stringify(args)); +if (JSON.stringify(args.slice(0, 8)) !== JSON.stringify(['install', '-T', '-o', 'root', '-g', 'root', '-m', '4755']) || args.length !== 10) process.exit(2); +fs.copyFileSync(args[8], args[9]); +if (process.env.FIXTURE_CORRUPT === '1') fs.appendFileSync(args[9], 'changed'); +`, { mode: 0o755 }); + const result = spawnSync('/bin/bash', ['-c', command], { + cwd: root, encoding: 'utf8', timeout: 10_000, + env: { ...process.env, HOME: home, PATH: bin + path.delimiter + process.env.PATH, + FIXTURE_CHROME: executable, FIXTURE_STAT: options.mode ?? '0:4755', + FIXTURE_RECEIPT: receipt, FIXTURE_CORRUPT: options.corrupt ? '1' : '0' }, + }); + return { result, helper, installed, calls: existsSync(receipt) ? JSON.parse(readFileSync(receipt, 'utf8')) : null }; +} + +test('the actual CI sandbox setup uses the resolved bundled helper before tests without disabling protections', () => { + expect(typeof command).toBe('string'); + expect(index).toBeGreaterThan(steps.findIndex((step: any) => step.name === 'Install Playwright Chromium')); + expect(index).toBeLessThan(steps.findIndex((step: any) => step.name === 'Run free suite')); + expect(command).not.toMatch(/--no-sandbox|chromiumSandbox:\s*false|sysctl|apparmor_restrict/); + const { result, helper, installed, calls } = run(); + expect(result.status).toBe(0); + expect(calls).toEqual(['install', '-T', '-o', 'root', '-g', 'root', '-m', '4755', helper, installed]); + expect(readFileSync(installed)).toEqual(readFileSync(helper)); +}); + +test('unexpected paths and linked helpers are refused before privileged installation', () => { + for (const options of [{ outside: true }, { link: true }]) { + const { result, calls } = run(options); + expect(result.status).not.toBe(0); + expect(calls).toBeNull(); + } +}); + +test('the actual CI setup requires root ownership, setuid mode, and unchanged helper bytes', () => { + for (const options of [{ mode: '1000:4755' }, { mode: '0:755' }, { corrupt: true }]) { + const { result, calls } = run(options); + expect(result.status).not.toBe(0); + expect(calls).not.toBeNull(); + } +}); diff --git a/test/ci-eval-cache.test.ts b/test/ci-eval-cache.test.ts index 481247d91..a34f430f5 100644 --- a/test/ci-eval-cache.test.ts +++ b/test/ci-eval-cache.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'bun:test'; -import { readFileSync, writeFileSync, mkdtempSync, mkdirSync, rmSync } from 'node:fs'; +import { readFileSync, writeFileSync, mkdtempSync, mkdirSync, rmSync, chmodSync } from 'node:fs'; import { resolve, join } from 'node:path'; import { tmpdir } from 'node:os'; import { spawnSync } from 'node:child_process'; @@ -102,3 +102,66 @@ test.skipIf(!Bun.which('jq'))('the actual comment separates reused evidence, ret expect(text).toContain('receive no PR-pass credit'); expect(comment).not.toContain('diff-selected gate census'); }); + +test.skipIf(!Bun.which('jq') || !Bun.which('bash'))('comment consumes verified final counts without running repository code and fails closed without them', () => { + const job = paid.jobs['slices-comment']; + expect(job.permissions).toMatchObject({ 'pull-requests': 'write' }); + expect(JSON.stringify(job.steps)).not.toMatch(/actions\/checkout|setup-bun|bun run|npm |node /); + const upload = paid.jobs['slices-report'].steps.find((step: any) => step.with?.name === 'report-verdict'); + expect(upload.with.path.trim().split('\n')).toEqual(['/tmp/report.txt', '/tmp/paid-report/collector-outcomes.json']); + expect(job.steps.find((step: any) => step.with?.name === 'report-verdict').with.path).toBe('/tmp/verdict'); + const root = mkdtempSync(join(tmpdir(), 'ci-comment-')); + const paidDir = join(root, 'paid-report'); + const verdictDir = join(root, 'verdict'); + const binDir = join(root, 'bin'); + mkdirSync(paidDir); mkdirSync(verdictDir); mkdirSync(binDir); + writeFileSync(join(binDir, 'gh'), '#!/bin/sh\ncase "$*" in *--jq*) exit 0;; esac\nfor arg do case "$arg" in body=*) printf "%s\\n" "${arg#body=}";; esac; done\n'); + chmodSync(join(binDir, 'gh'), 0o755); + writeFileSync(join(binDir, 'bc'), '#!/bin/sh\nread -r expression\n[ "$expression" = "0 + 0" ] && printf "0\\n"\n'); + chmodSync(join(binDir, 'bc'), 0o755); + writeFileSync(join(paidDir, 'manifest.json'), JSON.stringify({ profile: 'pr', selection: { e2e: [], judges: [] } })); + writeFileSync(join(paidDir, 'judge.json'), JSON.stringify({ total_tests: 2, tier: 'llm-judge', shard: 1, + tests: [{ name: 'manual', passed: false, manual_review: { unverified: true } }, + { name: 'reused', passed: true, execution: 'reused' }], flaky_retries: [] })); + const summary = { version: 1, files: [{ file: 'judge.json', tier: 'llm-judge', shard: 1, cost: 0, + total: 2, passed: 1, failed: 0, manual_accepted: 1, executed: 1, reused: 1, attempts: 2, flaky: 0 }], + totals: { total: 2, passed: 1, failed: 0, manual_accepted: 1, executed: 1, reused: 1, attempts: 2, flaky: 0 } }; + mkdirSync(join(verdictDir, 'paid-report')); + const summaryPath = join(verdictDir, 'paid-report/collector-outcomes.json'); + const script = (job.steps.find((step: any) => step.name === 'Post PR comment').run as string) + .replaceAll('/tmp/paid-report', paidDir).replaceAll('/tmp/verdict', verdictDir) + .replaceAll('${{ github.repository }}', 'garrytan/gstack') + .replaceAll('${{ github.event.pull_request.number }}', '123'); + const check = spawnSync('bash', ['-n', '-c', script], { cwd: root, encoding: 'utf8', timeout: 5000 }); + expect(check.status, check.stderr).toBe(0); + const run = () => spawnSync('bash', ['-e', '-c', script], { cwd: root, + env: { ...process.env, PATH: `${binDir}:${process.env.PATH}`, RECONCILE_EXIT: '0' }, + encoding: 'utf8', timeout: 5000 }); + try { + writeFileSync(summaryPath, JSON.stringify(summary)); + const verified = run(); + expect(verified.status, verified.stderr).toBe(0); + expect(verified.stdout).toContain('⚠ MANUAL ACCEPTED (unscored)'); + expect(verified.stdout).toContain('1 automated passed / 2 final results'); + expect(verified.stdout).toContain('0 failed, 1 manual accepted'); + + const unrelatedFailure = { ...summary, files: [{ ...summary.files[0], total: 3, failed: 1, + executed: 2, attempts: 3 }], totals: { ...summary.totals, total: 3, failed: 1, + executed: 2, attempts: 3 } }; + writeFileSync(summaryPath, JSON.stringify(unrelatedFailure)); + const red = run(); + expect(red.status, red.stderr).toBe(0); + expect(red.stdout).toContain('❌ FAIL'); + expect(red.stdout).toContain('1 failed, 1 manual accepted'); + + writeFileSync(summaryPath, JSON.stringify({ ...summary, totals: { ...summary.totals, manual_accepted: 2 } })); + const tampered = run(); + expect(tampered.status, tampered.stderr).toBe(0); + expect(tampered.stdout).toContain('manual acceptance unavailable/unverified'); + expect(tampered.stdout).not.toContain('⚠ MANUAL ACCEPTED (unscored)'); + rmSync(summaryPath); + const absent = run(); + expect(absent.status, absent.stderr).toBe(0); + expect(absent.stdout).toContain('manual acceptance unavailable/unverified'); + } finally { rmSync(root, { recursive: true, force: true }); } +}); diff --git a/test/ci-paid-coordination.test.ts b/test/ci-paid-coordination.test.ts index fc96f84a1..184bda509 100644 --- a/test/ci-paid-coordination.test.ts +++ b/test/ci-paid-coordination.test.ts @@ -5,6 +5,7 @@ import * as path from 'node:path'; import { spawnSync } from 'node:child_process'; import { buildRunManifest, collectPaidTestFiles, type PaidRunManifest, type SliceResult } from '../scripts/test-paid-shards'; import { STRICT_RETRY_CASE_BUDGETS } from './helpers/eval-budgets'; +import { manualReviewFixture } from './helpers/manual-judge-review-fixture'; const ROOT = path.resolve(import.meta.dir, '..'); type Step = { uses?: string; run?: string; if?: string; with?: Record }; @@ -23,6 +24,48 @@ const workflows = ['evals.yml', 'evals-periodic.yml'].map(name => ({ })); describe('paid CI coordination stays off the eval image', () => { + test('the actual planner and reporter load from a checkout without installed packages', () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'paid-offline-')); + const listed = spawnSync('git', ['ls-files', '-z'], { cwd: ROOT, encoding: 'utf8', timeout: 5_000 }); + expect(listed.status, listed.stderr).toBe(0); + try { + for (const relative of listed.stdout.split('\0').filter(Boolean)) { + const source = path.join(ROOT, relative); + const destination = path.join(directory, relative); + fs.mkdirSync(path.dirname(destination), { recursive: true }); + expect(fs.realpathSync(path.dirname(destination)).startsWith(fs.realpathSync(directory) + path.sep) + || fs.realpathSync(path.dirname(destination)) === fs.realpathSync(directory)).toBe(true); + if (fs.lstatSync(source).isSymbolicLink()) fs.symlinkSync(fs.readlinkSync(source), destination); + else fs.copyFileSync(source, destination); + } + const env = { ...process.env, ANTHROPIC_API_KEY: undefined, EVALS: undefined, EVALS_ALL: undefined, + EVALS_PROFILE: 'pr', EVALS_TIER: 'gate' }; + for (const args of [['init', '-b', 'main'], ['add', '-A'], ['commit', '-m', 'Seed offline planner fixture'], ['checkout', '-b', 'fixture-head']]) { + const git = spawnSync('git', args, { cwd: directory, env, encoding: 'utf8', timeout: 15_000 }); + expect(git.status, git.stderr).toBe(0); + } + fs.appendFileSync(path.join(directory, '.github/workflows/evals.yml'), '\n'); + const report = path.join(directory, 'report'); + const plan = spawnSync(process.execPath, ['--no-install', 'run', 'scripts/test-paid-shards.ts', '--tier', 'gate', + '--emit-plan', path.join(report, 'manifest.json'), '--slices', '6'], { cwd: directory, env, encoding: 'utf8', timeout: 15_000 }); + expect(plan.status, plan.stdout + plan.stderr).toBe(0); + const manifest = JSON.parse(fs.readFileSync(path.join(report, 'manifest.json'), 'utf8')); + expect(manifest.sliceCount).toBe(6); + expect(manifest.entries.some((entry: any) => entry.status === 'planned')).toBe(true); + const reconcile = spawnSync(process.execPath, ['--no-install', 'run', 'scripts/test-paid-shards.ts', '--tier', 'gate', + '--report', report], { cwd: directory, env, encoding: 'utf8', timeout: 15_000 }); + expect(reconcile.status, reconcile.stdout + reconcile.stderr).toBe(1); + expect(reconcile.stdout).toContain('report: 0/6 slices'); + expect(reconcile.stderr.match(/slice \d\/6 reported NO result/g)).toHaveLength(6); + expect(reconcile.stderr).not.toContain('Cannot find module'); + expect(fs.existsSync(path.join(directory, 'node_modules'))).toBe(false); + const provider = spawnSync(process.execPath, ['--no-install', '-e', 'import "./test/helpers/llm-judge.ts"'], + { cwd: directory, env, encoding: 'utf8', timeout: 15_000 }); + expect(provider.status).toBe(1); + expect(provider.stderr).toContain("Cannot find module '@anthropic-ai/sdk'"); + } finally { fs.rmSync(directory, { recursive: true, force: true }); } + }, 60_000); + for (const { name, jobs } of workflows) { test(`${name}: planning is independent of image startup and has no dependency install`, () => { const planner = jobs['plan-slices']; @@ -88,8 +131,18 @@ describe('dependency-free CI planner and report execution', () => { beforeAll(() => { fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'ci-paid-coordination-')); - fs.cpSync(path.join(ROOT, 'scripts'), path.join(fixture, 'scripts'), { recursive: true }); - fs.cpSync(path.join(ROOT, 'test/helpers'), path.join(fixture, 'test/helpers'), { recursive: true }); + const sourceOnly = { recursive: true, filter: (file: string) => path.basename(file) !== 'node_modules' }; + fs.cpSync(path.join(ROOT, 'scripts'), path.join(fixture, 'scripts'), sourceOnly); + fs.cpSync(path.join(ROOT, 'test/helpers'), path.join(fixture, 'test/helpers'), sourceOnly); + expect(fs.readFileSync(path.join(fixture, 'test/helpers/llm-judge.ts'), 'utf8')) + .toBe(fs.readFileSync(path.join(ROOT, 'test/helpers/llm-judge.ts'), 'utf8')); + fs.cpSync(path.join(ROOT, 'lib'), path.join(fixture, 'lib'), sourceOnly); + expect(fs.existsSync(path.join(fixture, 'lib/diagram-render/node_modules'))).toBe(false); + fs.mkdirSync(path.join(fixture, '.github'), { recursive: true }); + for (const file of ['.github/cookie-workflow-manual-review.json', 'setup-browser-cookies/SKILL.md', 'BROWSER.md']) { + fs.mkdirSync(path.dirname(path.join(fixture, file)), { recursive: true }); + fs.copyFileSync(path.join(ROOT, file), path.join(fixture, file)); + } for (const file of collectPaidTestFiles()) { fs.copyFileSync(path.join(ROOT, file), path.join(fixture, file)); } @@ -183,7 +236,7 @@ describe('dependency-free CI planner and report execution', () => { const red = run(['--report', reportDir], tier); expect(red.status).toBe(1); expect(red.stderr).toContain(`${failed.outcomes[0].files[0]}: failed`); - expect(red.stdout).toContain('3 executed, 0 reused; 1 passed, 2 failed (6 attempt records from 1 collectors)'); + expect(red.stdout).toContain('3 executed, 0 reused; 1 passed, 2 failed, 0 manual accepted (unscored; no score-cache credit) (6 attempt records from 1 collectors)'); expect(red.stdout).toContain('3 cases with multiple attempts this run:'); expect(red.stdout).not.toMatch(/passed only on retry|not blocking/); @@ -192,4 +245,116 @@ describe('dependency-free CI planner and report execution', () => { expect(corrupt.status).toBe(1); }); } + + test('report verifies every manual claim against current source, preserves attempts, and never masks a failed shard', () => { + const reportDir = path.join(fixture, 'manual-report'); + const manifestPath = path.join(reportDir, 'manifest.json'); + const planned = run(['--emit-plan', manifestPath, '--slices', '1'], 'gate'); + expect(planned.status, planned.stderr).toBe(0); + const manifest: PaidRunManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const slice: SliceResult = { + version: 1, tier: 'gate', sliceIndex: 1, sliceCount: 1, + outcomes: manifest.entries.filter(entry => entry.status === 'planned').map(entry => ({ + files: [entry.file], status: 'passed', exitCode: 0, elapsedMs: 1, + executedTests: STRICT_RETRY_CASE_BUDGETS.find(budget => budget.file === entry.file)?.cases ?? 1, + skippedTests: 0, ...(entry.budget ? { budget: entry.budget } : {}), + })), + }; + const slicePath = path.join(reportDir, 'slice-1.json'); + const collectorPath = path.join(reportDir, 'judge-results.json'); + const summaryPath = path.join(reportDir, 'collector-outcomes.json'); + const receipt = manualReviewFixture(ROOT); + const write = (tests: unknown[]) => fs.writeFileSync(collectorPath, JSON.stringify({ + total_tests: tests.length, tier: 'llm-judge', shard: 1, total_cost_usd: 0, + tests, flaky_retries: [{ name: receipt.name, attempts: tests.length }], + })); + fs.writeFileSync(slicePath, JSON.stringify(slice)); + write([{ ...receipt, passed: true }, receipt]); + const historical = run(['--report', reportDir], 'gate'); + expect(historical.status).toBe(1); + expect(historical.stderr).toContain('attempt 1: Malformed manual-review claim'); + expect(fs.existsSync(summaryPath)).toBe(false); + + write([{ ...receipt, manual_review: { ...receipt.manual_review, refusal: { + ...receipt.manual_review!.refusal, response_id: '', + } } }, receipt]); + const malformed = run(['--report', reportDir], 'gate'); + expect(malformed.status).toBe(1); + expect(malformed.stderr).toContain('attempt 1: Malformed manual-review claim'); + + write([{ ...receipt, manual_review: undefined, passed: false }, receipt]); + const forgedFirstAttempt = run(['--report', reportDir], 'gate'); + expect(forgedFirstAttempt.status).toBe(1); + expect(forgedFirstAttempt.stderr).toContain('attempt 2: manual review is only valid on the first case attempt'); + expect(fs.existsSync(summaryPath)).toBe(false); + write([receipt, { ...receipt, attempt: 2 }]); + const retriedManual = run(['--report', reportDir], 'gate'); + expect(retriedManual.status).toBe(1); + expect(retriedManual.stderr).toContain('attempt 2: Malformed manual-review claim'); + expect(fs.existsSync(summaryPath)).toBe(false); + + write([receipt]); + const secondCollector = path.join(reportDir, 'other-results.json'); + fs.writeFileSync(secondCollector, JSON.stringify({ total_tests: 1, tests: [receipt] })); + const duplicateCollector = run(['--report', reportDir], 'gate'); + expect(duplicateCollector.status).toBe(1); + expect(duplicateCollector.stderr).toContain('duplicate manual-review claim'); + expect(fs.existsSync(summaryPath)).toBe(false); + fs.rmSync(secondCollector); + + write([receipt, { name: 'automated', suite: 'other', passed: true, execution: 'reused' }]); + const clean = run(['--report', reportDir], 'gate'); + expect(clean.status, clean.stderr).toBe(0); + expect(clean.stdout).toContain('1 passed, 0 failed, 1 manual accepted (unscored; no score-cache credit) (2 attempt records'); + const summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8')); + expect(summary.totals).toEqual({ executed: 1, reused: 1, passed: 1, failed: 0, + manual_accepted: 1, attempts: 2, total: 2, flaky: 1 }); + expect(summary.files[0]).toMatchObject({ file: 'judge-results.json', total: 2, manual_accepted: 1, passed: 1 }); + expect(JSON.parse(fs.readFileSync(collectorPath, 'utf8')).tests[0]).toEqual(receipt); + + const browserPath = path.join(fixture, 'BROWSER.md'); + const browserSource = fs.readFileSync(browserPath, 'utf8'); + fs.writeFileSync(browserPath, browserSource.replace('#### Choosing a source and checking sign-in', + '#### Choosing a source and checking sign-in\nchanged approved source')); + const sourceDrift = run(['--report', reportDir], 'gate'); + expect(sourceDrift.status).toBe(1); + expect(sourceDrift.stderr).toContain('does not match current source and approval'); + expect(fs.existsSync(summaryPath)).toBe(false); + fs.writeFileSync(browserPath, browserSource); + + write([receipt, { name: 'unapproved', passed: false, execution: 'executed' }]); + const failedCollector = run(['--report', reportDir], 'gate'); + expect(failedCollector.status).toBe(1); + expect(failedCollector.stderr).toContain('1 unapproved final collector failure(s)'); + expect(JSON.parse(fs.readFileSync(summaryPath, 'utf8')).totals).toMatchObject({ failed: 1, manual_accepted: 1 }); + + write([receipt, { passed: true }]); + const missingName = run(['--report', reportDir], 'gate'); + expect(missingName.status).toBe(1); + expect(missingName.stderr).toContain('attempt 2: malformed collector entry (name/passed required)'); + expect(fs.existsSync(summaryPath)).toBe(false); + write([receipt, { name: 'malformed', passed: 'true' }]); + const malformedPassed = run(['--report', reportDir], 'gate'); + expect(malformedPassed.status).toBe(1); + expect(malformedPassed.stderr).toContain('attempt 2: malformed collector entry (name/passed required)'); + expect(fs.existsSync(summaryPath)).toBe(false); + + write([receipt, { name: 'automated', suite: 'other', passed: true, execution: 'reused' }]); + + slice.outcomes[0].status = 'failed'; + slice.outcomes[0].exitCode = 1; + fs.writeFileSync(slicePath, JSON.stringify(slice)); + const failedShard = run(['--report', reportDir], 'gate'); + expect(failedShard.status).toBe(1); + expect(failedShard.stderr).toContain(`${slice.outcomes[0].files[0]}: failed`); + expect(JSON.parse(fs.readFileSync(summaryPath, 'utf8')).totals.manual_accepted).toBe(1); + + const stale = structuredClone(receipt); + stale.manual_review!.approval.prompt_sha256 = '0'.repeat(64); + write([stale]); + const mismatched = run(['--report', reportDir], 'gate'); + expect(mismatched.status).toBe(1); + expect(mismatched.stderr).toContain('attempt 1: Malformed manual-review claim'); + expect(fs.existsSync(summaryPath)).toBe(false); + }); }); diff --git a/test/claude-code-runner.test.ts b/test/claude-code-runner.test.ts index 31add3ffa..8b4254afc 100644 --- a/test/claude-code-runner.test.ts +++ b/test/claude-code-runner.test.ts @@ -1,5 +1,5 @@ import { afterAll, describe, expect, spyOn, test } from 'bun:test'; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, lstatSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; @@ -11,22 +11,42 @@ const FAKE = path.join(DIR, 'fake claude.ts'); const DESCENDANT = path.join(DIR, 'pipe holder.ts'); const CAPTURE = path.join(DIR, 'capture.json'); const PID = path.join(DIR, 'descendant.pid'); +const ACTOR_STAGES = path.join(DIR, 'actor-stages.jsonl'); +const DESCENDANT_STAGES = path.join(DIR, 'descendant-stages.jsonl'); +const DIR_IDENTITY = lstatSync(DIR, { bigint: true }); +const RESOLVED_DIR = realpathSync(DIR); // Publish readiness only after the grandchild has initialized and flushed both // inherited pipes; a PID returned by spawn alone does not establish that state. writeFileSync(DESCENDANT, ` -import { writeFileSync } from 'node:fs'; +import { appendFileSync, writeFileSync } from 'node:fs'; +const record = stage => { + if (process.env.FAKE_MODE === 'timeout') { + try { appendFileSync(${JSON.stringify(DESCENDANT_STAGES)}, JSON.stringify({ stage, atUnixMs: Date.now(), processNs: process.hrtime.bigint().toString(), pid: process.pid }) + '\\n'); } catch {} + } +}; +record('descendant_start'); await Bun.sleep(Number(process.env.DESCENDANT_DELAY_MS || 0)); setInterval(() => {}, 1000); await new Promise(resolve => process.stdout.write(' ', resolve)); +record('stdout_flushed'); await new Promise(resolve => process.stderr.write(' ', resolve)); +record('stderr_flushed'); writeFileSync(process.env.PID_FILE!, String(process.pid)); +record('pid_published'); `); writeFileSync(FAKE, ` import { spawn } from 'node:child_process'; -import { existsSync, rmSync, writeFileSync } from 'node:fs'; +import { appendFileSync, existsSync, rmSync, writeFileSync } from 'node:fs'; +const record = (stage, pid = process.pid) => { + if (process.env.FAKE_MODE === 'timeout') { + try { appendFileSync(${JSON.stringify(ACTOR_STAGES)}, JSON.stringify({ stage, atUnixMs: Date.now(), processNs: process.hrtime.bigint().toString(), pid }) + '\\n'); } catch {} + } +}; +record('actor_start'); const prompt = await Bun.stdin.text(); +record('stdin_read'); writeFileSync(process.env.CAPTURE!, JSON.stringify({args:process.argv.slice(2),prompt,cwd:process.cwd(),model:process.env.ANTHROPIC_MODEL,auth:process.env.ANTHROPIC_API_KEY})); const mode = process.env.FAKE_MODE; if (mode === 'startup-timeout') { @@ -39,11 +59,18 @@ if (mode === 'timeout' || mode === 'descendant' || mode === 'escaped') { // drain fixture must survive that exit so its inherited pipes remain open. const child = spawn(process.execPath, [process.env.DESCENDANT!], {stdio:['ignore','inherit','inherit'], detached:mode === 'escaped' || (process.platform === 'win32' && mode === 'descendant')}); + record('descendant_spawn_requested'); + child.once('spawn', () => record('descendant_spawned', child.pid)); + child.once('exit', () => record('descendant_exited', child.pid)); const readyBy = Date.now() + 2000; while (!existsSync(process.env.PID_FILE!)) { - if (child.exitCode !== null || Date.now() >= readyBy) throw new Error('Descendant did not initialize its inherited pipes'); + if (child.exitCode !== null || Date.now() >= readyBy) { + record('readiness_failed'); + throw new Error('Descendant did not initialize its inherited pipes'); + } await Bun.sleep(5); } + record('readiness_observed'); if (mode === 'timeout') await new Promise(() => {}); } if (mode === 'auth') { process.stderr.write('Not logged in. Please run claude /login.'); process.exit(1); } @@ -79,6 +106,27 @@ function run(mode = 'success', extra: Partial[0 function capture() { return JSON.parse(readFileSync(CAPTURE, 'utf8')); } +function timeoutStageReceipt(file: string): object { + try { + const directory = lstatSync(DIR, { bigint: true }); + if (directory.isSymbolicLink() || directory.dev !== DIR_IDENTITY.dev || directory.ino !== DIR_IDENTITY.ino || realpathSync(DIR) !== RESOLVED_DIR || ![ACTOR_STAGES, DESCENDANT_STAGES].includes(file)) return { available: false, reason: 'identity_mismatch' }; + const state = lstatSync(file); + if (!state.isFile() || state.isSymbolicLink() || state.size > 4096) return { available: false, reason: 'invalid_receipt' }; + const lines = readFileSync(file, 'utf8').trim().split('\n'); + if (lines.length > 16) return { available: false, reason: 'invalid_receipt' }; + const stages = []; + for (const line of lines) { + let event; + try { event = JSON.parse(line); } catch { return { available: false, reason: 'incomplete_receipt', stages }; } + if (!event || !['actor_start', 'stdin_read', 'descendant_spawn_requested', 'descendant_spawned', 'descendant_exited', 'readiness_failed', 'readiness_observed', 'descendant_start', 'stdout_flushed', 'stderr_flushed', 'pid_published'].includes(event.stage) || !Number.isSafeInteger(event.atUnixMs) || event.atUnixMs <= 0 || typeof event.processNs !== 'string' || !/^\d{1,24}$/.test(event.processNs) || !Number.isSafeInteger(event.pid) || event.pid <= 0) return { available: false, reason: 'invalid_receipt', stages }; + stages.push({ stage: event.stage, atUnixMs: event.atUnixMs, processNs: event.processNs, pid: event.pid }); + } + return { available: true, stages }; + } catch (error) { + return { available: false, reason: (error as NodeJS.ErrnoException).code === 'ENOENT' ? 'not_published' : 'inspection_failed' }; + } +} + function running(pid: number): boolean { try { process.kill(pid, 0); @@ -107,6 +155,22 @@ function cleanupDescendant() { } describe('Claude Code restricted execution', () => { + test('timeout stage diagnostics retain only bounded safe fixture events', () => { + const event = { stage: 'actor_start', atUnixMs: 123, processNs: '123', pid: process.pid }; + try { + expect(timeoutStageReceipt(ACTOR_STAGES)).toEqual({ available: false, reason: 'not_published' }); + writeFileSync(ACTOR_STAGES, JSON.stringify({ ...event, secret: 'sensitive-sentinel' }) + '\n'); + expect(timeoutStageReceipt(ACTOR_STAGES)).toEqual({ available: true, stages: [event] }); + writeFileSync(ACTOR_STAGES, JSON.stringify(event) + '\n{"stage":'); + expect(timeoutStageReceipt(ACTOR_STAGES)).toEqual({ available: false, reason: 'incomplete_receipt', stages: [event] }); + writeFileSync(ACTOR_STAGES, JSON.stringify({ ...event, stage: 'sensitive-sentinel' })); + expect(JSON.stringify(timeoutStageReceipt(ACTOR_STAGES))).not.toContain('sensitive-sentinel'); + writeFileSync(ACTOR_STAGES, 'x'.repeat(4097)); + expect(timeoutStageReceipt(ACTOR_STAGES)).toEqual({ available: false, reason: 'invalid_receipt' }); + expect(timeoutStageReceipt(CAPTURE)).toEqual({ available: false, reason: 'identity_mismatch' }); + } finally { rmSync(ACTOR_STAGES, { force: true }); } + }); + test('explicit model override stays one literal argument across access modes and resume', async () => { const model = 'custom-model "quoted" $(touch /never)'; for (const access of ['none', 'read-only'] as const) { @@ -211,7 +275,12 @@ describe('Claude Code restricted execution', () => { test('timeout kills its descendants and clears process signal listeners', async () => { rmSync(PID, { force: true }); + rmSync(ACTOR_STAGES, { force: true }); + rmSync(DESCENDANT_STAGES, { force: true }); const before = ['SIGINT','SIGTERM','exit'].map(name => process.listenerCount(name)); + const startedAtUnixMs = Date.now(); + const startedProcessNs = process.hrtime.bigint().toString(); + let returned: { atUnixMs: number; processNs: string; timedOut: boolean } | undefined; const schedule = globalThis.setTimeout; let fireTimeout: (() => void) | undefined; const timer = spyOn(globalThis, 'setTimeout').mockImplementation((callback, delay, ...args) => { @@ -233,12 +302,14 @@ describe('Claude Code restricted execution', () => { fireTimeout = undefined; expire(); const result = await invocation; + returned = { atUnixMs: Date.now(), processNs: process.hrtime.bigint().toString(), timedOut: result.error?.code === 'timeout' }; expect(result.status).toBe('unavailable'); expect(result.error?.code).toBe('timeout'); expect(Date.now() - start).toBeLessThan(2000); expect(['SIGINT','SIGTERM','exit'].map(name => process.listenerCount(name))).toEqual(before); await expectDescendantDead(); } finally { + console.error(JSON.stringify({ claudeTimeoutStages: { startedAtUnixMs, startedProcessNs, timeoutMs: 500, readinessMs: 2000, returned, pidPublished: existsSync(PID), actor: timeoutStageReceipt(ACTOR_STAGES), descendant: timeoutStageReceipt(DESCENDANT_STAGES) } })); fireTimeout?.(); await invocation; cleanupDescendant(); diff --git a/test/cookie-validation-phases.test.ts b/test/cookie-validation-phases.test.ts new file mode 100644 index 000000000..eca1ccb28 --- /dev/null +++ b/test/cookie-validation-phases.test.ts @@ -0,0 +1,138 @@ +import { afterAll, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { parseRunManifest } from '../scripts/test-paid-shards'; + +const root = path.resolve(import.meta.dir, '..'); +const scratch = realpathSync(mkdtempSync(path.join(tmpdir(), 'cookie-phase-'))); +const workflow = Bun.YAML.parse(readFileSync(path.join(root, '.github/workflows/evals.yml'), 'utf8')) as any; +const command = workflow.jobs['plan-slices'].steps.find((step: any) => step.name === 'Emit validation-phase manifest').run; +const body = command.match(/^bun --no-install -e '\n([\s\S]*)\n'\s*$/)?.[1]; +if (!body) throw new Error('Validation planner script was not found'); + +afterAll(() => rmSync(scratch, { recursive: true, force: true })); + +function manifestFor(phase: string) { + const output = path.join(scratch, phase); + const result = spawnSync(process.execPath, ['--no-env-file', '--no-install', '--no-macros', `--config=${process.platform === 'win32' ? 'NUL' : '/dev/null'}`, '-e', body!.replaceAll('/tmp/paid-plan', output.replaceAll('\\', '/'))], { + cwd: root, env: { ...process.env, VALIDATION_PHASE: phase, EVALS_TIER: 'gate', EVALS_ALL: '1' }, encoding: 'utf8', timeout: 30_000, + }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + return parseRunManifest(readFileSync(path.join(output, 'manifest.json'), 'utf8')); +} + +test('the actual CI cookie repair planner executes only eight dependent cases without judges or run-all', () => { + const manifest = manifestFor('cookie-behavior'); + expect(manifest.profile).toBe('full'); + expect(manifest.evalsAll).toBe(false); + expect(manifest.selection).toEqual({ e2e: ['browse-basic', 'browse-snapshot', 'qa-quick', 'qa-only-no-fix', 'design-review-detector-shim-dom', 'diagram-triplet', 'canary-workflow', 'benchmark-workflow'], judges: [] }); + expect(manifest.entries.filter(entry => entry.status === 'planned').map(entry => entry.file).sort()).toEqual([ + 'test/skill-e2e-bws.test.ts', 'test/skill-e2e-deploy.test.ts', 'test/skill-e2e-design.test.ts', 'test/skill-e2e-diagram.test.ts', 'test/skill-e2e-qa-workflow.test.ts', + ]); +}); + +test('the existing quality and behavior phases retain their complete separate shard census', () => { + const quality = manifestFor('quality'); + const behavior = manifestFor('behavior'); + const qualityFiles = quality.entries.filter(entry => entry.status === 'planned').map(entry => entry.file); + const behaviorFiles = behavior.entries.filter(entry => entry.status === 'planned').map(entry => entry.file); + expect(quality.evalsAll).toBe(true); + expect(behavior.evalsAll).toBe(true); + expect(qualityFiles).toHaveLength(2); + expect(behaviorFiles).toHaveLength(52); + expect(qualityFiles.every(file => file.startsWith('test/skill-llm-eval'))).toBe(true); + expect(behaviorFiles.every(file => !qualityFiles.includes(file))).toBe(true); +}); + +test('curated Windows and native qualification use the same pinned Node runtime', () => { + const windows = Bun.YAML.parse(readFileSync(path.join(root, '.github/workflows/windows-free-tests.yml'), 'utf8')) as any; + for (const job of ['windows-free-tests', 'cookie-native-qualification']) { + const setup = windows.jobs[job].steps.find((step: any) => step.uses?.startsWith('actions/setup-node@')); + expect(setup.with['node-version']).toBe('24.18.0'); + expect(setup.if).toBeUndefined(); + } +}); + +test('Windows retains complete shard logs on successful and failed runs', () => { + const windows = Bun.YAML.parse(readFileSync(path.join(root, '.github/workflows/windows-free-tests.yml'), 'utf8')) as any; + const upload = windows.jobs['windows-free-tests'].steps.find((step: any) => step.with?.name === 'windows-free-test-shard-logs'); + expect(upload.if).toBe('always()'); + expect(upload.with.path).toBe('${{ runner.temp }}/gstack-free-test-*.log'); +}); + +test('focused Windows diagnostics include the repaired lock and close cases without default-profile qualification', () => { + const windows = Bun.YAML.parse(readFileSync(path.join(root, '.github/workflows/windows-free-tests.yml'), 'utf8')) as any; + const run = windows.jobs['windows-free-tests'].steps.find((step: any) => step.name === 'Run focused native launch and credential diagnostics').run; + const pattern = run.match(/--test-name-pattern '([^']+)'/)?.[1]; + expect(pattern).toBeDefined(); + const selected = new RegExp(pattern); + for (const name of ['native Windows launch diagnostics > observer', 'native Windows process qualification > a locked real Edge profile leaves its existing owner alive', + 'native Windows process qualification > real Edge synthetic profile: normal-close', 'native Windows process qualification > real Edge synthetic profile: stalled-close']) expect(selected.test(name)).toBe(true); + expect(selected.test('native Windows process qualification > an exclusively created default Edge profile persists v20')).toBe(false); +}); + +test('Dia comparison uses separate pinned runtime jobs and retains diagnostic failures', () => { + const windows = Bun.YAML.parse(readFileSync(path.join(root, '.github/workflows/windows-free-tests.yml'), 'utf8')) as any; + const job = windows.jobs['dia-native-qualification']; + expect(job['runs-on']).toBe('macos-15'); + expect(job.strategy['fail-fast']).toBe(false); + expect(job.strategy.matrix.runtime).toContain('["bun","node"]'); + expect(job.strategy.matrix.runtime).toContain('inputs.dia_launch_comparison'); + const node = job.steps.find((step: any) => step.uses?.startsWith('actions/setup-node@')); + expect(node.with).toEqual({ 'node-version': '24.18.0', architecture: 'arm64' }); + const comparison = job.steps.find((step: any) => step.name === 'Compare protected native Dia launch without qualification credit'); + expect(comparison.run).toContain('--launch-comparison "$COMPARISON_RUNTIME"'); + expect(comparison['continue-on-error']).toBeUndefined(); + const upload = job.steps.find((step: any) => step.uses?.startsWith('actions/upload-artifact@')); + expect(upload.if).toBe('always()'); + expect(upload.with.name).toContain("format('dia-launch-comparison-{0}', matrix.runtime)"); + expect(windows.jobs['windows-free-tests'].if).toContain('!inputs.dia_launch_comparison'); + expect(windows.jobs['cookie-native-qualification'].if).toContain('!inputs.dia_launch_comparison'); +}); + +test('Dia GUI readiness is an exclusive, single-job diagnostic without browser installation or launch', () => { + const windows = Bun.YAML.parse(readFileSync(path.join(root, '.github/workflows/windows-free-tests.yml'), 'utf8')) as any; + const job = windows.jobs['dia-native-qualification']; + const input = windows.on.workflow_dispatch.inputs.dia_gui_readiness; + expect(input).toMatchObject({ type: 'boolean', default: false }); + expect(job.if).toContain('inputs.dia_gui_readiness'); + expect(job.strategy.matrix.runtime).toContain('inputs.dia_launch_comparison && !inputs.dia_gui_readiness'); + for (const name of ['windows-free-tests', 'cookie-native-qualification']) { + expect(windows.jobs[name].if).toContain('!inputs.dia_gui_readiness'); + } + const probe = job.steps.find((step: any) => step.name === 'Inspect GUI readiness without browser or Keychain access'); + expect(probe.if).toBe('inputs.dia_gui_readiness'); + expect(probe.run).toEndWith('.github/scripts/run-dia-native-qualification.ts --gui-readiness-only'); + expect(probe.env).toEqual({ GSTACK_DIA_NATIVE_QUALIFY: '1' }); + const excluded = job.steps.filter((step: any) => step.uses?.startsWith('actions/setup-node@') + || ['Install pinned dependencies', 'Install the synthetic destination browser', 'Qualify native Dia discovery, decryption, and import', + 'Compare protected native Dia launch without qualification credit'].includes(step.name)); + expect(excluded).toHaveLength(5); + for (const step of excluded) expect(step.if).toContain('!inputs.dia_gui_readiness'); + const upload = job.steps.find((step: any) => step.uses?.startsWith('actions/upload-artifact@')); + expect(upload.if).toBe('always()'); + expect(upload.with.name).toContain("inputs.dia_gui_readiness && 'dia-gui-readiness'"); + expect(upload.with.path).toBe('${{ runner.temp }}/dia-native-qualification.json'); +}); + +test('the actual GUI readiness selection guard refuses conflicting or malformed mode inputs', () => { + const windows = Bun.YAML.parse(readFileSync(path.join(root, '.github/workflows/windows-free-tests.yml'), 'utf8')) as any; + const steps = windows.jobs['dia-native-qualification'].steps; + const guard = steps.find((step: any) => step.name === 'Validate GUI readiness selection'); + expect(guard.if).toBe('inputs.dia_gui_readiness'); + expect(guard.env.OTHER_DIA_MODES).toBe('${{ inputs.dia_native_only || inputs.dia_launch_comparison || inputs.native_diagnostics_only }}'); + expect(steps.indexOf(guard)).toBeLessThan(steps.findIndex((step: any) => step.name === 'Install pinned dependencies')); + const script = guard.run.match(/ -e '\n([\s\S]*)\n'\s*$/)?.[1]; + expect(script).toBeDefined(); + for (const input of ['false', 'true', '', 'unknown']) { + const result = spawnSync(process.execPath, ['--no-env-file', '--no-install', '--no-macros', `--config=${process.platform === 'win32' ? 'NUL' : '/dev/null'}`, '-e', script!], { + cwd: root, env: { ...process.env, OTHER_DIA_MODES: input }, encoding: 'utf8', timeout: 5000, + }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(input === 'false' ? 0 : 1); + expect(result.stderr.includes('must be selected alone')).toBe(input !== 'false'); + } +}); diff --git a/test/cookie-workflow-judge-input.test.ts b/test/cookie-workflow-judge-input.test.ts new file mode 100644 index 000000000..d66992ace --- /dev/null +++ b/test/cookie-workflow-judge-input.test.ts @@ -0,0 +1,333 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { buildCookieWorkflowJudgeInput, COOKIE_WORKFLOW_JUDGE } from './helpers/cookie-workflow-judge-input'; +import { buildWorkflowJudgePrompt, readWorkflowJudgeInput } from './helpers/workflow-judge-input'; +import { prepareWorkflowJudgeCache, type WorkflowCacheOptions } from './helpers/workflow-judge-cache'; +import type { EvalTestEntry } from './helpers/eval-store'; +import { selectTests } from './helpers/test-selection'; +import { E2E_TOUCHFILES, LLM_JUDGE_TOUCHFILES } from './helpers/touchfiles-data'; +import { selectPrProfile } from '../scripts/test-pr-profile'; +import { JUDGE_MS } from './helpers/eval-budgets'; +import { JudgeRefusalError, DEFAULT_JUDGE_MAX_TOKENS } from './helpers/llm-judge'; +import { COOKIE_MANUAL_REVIEW_FILE, getCookieWorkflowManualReview, isManualReviewEntry } from './helpers/cookie-workflow-manual-review'; + +const ROOT = resolve(import.meta.dir, '..'); +const NAME = 'setup-browser-cookies/SKILL.md workflow'; +const scratch: string[] = []; +const skill = 'excluded preamble\n# Setup Browser Cookies\nRead the gstack root\'s `BROWSER.md`, **Choosing a source and checking sign-in**.\nExact body.\n'; +const browser = 'excluded browser introduction\n#### Choosing a source and checking sign-in\nExact reference.\n\n### Tabs + frames\nexcluded tab commands\n'; + +function fixture(entry: string | null = skill, reference: string | null = browser): string { + const root = mkdtempSync(join(tmpdir(), 'gstack-cookie-judge-')); + scratch.push(root); + mkdirSync(join(root, 'setup-browser-cookies')); + if (entry !== null) writeFileSync(join(root, 'setup-browser-cookies/SKILL.md'), entry); + if (reference !== null) writeFileSync(join(root, 'BROWSER.md'), reference); + return root; +} + +function approveFixture(root: string) { + const input = buildCookieWorkflowJudgeInput(root); + const approval = { ...JSON.parse(readFileSync(join(ROOT, COOKIE_MANUAL_REVIEW_FILE), 'utf8')), + prompt_sha256: input.sha256, prompt_bytes: Buffer.byteLength(input.prompt), model: 'fixture-model', + reason: 'Synthetic approval fixture, not live review evidence' }; + mkdirSync(join(root, '.github'), { recursive: true }); + writeFileSync(join(root, COOKIE_MANUAL_REVIEW_FILE), JSON.stringify(approval)); + return approval; +} + +const refusal = () => new JudgeRefusalError({ id: 'msg_synthetic', _request_id: 'req_synthetic', + model: 'fixture-model', usage: { input_tokens: 1, output_tokens: 0 }, content: [] }); + +afterEach(() => { + for (const root of scratch.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +type FixtureScore = { clarity: number; completeness: number; actionability: number; reasoning: string }; +const passingScore: FixtureScore = { ...COOKIE_WORKFLOW_JUDGE.thresholds, reasoning: 'Synthetic fixture score' }; + +function actualCookieCallback(root: string, overrides: { + judge?: () => Promise; + clock?: () => number; + budget?: number; + setTimer?: typeof setTimeout; + clearTimer?: typeof clearTimeout; +} = {}) { + const source = readFileSync(join(ROOT, 'test/skill-llm-eval.test.ts'), 'utf8'); + const managedStart = source.indexOf('async function runWorkflowJudge'); + const managedEnd = source.indexOf('// Block 1:', managedStart); + const start = source.indexOf("describeIfSelected('Cookie setup workflow quality'"); + const end = source.indexOf('// Module-level afterAll', start); + expect(managedStart).toBeGreaterThan(-1); + expect(managedEnd).toBeGreaterThan(managedStart); + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + const registration = new Bun.Transpiler({ loader: 'ts' }).transformSync(source.slice(managedStart, managedEnd) + source.slice(start, end)); + const requests: Array<{ prompt: string; model: undefined; signal: AbortSignal }> = []; + const records: EvalTestEntry[] = []; + const attempts = new Map(); + let callback: () => Promise = async () => { throw new Error('Judge callback was not registered'); }; + new Function('describeIfSelected', 'testIfSelected', 'ROOT', 'buildCookieWorkflowJudgeInput', 'resolveEvalModel', 'callJudge', 'COOKIE_WORKFLOW_JUDGE', 'JUDGE_MS', 'WORKFLOW_JUDGE_TEST_MS', 'WORKFLOW_JUDGE_RECORD_MS', 'evalCollector', 'expect', 'console', 'readWorkflowJudgeInput', 'buildWorkflowJudgePrompt', 'prepareWorkflowJudgeCache', 'workflowJudgeAttempts', 'performance', 'setTimeout', 'clearTimeout', 'JudgeRefusalError', 'getCookieWorkflowManualReview', 'DEFAULT_JUDGE_MAX_TOKENS', registration)( + (_suite: string, names: string[], run: () => void) => { expect(names).toEqual([NAME]); run(); }, + (name: string, run: () => Promise, budget: number) => { expect(name).toBe(NAME); expect(budget).toBe(JUDGE_MS + 10_000); callback = run; }, + root, buildCookieWorkflowJudgeInput, () => 'fixture-model', + async (prompt: string, model: undefined, options: { signal: AbortSignal }) => { requests.push({ prompt, model, signal: options.signal }); return overrides.judge ? overrides.judge() : passingScore; }, + COOKIE_WORKFLOW_JUDGE, overrides.budget ?? JUDGE_MS, JUDGE_MS + 10_000, 5_000, + { addTest: (record: EvalTestEntry) => records.push(record) }, expect, { log() {} }, + readWorkflowJudgeInput, buildWorkflowJudgePrompt, + (options: WorkflowCacheOptions) => prepareWorkflowJudgeCache({ ...options, env: { + EVALS_CACHE_DIR: join(root, 'cache'), EVALS_CACHE_REPOSITORY: 'fixture/cookie', EVALS_CACHE_PR: '1', + EVALS_CACHE_RUNTIME_ID: 'a'.repeat(64), EVALS_TIER: 'gate', EVALS_RUN_ID: 'cookie-fixture', + } }), + attempts, overrides.clock ? { now: overrides.clock } : performance, + overrides.setTimer ?? setTimeout, overrides.clearTimer ?? clearTimeout, + JudgeRefusalError, getCookieWorkflowManualReview, DEFAULT_JUDGE_MAX_TOKENS, + ); + return { run: () => callback(), requests, records, attempts }; +} + +describe('cookie workflow judge input', () => { + test('the registered callback accepts only an approved empty provider refusal, with no score or cache credit', async () => { + const root = fixture(); + approveFixture(root); + const h = actualCookieCallback(root, { judge: async () => { throw refusal(); } }); + await h.run(); + expect(h.requests).toHaveLength(1); + expect(h.records).toHaveLength(1); + expect(h.records[0]).toMatchObject({ passed: false, execution: 'executed', exit_reason: 'provider_refusal' }); + expect(isManualReviewEntry(h.records[0])).toBe(true); + expect(h.records[0]).not.toHaveProperty('judge_scores'); + expect(h.records[0]).not.toHaveProperty('judge_reasoning'); + expect(h.records[0]).not.toHaveProperty('reused_from'); + expect(existsSync(join(root, 'cache'))).toBe(false); + }); + + test('an approval cannot turn low scores, malformed output, or a refusal-named ordinary error into acceptance', async () => { + const root = fixture(); approveFixture(root); + for (const result of [ + { ...passingScore, clarity: 1 }, + new SyntaxError('Malformed provider JSON'), + Object.assign(new Error('Synthetic refusal text'), { name: 'JudgeRefusalError' }), + ]) { + const h = actualCookieCallback(root, { judge: async () => { if (result instanceof Error) throw result; return result; } }); + await expect(h.run()).rejects.toThrow(); + expect(h.records).toHaveLength(1); + expect(h.records[0].passed).toBe(false); + expect(h.records[0]).not.toHaveProperty('manual_review'); + expect(isManualReviewEntry(h.records[0])).toBe(false); + } + const scored = actualCookieCallback(root); + await scored.run(); + expect(scored.records[0]).toMatchObject({ passed: true, judge_scores: COOKIE_WORKFLOW_JUDGE.thresholds }); + expect(scored.records[0]).not.toHaveProperty('manual_review'); + }); + + test.each(['prompt', 'model', 'budget', 'thresholds', 'malformed', 'missing', 'evidence', 'response-evidence'])( + 'the actual callback rejects an unapproved refusal: %s', async mismatch => { + const root = fixture(); const approval = approveFixture(root); + const error = refusal(); + if (mismatch === 'prompt') writeFileSync(join(root, 'BROWSER.md'), browser.replace('Exact reference.', 'Changed reference.')); + if (mismatch === 'model') approval.model = 'different-model'; + if (mismatch === 'budget') approval.max_tokens++; + if (mismatch === 'thresholds') approval.thresholds.clarity++; + if (mismatch === 'evidence') error.refusal.request_id = null; + if (mismatch === 'response-evidence') error.refusal.response_id = null; + writeFileSync(join(root, COOKIE_MANUAL_REVIEW_FILE), mismatch === 'malformed' ? '{}' : JSON.stringify(approval)); + if (mismatch === 'missing') rmSync(join(root, COOKIE_MANUAL_REVIEW_FILE)); + const h = actualCookieCallback(root, { judge: async () => { throw error; } }); + await expect(h.run()).rejects.toThrow(); + expect(h.records).toHaveLength(1); + expect(h.records[0].passed).toBe(false); + expect(h.records[0]).not.toHaveProperty('manual_review'); + expect(existsSync(join(root, 'cache'))).toBe(false); + }); + + test('a late refusal stays a timeout despite exact approval', async () => { + const root = fixture(); approveFixture(root); + let now = 0; + const h = actualCookieCallback(root, { budget: 20, clock: () => now, + judge: async () => { now = 20; throw refusal(); } }); + await expect(h.run()).rejects.toThrow('deadline'); + expect(h.records).toHaveLength(1); + expect(h.records[0]).toMatchObject({ passed: false, exit_reason: 'timeout' }); + expect(h.records[0]).not.toHaveProperty('manual_review'); + }); + + test('a retry refusal cannot erase an earlier scored failure with manual acceptance', async () => { + const root = fixture(); approveFixture(root); + let calls = 0; + const h = actualCookieCallback(root, { judge: async () => { + if (++calls === 1) return { ...passingScore, clarity: 1 }; + throw refusal(); + } }); + await expect(h.run()).rejects.toThrow(); + await expect(h.run()).rejects.toThrow('provider refused'); + expect(h.records.map(record => [record.attempt, record.passed, record.exit_reason])) + .toEqual([[1, false, 'validation_failed'], [2, false, 'provider_refusal']]); + for (const record of h.records) expect(record).not.toHaveProperty('manual_review'); + }); + + test('preserves exact source bytes, line ranges, and the immutable rubric', () => { + const input = buildCookieWorkflowJudgeInput(fixture()); + expect(input.files).toEqual([ + { path: 'setup-browser-cookies/SKILL.md', kind: 'entrypoint', startLine: 2, endLine: 4, content: skill.slice(skill.indexOf('# Setup')) }, + { path: 'BROWSER.md', kind: 'section', startLine: 2, endLine: 4, content: browser.slice(browser.indexOf('#### Choosing'), browser.indexOf('### Tabs')) }, + ]); + for (const file of input.files) { + expect(input.text).toContain(`--- BEGIN FILE ${JSON.stringify(file.path)} (lines ${file.startLine}-${file.endLine}; ${file.kind}) ---\n${file.content}\n--- END FILE ${JSON.stringify(file.path)} ---`); + expect(input.text.split(file.content)).toHaveLength(2); + } + expect(input.prompt).toBe(buildWorkflowJudgePrompt(COOKIE_WORKFLOW_JUDGE, input)); + expect(input.sha256).toBe(createHash('sha256').update(input.prompt).digest('hex')); + expect(input.prompt).not.toContain('excluded'); + expect(COOKIE_WORKFLOW_JUDGE.thresholds).toEqual({ clarity: 4, completeness: 3, actionability: 4 }); + }); + + test('preserves CRLF instead of rewriting excerpts', () => { + const input = buildCookieWorkflowJudgeInput(fixture(skill.replaceAll('\n', '\r\n'), browser.replaceAll('\n', '\r\n'))); + expect(input.files[0].content).toBe(skill.slice(skill.indexOf('# Setup')).replaceAll('\n', '\r\n')); + expect(input.files[1].content).toBe(browser.slice(browser.indexOf('#### Choosing'), browser.indexOf('### Tabs')).replaceAll('\n', '\r\n')); + expect(input.files.map(file => [file.startLine, file.endLine])).toEqual([[2, 4], [2, 4]]); + }); + + test('fingerprints consumed bytes, not excluded source text', () => { + const original = buildCookieWorkflowJudgeInput(fixture()); + expect(buildCookieWorkflowJudgeInput(fixture(skill.replace('Exact body.', 'Changed body.'))).sha256).not.toBe(original.sha256); + expect(buildCookieWorkflowJudgeInput(fixture(skill, browser.replace('Exact reference.', 'Changed reference.'))).sha256).not.toBe(original.sha256); + expect(buildCookieWorkflowJudgeInput(fixture(skill.replace('excluded preamble', 'different preamble'), browser.replace('excluded tab commands', 'different tab commands'))).sha256).toBe(original.sha256); + }); + + test('reads the actual generated workflow and only its referenced cookie section', () => { + const input = buildCookieWorkflowJudgeInput(ROOT); + const entry = readFileSync(join(ROOT, 'setup-browser-cookies/SKILL.md'), 'utf8'); + const reference = readFileSync(join(ROOT, 'BROWSER.md'), 'utf8'); + expect(input.files[0].content).toBe(entry.slice(entry.indexOf('# Setup Browser Cookies'))); + expect(input.files[1].content).toBe(reference.slice(reference.indexOf('#### Choosing a source and checking sign-in'), reference.indexOf('### Tabs + frames'))); + expect(input.files[0].content.length).toBeGreaterThan(500); + expect(input.files[1].content.length).toBeGreaterThan(500); + expect(input.prompt).toContain('GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY'); + expect(input.prompt).toContain('isolated world'); + expect(input.prompt).not.toContain('### Tabs + frames'); + }); + + test('fails closed when either source file is absent', () => { + expect(() => buildCookieWorkflowJudgeInput(fixture(null))).toThrow(); + expect(() => buildCookieWorkflowJudgeInput(fixture(skill, null))).toThrow(); + }); + + test('rejects missing, duplicate, or non-heading markers', () => { + for (const broken of [skill.replace('# Setup Browser Cookies', '# Missing'), skill + '# Setup Browser Cookies\nDuplicate\n', skill.replace('# Setup Browser Cookies', 'Quoted # Setup Browser Cookies')]) { + expect(() => buildCookieWorkflowJudgeInput(fixture(broken))).toThrow('expected exactly one marker'); + } + for (const marker of ['#### Choosing a source and checking sign-in', '### Tabs + frames']) { + expect(() => buildCookieWorkflowJudgeInput(fixture(skill, browser.replace(marker, 'missing')))).toThrow('expected exactly one marker'); + expect(() => buildCookieWorkflowJudgeInput(fixture(skill, browser + marker + '\n'))).toThrow('expected exactly one marker'); + } + }); + + test('rejects empty, reversed, or unreferenced excerpts', () => { + expect(() => buildCookieWorkflowJudgeInput(fixture('# Setup Browser Cookies\n'))).toThrow('empty or reversed'); + expect(() => buildCookieWorkflowJudgeInput(fixture(skill, '#### Choosing a source and checking sign-in\n\n### Tabs + frames\n'))).toThrow('empty or reversed'); + expect(() => buildCookieWorkflowJudgeInput(fixture(skill, '### Tabs + frames\n#### Choosing a source and checking sign-in\nContent\n'))).toThrow('empty or reversed'); + expect(() => buildCookieWorkflowJudgeInput(fixture(skill.replace('`BROWSER.md`', '`other.md`')))).toThrow('missing cookie reference link'); + expect(() => buildCookieWorkflowJudgeInput(fixture(skill.replace('**Choosing a source and checking sign-in**', '**Other section**')))).toThrow('missing cookie reference link'); + }); + + test('generated host workflows resolve shared references from the installation root and check Aside first', () => { + for (const file of ['setup-browser-cookies/SKILL.md', '.agents/skills/gstack-setup-browser-cookies/SKILL.md']) { + const source = readFileSync(join(ROOT, file), 'utf8'); + const body = source.slice(source.indexOf('# Setup Browser Cookies')); + expect(body).toContain('Use this checkout as the gstack root if it contains `BROWSER.md` and `browse/SKILL.md`'); + expect(body).toContain('the installed root containing `bin/gstack-skill-start`, never a generated host stub'); + expect(body).toContain("Read that root's `browse/SKILL.md`"); + expect(body).toContain('On `READY`, stop importing'); + expect(body.indexOf('**BROWSER SETUP**')).toBeLessThan(body.indexOf('## SETUP')); + expect(body).toContain("Read that same root's `BROWSER.md`, **Choosing a source and checking sign-in**"); + expect(body).not.toContain('../BROWSER.md'); + } + expect(readFileSync(join(ROOT, 'browse/SKILL.md'), 'utf8')).toContain('## BROWSER SETUP (Aside'); + expect(readFileSync(join(ROOT, 'BROWSER.md'), 'utf8')).toContain('#### Choosing a source and checking sign-in'); + }); + + test('each owned dependency selects this judge in the fast PR profile', () => { + for (const file of ['setup-browser-cookies/SKILL.md.tmpl', 'setup-browser-cookies/SKILL.md', 'BROWSER.md', 'test/helpers/cookie-workflow-judge-input.ts', 'test/cookie-workflow-judge-input.test.ts', 'test/helpers/cookie-workflow-manual-review.ts', 'test/cookie-workflow-manual-review.test.ts', 'test/helpers/manual-judge-review-fixture.ts', '.github/cookie-workflow-manual-review.json']) { + const selectedJudges = selectTests([file], LLM_JUDGE_TOUCHFILES).selected; + expect(selectedJudges).toEqual([NAME]); + const selectedE2E = selectTests([file], E2E_TOUCHFILES).selected; + const profile = selectPrProfile({ changedFiles: [file], selectedJudges, selectedE2E }); + expect(profile.judges).toEqual([NAME]); + expect(profile.deferredPromptFiles).toEqual([]); + expect(profile.missingCoverage).toEqual([]); + expect(profile.needsFullValidation).toBe(false); + } + expect(selectTests(['README.md'], LLM_JUDGE_TOUCHFILES).selected).not.toContain(NAME); + }); + + test('the managed callback sends and records exact custom input without borrowing a normal cache identity', async () => { + const root = fixture(); + const input = buildCookieWorkflowJudgeInput(root); + let scores = passingScore; + const h = actualCookieCallback(root, { judge: async () => scores }); + await h.run(); + expect(h.requests).toHaveLength(1); + expect(h.requests[0].prompt).toBe(input.prompt); + expect(h.requests[0].model).toBeUndefined(); + expect(h.requests[0].signal).toBeInstanceOf(AbortSignal); + expect(h.records[0]).toMatchObject({ name: NAME, prompt: input.prompt, model: 'fixture-model', execution: 'executed', passed: true }); + expect(existsSync(join(root, 'cache'))).toBe(false); + const fresh = actualCookieCallback(root); + await fresh.run(); + expect(fresh.requests).toHaveLength(1); + for (const dimension of ['clarity', 'completeness', 'actionability'] as const) { + scores = { ...COOKIE_WORKFLOW_JUDGE.thresholds, [dimension]: COOKIE_WORKFLOW_JUDGE.thresholds[dimension] - 1, reasoning: 'Synthetic failing fixture score' }; + await expect(h.run()).rejects.toThrow(); + expect(h.records.at(-1)).toMatchObject({ passed: false, exit_reason: 'validation_failed', prompt: input.prompt }); + } + expect(existsSync(join(root, 'cache'))).toBe(false); + }); + + test('custom input timeout aborts once and a late response cannot overwrite its successful retry', async () => { + const root = fixture(); + let completeLate!: (value: FixtureScore) => void; + let calls = 0, now = 0, timerId = 0; + const timers = new Map void>(); + const h = actualCookieCallback(root, { + budget: 20, clock: () => now, + setTimer: ((callback: () => void) => { timers.set(++timerId, callback); return timerId; }) as any, + clearTimer: ((id: number) => { timers.delete(id); }) as any, + judge: async () => ++calls === 1 ? new Promise(resolve => { completeLate = resolve; }) : passingScore, + }); + const expired = h.run().catch((error: Error) => error); + now = 20; + for (const callback of [...timers.values()]) callback(); + expect((await expired as Error).message).toContain('deadline'); + expect(h.records).toHaveLength(1); + expect(h.records[0]).toMatchObject({ passed: false, exit_reason: 'timeout', prompt: buildCookieWorkflowJudgeInput(root).prompt }); + expect(h.requests[0].signal.aborted).toBe(true); + await h.run(); + expect(h.records.map(record => record.passed)).toEqual([false, true]); + expect(h.attempts.get(NAME)?.attempt).toBe(2); + completeLate(passingScore); + await new Promise(resolve => setImmediate(resolve)); + expect(h.records).toHaveLength(2); + expect(existsSync(join(root, 'cache'))).toBe(false); + }); + + test('a superseding custom-input attempt cancels its predecessor without stale recording', async () => { + let completeLate!: (value: FixtureScore) => void; + let calls = 0; + const h = actualCookieCallback(fixture(), { + judge: async () => ++calls === 1 ? new Promise(resolve => { completeLate = resolve; }) : passingScore, + }); + const old = h.run().catch((error: Error) => error); + await h.run(); + expect((await old as Error).name).toBe('WorkflowJudgeSuperseded'); + expect(h.requests[0].signal.aborted).toBe(true); + completeLate(passingScore); + await new Promise(resolve => setImmediate(resolve)); + expect(h.records.map(record => [record.passed, record.exit_reason])).toEqual([[false, 'cancelled'], [true, undefined]]); + }); +}); diff --git a/test/cookie-workflow-manual-review.test.ts b/test/cookie-workflow-manual-review.test.ts new file mode 100644 index 000000000..9df2c8a81 --- /dev/null +++ b/test/cookie-workflow-manual-review.test.ts @@ -0,0 +1,103 @@ +import { afterEach, expect, test } from 'bun:test'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { buildCookieWorkflowJudgeInput, COOKIE_WORKFLOW_JUDGE } from './helpers/cookie-workflow-judge-input'; +import { COOKIE_MANUAL_REVIEW_FILE, getCookieWorkflowManualReview, isManualReviewEntry, manualReviewProblem } from './helpers/cookie-workflow-manual-review'; +import { manualReviewFixture } from './helpers/manual-judge-review-fixture'; +import { validWorkflowJudgeScore } from './helpers/workflow-judge-cache'; + +const ROOT = resolve(import.meta.dir, '..'); +const roots: string[] = []; +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'cookie-policy-')); roots.push(root); + for (const file of [COOKIE_MANUAL_REVIEW_FILE, 'setup-browser-cookies/SKILL.md', 'BROWSER.md']) { + const target = join(root, file); mkdirSync(resolve(target, '..'), { recursive: true }); + writeFileSync(target, readFileSync(join(ROOT, file))); + } + const entry = manualReviewFixture(root); + const approval = entry.manual_review!.approval; + const request = { testName: entry.name, prompt: entry.prompt!, model: entry.model!, + maxTokens: approval.max_tokens, thresholds: { ...approval.thresholds }, attempt: 1 }; + return { root, entry, approval, request, refusal: entry.manual_review!.refusal }; +} + +test('the committed approval names precisely the complete reviewed request, not a numerical score', () => { + const f = fixture(); + expect(buildCookieWorkflowJudgeInput(ROOT).sha256).toBe(f.approval.prompt_sha256); + expect(f.approval.thresholds).toEqual(COOKIE_WORKFLOW_JUDGE.thresholds); + expect(isManualReviewEntry(f.entry)).toBe(true); + expect(manualReviewProblem(f.entry, ROOT)).toBeNull(); + expect(getCookieWorkflowManualReview(f.root, f.request, f.refusal)).toEqual(f.entry.manual_review); + expect(validWorkflowJudgeScore(f.entry as any, f.request.thresholds)).toBe(false); + expect(validWorkflowJudgeScore(f.entry.manual_review as any, f.request.thresholds)).toBe(false); +}); + +test.each(['case', 'prompt', 'model', 'budget', 'thresholds', 'source', 'approval', 'retry'])( + 'admission rejects mismatched %s without changing the approval', mismatch => { + const f = fixture(); const original = readFileSync(join(f.root, COOKIE_MANUAL_REVIEW_FILE), 'utf8'); + if (mismatch === 'case') f.request.testName = 'ship/SKILL.md workflow'; + if (mismatch === 'prompt') f.request.prompt += '\n'; + if (mismatch === 'model') f.request.model += '-other'; + if (mismatch === 'budget') f.request.maxTokens++; + if (mismatch === 'thresholds') f.request.thresholds.clarity++; + if (mismatch === 'retry') f.request.attempt++; + if (mismatch === 'source') writeFileSync(join(f.root, 'BROWSER.md'), readFileSync(join(f.root, 'BROWSER.md'), 'utf8').replace('Storage stays intact', 'Changed storage stays intact')); + if (mismatch === 'approval') writeFileSync(join(f.root, COOKIE_MANUAL_REVIEW_FILE), JSON.stringify({ ...f.approval, prompt_sha256: 'a'.repeat(64) })); + expect(getCookieWorkflowManualReview(f.root, f.request, f.refusal)).toBeNull(); + if (mismatch !== 'approval') expect(readFileSync(join(f.root, COOKIE_MANUAL_REVIEW_FILE), 'utf8')).toBe(original); + }); + +test.each(['passed', 'scores', 'reused', 'timeout', 'case', 'suite', 'prompt', 'model', 'refusal', 'missing-id', 'missing-response-id', 'output', 'text', 'tokens', 'schema', 'retry'])( + 'malformed manual receipt %s cannot count as acceptance', invalid => { + const f = fixture(); const entry: any = f.entry; + if (invalid === 'passed') entry.passed = true; + if (invalid === 'retry') entry.attempt++; + if (invalid === 'scores') entry.judge_scores = { clarity: 1 }; + if (invalid === 'reused') entry.execution = 'reused'; + if (invalid === 'timeout') entry.exit_reason = 'timeout'; + if (invalid === 'case') entry.name = 'ship/SKILL.md workflow'; + if (invalid === 'suite') entry.suite = 'Unrelated suite'; + if (invalid === 'prompt') entry.prompt += '\n'; + if (invalid === 'model') entry.model = 'another-model'; + if (invalid === 'refusal') entry.manual_review.refusal.stop_reason = 'end_turn'; + if (invalid === 'missing-id') entry.manual_review.refusal.request_id = null; + if (invalid === 'missing-response-id') entry.manual_review.refusal.response_id = null; + if (invalid === 'output') entry.manual_review.refusal.output_tokens = 1; + if (invalid === 'text') entry.manual_review.refusal.text_blocks = 1; + if (invalid === 'tokens') entry.manual_review.refusal.input_tokens = -1; + if (invalid === 'schema') entry.manual_review.approval.schema_version = 2; + expect(isManualReviewEntry(entry)).toBe(false); + expect(manualReviewProblem(entry, f.root)).not.toBeNull(); + }); + +test('reconciliation rejects changed source, approval provenance, missing approval and corrupt JSON', () => { + const f = fixture(); + const modified = structuredClone(f.entry); + modified.manual_review!.approval.approved_by = 'unapproved-reviewer'; + expect(manualReviewProblem(modified, f.root)).not.toBeNull(); + const file = join(f.root, COOKIE_MANUAL_REVIEW_FILE); + writeFileSync(file, '{'); + expect(manualReviewProblem(f.entry, f.root)).not.toBeNull(); + rmSync(file); + expect(manualReviewProblem(f.entry, f.root)).not.toBeNull(); + writeFileSync(file, JSON.stringify(f.approval)); + writeFileSync(join(f.root, 'BROWSER.md'), readFileSync(join(f.root, 'BROWSER.md'), 'utf8').replace('Storage stays intact', 'Changed storage stays intact')); + expect(manualReviewProblem(f.entry, f.root)).not.toBeNull(); + expect(isManualReviewEntry(f.entry)).toBe(true); +}); + +test('a current judge-model override cannot reuse the different approved model', () => { + const f = fixture(); + const original = process.env.GSTACK_EVAL_MODEL_JUDGE; + try { + process.env.GSTACK_EVAL_MODEL_JUDGE = 'synthetic-unapproved-model'; + expect(manualReviewProblem(f.entry, f.root)).toBe('Manual review model is not the current judge model'); + expect(isManualReviewEntry(f.entry)).toBe(true); + } finally { + if (original === undefined) delete process.env.GSTACK_EVAL_MODEL_JUDGE; + else process.env.GSTACK_EVAL_MODEL_JUDGE = original; + } +}); diff --git a/test/cso-ntfs-fixture.test.ts b/test/cso-ntfs-fixture.test.ts new file mode 100644 index 000000000..19b2f6e54 --- /dev/null +++ b/test/cso-ntfs-fixture.test.ts @@ -0,0 +1,78 @@ +import { afterEach, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { createPrecisionLossCandidate } from './helpers/cso-ntfs-fixture'; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +function fixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ntfs-fixture-')); + roots.push(root); + return { root, target: path.join(root, 'candidate.json') }; +} + +test('the actual candidate builder reaches a precision-losing generation in a fresh allocator model', () => { + const { root, target } = fixture(); + const generations = new Map(); + let reads = 0; + let selectedIdentity: bigint | undefined; + const inode = createPrecisionLossCandidate(target, 'owned candidate', file => { + reads++; + const slot = Number(path.basename(file).split('-').at(-1)); + const generation = (generations.get(slot) ?? 0n) + 1n; + generations.set(slot, generation); + const result = (generation << 48n) + BigInt(101 + slot); + if (result > BigInt(Number.MAX_SAFE_INTEGER) && String(Number(result)) !== String(result)) { + selectedIdentity = fs.lstatSync(file, { bigint: true }).ino; + } + return result; + }); + expect(inode).toBeGreaterThan(BigInt(Number.MAX_SAFE_INTEGER)); + expect(String(Number(inode))).not.toBe(String(inode)); + expect(reads).toBeLessThanOrEqual(1024); + expect(fs.lstatSync(target, { bigint: true }).ino).toBe(selectedIdentity); + expect(fs.readFileSync(target, 'utf8')).toBe('owned candidate'); + expect(fs.readdirSync(root)).toEqual(['candidate.json']); +}); + +test('an unavailable precision-losing ID fails at the original creation bound and leaves no candidate', () => { + const { root, target } = fixture(); + for (const inode of [1n, 2n ** 54n]) { + let reads = 0; + expect(() => createPrecisionLossCandidate(target, 'owned candidate', () => { reads++; return inode; })) + .toThrow('within 1024 file creations'); + expect(reads).toBe(1024); + expect(fs.readdirSync(root)).toEqual([]); + } +}); + +test('existing targets and candidate-inspection failures never overwrite unrelated fixture state', () => { + const { root, target } = fixture(); + fs.writeFileSync(target, 'preserved'); + expect(() => createPrecisionLossCandidate(target, 'replacement')).toThrow('already exists'); + expect(fs.readFileSync(target, 'utf8')).toBe('preserved'); + const next = path.join(root, 'next.json'); + expect(() => createPrecisionLossCandidate(next, 'owned candidate', () => { throw new Error('inspection failed'); })) + .toThrow('inspection failed'); + expect(fs.readdirSync(root)).toEqual(['candidate.json']); +}); + +test('a concurrent target and a linked parent are preserved rather than written through', () => { + const { root, target } = fixture(); + expect(() => createPrecisionLossCandidate(target, 'replacement', () => { + fs.writeFileSync(target, 'concurrent fixture'); + return (1n << 54n) + 1n; + })).toThrow(); + expect(fs.readFileSync(target, 'utf8')).toBe('concurrent fixture'); + const owned = path.join(root, 'owned'); + const link = path.join(root, 'linked'); + fs.mkdirSync(owned); + fs.symlinkSync(owned, link, process.platform === 'win32' ? 'junction' : 'dir'); + expect(() => createPrecisionLossCandidate(path.join(link, 'candidate.json'), 'replacement')) + .toThrow('must not be linked'); + expect(fs.readdirSync(owned)).toEqual([]); +}); diff --git a/test/cso-windows-launcher.test.ts b/test/cso-windows-launcher.test.ts index 41709df1a..ad1ae77d4 100644 --- a/test/cso-windows-launcher.test.ts +++ b/test/cso-windows-launcher.test.ts @@ -4,6 +4,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { createHash } from 'node:crypto'; import { spawn, spawnSync } from 'node:child_process'; +import { createPrecisionLossCandidate } from './helpers/cso-ntfs-fixture'; const ROOT = path.resolve(import.meta.dir, '..'); const windows = process.platform === 'win32'; @@ -347,19 +348,11 @@ describe('CSO native Windows build contract', () => { if(i!==2)continue; const token='d'.repeat(32),candidate=path.join(leases,`${token}.json`),decision=path.join(leases,`${token}.decision`); for(const rounded of [false,true]){ - let selected:string|undefined; - for(let batch=0;batch<16&&!selected;batch++){ - const paths:string[]=[]; - for(let index=0;index<64;index++){ - const file=path.join(leases,`fixture-${batch}-${index}`); - fs.writeFileSync(file,JSON.stringify({pid:2147483647,token,createdAt:0})+'\n');paths.push(file); - const inode=fs.lstatSync(file,{bigint:true}).ino; - if(!selected&&inode>BigInt(Number.MAX_SAFE_INTEGER)&&String(Number(inode))!==String(inode))selected=file; - } - for(const file of paths){if(file===selected)fs.renameSync(file,candidate);else fs.unlinkSync(file);} - } - expect(selected).toBeDefined(); + const selected=createPrecisionLossCandidate(candidate,JSON.stringify({pid:2147483647,token,createdAt:0})+'\n'); const stat=fs.lstatSync(candidate,{bigint:true}),record={schemaVersion:1,token,kind:'ticket',ticket:'0000000000000001',candidateDev:String(stat.dev),candidateIno:rounded?String(Number(stat.ino)):String(stat.ino),ownerPid:2147483647,ownerCreatedAt:0,publisherPid:2147483647,createdAt:0}; + expect(stat.ino).toBe(selected); + expect(stat.ino).toBeGreaterThan(BigInt(Number.MAX_SAFE_INTEGER)); + expect(String(Number(stat.ino))).not.toBe(String(stat.ino)); fs.writeFileSync(decision,JSON.stringify(record)+'\n'); const result=command(['resume',run.runId]);expect(result.status).not.toBe(0); if(rounded){ diff --git a/test/dx-manual-handoff-ao.test.ts b/test/dx-manual-handoff-ao.test.ts index 2093faecd..f4319bd2d 100644 --- a/test/dx-manual-handoff-ao.test.ts +++ b/test/dx-manual-handoff-ao.test.ts @@ -29,7 +29,7 @@ describe('AO completed manual DX handoff preserves report freshness',()=>{ expect(E2E_TOUCHFILES[owner]).toContain('test/fixtures/dx-manual-handoff-ao.json'); } const arrays=[...Object.values(E2E_TOUCHFILES),...Object.values(LLM_JUDGE_TOUCHFILES),GLOBAL_TOUCHFILES]; - expect(arrays).toHaveLength(233); // Upstream owners plus eleven shared-code audit/review evals. + expect(arrays).toHaveLength(234); for(const values of arrays)for(let i=0;i{ diff --git a/test/eng-finding-retry-budget.test.ts b/test/eng-finding-retry-budget.test.ts index 83c80d669..0b0323e31 100644 --- a/test/eng-finding-retry-budget.test.ts +++ b/test/eng-finding-retry-budget.test.ts @@ -176,9 +176,9 @@ test('current detach supervision covers the live-census floor', () => { const floor = Math.ceil((Math.ceil(files.length / DEFAULT_JOBS) * DEFAULT_SHARD_TIMEOUT_MS + excess) / 1000 * 1.05); const pkg = JSON.parse(fs.readFileSync(path.join(import.meta.dir, '../package.json'), 'utf8')); const configured = Number(pkg.scripts['eval:bg:periodic'].match(/--timeout\s+(\d+)/)[1]); - expect(floor).toBe(64995); + expect(floor).toBe(65268); expect(configured).toBeGreaterThanOrEqual(floor); - expect(pkg.scripts['eval:bg:gate']).toContain('--timeout 33600'); + expect(pkg.scripts['eval:bg:gate']).toContain('--timeout 33800'); }); for (const jobs of [1, 2, 3]) test(`FIFO bound covers partial durations with ${jobs} workers`, () => { diff --git a/test/eng-seeded-completion-ai.test.ts b/test/eng-seeded-completion-ai.test.ts index 8900462ac..0fe2745b0 100644 --- a/test/eng-seeded-completion-ai.test.ts +++ b/test/eng-seeded-completion-ai.test.ts @@ -127,7 +127,7 @@ async function mockedObservation(frames: string[], verdict: 'waiting' | 'working expect(start).toBeGreaterThan(0); expect(end).toBeGreaterThan(start); const executable = source.slice(start, end).replace('export async function', 'async function') + '\nreturn runPlanSkillObservation;'; const js = new Bun.Transpiler({ loader: 'ts' }).transformSync(executable); - let clock = 0, tick = -1, closed = 0, judged = 0; + let clock = 0, tick = -1, closed = 0, judged = 0, seedSubmittedAt: number | null = null; const current = () => frames[Math.min(Math.max(tick, 0), frames.length - 1)]!; const args: Record = { path, process: { cwd: () => '/synthetic-owned' }, Date: { now: () => clock }, randomUUID: () => 'owned', @@ -136,7 +136,7 @@ async function mockedObservation(frames: string[], verdict: 'waiting' | 'working visibleSince: current, rawOutput: current, currentScreen: async () => current(), hermeticConfigDir: null, close: async () => { closed++; } }), createPlanCountSnapshotWriter: () => () => ({}), logPtySnapshot: () => {}, - submitPlanSeed: async () => {}, PlanSeedTimeout: class extends Error {}, + submitPlanSeed: async () => { seedSubmittedAt = clock; }, PlanSeedTimeout: class extends Error {}, isRejectedSlashCommand: predicates.isRejectedSlashCommand, isProseAUQVisible: predicates.isProseAUQVisible, isPlanReadyVisible: predicates.isPlanReadyVisible, isUnknownSlashCommandVisible: predicates.isUnknownSlashCommandVisible, @@ -149,9 +149,18 @@ async function mockedObservation(frames: string[], verdict: 'waiting' | 'working const obs = await run({ skillName: 'plan-eng-review', timeoutMs: 70000, ...(seeded ? { initialPlanContent: '# Plan: Required draft' } : {}) }); expect(closed).toBe(1); - return { obs, judged }; + return { obs, judged, seedSubmittedAt }; } +test('seeded preflight checks owned readiness without spending eight seconds before submission', async () => { + const seeded = await mockedObservation([gate], 'working'); + expect(seeded.seedSubmittedAt).toBe(0); + expect(seeded.obs.outcome).toBe('plan_ready'); + const unseeded = await mockedObservation([gate], 'working', false); + expect(unseeded.seedSubmittedAt).toBeNull(); + expect(unseeded.obs.outcome).toBe('plan_ready'); +}); + for (const [name, current] of [ ['cursorless approval', gate.replace('❯ ', '')], ['partial approval', gate.split('\n').slice(0, -1).join('\n')], diff --git a/test/eval-cli-family.test.ts b/test/eval-cli-family.test.ts index 8ef6fc9a0..4f8717e64 100644 --- a/test/eval-cli-family.test.ts +++ b/test/eval-cli-family.test.ts @@ -29,11 +29,22 @@ import * as os from 'os'; import * as path from 'path'; import { runBin } from './helpers/run-bin'; import { selectTests, E2E_TOUCHFILES, LLM_JUDGE_TOUCHFILES, GLOBAL_TOUCHFILES } from './helpers/touchfiles'; +import { manualReviewFixture } from './helpers/manual-judge-review-fixture'; +import { renderDashboard } from '../scripts/eval-watch'; const ROOT = path.resolve(import.meta.dir, '..'); const SCRIPT = (name: string) => path.join(ROOT, 'scripts', name); const SLUG = 'eval-cli-fixture'; +test('eval:watch distinguishes unscored manual acceptance from malformed claims', () => { + const manual = manualReviewFixture(); + const output = renderDashboard(null, { tests: [manual, { ...manual, passed: true }], total_cost_usd: 0 }); + expect(output).toContain('MANUAL/unscored'); + expect(output).toContain(manual.manual_review!.approval.approval_url); + expect(output).toContain('Manual accepted: 1'); + expect(output).toContain('✗'); +}); + let tmpHome: string; let evalDir: string; @@ -256,6 +267,22 @@ describe('eval:list CLI (scripts/eval-list.ts)', () => { // ── eval-compare ───────────────────────────────────────────────────────────── describe('eval:compare CLI (scripts/eval-compare.ts)', () => { + test('shows a manual transition without calling it a scored regression', () => { + const manual = manualReviewFixture(); + const prior = writeRun(evalDir, { tier: 'llm-judge', timestamp: '2026-01-01T01:00:00Z', + tests: [{ name: manual.name, passed: true }] }); + const after = writeRun(evalDir, { tier: 'llm-judge', timestamp: '2026-01-02T01:00:00Z', + tests: [{ name: manual.name, passed: false }] }); + const body = JSON.parse(fs.readFileSync(after, 'utf8')); + body.tests = [manual]; body.passed = 0; body.failed = 0; body.manual_accepted_tests = 1; + fs.writeFileSync(after, JSON.stringify(body)); + const result = runEvalCli('eval-compare.ts', prior, after); + expect(result.status).toBe(0); + expect(result.stdout).toContain('PASS → MANUAL'); + expect(result.stdout).toContain('unscored manual review'); + expect(result.stdout).not.toContain('REGRESSION:'); + }); + test('empty eval dir prints the getting-started hint and exits 0', () => { const result = runEvalCli('eval-compare.ts'); expect(result.status).toBe(0); @@ -337,6 +364,40 @@ describe('eval:compare CLI (scripts/eval-compare.ts)', () => { // ── eval-summary ───────────────────────────────────────────────────────────── describe('eval:summary CLI (scripts/eval-summary.ts)', () => { + test('reports manual provenance without inventing a scored flake', () => { + const manual = manualReviewFixture(); + writeRun(evalDir, { tier: 'llm-judge', timestamp: '2026-01-01T01:00:00Z', + tests: [{ name: manual.name, passed: true }] }); + const accepted = writeRun(evalDir, { tier: 'llm-judge', timestamp: '2026-01-02T01:00:00Z', + tests: [{ name: manual.name, passed: false }] }); + const body = JSON.parse(fs.readFileSync(accepted, 'utf8')); + body.tests = [manual]; body.passed = 0; body.failed = 0; body.manual_accepted_tests = 1; + fs.writeFileSync(accepted, JSON.stringify(body)); + const result = runEvalCli('eval-summary.ts'); + expect(result.status).toBe(0); + expect(result.stdout).toContain('Manual accepted: 1 unscored provider refusal'); + expect(result.stdout).toContain(manual.manual_review!.approval.approval_url); + expect(result.stdout).not.toContain('Flaky tests'); + }); + + test('retried manual claims remain failed in summary history', () => { + const manual = manualReviewFixture(); + writeRun(evalDir, { tier: 'llm-judge', timestamp: '2026-01-01T01:00:00Z', + tests: [{ name: manual.name, passed: true }] }); + const attempted = writeRun(evalDir, { tier: 'llm-judge', timestamp: '2026-01-02T01:00:00Z', + tests: [{ name: manual.name, passed: false }] }); + const body = JSON.parse(fs.readFileSync(attempted, 'utf8')); + const { manual_review: _receipt, ...ordinary } = manual; + body.tests = [{ ...ordinary, attempt: 1 }, { ...manual, attempt: 2 }]; + body.total_tests = 2; body.passed = 0; body.failed = 2; + fs.writeFileSync(attempted, JSON.stringify(body)); + const result = runEvalCli('eval-summary.ts'); + expect(result.status).toBe(0); + expect(result.stdout).not.toContain('Manual accepted:'); + expect(result.stdout).toContain('Flaky tests (1):'); + expect(result.stdout).toContain(`llm-judge:${manual.name}`); + }); + test('empty eval dir prints the getting-started hint and exits 0', () => { const result = runEvalCli('eval-summary.ts'); expect(result.status).toBe(0); diff --git a/test/eval-flake-rank.test.ts b/test/eval-flake-rank.test.ts index 661b187f4..198a64d54 100644 --- a/test/eval-flake-rank.test.ts +++ b/test/eval-flake-rank.test.ts @@ -10,7 +10,9 @@ import { describe, expect, test } from 'bun:test'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; +import { spawnSync } from 'node:child_process'; import { aggregate, collectEvalFiles } from '../scripts/eval-flake-rank'; +import { manualReviewFixture } from './helpers/manual-judge-review-fixture'; const entry = (name: string, passed: boolean, attempt: number) => ({ name, suite: 's', tier: 'e2e', passed, attempt, duration_ms: 1000, cost_usd: 0.1, @@ -24,6 +26,33 @@ const run = (tests: object[], extra: object = {}) => JSON.stringify({ }); describe('eval-flake-rank aggregate', () => { + test('manual acceptance is visible but not a scored failure or retried pass', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'flakerank-manual-')); + const manual = manualReviewFixture(); + const { manual_review: _receipt, ...ordinary } = manual; + fs.writeFileSync(path.join(dir, 'prior.json'), run([{ ...ordinary, passed: true, exit_reason: 'success', + judge_scores: { clarity: 4, completeness: 3, actionability: 4 } }])); + fs.writeFileSync(path.join(dir, 'accepted.json'), run([manual])); + const series = aggregate(collectEvalFiles(dir)).get(manual.name); + expect(series).toMatchObject({ runs: 1, passes: 1, fails: 0, manualAccepted: 1, + retriedPasses: 0, totalAttempts: 2 }); + const display = spawnSync(process.execPath, [path.resolve(import.meta.dir, '../scripts/eval-flake-rank.ts'), '--dir', dir], + { encoding: 'utf8', timeout: 10_000 }); + expect(display.status, display.stderr).toBe(0); + expect(display.stdout).toContain('fails/runs manual'); + expect(display.stdout).toContain('0/1'); + expect(display.stdout).toContain(manual.name); + fs.writeFileSync(path.join(dir, 'invalid-retry.json'), run([ + { ...ordinary, attempt: 1 }, { ...manual, attempt: 2 }, + ])); + expect(aggregate(collectEvalFiles(dir)).get(manual.name)).toMatchObject({ runs: 2, passes: 1, + fails: 1, manualAccepted: 1, retriedPasses: 0 }); + fs.writeFileSync(path.join(dir, 'invalid-pass.json'), run([{ ...manual, passed: true }])); + expect(aggregate(collectEvalFiles(dir)).get(manual.name)).toMatchObject({ runs: 3, passes: 1, + fails: 2, manualAccepted: 1, retriedPasses: 0 }); + fs.rmSync(dir, { recursive: true, force: true }); + }); + test('final attempt decides; retried pass counts as retriedPass, not a fail', () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'flakerank-')); fs.writeFileSync(path.join(dir, 'run1.json'), run([ diff --git a/test/eval-list-cli.test.ts b/test/eval-list-cli.test.ts index cffc68e09..949e4d880 100644 --- a/test/eval-list-cli.test.ts +++ b/test/eval-list-cli.test.ts @@ -3,6 +3,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { spawnSync } from 'child_process'; +import { manualReviewFixture } from './helpers/manual-judge-review-fixture'; const ROOT = path.resolve(import.meta.dir, '..'); @@ -75,6 +76,25 @@ function runEvalList(...args: string[]): { stdout: string; stderr: string; statu } describe('eval:list CLI', () => { + test('labels validated manual acceptance as unscored and rejects malformed pass claims', () => { + const manual = manualReviewFixture(); + const dir = path.join(tmpHome, '.gstack-dev', 'evals'); + const body = (entry: typeof manual, timestamp: string) => ({ + schema_version: 2, version: '1.90.0', branch: 'main', git_sha: 'fixture', timestamp, + tier: 'llm-judge', total_tests: 1, passed: 0, failed: 0, total_cost_usd: 0, + total_duration_ms: 1, tests: [entry], + }); + fs.writeFileSync(path.join(dir, 'manual.json'), JSON.stringify(body(manual, '2026-05-24T03:00:00Z'))); + fs.writeFileSync(path.join(dir, 'invalid.json'), JSON.stringify(body({ ...manual, passed: true }, '2026-05-24T04:00:00Z'))); + const result = runEvalList('--limit', '2'); + expect(result.status).toBe(0); + const lines = result.stdout.split('\n'); + expect(lines.find(line => line.includes('03:00'))).toContain('MANUAL/unscored'); + expect(lines.find(line => line.includes('03:00'))).toContain(manual.manual_review!.approval.approval_url); + expect(lines.find(line => line.includes('04:00'))).toContain('0/1'); + expect(lines.find(line => line.includes('04:00'))).not.toContain('MANUAL'); + }); + test('limits displayed eval runs with a valid positive integer', () => { const result = runEvalList('--limit', '1'); diff --git a/test/fixtures/shared-libs-index-flags-native-questions.json b/test/fixtures/shared-libs-index-flags-native-questions.json new file mode 100644 index 000000000..e81e6bc4c --- /dev/null +++ b/test/fixtures/shared-libs-index-flags-native-questions.json @@ -0,0 +1,133 @@ +{ + "sourceRun": 36036582724, + "sourceRevision": "0d455d385272fee9fcd873951605e2cf46d8757c", + "cases": [ + { + "attempt": 1, + "input": { + "questions": [ + { + "question": "Core checklist pass: no defects. 1 optional advisory needs your input (prior Skip could NOT be reused — see note).\n\n1. [ADVISORY] src/retry-worker.ts:2 — retrySeconds is re-implemented byte-for-byte identical to lib/retry-after.ts:2–15 (a tested helper already used by src/scheduler.ts:1). src/retry-route.ts:2–15 carries the same copy. Three copies of the 3600s-ceiling / date-parse / fallback contract will drift.\n Fix: worker → `export { retrySeconds } from '../lib/retry-after';` (this is exactly what HEAD had). Same one-liner for the route. Existing test/retry-after.test.ts covers the contract; no new tests needed. Est. impl: −28 / +2 = −26 lines; total −30 / +2 = −28 (comment lines included). Blast radius: a helper bug now hits worker + route + scheduler together — but they already run identical logic.\n\nNOTE: src/retry-route.ts is flagged skip-worktree in the index and its on-disk bytes differ from the tracked blob (an extra trailing comment). git diff/status hide this. Any edit to that file will also be invisible to Git until the flag is cleared.\n\nRECOMMENDATION: A — reuse the helper in both files; the worker change is inside this diff and the route copy is the same duplication.", + "header": "Advisory #1", + "options": [ + { "label": "A) Fix both (Recommended)", "description": "Replace the duplicated function in src/retry-worker.ts AND src/retry-route.ts with a re-export from lib/retry-after.ts." }, + { "label": "A') Fix worker only", "description": "Re-export in src/retry-worker.ts only (the changed file in this diff); leave src/retry-route.ts as-is." }, + { "label": "B) Skip", "description": "Keep the duplicated implementation. Recorded as an explicit skipped advisory (route excluded from reusable coverage)." } + ], + "multiSelect": false + }, + { + "question": "Separately: should I clear the skip-worktree flag on src/retry-route.ts (`git update-index --no-skip-worktree`) so its hidden local change becomes visible to git status/diff? This changes only the local index flag, not file contents.", + "header": "skip-worktree", + "options": [ + { "label": "Clear the flag (Recommended)", "description": "Make the hidden modification to src/retry-route.ts visible so it can be reviewed/committed normally." }, + { "label": "Leave it", "description": "Keep the flag; the route change stays hidden from Git and is reported only in this review summary." } + ], + "multiSelect": false + } + ] + } + }, + { + "attempt": 2, + "input": { + "questions": [ + { + "question": "1. [ADVISORY] src/retry-worker.ts:2 — This diff replaces the worker's `export { retrySeconds } from '../lib/retry-after'` (HEAD) with a verbatim 14-line copy of the helper. Verified callers: src/retry-worker.ts:2-15 (changed anchor) and src/retry-route.ts:2-15 (authored, first-party) both duplicate lib/retry-after.ts:2-15 `retrySeconds` byte-for-byte — same signature, fallback=5 default, 3600s cap, negative→0, unsafe-int→fallback. The helper is already tested (test/retry-after.test.ts 'retry parser contract') and in established use (src/scheduler.ts:1-2). No preserved differences exist; same language/repo/relative-import layout. Smallest adoption: re-export from lib in both files. Estimated implementation: removed 30, added 2, saved 28 (worker −15/+1, route −15/+1). Total incl. tests: same — existing test already covers the contract; grows by ~5-8 lines only if you want an export-identity integration test. Shared-failure blast radius: unchanged (all three already run identical logic; a bug would just become fixable in one place). Note: src/retry-route.ts is flagged assume-unchanged in your index, so any edit to it is invisible to `git diff`/`git commit -a` until the flag is cleared. RECOMMENDATION: A — reuse the proven helper in both callers. Which do you want?", + "header": "Shared-libs", + "multiSelect": false, + "options": [ + { "label": "A) Fix both callers (Recommended)", "description": "Restore the one-line re-export in src/retry-worker.ts and replace the duplicate body in src/retry-route.ts with the same re-export. Net −28 lines." }, + { "label": "B) Fix worker only", "description": "Restore the re-export in src/retry-worker.ts (the file this diff actually changes). Leave src/retry-route.ts's copy alone. Net −14 lines." }, + { "label": "C) Skip", "description": "Keep the duplicated implementation in the worker. Recorded as an explicit skip for this advisory." } + ] + }, + { + "question": "2. Git state note (not a code defect): src/retry-route.ts has the assume-unchanged bit set (`git ls-files -v` shows `h`), and its working-tree bytes differ from the index — a trailing comment was appended that git diff/status hide. Should I clear the flag with `git update-index --no-assume-unchanged src/retry-route.ts` so the file's real state is visible to Git (and to any edit from question 1)?", + "header": "Index flag", + "multiSelect": false, + "options": [ + { "label": "Yes, clear the flag (Recommended)", "description": "Run `git update-index --no-assume-unchanged src/retry-route.ts`. The hidden trailing-comment change will then appear as a normal modification." }, + { "label": "No, leave it", "description": "Keep the local index flag as is. I will still report the hidden change and exclude the route from reusable snapshot coverage." } + ] + } + ] + } + } + ], + "regressions": [ + { + "sourceRun": 36044212977, + "sourceRevision": "70b023b3424453207c95e5b05298955f3094b50f", + "attempt": 1, + "toolUseId": "toolu_01DLWrTd85Uf3PA4gagJCFrW", + "input": { + "questions": [ + { + "question": "Pre-Landing Review: 0 issues (0 critical, 0 informational). 1 [ADVISORY] needs your input — the prior 'skipped' decision could not be reused because src/retry-route.ts is skip-worktree with hidden raw changes.\n\n1. [ADVISORY] src/retry-worker.ts:2 — The diff replaces `export { retrySeconds } from '../lib/retry-after'` with a verbatim 15-line copy of the tested helper; src/retry-route.ts:2-15 carries the same copy. Helper: lib/retry-after.ts `retrySeconds` (already used by src/scheduler.ts, covered by test/retry-after.test.ts). Migration: worker → one-line re-export (revert to HEAD form); route → same one-liner. Estimated implementation: −30 / +2 = 28 lines saved; total change same (existing test already covers the contract, no new tests needed). Shared-failure blast radius: scheduler, worker, route — all three copies are identical today, so consolidation removes drift risk without new behavior. Caveat: src/retry-route.ts has the skip-worktree index flag, so an edit there stays invisible to git until the flag is cleared (`git update-index --no-skip-worktree`).\n\nRECOMMENDATION: A — reuse the existing tested helper in both files.\n\nHow should I handle this advisory?", + "header": "Advisory", + "multiSelect": false, + "options": [ + { + "label": "A) Fix both (Recommended)", + "description": "Replace the copies in src/retry-worker.ts and src/retry-route.ts with `export { retrySeconds } from '../lib/retry-after';`. I will NOT change the skip-worktree flag; you decide how to stage the route file." + }, + { + "label": "A2) Fix worker only", + "description": "Only revert the changed file in this diff (src/retry-worker.ts) to the one-line re-export. Leave src/retry-route.ts untouched." + }, + { + "label": "B) Skip", + "description": "Keep the duplicated copies. Recorded as an explicit skip; reuse of this skip will still require revalidation while src/retry-route.ts is skip-worktree." + } + ] + } + ] + } + }, + { + "sourceRun": 36080890009, + "sourceRevision": "5460ce0847574aadfa6cbdd0b9545d2caf9935da", + "attempt": 1, + "toolUseId": "toolu_01Up8B1FR4bhkmRxyhbcAqhY", + "input": { + "questions": [ + { + "question": "Pre-Landing Review: 0 issues (0 critical, 0 informational) + 1 advisory.\n\n1. [ADVISORY] (confidence 10/10, core + maintainability, MULTI-SPECIALIST CONFIRMED) src/retry-worker.ts:2 — This diff replaces the re-export of the tested helper with a verbatim copy of retrySeconds; src/retry-route.ts:2-15 carries the same copy. Both bodies are byte-identical to lib/retry-after.ts:2-15 (already used by src/scheduler.ts, covered by test/retry-after.test.ts, 5 assertions passing).\n Fix: `export { retrySeconds } from '../lib/retry-after';` in each caller. Implementation: worker -15/+1, route -15/+1 → ~28 lines saved; no new tests needed (existing contract test covers the shared helper); shared-failure blast radius = scheduler, worker, route all depend on one parser (they already share the contract by copy).\n\nNote: your prior review skipped this same advisory, but src/retry-route.ts has changed on disk since then behind an assume-unchanged index flag, so that decision could not be reused.\n\nRECOMMENDATION: Fix both callers — the copies are identical, the helper is proven, and the diff itself undoes prior reuse.\n\nHow do you want to handle the advisory?", + "header": "Advisory", + "options": [ + { + "label": "Fix both callers (Recommended)", + "description": "Restore the re-export in src/retry-worker.ts and replace the copy in src/retry-route.ts with the same re-export (keeping its trailing comment)." + }, + { + "label": "Fix worker only", + "description": "Restore the re-export in src/retry-worker.ts (the file changed in this diff); leave src/retry-route.ts as-is." + }, + { + "label": "Skip", + "description": "Keep the duplicated implementation; record the advisory as skipped." + } + ], + "multiSelect": false + }, + { + "question": "src/retry-route.ts is marked assume-unchanged in the index (`git ls-files -v` → `h`), so its uncommitted on-disk edit (trailing comment, line 17) is invisible to `git status`/`git diff` and to the review snapshot fingerprint. Should I clear that flag so the change becomes visible and reviewable?", + "header": "Git index", + "options": [ + { + "label": "Clear the flag (Recommended)", + "description": "Run `git update-index --no-assume-unchanged src/retry-route.ts` so Git tracks the working-tree change normally." + }, + { + "label": "Leave it", + "description": "Keep the assume-unchanged bit; the path will be excluded from snapshot coverage in the persisted record." + } + ], + "multiSelect": false + } + ] + } + } + ] +} diff --git a/test/helpers/claude-pty-runner.ts b/test/helpers/claude-pty-runner.ts index e51de8dbf..f1e39bd73 100644 --- a/test/helpers/claude-pty-runner.ts +++ b/test/helpers/claude-pty-runner.ts @@ -4605,7 +4605,7 @@ export async function runPlanSkillObservation(opts: { }; // Entry deadline → boot → owned paste/receipt/ack → slash → observation. // Setup consumes the existing case budget; cleanup has its separate grace. - await Bun.sleep(Math.min(8000, Math.max(0, deadlineAt - Date.now()))); + if (!opts.initialPlanContent) await Bun.sleep(Math.min(8000, Math.max(0, deadlineAt - Date.now()))); if (opts.initialPlanContent) { const seed = `Keep this draft plan as context. Briefly acknowledge receipt, then wait for my next message containing a slash command. Do not start the review or call tools yet.\n\n${opts.initialPlanContent}`; try { diff --git a/test/helpers/cookie-workflow-judge-input.ts b/test/helpers/cookie-workflow-judge-input.ts new file mode 100644 index 000000000..157ec9b2c --- /dev/null +++ b/test/helpers/cookie-workflow-judge-input.ts @@ -0,0 +1,50 @@ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { buildWorkflowJudgePrompt, type WorkflowJudgeFile, type WorkflowJudgeInput } from './workflow-judge-input'; + +export const COOKIE_WORKFLOW_JUDGE = { + judgeContext: 'a fallback-browser cookie import workflow', + judgeGoal: 'how to select an authorized source browser, profile, and domain without guessing an account; configure optional authentication verification before mutation; obtain explicit consent for precisely scoped storage reset; distinguish copied cookies from positive sign-in evidence; and recover within the documented platform and privacy boundaries', + thresholds: { clarity: 4, completeness: 3, actionability: 4 }, +} as const; + +export function buildCookieWorkflowJudgeInput(root: string): WorkflowJudgeInput & { prompt: string; sha256: string } { + const files: WorkflowJudgeFile[] = [ + { path: 'setup-browser-cookies/SKILL.md', kind: 'entrypoint', start: '# Setup Browser Cookies', end: null }, + { path: 'BROWSER.md', kind: 'section', start: '#### Choosing a source and checking sign-in', end: '### Tabs + frames' }, + ].map(spec => { + const source = readFileSync(join(root, spec.path), 'utf8'); + const locate = (marker: string): number => { + const matches = [...source.matchAll(new RegExp(`^${marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\r?$`, 'gm'))]; + if (matches.length !== 1) throw new Error(`${spec.path}: expected exactly one marker ${JSON.stringify(marker)}, found ${matches.length}`); + return matches[0].index!; + }; + const start = locate(spec.start); + const end = spec.end === null ? source.length : locate(spec.end); + if (end <= start || !source.slice(start + spec.start.length, end).trim()) { + throw new Error(`${spec.path}: empty or reversed cookie workflow excerpt`); + } + return { + path: spec.path, + kind: spec.kind as WorkflowJudgeFile['kind'], + content: source.slice(start, end), + startLine: source.slice(0, start).split('\n').length, + endLine: source.slice(0, end - 1).split('\n').length, + }; + }); + if (!files[0].content.includes('`BROWSER.md`') || !files[0].content.includes('**Choosing a source and checking sign-in**')) { + throw new Error('setup-browser-cookies/SKILL.md: missing cookie reference link'); + } + const text = [ + 'SKILL.md is the entry point. BROWSER.md supplies the exact referenced cookie section, not an additional execution step.', + ...files.map(file => [ + `--- BEGIN FILE ${JSON.stringify(file.path)} (lines ${file.startLine}-${file.endLine}; ${file.kind}) ---`, + file.content, + `--- END FILE ${JSON.stringify(file.path)} ---`, + ].join('\n')), + ].join('\n\n'); + const input = { files, text }; + const prompt = buildWorkflowJudgePrompt(COOKIE_WORKFLOW_JUDGE, input); + return { ...input, prompt, sha256: createHash('sha256').update(prompt).digest('hex') }; +} diff --git a/test/helpers/cookie-workflow-manual-review.ts b/test/helpers/cookie-workflow-manual-review.ts new file mode 100644 index 000000000..723b9e63e --- /dev/null +++ b/test/helpers/cookie-workflow-manual-review.ts @@ -0,0 +1,101 @@ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { buildCookieWorkflowJudgeInput, COOKIE_WORKFLOW_JUDGE } from './cookie-workflow-judge-input'; +import type { JudgeRefusalEvidence } from './llm-judge'; +import { DEFAULT_JUDGE_MAX_TOKENS, resolveEvalModel } from '../../lib/eval-model'; + +export const COOKIE_MANUAL_REVIEW_FILE = '.github/cookie-workflow-manual-review.json'; +const CASE = 'setup-browser-cookies/SKILL.md workflow'; +type Thresholds = { clarity: number; completeness: number; actionability: number }; + +export interface CookieManualApproval { + schema_version: 1; + test_name: typeof CASE; + prompt_sha256: string; + prompt_bytes: number; + model: string; + max_tokens: number; + thresholds: Thresholds; + approved_by: string; + approved_at: string; + approval_url: string; + reason: string; +} + +export interface ManualJudgeReview { + approval: CookieManualApproval; + refusal: JudgeRefusalEvidence; +} + +const object = (value: unknown): value is Record => value !== null && typeof value === 'object' && !Array.isArray(value); +const nonempty = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0; +const dimensions = ['clarity', 'completeness', 'actionability'] as const; +const sameThresholds = (a: Thresholds, b: Thresholds) => dimensions.every(key => a[key] === b[key]); + +function validApproval(value: unknown): value is CookieManualApproval { + return object(value) && value.schema_version === 1 && value.test_name === CASE + && typeof value.prompt_sha256 === 'string' && /^[a-f0-9]{64}$/.test(value.prompt_sha256) + && Number.isSafeInteger(value.prompt_bytes) && value.prompt_bytes > 0 + && nonempty(value.model) && Number.isSafeInteger(value.max_tokens) && value.max_tokens > 0 + && object(value.thresholds) && dimensions.every(key => Number.isInteger(value.thresholds[key]) && value.thresholds[key] >= 1 && value.thresholds[key] <= 5) + && nonempty(value.approved_by) && typeof value.approved_at === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value.approved_at) + && typeof value.approval_url === 'string' && /^https:\/\/github\.com\/garrytan\/gstack\/pull\/2964#issuecomment-\d+$/.test(value.approval_url) + && nonempty(value.reason); +} + +function validRefusal(value: unknown, model: string): value is JudgeRefusalEvidence { + return object(value) && value.stop_reason === 'refusal' && value.model === model + && nonempty(value.response_id) && nonempty(value.request_id) + && Number.isSafeInteger(value.input_tokens) && value.input_tokens >= 0 + && value.output_tokens === 0 && value.text_blocks === 0; +} + +export function isManualReviewEntry(entry: unknown): boolean { + if (!object(entry) || entry.name !== CASE || entry.suite !== 'Cookie setup workflow quality' + || entry.tier !== 'llm-judge' || entry.passed !== false + || entry.attempt !== 1 + || entry.execution !== 'executed' || entry.exit_reason !== 'provider_refusal' + || entry.judge_scores !== undefined || entry.judge_reasoning !== undefined || entry.reused_from !== undefined + || typeof entry.prompt !== 'string' || !object(entry.manual_review)) return false; + const { approval, refusal } = entry.manual_review; + return validApproval(approval) && entry.model === approval.model && validRefusal(refusal, approval.model) + && Buffer.byteLength(entry.prompt) === approval.prompt_bytes + && createHash('sha256').update(entry.prompt).digest('hex') === approval.prompt_sha256; +} + +export function getCookieWorkflowManualReview(root: string, request: { + testName: string; prompt: string; model: string; maxTokens: number; thresholds: Thresholds; attempt: number; +}, refusal: JudgeRefusalEvidence): ManualJudgeReview | null { + if (request.testName !== CASE || request.attempt !== 1 || !validRefusal(refusal, request.model)) return null; + let approval: unknown; + try { approval = JSON.parse(readFileSync(join(root, COOKIE_MANUAL_REVIEW_FILE), 'utf8')); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + } + if (!validApproval(approval)) throw new Error('Invalid cookie workflow manual-review approval'); + const current = buildCookieWorkflowJudgeInput(root); + if (request.prompt !== current.prompt || current.sha256 !== approval.prompt_sha256 + || Buffer.byteLength(request.prompt) !== approval.prompt_bytes || request.model !== approval.model + || request.maxTokens !== approval.max_tokens || request.maxTokens !== DEFAULT_JUDGE_MAX_TOKENS + || !sameThresholds(request.thresholds, approval.thresholds) + || !sameThresholds(request.thresholds, COOKIE_WORKFLOW_JUDGE.thresholds)) return null; + return { approval, refusal }; +} + +export function manualReviewProblem(entry: unknown, root: string): string | null { + if (!object(entry) || !Object.hasOwn(entry, 'manual_review')) return null; + if (!isManualReviewEntry(entry)) return 'Malformed manual-review claim'; + if (entry.model !== resolveEvalModel('judge')) return 'Manual review model is not the current judge model'; + try { + const claimed = entry.manual_review as ManualJudgeReview; + const verified = getCookieWorkflowManualReview(root, { testName: entry.name, prompt: entry.prompt, + model: entry.model, maxTokens: claimed.approval.max_tokens, thresholds: claimed.approval.thresholds, + attempt: entry.attempt }, claimed.refusal); + if (!verified || Object.entries(verified.approval).some(([key, value]) => key === 'thresholds' + ? !sameThresholds(value as Thresholds, claimed.approval.thresholds) + : value !== claimed.approval[key as keyof CookieManualApproval])) return 'Manual review does not match current source and approval'; + return null; + } catch { return 'Manual review approval or current input is unavailable or invalid'; } +} diff --git a/test/helpers/cso-ntfs-fixture.ts b/test/helpers/cso-ntfs-fixture.ts new file mode 100644 index 000000000..dc7633d2e --- /dev/null +++ b/test/helpers/cso-ntfs-fixture.ts @@ -0,0 +1,34 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +export function createPrecisionLossCandidate( + target: string, + contents: string, + readFileId: (file: string) => bigint = file => fs.lstatSync(file, { bigint: true }).ino, +): bigint { + if (fs.existsSync(target)) throw new Error('Precision fixture target already exists'); + const directory = path.dirname(target); + if (fs.lstatSync(directory).isSymbolicLink()) throw new Error('Precision fixture directory must not be linked'); + const pool = fs.mkdtempSync(path.join(directory, 'ntfs-id-')); + let aboveSafe = 0; + try { + for (let batch = 0; batch < 512; batch++) { + const files: string[] = []; + for (let slot = 0; slot < 2; slot++) { + const file = path.join(pool, `candidate-${slot}`); + fs.writeFileSync(file, contents, { flag: 'wx' }); + files.push(file); + const inode = readFileId(file); + if (inode > BigInt(Number.MAX_SAFE_INTEGER)) aboveSafe++; + if (inode > BigInt(Number.MAX_SAFE_INTEGER) && String(Number(inode)) !== String(inode)) { + fs.linkSync(file, target); + return inode; + } + } + for (const file of files) fs.unlinkSync(file); + } + throw new Error(`No precision-losing NTFS file ID within 1024 file creations (${aboveSafe} above-safe observations)`); + } finally { + fs.rmSync(pool, { recursive: true, force: true }); + } +} diff --git a/test/helpers/eval-budgets.ts b/test/helpers/eval-budgets.ts index 6dc7fd93c..aacee21f7 100644 --- a/test/helpers/eval-budgets.ts +++ b/test/helpers/eval-budgets.ts @@ -107,7 +107,7 @@ export const FILE_RETRY_BUDGETS = [ ...[ // Fourteen workflow judges include their 10s recording grace; the other // eleven judges retain 120s. Supervise all 25 and the existing one retry. - { file: 'test/skill-llm-eval.test.ts', attemptMs: 14 * (JUDGE_MS + 10_000) + 11 * JUDGE_MS, retries: 1 }, + { file: 'test/skill-llm-eval.test.ts', attemptMs: 15 * (JUDGE_MS + 10_000) + 11 * JUDGE_MS, retries: 1 }, { file: 'test/codex-e2e-plan-format.test.ts', attemptMs: 4 * (CAPTURE_LONG_MS + 10_000), retries: 1 }, { file: 'test/skill-e2e-auq-matrix.test.ts', attemptMs: 6 * CAPTURE_MS, retries: 1 }, { file: 'test/skill-e2e-plan-format.test.ts', attemptMs: 4 * (CAPTURE_MS + 10_000), retries: 1 }, diff --git a/test/helpers/eval-store.test.ts b/test/helpers/eval-store.test.ts index 7d90ef68f..f613c3b42 100644 --- a/test/helpers/eval-store.test.ts +++ b/test/helpers/eval-store.test.ts @@ -10,11 +10,13 @@ import { isPartialEval, listEvalJsonFiles, compareEvalResults, + evalEntryOutcome, formatComparison, generateCommentary, judgePassed, } from './eval-store'; import type { EvalResult, EvalTestEntry, ComparisonResult } from './eval-store'; +import { manualReviewFixture } from './manual-judge-review-fixture'; let tmpDir: string; @@ -77,6 +79,36 @@ async function captureStderr(fn: () => Promise): Promise { // --- EvalCollector tests --- describe('EvalCollector', () => { + test('manual provider refusal stays unscored and executed; malformed claims stay failed', async () => { + const manual = manualReviewFixture(); + const invalidPass = { ...manual, passed: true }; + const invalidScore = { ...manual, judge_scores: { clarity: 5 } }; + expect(evalEntryOutcome(manual)).toBe('manual-review'); + expect(evalEntryOutcome(invalidPass)).toBe('failed'); + expect(evalEntryOutcome(invalidScore)).toBe('failed'); + expect(evalEntryOutcome({ ...manual, manual_review: { ...manual.manual_review, approval: { ...manual.manual_review!.approval, + prompt_sha256: '0'.repeat(64) } } })).toBe('failed'); + const collector = new EvalCollector('llm-judge', tmpDir); + collector.addTest(makeEntry({ name: 'ordinary', tier: 'llm-judge' })); + collector.addTest(manual); + collector.addTest(invalidPass); + collector.addTest(invalidScore); + const partial: EvalResult = JSON.parse(fs.readFileSync(path.join(tmpDir, '_partial-e2e.json'), 'utf8')); + expect(partial).toMatchObject({ passed: 1, failed: 2, manual_accepted_tests: 1, + executed_tests: 4, reused_tests: 0 }); + const output = await captureStderr(async () => { await collector.finalize(); }); + const result: EvalResult = JSON.parse(fs.readFileSync(fs.readdirSync(tmpDir).map(name => path.join(tmpDir, name)) + .find(name => name.endsWith('.json') && !path.basename(name).startsWith('_'))!, 'utf8')); + expect(result).toMatchObject({ passed: 1, failed: 2, manual_accepted_tests: 1, executed_tests: 4 }); + expect(result.tests[1]).toMatchObject({ passed: false, execution: 'executed', exit_reason: 'provider_refusal' }); + expect(result.tests[1].judge_scores).toBeUndefined(); + expect(output).toContain('MANUAL'); + expect(output).toContain('1 unscored provider refusal'); + expect(output).toContain(manual.manual_review!.approval.approval_url); + expect(result.tests[2].passed).toBe(true); + expect(output).toContain(' FAIL '); + }); + test('reused passing evidence preserves origin and remains separate from newly executed attempts', async () => { const collector = new EvalCollector('llm-judge', tmpDir); const reused_from = { input_key: 'a'.repeat(64), run_id: '1234/1', revision: 'b'.repeat(40), @@ -590,6 +622,45 @@ describe('findLatestFinalizedRun', () => { // --- compareEvalResults tests --- describe('compareEvalResults', () => { + test('manual acceptance is neither a score regression nor a recovery', () => { + const manual = manualReviewFixture(); + const prior = makeResult({ tests: [makeEntry({ name: manual.name, passed: true })] }); + const accepted = makeResult({ tests: [manual], passed: 0, failed: 0, manual_accepted_tests: 1 }); + const toManual = compareEvalResults(prior, accepted, 'prior.json', 'accepted.json'); + expect(toManual).toMatchObject({ improved: 0, regressed: 0, manual_reviewed: 1 }); + expect(toManual.deltas[0]).toMatchObject({ status_change: 'manual-review', before: { passed: true }, + after: { passed: false, manual_review: true } }); + expect(formatComparison(toManual)).toContain('PASS → MANUAL'); + expect(formatComparison(toManual)).not.toContain('REGRESSION:'); + const fromManual = compareEvalResults(accepted, prior, 'accepted.json', 'prior.json'); + expect(fromManual).toMatchObject({ improved: 0, regressed: 0, manual_reviewed: 1 }); + expect(formatComparison(fromManual)).toContain('MANUAL → PASS'); + const invalid = makeResult({ tests: [{ ...manual, passed: true }] }); + expect(compareEvalResults(prior, invalid, 'prior.json', 'invalid.json').regressed).toBe(1); + }); + + test('manual acceptance followed by a real or malformed failure is a blocking regression', () => { + const manual = manualReviewFixture(); + const accepted = makeResult({ tests: [manual], passed: 0, failed: 0, manual_accepted_tests: 1 }); + const { manual_review: _receipt, ...ordinary } = manual; + for (const after of [ + { ...ordinary, exit_reason: 'timeout' }, + { ...manual, passed: true }, + { ...manual, judge_scores: { clarity: 5 } }, + ]) { + const failed = makeResult({ tests: [after] }); + const comparison = compareEvalResults(accepted, failed, 'accepted.json', 'failed.json'); + expect(comparison).toMatchObject({ improved: 0, regressed: 1 }); + expect(comparison.manual_reviewed).toBeUndefined(); + expect(comparison.deltas[0]).toMatchObject({ status_change: 'regressed', before: { manual_review: true }, + after: { passed: false } }); + const output = formatComparison(comparison); + expect(output).toContain('MANUAL → FAIL'); + expect(output).toContain('REGRESSION:'); + expect(output).toContain('blocking failure'); + } + }); + test('detects improved/regressed/unchanged per test', () => { const before = makeResult({ tests: [ diff --git a/test/helpers/eval-store.ts b/test/helpers/eval-store.ts index d04a3bc17..b968447ea 100644 --- a/test/helpers/eval-store.ts +++ b/test/helpers/eval-store.ts @@ -12,6 +12,8 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { spawnSync } from 'child_process'; +import { isManualReviewEntry } from './cookie-workflow-manual-review'; +import type { ManualJudgeReview } from './cookie-workflow-manual-review'; // v2: EvalTestEntry.harvest gains optional {insertions, deletions, net} and // may be explicitly null (arm-benchmark harvest-failure taxonomy). Readers @@ -66,6 +68,7 @@ export interface EvalTestEntry { /** Absent in older records means executed; reuse is never a new model run. */ execution?: 'executed' | 'reused'; reused_from?: { input_key: string; run_id: string; revision: string; completed_at: string }; + manual_review?: ManualJudgeReview; /** 1-based record attempt for this name in this run. bun's --retry leaves * retried passes INVISIBLE in its text output (a fail→pass prints no * (fail) line and recaps as a clean pass — probed on 1.3.10), so the ONLY @@ -120,6 +123,14 @@ export interface EvalTestEntry { } | null; } +export function evalEntryOutcome(entry: unknown): 'passed' | 'failed' | 'manual-review' { + if (!entry || typeof entry !== 'object') return 'failed'; + if ('manual_review' in entry) return Object.hasOwn(entry, 'manual_review') && isManualReviewEntry(entry) ? 'manual-review' : 'failed'; + const result = entry as EvalTestEntry; + if (result.execution !== undefined && result.execution !== 'executed' && result.execution !== 'reused') return 'failed'; + return result.passed === true ? 'passed' : 'failed'; +} + export interface EvalResult { schema_version: number; version: string; @@ -135,6 +146,7 @@ export interface EvalResult { total_tests: number; executed_tests?: number; reused_tests?: number; + manual_accepted_tests?: number; passed: number; failed: number; total_cost_usd: number; @@ -154,10 +166,10 @@ export interface EvalResult { export interface TestDelta { name: string; before: { passed: boolean; cost_usd: number; turns_used?: number; duration_ms?: number; - detection_rate?: number; tool_summary?: Record }; + detection_rate?: number; tool_summary?: Record; manual_review?: boolean }; after: { passed: boolean; cost_usd: number; turns_used?: number; duration_ms?: number; - detection_rate?: number; tool_summary?: Record }; - status_change: 'improved' | 'regressed' | 'unchanged'; + detection_rate?: number; tool_summary?: Record; manual_review?: boolean }; + status_change: 'improved' | 'regressed' | 'unchanged' | 'manual-review'; } export interface ComparisonResult { @@ -173,6 +185,7 @@ export interface ComparisonResult { improved: number; regressed: number; unchanged: number; + manual_reviewed?: number; tool_count_before: number; tool_count_after: number; /** After-tests that had a same-named entry in the before run. 0 = nothing was @@ -388,6 +401,7 @@ export function compareEvalResults( ): ComparisonResult { const deltas: TestDelta[] = []; let improved = 0, regressed = 0, unchanged = 0; + let manualReviewed = 0; let toolCountBefore = 0, toolCountAfter = 0; let matched = 0; @@ -409,33 +423,40 @@ export function compareEvalResults( toolCountAfter += afterToolCount; let statusChange: TestDelta['status_change'] = 'unchanged'; + const beforeManual = beforeTest !== undefined && evalEntryOutcome(beforeTest) === 'manual-review'; + const afterOutcome = evalEntryOutcome(afterTest); + const afterManual = afterOutcome === 'manual-review'; if (beforeTest) { matched++; - if (!beforeTest.passed && afterTest.passed) { statusChange = 'improved'; improved++; } - else if (beforeTest.passed && !afterTest.passed) { statusChange = 'regressed'; regressed++; } + if (beforeManual && afterOutcome === 'failed') { statusChange = 'regressed'; regressed++; } + else if (beforeManual || afterManual) { statusChange = 'manual-review'; manualReviewed++; } + else if (evalEntryOutcome(beforeTest) === 'failed' && evalEntryOutcome(afterTest) === 'passed') { statusChange = 'improved'; improved++; } + else if (evalEntryOutcome(beforeTest) === 'passed' && evalEntryOutcome(afterTest) === 'failed') { statusChange = 'regressed'; regressed++; } else { unchanged++; } } else { - // New test — treat as unchanged (no prior data) - unchanged++; + if (afterManual) { statusChange = 'manual-review'; manualReviewed++; } + else unchanged++; } deltas.push({ name: afterTest.name, before: { - passed: beforeTest?.passed ?? false, + passed: beforeTest !== undefined && evalEntryOutcome(beforeTest) === 'passed', cost_usd: beforeTest?.cost_usd ?? 0, turns_used: beforeTest?.turns_used, duration_ms: beforeTest?.duration_ms, detection_rate: beforeTest?.detection_rate, tool_summary: beforeToolSummary, + ...(beforeManual ? { manual_review: true } : {}), }, after: { - passed: afterTest.passed, + passed: afterOutcome === 'passed', cost_usd: afterTest.cost_usd, turns_used: afterTest.turns_used, duration_ms: afterTest.duration_ms, detection_rate: afterTest.detection_rate, tool_summary: afterToolSummary, + ...(afterManual ? { manual_review: true } : {}), }, status_change: statusChange, }); @@ -452,12 +473,13 @@ export function compareEvalResults( deltas.push({ name: `${name} (removed)`, before: { - passed: beforeTest.passed, + passed: evalEntryOutcome(beforeTest) === 'passed', cost_usd: beforeTest.cost_usd, turns_used: beforeTest.turns_used, duration_ms: beforeTest.duration_ms, detection_rate: beforeTest.detection_rate, tool_summary: beforeToolSummary, + ...(evalEntryOutcome(beforeTest) === 'manual-review' ? { manual_review: true } : {}), }, after: { passed: false, cost_usd: 0, tool_summary: {} }, status_change: 'unchanged', @@ -477,6 +499,7 @@ export function compareEvalResults( improved, regressed, unchanged, + ...(manualReviewed ? { manual_reviewed: manualReviewed } : {}), tool_count_before: toolCountBefore, tool_count_after: toolCountAfter, matched, @@ -495,8 +518,8 @@ export function formatComparison(c: ComparisonResult): string { // Per-test deltas for (const d of c.deltas) { const arrow = d.status_change === 'improved' ? '↑' : d.status_change === 'regressed' ? '↓' : '='; - const beforeStatus = d.before.passed ? 'PASS' : 'FAIL'; - const afterStatus = d.after.passed ? 'PASS' : 'FAIL'; + const beforeStatus = d.before.manual_review ? 'MANUAL' : d.before.passed ? 'PASS' : 'FAIL'; + const afterStatus = d.after.manual_review ? 'MANUAL' : d.after.passed ? 'PASS' : 'FAIL'; // Turns delta let turnsDelta = ''; @@ -540,6 +563,7 @@ export function formatComparison(c: ComparisonResult): string { if (c.improved > 0) parts.push(`${c.improved} improved`); if (c.regressed > 0) parts.push(`${c.regressed} regressed`); if (c.unchanged > 0) parts.push(`${c.unchanged} unchanged`); + if (c.manual_reviewed) parts.push(`${c.manual_reviewed} unscored manual review`); lines.push(` Status: ${parts.join(', ')}`); const costSign = c.total_cost_delta >= 0 ? '+' : ''; @@ -607,7 +631,9 @@ export function generateCommentary(c: ComparisonResult): string[] { const regressions = c.deltas.filter(d => d.status_change === 'regressed'); if (regressions.length > 0) { for (const d of regressions) { - notes.push(`REGRESSION: "${d.name}" was passing, now fails. Investigate immediately.`); + notes.push(d.before.manual_review + ? `REGRESSION: "${d.name}" lost its unscored manual acceptance and now has a blocking failure. Investigate immediately.` + : `REGRESSION: "${d.name}" was passing, now fails. Investigate immediately.`); } } @@ -617,6 +643,10 @@ export function generateCommentary(c: ComparisonResult): string[] { notes.push(`Fixed: "${d.name}" now passes.`); } + for (const d of c.deltas.filter(delta => delta.status_change === 'manual-review')) { + notes.push(`"${d.name}" includes an unscored manual acceptance; no model-score improvement or regression is inferred.`); + } + // 3. Per-test efficiency changes (only for unchanged-status tests — regressions/improvements are already noted) const stable = c.deltas.filter(d => d.status_change === 'unchanged' && d.after.passed); for (const d of stable) { @@ -893,7 +923,8 @@ export class EvalCollector { const version = getVersion(); const totalCost = this.tests.reduce((s, t) => s + t.cost_usd, 0); const totalDuration = this.tests.reduce((s, t) => s + t.duration_ms, 0); - const passed = this.tests.filter(t => t.passed).length; + const passed = this.tests.filter(t => evalEntryOutcome(t) === 'passed').length; + const manual = this.tests.filter(t => evalEntryOutcome(t) === 'manual-review').length; const partial: EvalResult = { schema_version: SCHEMA_VERSION, @@ -907,8 +938,9 @@ export class EvalCollector { total_tests: this.tests.length, executed_tests: this.tests.filter(t => t.execution !== 'reused').length, reused_tests: this.tests.filter(t => t.execution === 'reused').length, + ...(manual ? { manual_accepted_tests: manual } : {}), passed, - failed: this.tests.length - passed, + failed: this.tests.length - passed - manual, total_cost_usd: Math.round(totalCost * 100) / 100, total_duration_ms: totalDuration, tests: this.tests, @@ -933,7 +965,8 @@ export class EvalCollector { const timestamp = new Date().toISOString(); const totalCost = this.tests.reduce((s, t) => s + t.cost_usd, 0); const totalDuration = this.tests.reduce((s, t) => s + t.duration_ms, 0); - const passed = this.tests.filter(t => t.passed).length; + const passed = this.tests.filter(t => evalEntryOutcome(t) === 'passed').length; + const manual = this.tests.filter(t => evalEntryOutcome(t) === 'manual-review').length; const flaky = this.flakyRetries(); const result: EvalResult = { @@ -948,8 +981,9 @@ export class EvalCollector { total_tests: this.tests.length, executed_tests: this.tests.filter(t => t.execution !== 'reused').length, reused_tests: this.tests.filter(t => t.execution === 'reused').length, + ...(manual ? { manual_accepted_tests: manual } : {}), passed, - failed: this.tests.length - passed, + failed: this.tests.length - passed - manual, total_cost_usd: Math.round(totalCost * 100) / 100, total_duration_ms: totalDuration, wall_clock_ms: Date.now() - this.createdAt, @@ -999,7 +1033,9 @@ export class EvalCollector { lines.push('═'.repeat(70)); for (const t of this.tests) { - const status = !t.passed ? ' FAIL ' : t.execution === 'reused' ? ' REUSE' : ' PASS '; + const outcome = evalEntryOutcome(t); + const status = outcome === 'manual-review' ? 'MANUAL' : outcome === 'failed' ? ' FAIL ' + : t.execution === 'reused' ? ' REUSE' : ' PASS '; const cost = `$${t.cost_usd.toFixed(2)}`; const dur = t.duration_ms ? `${Math.round(t.duration_ms / 1000)}s` : ''; const turns = t.turns_used !== undefined ? `${t.turns_used}t` : ''; @@ -1010,6 +1046,8 @@ export class EvalCollector { } else if (t.judge_scores) { const scores = Object.entries(t.judge_scores).map(([k, v]) => `${k[0]}:${v}`).join(' '); detail = scores; + } else if (outcome === 'manual-review') { + detail = `unscored; approved by ${t.manual_review!.approval.approved_by} (${t.manual_review!.approval.approval_url})`; } const name = t.name.length > 35 ? t.name.slice(0, 32) + '...' : t.name.padEnd(35); @@ -1020,6 +1058,7 @@ export class EvalCollector { const totalCost = `$${result.total_cost_usd.toFixed(2)}`; const totalDur = `${Math.round(result.total_duration_ms / 1000)}s`; lines.push(` Total: ${result.passed}/${result.total_tests} passed${' '.repeat(20)}${totalCost.padStart(6)} ${totalDur}`); + if (result.manual_accepted_tests) lines.push(` Manual accepted: ${result.manual_accepted_tests} unscored provider refusal(s)`); lines.push(` Evidence: ${result.executed_tests ?? result.total_tests} executed, ${result.reused_tests ?? 0} reused`); if (result.flaky_retries && result.flaky_retries.length > 0) { // Loud, never fatal: a flaky pass must not block anyone, but it must diff --git a/test/helpers/llm-judge.ts b/test/helpers/llm-judge.ts index 02f19115c..592d9e6da 100644 --- a/test/helpers/llm-judge.ts +++ b/test/helpers/llm-judge.ts @@ -13,7 +13,8 @@ import Anthropic from '@anthropic-ai/sdk'; import type { JSONOutputFormat } from '@anthropic-ai/sdk/resources/messages'; import { setTimeout as delay } from 'node:timers/promises'; -import { CLAUDE_FRONTIER_EVAL_MODEL, resolveEvalModel } from '../../lib/eval-model'; +import { CLAUDE_FRONTIER_EVAL_MODEL, DEFAULT_JUDGE_MAX_TOKENS, resolveEvalModel } from '../../lib/eval-model'; +export { DEFAULT_JUDGE_MAX_TOKENS } from '../../lib/eval-model'; export interface JudgeScore { clarity: number; // 1-5 @@ -22,6 +23,35 @@ export interface JudgeScore { reasoning: string; } +export interface JudgeRefusalEvidence { + stop_reason: 'refusal'; + response_id: string | null; + request_id: string | null; + model: string | null; + input_tokens: number | null; + output_tokens: number | null; + text_blocks: number; +} + +export class JudgeRefusalError extends Error { + readonly refusal: JudgeRefusalEvidence; + + constructor(response: { id?: unknown; _request_id?: unknown; model?: unknown; + usage?: { input_tokens?: unknown; output_tokens?: unknown }; content: Array<{ type: string }> }) { + super('Judge provider refused the evaluation; no automated score'); + this.name = 'JudgeRefusalError'; + this.refusal = { + stop_reason: 'refusal', + response_id: typeof response.id === 'string' ? response.id : null, + request_id: typeof response._request_id === 'string' ? response._request_id : null, + model: typeof response.model === 'string' ? response.model : null, + input_tokens: typeof response.usage?.input_tokens === 'number' ? response.usage.input_tokens : null, + output_tokens: typeof response.usage?.output_tokens === 'number' ? response.usage.output_tokens : null, + text_blocks: response.content.filter(block => block.type === 'text').length, + }; + } +} + export interface OutcomeJudgeResult { detected: string[]; missed: string[]; @@ -89,7 +119,7 @@ export async function callJudge( // Thinking and answer text share max_tokens. The old 1024-token budget // could be exhausted before a frontier judge emitted any JSON. const resolvedModel = resolveEvalModel('judge', model); - const maxTokens = opts?.max_tokens ?? 8192; + const maxTokens = opts?.max_tokens ?? DEFAULT_JUDGE_MAX_TOKENS; const client = new Anthropic(); const makeRequest = () => client.messages.create({ @@ -134,6 +164,7 @@ export async function callJudge( .map(block => block.text) .join('\n'); try { + if (response.stop_reason === 'refusal') throw new JudgeRefusalError(response); if (opts?.jsonSchema !== undefined) { if (response.stop_reason !== 'end_turn') throw new Error(`Structured judge did not complete: stop_reason=${response.stop_reason}`); return JSON.parse(text) as T; diff --git a/test/helpers/manual-judge-review-fixture.ts b/test/helpers/manual-judge-review-fixture.ts new file mode 100644 index 000000000..6ca71af03 --- /dev/null +++ b/test/helpers/manual-judge-review-fixture.ts @@ -0,0 +1,17 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import type { EvalTestEntry } from './eval-store'; +import { buildCookieWorkflowJudgeInput } from './cookie-workflow-judge-input'; +import { COOKIE_MANUAL_REVIEW_FILE } from './cookie-workflow-manual-review'; + +export function manualReviewFixture(root = resolve(import.meta.dir, '../..')): EvalTestEntry { + const approval = JSON.parse(readFileSync(resolve(root, COOKIE_MANUAL_REVIEW_FILE), 'utf8')); + return { + name: 'setup-browser-cookies/SKILL.md workflow', suite: 'Cookie setup workflow quality', tier: 'llm-judge', + passed: false, execution: 'executed', exit_reason: 'provider_refusal', attempt: 1, duration_ms: 1, cost_usd: 0, + model: approval.model, prompt: buildCookieWorkflowJudgeInput(root).prompt, + error: 'Synthetic provider refusal fixture, not live model evidence', + manual_review: { approval, refusal: { stop_reason: 'refusal', response_id: 'msg_synthetic_fixture', + request_id: 'req_synthetic_fixture', model: approval.model, input_tokens: 1, output_tokens: 0, text_blocks: 0 } }, + }; +} diff --git a/test/helpers/shared-libs-eval-fixture.ts b/test/helpers/shared-libs-eval-fixture.ts index 0d73565a7..45d346e20 100644 --- a/test/helpers/shared-libs-eval-fixture.ts +++ b/test/helpers/shared-libs-eval-fixture.ts @@ -830,17 +830,31 @@ export type SharedQuestionSelector = (input: Record) => Record< /** The skip actor may decline work, never approve a mixed fix/preservation choice. */ function skippedReviewOption(question: any): any { const options = Array.isArray(question?.options) ? question.options : []; + const qualifiedIndexState = /^(?:(?:git\s+)?index|skip-worktree|assume-unchanged)\s+(?:bits?|flags?|attributes?|settings?)$/i; const candidates = options.flatMap((option: any) => { if (typeof option?.label !== 'string' || ['description', 'preview'].some(field => option[field] !== undefined && typeof option[field] !== 'string')) return []; - const label = option.label.replace(/[‘’]/g, "'").replace(/^\s*(?:[A-Z]|\d+)[.)]\s*/i, '') - .replace(/\s*\(recommended\)\s*$/i, '').trim(); + const label = option.label.replace(/[‘’]/g, "'").replace(/`/g, '').replace(/^\s*(?:[A-Z]|\d+)[.)]\s*/i, '') + .replace(/\s*\(recommended\)\s*$/i, '').trim() + .replace(/^no\s*[,.:!?]\s*(?=(?:skip|decline|keep|leave|do not|don't)\b)/i, ''); + const referentialRetention = /^(?:keep|leave)\s+(?:it|this|that|them|these)$/i.test(label); + const preservation = option.description?.trim().replace(/`/g, '').match(/^(?:keep|leave|retain|preserve)\s+([^,;.!?]+)/i); + const preservedObject = preservation?.[1].split(/\b(?:and|but|while)\b/i)[0] + .replace(/(? [word, word.replace(/s$/, ''), word.replace(/(?:es|ed|ing)$/, ''), - word.replace(/(?:ed|ing)$/, 'e'), word.replace(/(?:ies|ied)$/, 'y')].some(form => actions.has(form)); + word.replace(/(?:ed|ing)$/, 'e'), word.replace(/(?:ies|ied)$/, 'y'), + word.replace(/([a-z])\1(?:ed|ing)$/, '$1')].some(form => actions.has(form)); const changes = commitment.toLowerCase().split(/[,;\n]|[.!?](?:\s|$)|\b(?:and|but|then|while)\b/).some(part => { const clause = part.replace(/^[^a-z]+/, '') .replace(/^(?:(?:this|that|the|selected|chosen)\s+(?:option|choice|selection)|i|we|you|it|(?:the\s+)?(?:source|code|route|worker|helper|parser|index(?:\s+flag)?))\s+/, '') .replace(/^(?:will|would|should|must|can|may|does|do)\s+/, '') .replace(/^(?:(?:please|also|still|just|now|be)\s+)+/, ''); if (/^(?:not|does not|don't|doesn't|won't|without|no)\b/.test(clause)) return false; - // The no-change choice may persist/reuse its review decision. That is not - // permission to modify source or clear an index flag. - if (/^(?:updates?|updated|updating|reuses?|reused|reusing)\s+(?:the\s+)?(?:(?:prior|recorded|existing)\s+)?(?:review\s+(?:log|record)|decision|advisory|snapshot|ledger)\b/.test(clause)) return false; const first = clause.match(/^[a-z]+(?:-[a-z]+)*/)?.[0]; - const future = clause.match(/\bwill\s+(?:be\s+)?([a-z]+(?:-[a-z]+)*)/)?.[1]; - return isAction(first) || isAction(future); + const futureMatch = clause.match(/\b(?:will|would|should|must|can|may)\s+(?:(?:still|also|now|just|[a-z]+ly)\s+)*(?:be\s+)?(?:(?:still|also|now|just|[a-z]+ly)\s+)*([a-z]+(?:-[a-z]+)*)/); + const future = futureMatch?.[1]; + const method = /^(?:keep|leave|retain|preserve)\b/.test(clause) + && [...clause.matchAll(/\b(?:by|through|via)\s+([a-z]+(?:-[a-z]+)*)/g)].some(match => isAction(match[1])); + const state = /^(?:change|modification|file|flag|state|content)\s+(?:stays?|remains?)\b/.test(clause); + const recordedDecision = /^(?:updates?|updated|updating|reuses?|reused|reusing)\s+(?:the\s+)?(?:(?:prior|recorded|existing)\s+)?(?:review\s+(?:log|record)|decision|advisory|snapshot|ledger)\b/.test(clause); + const nominalReuse = /^(?:the\s+)?reuse\s+of\b/.test(clause); + const describedReuse = /\b(?:is|are|was|were|remains?|stays?|requires?|needs?|will|would|should|must|can|may)\s+(?:(?:still|also|now|just|not|never|[a-z]+ly)\s+)*[a-z]+(?:-[a-z]+)*/.test(clause); + const futureSubject = clause.slice(0, futureMatch?.index ?? 0).trim(); + const passiveDecision = /\b(?:review\s+(?:log|record)|decision|advisory|snapshot|ledger)$/.test(futureSubject) + || /\b(?:review\s+(?:log|record)|decision|advisory|snapshot|ledger)\b(?:(?!\b(?:source|code|route|worker|helper|parser|file|flag)\b).)*\bit$/.test(futureSubject); + const futureDecision = /\b(?:can|will|would|should|must|may)\s+(?:(?:still|also|now|just|[a-z]+ly)\s+)*reuse\s+(?:(?:this|the|prior|recorded|existing)\s+)*(?:review\s+(?:log|record)|decision|advisory|snapshot|ledger)\b/.test(clause); + const purpose = [...clause.matchAll(/\b(?:to|by|through|via)\s+(?:[a-z]+ly\s+)*([a-z]+(?:-[a-z]+)*)/g)] + .some(match => isAction(match[1])); + return (isAction(future) && !(future === 'reused' && passiveDecision) && !(future === 'reuse' && futureDecision)) || method || purpose + || (nominalReuse && !describedReuse) + || (!state && !recordedDecision && !nominalReuse && isAction(first)); }); return changes ? [] : [{ option, rank }]; }); diff --git a/test/helpers/touchfiles-data.ts b/test/helpers/touchfiles-data.ts index 3d4481664..6c4247e9a 100644 --- a/test/helpers/touchfiles-data.ts +++ b/test/helpers/touchfiles-data.ts @@ -1772,6 +1772,7 @@ export const E2E_TIERS: Record = { * LLM-judge test touchfiles — keyed by test description string. */ export const LLM_JUDGE_TOUCHFILES: Record = { + 'setup-browser-cookies/SKILL.md workflow': ['setup-browser-cookies/SKILL.md.tmpl', 'setup-browser-cookies/SKILL.md', 'BROWSER.md', 'test/helpers/cookie-workflow-judge-input.ts', 'test/cookie-workflow-judge-input.test.ts', 'test/helpers/cookie-workflow-manual-review.ts', 'test/cookie-workflow-manual-review.test.ts', 'test/helpers/manual-judge-review-fixture.ts', '.github/cookie-workflow-manual-review.json', 'test/helpers/workflow-judge-input.ts', 'test/skill-llm-eval.test.ts'], 'command reference table': ['browse/sections/**', 'SKILL.md', 'SKILL.md.tmpl', 'browse/src/commands.ts', 'test/skill-llm-eval.test.ts'], 'snapshot flags reference': ['browse/sections/**', 'SKILL.md', 'SKILL.md.tmpl', 'browse/src/snapshot.ts', 'test/skill-llm-eval.test.ts'], 'browse/SKILL.md reference': ['browse/sections/**', 'browse/SKILL.md', 'browse/SKILL.md.tmpl', 'browse/src/**', 'test/skill-llm-eval.test.ts'], diff --git a/test/helpers/workflow-judge-cache.ts b/test/helpers/workflow-judge-cache.ts index 03ad398fa..c7cf6a281 100644 --- a/test/helpers/workflow-judge-cache.ts +++ b/test/helpers/workflow-judge-cache.ts @@ -3,7 +3,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { isBuiltin } from 'node:module'; import { spawnSync } from 'node:child_process'; -import { resolveEvalModel } from '../../lib/eval-model'; +import { DEFAULT_JUDGE_MAX_TOKENS, resolveEvalModel } from '../../lib/eval-model'; import { JUDGE_MS } from './eval-budgets'; import type { JudgeScore } from './llm-judge'; import { readWorkflowJudgeInput, buildWorkflowJudgePrompt } from './workflow-judge-input'; @@ -98,7 +98,7 @@ export function prepareWorkflowJudgeCache(opts: WorkflowCacheOptions): { coverage: { dependencies: 'complete', prompts: 'complete', environment: 'complete' }, unknownDependencies: [], files: workflowJudgeDependencies(opts.root, input.files.map(file => file.path)), prompts: { [opts.testName]: prompt }, - parameters: { rootPackage, thresholds: opts.thresholds, max_tokens: 8192, temperature: null, budget_ms: JUDGE_MS, + parameters: { rootPackage, thresholds: opts.thresholds, max_tokens: DEFAULT_JUDGE_MAX_TOKENS, temperature: null, budget_ms: JUDGE_MS, request: 'messages.create/user', retries: 1 }, runtime: { image: env.EVALS_CACHE_RUNTIME_ID!, bun: Bun.version, node: process.versions.node, platform: process.platform, arch: process.arch, judge: resolveEvalModel('judge', undefined, env), diff --git a/test/llm-judge-frontier.test.ts b/test/llm-judge-frontier.test.ts index 61f814fee..e3257916c 100644 --- a/test/llm-judge-frontier.test.ts +++ b/test/llm-judge-frontier.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test'; import Anthropic from '@anthropic-ai/sdk'; -import { armJudge, callJudge } from './helpers/llm-judge'; +import { armJudge, callJudge, JudgeRefusalError } from './helpers/llm-judge'; describe('frontier Claude judge compatibility', () => { let originalKey: string | undefined; @@ -31,6 +31,18 @@ describe('frontier Claude judge compatibility', () => { expect(diagnostics).not.toHaveBeenCalled(); }); + test('an explicit provider refusal retains typed public evidence without parsing a score or retrying', async () => { + create.mockResolvedValue({ id: 'msg_synthetic', _request_id: 'req_synthetic', model: 'claude-fable-5-1', + stop_reason: 'refusal', content: [], usage: { input_tokens: 3886, output_tokens: 0 } } as never); + let failure: unknown; + try { await callJudge('Synthetic test prompt', 'claude-fable-5-1'); } catch (error) { failure = error; } + expect(failure).toBeInstanceOf(JudgeRefusalError); + expect((failure as JudgeRefusalError).refusal).toEqual({ stop_reason: 'refusal', response_id: 'msg_synthetic', + request_id: 'req_synthetic', model: 'claude-fable-5-1', input_tokens: 3886, output_tokens: 0, text_blocks: 0 }); + expect(create).toHaveBeenCalledTimes(1); + expect(JSON.parse(diagnostics.mock.calls[0][0]).stopReason).toBe('refusal'); + }); + test('retains complete public response on malformed JSON and fails without another request', async () => { // Synthetic malformed response: the actual failed provider text was not retained. const textBlocks = ['{"questions":[{"toolUseId":"c1","reason":"' + 'evidence '.repeat(500) + '"', '}]']; @@ -150,7 +162,8 @@ describe('frontier Claude judge compatibility', () => { create.mockClear(); diagnostics.mockClear(); create.mockResolvedValue({ stop_reason, content: [{ type: 'text', text: '{"score":4}' }] } as never); await expect(callJudge('score this', 'claude-fable-5-1', { max_tokens: 16_384, jsonSchema })) - .rejects.toThrow(stop_reason === 'max_tokens' ? 'truncated at max_tokens=16384' : 'Structured judge did not complete'); + .rejects.toThrow(stop_reason === 'max_tokens' ? 'truncated at max_tokens=16384' + : stop_reason === 'refusal' ? 'Judge provider refused the evaluation; no automated score' : 'Structured judge did not complete'); expect(create).toHaveBeenCalledTimes(1); } }); diff --git a/test/paid-pr-profile.test.ts b/test/paid-pr-profile.test.ts index 8307ec8e8..164a014bd 100644 --- a/test/paid-pr-profile.test.ts +++ b/test/paid-pr-profile.test.ts @@ -216,6 +216,6 @@ describe('PR profile paid-runner integration', () => { { name: 'retry', suite: 'judge', passed: true, execution: 'executed' }, { name: 'cached', suite: 'judge', passed: true, execution: 'reused' }, { name: 'failed', suite: 'native', passed: false }, - ] }])).toEqual({ executed: 2, reused: 1, passed: 2, failed: 1, attempts: 4 }); + ] }])).toEqual({ executed: 2, reused: 1, passed: 2, failed: 1, manual_accepted: 0, attempts: 4 }); }); }); diff --git a/test/paid-retry-supervision.test.ts b/test/paid-retry-supervision.test.ts index 5ab99471e..6095d02d8 100644 --- a/test/paid-retry-supervision.test.ts +++ b/test/paid-retry-supervision.test.ts @@ -13,7 +13,7 @@ import { const read = (file: string) => readFileSync(join(import.meta.dir, '..', file), 'utf8'); const newBudgets = FILE_RETRY_BUDGETS.filter(row => !FINDING_RETRY_BUDGETS.some(old => old.file === row.file)); const expectedWalls = { - 'test/skill-llm-eval.test.ts': 6_400_000, + 'test/skill-llm-eval.test.ts': 6_660_000, 'test/skill-e2e-auq-consistency.test.ts': 2_040_000, 'test/codex-e2e-plan-format.test.ts': 5_000_000, 'test/skill-e2e-auq-matrix.test.ts': 3_720_000, @@ -139,15 +139,15 @@ test('quality model work, ordinary tiers and the six existing finding registrati expect(ALL_TIERS).toEqual({ JUDGE_MS: 120000, CAPTURE_MS: 300000, CAPTURE_LONG_MS: 600000, PTY_MS: 900000, PTY_LONG_MS: 1200000 }); const quality = 'test/skill-llm-eval.test.ts'; const qualityBudget = FILE_RETRY_BUDGETS.find(row => row.file === quality)!; - expect(resolvePaidShardBudget([quality])).toEqual({ timeoutMs: 6_400_000, source: 'registered', policyId: qualityBudget.id }); + expect(resolvePaidShardBudget([quality])).toEqual({ timeoutMs: 6_660_000, source: 'registered', policyId: qualityBudget.id }); expect(retriesForFiles([quality])).toBe(1); const qualitySource = read(quality); const judgeTimeouts = [...qualitySource.matchAll(/}\s*,\s*(JUDGE_MS|WORKFLOW_JUDGE_TEST_MS)\s*\);/g)].map(match => match[1]); expect(judgeTimeouts.filter(timeout => timeout === 'JUDGE_MS')).toHaveLength(11); - expect(judgeTimeouts.filter(timeout => timeout === 'WORKFLOW_JUDGE_TEST_MS')).toHaveLength(14); + expect(judgeTimeouts.filter(timeout => timeout === 'WORKFLOW_JUDGE_TEST_MS')).toHaveLength(15); expect(qualitySource).toContain('WORKFLOW_JUDGE_TEST_MS = JUDGE_MS + 10_000'); expect(qualitySource).toContain('const workDeadline = started + JUDGE_MS'); - expect(qualityBudget.shardMs).toBe((11 * ALL_TIERS.JUDGE_MS + 14 * (ALL_TIERS.JUDGE_MS + 10_000)) * 2 + 120_000); + expect(qualityBudget.shardMs).toBe((11 * ALL_TIERS.JUDGE_MS + 15 * (ALL_TIERS.JUDGE_MS + 10_000)) * 2 + 120_000); expect(FINDING_RETRY_BUDGETS.map(row => [row.cases, row.testMs, row.retries, row.shardMs])).toEqual([ [2, 1500000, 1, 6120000], ...Array(5).fill([1, 1500000, 1, 3120000]), ]); @@ -247,7 +247,7 @@ test('the periodic executor supervises every actual case and retry within its CI dedicatedAutoplanSlice: planned.dedicatedAutoplanSlice, evalsAll: true, env: { EVALS_ALL: '1' } }); const census = manifest.entries.filter(row => row.status === 'planned'); expect(census).toHaveLength(99); - expect(census.find(row => row.file === 'test/skill-llm-eval.test.ts')?.budget?.timeoutMs).toBe(6_400_000); + expect(census.find(row => row.file === 'test/skill-llm-eval.test.ts')?.budget?.timeoutMs).toBe(6_660_000); expect(manifest.autoplanSlice).toBe(8); const walls = executor.strategy.matrix.slice.map((slice: number) => paidShardWallUpperBoundMs( census.filter(row => row.slice === slice).map(row => row.file), active.jobs, diff --git a/test/plan-seed-submission.test.ts b/test/plan-seed-submission.test.ts index fad574ffa..ea050776b 100644 --- a/test/plan-seed-submission.test.ts +++ b/test/plan-seed-submission.test.ts @@ -124,7 +124,7 @@ for (const mode of ['unseeded-deadline', 'seeded-deadline', 'protocol-error']) t try { const run = runPlanSkillObservation({ skillName: 'plan-eng-review', cwd: dir, ...(seeded ? { initialPlanContent: '# Exact plan\nNo new work allowance.' } : {}), timeoutMs: mode === 'protocol-error' ? 10000 : 600, model: 'fixture', - env: { CLAUDE_CONFIG_DIR: config, SEED_CASE: mode === 'protocol-error' ? 'wrong-pid' : 'success' } }); + env: { CLAUDE_CONFIG_DIR: config, SEED_CASE: mode === 'protocol-error' ? 'wrong-pid' : mode === 'seeded-deadline' ? 'no-ack' : 'success' } }); if (mode === 'protocol-error') { await expect(run).rejects.toThrow('Plan seed PID status does not match this launch'); expect(fs.existsSync(path.join(config, 'events.jsonl'))).toBe(false); diff --git a/test/shared-libs-fixture.test.ts b/test/shared-libs-fixture.test.ts index dda2f5bac..71ff0e96a 100644 --- a/test/shared-libs-fixture.test.ts +++ b/test/shared-libs-fixture.test.ts @@ -83,6 +83,172 @@ describe('shared-code legacy interactive actor', () => { expect(input).toEqual(before); }); + test('the registered callback answers both complete native index-flag questions without approving changes', async () => { + const native = JSON.parse(fs.readFileSync(path.join(import.meta.dir, 'fixtures/shared-libs-index-flags-native-questions.json'), 'utf8')); + expect(native.sourceRun).toBe(36036582724); + for (const { attempt, input } of native.cases) { + const original = structuredClone(input), questions: unknown[] = [], answers: unknown[] = [], refusals: Error[] = []; + const callback = createSharedInteractiveToolHandler('skip', { + nonQuestion: () => { throw new Error('unexpected tool'); }, + onQuestion: question => { questions.push(question); }, + onAnswer: (question, answer) => { answers.push({ question, answer }); }, + onRefusal: error => { refusals.push(error); }, + }); + const expectedLabels = attempt === 1 ? ['B) Skip', 'Leave it'] : ['C) Skip', 'No, leave it']; + const expected = Object.fromEntries(input.questions.map((question: any, index: number) => + [question.question, expectedLabels[index]])); + expect(await callback('AskUserQuestion', input)).toEqual({ behavior: 'allow', updatedInput: { ...input, answers: expected } }); + expect(questions).toEqual([input]); + expect(answers).toEqual([{ question: input, answer: expected }]); + expect(refusals).toEqual([]); + expect(input).toEqual(original); + } + }); + + test('the captured native no-change questions select every owning interactive lifecycle case', () => { + expect(selectTests(['test/fixtures/shared-libs-index-flags-native-questions.json'], E2E_TOUCHFILES, GLOBAL_TOUCHFILES).selected.sort()) + .toEqual(['shared-libs-review-index-flags', 'shared-libs-review-lifecycle', 'shared-libs-review-path-eligibility', + 'shared-libs-review-prior-coverage', 'shared-libs-review-revalidation']); + }); + + test('the registered callback acknowledges the complete first-attempt native skip despite descriptive reuse', async () => { + const native = JSON.parse(fs.readFileSync(path.join(import.meta.dir, 'fixtures/shared-libs-index-flags-native-questions.json'), 'utf8')); + const { sourceRun, attempt, input } = native.regressions[0]; + expect({ sourceRun, attempt }).toEqual({ sourceRun: 36044212977, attempt: 1 }); + const before = structuredClone(input), questions: unknown[] = [], answers: unknown[] = [], refusals: Error[] = []; + const expected = { [input.questions[0].question]: 'B) Skip' }; + const callback = createSharedInteractiveToolHandler('skip', { + nonQuestion: () => { throw new Error('unexpected tool'); }, + onQuestion: question => { questions.push(question); }, + onAnswer: (question, answer) => { answers.push({ question, answer }); }, + onRefusal: error => { refusals.push(error); }, + }); + expect(await callback('AskUserQuestion', input)).toEqual({ behavior: 'allow', updatedInput: { ...input, answers: expected } }); + expect(questions).toEqual([input]); + expect(answers).toEqual([{ question: input, answer: expected }]); + expect(refusals).toEqual([]); + expect(input).toEqual(before); + }); + + test('the registered callback answers the complete 5460 first-attempt native packet', async () => { + const native = JSON.parse(fs.readFileSync(path.join(import.meta.dir, 'fixtures/shared-libs-index-flags-native-questions.json'), 'utf8')); + const { sourceRun, sourceRevision, attempt, toolUseId, input } = native.regressions[1]; + expect({ sourceRun, sourceRevision, attempt, toolUseId }).toEqual({ sourceRun: 36080890009, + sourceRevision: '5460ce0847574aadfa6cbdd0b9545d2caf9935da', attempt: 1, + toolUseId: 'toolu_01Up8B1FR4bhkmRxyhbcAqhY' }); + const before = structuredClone(input), questions: unknown[] = [], answers: unknown[] = [], refusals: Error[] = []; + const expected = { [input.questions[0].question]: 'Skip', [input.questions[1].question]: 'Leave it' }; + const callback = createSharedInteractiveToolHandler('skip', { + nonQuestion: () => { throw new Error('unexpected tool'); }, + onQuestion: question => { questions.push(question); }, + onAnswer: (question, answer) => { answers.push({ question, answer }); }, + onRefusal: error => { refusals.push(error); }, + }); + expect(await callback('AskUserQuestion', input)).toEqual({ behavior: 'allow', updatedInput: { ...input, answers: expected } }); + expect(questions).toEqual([input]); + expect(answers).toEqual([{ question: input, answer: expected }]); + expect(refusals).toEqual([]); + expect(input).toEqual(before); + }); + + test.each([ + { label: 'Leave it', description: 'Keep the index flag as-is and record the decision in the review log.' }, + { label: 'No, leave it', description: 'Preserve the current index flag. Report its hidden source without modifying it.' }, + { label: 'Keep it', description: 'Leave the index flag set; update the review log with the skipped advisory.' }, + ])('a referential retention choice requires an explicit no-change description: $label', async option => { + const input = { questions: [{ question: 'Should I clear the index flag?', options: [ + { label: 'Clear the index flag', description: 'Make its hidden changes visible.' }, option, + ] }] }; + const answer = await createSharedInteractiveToolHandler('skip', { + nonQuestion: () => {}, onQuestion: () => {}, onAnswer: () => {}, + })('AskUserQuestion', input); + expect(answer.updatedInput.answers).toEqual({ [input.questions[0].question]: option.label }); + }); + + test.each([ + { label: 'Leave it', description: 'Keep the assume-unchanged bit; exclude its path from snapshot coverage.' }, + { label: 'Leave it', description: 'Preserve the `skip-worktree` bit; report its hidden source.' }, + { label: 'Leave it', description: 'Keep the Git index bits unchanged; record the advisory.' }, + { label: 'Leave it', description: 'Keep the index attributes; report the hidden route.' }, + { label: 'Leave it', description: 'Retain the index settings; report without changing them.' }, + { label: 'Keep the assume-unchanged bit' }, + { label: 'Leave the `skip-worktree` flag' }, + { label: 'Keep Git index attributes' }, + ])('a qualified Git-index state may be preserved without authorizing edits: $label', async option => { + const input = { questions: [{ question: 'Should I clear the index flag?', options: [ + { label: 'Clear the index flag', description: 'Make its hidden changes visible.' }, option, + ] }] }; + const answer = await createSharedInteractiveToolHandler('skip', { + nonQuestion: () => {}, onQuestion: () => {}, onAnswer: () => {}, + })('AskUserQuestion', input); + expect(answer.updatedInput.answers).toEqual({ [input.questions[0].question]: option.label }); + }); + + test.each([ + [{ label: 'Leave it' }], + [{ label: 'Leave it', description: 'Keep it.' }], + [{ label: 'Leave it', description: 'Keep going.' }], + [{ label: 'Leave it', description: 'Keep working on the fix.' }], + [{ label: 'Leave it', description: 'Keep trying.' }], + [{ label: 'Leave it', description: 'Keep investigating the source state.' }], + [{ label: 'Leave it', description: 'Keep pursuing the source fix.' }], + [{ label: 'Leave it', description: 'Keep progress going.' }], + [{ label: 'Leave it', description: 'Keep source changing.' }], + [{ label: 'Leave it', description: 'Keep the changes going.' }], + [{ label: 'Leave it', description: 'Preserve the implementation by rewriting the helper.' }], + [{ label: 'Skip', description: 'Keep the duplicated copies. Reuse of this skip.' }], + [{ label: 'Skip', description: 'Keep the duplicated copies. The reuse of this decision.' }], + [{ label: 'Skip', description: 'Keep the duplicated copies. Reuse of the review decision will still clear the flag.' }], + [{ label: 'Skip', description: 'Keep the duplicated copies. Reuse of the review decision will eventually modify source.' }], + [{ label: 'Skip', description: 'Keep the duplicated copies. Reuse of the review decision can quietly replace the helper.' }], + [{ label: 'Skip', description: 'Keep the duplicated copies. Reuse of this decision will still be applied to source.' }], + [{ label: 'Skip', description: 'Keep the duplicated copies. Reuse of this decision will need to clear the flag.' }], + [{ label: 'Skip', description: 'Keep the duplicated copies. Reuse the recorded decision will still clear the flag.' }], + [{ label: 'Skip', description: 'Keep the code unchanged; the recorded decision says the route will be reused.' }], + [{ label: 'Skip', description: 'Keep the code unchanged; the decision names the source so it will be reused.' }], + [{ label: 'Skip', description: 'Keep both duplicated copies; a later review can reuse this decision to clear the flag.' }], + [{ label: 'No, leave it', description: 'No changes.' }], + [{ label: 'Leave it', description: 'Clear the index flag and report it.' }], + [{ label: 'No, leave it', description: 'Keep the index flag as-is; apply the worker fix.' }], + [{ label: 'Leave it', description: 'Keep the flag set; the route change remains hidden yet will clear the flag tomorrow.' }], + [{ label: 'No, leave it and clear the flag', description: 'Keep the index flag set.' }], + [{ label: 'Leave it', description: 'Update the review log with this decision.' }], + [{ label: 'Leave it', description: 'Keep the flag set.' }, { label: 'Keep it', description: 'Preserve the index flag as-is.' }], + ])('referential retention refuses vague, mixed, or duplicate choices: %j', async options => { + const refusals: Error[] = [], answers: unknown[] = []; + const callback = createSharedInteractiveToolHandler('skip', { + nonQuestion: () => {}, onQuestion: () => {}, onAnswer: answer => { answers.push(answer); }, + onRefusal: error => { refusals.push(error); }, + }); + await expect(callback('AskUserQuestion', { questions: [{ question: 'Should I clear the index flag?', options }] })) + .rejects.toThrow('No unambiguous no-change option'); + expect(refusals).toHaveLength(1); + expect(answers).toEqual([]); + }); + + test.each([ + [{ label: 'Leave it', description: 'Keep the bit unchanged.' }], + [{ label: 'Keep the bit' }], + [{ label: 'Keep the index bit and set the other flag' }], + [{ label: 'Leave it', description: 'Keep the index bit; set the skip-worktree flag.' }], + [{ label: 'Leave it', description: 'Keep the assume-unchanged bit while unsetting the skip-worktree flag.' }], + [{ label: 'Leave it', description: 'Keep the index setting by toggling the other flag.' }], + [{ label: 'Leave it', description: 'Keep the `assume-unchanged` bit; flip the skip-worktree flag.' }], + [{ label: 'Leave it', description: 'Keep the index flag, then reset the other index bit.' }], + [{ label: 'Leave it', description: 'Keep the index flag; enable the skip-worktree bit.' }], + [{ label: 'Leave it', description: 'Keep the index flag; disable the skip-worktree bit.' }], + ])('qualified index retention rejects ambiguous bits and state-changing commitments: %j', async options => { + const refusals: Error[] = [], answers: unknown[] = []; + const callback = createSharedInteractiveToolHandler('skip', { + nonQuestion: () => {}, onQuestion: () => {}, onAnswer: answer => { answers.push(answer); }, + onRefusal: error => { refusals.push(error); }, + }); + await expect(callback('AskUserQuestion', { questions: [{ question: 'Should I clear the index flag?', options }] })) + .rejects.toThrow('No unambiguous no-change option'); + expect(refusals).toHaveLength(1); + expect(answers).toEqual([]); + }); + const preservationCaptures = JSON.parse(fs.readFileSync(path.join(import.meta.dir, 'fixtures/shared-libs-index-flags-no-change-description.json'), 'utf8')).cases; @@ -141,11 +307,16 @@ describe('shared-code legacy interactive actor', () => { { label: 'Decline', description: 'Don’t refactor either caller.' }, { label: 'Don’t refactor', description: 'Keep the current implementation.' }, { label: 'Do not change', description: 'Leave source untouched. No code edits or new tests.' }, + { label: 'Skip', description: 'Do not refactor by extracting a helper. Keep the source unchanged.' }, { label: 'Leave it set', description: 'Do not touch the index flag; report missing snapshot coverage.' }, { label: 'Keep current', description: 'Keep both implementations unchanged.', preview: '// no edits; record skipped advisory' }, { label: 'Leave it set', description: 'Do not touch the index flag. Any edit to retry-route.ts stays local-only until you clear it yourself; it stays excluded from snapshot coverage.' }, { label: 'Skip', description: 'Keep the duplicated implementation as-is. Recorded as an explicit skipped advisory with full snapshot coverage so it can be reused next review.' }, { label: 'Skip', description: 'Keep both inline copies. Recorded as an explicit skip with verified snapshot coverage for future reuse.' }, + { label: 'Skip', description: 'Keep the duplicated copies. Reuse of this skip will still require revalidation while the route remains hidden.' }, + { label: 'Skip', description: 'Keep the duplicated copies. The reuse of this decision is recorded as requiring revalidation.' }, + { label: 'Skip', description: 'Keep both duplicated copies; a later review can reuse this decision.' }, + { label: 'Skip', description: 'Keep both copies. The recorded review decision can be reused in a later review.' }, { label: 'Skip', description: 'Update the review log with the skipped advisory; reuse the recorded decision next review.' }, { label: 'Skip', description: 'This option does not refactor the route. You should not fix the worker.' }, { label: 'Skip', description: 'This option updates the review log. We will reuse the recorded decision.' }, @@ -178,6 +349,9 @@ describe('shared-code legacy interactive actor', () => { [{ label: 'Skip', description: 'You should fix the worker' }], [{ label: 'Skip', description: 'The worker imports the helper' }], [{ label: 'Skip', description: 'We will clear the index flag' }], + [{ label: 'Skip', description: 'Preserve the implementation by rewriting the helper.' }], + [{ label: 'Skip', description: 'Keep the source through applying the fix.' }], + [{ label: 'Skip', description: 'Retain the implementation via extracting a helper.' }], [{ label: 'Keep going' }], [{ label: 'Do not warn' }], [{ label: 'Leave logging disabled and fix parser' }], @@ -631,7 +805,7 @@ describe('shared-code capture attempt accounting', () => { expect(result.tests.map((row: EvalTestEntry) => row.transcript!.filter(event => event.scenario_name).length)) .toEqual([3, 3, 2, 2, 4, 4]); expect(result.total_cost_usd).toBe(0.18); - expect(collectorOutcomeCounts([result])).toEqual({ executed: 3, reused: 0, passed: 3, failed: 0, attempts: 6 }); + expect(collectorOutcomeCounts([result])).toEqual({ executed: 3, reused: 0, passed: 3, failed: 0, manual_accepted: 0, attempts: 6 }); }); test('missing scenarios in a later attempt cannot inherit an earlier pass', async () => { @@ -645,7 +819,7 @@ describe('shared-code capture attempt accounting', () => { })).rejects.toThrow('missing scenarios: second'); const result = await finalized(captures); expect(result.tests[1]).toMatchObject({ attempt: 2, passed: false, exit_reason: 'attempt_incomplete' }); - expect(collectorOutcomeCounts([result])).toEqual({ executed: 1, reused: 0, passed: 0, failed: 1, attempts: 2 }); + expect(collectorOutcomeCounts([result])).toEqual({ executed: 1, reused: 0, passed: 0, failed: 1, manual_accepted: 0, attempts: 2 }); }); test('setup, verification and cleanup failures survive even when all recorded captures passed', async () => { @@ -764,7 +938,7 @@ test('actual-retry', () => captures.runAttempt('actual-retry', ['audit'], 5_000, const result = JSON.parse(fs.readFileSync(path.join(resultDir, file), 'utf8')); expect(result.tests.map((row: EvalTestEntry) => [row.attempt, row.passed, row.exit_reason])) .toEqual([[1, false, 'timeout'], [2, true, 'success']]); - expect(collectorOutcomeCounts([result])).toEqual({ executed: 1, reused: 0, passed: 1, failed: 0, attempts: 2 }); + expect(collectorOutcomeCounts([result])).toEqual({ executed: 1, reused: 0, passed: 1, failed: 0, manual_accepted: 0, attempts: 2 }); }); test('Bun outer timeouts stay failed after late completion, with and without a retry', () => { @@ -806,7 +980,7 @@ test('outer-timeout', () => captures.runAttempt('outer-timeout', ['audit'], 50, .toEqual(mode === 'retry' ? [[1, false], [2, true]] : [[1, false]]); expect(['timeout', 'attempt_incomplete']).toContain(result.tests[0].exit_reason); expect(result.tests[0].error).toContain('Test attempt stopped:'); - expect(collectorOutcomeCounts([result])).toEqual({ executed: 1, reused: 0, + expect(collectorOutcomeCounts([result])).toEqual({ executed: 1, reused: 0, manual_accepted: 0, passed: mode === 'retry' ? 1 : 0, failed: mode === 'retry' ? 0 : 1, attempts: mode === 'retry' ? 2 : 1 }); } }); diff --git a/test/skill-llm-eval.test.ts b/test/skill-llm-eval.test.ts index 5d784f827..7c15a423c 100644 --- a/test/skill-llm-eval.test.ts +++ b/test/skill-llm-eval.test.ts @@ -15,11 +15,14 @@ import { JUDGE_MS } from './helpers/eval-budgets'; import Anthropic from '@anthropic-ai/sdk'; import * as fs from 'fs'; import * as path from 'path'; -import { callJudge, judge } from './helpers/llm-judge'; +import { callJudge, judge, JudgeRefusalError, DEFAULT_JUDGE_MAX_TOKENS } from './helpers/llm-judge'; import { ENG_REVIEW_EXCERPT } from './helpers/workflow-excerpt'; import type { JudgeScore } from './helpers/llm-judge'; -import { readWorkflowJudgeInput, buildWorkflowJudgePrompt } from './helpers/workflow-judge-input'; +import { readWorkflowJudgeInput, buildWorkflowJudgePrompt, type WorkflowJudgeInput } from './helpers/workflow-judge-input'; import { prepareWorkflowJudgeCache } from './helpers/workflow-judge-cache'; +import { buildCookieWorkflowJudgeInput, COOKIE_WORKFLOW_JUDGE } from './helpers/cookie-workflow-judge-input'; +import { getCookieWorkflowManualReview, type ManualJudgeReview } from './helpers/cookie-workflow-manual-review'; +import { resolveEvalModel } from '../lib/eval-model'; import { LLM_JUDGE_TOUCHFILES } from './helpers/touchfiles'; // Runs when EVALS=1 is set (requires ANTHROPIC_API_KEY in env) — the EVALS // gate lives in the shared describeIfSelected. Selection machinery is shared @@ -593,6 +596,7 @@ async function runWorkflowJudge(opts: { judgeContext: string; judgeGoal: string; thresholds?: { clarity: number; completeness: number; actionability: number }; + readInput?: () => WorkflowJudgeInput; }) { const started = performance.now(); const previous = workflowJudgeAttempts.get(opts.testName); @@ -603,6 +607,8 @@ async function runWorkflowJudge(opts: { let stage: 'input' | 'judge' | 'validation' | 'recording' = 'input'; let finalized = false; let scores: JudgeScore | undefined; + let manualReview: ManualJudgeReview | undefined; + let customInputMetadata: { prompt: string; model: string } | undefined; let reused: ReturnType['lookup']> = null; let timer: ReturnType; let rejectStopped: (error: Error) => void; @@ -617,18 +623,23 @@ async function runWorkflowJudge(opts: { clearTimeout(timer); if (!passed) controller.abort(error); evalCollector?.addTest({ - name: opts.testName, suite: opts.suite, tier: 'llm-judge', passed, + name: opts.testName, suite: opts.suite, tier: 'llm-judge', passed, attempt, duration_ms: Math.max(0, performance.now() - started), cost_usd: reused || !scores ? 0 : 0.02, execution: reused ? 'reused' : 'executed', + ...customInputMetadata, + ...(manualReview ? { manual_review: manualReview } : {}), ...(reused ? { reused_from: { input_key: reused.reuse.key, run_id: reused.reuse.source.runId, revision: reused.reuse.source.revision, completed_at: new Date(reused.reuse.source.completedAt).toISOString() } } : {}), ...(scores ? { judge_scores: { clarity: scores.clarity, completeness: scores.completeness, actionability: scores.actionability }, judge_reasoning: scores.reasoning } : {}), - ...(passed ? {} : { exit_reason: error instanceof Error && error.name === 'WorkflowJudgeDeadline' ? 'timeout' + ...(passed ? {} : { exit_reason: error instanceof JudgeRefusalError ? 'provider_refusal' + : error instanceof Error && error.name === 'WorkflowJudgeDeadline' ? 'timeout' : error instanceof Error && error.name === 'WorkflowJudgeSuperseded' ? 'cancelled' : stage === 'validation' ? 'validation_failed' : 'harness_error', - error: `${error instanceof Error ? error.message : String(error)}${scores ? '' : '\nNo completed model response; cost and usage unavailable.'}` }), + error: `${error instanceof Error ? error.message : String(error)}${scores ? '' : error instanceof JudgeRefusalError + ? '\nNo automated score; provider refusal usage retained when manually accepted; cost unavailable.' + : '\nNo completed model response; cost and usage unavailable.'}` }), }); }; const stop = (error: Error) => { @@ -653,16 +664,35 @@ async function runWorkflowJudge(opts: { const work = async () => { checkActive(); const thresholds = { clarity: 4, completeness: 3, actionability: 4, ...opts.thresholds }; - const input = readWorkflowJudgeInput({ root: ROOT, skillPath: opts.skillPath, + const input = opts.readInput ? opts.readInput() : readWorkflowJudgeInput({ root: ROOT, skillPath: opts.skillPath, startMarker: opts.startMarker, endMarker: opts.endMarker }); checkActive(); const prompt = buildWorkflowJudgePrompt(opts, input); + if (opts.readInput) customInputMetadata = { prompt, model: resolveEvalModel('judge') }; const cache = prepareWorkflowJudgeCache({ ...opts, root: ROOT, thresholds, prompt, attempt }); checkActive(); reused = cache.lookup(); checkActive(); stage = 'judge'; - const result = reused?.scores ?? await callJudge(prompt, undefined, { signal: controller.signal }); + const maxTokens = DEFAULT_JUDGE_MAX_TOKENS; + let result: JudgeScore; + try { + result = reused?.scores ?? await callJudge(prompt, undefined, { signal: controller.signal, max_tokens: maxTokens }); + } catch (error) { + checkActive(); + if (error instanceof JudgeRefusalError && customInputMetadata) { + const approved = getCookieWorkflowManualReview(ROOT, { testName: opts.testName, prompt, + model: customInputMetadata.model, maxTokens, thresholds, attempt }, error.refusal); + checkActive(); + if (approved) { + manualReview = approved; + console.log(`[workflow-judge] ${opts.testName}: MANUAL ACCEPTANCE, no automated score; ${approved.approval.approval_url}`); + finish(false, error); + return; + } + } + throw error; + } checkActive(); scores = result; console.log(`[workflow-judge] ${opts.testName}: ${reused ? `reused ${reused.reuse.source.runId} @ ${reused.reuse.source.revision} (${new Date(reused.reuse.source.completedAt).toISOString()})` : 'executed'}`); @@ -941,5 +971,19 @@ ${voiceSection}`); }, JUDGE_MS); }); +describeIfSelected('Cookie setup workflow quality', ['setup-browser-cookies/SKILL.md workflow'], () => { + testIfSelected('setup-browser-cookies/SKILL.md workflow', async () => { + await runWorkflowJudge({ + testName: 'setup-browser-cookies/SKILL.md workflow', + suite: 'Cookie setup workflow quality', + skillPath: 'setup-browser-cookies/SKILL.md', + startMarker: '# Setup Browser Cookies', + endMarker: null, + ...COOKIE_WORKFLOW_JUDGE, + readInput: () => buildCookieWorkflowJudgeInput(ROOT), + }); + }, WORKFLOW_JUDGE_TEST_MS); +}); + // Module-level afterAll — finalize eval collector after all tests complete afterAll(() => finalizeEvalCollector(evalCollector)); diff --git a/test/touchfiles.test.ts b/test/touchfiles.test.ts index 67ab726df..b68aaf097 100644 --- a/test/touchfiles.test.ts +++ b/test/touchfiles.test.ts @@ -594,7 +594,7 @@ describe('TOUCHFILES completeness', () => { ); const unique = registeredJudgeTestNames(llmContent); - expect(unique).toHaveLength(25); + expect(unique).toHaveLength(26); const missing = unique.filter(name => !(name in LLM_JUDGE_TOUCHFILES)); if (missing.length > 0) { @@ -613,7 +613,7 @@ describe('TOUCHFILES completeness', () => { testIfSelected('unmapped judge case', async () => {}, 120_000); `; const names = registeredJudgeTestNames(withUnmappedCase); - expect(names).toHaveLength(26); + expect(names).toHaveLength(27); expect(names.filter(name => !(name in LLM_JUDGE_TOUCHFILES))).toEqual(['unmapped judge case']); }); diff --git a/test/workflow-judge-cache.test.ts b/test/workflow-judge-cache.test.ts index 852c00057..bf91a3159 100644 --- a/test/workflow-judge-cache.test.ts +++ b/test/workflow-judge-cache.test.ts @@ -1,6 +1,7 @@ import { afterEach, expect, spyOn, test } from 'bun:test'; import { Messages } from '@anthropic-ai/sdk/resources/messages'; -import { callJudge } from './helpers/llm-judge'; +import { callJudge, JudgeRefusalError, DEFAULT_JUDGE_MAX_TOKENS } from './helpers/llm-judge'; +import { getCookieWorkflowManualReview } from './helpers/cookie-workflow-manual-review'; import { resolveEvalModel } from '../lib/eval-model'; import * as fs from 'node:fs'; import * as os from 'node:os'; @@ -141,7 +142,7 @@ test('workflow registration preserves model work and reserves only terminal-reco const source = fs.readFileSync(path.join(import.meta.dir, 'skill-llm-eval.test.ts'), 'utf8'); const body = source.split('async function runWorkflowJudge')[1]!.split('// Block 1:')[0]!; const stages = ['workflowJudgeAttempts.set', 'readWorkflowJudgeInput(', 'cache.lookup()', - 'callJudge(prompt, undefined, { signal: controller.signal })', + 'callJudge(prompt, undefined, { signal: controller.signal, max_tokens: maxTokens })', 'expect(scores.clarity)', 'expect(scores.completeness)', 'expect(scores.actionability)', 'cache.publish(scores, active)'] .map(stage => body.indexOf(stage)); expect(stages.every(position => position >= 0)).toBe(true); @@ -150,7 +151,7 @@ test('workflow registration preserves model work and reserves only terminal-reco expect(body).toContain('const workDeadline = started + JUDGE_MS;'); expect(source).toContain('const WORKFLOW_JUDGE_RECORD_MS = 5_000;'); expect(source).toContain('const WORKFLOW_JUDGE_TEST_MS = JUDGE_MS + 10_000;'); - expect(source.match(/\}, WORKFLOW_JUDGE_TEST_MS\);/g)).toHaveLength(14); + expect(source.match(/\}, WORKFLOW_JUDGE_TEST_MS\);/g)).toHaveLength(15); expect(source.match(/\}, JUDGE_MS\);/g)).toHaveLength(11); }); @@ -174,7 +175,7 @@ function actualCallback(f: ReturnType, overrides: { const run = new Function('ROOT', 'readWorkflowJudgeInput', 'buildWorkflowJudgePrompt', 'prepareWorkflowJudgeCache', 'workflowJudgeAttempts', 'callJudge', 'evalCollector', 'expect', 'console', 'performance', 'JUDGE_MS', 'WORKFLOW_JUDGE_RECORD_MS', - 'setTimeout', 'clearTimeout', + 'setTimeout', 'clearTimeout', 'JudgeRefusalError', 'getCookieWorkflowManualReview', 'DEFAULT_JUDGE_MAX_TOKENS', `${javascript}\nreturn runWorkflowJudge;`)( f.root, overrides.read ?? readWorkflowJudgeInput, buildWorkflowJudgePrompt, (options: WorkflowCacheOptions) => (overrides.prepare ?? prepareWorkflowJudgeCache)({ ...options, env: f.env }), @@ -183,7 +184,8 @@ function actualCallback(f: ReturnType, overrides: { return overrides.judge ? overrides.judge(prompt, model, options) : scores; }, { addTest: (entry: any) => records.push(entry) }, expect, { log() {} }, overrides.clock ? { now: overrides.clock } : performance, overrides.budget ?? 120_000, overrides.allowance ?? 5_000, - overrides.setTimer ?? setTimeout, overrides.clearTimer ?? clearTimeout); + overrides.setTimer ?? setTimeout, overrides.clearTimer ?? clearTimeout, + JudgeRefusalError, getCookieWorkflowManualReview, DEFAULT_JUDGE_MAX_TOKENS); return { run, records, signals, prompts, attempts, options: { ...f.opts, suite: 'Cache regression' } }; } @@ -193,6 +195,8 @@ test('the actual workflow callback executes once, reuses with provenance, and pr await first.run(options); expect(first.prompts).toEqual([f.opts.prompt]); expect(f.entries()).toHaveLength(1); expect(first.records[0]).toMatchObject({ passed: true, execution: 'executed', cost_usd: 0.02 }); + expect(first.records[0]).not.toHaveProperty('prompt'); + expect(first.records[0]).not.toHaveProperty('model'); const reused = actualCallback(f, { judge: async () => ({ ...scores, clarity: 1 }) }); await reused.run(options); expect(reused.prompts).toHaveLength(0);