mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-28 15:41:57 +02:00
v1.90.0.0 feat: make browser cookie imports explicit and safe (#2964)
* fix(browse): prepare reliable cookie import wave for validation * ci: sequence quality and behavior for validation branch * fix(browse): isolate Windows qualification and preserve native diagnostics * test(browse): cover cookie workflow quality and isolate Windows user paths * test(browse): trace native member startup and initialize fresh folders * fix(browse): keep Windows member stdin alive through EOF * fix(browse): latch native timeouts and compare contained Edge startup * test(browse): verify native version metadata and actual Windows argv * test(browse): qualify Dia import on isolated macOS CI * fix(browse): require picker origin for session mutations * fix(browse): bound credential reads through stream completion * test(browse): inspect owned Windows process arguments natively * test(evals): preserve passing coverage during cookie repair reruns * test(browse): isolate Dia qualification in a fresh macOS account * test(browse): pass bounded integer timeouts to native Mac probes * test(browse): distinguish Windows profile initialization from containment * test(browse): await descendant pipe readiness before parent exit * test(browse): initialize and restore isolated macOS Keychain state * test(browse): initialize Windows fixture folders before qualification * test(ci): pin the same Node runtime across Windows checks * test(browse): distinguish native macOS browser preflight stages * test(browse): isolate Windows descendant console lifetime * test(browse): preserve native receipts and identify fixture lock holders * test(browse): prepare dependency resolution before native Mac worker startup * test(ci): include lock and close checks in native diagnostics * test(browse): preserve native owner probe stages and subprocess deadlines * fix(browse): classify Chromium profile-in-use exit precisely * test(browse): retain Mac qualification evidence through cleanup failures * test(browse): bound Mac fixture paths and retire its owned user domain * test(browse): accept vanished fixture entries without weakening cleanup * test(browse): identify probe-created macOS user domains safely * test(browse): observe Mac user domains without targeting them first * test(browse): use passive fresh-user ownership throughout Mac qualification * test(browse): distinguish profile and registered-home Keychain lookups * test(browse): qualify Dia under one registered account home * test(browse): identify Dia startup and owned process-group failures * test(browse): classify bounded Dia startup diagnostics without leaking output * fix(test): preserve native Mac sandboxing and reap owned browser children * fix(browse): preserve Chromium sandboxing for native profile imports * test(browse): inspect signed Mach-O architecture without launching Xcode tools * test(browse): sample pending Dia startup and reap on all cleanup paths * test(browse): compare protected Dia launches in fresh Bun and Node accounts * test(browse): inspect isolated Mac GUI readiness without browser access * v1.90.0.0 fix: bind cookie picker actions to their document * test: validate cookie guards and fit nested launch fixtures * ci: configure the bundled Chromium sandbox helper * fix(browse): classify Playwright authentication timeouts * test: retain bounded Windows lifecycle diagnostics * test(cso): reuse bounded NTFS precision candidates * test(review): handle explicit preservation choices safely * test(browse): remove owned fixture directories with explicit primitives * test(review): distinguish descriptive reuse from edit commitments * test: admit only the approved unscored cookie workflow refusal * test: keep the Office Hours judge mock export-complete * fix: keep dependency-free CI planners independent of the model SDK * test: observe the exact holder after a native fixture unlink failure * fix: start seeded PTY observations at owned readiness * test: acquire identity-bound Windows deletion admission before profile resets * test: preserve qualified Git index bits without authorizing mutations
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"test_name": "setup-browser-cookies/SKILL.md workflow",
|
||||
"prompt_sha256": "7f7f76f49912818d51f25c86d686aab6ad2c95ccdea60ac912c1e4c8b6936e5f",
|
||||
"prompt_bytes": 10159,
|
||||
"model": "claude-fable-5-1",
|
||||
"max_tokens": 8192,
|
||||
"thresholds": { "clarity": 4, "completeness": 3, "actionability": 4 },
|
||||
"approved_by": "garrytan",
|
||||
"approved_at": "2026-09-24",
|
||||
"approval_url": "https://github.com/garrytan/gstack/pull/2964#issuecomment-5822514476",
|
||||
"reason": "Maintainer-approved manual review of this exact cookie workflow after empty provider refusals. No automated quality score or pass credit; other errors and changed inputs remain blocking."
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
# Cookie workflow manual-review exception
|
||||
|
||||
`cookie-workflow-manual-review.json` records the maintainer's approval for one
|
||||
exact cookie-workflow judge request. It is not generated content. Do not update
|
||||
its hash, model, budget, thresholds, or provenance just to make a changed test
|
||||
pass; a changed request needs a new explicit review and approval.
|
||||
|
||||
The ordinary judge request still runs. Only an explicit provider refusal with
|
||||
complete request/response identifiers, zero output tokens, and no text blocks
|
||||
can use this approval. Low scores, malformed output or evidence, other errors,
|
||||
timeouts, and late or superseded attempts remain failures. Every other case
|
||||
remains subject to its existing gate.
|
||||
|
||||
Manual acceptance is first-attempt-only: a retry refusal cannot erase an earlier
|
||||
scored failure, timeout, or other error. Normal configured retries are unchanged.
|
||||
|
||||
The collector preserves `passed: false`, `execution: executed`, the refusal,
|
||||
and the manual approval, without a score or score-cache receipt. Reports count
|
||||
manual acceptance separately from automated passes and failures; “executed”
|
||||
counts the actual provider request, not a completed scored evaluation. Historical
|
||||
records retain that distinction. CI also checks manual claims against the
|
||||
current source and committed approval before accepting them.
|
||||
@@ -0,0 +1,138 @@
|
||||
#define _DARWIN_C_SOURCE
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <limits.h>
|
||||
#include <pwd.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
#ifdef __APPLE__
|
||||
#include <ApplicationServices/ApplicationServices.h>
|
||||
#include <Security/AuthSession.h>
|
||||
#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
|
||||
@@ -0,0 +1,231 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { closeSync, constants, createReadStream, fstatSync, lstatSync, openSync, readdirSync, readSync, realpathSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { release } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { assertOwnedDiaProfile, browserOperationTimedOut, browserPreflightError, browserRootFacts, browserStartupFacts, browserStderrFacts,
|
||||
nativeDiaLaunchOptions, observeBrowserLaunches, ownsFreshAccount, parseDirectoryRecord,
|
||||
readFreshAccountConfiguration, stopOwnedBrowserGroup, validateQualificationHost } from './qualify-dia-macos.ts';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const sha256 = async file => {
|
||||
const hash = createHash('sha256');
|
||||
for await (const chunk of createReadStream(file)) hash.update(chunk);
|
||||
return hash.digest('hex');
|
||||
};
|
||||
|
||||
export function normalizedLaunchHashes(args, env, ownedPaths) {
|
||||
const paths = Object.entries(ownedPaths).sort(([, left], [, right]) => right.length - left.length);
|
||||
const normalize = value => {
|
||||
const equals = value.startsWith('--') ? value.indexOf('=') : -1;
|
||||
const prefix = equals >= 0 ? value.slice(0, equals + 1) : '';
|
||||
const candidate = equals >= 0 ? value.slice(equals + 1) : value;
|
||||
for (const [role, owned] of paths) {
|
||||
if (candidate === owned || candidate.startsWith(owned + path.sep)) return prefix + '<' + role + '>' + candidate.slice(owned.length);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const hash = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
||||
return { argvSha256: hash(args.map(normalize)), environmentSha256: hash(Object.entries(env).sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, value]) => [key, normalize(value)])) };
|
||||
}
|
||||
|
||||
export function compareDiaLaunchReceipts(left, right) {
|
||||
const invalid = { comparable: false, qualificationCredit: false, reason: 'incomplete_or_incompatible_arms' };
|
||||
if (!left?.launcher?.accountGuid || left.launcher.accountGuid === right?.launcher?.accountGuid) return invalid;
|
||||
const fingerprints = [];
|
||||
for (const [receipt, runtime] of [[left, 'bun'], [right, 'node']]) {
|
||||
const qualification = receipt?.qualification;
|
||||
const control = receipt?.backgroundPreflight?.comparisonControl;
|
||||
const source = qualification?.launchComparison?.source;
|
||||
const config = receipt?.launchComparison;
|
||||
if (config?.mode !== 'launch-only' || config.runtime !== runtime || config.qualificationCredit !== false
|
||||
|| receipt.backgroundPreflight?.status !== 'passed' || !control?.ready || qualification?.launchComparison?.qualificationCredit !== false
|
||||
|| qualification.keychainStage !== 'completed' || qualification.isolation?.registeredIdentity !== true
|
||||
|| qualification.isolation?.sharedRegisteredHome !== true || qualification.artifact?.signatureVerified !== true
|
||||
|| qualification.artifact?.gatekeeperNotarized !== true || qualification.artifact?.macosCompatibility?.compatible !== true
|
||||
|| qualification.platform?.os !== 'darwin' || qualification.platform?.architecture !== 'arm64'
|
||||
|| qualification.platform?.bun !== '1.4.0' || qualification.platform?.playwright !== '1.62.1'
|
||||
|| !/^\d+(?:\.\d+){1,2}$/.test(qualification.artifact.macosCompatibility.hostVersion)
|
||||
|| qualification.artifact?.architectures?.includes('arm64') !== true
|
||||
|| ['serviceStopped', 'userDomainStopped', 'userProcessesStopped', 'accountRemoved', 'groupRemoved', 'stagingRemoved'].some(key => receipt.launcherCleanup?.[key] !== true)
|
||||
|| ['ownedBrowsersStopped', 'sourceProfileRemoved', 'keychainRestored', 'mountDetached', 'fixtureRemoved'].some(key => qualification.cleanup?.[key] !== true)
|
||||
|| ['pass', 'fail', 'skip'].some(key => receipt.counts?.[key] !== 0 || qualification.counts?.[key] !== 0)) return invalid;
|
||||
for (const [result, purpose] of [[control, 'control'], [source, 'source']]) {
|
||||
const policy = result?.launchAttempts?.[0];
|
||||
if (result?.protocol !== 1 || result.purpose !== purpose || result.samplingEnabled !== false || result.rootCount !== 1
|
||||
|| result.supervisor?.closed !== true || result.supervisor?.exitCode !== 0 || result.cleanup?.confirmed !== true
|
||||
|| result.cleanup?.childClosed !== true || result.cleanup?.groupAbsent !== true || result.cleanup?.launchSettled !== true || result.launchAttempts?.length !== 1
|
||||
|| result.cleanup?.groups?.length !== 1 || result.cleanup.groups[0].absenceConfirmed !== true || result.cleanup.groups[0].childCloseObserved !== true
|
||||
|| typeof result.ready !== 'boolean' || typeof result.launchReturned !== 'boolean'
|
||||
|| policy?.sandboxRequired !== true || policy?.sandboxDisablingFlag !== false || policy?.pipeFlag !== true || policy?.tcpDebuggingFlag !== false
|
||||
|| policy?.mockKeychainFlag !== false || policy?.passwordStoreFlag !== false || policy?.firstRunSuppressed !== false
|
||||
|| policy?.headlessFlag !== true || policy?.expectedProfile !== true || policy?.detached !== true || policy?.shellDisabled !== true
|
||||
|| policy?.stdioCount !== 5 || policy?.extraPipeDescriptors !== true || policy?.profileArgumentCount !== 1
|
||||
|| result.driver?.runtime !== runtime || result.driver?.version !== (runtime === 'bun' ? '1.4.0' : '24.18.0')
|
||||
|| result.driver?.os !== 'darwin' || result.driver?.architecture !== 'arm64' || result.driver?.playwright !== '1.62.1'
|
||||
|| result.driver?.release !== qualification.platform.release
|
||||
|| result.driver?.executableSha256 !== config.executableSha256 || result.driver?.driverSha256 !== config.driverSha256
|
||||
|| result.driver?.helpersSha256 !== config.helpersSha256 || (result.ready && (!result.protocolResponded || !result.startupPages?.allowed || !result.postProbePages?.allowed))
|
||||
|| ![result.argvSha256, result.environmentSha256, config.executableSha256, config.driverSha256, config.helpersSha256].every(value => /^[a-f0-9]{64}$/.test(value))) return invalid;
|
||||
}
|
||||
const hashes = [receipt.launcher?.sourceRevision, receipt.launcher?.archiveSha256, receipt.launcher?.destinationSha256,
|
||||
qualification.artifact.sha256, qualification.artifact.executableSha256];
|
||||
if (!/^[a-f0-9]{40}$/.test(hashes[0]) || !hashes.slice(1).every(value => /^[a-f0-9]{64}$/.test(value))) return invalid;
|
||||
fingerprints.push(JSON.stringify({ hashes, platform: qualification.platform, compatibility: qualification.artifact.macosCompatibility,
|
||||
version: qualification.artifact.version, bundle: qualification.artifact.bundleId, team: qualification.artifact.team,
|
||||
driver: config.driverSha256, helpers: config.helpersSha256, controlArgs: control.argvSha256, controlEnv: control.environmentSha256,
|
||||
sourceArgs: source.argvSha256, sourceEnv: source.environmentSha256 }));
|
||||
}
|
||||
if (fingerprints[0] !== fingerprints[1]) return { ...invalid, reason: 'comparison_inputs_differ' };
|
||||
const bun = left.qualification.launchComparison.source.ready === true;
|
||||
const node = right.qualification.launchComparison.source.ready === true;
|
||||
return { comparable: true, qualificationCredit: false, outcome: bun ? node ? 'both_ready' : 'bun_only_ready' : node ? 'node_only_ready' : 'neither_ready' };
|
||||
}
|
||||
|
||||
export async function runProtectedLaunch(executable, profile, env, purpose, ownedPaths) {
|
||||
const result = { protocol: 1, purpose, stage: 'runtime_import', launchReturned: false, protocolResponded: false, ready: false,
|
||||
timedOut: false, error: null, samplingEnabled: false, cleanup: { childClosed: false, groupAbsent: false, confirmed: false } };
|
||||
const { chromium } = await import('playwright');
|
||||
const cp = require('node:child_process');
|
||||
const original = cp.spawn;
|
||||
cp.spawn = function(command, args, options) {
|
||||
if (command === executable) Object.assign(result, normalizedLaunchHashes(args, options.env, ownedPaths));
|
||||
return original.call(this, command, args, options);
|
||||
};
|
||||
const observer = observeBrowserLaunches(new Map([[executable, profile]]));
|
||||
let context;
|
||||
let timer;
|
||||
let launchSettled = false;
|
||||
try {
|
||||
result.stage = 'launch';
|
||||
const launch = chromium.launchPersistentContext(profile, nativeDiaLaunchOptions(executable, env));
|
||||
void launch.then(() => { launchSettled = true; }, () => { launchSettled = true; });
|
||||
context = await Promise.race([launch,
|
||||
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('operation_timeout')), 30_000); })]);
|
||||
clearTimeout(timer);
|
||||
result.launchReturned = true;
|
||||
result.stage = 'ownership';
|
||||
if (observer.children.length !== 1) throw new Error('source_process_ownership_unconfirmed');
|
||||
result.stage = 'startup_pages';
|
||||
const urls = context.pages().map(page => page.url());
|
||||
result.startupPages = { ...browserStartupFacts(urls, 'http://127.0.0.1:1'), allowed: urls.every(url => url === 'about:blank') };
|
||||
if (!result.startupPages.allowed) throw new Error('onboarding_or_external_page');
|
||||
result.stage = 'protocol_probe';
|
||||
if (!context.pages()[0]) throw new Error('source_protocol_page_unavailable');
|
||||
result.protocolResponded = await Promise.race([context.pages()[0].evaluate(() => 1).then(value => value === 1),
|
||||
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('operation_timeout')), 5000); })]);
|
||||
clearTimeout(timer);
|
||||
const after = context.pages().map(page => page.url());
|
||||
result.postProbePages = { ...browserStartupFacts(after, 'http://127.0.0.1:1'), allowed: after.every(url => url === 'about:blank') };
|
||||
if (!result.postProbePages.allowed) throw new Error('onboarding_or_external_page');
|
||||
result.ready = result.protocolResponded;
|
||||
result.stage = 'ready';
|
||||
} catch (error) {
|
||||
const reasons = browserStderrFacts(observer.children).flatMap(facts => Object.entries(facts.reasonCounts ?? {})
|
||||
.filter(([, count]) => count > 0).map(([reason]) => reason));
|
||||
result.error = browserPreflightError(error, reasons);
|
||||
result.timedOut = browserOperationTimedOut(error);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
const deadline = performance.now() + 5_000;
|
||||
observer.stop();
|
||||
result.rootCount = observer.children.length;
|
||||
result.launchAttempts = observer.attempts;
|
||||
result.rootsBeforeCleanup = browserRootFacts(observer.children);
|
||||
result.stderrBeforeCleanup = browserStderrFacts(observer.children);
|
||||
if (context) void context.close().catch(() => {});
|
||||
const groups = [];
|
||||
for (const child of observer.children) {
|
||||
const facts = { pid: child.pid, signalSent: false, absenceConfirmed: false, childCloseObserved: false };
|
||||
try { await stopOwnedBrowserGroup(child, deadline, facts); }
|
||||
catch { facts.failed = true; }
|
||||
groups.push(facts);
|
||||
}
|
||||
result.cleanup.groups = groups;
|
||||
result.cleanup.childClosed = observer.children.length === 1 && observer.children.every(child => child.closeObserved);
|
||||
result.cleanup.groupAbsent = groups.length === 1 && groups.every(group => group.absenceConfirmed);
|
||||
result.cleanup.launchSettled = launchSettled;
|
||||
result.cleanup.confirmed = result.cleanup.childClosed && result.cleanup.groupAbsent && launchSettled;
|
||||
result.rootsAfterCleanup = browserRootFacts(observer.children);
|
||||
result.stderrAfterCleanup = browserStderrFacts(observer.children);
|
||||
if (launchSettled) { observer.restore(); cp.spawn = original; }
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function readComparisonRequest() {
|
||||
const buffer = Buffer.alloc(16 * 1024 + 1);
|
||||
let length = 0;
|
||||
while (length < buffer.length) {
|
||||
const read = readSync(0, buffer, length, buffer.length - length, null);
|
||||
if (!read) break;
|
||||
length += read;
|
||||
}
|
||||
if (length > 16 * 1024) throw new Error('invalid_driver_request');
|
||||
const request = JSON.parse(buffer.subarray(0, length).toString());
|
||||
if (!request || !['control', 'source'].includes(request.purpose)
|
||||
|| Object.keys(request).some(key => !(request.purpose === 'control' ? ['purpose'] : ['purpose', 'assetRoot', 'executableName', 'executableSha256']).includes(key))) throw new Error('invalid_driver_request');
|
||||
return request;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
validateQualificationHost(process.env);
|
||||
const request = readComparisonRequest();
|
||||
const account = readFreshAccountConfiguration(process.argv[2], 'comparison-driver');
|
||||
const config = account.launchComparison;
|
||||
const runtimeVersion = process.versions.bun ?? process.versions.node;
|
||||
if ((config.runtime === 'bun' ? process.versions.bun !== '1.4.0' : Boolean(process.versions.bun) || runtimeVersion !== '24.18.0')
|
||||
|| process.arch !== 'arm64' || require('playwright/package.json').version !== '1.62.1') throw new Error('invalid_driver_runtime');
|
||||
if (await sha256(process.execPath) !== config.executableSha256 || await sha256(import.meta.filename) !== config.driverSha256
|
||||
|| await sha256(path.join(import.meta.dirname, 'qualify-dia-macos.ts')) !== config.helpersSha256) throw new Error('driver_inputs_changed');
|
||||
const identity = spawnSync('/usr/bin/dscl', ['.', '-read', '/Users/' + account.account, 'UniqueID', 'PrimaryGroupID', 'NFSHomeDirectory', 'GeneratedUID'],
|
||||
{ env: account.environment, encoding: 'utf8', timeout: 5000, maxBuffer: 64 * 1024 });
|
||||
if (identity.error || identity.status !== 0 || !ownsFreshAccount(parseDirectoryRecord(identity.stdout), account)) throw new Error('driver_identity_unconfirmed');
|
||||
let executable = account.destinationExecutable;
|
||||
let profile = path.join(account.temporary, 'probe/chromium');
|
||||
let temporary = account.temporary;
|
||||
const ownedPaths = { home: account.home, snapshot: account.snapshot, temporary: account.temporary, work: account.work };
|
||||
if (request.purpose === 'source') {
|
||||
const root = request.assetRoot;
|
||||
if (typeof root !== 'string' || realpathSync(root) !== root || path.dirname(root) !== account.temporary || !path.basename(root).startsWith('dia-')
|
||||
|| lstatSync(root).uid !== account.uid || typeof request.executableName !== 'string' || !request.executableName
|
||||
|| path.basename(request.executableName) !== request.executableName || ['.', '..'].includes(request.executableName)) throw new Error('invalid_source_request');
|
||||
executable = realpathSync(path.join(root, 'Dia.app/Contents/MacOS', request.executableName));
|
||||
if (!executable.startsWith(path.join(root, 'Dia.app/Contents/MacOS') + path.sep)
|
||||
|| !/^[a-f0-9]{64}$/.test(request.executableSha256) || await sha256(executable) !== request.executableSha256) throw new Error('source_identity_unconfirmed');
|
||||
profile = path.join(account.home, 'Library/Application Support/Dia/User Data');
|
||||
if (realpathSync(profile) !== profile || lstatSync(profile).uid !== account.uid) throw new Error('source_profile_unowned');
|
||||
if (JSON.stringify(readdirSync(profile)) !== JSON.stringify(['.gstack-dia-owner'])) throw new Error('source_profile_not_fresh');
|
||||
const descriptor = openSync(path.join(profile, '.gstack-dia-owner'), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||
const marker = Buffer.alloc(65);
|
||||
let bytes;
|
||||
try {
|
||||
const entry = fstatSync(descriptor);
|
||||
if (!entry.isFile() || entry.uid !== account.uid || entry.nlink !== 1 || entry.size !== 64 || (entry.mode & 0o077) !== 0) throw new Error('source_profile_unowned');
|
||||
bytes = readSync(descriptor, marker, 0, marker.length, 0);
|
||||
} finally { closeSync(descriptor); }
|
||||
if (bytes !== 64) throw new Error('source_profile_unowned');
|
||||
const info = lstatSync(profile, { bigint: true });
|
||||
assertOwnedDiaProfile({ home: account.home, profile, uid: account.uid, dev: info.dev, ino: info.ino, nonce: marker.subarray(0, bytes).toString() });
|
||||
temporary = path.join(root, 't');
|
||||
ownedPaths.assets = root;
|
||||
} else {
|
||||
if (await sha256(executable) !== account.destinationSha256) throw new Error('control_identity_unconfirmed');
|
||||
try { lstatSync(profile); throw new Error('control_profile_not_fresh'); }
|
||||
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
||||
}
|
||||
const env = { HOME: account.home, TMPDIR: temporary, PATH: '/usr/bin:/bin:/usr/sbin:/sbin', LANG: 'en_US.UTF-8' };
|
||||
const result = await runProtectedLaunch(executable, profile, env, request.purpose, ownedPaths);
|
||||
result.driver = { runtime: config.runtime, version: runtimeVersion, architecture: process.arch, os: process.platform, release: release(),
|
||||
executableSha256: config.executableSha256, driverSha256: config.driverSha256, helpersSha256: config.helpersSha256, playwright: '1.62.1' };
|
||||
return result;
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
main().then(result => { process.stdout.write(JSON.stringify(result) + '\n'); }, () => {
|
||||
process.stdout.write(JSON.stringify({ protocol: 1, ready: false, error: 'driver_admission_failed', cleanup: { confirmed: false } }) + '\n');
|
||||
process.exitCode = 2;
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,125 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$OutputRoot,
|
||||
[switch]$Child,
|
||||
[switch]$Initialized,
|
||||
[string]$ExpectedSid,
|
||||
[string]$BinDirectory,
|
||||
[string]$GitDirectory
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if (-not $IsWindows -or $env:GITHUB_ACTIONS -ne 'true' -or $env:CI -ne 'true') {
|
||||
throw 'Native cookie qualification requires a disposable GitHub Actions Windows runner.'
|
||||
}
|
||||
$repository = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||
|
||||
if ($Child) {
|
||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
if ($identity.User.Value -ne $ExpectedSid) { throw 'Unexpected qualification account identity.' }
|
||||
$registered = (Get-ItemProperty "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\$ExpectedSid").ProfileImagePath
|
||||
$registered = [Environment]::ExpandEnvironmentVariables($registered)
|
||||
$env:USERPROFILE = $registered
|
||||
$env:HOME = $registered
|
||||
$folders = [Microsoft.Win32.Registry]::Users.OpenSubKey("$ExpectedSid\Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders")
|
||||
if (-not $folders) { throw 'The new account known-folder registry is unavailable.' }
|
||||
try {
|
||||
$rawLocal = $folders.GetValue('Local AppData', $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
|
||||
$rawRoaming = $folders.GetValue('AppData', $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
|
||||
if (-not $rawLocal -or -not $rawRoaming) { throw 'The new account app-data folders are undefined.' }
|
||||
$env:LOCALAPPDATA = [Environment]::ExpandEnvironmentVariables($rawLocal)
|
||||
$env:APPDATA = [Environment]::ExpandEnvironmentVariables($rawRoaming)
|
||||
} finally { $folders.Dispose() }
|
||||
if (-not $Initialized) {
|
||||
& (Join-Path $PSHOME 'pwsh.exe') -NoLogo -NoProfile -NonInteractive -File $PSCommandPath -Child -Initialized -ExpectedSid $ExpectedSid -BinDirectory $BinDirectory -GitDirectory $GitDirectory -OutputRoot $OutputRoot
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
$profile = [Environment]::GetFolderPath('UserProfile')
|
||||
$local = [Environment]::GetFolderPath('LocalApplicationData', 'DoNotVerify')
|
||||
$roaming = [Environment]::GetFolderPath('ApplicationData', 'DoNotVerify')
|
||||
if ($profile -ne $registered -or -not $local.StartsWith($profile + '\', [StringComparison]::OrdinalIgnoreCase)) {
|
||||
Write-Output (ConvertTo-Json -Compress @{ profileMatchesRegistered = ($profile -eq $registered); localInsideProfile = $local.StartsWith($profile + '\', [StringComparison]::OrdinalIgnoreCase); localEmpty = [string]::IsNullOrEmpty($local); localMatchesInherited = ($local -eq $env:LOCALAPPDATA) })
|
||||
throw 'Qualification must use the new account real Windows profile.'
|
||||
}
|
||||
$keep = @('SystemRoot', 'WINDIR', 'ProgramFiles', 'ProgramFiles(x86)', 'ProgramData', 'PATHEXT')
|
||||
Get-ChildItem Env: | Where-Object { $_.Name -notin $keep } | ForEach-Object { Remove-Item "Env:$($_.Name)" }
|
||||
$env:USERPROFILE = $profile
|
||||
$env:HOME = $profile
|
||||
$env:LOCALAPPDATA = $local
|
||||
$env:APPDATA = $roaming
|
||||
$env:TEMP = Join-Path $local 'Temp'
|
||||
$env:TMP = $env:TEMP
|
||||
$env:PATH = "$BinDirectory;$GitDirectory\cmd;$GitDirectory\bin;$GitDirectory\usr\bin;$env:SystemRoot\System32;$env:SystemRoot"
|
||||
$env:CI = 'true'
|
||||
$env:GITHUB_ACTIONS = 'true'
|
||||
New-Item -ItemType Directory -Force -Path $env:TEMP | Out-Null
|
||||
Set-Location $repository
|
||||
& (Join-Path $BinDirectory 'bun.exe') install --frozen-lockfile
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
& (Join-Path $GitDirectory 'bin\bash.exe') browse/scripts/build-node-server.sh
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
& (Join-Path $BinDirectory 'bun.exe') --no-env-file --no-install --no-macros --config=NUL browse/test/cookie-import-native-qualification.ts $OutputRoot
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
$work = Join-Path $OutputRoot ('cookie-native-host-' + [Guid]::NewGuid().ToString('N'))
|
||||
$bin = Join-Path $work 'bin'
|
||||
$evidence = Join-Path $work 'evidence'
|
||||
$snapshot = Join-Path $work 'repository'
|
||||
New-Item -ItemType Directory -Path $work | Out-Null
|
||||
$name = 'gstack' + [Guid]::NewGuid().ToString('N').Substring(0, 10)
|
||||
$password = ConvertTo-SecureString ([Convert]::ToBase64String([Security.Cryptography.RandomNumberGenerator]::GetBytes(32)) + '!aA1') -AsPlainText -Force
|
||||
$account = $null
|
||||
$process = $null
|
||||
$exitCode = 1
|
||||
try {
|
||||
$account = New-LocalUser -Name $name -Password $password -AccountExpires (Get-Date).AddHours(1) -Description 'Disposable gstack cookie qualification'
|
||||
Add-LocalGroupMember -SID 'S-1-5-32-545' -Member $account
|
||||
$principal = "$env:COMPUTERNAME\$name"
|
||||
& icacls.exe $work /grant "${principal}:(OI)(CI)M" /Q | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Could not grant fixture output access.' }
|
||||
New-Item -ItemType Directory -Path $bin, $evidence, $snapshot | Out-Null
|
||||
$archive = Join-Path $work 'source.tar'
|
||||
& git -C $repository archive --format=tar -o $archive HEAD
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Could not snapshot the candidate source.' }
|
||||
& (Join-Path $env:SystemRoot 'System32\tar.exe') -xf $archive -C $snapshot
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Could not materialize the isolated source snapshot.' }
|
||||
Copy-Item (Get-Command bun).Source (Join-Path $bin 'bun.exe')
|
||||
Copy-Item (Get-Command node).Source (Join-Path $bin 'node.exe')
|
||||
$gitDirectory = Split-Path (Split-Path (Get-Command git).Source)
|
||||
if (-not (Test-Path (Join-Path $gitDirectory 'bin\bash.exe'))) { throw 'Git Bash is required for the isolated Node build.' }
|
||||
$childScript = Join-Path $snapshot '.github\scripts\run-cookie-native-qualification.ps1'
|
||||
$credential = [Management.Automation.PSCredential]::new($principal, $password)
|
||||
$arguments = @('-NoLogo', '-NoProfile', '-NonInteractive', '-File', ('"' + $childScript + '"'), '-Child', '-ExpectedSid', $account.SID.Value,
|
||||
'-BinDirectory', ('"' + $bin + '"'), '-GitDirectory', ('"' + $gitDirectory + '"'), '-OutputRoot', ('"' + $evidence + '"'))
|
||||
$process = Start-Process -FilePath (Join-Path $PSHOME 'pwsh.exe') -ArgumentList $arguments -Credential $credential -LoadUserProfile -WorkingDirectory $snapshot -PassThru -WindowStyle Hidden -RedirectStandardOutput (Join-Path $work 'stdout.log') -RedirectStandardError (Join-Path $work 'stderr.log')
|
||||
$null = $process.Handle
|
||||
if (-not $process.WaitForExit(360000)) {
|
||||
$process.Kill($true)
|
||||
throw 'Native qualification exceeded its launcher deadline.'
|
||||
}
|
||||
if ($null -eq $process.ExitCode) { throw 'Native qualification did not return an exit status.' }
|
||||
$exitCode = $process.ExitCode
|
||||
foreach ($file in Get-ChildItem $evidence -Filter qualification.json -Recurse) {
|
||||
$receipt = Get-Content $file.FullName -Raw | ConvertFrom-Json
|
||||
if ($receipt.status -eq 'passed') {
|
||||
$expected = (Get-FileHash (Join-Path $repository 'browse\dist\server-node.mjs') -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if ($receipt.qualifiedBuild.sourceHashes.'browse/dist/server-node.mjs' -ne $expected) {
|
||||
throw 'The qualified Node bundle differs from the candidate workspace build.'
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
if ($process -and -not $process.HasExited) { $process.Kill($true) }
|
||||
} finally {
|
||||
try {
|
||||
if (Test-Path (Join-Path $work 'stdout.log')) { Get-Content (Join-Path $work 'stdout.log') }
|
||||
if (Test-Path (Join-Path $work 'stderr.log')) { Get-Content (Join-Path $work 'stderr.log') }
|
||||
if (Test-Path $evidence) { Get-ChildItem $evidence -Directory -Filter 'cookie-native-qualification-*' | Copy-Item -Destination $OutputRoot -Recurse }
|
||||
} finally {
|
||||
try { if ($account) { Remove-LocalUser -SID $account.SID } }
|
||||
finally { $password.Dispose() }
|
||||
}
|
||||
}
|
||||
}
|
||||
exit $exitCode
|
||||
@@ -0,0 +1,863 @@
|
||||
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { accessSync, chmodSync, constants, copyFileSync, createReadStream, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { homedir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { assertDiaSocketPath, browserPreflightError, browserStartupCategory, captureUserKeychains, fixtureKeychainRestoreCommands, FRESH_WORK_PREFIX, type FreshAccount,
|
||||
nativeDiaLaunchOptions, observeBrowserLaunches, observeFixtureKeychain, ownsFreshAccount, parseDirectoryRecord, stopOwnedBrowserGroup,
|
||||
inspectMachOArchitectures, playwrightModuleLoadFacts, prepareKeychainHome, readFreshAccountConfiguration, runDiaLaunchComparison, runGuiReadiness,
|
||||
validateQualificationHost, writePrivateReceipt } from './qualify-dia-macos';
|
||||
export { FRESH_WORK_PREFIX, ownsFreshAccount, parseDirectoryRecord } from './qualify-dia-macos';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const repository = path.resolve(import.meta.dir, '../..');
|
||||
|
||||
interface UserDomainObservation {
|
||||
uid: number;
|
||||
state: 'present' | 'absent' | 'unavailable';
|
||||
hasGuiDomain: boolean;
|
||||
exitCode: number | null;
|
||||
stdoutBytes: number;
|
||||
stderrBytes: number;
|
||||
structure?: {
|
||||
complete: boolean;
|
||||
type: 'user' | 'other' | 'unavailable';
|
||||
handleMatchesUid: boolean | null;
|
||||
creator: 'launchctl' | 'other' | 'unavailable';
|
||||
creatorIsProbe: boolean | null;
|
||||
counts: Record<string, number | null>;
|
||||
sectionNonemptyLines: Record<string, number | null>;
|
||||
};
|
||||
}
|
||||
|
||||
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<string, string>();
|
||||
const sections: Record<string, number | null> = 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<string, string>, 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<string>();
|
||||
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<string>();
|
||||
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<typeof classifyParentDomain>;
|
||||
|
||||
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<string, string>, 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<string, string>, 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<string, unknown>): 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<number> {
|
||||
const ids = new Set<number>();
|
||||
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<string, string>, account: Pick<FreshAccount, 'guid' | 'uid' | 'gid' | 'home'>,
|
||||
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<FreshAccount, 'label' | 'bun' | 'account'>): 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<T>(promise: Promise<T>, timeout: number): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
try {
|
||||
return await Promise.race([promise, new Promise<never>((_, 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<string, any> = { 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<typeof observeBrowserLaunches> | undefined;
|
||||
let comparisonControl: Record<string, any> | undefined;
|
||||
let launchAttempted = false;
|
||||
let keychainCreated = false;
|
||||
let keychainChanged = false;
|
||||
let snapshot: ReturnType<typeof captureUserKeychains> | undefined;
|
||||
const probe = path.join(account.temporary, 'probe');
|
||||
const keychain = path.join(probe, 'probe.keychain-db');
|
||||
try {
|
||||
if (!/^[a-z][a-z0-9]{8,24}$/.test(account.account) || process.getuid?.() !== account.uid || process.geteuid?.() !== account.uid
|
||||
|| process.getgid?.() !== account.gid || realpathSync(homedir()) !== account.home || realpathSync(account.work) !== account.work) throw new Error('fresh_identity_mismatch');
|
||||
for (const directory of [account.home, account.temporary, account.snapshot, path.dirname(account.bun),
|
||||
...(account.guiReadiness ? [] : [path.join(account.snapshot, 'node_modules')])]) {
|
||||
if (!directory.startsWith(account.work + path.sep) || realpathSync(directory) !== directory || lstatSync(directory).uid !== account.uid) throw new Error('fresh_directory_ownership_mismatch');
|
||||
}
|
||||
const record = parseDirectoryRecord(run('/usr/bin/dscl', ['.', '-read', '/Users/' + account.account, 'UniqueID', 'PrimaryGroupID', 'NFSHomeDirectory', 'GeneratedUID']));
|
||||
if (!ownsFreshAccount(record, account)) throw new Error('fresh_registered_identity_mismatch');
|
||||
receipt.preflight.registeredIdentity = true;
|
||||
if (account.guiReadiness) {
|
||||
if (await digest(account.bun) !== account.bunSha256) throw new Error('staged_executable_changed');
|
||||
receipt.reason = 'gui_readiness_only';
|
||||
receipt.operations = { dependencyInstall: false, browserLaunch: false, keychainAccess: false, diaDownload: false };
|
||||
receipt.guiReadiness = await runGuiReadiness(account.guiReadiness.executable, account.guiReadiness.executableSha256,
|
||||
path.join(account.snapshot, '.github/scripts/dia-gui-readiness.c'), account.guiReadiness.sourceSha256, false, env);
|
||||
return receipt.guiReadiness.available && receipt.guiReadiness.observation.identity.effectiveUidMatches
|
||||
&& receipt.guiReadiness.observation.identity.homeMatchesRegistered ? 0 : 2;
|
||||
}
|
||||
if (!account.destinationExecutable || !account.destinationSha256) throw new Error('browser_qualification_authority_required');
|
||||
const foundationHome = run('/usr/bin/osascript', ['-l', 'JavaScript', '-e', 'ObjC.import("Foundation"); $.NSHomeDirectory().js']);
|
||||
if (realpathSync(foundationHome) !== account.home) throw new Error('foundation_home_mismatch');
|
||||
receipt.preflight.foundationHome = true;
|
||||
receipt.keychainHome = prepareKeychainHome(account.home, account.uid);
|
||||
receipt.dependencyDirectoryPresentBeforeInstall = true;
|
||||
for (const executable of [account.bun, account.destinationExecutable]) {
|
||||
accessSync(executable, constants.X_OK);
|
||||
if (!path.isAbsolute(executable) || realpathSync(executable) !== executable || !executable.startsWith(account.work + path.sep)) throw new Error('staged_executable_escape');
|
||||
}
|
||||
if (await digest(account.bun) !== account.bunSha256 || await digest(account.destinationExecutable) !== account.destinationSha256) throw new Error('staged_executable_changed');
|
||||
receipt.reason = 'fresh_dependency_install';
|
||||
run(account.bun, ['install', '--frozen-lockfile', '--ignore-scripts'], 180_000);
|
||||
if (Bun.version !== '1.4.0' || require(path.join(account.snapshot, 'node_modules/playwright/package.json')).version !== '1.62.1') throw new Error('pinned_runtime_mismatch');
|
||||
mkdirSync(probe, { mode: 0o700 });
|
||||
receipt.reason = 'background_keychain_preflight';
|
||||
snapshot = captureUserKeychains(env, [account.home, account.temporary]);
|
||||
const password = randomBytes(24).toString('hex');
|
||||
const value = randomBytes(24).toString('hex');
|
||||
keychainChanged = true;
|
||||
run('/usr/bin/security', ['create-keychain', '-p', password, keychain]);
|
||||
keychainCreated = true;
|
||||
run('/usr/bin/security', ['set-keychain-settings', '-lut', '300', keychain]);
|
||||
run('/usr/bin/security', ['unlock-keychain', '-p', password, keychain]);
|
||||
run('/usr/bin/security', ['list-keychains', '-d', 'user', '-s', keychain]);
|
||||
run('/usr/bin/security', ['default-keychain', '-d', 'user', '-s', keychain]);
|
||||
run('/usr/bin/security', ['add-generic-password', '-s', 'Gstack Native Probe', '-a', 'fixture', '-w', value,
|
||||
'-T', '/usr/bin/security', keychain]);
|
||||
const observed = observeFixtureKeychain(env, [account.home, account.temporary], keychain, value);
|
||||
receipt.keychainObservations = { ...observed, preferencesFileExists: existsSync(path.join(account.home, 'Library/Preferences/com.apple.security.plist')) };
|
||||
if (!observed.searchPathMatches || !observed.defaultPathMatches || !observed.explicitReadMatches) throw new Error('native_keychain_probe_failed');
|
||||
receipt.preflight.keychain = true;
|
||||
if (account.launchComparison) {
|
||||
receipt.reason = 'comparison_chromium_control';
|
||||
launchAttempted = true;
|
||||
comparisonControl = await runDiaLaunchComparison(account, 'control');
|
||||
receipt.comparisonControl = comparisonControl;
|
||||
if (!comparisonControl.ready || !comparisonControl.cleanup?.confirmed) throw new Error('comparison_control_failed');
|
||||
receipt.preflight.headlessChromium = true;
|
||||
receipt.status = 'passed';
|
||||
receipt.reason = 'background_session_ready';
|
||||
} else {
|
||||
receipt.browserPreflight = { stage: 'runtime_import', launchReturned: false, ownedRootCount: 0,
|
||||
startupPageCount: null, startupPageCategories: [], pageSelected: false, contentSet: false, readbackMatched: false };
|
||||
receipt.reason = 'background_browser_runtime_import';
|
||||
const { chromium } = await import('playwright');
|
||||
const profile = path.join(probe, 'chromium');
|
||||
observer = observeBrowserLaunches(new Map([[account.destinationExecutable, profile]]));
|
||||
launchAttempted = true;
|
||||
receipt.browserPreflight.stage = 'launch';
|
||||
receipt.reason = 'background_browser_launch';
|
||||
context = await limit(chromium.launchPersistentContext(profile, nativeDiaLaunchOptions(account.destinationExecutable, env)), 40_000);
|
||||
receipt.browserPreflight.launchReturned = true;
|
||||
receipt.browserPreflight.stage = 'ownership';
|
||||
receipt.reason = 'background_browser_ownership';
|
||||
receipt.browserPreflight.ownedRootCount = observer.children.length;
|
||||
if (observer.children.length !== 1) throw new Error('background_browser_ownership_failed');
|
||||
receipt.browserPreflight.stage = 'startup_pages';
|
||||
receipt.reason = 'background_browser_startup_pages';
|
||||
const pages = context.pages();
|
||||
const startupUrls = pages.map((page: any) => page.url());
|
||||
receipt.browserPreflight.startupPageCount = pages.length;
|
||||
receipt.browserPreflight.startupPageCategories = startupUrls.map(browserStartupCategory);
|
||||
if (startupUrls.some((url: string) => url !== 'about:blank')) throw new Error('background_browser_startup_page_rejected');
|
||||
receipt.browserPreflight.stage = 'page_selection';
|
||||
receipt.reason = 'background_browser_page_selection';
|
||||
const page = pages[0] ?? await limit(context.newPage(), 5_000);
|
||||
receipt.browserPreflight.pageSelected = true;
|
||||
receipt.browserPreflight.stage = 'content_set';
|
||||
receipt.reason = 'background_browser_content_set';
|
||||
await limit(page.setContent('<div id="fixture">background browser ready</div>'), 5_000);
|
||||
receipt.browserPreflight.contentSet = true;
|
||||
receipt.browserPreflight.stage = 'readback';
|
||||
receipt.reason = 'background_browser_readback';
|
||||
receipt.browserPreflight.readbackMatched = await limit(page.locator('#fixture').innerText(), 5_000) === 'background browser ready';
|
||||
if (!receipt.browserPreflight.readbackMatched) throw new Error('background_browser_render_failed');
|
||||
receipt.browserPreflight.stage = 'completed';
|
||||
receipt.preflight.headlessChromium = true;
|
||||
receipt.status = 'passed';
|
||||
receipt.reason = 'background_session_ready';
|
||||
}
|
||||
} catch (error) {
|
||||
receipt.status = 'incomplete';
|
||||
if (error instanceof Error && ['fresh_identity_mismatch', 'fresh_directory_ownership_mismatch', 'fresh_registered_identity_mismatch',
|
||||
'foundation_home_mismatch', 'staged_executable_escape', 'staged_executable_changed', 'pinned_runtime_mismatch',
|
||||
'user_keychain_search_unavailable', 'user_default_keychain_unavailable', 'keychain_outside_owned_home_refused',
|
||||
'keychain_home_unsafe', 'fixture_keychain_not_owned', 'native_keychain_probe_failed', 'comparison_control_failed',
|
||||
'gui_readiness_inputs_changed', 'gui_readiness_budget_exhausted'].includes(error.message)) receipt.blocker = error.message;
|
||||
if (receipt.browserPreflight) {
|
||||
receipt.blocker = browserPreflightError(error);
|
||||
receipt.browserPreflight.error = receipt.blocker;
|
||||
if (receipt.browserPreflight.stage === 'runtime_import') receipt.browserPreflight.moduleLoad = playwrightModuleLoadFacts(account.snapshot, error);
|
||||
receipt.browserPreflight.ownedRootCount = observer?.children.length ?? 0;
|
||||
receipt.browserPreflight.launchAttempts = observer?.attempts ?? [];
|
||||
}
|
||||
receipt.initialFailure = { stage: receipt.reason, blocker: receipt.blocker ?? 'native_preflight_failed' };
|
||||
} finally {
|
||||
cleaning = true;
|
||||
if (receipt.browserPreflight) {
|
||||
receipt.browserPreflight.launchAttempts ??= observer?.attempts ?? [];
|
||||
receipt.browserPreflight.rootStatesBeforeCleanup = (observer?.children ?? []).map(child => ({
|
||||
pid: child.pid, exitCode: Number.isInteger(child.process.exitCode) ? child.process.exitCode : null,
|
||||
signal: child.process.signalCode == null ? null
|
||||
: ['SIGABRT', 'SIGTRAP', 'SIGSEGV', 'SIGBUS', 'SIGKILL', 'SIGTERM', 'SIGILL'].includes(child.process.signalCode) ? child.process.signalCode : 'other',
|
||||
}));
|
||||
}
|
||||
observer?.stop();
|
||||
if (context) await limit(context.close().catch(() => {}), 5_000).catch(() => {});
|
||||
let stopped = !launchAttempted || (account.launchComparison ? comparisonControl?.cleanup?.confirmed === true : observer?.children.length === 1);
|
||||
for (const child of observer?.children ?? []) {
|
||||
const until = performance.now() + 5_000;
|
||||
try {
|
||||
await stopOwnedBrowserGroup(child, until, {});
|
||||
} catch { stopped = false; }
|
||||
}
|
||||
receipt.cleanup.probeBrowsersStopped = stopped;
|
||||
if (stopped) {
|
||||
try {
|
||||
if (keychainChanged && snapshot) {
|
||||
let restored = true;
|
||||
for (const args of fixtureKeychainRestoreCommands(snapshot, keychain, keychainCreated)) {
|
||||
try { run('/usr/bin/security', args); } catch { restored = false; }
|
||||
}
|
||||
if (!restored || JSON.stringify(captureUserKeychains(env, [account.home, account.temporary])) !== JSON.stringify(snapshot)) throw new Error('probe_keychain_restore_failed');
|
||||
}
|
||||
receipt.cleanup.probeKeychainRestored = true;
|
||||
if (existsSync(probe) && realpathSync(probe) === probe) rmSync(probe, { recursive: true, force: true });
|
||||
} catch { receipt.cleanupFailure = 'probe_keychain_restore_failed'; }
|
||||
}
|
||||
if (!receipt.cleanup.probeBrowsersStopped || !receipt.cleanup.probeKeychainRestored) { receipt.status = 'incomplete'; receipt.reason = 'background_probe_cleanup_incomplete'; }
|
||||
writePrivateReceipt(preflightFile, receipt, true);
|
||||
}
|
||||
if (receipt.status !== 'passed') return 2;
|
||||
const result = spawnSync(account.bun, ['--no-env-file', '--no-install', '--no-macros', '--config=/dev/null',
|
||||
path.join(account.snapshot, '.github/scripts/qualify-dia-macos.ts'), '--fresh-account', account.configFile], {
|
||||
cwd: account.snapshot, env, stdio: 'ignore', timeout: 660_000, killSignal: 'SIGKILL',
|
||||
});
|
||||
return !result.error && result.status === 0 ? 0 : 2;
|
||||
}
|
||||
|
||||
export async function runFreshAccountQualification(comparisonRuntime?: 'bun' | 'node', guiReadinessOnly = false) {
|
||||
validateQualificationHost(process.env);
|
||||
if (comparisonRuntime !== undefined && !['bun', 'node'].includes(comparisonRuntime)) throw new Error('invalid_comparison_runtime');
|
||||
if (guiReadinessOnly && comparisonRuntime !== undefined) throw new Error('conflicting_diagnostic_modes');
|
||||
if (process.getuid?.() === 0 || Bun.version !== '1.4.0') throw new Error('run_as_unprivileged_pinned_ci_runner');
|
||||
const outputRoot = realpathSync(process.env.RUNNER_TEMP!);
|
||||
const output = path.join(outputRoot, 'dia-native-qualification.json');
|
||||
if (existsSync(output)) throw new Error('fresh_output_required');
|
||||
const work = realpathSync(mkdtempSync(FRESH_WORK_PREFIX));
|
||||
const home = path.join(work, 'home');
|
||||
const temporary = path.join(work, 'tmp');
|
||||
const snapshot = path.join(work, 'repo');
|
||||
const bin = path.join(work, 'bin');
|
||||
const browserDirectory = path.join(work, 'browser');
|
||||
const archive = path.join(work, 'source.tar');
|
||||
const suffix = randomBytes(6).toString('hex');
|
||||
const accountName = 'gsdia' + suffix;
|
||||
const label = 'ai.gstack.dia.' + suffix;
|
||||
const deadline = performance.now() + 16 * 60_000;
|
||||
const hostEnv = { HOME: homedir(), PATH: '/usr/bin:/bin:/usr/sbin:/sbin', LANG: 'en_US.UTF-8' };
|
||||
let cleanupDeadline = 0;
|
||||
const run = (command: string, args: string[], timeout = 10_000) => {
|
||||
const remaining = (cleanupDeadline || deadline) - performance.now();
|
||||
if (remaining <= 0) throw new Error('fresh_launcher_deadline');
|
||||
return safeCommand(command, args, Math.min(timeout, remaining), hostEnv);
|
||||
};
|
||||
const rootCommand = (command: string, args: string[], timeout = 10_000) => run('/usr/bin/sudo', ['-n', command, ...args], timeout);
|
||||
let account: FreshAccount | undefined;
|
||||
let userCreated = false;
|
||||
let groupCreated = false;
|
||||
let serviceAttempted = false;
|
||||
let domainBeforeCreation: ParentDomainObservation | undefined;
|
||||
let stage = 'fresh_launcher_preflight';
|
||||
const receipt: Record<string, any> = { 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<FreshAccount, 'destinationExecutable' | 'destinationSha256'> = {};
|
||||
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<string, string> = {};
|
||||
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;
|
||||
}
|
||||
}
|
||||
+80
-10
@@ -8,6 +8,11 @@ on:
|
||||
description: 'Run ALL gate tests in the sliced lane (bypass diff selection; also arms the hollow-shard guard)'
|
||||
type: boolean
|
||||
default: true
|
||||
validation_phase:
|
||||
description: 'Validation branch phase; run quality before behavior on unchanged inputs'
|
||||
type: choice
|
||||
options: [all, quality, cookie-quality, behavior, cookie-behavior]
|
||||
default: all
|
||||
|
||||
concurrency:
|
||||
group: evals-${{ github.event.pull_request.number || github.run_id }}
|
||||
@@ -133,10 +138,37 @@ jobs:
|
||||
bun-version: 1.4.0
|
||||
|
||||
- name: Emit run manifest
|
||||
if: github.event_name != 'workflow_dispatch' || inputs.validation_phase == 'all'
|
||||
env:
|
||||
EVALS_ALL: ${{ (github.event_name == 'workflow_dispatch' && inputs.evals_all) && '1' || '' }}
|
||||
run: EVALS_TIER=gate bun --no-install run scripts/test-paid-shards.ts --tier gate --emit-plan /tmp/paid-plan/manifest.json --slices 6
|
||||
|
||||
- name: Emit validation-phase manifest
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.validation_phase != 'all'
|
||||
env:
|
||||
VALIDATION_PHASE: ${{ inputs.validation_phase }}
|
||||
EVALS_ALL: ${{ inputs.evals_all && '1' || '' }}
|
||||
EVALS_TIER: gate
|
||||
run: |
|
||||
bun --no-install -e '
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { buildRunManifest, collectPaidTestFiles } from "./scripts/test-paid-shards.ts";
|
||||
const phase = process.env.VALIDATION_PHASE;
|
||||
if (!["quality", "cookie-quality", "behavior", "cookie-behavior"].includes(phase)) throw new Error("Invalid validation phase");
|
||||
const cookieBehavior = phase === "cookie-behavior";
|
||||
const discovered = phase === "cookie-quality" ? ["test/skill-llm-eval.test.ts"]
|
||||
: cookieBehavior ? ["test/skill-e2e-bws.test.ts", "test/skill-e2e-qa-workflow.test.ts", "test/skill-e2e-design.test.ts", "test/skill-e2e-diagram.test.ts", "test/skill-e2e-deploy.test.ts"]
|
||||
: collectPaidTestFiles().filter(file => file.startsWith("test/skill-llm-eval") === (phase === "quality"));
|
||||
const manifest = buildRunManifest({ tier: "gate", profile: "full", sliceCount: 6, evalsAll: !cookieBehavior && process.env.EVALS_ALL === "1", discovered,
|
||||
...(cookieBehavior ? { changedFiles: ["browse/src/cookie-picker-routes.ts", "browse/src/cookie-import-browser.ts", "browse/src/bun-polyfill.cjs"], env: { ...process.env, EVALS_ALL: "" } } : {}) });
|
||||
if (phase === "cookie-quality") manifest.selection = { e2e: [], judges: ["setup-browser-cookies/SKILL.md workflow"] };
|
||||
if (cookieBehavior) manifest.selection = { e2e: ["browse-basic", "browse-snapshot", "qa-quick", "qa-only-no-fix", "design-review-detector-shim-dom", "diagram-triplet", "canary-workflow", "benchmark-workflow"], judges: [] };
|
||||
manifest.selectionReason = phase + " validation subset; " + manifest.selectionReason;
|
||||
mkdirSync("/tmp/paid-plan", { recursive: true });
|
||||
writeFileSync("/tmp/paid-plan/manifest.json", JSON.stringify(manifest, null, 2) + "\n");
|
||||
console.log(phase + ": " + manifest.entries.filter(entry => entry.status === "planned").length + " planned shards");
|
||||
'
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: paid-plan
|
||||
@@ -152,9 +184,9 @@ jobs:
|
||||
# 40-way per row queued claude session STARTUP behind 39 siblings and ate
|
||||
# per-test budgets — the documented timeout-flake family). Tune with
|
||||
# parity data before raising.
|
||||
# The complete gate census needs at most 197 minutes per slice; keep
|
||||
# The complete gate census needs at most 201 minutes per slice; keep
|
||||
# 20 minutes for setup/upload without preempting configured retries.
|
||||
timeout-minutes: 220
|
||||
timeout-minutes: 221
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
@@ -334,7 +366,9 @@ jobs:
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: report-verdict
|
||||
path: /tmp/report.txt
|
||||
path: |
|
||||
/tmp/report.txt
|
||||
/tmp/paid-report/collector-outcomes.json
|
||||
if-no-files-found: ignore
|
||||
retention-days: 30
|
||||
|
||||
@@ -374,7 +408,8 @@ jobs:
|
||||
path: /tmp/verdict
|
||||
continue-on-error: true
|
||||
|
||||
# Sourced from the slice artifacts' eval-store JSONs. Keeps the
|
||||
# Verified counts come from the read-only report job, not repo code in
|
||||
# this write-token job. Keeps the
|
||||
# "## E2E Evals" marker so the upsert keeps updating the same comment.
|
||||
# Runs even when reconciliation failed — a red lane on the PR is the point.
|
||||
- name: Post PR comment
|
||||
@@ -384,8 +419,36 @@ jobs:
|
||||
run: |
|
||||
# shellcheck disable=SC2086,SC2059
|
||||
RESULTS=$(find /tmp/paid-report -name '*.json' ! -name 'manifest.json' ! -name 'slice-*.json' ! -name '_partial*' 2>/dev/null | sort)
|
||||
TOTAL=0; PASSED=0; FAILED=0; FLAKY=0; EXECUTED=0; REUSED=0; COST="0"
|
||||
TOTAL=0; PASSED=0; FAILED=0; MANUAL=0; FLAKY=0; EXECUTED=0; REUSED=0; COST="0"
|
||||
SUITE_LINES=""
|
||||
VERIFIED=/tmp/verdict/paid-report/collector-outcomes.json
|
||||
if ! jq -e '
|
||||
. as $summary |
|
||||
.version == 1 and (.files | type == "array") and (.totals | type == "object") and
|
||||
([.files[] | .total == (.passed + .failed + .manual_accepted) and
|
||||
(.total == (.executed + .reused)) and
|
||||
([.total,.passed,.failed,.manual_accepted,.executed,.reused,.attempts,.flaky] | all(. >= 0 and (floor == .))) ] | all) and
|
||||
(.totals | .total == (.passed + .failed + .manual_accepted) and .total == (.executed + .reused)) and
|
||||
(["total","passed","failed","manual_accepted","executed","reused","attempts","flaky"] |
|
||||
all(. as $key | ([$summary.files[] | .[$key]] | add // 0) == $summary.totals[$key]))
|
||||
' "$VERIFIED" >/dev/null 2>&1; then
|
||||
VERIFIED=""
|
||||
echo 'Verified collector summary unavailable; manual acceptance is unavailable/unverified.'
|
||||
fi
|
||||
if [ -n "$VERIFIED" ]; then
|
||||
while IFS=$'\t' read -r f T P F M FL EX RE _ATTEMPTS C TIER SHARD; do
|
||||
[ "$T" -eq 0 ] && continue
|
||||
TOTAL=$((TOTAL + T)); PASSED=$((PASSED + P)); FAILED=$((FAILED + F))
|
||||
MANUAL=$((MANUAL + M)); FLAKY=$((FLAKY + FL))
|
||||
EXECUTED=$((EXECUTED + EX)); REUSED=$((REUSED + RE))
|
||||
COST=$(echo "$COST + $C" | bc)
|
||||
STATUS_ICON="✅"
|
||||
[ "$M" -gt 0 ] && STATUS_ICON="⚠ manual/unscored"
|
||||
[ "$F" -gt 0 ] && STATUS_ICON="❌"
|
||||
[ "$F" -eq 0 ] && [ "$M" -eq 0 ] && [ "$FL" -gt 0 ] && STATUS_ICON="✅⚠"
|
||||
SUITE_LINES="${SUITE_LINES}| ${TIER}/${SHARD} | ${P}/${T} | ${M} | ${EX} | ${RE} | ${STATUS_ICON} | \$${C} |\n"
|
||||
done < <(jq -r '.files[] | [.file,.total,.passed,.failed,.manual_accepted,.flaky,.executed,.reused,.attempts,.cost,.tier,.shard] | @tsv' "$VERIFIED")
|
||||
else
|
||||
for f in $RESULTS; do
|
||||
if ! jq -e '.total_tests' "$f" >/dev/null 2>&1; then
|
||||
echo "Skipping malformed JSON: $f"
|
||||
@@ -418,22 +481,25 @@ jobs:
|
||||
STATUS_ICON="✅"
|
||||
[ "$F" -gt 0 ] && STATUS_ICON="❌"
|
||||
[ "$F" -eq 0 ] && [ "$FL" -gt 0 ] && STATUS_ICON="✅⚠"
|
||||
SUITE_LINES="${SUITE_LINES}| ${TIER}/${SHARD} | ${P}/${T} | ${EX} | ${RE} | ${STATUS_ICON} | \$${C} |\n"
|
||||
SUITE_LINES="${SUITE_LINES}| ${TIER}/${SHARD} | ${P}/${T} | unverified | ${EX} | ${RE} | ${STATUS_ICON} | \$${C} |\n"
|
||||
done
|
||||
fi
|
||||
|
||||
COVERAGE=$(jq -r '"Profile: \(.profile // "full") / \(.prCoverage.mode // "broad"); selected behaviors: \(.selection.e2e | if . == null then "all" else length end), judges: \(.selection.judges | if . == null then "all" else length end). Deferred to scheduled/release coverage: \(.prCoverage.deferred // [] | length) behaviors and \(.prCoverage.deferredPromptFiles // [] | length) changed prompt files. Deferred checks did not run and receive no PR-pass credit."' /tmp/paid-report/manifest.json) || COVERAGE='Coverage manifest unavailable; no coverage claim.'
|
||||
|
||||
STATUS="✅ PASS"
|
||||
if [ "${RECONCILE_EXIT:-1}" != "0" ] || [ "$FAILED" -gt 0 ]; then STATUS="❌ FAIL"; fi
|
||||
if [ "$STATUS" = '✅ PASS' ] && [ "$MANUAL" -gt 0 ]; then STATUS='⚠ MANUAL ACCEPTED (unscored)'; fi
|
||||
if [ -z "$VERIFIED" ]; then STATUS='❌ FAIL (manual acceptance unavailable/unverified)'; fi
|
||||
|
||||
BODY="## E2E Evals: ${STATUS}
|
||||
|
||||
**${PASSED}/${TOTAL}** recorded final results passed | **${EXECUTED} executed, ${REUSED} reused** | **\$${COST}** total cost | reconcile exit: ${RECONCILE_EXIT:-missing}$([ "$FLAKY" -gt 0 ] && printf ' | ⚠ %s cases with multiple attempts' "$FLAKY")
|
||||
**${PASSED} automated passed / ${TOTAL} final results** | **${FAILED} failed, ${MANUAL} manual accepted (unscored; no score-cache credit)** | **${EXECUTED} executed, ${REUSED} reused** | **\$${COST}** total cost | reconcile exit: ${RECONCILE_EXIT:-missing}$([ "$FLAKY" -gt 0 ] && printf ' | ⚠ %s cases with multiple attempts' "$FLAKY")
|
||||
|
||||
${COVERAGE}
|
||||
|
||||
| Shard | Result | Executed | Reused | Status | Cost |
|
||||
|-------|--------|----------|--------|--------|------|
|
||||
| Shard | Automated result | Manual/unscored | Executed | Reused | Status | Cost |
|
||||
|-------|------------------|-----------------|----------|--------|--------|------|
|
||||
$(echo -e "$SUITE_LINES")
|
||||
|
||||
<details><summary>Fail-closed reconciliation</summary>
|
||||
@@ -450,7 +516,11 @@ jobs:
|
||||
FAILURES=""
|
||||
for f in $RESULTS; do
|
||||
if ! jq -e '.failed' "$f" >/dev/null 2>&1; then continue; fi
|
||||
FAILS=$(jq -r '[.tests | group_by(.name)[] | last | select(.passed == false)][] | "- ❌ \(.name): \(.exit_reason // "unknown")"' "$f" 2>/dev/null || echo "- ⚠️ parse error")
|
||||
if [ -n "$VERIFIED" ]; then
|
||||
FAILS=$(jq -r '[.tests | group_by(.name)[] | last | select(.passed == false and (has("manual_review") | not))][] | "- ❌ \(.name): \(.exit_reason // "unknown")"' "$f" 2>/dev/null || echo "- ⚠️ parse error")
|
||||
else
|
||||
FAILS=$(jq -r '[.tests | group_by(.name)[] | last | select(.passed == false)][] | "- ❌ \(.name): \(.exit_reason // "unknown")"' "$f" 2>/dev/null || echo "- ⚠️ parse error")
|
||||
fi
|
||||
FAILURES="${FAILURES}${FAILS}\n"
|
||||
done
|
||||
BODY="${BODY}
|
||||
|
||||
@@ -182,6 +182,21 @@ jobs:
|
||||
- name: Install Playwright Chromium
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Configure the bundled Chromium sandbox helper
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chrome=$(bun -e 'import { chromium } from "playwright"; import { realpathSync } from "node:fs"; console.log(realpathSync(chromium.executablePath()))')
|
||||
case "$chrome" in
|
||||
"$HOME"/.cache/ms-playwright/chromium-*/chrome-linux*/chrome) ;;
|
||||
*) echo "Unexpected Chromium installation path" >&2; exit 1 ;;
|
||||
esac
|
||||
helper="${chrome%/*}/chrome_sandbox"
|
||||
installed="${chrome%/*}/chrome-sandbox"
|
||||
test -f "$helper" && test ! -L "$helper"
|
||||
sudo install -T -o root -g root -m 4755 "$helper" "$installed"
|
||||
test "$(stat -c '%u:%a' "$installed")" = '0:4755'
|
||||
cmp -s "$helper" "$installed"
|
||||
|
||||
# Headed-browser tests (handoff, extension sidepanel DOM) need a real
|
||||
# DISPLAY — first Linux run failed with Playwright's "launched a headed
|
||||
# browser without an XServer" banner. xvfb-run below provides it;
|
||||
|
||||
@@ -26,6 +26,23 @@ on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dia_native_only:
|
||||
description: Run disposable ARM64 macOS Dia qualification instead of Windows
|
||||
type: boolean
|
||||
default: false
|
||||
native_diagnostics_only:
|
||||
description: Run Windows launch diagnostics and credential regressions without qualification
|
||||
type: boolean
|
||||
default: false
|
||||
dia_launch_comparison:
|
||||
description: Compare protected Dia launch under Bun and Node in separate fresh Mac jobs
|
||||
type: boolean
|
||||
default: false
|
||||
dia_gui_readiness:
|
||||
description: Inspect disposable Mac GUI-session readiness without launching browsers
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: windows-free-${{ github.event.pull_request.number || github.run_id }}
|
||||
@@ -37,6 +54,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
windows-free-tests:
|
||||
if: ${{ !inputs.dia_native_only && !inputs.dia_launch_comparison && !inputs.dia_gui_readiness }}
|
||||
# Ubicloud Windows runner (same provider as the Linux evals workflow).
|
||||
# To revert: swap to `windows-latest` (GitHub's free 4-core Windows runner).
|
||||
runs-on: windows-latest
|
||||
@@ -49,6 +67,10 @@ jobs:
|
||||
with:
|
||||
bun-version: 1.4.0
|
||||
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38
|
||||
with:
|
||||
node-version: 24.18.0
|
||||
|
||||
# bun install was 35s of a 55s job, all network. Cache keyed on the
|
||||
# lockfile; bun's install cache lives under ~/.bun/install/cache on
|
||||
# every platform.
|
||||
@@ -82,6 +104,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
- name: Generate host SKILL.md outputs (.agents, .factory)
|
||||
if: ${{ !inputs.native_diagnostics_only }}
|
||||
# The golden-file regression tests in test/gen-skill-docs.test.ts read
|
||||
# .agents/skills/gstack-ship/SKILL.md and .factory/skills/gstack-ship/
|
||||
# SKILL.md. Both are gitignored — generated on demand by gen:skill-docs.
|
||||
@@ -91,6 +114,9 @@ jobs:
|
||||
run: bun run gen:skill-docs --host all
|
||||
shell: bash
|
||||
|
||||
- name: Install Chromium for the Node worker smoke
|
||||
run: bunx playwright install chromium
|
||||
|
||||
# The Windows job verifies the new portability work this PR delivers,
|
||||
# not the entire free suite. After v1.20.0.0 ships, full-suite Windows
|
||||
# parity is a P4 follow-up TODO that depends on porting many tests off
|
||||
@@ -110,6 +136,7 @@ jobs:
|
||||
# (test/test-free-shards.test.ts)
|
||||
|
||||
- name: Run curated Windows-safe suite
|
||||
if: ${{ !inputs.native_diagnostics_only }}
|
||||
# Replaces the previous hand-listed 13-file subset, which drifted from
|
||||
# the curation registry it was supposed to sample. The runner's
|
||||
# --windows-only curation (scripts/test-free-shards.ts) is the single
|
||||
@@ -125,15 +152,115 @@ jobs:
|
||||
run: bun run test:windows
|
||||
shell: bash
|
||||
|
||||
- name: Run focused native launch and credential diagnostics
|
||||
if: inputs.native_diagnostics_only
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
status=0
|
||||
bun test browse/test/cookie-import-native-job.test.ts --test-name-pattern 'native Windows launch diagnostics|a locked real Edge profile|real Edge synthetic profile' 2>&1 | tee "$RUNNER_TEMP/gstack-free-test-native-diagnostics.log" || status=1
|
||||
bun test browse/test/cookie-credential-deadline.test.ts browse/test/cookie-import-node.test.ts browse/test/bun-polyfill.test.ts 2>&1 | tee "$RUNNER_TEMP/gstack-free-test-credential-diagnostics.log" || status=1
|
||||
exit "$status"
|
||||
|
||||
# Same diagnosability contract as free-tests.yml: a red lane must
|
||||
# carry the WHY (the runner's quiet console names files, not causes).
|
||||
# (#2561 was written against the old hand-listed subset; its two new
|
||||
# test files are pure-TS and flow into the --windows-only curation
|
||||
# automatically, so no per-file entry is needed here.)
|
||||
- name: Upload shard logs on failure
|
||||
if: failure()
|
||||
- name: Upload full shard logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: windows-free-test-shard-logs
|
||||
path: ${{ runner.temp }}/gstack-free-test-*.log
|
||||
if-no-files-found: ignore
|
||||
|
||||
cookie-native-qualification:
|
||||
if: github.event_name == 'workflow_dispatch' && !inputs.dia_native_only && !inputs.native_diagnostics_only && !inputs.dia_launch_comparison && !inputs.dia_gui_readiness
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
|
||||
with:
|
||||
bun-version: 1.4.0
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38
|
||||
with:
|
||||
node-version: 24.18.0
|
||||
- name: Install pinned dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Build the qualified Node server inputs
|
||||
run: bash browse/scripts/build-node-server.sh
|
||||
shell: bash
|
||||
- name: Qualify owned native cookie extraction
|
||||
run: ./.github/scripts/run-cookie-native-qualification.ps1 -OutputRoot "$env:RUNNER_TEMP"
|
||||
- name: Preserve qualification evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
|
||||
with:
|
||||
name: cookie-native-qualification
|
||||
path: ${{ runner.temp }}/cookie-native-qualification-*/
|
||||
if-no-files-found: error
|
||||
|
||||
dia-native-qualification:
|
||||
if: github.event_name == 'workflow_dispatch' && (inputs.dia_native_only || inputs.dia_launch_comparison || inputs.dia_gui_readiness)
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
runtime: ${{ fromJSON(inputs.dia_launch_comparison && !inputs.dia_gui_readiness && '["bun","node"]' || '["bun"]') }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
|
||||
with:
|
||||
bun-version: 1.4.0
|
||||
- name: Validate GUI readiness selection
|
||||
if: inputs.dia_gui_readiness
|
||||
env:
|
||||
OTHER_DIA_MODES: ${{ inputs.dia_native_only || inputs.dia_launch_comparison || inputs.native_diagnostics_only }}
|
||||
run: |
|
||||
bun --no-env-file --no-install --no-macros --config=/dev/null -e '
|
||||
if (process.env.OTHER_DIA_MODES !== "false") {
|
||||
console.error("dia_gui_readiness must be selected alone");
|
||||
process.exit(1);
|
||||
}
|
||||
'
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38
|
||||
if: inputs.dia_launch_comparison && !inputs.dia_gui_readiness
|
||||
with:
|
||||
node-version: 24.18.0
|
||||
architecture: arm64
|
||||
- name: Install pinned dependencies
|
||||
if: ${{ !inputs.dia_gui_readiness }}
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Install the synthetic destination browser
|
||||
if: ${{ !inputs.dia_gui_readiness }}
|
||||
run: bunx --no-install playwright install chromium
|
||||
- name: Inspect GUI readiness without browser or Keychain access
|
||||
if: inputs.dia_gui_readiness
|
||||
env:
|
||||
GSTACK_DIA_NATIVE_QUALIFY: '1'
|
||||
run: bun --no-env-file --no-install --no-macros --config=/dev/null .github/scripts/run-dia-native-qualification.ts --gui-readiness-only
|
||||
- name: Qualify native Dia discovery, decryption, and import
|
||||
if: ${{ !inputs.dia_launch_comparison && !inputs.dia_gui_readiness }}
|
||||
env:
|
||||
GSTACK_DIA_NATIVE_QUALIFY: '1'
|
||||
run: bun --no-env-file --no-install --no-macros --config=/dev/null .github/scripts/run-dia-native-qualification.ts
|
||||
- name: Compare protected native Dia launch without qualification credit
|
||||
if: inputs.dia_launch_comparison && !inputs.dia_gui_readiness
|
||||
env:
|
||||
GSTACK_DIA_NATIVE_QUALIFY: '1'
|
||||
COMPARISON_RUNTIME: ${{ matrix.runtime }}
|
||||
run: bun --no-env-file --no-install --no-macros --config=/dev/null .github/scripts/run-dia-native-qualification.ts --launch-comparison "$COMPARISON_RUNTIME"
|
||||
- name: Preserve only the sanitized qualification receipt
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
|
||||
with:
|
||||
name: ${{ inputs.dia_gui_readiness && 'dia-gui-readiness' || inputs.dia_launch_comparison && format('dia-launch-comparison-{0}', matrix.runtime) || 'dia-native-qualification' }}
|
||||
path: ${{ runner.temp }}/dia-native-qualification.json
|
||||
if-no-files-found: error
|
||||
|
||||
Reference in New Issue
Block a user