diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 7392520e5..01f141b5b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -14,7 +14,7 @@ updates: # The windowsHide patch (package.json patchedDependencies, #2160/#1989) is # keyed to one playwright-core version; a minor or major bump drops it and # brings back Windows console flashes. Patch updates still flow. The 1.64 - # migration (re-key the patch, the Dia/cookie qualification pins, the + # migration (re-key the patch, the cookie qualification pins, the # arm64 artifact name) is owned by Garry, revisit 2026-11-15 (#2923). ignore: - dependency-name: "playwright" diff --git a/.github/scripts/dia-gui-readiness.c b/.github/scripts/dia-gui-readiness.c deleted file mode 100644 index b046dec49..000000000 --- a/.github/scripts/dia-gui-readiness.c +++ /dev/null @@ -1,138 +0,0 @@ -#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 deleted file mode 100644 index ef4538f3b..000000000 --- a/.github/scripts/dia-launch-driver.mjs +++ /dev/null @@ -1,231 +0,0 @@ -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.2' || 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.2' : '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.2' : 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 deleted file mode 100644 index b5fc3b9e3..000000000 --- a/.github/scripts/qualify-dia-macos.ts +++ /dev/null @@ -1,1430 +0,0 @@ -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.2' : '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.2' || 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()); - if (!comparisonSource) throw new Error('diagnostic_source_launch_returned_no_result'); - 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.2' || 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-dia-native-qualification.ts b/.github/scripts/run-dia-native-qualification.ts deleted file mode 100644 index 95236b1f9..000000000 --- a/.github/scripts/run-dia-native-qualification.ts +++ /dev/null @@ -1,864 +0,0 @@ -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.2' || 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'); - if (!comparisonControl) throw new Error('comparison_control_failed'); - 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.2') 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/native-qualification.yml b/.github/workflows/native-qualification.yml index fedf9c2e6..59d6e562f 100644 --- a/.github/workflows/native-qualification.yml +++ b/.github/workflows/native-qualification.yml @@ -1,8 +1,8 @@ name: Native Qualification # Dispatch-only diagnostic campaigns for native browser integrations: owned -# Windows cookie extraction, Windows launch/credential diagnostics, and Dia on -# disposable Apple-silicon Macs. They exercise real browsers and OS services, +# Windows cookie extraction and Windows launch/credential diagnostics. They +# exercise real browsers and OS services, # so they are qualification evidence, not PR gates; they used to live in # windows-free-tests.yml, where their red dispatch runs read as a red PR lane. # @@ -19,9 +19,6 @@ on: options: - cookie-native - windows-native-diagnostics - - dia-native - - dia-launch-comparison - - dia-gui-readiness concurrency: group: native-qualification-${{ github.ref }}-${{ inputs.mode }} @@ -102,53 +99,3 @@ jobs: name: windows-native-diagnostics path: ${{ runner.temp }}/gstack-free-test-*.log if-no-files-found: ignore - - dia-native-qualification: - if: startsWith(inputs.mode, 'dia-') - runs-on: macos-15 - timeout-minutes: 20 - strategy: - fail-fast: false - matrix: - runtime: ${{ fromJSON(inputs.mode == 'dia-launch-comparison' && '["bun","node"]' || '["bun"]') }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - persist-credentials: false - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 - with: - bun-version: 1.4.2 - - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - if: inputs.mode == 'dia-launch-comparison' - with: - node-version: 24.18.0 - architecture: arm64 - - name: Install pinned dependencies - if: inputs.mode != 'dia-gui-readiness' - run: bun install --frozen-lockfile - - name: Install the synthetic destination browser - if: inputs.mode != 'dia-gui-readiness' - run: bunx --no-install playwright install chromium - - name: Inspect GUI readiness without browser or Keychain access - if: inputs.mode == '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.mode == 'dia-native' - 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.mode == 'dia-launch-comparison' - 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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 - with: - name: ${{ inputs.mode == 'dia-gui-readiness' && 'dia-gui-readiness' || inputs.mode == '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 c91ac7c04..7dc9c3546 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -199,7 +199,7 @@ Storage reset and authentication verification are independent opt-ins. `--clear- ### Shell injection prevention -The browser registry (Chrome, Chromium, Brave, Edge, Windows-only Opera and Opera GX, 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. +The browser registry (Chrome, Chromium, Brave, Edge, Windows-only Opera and Opera GX, and macOS-only Comet and Arc) 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) diff --git a/BROWSER.md b/BROWSER.md index 886016ced..13aa70909 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` copies selected cookies from Chrome, Chromium, Brave, Edge, Windows-only Opera and Opera GX, or macOS-only Comet, Arc, and Dia; verify sign-in separately, or log in once in headed mode | +| Your sessions are already there | `/setup-browser-cookies` copies selected cookies from Chrome, Chromium, Brave, Edge, Windows-only Opera and Opera GX, or macOS-only Comet and Arc; 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 | @@ -568,7 +568,7 @@ from `snapshot`, or `@c` refs from `snapshot -C`. Full table: #### Choosing a source and checking sign-in -Select the source browser and account/profile explicitly. The picker recognizes Chrome, Chromium, Brave, Edge, Windows-only Opera and Opera GX, 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. +Select the source browser and account/profile explicitly. The picker recognizes Chrome, Chromium, Brave, Edge, Windows-only Opera and Opera GX, and macOS-only Comet and Arc. 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`: diff --git a/CHANGELOG.md b/CHANGELOG.md index cc9780e57..b7a8e247b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [Unreleased] + +**Dia is gone from gstack.** `/setup-browser-cookies` and `cookie-import-browser` no longer list Dia as a cookie source, and the Dia qualification harness is deleted. That harness was a set of macOS campaigns that never qualified a native import, and its `dia-launch-comparison` free test was failing the merge train on Ubicloud runners. Cookie import from Comet, Chrome, Chromium, Arc, Brave, Edge, Opera and Opera GX is unchanged. + +### Removed +- **Dia as a cookie source**: the registry entry, the `dia` alias, and every Dia mention in the `cookie-import-browser` description, the browse and setup-browser-cookies skills, README, BROWSER.md, ARCHITECTURE.md and docs/skills.md. +- The `dia-native`, `dia-launch-comparison` and `dia-gui-readiness` modes are gone from `native-qualification.yml`, along with their job, the four `.github/scripts` files behind them, and their three `browse/test` files. Those tests also leave the free-test duration seed and the test typecheck baseline. +- **`clang` on the Ubicloud free-suite VM**: only the Dia GUI readiness test compiled C on Linux. + +### Tests +- The cookie tests that used Dia as their macOS fixture browser now use Arc, which has the same `User Data` layout and Keychain shape: numbered-profile discovery, Keychain denial, the credential deadline, the Node bundle import and the picker UI. The historical cookie-workflow approval fixture adds one reverse edit so it still rebuilds the approved prompt byte for byte. No threshold or allowlist changed. + ## [1.91.295.0] - 2026-10-28 **Deleting or renaming a test no longer fails pregate with PREGATE_NO_LANE.** A PR that removed a test file, or renamed one, failed `lanes` because the removed path had no test lane of its own, so cleanup PRs needed a hand-written `dependencies` entry to pass (#3529). Pregate now accounts for a removed test the way it accounts for an edit. A renamed test runs under its new name, a deleted test runs the remaining tests of the files it exercised, and either one runs the tests of any file that still names it, such as a curation list. Every waived test is printed on its own line, so the reason is visible in the gate output. Removing any other file still needs a lane, as before. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 37eece64e..f3ba07335 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -194,7 +194,7 @@ the new defaults. | Clear pregate's test tier on a machine under 8 cores ([remote gate](docs/pregate.md#remote-gate)) | `gstack-pregate --stage tests --remote --spend-cap 2 --timeout 2700 --allow-repo-commands` (same command keyed or keyless) | Keyed: one billed 16-vCPU VM, about 7 minutes and $0.06 for the whole free tier, worst case $0.38; keyless: free, local parallel shards, practical for small selections only (98 files took 103 s on 4 cores) | Keyed: `UBICLOUD_API_KEY`; keyless: nothing | | Run the curated Windows-safe subset | `bun run test:windows` (CI: six `windows-latest` jobs) | Free | Windows, Git Bash | | Refresh the Windows duration seed | `gh workflow run windows-free-tests.yml --ref -f record_durations=true`, then commit the `free-test-durations-windows` artifact | Free CI runners | Pushed branch, `gh` with workflow rights | -| Run a native Windows or Dia qualification campaign | `gh workflow run native-qualification.yml --ref -f mode=` ([modes](docs/TESTING_INTERNALS.md#windows-free-lane)) | Free CI runners | Pushed branch, `gh` with workflow rights | +| Run a native Windows qualification campaign | `gh workflow run native-qualification.yml --ref -f mode=` ([modes](docs/TESTING_INTERNALS.md#windows-free-lane)) | Free CI runners | Pushed branch, `gh` with workflow rights | | Preview which paid cases my diff selects | `bun run eval:select` (PR profile; `--profile full` for the plain touchfile selection) | Free | — | | Preview the CI paid slice plan | `bun run scripts/test-paid-shards.ts --tier periodic --list --slice-budget 420 --jobs 2` (diff-selected; `EVALS_ALL=1` lists everything) | Free | — | | Run paid coverage for my change (agents: detached) | `bun run eval:bg:pr` (foreground: `bun run test:pr`) | API spend for the selected cases only; about 10 minutes when dispatched to CI | Dispatch: a clean, pushed HEAD and `gh`. Local fallback: `ANTHROPIC_API_KEY`, Claude Code CLI, a plain terminal | diff --git a/README.md b/README.md index a7519ae27..df7265150 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,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** | Copy selected cookies from Chrome, Chromium, Brave, Edge, Windows-only Opera and Opera GX, 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. | +| `/setup-browser-cookies` | **Session Manager** | Copy selected cookies from Chrome, Chromium, Brave, Edge, Windows-only Opera and Opera GX, or macOS-only Comet and Arc 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/browse/SKILL.md b/browse/SKILL.md index 21081c0d2..e5792ca58 100644 --- a/browse/SKILL.md +++ b/browse/SKILL.md @@ -452,8 +452,7 @@ 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 +browser and account/profile. 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 diff --git a/browse/SKILL.md.tmpl b/browse/SKILL.md.tmpl index 009a153f0..0655b388a 100644 --- a/browse/SKILL.md.tmpl +++ b/browse/SKILL.md.tmpl @@ -112,8 +112,7 @@ 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 +browser and account/profile. 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 diff --git a/browse/sections/command-list.md b/browse/sections/command-list.md index be87f64d9..94b7f8ac2 100644 --- a/browse/sections/command-list.md +++ b/browse/sections/command-list.md @@ -94,7 +94,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] [--clear-storage] [--verify-auth]` | Copy cookies from chrome, chromium, brave, edge, Windows-only opera, opera-gx, 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. | +| `cookie-import-browser [browser] [--domain d] [--profile p] [--all] [--clear-storage] [--verify-auth]` | Copy cookies from chrome, chromium, brave, edge, Windows-only opera, opera-gx, or macOS-only comet or arc. 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/commands.ts b/browse/src/commands.ts index 433561efd..2d2a26015 100644 --- a/browse/src/commands.ts +++ b/browse/src/commands.ts @@ -130,7 +130,7 @@ export const COMMAND_DESCRIPTIONS: Record|auto] [--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: 'Copy cookies from chrome, chromium, brave, edge, Windows-only opera, opera-gx, 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]' }, + 'cookie-import-browser': { category: 'Interaction', description: 'Copy cookies from chrome, chromium, brave, edge, Windows-only opera, opera-gx, or macOS-only comet or arc. 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-import-browser.ts b/browse/src/cookie-import-browser.ts index 86de21b20..52aaa6396 100644 --- a/browse/src/cookie-import-browser.ts +++ b/browse/src/cookie-import-browser.ts @@ -118,7 +118,6 @@ 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/', windowsNative: true }, { name: 'Chromium', dataDir: 'chromium/', keychainService: 'Chromium Safe Storage', aliases: ['chromium'], linuxDataDir: 'chromium/', linuxApplication: 'chromium', windowsDataDir: 'Chromium/User Data/', windowsNative: true }, { 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/', windowsNative: true }, { name: 'Edge', dataDir: 'Microsoft Edge/', keychainService: 'Microsoft Edge Safe Storage', aliases: ['edge'], linuxDataDir: 'microsoft-edge/', linuxApplication: 'microsoft-edge', windowsDataDir: 'Microsoft/Edge/User Data/', windowsNative: true }, // Windows-only. Local State sits directly under the browser root in %APPDATA% — no User Data segment. diff --git a/browse/test/cookie-credential-deadline.test.ts b/browse/test/cookie-credential-deadline.test.ts index c5c76f091..6e9a3987f 100644 --- a/browse/test/cookie-credential-deadline.test.ts +++ b/browse/test/cookie-credential-deadline.test.ts @@ -14,7 +14,7 @@ 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' + const folder = hostPlatform === 'darwin' ? 'Library/Application Support/Arc/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 }); @@ -72,7 +72,7 @@ describe('credential subprocess whole-operation deadline', () => { 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']); + const result = importCookies('arc', ['fixture.test']); expect(expire).toBeFunction(); expire!(); await expect(result).rejects.toMatchObject({ code: 'keychain_timeout', action: 'retry' }); @@ -94,7 +94,7 @@ describe('credential subprocess whole-operation deadline', () => { 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']); + const result = importCookies('arc', ['fixture.test']); expect(expire).toBeFunction(); expire!(); await expect(result).rejects.toMatchObject({ code: 'keychain_timeout' }); @@ -114,7 +114,7 @@ describe('credential subprocess whole-operation deadline', () => { 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; } + try { await importCookies('arc', ['fixture.test']); } catch (caught) { error = caught; } expect(error).toBeInstanceOf(CookieImportError); expect(error.code).toBe('keychain_error'); expect(error.message).not.toContain(privateValue); @@ -127,8 +127,8 @@ describe('credential subprocess whole-operation deadline', () => { 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 }); + if (!code) expect((await importCookies('arc', ['fixture.test'])).cookies[0].value).toBe(expected); + else await expect(importCookies('arc', ['fixture.test'])).rejects.toMatchObject({ code: expected }); } }); @@ -198,7 +198,7 @@ describe('credential subprocess whole-operation deadline', () => { 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'); + const dir = path.join(nodeFixture, 'Library/Application Support/Arc/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')); @@ -213,7 +213,7 @@ describe('credential subprocess whole-operation deadline', () => { 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']); + const result = await importCookies('arc', ['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' } }); diff --git a/browse/test/cookie-import-native.test.ts b/browse/test/cookie-import-native.test.ts index 400789e9a..8c6b372c7 100644 --- a/browse/test/cookie-import-native.test.ts +++ b/browse/test/cookie-import-native.test.ts @@ -149,7 +149,7 @@ describe('native-cookie production admission', () => { }); test('does not substitute a different browser for an unsupported identity', () => { - expect(adapter({ ...options, browserName: 'Dia' })).toMatchObject({ code: 'not_supported', typed: true }); + expect(adapter({ ...options, browserName: 'Arc' })).toMatchObject({ code: 'not_supported', typed: true }); }); test('rejects mismatched and copied profile roots', () => { diff --git a/browse/test/cookie-import-reliability.test.ts b/browse/test/cookie-import-reliability.test.ts index c6ea6ced3..6a029acf1 100644 --- a/browse/test/cookie-import-reliability.test.ts +++ b/browse/test/cookie-import-reliability.test.ts @@ -108,10 +108,10 @@ afterEach(() => { }); describe('cookie import reliability', () => { - test('discovers Dia with only a numbered macOS profile', () => { - profile('Library/Application Support/Dia/User Data', 'Profile 2'); - expect(findInstalledBrowsers().map(browser => browser.name)).toContain('Dia'); - expect(listProfiles('dia')[0].name).toBe('Profile 2'); + test('discovers Arc with only a numbered macOS profile', () => { + profile('Library/Application Support/Arc/User Data', 'Profile 2'); + expect(findInstalledBrowsers().map(browser => browser.name)).toContain('Arc'); + expect(listProfiles('arc')[0].name).toBe('Profile 2'); }); test('prefers current Local State names and sorts profile numbers naturally', () => { @@ -213,13 +213,13 @@ describe('cookie import reliability', () => { }); test('actual Keychain denial is sanitized, never repeated, and clears its deadline', async () => { - const dir = profile('Library/Application Support/Dia/User Data', 'Default'); + const dir = profile('Library/Application Support/Arc/User Data', 'Default'); const db = new Database(path.join(dir, 'Cookies')); db.run("UPDATE cookies SET value = '', encrypted_value = ?", [Buffer.from('v10synthetic-encrypted-row')]); db.close(); let requests = 0; Bun.spawn = ((command: string[]) => { - expect(command).toEqual(['security', 'find-generic-password', '-s', 'Dia Safe Storage', '-w']); + expect(command).toEqual(['security', 'find-generic-password', '-s', 'Arc Safe Storage', '-w']); requests++; return { stdout: new Blob([]).stream(), stderr: new Blob(['user canceled synthetic-private-detail']).stream(), exited: Promise.resolve(1), kill() {} }; }) as typeof Bun.spawn; @@ -227,7 +227,7 @@ describe('cookie import reliability', () => { const clear = spyOn(globalThis, 'clearTimeout').mockImplementation(() => {}); try { let error: any; - try { await withCookieReadRetry(() => importCookies('dia', ['example.test'])); } catch (caught) { error = caught; } + try { await withCookieReadRetry(() => importCookies('arc', ['example.test'])); } catch (caught) { error = caught; } expect(error).toBeInstanceOf(CookieImportError); expect(error.code).toBe('keychain_denied'); expect(error.message).not.toContain('synthetic-private-detail'); @@ -252,7 +252,7 @@ describe('cookie import reliability', () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }); expect(listSupportedBrowserNames()).toEqual(['Chrome', 'Chromium', 'Brave', 'Edge', 'Opera', 'Opera GX']); Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }); - expect(listSupportedBrowserNames()).toEqual(['Comet', 'Chrome', 'Chromium', 'Arc', 'Dia', 'Brave', 'Edge']); + expect(listSupportedBrowserNames()).toEqual(['Comet', 'Chrome', 'Chromium', 'Arc', 'Brave', 'Edge']); Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }); expect(listSupportedBrowserNames()).toEqual(['Chrome', 'Chromium', 'Brave', 'Edge']); }); @@ -618,7 +618,7 @@ describe('cookie import reliability', () => { test('windowsNative matches exactly the browsers the native extractor maps', () => { const env = { LOCALAPPDATA: 'C:\\Users\\fixture\\AppData\\Local' }; - for (const browser of ['Comet', 'Chrome', 'Chromium', 'Arc', 'Dia', 'Brave', 'Edge', 'Opera', 'Opera GX']) { + for (const browser of ['Comet', 'Chrome', 'Chromium', 'Arc', 'Brave', 'Edge', 'Opera', 'Opera GX']) { let mapped = true; try { nativeBrowserPaths(browser, env); } catch { mapped = false; } expect(resolveBrowserInfo(browser).windowsNative === true).toBe(mapped); diff --git a/browse/test/cookie-picker-ui.test.ts b/browse/test/cookie-picker-ui.test.ts index 3387a723e..1707fb128 100644 --- a/browse/test/cookie-picker-ui.test.ts +++ b/browse/test/cookie-picker-ui.test.ts @@ -51,10 +51,10 @@ describe('rendered cookie picker', () => { requests = []; imported = []; handler = () => undefined; - browsers = [{ name: 'Chrome', aliases: ['chrome', 'google-chrome', 'google-chrome-stable'] }, { name: 'Dia', aliases: ['dia'] }]; + browsers = [{ name: 'Chrome', aliases: ['chrome', 'google-chrome', 'google-chrome-stable'] }, { name: 'Arc', aliases: ['arc'] }]; profiles = { Chrome: { profiles: [{ name: 'Default', displayName: 'Personal' }, { name: 'Profile 1', displayName: 'Personal' }], recommendedProfile: 'Default' }, - Dia: { profiles: [{ name: 'Profile 1', displayName: 'Work' }, { name: 'Profile 2', displayName: 'Work' }], recommendedProfile: 'Profile 2' }, + Arc: { profiles: [{ name: 'Profile 1', displayName: 'Work' }, { name: 'Profile 2', displayName: 'Work' }], recommendedProfile: 'Profile 2' }, }; errors = []; context = await browser.newContext(); @@ -200,8 +200,8 @@ describe('rendered cookie picker', () => { await openPicker(); await page.getByRole('button', { name: 'Import .chrome-profile-1.test', exact: true }).waitFor(); expect(await page.locator('.profile-pill.active').getAttribute('data-profile')).toBe('Profile 1'); - await page.getByRole('button', { name: 'Dia', exact: true }).click(); - await page.getByRole('button', { name: 'Import .dia-profile-2.test', exact: true }).waitFor(); + await page.getByRole('button', { name: 'Arc', exact: true }).click(); + await page.getByRole('button', { name: 'Import .arc-profile-2.test', exact: true }).waitFor(); expect(await page.locator('.profile-pill.active').getAttribute('data-profile')).toBe('Profile 2'); }); @@ -211,12 +211,12 @@ describe('rendered cookie picker', () => { await page.getByRole('button', { name: 'Import .chrome-profile-1.test', exact: true }).waitFor(); expect(await page.locator('.pill.active').textContent()).toBe('Chrome'); expect(await page.locator('.profile-pill.active').getAttribute('data-profile')).toBe('Profile 1'); - await page.getByRole('button', { name: 'Dia', exact: true }).click(); - await page.getByRole('button', { name: 'Import .dia-profile-2.test', exact: true }).waitFor(); + await page.getByRole('button', { name: 'Arc', exact: true }).click(); + await page.getByRole('button', { name: 'Import .arc-profile-2.test', exact: true }).waitFor(); expect(await page.locator('.profile-pill.active').getAttribute('data-profile')).toBe('Profile 2'); await page.getByRole('button', { name: 'Chrome', exact: true }).click(); await page.getByRole('button', { name: 'Import .chrome-profile-1.test', exact: true }).waitFor(); - expect(requests.filter(request => request.path === '/profiles').map(request => request.browser)).toEqual(['Chrome', 'Dia', 'Chrome']); + expect(requests.filter(request => request.path === '/profiles').map(request => request.browser)).toEqual(['Chrome', 'Arc', 'Chrome']); }); test('does not interpret a browser alias substring as an installed browser', async () => { @@ -241,34 +241,34 @@ describe('rendered cookie picker', () => { for (const earlierFirst of [true, false]) { test(`ignores stale profile responses when the earlier request returns ${earlierFirst ? 'first' : 'last'}`, async () => { const chrome = Promise.withResolvers(); - const dia = Promise.withResolvers(); + const arc = Promise.withResolvers(); const chromeSeen = Promise.withResolvers(); - const diaSeen = Promise.withResolvers(); + const arcSeen = Promise.withResolvers(); handler = request => { if (request.path !== '/profiles') return; if (request.browser === 'Chrome') { chromeSeen.resolve(); return chrome.promise; } - diaSeen.resolve(); return dia.promise; + arcSeen.resolve(); return arc.promise; }; await openPicker(); await chromeSeen.promise; - await page.getByRole('button', { name: 'Dia', exact: true }).click(); - await diaSeen.promise; + await page.getByRole('button', { name: 'Arc', exact: true }).click(); + await arcSeen.promise; if (earlierFirst) { const response = page.waitForResponse(response => response.url().includes('/profiles?browser=Chrome')); chrome.resolve(Response.json(profiles.Chrome)); await response; expect(await page.locator('.profile-pill.active').count()).toBe(0); } - dia.resolve(Response.json(profiles.Dia)); - await page.getByRole('button', { name: 'Import .dia-profile-2.test', exact: true }).waitFor(); + arc.resolve(Response.json(profiles.Arc)); + await page.getByRole('button', { name: 'Import .arc-profile-2.test', exact: true }).waitFor(); if (!earlierFirst) { const response = page.waitForResponse(response => response.url().includes('/profiles?browser=Chrome')); chrome.resolve(Response.json(profiles.Chrome)); await response; } - expect(await page.locator('.pill.active').textContent()).toBe('Dia'); + expect(await page.locator('.pill.active').textContent()).toBe('Arc'); expect(await page.locator('.profile-pill.active').getAttribute('data-profile')).toBe('Profile 2'); - expect(requests.filter(request => request.path === '/domains').map(request => request.browser)).toEqual(['Dia']); + expect(requests.filter(request => request.path === '/domains').map(request => request.browser)).toEqual(['Arc']); }); } @@ -347,16 +347,16 @@ describe('rendered cookie picker', () => { const add = page.getByRole('button', { name: 'Import .chrome-default.test', exact: true }); await add.evaluate((element: HTMLButtonElement) => { element.click(); element.click(); }); await seen.promise; - expect(await page.getByRole('button', { name: 'Dia', exact: true }).isDisabled()).toBe(true); + expect(await page.getByRole('button', { name: 'Arc', exact: true }).isDisabled()).toBe(true); expect(await page.getByRole('button', { name: 'Personal (Profile 1)', exact: true }).isDisabled()).toBe(true); for (const selector of ['#btn-import-all', '#clear-storage', '#verify-auth', '#search', '.btn-trash']) expect(await page.locator(selector).isDisabled()).toBe(true); await page.locator('#btn-import-all').evaluate((element: HTMLButtonElement) => element.click()); - await page.getByRole('button', { name: 'Dia', exact: true }).evaluate((element: HTMLButtonElement) => element.click()); + await page.getByRole('button', { name: 'Arc', exact: true }).evaluate((element: HTMLButtonElement) => element.click()); expect(requests.filter(request => request.path === '/import')).toHaveLength(1); result.resolve(Response.json({ imported: 1, failed: 0, domainCounts: { '.chrome-default.test': 1 }, outcome: 'imported', reset: 'not_requested', verification: { verified: false, reason: 'not_requested' } })); await page.getByRole('status').filter({ hasText: 'Cookies imported.' }).waitFor(); expect(await page.locator('.pill.active').textContent()).toBe('Chrome'); - expect(await page.getByRole('button', { name: 'Dia', exact: true }).isEnabled()).toBe(true); + expect(await page.getByRole('button', { name: 'Arc', exact: true }).isEnabled()).toBe(true); expect(requests.filter(request => request.path === '/import')).toHaveLength(1); }); diff --git a/browse/test/dia-gui-readiness.test.ts b/browse/test/dia-gui-readiness.test.ts deleted file mode 100644 index c34ff3bf6..000000000 --- a/browse/test/dia-gui-readiness.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -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 deleted file mode 100644 index cab823ae6..000000000 --- a/browse/test/dia-launch-comparison.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -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.2' : '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.2', 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 deleted file mode 100644 index 48e483158..000000000 --- a/browse/test/dia-macos-qualification.test.ts +++ /dev/null @@ -1,1695 +0,0 @@ -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 unknown 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 unknown 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/docs/TESTING_INTERNALS.md b/docs/TESTING_INTERNALS.md index 549999ec0..dad6ded38 100644 --- a/docs/TESTING_INTERNALS.md +++ b/docs/TESTING_INTERNALS.md @@ -967,8 +967,8 @@ with `GSTACK_FREE_RETRY_FLAKY=0`. A stock Ubuntu 24.04 VM differs from a GitHub-hosted runner in three ways that the scripts correct: the login umask is `002` (group-writable directories fail the CSO private-state checks), AppArmor blocks the unprivileged user -namespaces Chromium's sandbox needs, and `clang` and `python3-venv` are absent -(required by the Dia readiness and Python runner tests). +namespaces Chromium's sandbox needs, and `python3-venv` is absent (required by +the Python runner tests). VMs are named `ubirun---`, plus a `-t` TTL suffix (below); `` is `UBI_OWNER` (set it to the thread id on a Capy machine; @@ -1523,10 +1523,10 @@ on win32, drives a POSIX signal path) leaves it through a the file moved. Native qualification campaigns (Windows cookie extraction, Windows launch -diagnostics, Dia on macOS) run only on dispatch of `native-qualification.yml`: +diagnostics) run only on dispatch of `native-qualification.yml`: ```bash -gh workflow run native-qualification.yml --ref -f mode= +gh workflow run native-qualification.yml --ref -f mode= ``` ### Real-setup install fixture diff --git a/docs/skills.md b/docs/skills.md index 3a72e27a9..a1fb382e0 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -31,7 +31,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** | Copy selected cookies from Chrome, Chromium, Brave, Edge, Windows-only Opera and Opera GX, 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. | +| [`/setup-browser-cookies`](#setup-browser-cookies) | **Session Manager** | Copy selected cookies from Chrome, Chromium, Brave, Edge, Windows-only Opera and Opera GX, or macOS-only Comet and Arc 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, and error messages scored for the agents that read them ([agent-ready errors](agent-ready-errors.md)). 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, and triggers CLI errors the way an agent meets them (no terminal, contained, redacted) through `gstack-error-capture`. | @@ -1179,7 +1179,7 @@ This is my **session manager mode** — for the fallback browser. With Aside ope 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. -The picker detects Chrome, Chromium, Brave, Edge, Windows-only Opera and Opera GX, 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. +The picker detects Chrome, Chromium, Brave, Edge, Windows-only Opera and Opera GX, and macOS-only Comet and Arc. 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 diff --git a/gstack/llms.txt b/gstack/llms.txt index 16e0fc860..a28e648fb 100644 --- a/gstack/llms.txt +++ b/gstack/llms.txt @@ -103,7 +103,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] [--clear-storage] [--verify-auth]`: Copy cookies from chrome, chromium, brave, edge, Windows-only opera, opera-gx, or macOS-only comet, arc, dia. +- `cookie-import-browser [browser] [--domain d] [--profile p] [--all] [--clear-storage] [--verify-auth]`: Copy cookies from chrome, chromium, brave, edge, Windows-only opera, opera-gx, or macOS-only comet or arc. - `dialog-accept [text]`: Auto-accept next alert/confirm/prompt. - `dialog-dismiss`: Auto-dismiss next dialog - `fill `: Fill input diff --git a/scripts/free-test-durations.json b/scripts/free-test-durations.json index 06b704c3e..de9bde403 100644 --- a/scripts/free-test-durations.json +++ b/scripts/free-test-durations.json @@ -57,9 +57,6 @@ "browse/test/daemon-log-hygiene.test.ts": 32, "browse/test/daemon-mismatch-refuse.test.ts": 105, "browse/test/data-platform.test.ts": 21, - "browse/test/dia-gui-readiness.test.ts": 895, - "browse/test/dia-launch-comparison.test.ts": 2807, - "browse/test/dia-macos-qualification.test.ts": 2829, "browse/test/domain-skills-e2e.test.ts": 520, "browse/test/domain-skills-storage.test.ts": 232, "browse/test/dotenv-isolation.test.ts": 405, diff --git a/scripts/typecheck-test-baseline.json b/scripts/typecheck-test-baseline.json index ff560c8a0..fafa2959a 100644 --- a/scripts/typecheck-test-baseline.json +++ b/scripts/typecheck-test-baseline.json @@ -34,10 +34,6 @@ "browse/test/cookie-import-reliability.test.ts\tTS2769\tNo overload matches this call. The last overload gave the following error. Argument of type 'number' is not assignable to parameter of type 'Function'.": 1, "browse/test/cookie-import-transport.test.ts\tTS2741\tProperty 'preconnect' is missing in type '() => Promise' but required in type 'typeof fetch'.": 2, "browse/test/cookie-import-transport.test.ts\tTS2741\tProperty 'preconnect' is missing in type '() => Promise' but required in type 'typeof fetch'.": 1, - "browse/test/dia-gui-readiness.test.ts\tTS2352\tConversion of type '() => { status: number; stderr: string; stdout: string; }' to type '{ (command: string): SpawnSyncReturns; (command: string, options: SpawnSyncOptionsWithStringEncoding): SpawnSyncReturns<...>; (command: string, options: SpawnSyncOptionsWithBufferEncoding): SpawnSyncReturns<...>; (command: string, options?: SpawnSyncOptions | undefined): SpawnSyncReturns<...>; (comm...' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first. Type '{ status: number; stderr: string; stdout: string; }' is missing the following properties from type 'SpawnSyncReturns': pid, output, signal": 1, - "browse/test/dia-launch-comparison.test.ts\tTS7016\tCould not find a declaration file for module '../../.github/scripts/dia-launch-driver.mjs'. '.github/scripts/dia-launch-driver.mjs' implicitly has an 'any' type.": 1, - "browse/test/dia-macos-qualification.test.ts\tTS2352\tConversion of type '() => { status: number; stderr: string; stdout: string; }' to type '{ (command: string): SpawnSyncReturns; (command: string, options: SpawnSyncOptionsWithStringEncoding): SpawnSyncReturns<...>; (command: string, options: SpawnSyncOptionsWithBufferEncoding): SpawnSyncReturns<...>; (command: string, options?: SpawnSyncOptions | undefined): SpawnSyncReturns<...>; (comm...' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first. Type '{ status: number; stderr: string; stdout: string; }' is missing the following properties from type 'SpawnSyncReturns': pid, output, signal": 2, - "browse/test/dia-macos-qualification.test.ts\tTS2769\tNo overload matches this call. The last overload gave the following error. Argument of type 'string' is not assignable to parameter of type '\"browser_profile_unavailable\" | \"code_signing_error\" | \"debugging_pipe_unavailable\" | \"default_profile_policy\" | \"dynamic_library_error\" | \"graphics_or_bootstrap_error\" | \"keychain_access_failed\" | \"keychain_interaction_disallowed\" | \"keychain_interaction_required\"'.": 1, "browse/test/domain-skills-e2e.test.ts\tTS2339\tProperty 'cleanup' does not exist on type 'BrowserManager'.": 1, "browse/test/extension-token.test.ts\tTS2353\tObject literal may only specify known properties, and 'idleTimeoutMs' does not exist in type 'ServerConfig'.": 1, "browse/test/handoff.test.ts\tTS2339\tProperty 'pid' does not exist on type 'never'.": 1, diff --git a/scripts/ubicloud/setup-free-suite.sh b/scripts/ubicloud/setup-free-suite.sh index aed51c2c9..97dfe80d6 100755 --- a/scripts/ubicloud/setup-free-suite.sh +++ b/scripts/ubicloud/setup-free-suite.sh @@ -11,7 +11,7 @@ NODE_SHA256=1805bcd35e6815559bd2eb97db438d3fdb0eb3a5e6a977b7c1badca76337e9ce export DEBIAN_FRONTEND=noninteractive sudo -E apt-get update -qq sudo -E apt-get install -y -qq --no-install-recommends \ - git curl unzip zip xz-utils ca-certificates build-essential clang python3-venv jq \ + git curl unzip zip xz-utils ca-certificates build-essential python3-venv jq \ xvfb x11-utils poppler-utils fonts-noto-color-emoji zsh >/dev/null # Ubuntu 24.04 blocks unprivileged user namespaces, which Chromium's sandbox diff --git a/setup-browser-cookies/SKILL.md b/setup-browser-cookies/SKILL.md index b9cb5edd9..c193d6c3f 100644 --- a/setup-browser-cookies/SKILL.md +++ b/setup-browser-cookies/SKILL.md @@ -201,6 +201,6 @@ Never request or publish cookie values, passwords, identity or profile text, ses ## Platform boundaries -Dia is macOS-only. Keychain approval is the user's choice. Database retries are bounded; permission denial needs user action, not repeated prompts. +Keychain approval is the user's choice. Database retries are bounded; permission denial needs user action, not repeated prompts. 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 82313c668..b8c81bfa1 100644 --- a/setup-browser-cookies/SKILL.md.tmpl +++ b/setup-browser-cookies/SKILL.md.tmpl @@ -77,6 +77,6 @@ Never request or publish cookie values, passwords, identity or profile text, ses ## Platform boundaries -Dia is macOS-only. Keychain approval is the user's choice. Database retries are bounded; permission denial needs user action, not repeated prompts. +Keychain approval is the user's choice. Database retries are bounded; permission denial needs user action, not repeated prompts. 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/helpers/manual-judge-review-fixture.ts b/test/helpers/manual-judge-review-fixture.ts index 9d283e02f..b5f610f52 100644 --- a/test/helpers/manual-judge-review-fixture.ts +++ b/test/helpers/manual-judge-review-fixture.ts @@ -10,7 +10,7 @@ import { COOKIE_MANUAL_REVIEW_FILE } from './cookie-workflow-manual-review'; // exact historical prompt bytes, including the section's line range. const LATER_BROWSER_BLOCK = /\*\*Windows: Opera and Opera GX\.\*\*[\s\S]*?appear in CLI output\.\n\n/; const LATER_BROWSER_EDITS: Array<[string, string]> = [ - ['The picker recognizes Chrome, Chromium, Brave, Edge, Windows-only Opera and Opera GX, and macOS-only Comet, Arc, and Dia.', 'The picker recognizes Chrome, Chromium, Brave, Edge, and macOS-only Comet, Arc, and Dia.'], + ['The picker recognizes Chrome, Chromium, Brave, Edge, Windows-only Opera and Opera GX, and macOS-only Comet and Arc.', 'The picker recognizes Chrome, Chromium, Brave, Edge, and macOS-only Comet, Arc, and Dia.'], [' Opera and Opera GX are Windows-only and read from `%APPDATA%\\Opera Software\\Opera Stable` or `Opera GX Stable`, in `Default` or `Profile N` directories; legacy root-level layouts, Opera side profiles and portable or relocated installs are not detected. Opera has no native extraction, so its App-Bound cookies (if any) need manual sign-in.', ''], ]; @@ -35,6 +35,8 @@ const LATER_WAVE_EDITS: Array<[string, string]> = [ "The skill-start output above already ran artifacts sync. Act on its lines:\nGBrain hint text (if present) tells you when to prefer `gbrain` over Grep;\n`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,\n`remote-mode`, or a restore hint naming `gstack-brain-restore`).\n\nThe one-time privacy stop-gate (artifacts-sync consent) arrives as a\n`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending\n\u2014 fire it via AskUserQuestion exactly as the block instructs."], // Oct 8 wave: the tier-1 voice directive gained the reply-language rule (two lines longer). ["Reply in the language of the user's latest message unless asked otherwise. Code, commands, paths, identifiers and quoted output stay verbatim.\n\n", ''], + // Dia removal (GSTA-143) dropped its platform sentence; same line count. + ["\nKeychain approval is the user's choice.", "\nDia is macOS-only. Keychain approval is the user's choice."], // #3129 dropped the Claude body's "When to invoke" section above the preamble (seven lines shorter). ['\n\n## Preamble (run first)\n', '\n\n\n## When to invoke this skill\n\nOpens an interactive picker UI where you select which cookie domains to import.\nUse before QA testing authenticated pages. Use when asked to "import cookies",\n"login to the site", or "authenticate the browser".\n\n## Preamble (run first)\n'], ]; diff --git a/test/windows-native-workflows.test.ts b/test/windows-native-workflows.test.ts index 04fd70868..ff9e466ba 100644 --- a/test/windows-native-workflows.test.ts +++ b/test/windows-native-workflows.test.ts @@ -113,11 +113,10 @@ test('native qualification is dispatch-only with one exclusive mode per run', () expect(Object.keys(native.on)).toEqual(['workflow_dispatch']); const mode = native.on.workflow_dispatch.inputs.mode; expect(mode).toMatchObject({ type: 'choice', required: true }); - expect(mode.options).toEqual(['cookie-native', 'windows-native-diagnostics', 'dia-native', 'dia-launch-comparison', 'dia-gui-readiness']); + expect(mode.options).toEqual(['cookie-native', 'windows-native-diagnostics']); expect(native.permissions).toEqual({ contents: 'read' }); expect(native.jobs['cookie-native-qualification'].if).toBe("inputs.mode == 'cookie-native'"); expect(native.jobs['windows-native-diagnostics'].if).toBe("inputs.mode == 'windows-native-diagnostics'"); - expect(native.jobs['dia-native-qualification'].if).toBe("startsWith(inputs.mode, 'dia-')"); for (const job of ['cookie-native-qualification', 'windows-native-diagnostics']) { const setup = native.jobs[job].steps.find((step: any) => step.uses?.startsWith('actions/setup-node@')); expect(setup.with['node-version']).toBe('24.18.0'); @@ -134,35 +133,3 @@ test('focused Windows diagnostics include the repaired lock and close cases with '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 job = native.jobs['dia-native-qualification']; - expect(job['runs-on']).toBe('macos-15'); - expect(job.strategy['fail-fast']).toBe(false); - expect(job.strategy.matrix.runtime).toBe(`\${{ fromJSON(inputs.mode == 'dia-launch-comparison' && '["bun","node"]' || '["bun"]') }}`); - const node = job.steps.find((step: any) => step.uses?.startsWith('actions/setup-node@')); - expect(node.if).toBe("inputs.mode == 'dia-launch-comparison'"); - 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.if).toBe("inputs.mode == 'dia-launch-comparison'"); - 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)"); -}); - -test('Dia GUI readiness runs alone, without dependency or browser installation', () => { - const job = native.jobs['dia-native-qualification']; - const probe = job.steps.find((step: any) => step.name === 'Inspect GUI readiness without browser or Keychain access'); - expect(probe.if).toBe("inputs.mode == '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' }); - for (const name of ['Install pinned dependencies', 'Install the synthetic destination browser']) { - expect(job.steps.find((step: any) => step.name === name).if).toBe("inputs.mode != 'dia-gui-readiness'"); - } - expect(job.steps.find((step: any) => step.name === 'Qualify native Dia discovery, decryption, and import').if).toBe("inputs.mode == 'dia-native'"); - const upload = job.steps.find((step: any) => step.uses?.startsWith('actions/upload-artifact@')); - expect(upload.with.name).toContain("inputs.mode == 'dia-gui-readiness' && 'dia-gui-readiness'"); - expect(upload.with.path).toBe('${{ runner.temp }}/dia-native-qualification.json'); -});